Skip to content

Commit 107cea7

Browse files
committed
feat: continuing textual development
1 parent c03af5a commit 107cea7

File tree

2 files changed

+246
-26
lines changed

2 files changed

+246
-26
lines changed

client/app.css renamed to client/app.tcss

+38
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@ Screen {
1616
color: white;
1717
}
1818

19+
.address_book {
20+
height: 1fr;
21+
}
22+
1923
.server-list {
2024
height: 1fr;
2125
}
2226

27+
.tabs {
28+
height: 1fr;
29+
}
30+
2331
.debug-log {
2432
height: 7;
2533
border: round gray;
@@ -48,3 +56,33 @@ Screen {
4856
background: transparent; /* No background unless explicitly needed */
4957
color: white; /* Text color */
5058
}
59+
60+
ServerDetailScreen {
61+
align: center middle;
62+
}
63+
64+
#server_details_dialog {
65+
grid-size: 3;
66+
grid-gutter: 1 2;
67+
grid-rows: 1fr 3;
68+
padding: 0 1;
69+
width: 99;
70+
height: 11;
71+
border: thick $background 80%;
72+
background: $surface;
73+
}
74+
75+
#server_details {
76+
column-span: 3;
77+
height: 1fr;
78+
width: 1fr;
79+
content-align: center top;
80+
padding: 1;
81+
}
82+
83+
.button {
84+
width: 1fr;
85+
height: 1fr;
86+
padding: 0 1;
87+
content-align: center middle;
88+
}

client/retibbs_textual.py

+208-26
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@
55
import re
66
import time
77

8-
from textual.app import App
9-
from textual.widgets import Header, Footer, Input, Log, DataTable, Static
8+
from textual.app import App, ComposeResult
109
from textual.binding import Binding
11-
from textual.containers import Horizontal, Vertical
10+
from textual.containers import Horizontal, Vertical, Grid
11+
from textual.screen import ModalScreen
12+
from textual.widgets import Header, Footer, TabbedContent, TabPane, Input, Log, DataTable, Static, Button, Label
1213

1314
import RNS
1415

16+
ADDRESS_BOOK_FILE = "address_book.json"
17+
1518
class AnnounceHandler:
1619
def __init__(self, app, aspect_filter=None):
1720
self.app = app
@@ -44,11 +47,48 @@ def received_announce(self, destination_hash, announced_identity, app_data):
4447
# f"[ANNOUNCE] {timestamp} - {display_name} ({dest_hash_display})"
4548
#)
4649

47-
# Call the app's `on_announce` function to update the table
4850
self.app.on_announce(destination_hash, announced_identity, app_data)
4951

52+
class ServerDetailScreen(ModalScreen):
53+
"""Screen to display server details."""
54+
55+
def __init__(self, server_name, destination_hash, timestamp, on_connect, saved_in_address_book):
56+
super().__init__()
57+
self.server_name = server_name
58+
self.destination_hash = destination_hash
59+
self.timestamp = timestamp
60+
self.on_connect = on_connect
61+
self.saved_in_address_book = saved_in_address_book
62+
63+
def compose(self) -> ComposeResult:
64+
yield Grid(
65+
Label(
66+
f"Server Name: {self.server_name}\nDestination Hash: {self.destination_hash}\nLast Heard: {self.timestamp}",
67+
id="server_details"
68+
),
69+
Button("Connect", id="connect", variant="success", classes="button"),
70+
Button(
71+
"Remove from Address Book" if self.saved_in_address_book else "Save to Address Book",
72+
id="toggle_address_book",
73+
variant="primary",
74+
classes="button",
75+
),
76+
Button("Close", id="close", variant="default", classes="button"),
77+
id="server_details_dialog",
78+
)
79+
80+
def on_button_pressed(self, event: Button.Pressed) -> None:
81+
if event.button.id == "connect":
82+
asyncio.create_task(self.on_connect(self.destination_hash))
83+
self.app.pop_screen()
84+
elif event.button.id == "toggle_address_book":
85+
self.app.pop_screen()
86+
self.app.toggle_address_book(self.destination_hash, self.saved_in_address_book)
87+
elif event.button.id == "close":
88+
self.app.pop_screen()
89+
5090
class RetiBBSClient(App):
51-
CSS_PATH = "app.css"
91+
CSS_PATH = "app.tcss"
5292

5393
BINDINGS = [
5494
Binding(key="q", action="quit", description="Quit the app"),
@@ -62,32 +102,73 @@ class RetiBBSClient(App):
62102

63103
def __init__(self, server_hexhash=None):
64104
super().__init__()
105+
self._deferred_debug_log = []
65106
self.server_hexhash = server_hexhash
66107
self.client_identity = None
67108
self.link = None
68109
self.servers = {}
110+
self.address_book = {}
111+
self.active_tab = "servers"
112+
self.server_list_update_pending = False
69113

70-
def compose(self):
114+
def compose(self) -> ComposeResult:
71115
yield Header()
116+
117+
server_tab = TabPane(title="Servers", id="servers")
118+
server_tab.compose_add_child(
119+
DataTable(id="server_list", classes="server-list", cursor_type="row")
120+
)
121+
122+
address_tab = TabPane(title="Address Book", id="address_book")
123+
address_tab.compose_add_child(
124+
DataTable(id="address_book", classes="address-book", cursor_type="row")
125+
)
126+
127+
tabs = TabbedContent(id="tabs", classes="tabs")
128+
tabs.compose_add_child(server_tab)
129+
tabs.compose_add_child(address_tab)
130+
tabs.default_tab = "Servers"
131+
72132
yield Horizontal(
73133
Vertical(
74134
Log(id="main_log"),
75135
Input(placeholder="Enter command...", id="command_input"),
76-
id="left_panel",
136+
id="left_panel"
77137
),
78138
Vertical(
79-
Static("Servers", id="server_list_title"),
80-
DataTable(id="server_list", classes="server-list"),
139+
tabs,
81140
Log(id="debug_log", classes="debug-log"),
82-
id="right_panel",
141+
id="right_panel"
83142
),
84-
id="main_body",
143+
id="main_body"
85144
)
86145
yield Footer()
146+
147+
def load_address_book(self):
148+
if os.path.exists(ADDRESS_BOOK_FILE):
149+
try:
150+
with open(ADDRESS_BOOK_FILE, "r") as file:
151+
address_book = json.load(file)
152+
self._deferred_debug_log.append(f"[DEBUG] Address book loaded: {address_book}")
153+
return address_book
154+
except Exception as e:
155+
self._deferred_debug_log.append(f"[DEBUG] Error loading address book: {e}")
156+
return {}
157+
158+
def save_address_book(self):
159+
with open(ADDRESS_BOOK_FILE, "w") as file:
160+
json.dump(self.address_book, file, indent=4)
87161

88162
def write_debug_log(self, message):
89-
debug_log = self.query_one("#debug_log", Log)
90-
debug_log.write_line(message)
163+
try:
164+
# Attempt to find the debug log and write the message
165+
debug_log = self.query_one("#debug_log", Log)
166+
debug_log.write_line(message)
167+
except Exception:
168+
# Defer the message if the debug log is not found
169+
if not hasattr(self, "_deferred_debug_log"):
170+
self._deferred_debug_log = []
171+
self._deferred_debug_log.append(message)
91172

92173
def write_log(self, message):
93174
log = self.query_one("#main_log", Log)
@@ -138,7 +219,28 @@ async def on_mount(self):
138219
self.title = "- RetiBBS Client -"
139220

140221
server_list = self.query_one("#server_list", DataTable)
141-
server_list.add_columns("Server Name", "Destination Hash")
222+
if not server_list.columns:
223+
server_list.add_columns("Server Name", "Destination Hash")
224+
225+
address_book = self.query_one("#address_book", DataTable)
226+
if not address_book.columns:
227+
address_book.add_columns("Server Name", "Destination Hash")
228+
229+
server_list.visible = True
230+
address_book.visible = True
231+
232+
# Log deferred errors (if any)
233+
if hasattr(self, "_deferred_debug_log"):
234+
for message in self._deferred_debug_log:
235+
try:
236+
debug_log = self.query_one("#debug_log", Log)
237+
debug_log.write_line(message)
238+
except Exception:
239+
continue # Skip if still unavailable
240+
self._deferred_debug_log.clear()
241+
242+
self.address_book = self.load_address_book()
243+
self.update_address_book()
142244

143245
self.write_log("Initializing Reticulum...")
144246
try:
@@ -161,16 +263,17 @@ async def on_mount(self):
161263
else:
162264
self.write_log("Server hexhash not provided. Please wait for an announce...")
163265

164-
async def connect_client(self):
165-
if not self.server_hexhash:
266+
async def connect_client(self, destination_hash=None):
267+
server_hexhash = destination_hash or self.server_hexhash
268+
if not server_hexhash:
166269
self.write_log("[CONNECT] Failed: No server hexhash provided.")
167270
return
168271

169272
try:
170273
try:
171-
server_addr = bytes.fromhex(self.server_hexhash)
274+
server_addr = bytes.fromhex(server_hexhash)
172275
except ValueError:
173-
self.write_log(f"[CONNECT] Failed: Invalid server hexhash: {self.server_hexhash}.")
276+
self.write_log(f"[CONNECT] Failed: Invalid server hexhash: {server_hexhash}.")
174277
return
175278

176279
if not RNS.Transport.has_path(server_addr):
@@ -233,9 +336,8 @@ async def wait_for_link(self):
233336
await asyncio.sleep(0.1)
234337

235338
def on_link_established(self, link):
236-
self.write_log("Connected to the RetiBBS server.")
237-
self.write_log("[DEBUG] Link established!")
238-
self.write_log(f"[DEBUG] Link status: {link.status}")
339+
self.write_debug_log("[DEBUG] Link established!")
340+
self.write_debug_log(f"[DEBUG] Link status: {link.status}")
239341

240342
def on_link_closed(self, link):
241343
self.write_log("Disconnected from the RetiBBS server.")
@@ -302,10 +404,12 @@ async def on_input_submitted(self, message: Input.Submitted):
302404
message.input.value = ""
303405

304406
def on_announce(self, destination_hash, announced_identity, app_data):
305-
self.write_debug_log("[ANNOUNCE] [Callback] Received announce packet.")
407+
# TOREMOVE: Debug log
408+
#self.write_debug_log("[ANNOUNCE] [Callback] Received announce packet.")
306409

307410
dest_hash_hex = destination_hash.hex()
308411
display_name = RNS.prettyhexrep(destination_hash)
412+
timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
309413

310414
if app_data:
311415
try:
@@ -318,17 +422,53 @@ def on_announce(self, destination_hash, announced_identity, app_data):
318422
self.servers[dest_hash_hex] = {
319423
"display_name": display_name,
320424
"hash": dest_hash_hex,
425+
"timestamp": timestamp,
321426
}
322427

323428
self.call_later(self.update_server_list)
324429

325430
self.write_debug_log(f"[ANNOUNCE] Discovered server: {display_name} ({dest_hash_hex})")
326431

327432
def update_server_list(self):
328-
server_list = self.query_one("#server_list", DataTable)
329-
server_list.clear()
330-
for server in self.servers.values():
331-
server_list.add_row(server["display_name"], server["hash"])
433+
try:
434+
# Check if a modal is currently active
435+
if len(self.screen_stack) > 1 and isinstance(self.screen_stack[-1], ModalScreen):
436+
if not self.server_list_update_pending:
437+
self.server_list_update_pending = True # Set flag to avoid repeated deferrals
438+
self.write_debug_log("[INFO] Modal is active, deferring server list update.")
439+
self.call_later(self.update_server_list) # Defer the update
440+
return
441+
442+
# Proceed with updating the server list
443+
self.server_list_update_pending = False # Reset the flag
444+
server_list = self.query_one("#server_list", DataTable)
445+
server_list.clear()
446+
for server in self.servers.values():
447+
server_list.add_row(server["display_name"], server["hash"])
448+
self.write_debug_log("[DEBUG] Server list updated successfully.")
449+
except Exception as e:
450+
self.server_list_update_pending = False # Ensure the flag is reset on error
451+
self.write_debug_log(f"[ERROR] Error updating server list: {e}")
452+
453+
def update_address_book(self):
454+
try:
455+
address_book = self.query_one("#address_book", DataTable)
456+
address_book.clear()
457+
for server in self.address_book.values():
458+
address_book.add_row(server["display_name"], server["hash"])
459+
self.write_debug_log(f"[DEBUG] Added to address book: {server['display_name']} - {server['hash']}")
460+
self.write_debug_log("[DEBUG] Address book updated successfully.")
461+
except Exception as e:
462+
self.write_debug_log(f"[DEBUG] Error updating address book: {e}")
463+
464+
def toggle_address_book(self, destination_hash, currently_saved):
465+
if currently_saved:
466+
del self.address_book[destination_hash]
467+
else:
468+
server = self.servers[destination_hash]
469+
self.address_book[destination_hash] = server
470+
self.save_address_book()
471+
self.update_address_book()
332472

333473
def action_refresh_servers(self):
334474
self.write_log("[ACTION] Refreshing server list...")
@@ -337,6 +477,48 @@ def action_refresh_servers(self):
337477
server_list.add_columns("Server Name", "Destination Hash")
338478
for server in self.servers.values():
339479
server_list.add_row(server["display_name"], server["hash"])
480+
481+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
482+
try:
483+
# Get the triggering table
484+
triggering_table = event.control
485+
table_id = triggering_table.id # Retrieve the table's ID
486+
self.write_debug_log(f"[ACTION] Selected row in table: {table_id}, row key: {event.row_key}")
487+
488+
if table_id == "server_list":
489+
# Handle server list selection
490+
row_data = triggering_table.get_row(event.row_key)
491+
if row_data:
492+
server_name, destination_hash = row_data
493+
server_info = self.servers.get(destination_hash)
494+
if server_info:
495+
self.push_screen(
496+
ServerDetailScreen(
497+
server_name=server_info["display_name"],
498+
destination_hash=server_info["hash"],
499+
timestamp=server_info["timestamp"],
500+
on_connect=self.connect_client,
501+
saved_in_address_book=destination_hash in self.address_book,
502+
)
503+
)
504+
elif table_id == "address_book":
505+
# Handle address book selection
506+
row_data = triggering_table.get_row(event.row_key)
507+
if row_data:
508+
server_name, destination_hash = row_data
509+
server_info = self.address_book.get(destination_hash)
510+
if server_info:
511+
self.push_screen(
512+
ServerDetailScreen(
513+
server_name=server_info["display_name"],
514+
destination_hash=server_info["hash"],
515+
timestamp=server_info["timestamp"],
516+
on_connect=self.connect_client,
517+
saved_in_address_book=True,
518+
)
519+
)
520+
except Exception as e:
521+
self.write_debug_log(f"[ERROR] Exception in row selection: {e}")
340522

341523
if __name__ == "__main__":
342524
parser = argparse.ArgumentParser(description="RetiBBS Client")

0 commit comments

Comments
 (0)