diff --git a/nomadnet/Conversation.py b/nomadnet/Conversation.py index b966093..5c804b6 100644 --- a/nomadnet/Conversation.py +++ b/nomadnet/Conversation.py @@ -72,6 +72,24 @@ class Conversation: ingested_path = lxmessage.write_to_directory(conversation_path) + if not originator and ingested_path is not None: + try: + rssi = getattr(lxmessage, "rssi", None) + snr = getattr(lxmessage, "snr", None) + q = getattr(lxmessage, "q", None) + if rssi is not None or snr is not None or q is not None: + index = ConversationMessage.read_index(conversation_path) + filename = os.path.basename(ingested_path) + entry = index.get(filename) or {} + if rssi is not None: entry["rssi"] = rssi + if snr is not None: entry["snr"] = snr + if q is not None: entry["q"] = q + index[filename] = entry + with open(os.path.join(conversation_path, ".index"), "wb") as index_file: + index_file.write(msgpack.packb(index)) + except Exception as e: + RNS.log("Error while storing reception stats for message: "+str(e), RNS.LOG_ERROR) + try: ConversationMessage.extract_attachments_from_lxm(lxmessage, app) except Exception as e: @@ -204,7 +222,7 @@ class Conversation: self.__changed_callback = None - if not self.__resolve_send_destination(): + if not self.ensure_send_destination(): RNS.Transport.request_path(bytes.fromhex(source_hash)) if initiator: @@ -289,7 +307,7 @@ class Conversation: def register_changed_callback(self, callback): self.__changed_callback = callback - def __resolve_send_destination(self): + def ensure_send_destination(self): if self.send_destination == None: self.source_identity = RNS.Identity.recall(bytes.fromhex(self.source_hash)) @@ -300,13 +318,16 @@ class Conversation: return self.send_destination != None def send(self, content="", title="", fields=None): - if self.__resolve_send_destination(): + if self.ensure_send_destination(): dest = self.send_destination source = self.app.lxmf_destination desired_method = LXMF.LXMessage.DIRECT - if self.app.directory.preferred_delivery(dest.hash) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(dest.hash) + if preferred == DirectoryEntry.PROPAGATED: if self.app.message_router.get_outbound_propagation_node() != None: desired_method = LXMF.LXMessage.PROPAGATED + elif preferred == DirectoryEntry.OPPORTUNISTIC: + desired_method = LXMF.LXMessage.OPPORTUNISTIC else: if not self.app.message_router.delivery_link_available(dest.hash) and RNS.Identity.current_ratchet_id(dest.hash) != None: RNS.log(f"Have ratchet for {RNS.prettyhexrep(dest.hash)}, requesting opportunistic delivery of message", RNS.LOG_DEBUG) @@ -335,7 +356,7 @@ class Conversation: return False def paper_output(self, content="", title="", mode="print_qr"): - if self.__resolve_send_destination(): + if self.ensure_send_destination(): try: dest = self.send_destination source = self.app.lxmf_destination @@ -440,6 +461,9 @@ class ConversationMessage: self._cached_method = None self._cached_has_attachments = None self._cached_attachment_names = None + self._cached_rssi = None + self._cached_snr = None + self._cached_q = None self.sort_timestamp = os.path.getmtime(file_path) if os.path.isfile(file_path) else 0 @@ -483,6 +507,12 @@ class ConversationMessage: if self._cached_signature_validated is None: self._cached_signature_validated = self.lxm.signature_validated self._cached_method = self.lxm.method + if self._cached_rssi is None: + self._cached_rssi = getattr(self.lxm, "rssi", None) + if self._cached_snr is None: + self._cached_snr = getattr(self.lxm, "snr", None) + if self._cached_q is None: + self._cached_q = getattr(self.lxm, "q", None) if hasattr(self.lxm, "get_fields"): _fields = self.lxm.get_fields() if _fields and isinstance(_fields, dict) and LXMF.FIELD_RENDERER in _fields: @@ -603,6 +633,22 @@ class ConversationMessage: self.load() return self._cached_state + def get_method(self): + if self._cached_method is not None: + return self._cached_method + if not self.loaded: + self.load() + return self._cached_method + + def get_rssi(self): + return self._cached_rssi + + def get_snr(self): + return self._cached_snr + + def get_q(self): + return self._cached_q + def get_transport_encryption(self): if self._cached_transport_encryption is not None: return self._cached_transport_encryption @@ -964,6 +1010,9 @@ class ConversationMessage: "renderer": None if self._cached_renderer is ConversationMessage._RENDERER_UNSET else self._cached_renderer, "has_attachments": self._cached_has_attachments, "attachment_names": self._cached_attachment_names, + "rssi": self._cached_rssi, + "snr": self._cached_snr, + "q": self._cached_q, } def restore_from_index(self, entry): @@ -981,6 +1030,9 @@ class ConversationMessage: self._cached_renderer = entry.get("renderer", ConversationMessage._RENDERER_UNSET) self._cached_has_attachments = entry.get("has_attachments") self._cached_attachment_names = entry.get("attachment_names") + self._cached_rssi = entry.get("rssi") + self._cached_snr = entry.get("snr") + self._cached_q = entry.get("q") @staticmethod def read_index(conversation_path): diff --git a/nomadnet/Directory.py b/nomadnet/Directory.py index ea2f21e..65ab3a7 100644 --- a/nomadnet/Directory.py +++ b/nomadnet/Directory.py @@ -412,8 +412,9 @@ class DirectoryEntry: UNKNOWN = 0x02 TRUSTED = 0xFF - DIRECT = 0x01 - PROPAGATED = 0x02 + DIRECT = 0x01 + PROPAGATED = 0x02 + OPPORTUNISTIC = 0x03 def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None, notes=None): if len(source_hash) == RNS.Identity.TRUNCATED_HASHLENGTH//8: diff --git a/nomadnet/InterfaceProfiles.py b/nomadnet/InterfaceProfiles.py new file mode 100644 index 0000000..254380b --- /dev/null +++ b/nomadnet/InterfaceProfiles.py @@ -0,0 +1,240 @@ +import os +import threading + +import RNS +import RNS.vendor.umsgpack as msgpack +from nomadnet.util import write_config_atomic + + +class InterfaceProfiles: + VERSION = 1 + + def __init__(self, app): + self.app = app + self.path = os.path.join(app.storagepath, "interface_profiles.msgpack") + self._lock = threading.Lock() + self.profiles = [] + self.load() + + def load(self): + self.profiles = [] + self.default_members = None + try: + if not os.path.isfile(self.path): + return + with open(self.path, "rb") as f: + data = msgpack.unpackb(f.read()) + if not isinstance(data, dict): + return + clean = [] + seen = set() + for p in data.get("profiles") or []: + if not isinstance(p, dict): + continue + pid = str(p.get("id") or "") + if not pid or pid in seen: + continue + members = [str(m) for m in (p.get("members") or []) if isinstance(m, (str, bytes))] + clean.append({"id": pid, "name": str(p.get("name") or ""), "members": members}) + seen.add(pid) + self.profiles = clean + dm = data.get("default_members") + self.default_members = [str(m) for m in dm] if isinstance(dm, list) else None + except Exception as e: + RNS.log("Could not load interface profiles: "+str(e), RNS.LOG_ERROR) + self.profiles = [] + self.default_members = None + + def save(self): + with self._lock: + tmp = self.path + ".tmp" + try: + payload = { + "version": InterfaceProfiles.VERSION, + "profiles": [{"id": p["id"], "name": p["name"], "members": list(p["members"])} for p in self.profiles], + "default_members": self.default_members, + } + with open(tmp, "wb") as f: + f.write(msgpack.packb(payload)) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp, self.path) + except Exception as e: + RNS.log("Could not save interface profiles: "+str(e), RNS.LOG_ERROR) + try: os.remove(tmp) + except Exception: pass + + def _interfaces(self): + try: + return self.app.rns.config["interfaces"] + except Exception: + return {} + + def interface_names(self): + try: + return list(self._interfaces().keys()) + except Exception: + return [] + + def _is_enabled(self, iface): + return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + def enabled_set(self): + interfaces = self._interfaces() + result = set() + for name in interfaces: + try: + if self._is_enabled(interfaces[name]): + result.add(name) + except Exception: + pass + return result + + def get(self, pid): + for p in self.profiles: + if p["id"] == pid: + return p + return None + + def members_existing(self, p): + existing = set(self.interface_names()) + return set(m for m in p["members"] if m in existing) + + def active_profile_id(self): + enabled = self.enabled_set() + for p in self.profiles: + if self.members_existing(p) == enabled: + return p["id"] + return None + + def profiles_for(self, iface_name): + return [p for p in self.profiles if iface_name in p["members"]] + + def label_for(self, iface_name): + names = [p["name"] or p["id"] for p in self.profiles_for(iface_name)] + return ", ".join(names) if names else None + + def member_count(self, p): + return len(self.members_existing(p)) + + def _new_id(self): + while True: + pid = os.urandom(4).hex() + if not self.get(pid): + return pid + + def create(self, name): + pid = self._new_id() + self.profiles.append({"id": pid, "name": name or "Profile", "members": []}) + self.save() + return pid + + def save_current_as_profile(self, name): + pid = self.create(name) + self.set_members(pid, sorted(self.enabled_set())) + return pid + + def rename(self, pid, name): + p = self.get(pid) + if p is not None and name: + p["name"] = name + self.save() + + def delete(self, pid): + self.profiles = [p for p in self.profiles if p["id"] != pid] + self.save() + + def set_members(self, pid, members): + p = self.get(pid) + if p is not None: + valid = set(self.interface_names()) + seen = set() + ordered = [] + for m in members: + if m in valid and m not in seen: + ordered.append(m) + seen.add(m) + p["members"] = ordered + self.save() + + def set_interface_profiles(self, iface_name, profile_ids): + pset = set(profile_ids) + for p in self.profiles: + if p["id"] in pset: + if iface_name not in p["members"]: + p["members"].append(iface_name) + elif iface_name in p["members"]: + p["members"] = [m for m in p["members"] if m != iface_name] + self.save() + + def _apply_members(self, members): + existing = set(self.interface_names()) + members = set(m for m in members if m in existing) + interfaces = self._interfaces() + try: + for name in interfaces: + value = "true" if name in members else "false" + interfaces[name]["interface_enabled"] = value + if "enabled" in interfaces[name]: + interfaces[name]["enabled"] = value + write_config_atomic(self.app.rns.config) + return True + except Exception as e: + RNS.log("Could not apply interface profile: "+str(e), RNS.LOG_ERROR) + return False + + def select_profile(self, pid): + p = self.get(pid) + if p is None: + return False + return self._apply_members(self.members_existing(p)) + + def select_default(self): + if self.default_members is None: + return False + return self._apply_members(set(self.default_members)) + + def update_default_if_custom(self): + # Remember the live enabled-set as "Default" whenever it matches no named + # profile -- i.e. it's the user's manual setup. Only relevant once profiles exist. + if not self.profiles: + return + enabled = self.enabled_set() + matches_named = any(self.members_existing(p) == enabled for p in self.profiles) + if matches_named and self.default_members is not None: + return + new_default = sorted(enabled) + if self.default_members != new_default: + self.default_members = new_default + self.save() + + def remove_interface(self, iface_name): + changed = False + for p in self.profiles: + if iface_name in p["members"]: + p["members"] = [m for m in p["members"] if m != iface_name] + changed = True + if changed: + self.save() + + def rename_interface(self, old_name, new_name): + changed = False + for p in self.profiles: + if old_name in p["members"]: + p["members"] = [new_name if m == old_name else m for m in p["members"]] + changed = True + if changed: + self.save() + + def prune(self): + valid = set(self.interface_names()) + changed = False + for p in self.profiles: + pruned = [m for m in p["members"] if m in valid] + if len(pruned) != len(p["members"]): + p["members"] = pruned + changed = True + if changed: + self.save() diff --git a/nomadnet/NomadNetworkApp.py b/nomadnet/NomadNetworkApp.py index 50b4c6d..0ca19c5 100644 --- a/nomadnet/NomadNetworkApp.py +++ b/nomadnet/NomadNetworkApp.py @@ -331,6 +331,9 @@ class NomadNetworkApp: self.rrc = RRCManager(self) self.rrc.load() + from nomadnet.InterfaceProfiles import InterfaceProfiles + self.interface_profiles = InterfaceProfiles(self) + static_peers = [] for static_peer in self.static_peers: try: @@ -982,6 +985,13 @@ class NomadNetworkApp: if value == "web": self.uimode = nomadnet.ui.UI_WEB + if not "utilities" in self.config: + self.config["utilities"] = {} + if not "enable_utilities" in self.config["utilities"]: + self.config["utilities"]["enable_utilities"] = False + else: + self.config["utilities"]["enable_utilities"] = self.config["utilities"].as_bool("enable_utilities") + if "rrc" in self.config: for option in self.config["rrc"]: if option == "history_per_room_cap": @@ -1387,6 +1397,14 @@ sanitize_names = yes # escape sequences. clipboard_copy = no +[utilities] + +# You can enable a Utilities section in the +# menu, where you can view the path and announce +# rate tables and probe destinations. Disabled +# by default. +enable_utilities = no + [rrc] # Maximum number of messages retained per diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 5d1eccc..87f4d87 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -25,6 +25,8 @@ An masked field : `B444``b An small field : `B444`<8|small`test>`b, and some more text. +A multi-row field : `B444`<40x4|notes`>`b + Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 15abd7b..50d65d1 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -55,8 +55,10 @@ THEMES = { ("progress_empty", "light gray", "default", "default", "#ddd", "default"), ("interface_title", "", "", "default", "", ""), ("interface_title_selected", "bold", "", "bold", "", ""), + ("interface_tile_focus", "light blue,bold", "default", "bold", "#5af,bold", "default"), ("connected_status", "dark green", "default", "default", "dark green", "default"), - ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("disconnected_status", "dark red", "default", "default", "dark red", "default"), + ("disabled_status", "dark red", "default", "default", "dark red", "default"), ("placeholder", "dark gray", "default", "default", "dark gray", "default"), ("placeholder_text", "dark gray", "default", "default", "dark gray", "default"), ("error", "light red,blink", "default", "blink", "#f44,blink", "default"), @@ -108,8 +110,10 @@ THEMES = { ("progress_empty", "dark gray", "default", "default", "#ddd", "default"), ("interface_title", "dark gray", "default", "default", "#444", "default"), ("interface_title_selected", "dark gray,bold", "default", "bold", "#444,bold", "default"), + ("interface_tile_focus", "dark blue,bold", "default", "bold", "#06c,bold", "default"), ("connected_status", "dark green", "default", "default", "#4a0", "default"), - ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("disconnected_status", "dark red", "default", "default", "#a22", "default"), + ("disabled_status", "dark red", "default", "default", "#a22", "default"), ("placeholder", "light gray", "default", "default", "#999", "default"), ("placeholder_text", "light gray", "default", "default", "#999", "default"), ("error", "dark red,blink", "default", "blink", "#a22,blink", "default"), @@ -124,6 +128,23 @@ THEMES = { } } +CONFIG_SECTION_COLORS = { + "logging": ("yellow", "#fd3", "brown", "#a80"), + "client": ("light cyan", "#3cd", "dark cyan", "#077"), + "textui": ("light green", "#6c5", "dark green", "#2a0"), + "utilities": ("light gray", "#bbb", "dark gray", "#555"), + "rrc": ("light magenta", "#d7f", "dark magenta", "#a2a"), + "node": ("light blue", "#5af", "dark blue", "#06c"), + "printing": ("brown", "#b86", "brown", "#852"), + "reticulum": ("light magenta", "#a6f", "dark magenta", "#63c"), +} + +for _section, (_d16, _dhi, _l16, _lhi) in CONFIG_SECTION_COLORS.items(): + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section, _d16, "default", "default", _dhi, "default")) + THEMES[THEME_DARK]["urwid_theme"].append(("config_title_"+_section+"_selected", _d16+",bold", "default", "bold", _dhi+",bold", "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section, _l16, "default", "default", _lhi, "default")) + THEMES[THEME_LIGHT]["urwid_theme"].append(("config_title_"+_section+"_selected", _l16+",bold", "default", "bold", _lhi+",bold", "default")) + GLYPHSETS = { "plain": 1, "unicode": 2, @@ -164,11 +185,20 @@ GLYPHS = { ("qrcode", "QR", "\u25a4", "\uf029"), ("selected", "[*] ", "\u25CF", "\u25CF"), ("unselected", "[ ] ", "\u25CB", "\u25CB"), + ("focus_arrow", ">", "\u25B8", "\u25B8"), + ("sep_dot", "|", "\u00B7", "\u00B7"), + ("connected", "+", "\u2713", "\U000f0201"), + ("disconnected", "x", "\u2717", "\U000f0202"), ("file", "[F]", "\u25a4", "\uf15b"), ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), ("pin", "*", "\u2605", "\uf08d"), ("copy", "[C]", "\u29c9", "\uf0c5"), + ("folder", "[+]", "\u25b8", "\uf07b"), + ("folder_open", "[-]", "\u25be", "\uf07c"), + ("fold_open", "-", "▾", "▾"), + ("fold_closed", "+", "▸", "▸"), + ("dropdown", " [v]", " ▾", " "), } class TextUI: @@ -261,7 +291,7 @@ class TextUI: def unhandled_input(self, key): if key == "ctrl q": - raise urwid.ExitMainLoop + self.main_display.show_quit_dialog() elif key == "ctrl e": pass diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index d98ee23..51685b3 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -347,6 +347,8 @@ class Browser: if target_idx is None: return + self.reveal_index(int(target_idx)) + row_offset = self._rows_above(int(target_idx), cols) try: @@ -363,7 +365,7 @@ class Browser: browser_cols = screen_cols-lw-2 cols = browser_cols - except Exception: + except Exception as e: RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) RNS.trace_exception(e) cols = 100 @@ -373,8 +375,10 @@ class Browser: def _rows_above(self, index, cols): if index <= 0 or not self.attr_maps: return 0 + hidden = self.hidden_indices() total = 0 for i in range(min(index, len(self.attr_maps))): + if i in hidden: continue try: total += self.attr_maps[i].rows((cols,)) except Exception: total += 1 @@ -627,6 +631,106 @@ class Browser: self.page_partials = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() + self.init_folds() + + # render an arbitrary markup buffer (used by the page editor preview) and + # return the content widget, with partials/folds/fields wired as usual + def render_markup_buffer(self, markup): + self.status = Browser.DONE + self.markup = markup + self.page_background_color = None + self.page_foreground_color = None + for tag, attr in (("#!bg=", "page_background_color"), ("#!fg=", "page_foreground_color")): + pos = markup.find(tag) + if pos >= 0: + endpos = markup.find("\n", pos) + if endpos < 0: endpos = len(markup) + if endpos-(pos+len(tag)) in (3, 6): + setattr(self, attr, markup[pos+len(tag):endpos]) + try: + self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: + self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render preview: {e}"), "browser_inactive")] + self.update_page_display() + return self.browser_body + + def init_folds(self): + self.fold_sections = {} + self.collapsed_headings = set() + attrmaps = self.attr_maps + header_levels = getattr(attrmaps, "header_levels", None) or {} + row_levels = getattr(attrmaps, "row_levels", None) or {} + collapsibles = getattr(attrmaps, "collapsibles", None) or {} + if not collapsibles: + return + + n = len(attrmaps) + for idx in sorted(collapsibles.keys()): + widget = self.collapsible_widget_at(idx) + if widget is None: continue + depth = header_levels.get(idx, 1) + end = n + for i in range(idx+1, n): + if row_levels.get(i, depth) < depth or \ + (i in header_levels and header_levels.get(i, 1) <= depth): + end = i; break + self.fold_sections[widget] = (idx+1, end) + if collapsibles[idx]: + self.collapsed_headings.add(widget) + try: widget.set_collapsed(True) + except Exception: pass + + if self.collapsed_headings: + self.rebuild_visible() + + def collapsible_widget_at(self, idx): + try: + o = self.attr_maps[idx]._original_widget + if getattr(o, "is_collapsible_heading", False): return o + except Exception: pass + return None + + def hidden_indices(self): + hidden = set() + for widget in getattr(self, "collapsed_headings", ()): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng: hidden.update(range(rng[0], rng[1])) + return hidden + + def rebuild_visible(self): + pile = getattr(self, "page_pile", None) + if pile is None: return + hidden = self.hidden_indices() + opts = pile.options() + pile.contents = [(w, opts) for i, w in enumerate(self.attr_maps) if i not in hidden] + try: + if pile.focus_position >= len(pile.contents): + pile.focus_position = max(0, len(pile.contents)-1) + except Exception: pass + + def fold_changed(self, heading_widget): + if getattr(heading_widget, "collapsed", False): + self.collapsed_headings.add(heading_widget) + else: + self.collapsed_headings.discard(heading_widget) + self.rebuild_visible() + try: + for pos, (w, _) in enumerate(self.page_pile.contents): + if getattr(w, "_original_widget", None) is heading_widget: + self.page_pile.focus_position = pos + break + except Exception: pass + + def reveal_index(self, idx): + changed = False + for widget in list(getattr(self, "collapsed_headings", ())): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng and rng[0] <= idx < rng[1]: + self.collapsed_headings.discard(widget) + try: widget.set_collapsed(False) + except Exception: pass + changed = True + if changed: self.rebuild_visible() def parse_url(self, url): path = None diff --git a/nomadnet/ui/textui/Config.py b/nomadnet/ui/textui/Config.py index 36d92a5..7a0f771 100644 --- a/nomadnet/ui/textui/Config.py +++ b/nomadnet/ui/textui/Config.py @@ -1,55 +1,1228 @@ +import RNS import nomadnet import urwid import platform +from RNS.vendor.configobj import ConfigObj +from nomadnet.util import write_config_atomic +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * + +CONFIG_SECTION_GLYPHS = { + ("logging", "(L)", "≡", ""), + ("client", "(C)", "✉", ""), + ("utilities", "(Y)", "⚒", ""), + ("textui", "(U)", "▤", ""), + ("rrc", "(R)", "❖", ""), + ("node", "(N)", "Ⓝ", "\U000f0002"), + ("printing", "(P)", "⎙", ""), + ("reticulum", "(Rt)", "⇄", ""), + ("editor", "(E)", "✎", ""), +} + +def _get_section_icon(glyphset, name): + glyphset_index = 1 + if glyphset == "plain": + glyphset_index = 0 + elif glyphset == "nerdfont": + glyphset_index = 2 + + for glyph_tuple in CONFIG_SECTION_GLYPHS: + if glyph_tuple[0] == name: + return glyph_tuple[glyphset_index + 1] + + return "(#)" if glyphset == "plain" else "⚙" if glyphset == "unicode" else "" + +def _get_cols_rows(): + return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() + +def section_title_attr(section_key): + return "config_title_" + section_key + +class ConfigCheckbox(FormCheckbox): + def __init__(self, config_key, on_text="Enabled", off_text="Disabled", state=False, validation_types=None, transform=None, **kwargs): + self.on_text = on_text + self.off_text = off_text + super().__init__(config_key, label="", state=state, validation_types=validation_types, transform=transform, **kwargs) + self._refresh_label(self.get_state()) + urwid.connect_signal(self, "change", self._on_change) + + def _on_change(self, widget, new_state): + self._refresh_label(new_state) + + def _refresh_label(self, state): + if state: + self.set_label(("connected_status", " " + self.on_text)) + else: + self.set_label(("inactive_text", " " + self.off_text)) + +CONFIG_SECTIONS = [ + { + "key": "logging", + "name": "Logging", + "glyph": "logging", + "description": "Log verbosity and where log output is written", + "fields": [ + { + "config_key": "loglevel", + "label": "Log level: ", + "type": "dropdown", + "options": ["0", "1", "2", "3", "4", "5", "6", "7"], + "default": "4", + "help": "0 = critical, 4 = info, 7 = extreme verbosity", + }, + { + "config_key": "destination", + "label": "Destination: ", + "type": "dropdown", + "options": ["file", "stdout"], + "default": "file", + "help": "Write log to a file or to standard output", + }, + { + "config_key": "logfile", + "label": "Log file: ", + "type": "edit", + "placeholder": "Defaults to the config directory", + "help": "Path to the log file (when destination is file)", + }, + ], + }, + { + "key": "client", + "name": "LXMF client", + "glyph": "client", + "description": "Client behaviour, announces, syncing and messages", + "fields": [ + { + "config_key": "downloads_path", + "label": "Downloads path: ", + "type": "edit", + "default": "~/Downloads", + "placeholder": "~/Downloads", + "help": "Where downloaded files are saved", + }, + { + "config_key": "attachment_save_path", + "label": "Attachment path: ", + "type": "edit", + "placeholder": "Defaults to the downloads path", + "help": "Where message attachments are saved", + }, + { + "config_key": "notify_on_new_message", + "label": "Notify on message: ", + "type": "checkbox", + "default": True, + "help": "Show a notification when a new message arrives", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the local peer when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between peer announces (minimum 30)", + }, + { + "config_key": "try_propagation_on_send_fail", + "label": "Propagate on fail: ", + "type": "checkbox", + "default": True, + "help": "Use a propagation node if direct delivery fails", + }, + { + "config_key": "periodic_lxmf_sync", + "label": "Periodic sync: ", + "type": "checkbox", + "default": True, + "help": "Automatically sync messages from a propagation node", + }, + { + "config_key": "lxmf_sync_interval", + "label": "Sync interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between automatic syncs", + }, + { + "config_key": "lxmf_sync_limit", + "label": "Sync limit: ", + "type": "edit", + "default": "8", + "validation": ["number"], + "help": "Max messages fetched per sync (0 = no limit)", + }, + { + "config_key": "required_stamp_cost", + "label": "Required stamp cost: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "validation": ["stamp_cost"], + "help": "Required inbound stamp cost 1-255, or None to disable", + }, + { + "config_key": "accept_invalid_stamps", + "label": "Accept invalid stamps: ", + "type": "checkbox", + "default": False, + "help": "Accept messages carrying an invalid stamp", + }, + { + "config_key": "max_accepted_size", + "label": "Max message size: ", + "type": "edit", + "default": "500", + "validation": ["float"], + "help": "Maximum accepted incoming message size, in KB", + }, + { + "config_key": "compact_announce_stream", + "label": "Compact announces: ", + "type": "checkbox", + "default": True, + "help": "Show only one announce per destination", + }, + { + "config_key": "compose_in_markdown", + "label": "Compose in markdown: ", + "type": "checkbox", + "default": True, + "help": "Author messages using markdown", + }, + ], + }, + { + "key": "textui", + "name": "Text UI", + "glyph": "textui", + "description": "Appearance and behaviour of the text interface", + "note": "Switch between the light and dark theme, glyphs and other appearance options here.", + "fields": [ + { + "config_key": "intro_time", + "label": "Intro time: ", + "type": "edit", + "default": "1", + "validation": ["float"], + "help": "Seconds to show the intro screen at startup (0 to disable)", + }, + { + "config_key": "intro_text", + "label": "Intro text: ", + "type": "edit", + "default": "Nomad Network", + "placeholder": "Nomad Network", + "help": "Text shown on the intro screen", + }, + { + "config_key": "theme", + "label": "Theme: ", + "type": "dropdown", + "options": ["dark", "light"], + "default": "dark", + "help": "Colour theme", + }, + { + "config_key": "colormode", + "label": "Color mode: ", + "type": "dropdown", + "options": ["monochrome", "16", "88", "256", "24bit"], + "default": "24bit", + "help": "Colour capability of your terminal", + }, + { + "config_key": "glyphs", + "label": "Glyphs: ", + "type": "dropdown", + "options": ["plain", "unicode", "nerdfont"], + "default": "nerdfont", + "help": "Glyph set used for icons", + }, + { + "config_key": "mouse_enabled", + "label": "Mouse enabled: ", + "type": "checkbox", + "default": True, + "help": "Allow mouse interaction", + }, + { + "config_key": "editor", + "label": "Editor: ", + "type": "edit", + "default": "nano", + "placeholder": "nano", + "help": "Command used to edit text", + }, + { + "config_key": "hide_guide", + "label": "Hide guide: ", + "type": "checkbox", + "default": False, + "help": "Hide the Guide entry in the menu", + }, + { + "config_key": "sanitize_names", + "label": "Sanitize names: ", + "type": "checkbox", + "default": True, + "help": "Strip control characters from announced names", + }, + { + "config_key": "clipboard_copy", + "label": "Clipboard copy: ", + "type": "checkbox", + "default": False, + "help": "Enable copying to the system clipboard", + }, + { + "config_key": "animation_interval", + "label": "Animation interval: ", + "type": "edit", + "default": "1", + "validation": ["number"], + "help": "Interval between UI animation frames", + }, + ], + }, + { + "key": "utilities", + "name": "Utilities", + "glyph": "utilities", + "description": "Optional utility views and tools", + "note": "Adds a Utilities menu where you can view path/announce tables and probe destinations.", + "fields": [ + { + "config_key": "enable_utilities", + "label": "Enable utilities: ", + "type": "checkbox", + "default": False, + "help": "Show the Utilities section (path tables, probe) in the menu", + }, + ], + }, + { + "key": "rrc", + "name": "RRC Client Settings", + "glyph": "rrc", + "description": "Rendering and history for the rooms client", + "fields": [ + { + "config_key": "history_per_room_cap", + "label": "History cap: ", + "type": "edit", + "default": "500", + "validation": ["number"], + "help": "Messages kept in memory per room", + }, + { + "config_key": "filter_loaded_history", + "label": "Filter history: ", + "type": "checkbox", + "default": True, + "help": "Filter system messages out of loaded history", + }, + { + "config_key": "ephemeral_notices", + "label": "Ephemeral notices: ", + "type": "edit", + "default": "10", + "validation": ["float"], + "help": "Minutes to keep notices (0 keeps them)", + }, + { + "config_key": "color_mention_timestamps", + "label": "Color mention time: ", + "type": "checkbox", + "default": True, + "help": "Colour the timestamp when you are mentioned", + }, + { + "config_key": "render_markdown", + "label": "Render markdown: ", + "type": "checkbox", + "default": True, + "help": "Render markdown formatting in messages", + }, + { + "config_key": "render_micron", + "label": "Render micron: ", + "type": "checkbox", + "default": True, + "help": "Render micron markup in messages", + }, + { + "config_key": "nick_colors", + "label": "Nick colors: ", + "type": "checkbox", + "default": True, + "help": "Colour user nicknames", + }, + { + "config_key": "justify_msgs", + "label": "Justify messages: ", + "type": "checkbox", + "default": True, + "help": "Justify message text", + }, + { + "config_key": "space_msgs", + "label": "Space messages: ", + "type": "checkbox", + "default": False, + "help": "Add extra spacing between messages", + }, + { + "config_key": "show_gutters", + "label": "Show gutters: ", + "type": "checkbox", + "default": True, + "help": "Show gutters alongside messages", + }, + { + "config_key": "mention_color", + "label": "Mention color: ", + "type": "edit", + "placeholder": "6-digit hex, e.g. ff8800", + "help": "Hex colour used for mentions of your nick", + }, + { + "config_key": "nick_colors_theme", + "label": "Nick color theme: ", + "type": "list", + "default": [], + "placeholder": "6-digit hex colour", + "help": "Custom palette of hex colours used for nicknames", + }, + { + "config_key": "enable_esoterics", + "label": "Enable esoterics: ", + "type": "checkbox", + "default": False, + "help": "Enable esoteric features", + }, + ], + }, + { + "key": "node", + "name": "Node", + "glyph": "node", + "description": "Node hosting, propagation and announces", + "note": "Enable this to host a node on the network, serving pages and files to others.", + "fields": [ + { + "config_key": "enable_node", + "label": "Enable node: ", + "type": "checkbox", + "default": False, + "help": "Host a node on the network", + }, + { + "config_key": "node_name", + "label": "Node name: ", + "type": "edit", + "default": "None", + "placeholder": "None", + "help": "Display name for the node, or None", + }, + { + "config_key": "announce_at_start", + "label": "Announce at start: ", + "type": "checkbox", + "default": True, + "help": "Announce the node when starting", + }, + { + "config_key": "announce_interval", + "label": "Announce interval: ", + "type": "edit", + "default": "360", + "validation": ["number"], + "help": "Minutes between node announces (minimum 1)", + }, + { + "config_key": "disable_propagation", + "label": "Disable propagation: ", + "type": "checkbox", + "default": True, + "help": "Disable the propagation node functionality", + }, + { + "config_key": "propagation_cost", + "label": "Propagation cost: ", + "type": "edit", + "default": "16", + "validation": ["number"], + "help": "Stamp cost for propagation (minimum 13)", + }, + { + "config_key": "max_transfer_size", + "label": "Max transfer size: ", + "type": "edit", + "default": "256", + "validation": ["float"], + "help": "Maximum single message size on propagation, in KB", + }, + { + "config_key": "max_sync_size", + "label": "Max sync size: ", + "type": "edit", + "default": "10240", + "validation": ["float"], + "help": "Maximum sync batch size, in KB", + }, + { + "config_key": "pages_path", + "label": "Pages path: ", + "type": "edit", + "placeholder": "Default node pages directory", + "help": "Directory of pages served by the node", + }, + { + "config_key": "page_refresh_interval", + "label": "Page refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between page rescans (0 = never)", + }, + { + "config_key": "files_path", + "label": "Files path: ", + "type": "edit", + "placeholder": "Default node files directory", + "help": "Directory of files served by the node", + }, + { + "config_key": "file_refresh_interval", + "label": "File refresh: ", + "type": "edit", + "default": "0", + "validation": ["number"], + "help": "Minutes between file rescans (0 = never)", + }, + { + "config_key": "prioritise_destinations", + "label": "Priority dests: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Destination hashes given storage priority", + }, + { + "config_key": "static_peers", + "label": "Static peers: ", + "type": "list", + "default": [], + "placeholder": "Destination hash", + "help": "Propagation peers always kept connected", + }, + { + "config_key": "max_peers", + "label": "Max peers: ", + "type": "edit", + "placeholder": "No limit", + "validation": ["number"], + "help": "Maximum number of propagation peers", + }, + { + "config_key": "message_storage_limit", + "label": "Storage limit: ", + "type": "edit", + "default": "2000", + "validation": ["float"], + "help": "Maximum propagation storage, in MB", + }, + ], + }, + { + "key": "printing", + "name": "Printing", + "glyph": "printing", + "description": "Printing of received messages", + "note": "The rendered message file is appended to this command, so it runs as e.g. 'lp '.", + "fields": [ + { + "config_key": "print_messages", + "label": "Print messages: ", + "type": "checkbox", + "default": False, + "help": "Print received messages", + }, + { + "config_key": "print_command", + "label": "Print command: ", + "type": "edit", + "default": "lp", + "placeholder": "lp", + "help": ("Command to print with; the message file is appended as the last argument. Examples: " + "lp | " + "lp -d NAME (named printer, list with lpstat -p) | " + "lp -d NAME -o cpi=16 -o lpi=8 (small thermal-roll printers) | " + "lp -d NAME -o media=Custom.58x210mm (58mm thermal roll)"), + }, + { + "config_key": "print_from", + "label": "Print from: ", + "type": "list", + "default": [], + "placeholder": "everywhere, trusted, or hash", + "help": "Which senders may trigger automatic printing", + }, + { + "config_key": "message_template", + "label": "Message template: ", + "type": "edit", + "placeholder": "Path to template file", + "help": "Template file used when printing messages", + }, + ], + }, + { + "key": "reticulum", + "name": "Reticulum (global)", + "glyph": "reticulum", + "configfile": "reticulum", + "description": "Global Reticulum settings (shared instance, transport)", + "fields": [ + { + "config_key": "enable_transport", + "label": "Enable transport: ", + "type": "checkbox", + "default": False, + "help": "Route traffic for other peers (transport node)", + }, + { + "config_key": "share_instance", + "label": "Share instance: ", + "type": "checkbox", + "default": True, + "help": "Run a shared instance other local programs connect to", + }, + { + "config_key": "instance_name", + "label": "Instance name: ", + "type": "edit", + "default": "default", + "placeholder": "default", + "help": "Name used to isolate multiple shared instances", + }, + { + "config_key": "shared_instance_type", + "label": "Instance type: ", + "type": "edit", + "placeholder": "unix or tcp (blank for auto)", + "help": "Transport used for shared instance communication", + }, + { + "config_key": "shared_instance_port", + "label": "Instance port: ", + "type": "edit", + "placeholder": "37428", + "validation": ["number"], + "help": "TCP port for the shared instance", + }, + { + "config_key": "instance_control_port", + "label": "Control port: ", + "type": "edit", + "placeholder": "37429", + "validation": ["number"], + "help": "TCP control port for the shared instance", + }, + { + "config_key": "discover_interfaces", + "label": "Discover interfaces: ", + "type": "checkbox", + "default": False, + "help": "Discover interfaces advertised by transport instances", + }, + { + "config_key": "enable_remote_management", + "label": "Remote management: ", + "type": "checkbox", + "default": False, + "help": "Allow remote management of this instance", + }, + { + "config_key": "remote_management_allowed", + "label": "Remote allowed: ", + "type": "list", + "default": [], + "placeholder": "Identity hash", + "help": "Identity hashes allowed to manage this instance", + }, + { + "config_key": "respond_to_probes", + "label": "Respond to probes: ", + "type": "checkbox", + "default": False, + "help": "Answer reachability probes from other nodes", + }, + { + "config_key": "use_implicit_proof", + "label": "Implicit proof: ", + "type": "checkbox", + "default": True, + "help": "Use implicit proofs for packet delivery", + }, + { + "config_key": "panic_on_interface_error", + "label": "Panic on iface error: ", + "type": "checkbox", + "default": False, + "help": "Exit if an interface has an unrecoverable error", + }, + ], + }, +] + +_SECTION_ORDER = {"node": 0, "client": 1, "logging": 2, "textui": 3, "utilities": 4, "rrc": 5, "printing": 6, "reticulum": 7} +CONFIG_SECTIONS.sort(key=lambda s: _SECTION_ORDER.get(s["key"], 99)) + class ConfigDisplayShortcuts(): def __init__(self, app): - import urwid self.app = app + self.default_shortcuts = "[Enter] Edit Section [C-w] Text Editor [Up/Down] Navigate" + self.widget = urwid.AttrMap(urwid.Text(self.default_shortcuts), "shortcutbar") - self.widget = urwid.AttrMap(urwid.Text(""), "shortcutbar") + def update_shortcuts(self, new_shortcuts): + self.widget.original_widget.set_text(new_shortcuts) + + def reset_shortcuts(self): + self.update_shortcuts(self.default_shortcuts) + + def set_section_shortcuts(self): + self.update_shortcuts("[Up/Down] Navigate Fields [Enter] Select Option [C-s] Save [C-r] Reset [Esc] Cancel") class ConfigFiller(urwid.WidgetWrap): def __init__(self, widget, app): self.app = app - self.filler = urwid.Filler(widget, urwid.TOP) - super().__init__(self.filler) + super().__init__(widget) + def keypress(self, size, key): + if key == "ctrl w": + self.app.ui.main_display.sub_displays.config_display.open_text_editor() + return None + return super(ConfigFiller, self).keypress(size, key) + +class SelectableConfigSection(urwid.WidgetWrap): + def __init__(self, parent, section_key, name, description, icon, note=None): + self.parent = parent + self.section_key = section_key + self.name = name + self.icon = icon + self._selectable = True + + self.title_attr = section_title_attr(section_key) + self.title_attr_selected = self.title_attr + "_selected" + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text((self.title_attr, f"{icon} {name}")) + + title_content = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + pile_rows = [title_content, description_row] + if note: + pile_rows.append(urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("inactive_text", note)), + ])) + + pile = urwid.Pile(pile_rows) + padded_body = urwid.Padding(pile, left=2, right=2) + + box = urwid.LineBox( + padded_body, + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + + if focus: + self.title_widget.set_text((self.title_attr_selected, f"{self.icon} {self.name}")) + else: + self.title_widget.set_text((self.title_attr, f"{self.icon} {self.name}")) + + return super().render(size, focus=focus) def keypress(self, size, key): if key == "up": - self.app.ui.main_display.frame.focus_position = "header" - - return super(ConfigFiller, self).keypress(size, key) + listbox = self.parent.list_box + if listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.parent.switch_to_section(self.section_key) + return None + return key + +class ConfigActionItem(urwid.WidgetWrap): + def __init__(self, parent, label, on_select): + self.parent = parent + self.on_select = on_select + self._selectable = True + + text_widget = urwid.Text(label, align="left") + super().__init__(urwid.AttrMap(text_widget, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if key == "enter": + self.on_select() + return None + return super().keypress(size, key) + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.on_select() + return True + return False + +class ConfigSectionView(urwid.WidgetWrap): + LABEL_WIDTH = 24 + + def __init__(self, parent, section): + self.parent = parent + self.section = section + self.section_key = section["key"] + self.fields = {} + + self.parent.shortcuts_display.set_section_shortcuts() + + self.config_path = self._resolve_config_path(section) + self.file_config = ConfigObj(self.config_path) + section_config = self.file_config.get(self.section_key, {}) + + cols, rows = _get_cols_rows() + self.content_width = min(80, max(40, cols - 6)) + + icon = _get_section_icon(self.parent.glyphset, section["glyph"]) + + field_items = [] + for field in section["fields"]: + widget = self._build_field_widget(field) + current = section_config.get(field["config_key"], field.get("default")) + self._populate_field(widget, field, current) + self.fields[field["config_key"]] = {"field": field, "widget": widget} + field_items.append(self._build_field_row(field, widget)) + + self.list_walker = urwid.SimpleFocusListWalker(field_items) + self.form_listbox = urwid.ListBox(self.list_walker) + + header = urwid.Pile([ + urwid.Text((section_title_attr(self.section_key) + "_selected", f"{icon} {section['name']}"), align=urwid.CENTER), + urwid.Divider("─"), + ]) + + save_btn = urwid.AttrMap(urwid.Button("Save", on_press=self.on_save), "button_normal", focus_map="button_focus") + reset_btn = urwid.AttrMap(urwid.Button("Reset", on_press=self.on_reset), "button_normal", focus_map="button_focus") + back_btn = urwid.AttrMap(urwid.Button("Cancel", on_press=self.on_back), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, save_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, reset_btn), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, back_btn), + ]) + self._footer = urwid.Pile([ + urwid.Divider("─"), + urwid.Padding(button_row, align=urwid.CENTER, width=self.content_width), + ]) + try: + self._footer.focus_position = 1 + except Exception: + pass + + self._frame = urwid.Frame(self.form_listbox, header=header, footer=self._footer) + super().__init__(self._frame) + + def _resolve_config_path(self, section): + if section.get("configfile") == "reticulum": + try: + return self.parent.app.rns.config.filename + except Exception: + return RNS.Reticulum.configpath + return self.parent.app.configpath + + def _build_field_row(self, field, widget): + row = urwid.Columns([ + (self.LABEL_WIDTH, urwid.Text(("key", field["label"]), align=urwid.RIGHT)), + widget, + ]) + contents = [row] + help_text = self._field_help(field) + if help_text: + contents.append(urwid.Padding(urwid.Text(("inactive_text", help_text)), left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Padding(widget.error_widget, left=self.LABEL_WIDTH + 2)) + contents.append(urwid.Divider()) + field_pile = urwid.Pile(contents) + return urwid.Padding(field_pile, align=urwid.CENTER, width=self.content_width) + + def _field_help(self, field): + parts = [] + if field.get("help"): + parts.append(field["help"]) + default = field.get("default", None) + if field["type"] == "checkbox": + default_text = "yes" if default else "no" + elif field["type"] == "list": + if not default: + default_text = "none" + elif isinstance(default, (list, tuple)): + default_text = ", ".join(str(item) for item in default) + else: + default_text = str(default) + else: + default_text = str(default) if default not in (None, "") else "none" + parts.append(f"(default: {default_text})") + return " ".join(parts) + + def _build_field_widget(self, field): + if field["type"] == "dropdown": + return FormDropdown( + config_key=field["config_key"], + label=field.get("label", ""), + options=field["options"], + default=str(field.get("default", field["options"][0])), + validation_types=field.get("validation", []), + ) + elif field["type"] == "checkbox": + return ConfigCheckbox( + config_key=field["config_key"], + on_text=field.get("on_text", "Enabled"), + off_text=field.get("off_text", "Disabled"), + state=bool(field.get("default", False)), + validation_types=field.get("validation", []), + ) + elif field["type"] == "list": + return FormMultiList( + config_key=field["config_key"], + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + else: + return FormEdit( + config_key=field["config_key"], + caption="", + edit_text=str(field.get("default", "")), + placeholder=field.get("placeholder", ""), + validation_types=field.get("validation", []), + ) + + def _populate_field(self, widget, field, value): + if value is None: + return + + if field["type"] == "checkbox": + if isinstance(value, bool): + state = value + else: + s = str(value).strip().lower() + if s in ("", "false", "off", "no"): + state = False + else: + try: + state = float(s) != 0 + except ValueError: + state = True + widget.set_state(state) + elif field["type"] == "dropdown": + str_value = str(value) + if str_value in widget.options: + widget.selected = str_value + widget.main_button.base_widget.set_text(str_value) + elif field["type"] == "list": + if value: + widget.set_value(value if isinstance(value, list) else [value]) + else: + if isinstance(value, (list, tuple)): + widget.edit_text = ", ".join(str(item) for item in value) + else: + widget.edit_text = str(value) + + def validate_all(self): + all_valid = True + for entry in self.fields.values(): + if not entry["widget"].validate(): + all_valid = False + return all_valid + + def _widget_write_value(self, field, widget): + if field["type"] == "checkbox": + return field.get("on_value", "yes") if widget.get_state() else field.get("off_value", "no") + return widget.get_value() + + def _default_write_value(self, field): + default = field.get("default") + if field["type"] == "checkbox": + return field.get("on_value", "yes") if bool(default) else field.get("off_value", "no") + if default is None: + return "" + if isinstance(default, (list, tuple)): + return list(default) + return str(default) + + def on_save(self, button): + if not self.validate_all(): + self.show_message("Some fields have invalid values.\nCorrect the marked fields and try again.", title="Not saved", back_to_list=False) + return + + try: + self.file_config.reload() + except Exception: + pass + + section_existed = self.section_key in self.file_config + if not section_existed: + self.file_config[self.section_key] = {} + + section_config = self.file_config[self.section_key] + + for config_key, entry in self.fields.items(): + field = entry["field"] + widget = entry["widget"] + present = config_key in section_config + value = self._widget_write_value(field, widget) + + if not value: + if present: + del section_config[config_key] + elif present or value != self._default_write_value(field): + section_config[config_key] = value + + if not section_existed and not section_config: + del self.file_config[self.section_key] + + try: + write_config_atomic(self.file_config) + if self.section.get("configfile") == "reticulum": + try: + self.parent.app.rns.config.reload() + except Exception: + pass + self.parent.mark_restart_pending() + self.show_message(f"{self.section['name']} configuration saved.\nRestart Nomad Network for changes to take effect.") + except Exception as e: + self.show_message(f"Error saving configuration: {str(e)}", title="Error", back_to_list=False) + + def on_reset(self, button=None): + self._confirm( + f"Reset all {self.section['name']} settings to their defaults?\nYou will still need to Save to apply.", + self._do_reset, + ) + + def _do_reset(self): + for entry in self.fields.values(): + self._reset_field(entry["widget"], entry["field"]) + + def _reset_field(self, widget, field): + default = field.get("default") + if field["type"] == "checkbox": + widget.set_state(bool(default)) + elif field["type"] == "dropdown": + default_str = str(default if default is not None else widget.options[0]) + if default_str in widget.options: + widget.selected = default_str + widget.main_button.base_widget.set_text(default_str) + elif field["type"] == "list": + widget.set_value(default if isinstance(default, list) else []) + else: + widget.edit_text = "" if default is None else str(default) + widget.error_widget.set_text("") + + def on_back(self, button): + self.parent.switch_to_list() + + def dismiss_dialog(self): + self._dismiss_overlay() + + def _confirm(self, message, on_yes): + def yes(button): + self._dismiss_overlay() + on_yes() + + def no(button): + self._dismiss_overlay() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]), + parent=self, + title="Confirm", + ) + self._show_overlay(dialog, height=9, width=64) + + def _show_overlay(self, dialog, height=8, width=60): + overlay = urwid.Overlay( + dialog, + self, + align=urwid.CENTER, + width=width, + valign=urwid.MIDDLE, + height=height, + min_width=1, + min_height=1, + ) + self.parent.widget = overlay + self.parent.app.ui.main_display.update_active_sub_display() + + def _dismiss_overlay(self): + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() + + def show_message(self, message, title="Notice", back_to_list=True): + def dismiss_dialog(button=None): + if back_to_list: + self.parent.switch_to_list() + else: + self._dismiss_overlay() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + parent=self, + title=title + ) + + self._show_overlay(dialog, height=10, width=60) + + def keypress(self, size, key): + if key == "ctrl s": + self.on_save(None) + return None + if key == "ctrl r": + self.on_reset(None) + return None + if key == "esc": + self.parent.switch_to_list() + return None + + result = super().keypress(size, key) + if result is None: + return None + + if result in ("down", "tab") and self._frame.focus_position != "footer": + self._frame.focus_position = "footer" + try: + self._footer.focus_position = 1 + except Exception: + pass + return None + if result == "up": + if self._frame.focus_position == "footer": + self._frame.focus_position = "body" + else: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + + return result class ConfigDisplay(): def __init__(self, app): - import urwid self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.restart_pending = False + self.editor_term = None - def open_editor(sender): - self.editor_term = EditorTerminal(self.app, self) - self.widget = urwid.LineBox(self.editor_term) - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.frame.focus_position = "body" - self.editor_term.term.change_focus(True) - - pile = urwid.Pile([ - urwid.Text( - ( - "body_text", - "\nTo change the configuration, edit the config file located at:\n\n" - +self.app.configpath - +"\n\nRestart Nomad Network for changes to take effect\n", - ), - align=urwid.CENTER, - ), - urwid.Padding(urwid.Button("Open Editor", on_press=open_editor), width=15, align=urwid.CENTER), - ]) - - self.config_explainer = ConfigFiller(pile, self.app) self.shortcuts_display = ConfigDisplayShortcuts(self.app) - self.widget = self.config_explainer + + self._build_list_view() + self.widget = self.list_view + + def _build_list_view(self): + self.section_items = [] + for section in CONFIG_SECTIONS: + icon = _get_section_icon(self.glyphset, section["glyph"]) + self.section_items.append( + SelectableConfigSection(self, section["key"], section["name"], section["description"], icon, note=section.get("note")) + ) + + editor_icon = _get_section_icon(self.glyphset, "editor") + self.editor_item = ConfigActionItem(self, f"{editor_icon} Open config file in text editor", self.open_text_editor) + + list_contents = list(self.section_items) + [urwid.Divider(), self.editor_item] + self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_box = urwid.ListBox(self.list_walker) + + self.header_text = urwid.Text("") + self.list_divider = urwid.Divider("─") + self.list_pile = urwid.Pile([('weight', 1, self.list_box)]) + self._update_header() + + self.list_view = ConfigFiller(self.list_pile, self.app) + + def _update_header(self): + contents = [] + if self.restart_pending: + self.header_text.set_text(("warning_text", "Changes saved. Restart NomadNet for them to take effect")) + contents.append((self.header_text, self.list_pile.options('pack'))) + contents.append((self.list_divider, self.list_pile.options('pack'))) + contents.append((self.list_box, self.list_pile.options('weight', 1))) + self.list_pile.contents = contents + + def mark_restart_pending(self): + self.restart_pending = True + self._update_header() + + def switch_to_section(self, section_key): + section = next(s for s in CONFIG_SECTIONS if s["key"] == section_key) + self.section_view = ConfigSectionView(self, section) + self.widget = self.section_view + self.app.ui.main_display.update_active_sub_display() + + def switch_to_list(self): + self.shortcuts_display.reset_shortcuts() + self.editor_term = None + self._update_header() + self.widget = self.list_view + self.app.ui.main_display.update_active_sub_display() + + def open_text_editor(self): + self.editor_term = EditorTerminal(self.app, self) + self.widget = urwid.LineBox(self.editor_term) + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.editor_term.term.change_focus(True) def shortcuts(self): return self.shortcuts_display @@ -72,9 +1245,7 @@ class EditorTerminal(urwid.WidgetWrap): ) def quit_term(*args, **kwargs): - self.parent.widget = self.parent.config_explainer - self.app.ui.main_display.update_active_sub_display() - self.app.ui.main_display.show_config(None) + self.parent.switch_to_list() self.app.ui.main_display.request_redraw() urwid.connect_signal(self.term, 'closed', quit_term) @@ -83,7 +1254,4 @@ class EditorTerminal(urwid.WidgetWrap): def keypress(self, size, key): - # TODO: Decide whether there should be a way to get out while editing - #if key == "up": - # nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - return super(EditorTerminal, self).keypress(size, key) \ No newline at end of file + return super(EditorTerminal, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index bd71d3b..dec5fa6 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -13,6 +13,7 @@ from datetime import datetime, timedelta from nomadnet.Directory import DirectoryEntry from LXMF import pn_announce_data_is_valid, PN_META_NAME from nomadnet.Conversation import ConversationMessage +from .FileBrowser import FileBrowser from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name @@ -20,7 +21,7 @@ from nomadnet.util import sanitize_name from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps -from .Helpers import ClickableIcon, osc52_copy +from .Helpers import ClickableIcon, osc52_copy, qr_ascii from .ReadlineEdit import ReadlineMixin, ReadlineEdit from nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags from nomadnet.ui import THEME_DARK, THEME_LIGHT @@ -71,13 +72,13 @@ class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-v] Delivery [C-r] Details [C-s] Save [Tab] ↑ Messages"), "shortcutbar") class ConversationBodyShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-r] Details [C-v] Delivery [C-o] Sort [C-s] Save [C-u] Purge [C-k] Cancel Send [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") class TabButton(urwid.Button): button_left = urwid.Text("[") @@ -572,9 +573,10 @@ class ConversationsDisplay(): def delete_selected_conversation(self): self.dialog_open = True item = self.ilb.get_selected_item() - if item == None: + source_hash = getattr(item, "source_hash", None) if item is not None else None + if source_hash is None: + self.dialog_open = False return - source_hash = item.source_hash def dismiss_dialog(sender): self.dialog_open = False @@ -650,22 +652,7 @@ class ConversationsDisplay(): self.show_qr_dialog(addr, title=display) def show_qr_dialog(self, data, title=None): - qr_text = None - try: - import qrcode - try: - qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) - qr.add_data(data) - qr.make() - import io - buf = io.StringIO() - qr.print_ascii(out=buf, invert=False) - qr_text = buf.getvalue().rstrip("\n") - except Exception as e: - RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) - qr_text = None - except Exception: - qr_text = None + qr_text = qr_ascii(data) def dismiss(_b): self._restore_listbox_pane() @@ -864,8 +851,9 @@ class ConversationsDisplay(): unknown_selected = True trusted_selected = False - direct_selected = True - propagated_selected = False + direct_selected = True + propagated_selected = False + opportunistic_selected = False pinned_initial = False notes_initial = "" @@ -887,9 +875,13 @@ class ConversationsDisplay(): unknown_selected = False trusted_selected = True - if self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) == DirectoryEntry.PROPAGATED: + preferred = self.app.directory.preferred_delivery(bytes.fromhex(source_hash_text)) + if preferred == DirectoryEntry.PROPAGATED: direct_selected = False propagated_selected = True + elif preferred == DirectoryEntry.OPPORTUNISTIC: + direct_selected = False + opportunistic_selected = True pinned_initial = existing_entry.sort_rank is not None notes_initial = getattr(existing_entry, "notes", "") or "" @@ -906,8 +898,9 @@ class ConversationsDisplay(): r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) method_button_group = [] - r_direct = urwid.RadioButton(method_button_group, "Deliver directly", state=direct_selected) - r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) + r_direct = urwid.RadioButton(method_button_group, "Deliver over direct link", state=direct_selected) + r_opportunistic = urwid.RadioButton(method_button_group, "Deliver opportunistically", state=opportunistic_selected) + r_propagated = urwid.RadioButton(method_button_group, "Use propagation nodes", state=propagated_selected) def dismiss_dialog(sender): self.dialog_open = False @@ -926,6 +919,8 @@ class ConversationsDisplay(): delivery = DirectoryEntry.DIRECT if r_propagated.state == True: delivery = DirectoryEntry.PROPAGATED + elif r_opportunistic.state == True: + delivery = DirectoryEntry.OPPORTUNISTIC sort_rank = 0 if cb_pin.state else None notes_value = e_notes.get_edit_text() @@ -997,6 +992,7 @@ class ConversationsDisplay(): r_trusted, urwid.Divider(g["divider1"]), r_direct, + r_opportunistic, r_propagated, urwid.Divider(g["divider1"]), cb_pin, @@ -1062,7 +1058,7 @@ class ConversationsDisplay(): elif r_trusted.state == True: trust_level = DirectoryEntry.TRUSTED - if not source_hash in [c[0] for c in existing_conversations]: + if not source_hash_text in [c[0] for c in existing_conversations]: entry = DirectoryEntry(source_hash, display_name, trust_level) self.app.directory.remember(entry) @@ -1073,7 +1069,7 @@ class ConversationsDisplay(): if trust_level != DirectoryEntry.TRUSTED: if self.list_filter != ConversationsDisplay.LIST_FILTER_UNTRUSTED: self._set_filter(ConversationsDisplay.LIST_FILTER_UNTRUSTED) - self.display_conversation(source_hash_text) + self.display_conversation(None, source_hash_text) self.dialog_open = False self.update_conversation_list() @@ -1620,7 +1616,7 @@ class ConversationsDisplay(): if selected_hash is not None: for idx, widget in enumerate(self.list_widgets): - if widget.source_hash == selected_hash: + if getattr(widget, "source_hash", None) == selected_hash: self.ilb.select_item(idx) break nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() @@ -1657,7 +1653,7 @@ class ConversationsDisplay(): conversation_position = None index = 0 for widget in self.list_widgets: - if widget.source_hash == source_hash: + if getattr(widget, "source_hash", None) == source_hash: conversation_position = index index += 1 @@ -1815,6 +1811,24 @@ class ListEntry(urwid.Text): self._emit('click') return True +LXM_STATE_NAMES = { + LXMF.LXMessage.GENERATING: "Generating", + LXMF.LXMessage.OUTBOUND: "Outbound", + LXMF.LXMessage.SENDING: "Sending", + LXMF.LXMessage.SENT: "Sent", + LXMF.LXMessage.DELIVERED: "Delivered", + LXMF.LXMessage.REJECTED: "Rejected", + LXMF.LXMessage.CANCELLED: "Cancelled", + LXMF.LXMessage.FAILED: "Failed", +} + +LXM_METHOD_NAMES = { + LXMF.LXMessage.OPPORTUNISTIC: "Opportunistic", + LXMF.LXMessage.DIRECT: "Direct link", + LXMF.LXMessage.PROPAGATED: "Propagation node", + LXMF.LXMessage.PAPER: "Paper", +} + class MessageEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "ctrl d": @@ -1825,6 +1839,13 @@ class MessageEdit(ReadlineMixin, urwid.Edit): self.delegate.attach_file() elif key == "ctrl s": self.delegate.save_focused_attachments() + elif key == "ctrl v": + self.delegate.cycle_delivery_method() + elif key == "ctrl r": + self.delegate.toggle_message_details() + elif key == "ctrl k": + if not self.delegate.cancel_pending_send(): + return super(MessageEdit, self).keypress(size, key) elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -1883,6 +1904,8 @@ class ConversationFrame(urwid.Frame): return super(ConversationFrame, self).keypress(size, key) class ConversationWidget(urwid.WidgetWrap): + MAX_ATTACHMENT_SIZE = 100 * 1024 * 1024 + def __init__(self, source_hash, delegate): self.app = nomadnet.NomadNetworkApp.get_shared_instance() g = self.app.ui.glyphs @@ -1902,6 +1925,7 @@ class ConversationWidget(urwid.WidgetWrap): self.pending_attachments = [] self.dialog_active = False self.editor_allowed = None + self.show_message_details = False self.update_message_widgets() @@ -2122,9 +2146,18 @@ class ConversationWidget(urwid.WidgetWrap): else: hops_str = str(hops)+" hop" + ("s" if hops != 1 else "") + preferred = self.app.directory.preferred_delivery(source_hash_bytes) + if preferred == DirectoryEntry.PROPAGATED: + via_str = "Propagation" + elif preferred == DirectoryEntry.OPPORTUNISTIC: + via_str = "Opportunistic" + else: + via_str = "Link" + right_parts = [] if stamp_cost is not None: right_parts.append("Stamp: "+str(stamp_cost)) + right_parts.append("Via: "+via_str) right_parts.append(g["speed"]+hops_str) left = " "+display_name @@ -2203,6 +2236,7 @@ class ConversationWidget(urwid.WidgetWrap): return self.editor_allowed = allowed if allowed: + self.conversation.ensure_send_destination() self.frame.contents["footer"] = (self._build_footer(), None) else: warning = urwid.AttrMap( @@ -2217,6 +2251,34 @@ class ConversationWidget(urwid.WidgetWrap): "msg_header_caution", ) self.frame.contents["footer"] = (warning, None) + self._schedule_editor_recheck() + + def _schedule_editor_recheck(self): + if getattr(self, "_editor_recheck_pending", False): + return + try: + loop = nomadnet.NomadNetworkApp.get_shared_instance().ui.loop + if loop is None: + return + self._editor_recheck_pending = True + loop.set_alarm_in(1.0, self._editor_recheck) + except Exception: + self._editor_recheck_pending = False + + def _editor_recheck(self, loop=None, user_data=None): + self._editor_recheck_pending = False + if not self.frame: + return + if getattr(self.delegate, "currently_displayed_conversation", None) != self.source_hash: + return + if nomadnet.NomadNetworkApp.get_shared_instance().directory.is_known(bytes.fromhex(self.source_hash)): + self.check_editor_allowed() + try: + loop.draw_screen() + except Exception: + pass + else: + self._schedule_editor_recheck() def toggle_focus_area(self): name = "" @@ -2242,6 +2304,8 @@ class ConversationWidget(urwid.WidgetWrap): elif key == "ctrl u": self.conversation.purge_failed() self.conversation_changed(None) + elif key == "ctrl k": + self.cancel_pending_send() elif key == "ctrl t": self.toggle_editor() elif key == "ctrl x": @@ -2255,9 +2319,61 @@ class ConversationWidget(urwid.WidgetWrap): self.attach_file() elif key == "ctrl s": self.save_focused_attachments() + elif key == "ctrl v": + self.cycle_delivery_method() + elif key == "ctrl r": + self.toggle_message_details() else: return key + def toggle_message_details(self): + self.show_message_details = not self.show_message_details + self.update_message_widgets(replace=True) + + def _message_is_pending(self, conv_message): + try: + src = conv_message._cached_source_hash + state = conv_message.get_state() + except Exception: + return False + is_outbound = src is not None and self.app.lxmf_destination.hash == src + return is_outbound and state is not None and state < LXMF.LXMessage.SENT + + def cancel_message(self, conv_message): + if conv_message is None: + return + try: + self.app.message_router.cancel_outbound(conv_message.get_hash()) + conv_message._cached_state = LXMF.LXMessage.FAILED + except Exception as e: + RNS.log("Could not cancel outbound message: "+str(e), RNS.LOG_ERROR) + self.conversation_changed(None) + + def cancel_pending_send(self): + for widget in reversed(self.message_widgets): + conv_message = getattr(widget, "conv_message", None) + if conv_message is not None and self._message_is_pending(conv_message): + self.cancel_message(conv_message) + return True + return False + + def cycle_delivery_method(self): + source_hash_bytes = bytes.fromhex(self.source_hash) + order = [DirectoryEntry.DIRECT, DirectoryEntry.OPPORTUNISTIC, DirectoryEntry.PROPAGATED] + current = self.app.directory.preferred_delivery(source_hash_bytes) + try: + selected = order[(order.index(current) + 1) % len(order)] + except ValueError: + selected = order[0] + entry = self.app.directory.find(source_hash_bytes) + if entry is None: + display_name = self.app.directory.display_name(source_hash_bytes) + entry = DirectoryEntry(source_hash_bytes, display_name, DirectoryEntry.UNKNOWN, preferred_delivery=selected) + else: + entry.preferred_delivery = selected + self.app.directory.remember(entry) + self._update_peer_info() + def _on_conversation_changed_from_callback(self, conversation): self.delegate._wake(lambda: self.conversation_changed(conversation)) @@ -2267,6 +2383,14 @@ class ConversationWidget(urwid.WidgetWrap): self.update_message_widgets(replace = True) def update_message_widgets(self, replace = False): + prev_pos = None + prev_count = len(self.message_widgets) if getattr(self, "message_widgets", None) else 0 + if getattr(self, "messagelist", None) is not None: + try: + prev_pos = self.messagelist.get_selected_position() + except Exception: + prev_pos = None + self.message_widgets = [] added_hashes = set() needs_index = [] @@ -2299,10 +2423,19 @@ class ConversationWidget(urwid.WidgetWrap): self.message_widgets.sort(key=lambda m: m.sort_timestamp, reverse=False) from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox - self.messagelist = IndicativeListBox(self.message_widgets, position = len(self.message_widgets)-1) + new_count = len(self.message_widgets) + if prev_pos is None or prev_count == 0 or prev_pos >= prev_count - 1: + position = max(0, new_count - 1) + else: + position = min(prev_pos, max(0, new_count - 1)) + self.messagelist = IndicativeListBox(self.message_widgets, position = position) self.messagelist.name = "messagelist" if replace: - self.frame.contents["body"] = (self.messagelist, None) + current_body = self.frame.contents["body"][0] + if self.dialog_active and isinstance(current_body, urwid.Overlay): + current_body.bottom_w = self.messagelist + else: + self.frame.contents["body"] = (self.messagelist, None) nomadnet.NomadNetworkApp.get_shared_instance().ui.loop.draw_screen() @@ -2452,7 +2585,31 @@ class ConversationWidget(urwid.WidgetWrap): def attach_file(self): self.dialog_active = True - browser = FileBrowserDialog(self) + initial_attachments = list(self.pending_attachments) + + def on_change(selected): + self.pending_attachments[:] = selected + self.frame.contents["footer"] = (self._build_footer(), None) + + def on_done(selected): + self.pending_attachments[:] = selected + self.frame.contents["footer"] = (self._build_footer(), None) + self.file_browser_closed() + + def on_cancel(): + self.pending_attachments[:] = initial_attachments + self.frame.contents["footer"] = (self._build_footer(), None) + self.file_browser_closed() + + browser = FileBrowser( + on_done=on_done, + on_cancel=on_cancel, + on_change=on_change, + title="Attach File", + multi_select=True, + selected=self.pending_attachments, + max_file_size=ConversationWidget.MAX_ATTACHMENT_SIZE, + ) bottom = self.messagelist overlay = urwid.Overlay(browser, bottom, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) self.frame.contents["body"] = (overlay, self.frame.options()) @@ -2595,6 +2752,7 @@ class LXMessageWidget(urwid.WidgetWrap): app = nomadnet.NomadNetworkApp.get_shared_instance() g = app.ui.glyphs self._conversation_widget = conversation_widget + self.conv_message = message self.timestamp = message.get_timestamp() self.sort_timestamp = message.sort_timestamp self.transfer_done = False @@ -2726,12 +2884,58 @@ class LXMessageWidget(urwid.WidgetWrap): pile_widgets = [title] + if conversation_widget is not None and getattr(conversation_widget, "show_message_details", False): + def _safe(fn): + try: return fn() + except Exception: return None + + def _detail_row(glyph, values): + return urwid.Columns([ + (3, urwid.Text(("body_text", glyph), align=urwid.RIGHT)), + urwid.Text(("inactive_text", " · ".join(values))), + ], dividechars=1) + + detail_rows = [] + + delivery = [] + if msg_method is not None: + delivery.append(LXM_METHOD_NAMES.get(msg_method, "Unknown")) + if is_outbound and msg_state is not None: + delivery.append(LXM_STATE_NAMES.get(msg_state, "Unknown")) + if delivery: + detail_rows.append(_detail_row(g.get("sent" if is_outbound else "peer", ">"), delivery)) + + signal = [] + snr = _safe(message.get_snr) + rssi = _safe(message.get_rssi) + q = _safe(message.get_q) + if snr is not None: signal.append("SNR "+str(round(snr, 1))+" dB") + if rssi is not None: signal.append("RSSI "+str(round(rssi, 1))+" dBm") + if q is not None: signal.append("Q "+str(round(q))+"%") + if signal: + detail_rows.append(_detail_row(g.get("speed", "~"), signal)) + + security = [] + sig = _safe(message.get_signature_description) + if sig: security.append(sig) + enc = _safe(message.get_transport_encryption) + if enc: security.append(str(enc)) + if security: + detail_rows.append(_detail_row(g.get("encrypted", "#"), security)) + + if msg_hash is not None: + short_hash = _safe(lambda: RNS.hexrep(msg_hash, delimit=False)) + if short_hash: + detail_rows.append(_detail_row(g.get("info", "i"), [short_hash])) + + if detail_rows: + pile_widgets.append(urwid.Padding(urwid.Pile(detail_rows), left=2, right=2)) + if is_outbound and msg_state is not None and msg_state < LXMF.LXMessage.SENT and msg_hash is not None: try: for pending in app.message_router.pending_outbound: if pending.hash == msg_hash: - if pending.representation == LXMF.LXMessage.RESOURCE: - self._live_lxm = pending + self._live_lxm = pending break except Exception: pass @@ -2745,7 +2949,16 @@ class LXMessageWidget(urwid.WidgetWrap): else: bar = "#" * filled + "-" * (bar_width - filled) self.progress_widget.set_text(" ["+bar+"] "+str(pct)+"%") - pile_widgets.append(self.progress_attr) + if conversation_widget is not None: + cancel_icon = ClickableIcon(" "+g["cross"]+" Cancel ", + on_click=lambda cw=conversation_widget, cm=message: cw.cancel_message(cm)) + progress_row = urwid.Columns([ + ("weight", 1, self.progress_attr), + ("pack", urwid.AttrMap(cancel_icon, "msg_notice_caution")), + ], dividechars=1) + pile_widgets.append(progress_row) + else: + pile_widgets.append(self.progress_attr) self._start_progress_poll() if markdown: pile_widgets.append(content_pile) @@ -2916,188 +3129,6 @@ def _save_attachment_to_disk(filename, data): return save_path -class FileBrowserEntry(urwid.WidgetWrap): - signals = ["click"] - - def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): - self.full_path = full_path - self.name = name - self.is_dir = is_dir - self.is_parent = is_parent - self.selected = selected - g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs - if is_parent: - display = g["arrow_l"]+" .." - elif is_dir: - display = g["arrow_r"]+" "+name+"/" - elif selected: - display = g["check"]+" "+name - else: - display = " "+name - self.text_widget = urwid.SelectableIcon(display, 0) - if is_dir or is_parent: - style = "list_trusted" - focus_style = "list_focus" - elif selected: - style = "list_trusted" - focus_style = "list_focus_trusted" - else: - style = "list_unknown" - focus_style = "list_focus" - display_widget = urwid.AttrMap(self.text_widget, style, focus_style) - super().__init__(display_widget) - - def keypress(self, size, key): - if key == "enter": - self._emit("click") - else: - return key - - def mouse_event(self, size, event, button, x, y, focus): - if button == 1 and urwid.util.is_mouse_press(event): - self._emit("click") - return True - return False - - -class FileBrowserDialog(urwid.WidgetWrap): - def __init__(self, delegate): - self.delegate = delegate - app = nomadnet.NomadNetworkApp.get_shared_instance() - self.g = app.ui.glyphs - self.current_path = os.path.expanduser("~") - - self.path_label = urwid.Text("") - self.status_label = urwid.Text("") - self.file_walker = urwid.SimpleFocusListWalker([]) - self.file_listbox = urwid.ListBox(self.file_walker) - - self.button_columns = urwid.Columns([ - (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), - ]) - - header_pile = urwid.Pile([ - self.path_label, - self.status_label, - urwid.Divider(self.g["divider1"]), - ]) - - footer_pile = urwid.Pile([ - urwid.Divider(self.g["divider1"]), - self.button_columns, - ]) - - self._populate() - - self.browser_frame = urwid.Frame( - self.file_listbox, - header=header_pile, - footer=footer_pile, - ) - - linebox = urwid.LineBox(self.browser_frame, title="Attach File") - super().__init__(linebox) - - def _update_status(self): - pending = self.delegate.pending_attachments - if pending: - names = [os.path.basename(p) for p in pending] - self.status_label.set_text(" "+self.g["file"]+" "+str(len(pending))+" selected: "+", ".join(names)) - else: - self.status_label.set_text(" No files selected") - - def _populate(self): - self.path_label.set_text(" "+self.current_path) - self._update_status() - - focus_pos = None - try: - focus_pos = self.file_listbox.focus_position - except Exception: - pass - - entries = [] - parent = os.path.dirname(self.current_path) - if parent != self.current_path: - entry = FileBrowserEntry("..", parent, is_parent=True) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - try: - items = sorted(os.listdir(self.current_path)) - except PermissionError: - entries.append(urwid.Text(("error_text", " Permission denied"))) - self.file_walker[:] = entries - return - - dirs = [] - files = [] - for item in items: - if item.startswith("."): - continue - full = os.path.join(self.current_path, item) - if os.path.isdir(full): - dirs.append((item, full)) - elif os.path.isfile(full): - files.append((item, full)) - - for name, full in dirs: - entry = FileBrowserEntry(name, full, is_dir=True) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - for name, full in files: - is_selected = full in self.delegate.pending_attachments - entry = FileBrowserEntry(name, full, selected=is_selected) - urwid.connect_signal(entry, "click", self._entry_clicked, entry) - entries.append(entry) - - if not dirs and not files: - entries.append(urwid.Text(("inactive_text", " (empty)"))) - - self.file_walker[:] = entries - if focus_pos is not None and focus_pos < len(entries): - self.file_listbox.set_focus(focus_pos) - elif entries: - self.file_listbox.set_focus(0) - - def _entry_clicked(self, entry_widget, user_data=None): - entry = user_data if user_data else entry_widget - if entry.is_dir or entry.is_parent: - self.current_path = entry.full_path - self._populate() - else: - if entry.full_path in self.delegate.pending_attachments: - self.delegate.pending_attachments.remove(entry.full_path) - else: - self.delegate.pending_attachments.append(entry.full_path) - self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) - self._populate() - - def _dismiss(self, sender): - self.delegate.file_browser_closed() - - def _cancel(self, sender): - self.delegate.pending_attachments.clear() - self.delegate.frame.contents["footer"] = (self.delegate._build_footer(), None) - self.delegate.file_browser_closed() - - def keypress(self, size, key): - if key == "esc": - self.delegate.file_browser_closed() - return - result = super().keypress(size, key) - if result == "down" and self.browser_frame.focus_position == "body": - self.browser_frame.focus_position = "footer" - return - elif result == "up" and self.browser_frame.focus_position == "footer": - self.browser_frame.focus_position = "body" - return - return result - - class SyncProgressBar(urwid.ProgressBar): def get_text(self): status = nomadnet.NomadNetworkApp.get_shared_instance().get_sync_status() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py new file mode 100644 index 0000000..42d8203 --- /dev/null +++ b/nomadnet/ui/textui/Editor.py @@ -0,0 +1,1152 @@ +import os +import json +import shutil +import urwid +import RNS +import nomadnet + +from .ReadlineEdit import ReadlineEdit +from .Browser import Browser +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + +PREVIEW_DELAY = 0.4 +PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38} +PANE_ORDER = ["files", "editor", "preview"] +MAX_HIGHLIGHT_CHARS = 50000 +MAX_PREVIEW_CHARS = 100000 + + + + +SNIPPETS = [ + ("Heading", ">Heading\n"), + ("Sub-heading", ">>Sub heading\n"), + ("Collapsible", "`+>Section\nbody\n<\n"), + ("Bold", "`!bold`!"), + ("Italic", "`*italic`*"), + ("Underline", "`_underline`_"), + ("Foreground col", "`F00ftext`f"), + ("Background col", "`B400text`b"), + ("Link", "`[label`:/page/index.mu]"), + ("Anchor", "`:anchor-name"), + ("Anchor link", "`[label`#anchor-name]"), + ("Input field", "`<24|name`>"), + ("Masked field", "`"), + ("Checkbox", "`"), + ("Radio", "`<^|name|value`>"), + ("Divider", "-\n"), + ("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"), + ("Literal block", "`=\nliteral text\n`=\n"), +] + + + + + + + +EDITOR_STYLES = [ + ("editor_comment", "dark gray", "default", None, "#888", "default"), + ("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"), + ("editor_tag", "light magenta", "default", None, "#c6c", "default"), + ("editor_field", "yellow", "default", None, "#cc0", "default"), + ("editor_link", "light green", "default", None, "#0c6", "default"), + ("editor_dir", "light blue,bold", "default", None, "#39f", "default"), + ("editor_exec", "dark gray", "default", None, "#666", "default"), + ("editor_lineno", "dark gray", "default", None, "#555", "default"), +] + + +def is_executable(path): + try: + return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK) + except Exception: + return False + + +def _scan_tag(text, i, n): + # text[i] == "`"; return (end_index, attr) + if i + 1 >= n: + return i + 1, "editor_tag" + c = text[i + 1] + if c == "<": + j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field" + if c == "[": + j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c == "{": + j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c in "FB": + if i + 2 < n and text[i + 2] == "T": + return min(i + 9, n), "editor_tag" + return min(i + 5, n), "editor_tag" + if c == ":": + j = i + 2 + while j < n and (text[j].isalnum() or text[j] in "_-"): + j += 1 + return j, "editor_tag" + return i + 2, "editor_tag" + + +def highlight_micron(text): + n = len(text) + attrs = [None] * n + i = 0 + line_start = True + while i < n: + c = text[i] + if line_start and c == "#": + j = text.find("\n", i); j = n if j < 0 else j + for k in range(i, j): attrs[k] = "editor_comment" + i = j; continue + if line_start and c == ">": + j = i + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">": + j = i + 2 + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if c == "\n": + i += 1; line_start = True; continue + if c == "\\" and i + 1 < n: + i += 2; line_start = False; continue + if c == "`": + j, attr = _scan_tag(text, i, n) + for k in range(i, min(j, n)): attrs[k] = attr + i = j; line_start = False; continue + i += 1; line_start = False + + out = [] + run_attr = attrs[0] if n else None + run_len = 0 + for a in attrs: + if a == run_attr: + run_len += 1 + else: + + out.append((run_attr, run_len)); run_attr = a; run_len = 1 + if run_len: + out.append((run_attr, run_len)) + return out + + +class MicronEdit(ReadlineEdit): + def __init__(self, *args, **kwargs): + self._hl_text = None + self._hl_attrib = [] + super().__init__(*args, **kwargs) + + def get_text(self): + text = self.caption + self.edit_text + if text != self._hl_text: + self._hl_text = text + self._hl_attrib = [] if len(text) > MAX_HIGHLIGHT_CHARS else highlight_micron(text) + return text, self._hl_attrib + + +class GutterEdit(MicronEdit): + # MicronEdit with a wrap-aware line-number gutter on the left + def _gutter_width(self, maxcol): + gw = max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + if maxcol - gw < 1: + return 0 + return gw + + def rows(self, size, focus=False): + gw = self._gutter_width(size[0]) + return super().rows((max(1, size[0] - gw),), focus) + + def get_cursor_coords(self, size): + gw = self._gutter_width(size[0]) + coords = super().get_cursor_coords((max(1, size[0] - gw),)) + if coords is None: + return None + return coords[0] + gw, coords[1] + + def move_cursor_to_coords(self, size, x, y): + gw = self._gutter_width(size[0]) + if isinstance(x, int): + x = max(0, x - gw) + return super().move_cursor_to_coords((max(1, size[0] - gw),), x, y) + + def render(self, size, focus=False): + maxcol = size[0] + gw = self._gutter_width(maxcol) + if gw == 0: + return super().render(size, focus) + tw = max(1, maxcol - gw) + + cheap = len(self.edit_text) > MAX_HIGHLIGHT_CHARS + wrap = "any" if cheap else "space" + if self.wrap != wrap: + self.set_wrap_mode(wrap) + + edit_canv = super().render((tw,), focus=False) + h = edit_canv.rows() + rows = [] + for i, line in enumerate(self.edit_text.split("\n"), 1): + rows.append(str(i).rjust(gw - 1) + " ") + if cheap: + wrapped = max(1, (len(line) + tw - 1) // tw) + else: + wrapped = max(1, urwid.Text(line).rows((tw,))) + rows.extend([""] * (wrapped - 1)) + rows = (rows + [""] * h)[:h] + + + + + gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,)) + joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)]) + if focus: + coords = super().get_cursor_coords((tw,)) + if coords is not None: + joined.cursor = (coords[0] + gw, coords[1]) + return joined + + def mouse_event(self, size, event, button, x, y, focus): + gw = self._gutter_width(size[0]) + if x < gw: + return True + return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus) + + +class FileEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, executable=False): + self.path = path + self.executable = executable + text = (" " * depth) + "· " + label + (" *" if executable else "") + style = "editor_exec" if executable else None + super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FolderEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, expanded, glyphs): + self.path = path + icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]") + text = (" " * depth) + icon + " " + label + "/" + super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class PathDialog(urwid.WidgetWrap): + def __init__(self, title, default, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=default) + pile = urwid.Pile([ + urwid.Text(title), + urwid.Divider(), + urwid.AttrMap(self.edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + if key == "enter" and self._w.original_widget.original_widget.focus_position == 2: + self.on_ok(self.edit.edit_text); return None + return super().keypress(size, key) + + +class InfoDialog(urwid.WidgetWrap): + def __init__(self, title, message, on_ok): + self.on_ok = on_ok + pile = urwid.Pile([ + urwid.Text(message), + urwid.Divider(), + urwid.Button("OK", on_press=lambda b: on_ok()), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key in ("esc", "enter"): + self.on_ok(); return None + return super().keypress(size, key) + + +class ConfirmDialog(urwid.WidgetWrap): + def __init__(self, title, message, buttons, on_cancel=None): + self.on_cancel = on_cancel + cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons] + pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc" and self.on_cancel: + self.on_cancel(); return None + return super().keypress(size, key) + + +class SnippetDialog(urwid.WidgetWrap): + def __init__(self, on_pick, on_cancel): + self.on_cancel = on_cancel + items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus") + for name, text in SNIPPETS] + super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet")) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class FindDialog(urwid.WidgetWrap): + def __init__(self, ed, on_close): + self.ed = ed + self.on_close = on_close + self.find_edit = ReadlineEdit(caption="Find: ") + self.repl_edit = ReadlineEdit(caption="Replace: ") + pile = urwid.Pile([ + urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"), + urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)), + urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("Close", on_press=lambda b: on_close()), + ], dividechars=1), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace")) + + def keypress(self, size, key): + if key == "esc": + self.on_close(); return None + return super().keypress(size, key) + + +class PermissionsDialog(urwid.WidgetWrap): + def __init__(self, title, note, content, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True) + pile = urwid.Pile([ + urwid.Text(note), + urwid.Divider(), + urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class EditorColumns(urwid.Columns): + def keypress(self, size, key): + ed = self.editor_display + if key == "ctrl s": + ed.save_or_saveas(); return None + if key == "ctrl n": + ed.new_file_dialog(); return None + if key == "ctrl f": + ed.find_dialog(); return None + if key == "ctrl g": + ed.snippet_dialog(); return None + if key == "ctrl p": + ed.permissions_dialog(); return None + if key == "f2" and ed.files_focused(): + ed.rename_dialog(); return None + if key == "delete" and ed.files_focused(): + ed.delete_dialog(); return None + if key == "ctrl t": + ed.toggle_pane("files"); return None + if key == "ctrl b": + ed.toggle_pane("editor"); return None + if key == "ctrl r": + ed.toggle_pane("preview"); return None + if key == "tab": + self.focus_position = (self.focus_position + 1) % len(self.contents); return None + if key == "shift tab": + self.focus_position = (self.focus_position - 1) % len(self.contents); return None + if key == "esc": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + result = super().keypress(size, key) + if key == "up" and result == "up": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + return result + + +class PageEditorShortcuts(): + def __init__(self, app): + self.app = app + self.status = urwid.Text("", align=urwid.RIGHT) + base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu" + self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar") + + def set_status(self, msg): + self.status.set_text(msg) + + +class PageEditorDisplay(): + def __init__(self, app): + self.app = app + self.current_path = None + self.editable = False + self.is_exec = False + self.dirty = False + self.preview_alarm = None + self._loading = False + self._find = None + self._last_file = None + self.expanded = set(p for p in (app.pagespath, app.filespath) if p) + self.filter_mu = False + self.pane_visible = {"files": True, "editor": True, "preview": True} + self.load_state() + self.register_styles() + + self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None) + self.preview_browser.handle_link = self._preview_handle_link + self.preview_browser.marked_link = lambda *a, **k: None + self.preview_browser._content_cols = self._preview_cols + # don't fetch partials in the preview + self.preview_browser.detect_partials = lambda: None + + self.editor = GutterEdit(caption="", edit_text="", multiline=True) + urwid.connect_signal(self.editor, "postchange", self.on_edit_change) + + self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change) + self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries()) + self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus") + self.files_pile = urwid.Pile([ + (urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")), + (urwid.WEIGHT, 1, self.tree_listbox), + ]) + try: self.files_pile.focus_position = 1 + except Exception: pass + + self.pane_widgets = { + "files": urwid.LineBox(self.files_pile, title="Files"), + "editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"), + "preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"), + } + + self.columns = EditorColumns([], dividechars=0) + self.columns.editor_display = self + self._visible = [] + self.rebuild_columns() + + self.shortcuts_display = PageEditorShortcuts(self.app) + self.body = urwid.WidgetPlaceholder(self.columns) + self.widget = self.body + + def register_styles(self): + screen = self.app.ui.screen + for entry in EDITOR_STYLES: + try: screen.register_palette_entry(*entry) + except Exception: pass + + def _preview_handle_link(self, target, link_data=None): + # in the preview follow in page anchors but ignore external navigation + # TODO + + if isinstance(target, str) and target.startswith("#"): + name = target[1:] + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name)) + + def _preview_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + visible = self.visible_panes() + total = sum(PANE_WEIGHTS[n] for n in visible) + frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"] + return max(20, int(cols * frac) - 3) + except Exception: + return 80 + + def shortcuts(self): + return self.shortcuts_display + + def start(self): + self.refresh_tree() + if self._last_file and os.path.isfile(self._last_file) and self.current_path is None: + self.load_file(self._last_file) + self._last_file = None + + def files_focused(self): + try: return self.visible_panes()[self.columns.focus_position] == "files" + except Exception: return False + + def _state_file(self): + d = getattr(self.app, "configdir", None) + return os.path.join(d, "editor_state.json") if d else None + + def load_state(self): + p = self._state_file() + if not p or not os.path.isfile(p): + return + try: + with open(p) as f: + st = json.load(f) + self.filter_mu = bool(st.get("filter_mu", self.filter_mu)) + if isinstance(st.get("expanded"), list): + self.expanded = set(st["expanded"]) + pv = st.get("pane_visible") + if isinstance(pv, dict): + for k in self.pane_visible: + if k in pv: self.pane_visible[k] = bool(pv[k]) + self._last_file = st.get("last_file") + except Exception: + pass + + def save_state(self): + p = self._state_file() + if not p: + return + try: + self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded), + "pane_visible": self.pane_visible, "last_file": self.current_path})) + except Exception: + pass + + def _selected_tree(self): + try: + sel = self.tree_listbox.get_selected_item() + return sel.original_widget if sel else None + except Exception: + return None + + def _roots(self): + return [r for r in (self.app.pagespath, self.app.filespath) if r] + + def _is_root(self, path): + try: + rp = os.path.realpath(path) + except Exception: + return False + return any(rp == os.path.realpath(r) for r in self._roots()) + + def _confine(self, path, base=None): + # Never save outside of the Nomad storage directory + path = (path or "").strip() + if not path: + return None + path = os.path.expanduser(path) + if not os.path.isabs(path): + base = base or self.app.pagespath or self.app.filespath + if not base: + return None + path = os.path.join(base, path) + norm = os.path.normpath(path) + for root in self._roots(): + rp = os.path.normpath(root) + if norm == rp or norm.startswith(rp + os.sep): + return norm + return None + + def _atomic_write(self, path, text): + d = os.path.dirname(path) or "." + tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp") + try: + existing_mode = None + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + def _confirm_overwrite(self, dest, proceed): + self.show_dialog(ConfirmDialog("Overwrite", + "%s already exists.\nOverwrite it?" % os.path.basename(dest), + [("Overwrite", proceed), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def visible_panes(self): + return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"] + + def rebuild_columns(self): + focused = None + try: focused = self._visible[self.columns.focus_position] + except Exception: pass + visible = self.visible_panes() + self.columns.contents = [ + (self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True)) + for n in visible + ] + self._visible = visible + # the rpeview starts non-selectable + target = focused if focused in visible else ("files" if "files" in visible else visible[0]) + try: self.columns.focus_position = visible.index(target) + except Exception: pass + + def toggle_pane(self, name): + visible = self.visible_panes() + if self.pane_visible[name] and len(visible) <= 1: + return + self.pane_visible[name] = not self.pane_visible[name] + self.rebuild_columns() + self.save_state() + + def refresh_tree(self): + focus_path = None + try: + sel = self.tree_listbox.get_selected_item() + if sel is not None: + focus_path = getattr(sel.original_widget, "path", None) + except Exception: + pass + entries = self.build_entries() + # indicativelistbox wraps items in attrmap for highlighting + self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries] + if focus_path is not None: + for i, e in enumerate(entries): + if getattr(e, "path", None) == focus_path: + try: self.tree_listbox.select_item(i) + except Exception: pass + break + + def build_entries(self): + entries = [] + for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)): + if not root or not os.path.isdir(root): + continue + entries.extend(self.walk_dir(label, root, 0)) + if not entries: + entries = [urwid.Text("no files")] + return entries + + def walk_dir(self, name, path, depth): + out = [] + expanded = path in self.expanded + folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs) + urwid.connect_signal(folder, "click", self.on_folder_click) + out.append(folder) + if not expanded: + return out + try: + names = sorted(os.listdir(path), key=str.lower) + except Exception: + return out + dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"] + files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")] + if self.filter_mu: + files = [n for n in files if n.endswith(".mu")] + dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))] + for d in dirs: + out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1)) + for fn in files: + full = os.path.join(path, fn) + entry = FileEntry(fn, full, depth + 1, executable=is_executable(full)) + urwid.connect_signal(entry, "click", self.on_file_click) + out.append(entry) + return out + + def _dir_has_mu(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if any(f.endswith(".mu") for f in files): + return True + return False + + def on_filter_change(self, checkbox, state): + self.filter_mu = state + self.refresh_tree() + self.save_state() + + def on_folder_click(self, folder): + if folder.path in self.expanded: + self.expanded.discard(folder.path) + else: + self.expanded.add(folder.path) + self.refresh_tree() + self.save_state() + + def on_file_click(self, entry): + self.open_path(entry.path) + + def open_path(self, path): + if self.dirty: + self.confirm_discard(lambda: self._do_open(path)) + else: + self._do_open(path) + + def _do_open(self, path): + self.load_file(path) + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + + def confirm_discard(self, proceed): + def save_then(): + self.close_dialog() + if self.current_path and self.editable: + self.save_file(self.current_path) + proceed() + name = os.path.basename(self.current_path) if self.current_path else "this buffer" + if self.current_path and self.editable: + buttons = [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] + else: + buttons = [("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)] + self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name, + buttons, on_cancel=self.close_dialog)) + + def load_file(self, path): + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.editable = True + except UnicodeDecodeError: + content = "" + self.editable = False + except Exception as e: + self.set_status("open failed: %s" % e); return + + self.current_path = path + self.is_exec = is_executable(path) + self.dirty = False + self._loading = True + self.editor.set_edit_text(content) + self.editor.set_edit_pos(0) + self._loading = False + + name = os.path.basename(path) + self.update_title() + self.save_state() + if not self.editable: + self.set_status("binary file (not editable)") + elif self.is_exec: + self.set_preview_note(name + " is an executable page. the node runs it and serves its output.") + self.set_status("executable page") + self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.") + else: + self.render_preview() + self.set_status("opened " + name) + + def update_title(self, modified=False): + + + + if self.current_path is None: + self.pane_widgets["editor"].set_title("Editor"); return + suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "") + if modified: + suffix += " *" + self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix) + + def set_preview_note(self, msg): + self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP) + + def show_info_dialog(self, title, message): + self.show_dialog(InfoDialog(title, message, self.close_dialog)) + + def save_or_saveas(self): + if self.current_path is None: + self.save_as_dialog(); return + self.save_file(self.current_path) + + def save_file(self, path): + if not self.editable and self.current_path is not None: + self.set_status("cannot save binary file"); return + try: + self._atomic_write(path, self.editor.edit_text) + self.current_path = path + self.editable = True + self.dirty = False + self.set_modified(False) + self.set_status("saved " + os.path.basename(path)) + self.refresh_tree() + self.save_state() + except Exception as e: + self.set_status("save failed: %s" % e) + + def new_file_dialog(self): + default = (self.app.pagespath or "") + os.sep + self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog)) + + def _do_new(self, path): + self.close_dialog() + raw = (path or "").strip() + if not raw: + return + is_folder = raw.endswith("/") or raw.endswith(os.sep) + target = self._confine(raw) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + try: + if is_folder: + os.makedirs(target, exist_ok=True) + self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("created folder " + os.path.basename(target)) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + if not os.path.exists(target): + self._atomic_write(target, "") + self.refresh_tree() + self.open_path(target) + except Exception as e: + self.set_status("new failed: %s" % e) + + def rename_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to rename"); return + if self._is_root(w.path): + self.set_status("cannot rename the pages/files root"); return + self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog)) + + def _do_rename(self, old, new): + self.close_dialog() + target = self._confine(new, base=os.path.dirname(old)) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + if target == os.path.normpath(old): + return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + os.rename(old, target) + if os.path.isfile(old + ".allowed"): + try: os.replace(old + ".allowed", target + ".allowed") + except Exception: pass + old_prefix = old + os.sep + if self.current_path == old: + self.current_path = target; self.update_title() + elif self.current_path and self.current_path.startswith(old_prefix): + self.current_path = target + self.current_path[len(old):]; self.update_title() + self.expanded = set( + target if p == old else (target + p[len(old):] if p.startswith(old_prefix) else p) + for p in self.expanded + ) + self.refresh_tree(); self.save_state() + self.set_status("renamed to " + os.path.basename(target)) + except Exception as e: + self.set_status("rename failed: %s" % e) + + if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old): + self._confirm_overwrite(target, proceed) + else: + proceed() + + def delete_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to delete"); return + path = w.path + if self._is_root(path): + self.set_status("cannot delete the pages/files root"); return + self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path), + [("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog)) + + def _do_delete(self, path): + self.close_dialog() + try: + prefix = path + os.sep + if os.path.isdir(path): + shutil.rmtree(path) + self.expanded = set(p for p in self.expanded if p != path and not p.startswith(prefix)) + else: + os.remove(path) + if os.path.isfile(path + ".allowed"): + try: os.remove(path + ".allowed") + except Exception: pass + if self.current_path is not None and (self.current_path == path or self.current_path.startswith(prefix)): + self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False + self._loading = True; self.editor.set_edit_text(""); self._loading = False + self.update_title(); self.set_preview_note("") + self.refresh_tree(); self.save_state() + self.set_status("deleted " + os.path.basename(path)) + except Exception as e: + self.set_status("delete failed: %s" % e) + + def _read_allowed(self, path): + ap = path + ".allowed" + if not os.path.isfile(ap): + return "" + try: + with open(ap, encoding="utf-8") as f: + return "\n".join(ln.strip() for ln in f.read().splitlines() if ln.strip()) + except Exception: + return "" + + def _walk_pages(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".allowed"): + continue + yield os.path.join(root, fn) + + def _folder_permissions(self, path): + bodies = {} + total = 0 + for page in self._walk_pages(path): + total += 1 + body = self._read_allowed(page) + bodies[body] = bodies.get(body, 0) + 1 + restricted = sum(count for body, count in bodies.items() if body) + distinct = [body for body in bodies if body] + common = distinct[0] if len(bodies) == 1 and distinct else None + return common, restricted, total + + def permissions_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder for permissions"); return + path = w.path + is_dir = os.path.isdir(path) + + if is_dir: + common, restricted, total = self._folder_permissions(path) + content = common or "" + if not total: + state = "This folder contains no pages." + elif not restricted: + state = "All %d pages here are public." % total + elif common is not None: + state = "All %d pages here share these entries." % total + else: + state = "Entries differ across pages (%d of %d restricted)." % (restricted, total) + note = ("Allowed identity hashes per line.\n" + + state + "\nSaving applies these entries to every page in this folder and its subfolders.") + else: + content = self._read_allowed(path) + note = "Allowed identity hashes per line. Empty = public.\nApplies to this page." + + title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") + self.show_dialog(PermissionsDialog(title, note, content, + lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog)) + + def _save_permissions(self, path, is_dir, text): + body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip()) + + if is_dir and not body: + restricted = self._folder_permissions(path)[1] + if restricted: + self.show_dialog(ConfirmDialog("Make public", + "This removes the access restrictions from %d page(s)\nunder %s, making them public.\n\nContinue?" + % (restricted, os.path.basename(path)), + [("Make public", lambda: self._apply_permissions(path, is_dir, body)), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + return + + self._apply_permissions(path, is_dir, body) + + def _apply_permissions(self, path, is_dir, body): + self.close_dialog() + try: + if is_dir: + count = 0 + for page in self._walk_pages(path): + self._write_allowed(page, body) + count += 1 + self.set_status(("permissions applied to %d files" if body else "permissions cleared on %d files") % count) + else: + self._write_allowed(path, body) + self.set_status("permissions saved" if body else "permissions cleared (public)") + self.refresh_tree() + except Exception as e: + self.set_status("permissions failed: %s" % e) + + def _write_allowed(self, path, body): + ap = path + ".allowed" + if body: + self._atomic_write(ap, body + "\n") + elif os.path.exists(ap): + os.remove(ap) + + def snippet_dialog(self): + self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog)) + + def _insert_snippet(self, text): + self.close_dialog() + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + pos = self.editor.edit_pos + t = self.editor.edit_text + self.editor.set_edit_text(t[:pos] + text + t[pos:]) + self.editor.set_edit_pos(pos + len(text)) + + def find_dialog(self): + if self._find is None: + self._find = FindDialog(self, self.close_dialog) + self.show_dialog(self._find) + + def find_next(self, term): + if not term: + return + t = self.editor.edit_text + idx = t.find(term, self.editor.edit_pos) + if idx < 0: + idx = t.find(term, 0) + if idx < 0: + self.set_status("'%s' not found" % term); return + self.editor.set_edit_pos(idx + len(term)) + self.set_status("found at %d" % idx) + + def replace_one(self, term, repl): + if not term: + return + t = self.editor.edit_text + end = self.editor.edit_pos + start = end - len(term) + if start >= 0 and t[start:end] == term: + self.editor.set_edit_text(t[:start] + repl + t[end:]) + self.editor.set_edit_pos(start + len(repl)) + self.set_status("replaced") + else: + self.find_next(term) + + def replace_all(self, term, repl): + if not term: + return + cnt = self.editor.edit_text.count(term) + if cnt: + self.editor.set_edit_text(self.editor.edit_text.replace(term, repl)) + self.set_status("replaced %d" % cnt) + else: + self.set_status("'%s' not found" % term) + + def save_as_dialog(self): + default = self.current_path or ((self.app.pagespath or "") + os.sep) + self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog)) + + def _do_saveas(self, path): + self.close_dialog() + target = self._confine(path) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + except Exception: + pass + self.editable = True + self.save_file(target) + + already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path) + if os.path.exists(target) and already: + self._confirm_overwrite(target, proceed) + else: + proceed() + + def show_dialog(self, dialog): + self.dialog = dialog + overlay = urwid.Overlay( + dialog, self.columns, + align=urwid.CENTER, width=(urwid.RELATIVE, 60), + valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40), + min_width=30, min_height=8, + ) + self.body.original_widget = overlay + + def close_dialog(self): + self.dialog = None + self.body.original_widget = self.columns + + def on_edit_change(self, widget, old_text): + if self._loading or (self.current_path is not None and not self.editable): + return + self.dirty = True + self.set_modified(True) + if self.is_exec: + return + if self.preview_alarm is not None: + try: self.app.ui.loop.remove_alarm(self.preview_alarm) + except Exception: pass + self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm) + + def _render_preview_alarm(self, loop, user_data): + self.preview_alarm = None + self.render_preview() + + def render_preview(self): + if len(self.editor.edit_text) > MAX_PREVIEW_CHARS: + self.set_preview_note("File too large to preview (%d KB). Editing still works." % (len(self.editor.edit_text) // 1000)) + return + pos = self._preview_scrollpos() + try: + body = self.preview_browser.render_markup_buffer(self.editor.edit_text) + except Exception as e: + body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP) + self.pane_widgets["preview"].original_widget = body + self._set_preview_scrollpos(pos) + + def _preview_scrollable(self): + try: + return self.preview_browser.browser_body.original_widget.original_widget + except Exception: + return None + + def _preview_scrollpos(self): + s = self._preview_scrollable() + try: return s.get_scrollpos() + except Exception: return None + + def _set_preview_scrollpos(self, pos): + if pos is None: return + s = self._preview_scrollable() + try: s.set_scrollpos(pos) + except Exception: pass + + def set_modified(self, modified): + self.update_title(modified) + + def set_status(self, msg): + self.shortcuts_display.set_status(msg) diff --git a/nomadnet/ui/textui/FileBrowser.py b/nomadnet/ui/textui/FileBrowser.py new file mode 100644 index 0000000..f80a4f6 --- /dev/null +++ b/nomadnet/ui/textui/FileBrowser.py @@ -0,0 +1,234 @@ +import os +import nomadnet +import urwid + +class FileBrowserEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, name, full_path, is_dir=False, is_parent=False, selected=False): + self.full_path = full_path + self.name = name + self.is_dir = is_dir + self.is_parent = is_parent + self.selected = selected + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + if is_parent: + display = g["arrow_l"]+" .." + elif is_dir: + display = g["arrow_r"]+" "+name+"/" + elif selected: + display = g["check"]+" "+name + else: + display = " "+name + self.text_widget = urwid.SelectableIcon(display, 0) + if is_dir or is_parent: + style = "list_trusted" + focus_style = "list_focus" + elif selected: + style = "list_trusted" + focus_style = "list_focus_trusted" + else: + style = "list_unknown" + focus_style = "list_focus" + display_widget = urwid.AttrMap(self.text_widget, style, focus_style) + super().__init__(display_widget) + + def keypress(self, size, key): + if key == "enter": + self._emit("click") + else: + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + +class FileBrowser(urwid.WidgetWrap): + def __init__(self, on_done=None, on_cancel=None, on_change=None, title="Select File", multi_select=True, start_path=None, selected=None, max_file_size=None): + self.on_done_cb = on_done + self.on_cancel_cb = on_cancel + self.on_change_cb = on_change + self.multi_select = multi_select + self.max_file_size = max_file_size + self.selected = list(selected) if selected else [] + + app = nomadnet.NomadNetworkApp.get_shared_instance() + self.g = app.ui.glyphs + self.current_path = os.path.expanduser(start_path) if start_path else os.path.expanduser("~") + + self.path_label = urwid.Text("") + self.status_label = urwid.Text("") + self.file_walker = urwid.SimpleFocusListWalker([]) + self.file_listbox = urwid.ListBox(self.file_walker) + + self.button_columns = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Done", on_press=self._dismiss)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=self._cancel)), + ]) + + header_pile = urwid.Pile([ + self.path_label, + self.status_label, + urwid.Divider(self.g["divider1"]), + ]) + footer_pile = urwid.Pile([ + urwid.Divider(self.g["divider1"]), + self.button_columns, + ]) + + self._populate() + + self.browser_frame = urwid.Frame(self.file_listbox, header=header_pile, footer=footer_pile) + self._main_widget = urwid.LineBox(self.browser_frame, title=title) + self._notice_open = False + super().__init__(self._main_widget) + + def _show_notice(self, message, title="Notice"): + def dismiss(_b=None): + self._notice_open = False + self._w = self._main_widget + + box = urwid.LineBox(urwid.Pile([ + urwid.Text(message, align=urwid.CENTER), + urwid.Divider(), + urwid.Padding(urwid.Button("OK", on_press=dismiss), align=urwid.CENTER, width=8), + ]), title=title) + self._notice_open = True + self._w = urwid.Overlay(box, self._main_widget, align=urwid.CENTER, width=("relative", 75), valign=urwid.MIDDLE, height=urwid.PACK) + + def _dismiss_notice(self): + self._notice_open = False + self._w = self._main_widget + + def _update_status(self): + if self.selected: + names = [os.path.basename(p) for p in self.selected] + self.status_label.set_text(" "+self.g["file"]+" "+str(len(self.selected))+" selected: "+", ".join(names)) + elif self.multi_select: + self.status_label.set_text(" No files selected") + else: + self.status_label.set_text(" Select a file") + + def _populate(self): + self.path_label.set_text(" "+self.current_path) + self._update_status() + + focus_pos = None + try: + focus_pos = self.file_listbox.focus_position + except Exception: + pass + + entries = [] + parent = os.path.dirname(self.current_path) + if parent != self.current_path: + entry = FileBrowserEntry("..", parent, is_parent=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + try: + items = sorted(os.listdir(self.current_path)) + except PermissionError: + entries.append(urwid.Text(("error_text", " Permission denied"))) + self.file_walker[:] = entries + return + except Exception: + entries.append(urwid.Text(("error_text", " Cannot read directory"))) + self.file_walker[:] = entries + return + + dirs = [] + files = [] + for item in items: + if item.startswith("."): + continue + full = os.path.join(self.current_path, item) + if os.path.isdir(full): + dirs.append((item, full)) + elif os.path.isfile(full): + files.append((item, full)) + + for name, full in dirs: + entry = FileBrowserEntry(name, full, is_dir=True) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + for name, full in files: + entry = FileBrowserEntry(name, full, selected=(full in self.selected)) + urwid.connect_signal(entry, "click", self._entry_clicked, entry) + entries.append(entry) + + if not dirs and not files: + entries.append(urwid.Text(("inactive_text", " (empty)"))) + + self.file_walker[:] = entries + if focus_pos is not None and focus_pos < len(entries): + self.file_listbox.set_focus(focus_pos) + elif entries: + self.file_listbox.set_focus(0) + + def _entry_clicked(self, entry_widget, user_data=None): + entry = user_data if user_data else entry_widget + if entry.is_dir or entry.is_parent: + self.current_path = entry.full_path + self._populate() + return + + if self.max_file_size is not None and entry.full_path not in self.selected: + try: + file_size = os.path.getsize(entry.full_path) + except Exception: + file_size = 0 + if file_size > self.max_file_size: + limit_mb = self.max_file_size / (1024 * 1024) + size_mb = file_size / (1024 * 1024) + self._show_notice( + "%s is %.1f MB.\nAttachments are limited to %.0f MB." % (entry.name, size_mb, limit_mb), + title="File too large", + ) + return + + if self.multi_select: + if entry.full_path in self.selected: + self.selected.remove(entry.full_path) + else: + self.selected.append(entry.full_path) + if self.on_change_cb is not None: + self.on_change_cb(list(self.selected)) + self._populate() + else: + self.selected = [entry.full_path] + if self.on_change_cb is not None: + self.on_change_cb(list(self.selected)) + if self.on_done_cb is not None: + self.on_done_cb(list(self.selected)) + + def _dismiss(self, sender): + if self.on_done_cb is not None: + self.on_done_cb(list(self.selected)) + + def _cancel(self, sender): + self.selected = [] + if self.on_cancel_cb is not None: + self.on_cancel_cb() + + def keypress(self, size, key): + if self._notice_open: + if key in ("esc", "enter"): + self._dismiss_notice() + return None + return super().keypress(size, key) + if key == "esc": + self._cancel(None) + return None + result = super().keypress(size, key) + if result == "down" and self.browser_frame.focus_position == "body": + self.browser_frame.focus_position = "footer" + return None + elif result == "up" and self.browser_frame.focus_position == "footer": + self.browser_frame.focus_position = "body" + return None + return result diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4316266..8cdb239 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -571,8 +571,32 @@ To add a new interface, press `!< Ctrl + A >`!. From here you can select which t `Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b +>Interface Profiles + +Interface profiles let you save different combinations of enabled interfaces and switch between them quickly. For example, you might keep a `!Home`! profile that uses a local AutoInterface, and a `!Travel`! profile that reaches out over a TCP gateway instead. + +Press `!< Tab >`! from the `![ Interfaces ]`! section to move between the `!Interfaces`! and `!Profiles`! tabs. + +To save your current set of enabled interfaces as a new profile, press `!< Ctrl + S >`!. To assign an interface to one or more profiles, edit it with `!< Ctrl + E >`! and use the profile checkboxes in the edit form. You can also toggle the selected interface on or off directly with `!< Ctrl + O >`!, and filter the list by All, Enabled or Disabled with `!< Ctrl + F >`!. + +`Ffff`! (!) Note:`! Switching profiles changes which interfaces are enabled, so a restart of NomadNet or your Reticulum instance is required for the change to take effect.`f`b + + >Interface Types +Jump to a specific interface type: + + `F79d`_`[AutoInterface`#autointerface]`_`f + `F79d`_`[TCPClientInterface`#tcpclientinterface]`_`f + `F79d`_`[TCPServerInterface`#tcpserverinterface]`_`f + `F79d`_`[UDPInterface`#udpinterface]`_`f + `F79d`_`[I2PInterface`#i2pinterface]`_`f + `F79d`_`[RNodeInterface`#rnodeinterface]`_`f + `F79d`_`[RNodeMultiInterface`#rnodemultiinterface]`_`f + `F79d`_`[SerialInterface`#serialinterface]`_`f + `F79d`_`[KISSInterface`#kissinterface]`_`f + `F79d`_`[PipeInterface`#pipeinterface]`_`f + >>AutoInterface The Auto Interface enables communication with other discoverable Reticulum nodes over autoconfigured IPv6 and UDP. It does not need any functional IP infrastructure like routers or DHCP servers, but will require at least some sort of switching medium between peers (a wired switch, a hub, a WiFi access point or similar). @@ -1420,6 +1444,7 @@ With micron you can easily create structured documents and pages with formatting `F79d`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f `F79d`_`[Micron Tags`#micron-tags]`_`f `F79d`_`[High Level Stuff`#high-level-stuff]`_`f + `F79d`_`[Collapsible Sections`#collapsible-sections]`_`f `F79d`_`[Colors`#colors]`_`f `F79d`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f `F79d`_`[Links`#links]`_`f @@ -1587,6 +1612,32 @@ A sub sub section. We could continue, but you get the point. Wait! It's worth noting that we can also create sections without headings. They look like this. `` +>Collapsible Sections + +Any section heading can be made collapsible, turning it into an accordion the reader can fold away and reveal on demand. Prefix a heading with \\`+ to make it foldable and open by default, or with \\`- to make it foldable but start collapsed: + +`Faaa +`= +`+>Open by default +This text can be folded away by selecting the heading. + +`->Hidden until expanded +This starts collapsed, and appears when the reader expands it. +`= +`` + +A collapsible heading folds everything beneath it, up to the next heading of the same or a higher level, exactly like a normal section. It can therefore contain anything: text, tables, input fields, sub-sections, and even live partials. + +To fold or unfold a section, move onto its heading and press Enter or Space, or click it with the mouse. Open sections are marked with ▾ and collapsed ones with ▸. + +You can choose your own indicators for a page by placing a #!fold directive near the top, giving the open glyph followed by the collapsed glyph: + +`Faaa +`= +#!fold [-] [+] +`= +`` + >Colors @@ -1781,6 +1832,14 @@ A sized input field: `B444`<16|with_size`>`b `= `` +You can also reserve more than one row for a field by appending `!xROWS`! to the width, which is useful for longer, multi-line input. This creates a field that is 40 columns wide and 5 rows tall: + +`Faaa +`= +A multi-row input field: `B444`<40x5|notes`>`b +`= +`` + It is possible to mask fields, for example for use with passwords and similar: `Faaa @@ -1809,6 +1868,8 @@ A sized input field: `B444`<16|with_size`>`B333 A masked input field: `B444``B333 +A multi-row input field: `B444`<40x5|notes`>`B333 + Full control: `B444``B333 `b diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py index ec0816e..ea97bee 100644 --- a/nomadnet/ui/textui/Helpers.py +++ b/nomadnet/ui/textui/Helpers.py @@ -17,6 +17,21 @@ def osc52_copy(text): return False +def qr_ascii(data): + try: + import qrcode + import io + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + return buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + return None + + class ClickableIcon(urwid.Text): _selectable = False diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index fe7d87b..b3914ef 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -7,6 +7,8 @@ from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * from nomadnet.vendor.AsciiChart import AsciiChart from .ReadlineEdit import ReadlineEdit +from .Helpers import ClickableIcon +from nomadnet.util import write_config_atomic ### GYLPHS ### INTERFACE_GLYPHS = { @@ -73,6 +75,48 @@ def format_bytes(bytes_value): else: return f"{size:.1f} {units[unit_index]}" + +def format_announce_rate(hz): + try: + hz = float(hz or 0) + except (TypeError, ValueError): + return "-" + if hz <= 0: + return "-" + per_hour = hz * 3600 + if per_hour < 100: + return "<1/hr" if per_hour < 1 else "%d/hr" % round(per_hour) + return "%d/min" % round(hz * 60) + +def format_speed(bps): + try: + bps = float(bps or 0) + except (TypeError, ValueError): + return "-" + try: + return RNS.prettyspeed(bps) + except Exception: + return "%d bps" % int(bps) + +def interface_mode_str(mode): + modes = { + 1: "Full", # MODE_FULL + 2: "Point-to-Point", # MODE_POINT_TO_POINT + 3: "Access Point", # MODE_ACCESS_POINT + 4: "Roaming", # MODE_ROAMING + 5: "Boundary", # MODE_BOUNDARY + 6: "Gateway", # MODE_GATEWAY + } + return modes.get(mode, "Full") + +def connection_markup(glyphs, is_connected, label=None): + icon = glyphs.get("connected" if is_connected else "disconnected", "") + if label is None: + label = "Connected" if is_connected else "Disconnected" + attr = "connected_status" if is_connected else "disconnected_status" + sep = " " if icon else "" + return (attr, "%s%s%s" % (icon, sep, label)) + def _get_cols_rows(): return nomadnet.NomadNetworkApp.get_shared_instance().ui.screen.get_cols_rows() @@ -588,7 +632,7 @@ INTERFACE_FIELDS = { "type": "edit", "label": "Target Port: ", "default": "", - "placeholder": "e.g., 8080", + "placeholder": "e.g., 4242", "validation": ["required", "number"], "transform": lambda x: int(x.strip()) if x.strip() else None }, @@ -1123,7 +1167,7 @@ INTERFACE_FIELDS = { ### INTERFACE WIDGETS #### class SelectableInterfaceItem(urwid.WidgetWrap): - def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, tx, rx, icon="?", iface_options=None, profiles_label=None, announce_rate="N/A"): self.parent = parent self._selectable = True self.icon = icon @@ -1136,12 +1180,9 @@ class SelectableInterfaceItem(urwid.WidgetWrap): if is_enabled: enabled_txt = ("connected_status", "Enabled") else: - enabled_txt = ("disconnected_status", "Disabled") + enabled_txt = ("disabled_status", "Disabled") - if is_connected: - connected_txt = ("connected_status", "Connected") - else: - connected_txt = ("disconnected_status", "Disconnected") + connected_txt = connection_markup(parent.g, is_connected) self.selection_txt = urwid.Text(" ") self.title_widget = urwid.Text(("interface_title", f"{icon} {name}")) @@ -1153,33 +1194,50 @@ class SelectableInterfaceItem(urwid.WidgetWrap): self.tx_widget = urwid.Text(("value", format_bytes(tx))) self.rx_widget = urwid.Text(("value", format_bytes(rx))) + self.announce_widget = urwid.Text(("value", announce_rate)) self.status_widget = urwid.Text(enabled_txt) self.connection_widget = urwid.Text(connected_txt) - rows = [ - urwid.Columns([ + if is_enabled: + status_row = urwid.Columns([ (10, urwid.Text(("key", "Status: "))), (10, self.status_widget), (3, urwid.Text(" | ")), self.connection_widget, - ]), + ]) + else: + status_row = urwid.Columns([ + (10, urwid.Text(("key", "Status: "))), + self.status_widget, + ]) + + rows = [ + status_row, urwid.Columns([ (10, urwid.Text(("key", "Type:"))), urwid.Text(("value", iface_type)), ]), - - urwid.Divider("-"), - - urwid.Columns([ - (10, urwid.Text(("key", "TX:"))), - (15, self.tx_widget), - (10, urwid.Text(("key", "RX:"))), - self.rx_widget, - ]), ] + if profiles_label is not None: + rows.append(urwid.Columns([ + (10, urwid.Text(("key", "Profiles:"))), + urwid.Text(("value", profiles_label)), + ])) + + if is_enabled: + rows.append(urwid.Divider("-")) + rows.append(urwid.Columns([ + (5, urwid.Text(("key", "TX:"))), + (14, self.tx_widget), + (5, urwid.Text(("key", "RX:"))), + (14, self.rx_widget), + (12, urwid.Text(("key", "Announces:"))), + self.announce_widget, + ])) + pile_contents = [title_content] + rows pile = urwid.Pile(pile_contents) @@ -1202,7 +1260,7 @@ class SelectableInterfaceItem(urwid.WidgetWrap): if self.is_enabled: self.status_widget.set_text(("connected_status", "Enabled")) else: - self.status_widget.set_text(("disconnected_status", "Disabled")) + self.status_widget.set_text(("disabled_status", "Disabled")) def selectable(self): return True @@ -1235,15 +1293,24 @@ class SelectableInterfaceItem(urwid.WidgetWrap): return None return key - def update_stats(self, tx, rx): + def update_stats(self, tx, rx, announce_rate=None): self.tx_widget.set_text(("value", format_bytes(tx))) self.rx_widget.set_text(("value", format_bytes(rx))) + if announce_rate is not None: + self.announce_widget.set_text(("value", announce_rate)) + + def update_connection(self, is_connected): + if is_connected == self.is_connected: + return + self.is_connected = is_connected + self.connection_widget.set_text(connection_markup(self.parent.g, is_connected)) class InterfaceOptionItem(urwid.WidgetWrap): - def __init__(self, parent_display, label, value): + def __init__(self, parent_display, label, value, on_select=None): self.parent_display = parent_display self.label = label self.value = value + self.on_select = on_select self._selectable = True text_widget = urwid.Text(label, align="left") @@ -1252,13 +1319,25 @@ class InterfaceOptionItem(urwid.WidgetWrap): def selectable(self): return True - def keypress(self, size, key): - if key == "enter": + def _activate(self): + if self.on_select is not None: + self.on_select() + else: self.parent_display.dismiss_dialog() self.parent_display.switch_to_add_interface(self.value) + + def keypress(self, size, key): + if key == "enter": + self._activate() return None return super().keypress(size, key) + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._activate() + return True + return False + class InterfaceBandwidthChart: def __init__(self, history_length=60, glyphset="unicode"): @@ -1395,6 +1474,24 @@ class InterfaceFiller(urwid.WidgetWrap): super().__init__(self.filler) def keypress(self, size, key): + if key == "tab": + self.app.ui.main_display.sub_displays.interface_display.show_profiles() + return None + if key == "ctrl t": + self.app.ui.main_display.sub_displays.interface_display.toggle_view_mode() + return None + if key == "ctrl s": + self.app.ui.main_display.sub_displays.interface_display.save_current_profile() + return None + if key == "ctrl f": + self.app.ui.main_display.sub_displays.interface_display.cycle_filter() + return None + if key == "ctrl o": + self.app.ui.main_display.sub_displays.interface_display.toggle_focused_interface() + return None + if key == "ctrl b": + self.app.ui.main_display.sub_displays.interface_display.show_bulk_actions() + return None if key == "ctrl a": # add interface self.app.ui.main_display.sub_displays.interface_display.add_interface() @@ -1717,6 +1814,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.calculator_button, self.calculator_widget, ]) + pile_items.extend(self._build_profiles_section()) pile_items.extend([ urwid.Divider("─"), button_row, @@ -1724,6 +1822,37 @@ class AddInterfaceView(urwid.WidgetWrap): return pile_items + def _build_profiles_section(self): + self.profile_checkboxes = [] + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None or not profiles.profiles: + return [] + iface_name = getattr(self, "iface_name", None) + current = set() + if iface_name: + current = set(p["id"] for p in profiles.profiles_for(iface_name)) + items = [ + urwid.Divider("─"), + urwid.Text(("key", "Profiles"), align="left"), + urwid.Text(("inactive_text", " Assign this interface to one or more profiles."), align="left"), + ] + for p in profiles.profiles: + cb = urwid.CheckBox(p["name"] or "(unnamed)", state=(p["id"] in current)) + cb.profile_id = p["id"] + self.profile_checkboxes.append(cb) + items.append(urwid.Padding(cb, left=2)) + return items + + def _apply_profile_membership(self, iface_name): + profiles = getattr(self.parent.app, "interface_profiles", None) + if profiles is None: + return + checked = [cb.profile_id for cb in getattr(self, "profile_checkboxes", []) if cb.get_state()] + try: + profiles.set_interface_profiles(iface_name, checked) + except Exception: + pass + def toggle_more_options(self, button): if self.more_options_visible: self.more_options_widget.contents = [] @@ -1938,7 +2067,8 @@ class AddInterfaceView(urwid.WidgetWrap): try: interfaces = self.parent.app.rns.config['interfaces'] interfaces[name] = interface_config - self.parent.app.rns.config.write() + write_config_atomic(self.parent.app.rns.config) + self._apply_profile_membership(name) display_type = custom_type if self.iface_type == "CustomInterface" else self.iface_type @@ -1956,6 +2086,7 @@ class AddInterfaceView(urwid.WidgetWrap): self.parent.interface_items.append(new_item) self.parent._rebuild_list() + self.parent.mark_restart_pending() self.show_message(f"Interface {name} added. Restart NomadNet to start using this interface") @@ -2141,8 +2272,10 @@ class EditInterfaceView(AddInterfaceView): updated_config = { "type": interface_type, - "interface_enabled": True + "interface_enabled": self.interface_config.get("interface_enabled", True), } + if "enabled" in self.interface_config: + updated_config["enabled"] = self.interface_config["enabled"] for field_key, field in self.fields.items(): if field_key not in ["name", "custom_parameters", "type", "subinterfaces"]: @@ -2181,6 +2314,9 @@ class EditInterfaceView(AddInterfaceView): del interfaces[self.iface_name] interfaces[new_name] = updated_config + try: self.parent.app.interface_profiles.rename_interface(self.iface_name, new_name) + except Exception: pass + for i, item in enumerate(self.parent.interface_items): if item.name == self.iface_name: self.parent.interface_items[i].name = new_name @@ -2188,7 +2324,8 @@ class EditInterfaceView(AddInterfaceView): else: interfaces[self.iface_name] = updated_config - self.parent.app.rns.config.write() + write_config_atomic(self.parent.app.rns.config) + self._apply_profile_membership(new_name) display_type = interface_type @@ -2198,6 +2335,7 @@ class EditInterfaceView(AddInterfaceView): break self.parent._rebuild_list() + self.parent.mark_restart_pending() self.show_message(f"Interface {new_name} updated. Restart NomadNet for these changes to take effect") except Exception as e: @@ -2267,42 +2405,41 @@ class ShowInterface(urwid.WidgetWrap): footer = urwid.Pile(footer_content) # status widgets - self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.status_text = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", "Enabled" if self.is_enabled else "Disabled")) - self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disconnected_status", + self.status_indicator = urwid.Text(("connected_status" if self.is_enabled else "disabled_status", self.parent.g['selected'] if self.is_enabled else self.parent.g[ 'unselected'])) - self.connection_text = urwid.Text(("connected_status" if self.is_connected else "disconnected_status", - "Connected" if self.is_connected else "Disconnected")) + self.connection_text = urwid.Text(connection_markup(self.parent.g, self.is_connected)) + + if self.is_enabled: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + (8, self.status_text), + (3, urwid.Text(" | ")), + self.connection_text, + ]) + else: + status_columns = urwid.Columns([ + (10, urwid.Text(("key", "Status:"))), + (4, self.status_indicator), + self.status_text, + ]) self.info_rows = [ urwid.Columns([ (10, urwid.Text(("key", "Type:"))), urwid.Text(("value", f"{_get_interface_icon(self.parent.glyphset, iface_type)} {iface_type}")), ]), - urwid.Columns([ - (10, urwid.Text(("key", "Status:"))), - (4, self.status_indicator), - (8, self.status_text), - (3, urwid.Text(" | ")), - self.connection_text, - ]), + status_columns, urwid.Divider("-") ] - self.tx_text = urwid.Text(("value", format_bytes(self.tx))) - self.rx_text = urwid.Text(("value", format_bytes(self.rx))) - - self.stat_row = urwid.Columns([ - (10, urwid.Text(("key", "TX:"))), - (15, self.tx_text), - (10, urwid.Text(("key", "RX:"))), - self.rx_text, - ]) - - self.info_rows.append(self.stat_row) + for row in self._build_statistics(): + self.info_rows.append(row) self.info_rows.append(urwid.Divider("-")) self.bandwidth_chart = InterfaceBandwidthChart(history_length=self.history_length, glyphset=self.parent.glyphset) @@ -2340,7 +2477,7 @@ class ShowInterface(urwid.WidgetWrap): ]) self.disconnected_message = urwid.Filler( - urwid.Text(("disconnected_status", + urwid.Text(("disabled_status", "Charts not available - Interface is not connected"), align="center"), valign="top" @@ -2476,20 +2613,160 @@ class ShowInterface(urwid.WidgetWrap): super().__init__(self.content_box) + def _stat_rows_spec(self, stats): + # Ordered (row_id, label) pairs that apply to this interface + spec = [("mode", "Mode:")] + if stats.get("bitrate") is not None: + spec.append(("bitrate", "Bitrate:")) + spec.append(("tx", "TX:")) + spec.append(("rx", "RX:")) + if "rxs" in stats and "txs" in stats: + spec.append(("speed", "Speed:")) + if stats.get("incoming_announce_frequency") is not None: + spec.append(("announce", "Announces:")) + if stats.get("incoming_pr_frequency") is not None: + spec.append(("pathreq", "Path Reqs:")) + if stats.get("clients") is not None: + spec.append(("clients", "Clients:")) + if stats.get("peers") is not None: + spec.append(("peers", "Peers:")) + if stats.get("ifac_netname") is not None or stats.get("ifac_signature") is not None: + spec.append(("ifac", "Network:")) + if stats.get("announce_queue") is not None: + spec.append(("queued", "Queued:")) + if "held_announces" in stats: + spec.append(("held", "Held:")) + return spec + + def _radio_rows_spec(self, stats): + spec = [] + if "channel_load_short" in stats and "channel_load_long" in stats: + spec.append(("chload", "Ch. Load:")) + if "airtime_short" in stats and "airtime_long" in stats: + spec.append(("airtime", "Airtime:")) + if "noise_floor" in stats: + spec.append(("noise", "Noise Fl.:")) + if stats.get("battery_percent") is not None: + spec.append(("battery", "Battery:")) + if "cpu_load" in stats or "cpu_temp" in stats: + spec.append(("cpu", "CPU:")) + if "mem_load" in stats: + spec.append(("mem", "Memory:")) + return spec + + def _fmt_pct(self, v): + try: + v = float(v) + except (TypeError, ValueError): + return "0" + if v <= 1.0: + v *= 100.0 + return "%d" % int(round(v)) + + def _fmt_pct_pair(self, short_v, long_v): + return "%s%% (15s), %s%% (1h)" % (self._fmt_pct(short_v), self._fmt_pct(long_v)) + + def _format_stat(self, row_id, stats): + if row_id == "mode": + return interface_mode_str(stats.get("mode")) + if row_id == "bitrate": + return format_speed(stats.get("bitrate")) + if row_id == "tx": + return format_bytes(stats.get("txb", 0)) + if row_id == "rx": + return format_bytes(stats.get("rxb", 0)) + if row_id == "speed": + return "TX %s RX %s" % (format_speed(stats.get("txs", 0)), format_speed(stats.get("rxs", 0))) + if row_id == "announce": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_announce_frequency")), + format_announce_rate(stats.get("outgoing_announce_frequency"))) + if row_id == "pathreq": + return "In %s Out %s" % (format_announce_rate(stats.get("incoming_pr_frequency")), + format_announce_rate(stats.get("outgoing_pr_frequency"))) + if row_id == "clients": + return str(stats.get("clients")) + if row_id == "peers": + return "%s reachable" % stats.get("peers") + if row_id == "ifac": + netname = stats.get("ifac_netname") + size = stats.get("ifac_size") + bits = ("%d-bit IFAC" % (size * 8)) if size else "IFAC" + return ("%s (%s)" % (netname, bits)) if netname else bits + if row_id == "queued": + n = stats.get("announce_queue") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "held": + n = stats.get("held_announces") or 0 + return "%d announce%s" % (n, "" if n == 1 else "s") + if row_id == "chload": + return self._fmt_pct_pair(stats.get("channel_load_short"), stats.get("channel_load_long")) + if row_id == "airtime": + return self._fmt_pct_pair(stats.get("airtime_short"), stats.get("airtime_long")) + if row_id == "noise": + nf = stats.get("noise_floor") + return ("%s dBm" % nf) if nf is not None else "Unknown" + if row_id == "battery": + try: + return "%d%% (%s)" % (int(stats.get("battery_percent")), stats.get("battery_state", "Unknown")) + except (TypeError, ValueError): + return "Unknown" + if row_id == "cpu": + load = stats.get("cpu_load") + temp = stats.get("cpu_temp") + parts = [] + if load is not None: + parts.append("%s%% load" % load) + if temp is not None: + parts.append("%s°C" % temp) + return ", ".join(parts) if parts else "Unknown" + if row_id == "mem": + m = stats.get("mem_load") + return ("%s%%" % m) if m is not None else "Unknown" + return "" + + def _build_statistics(self): + # Builds the live statistics section and records the dynamic widgets in + # self.stat_w so they can be refreshed in place each poll tick. + self.stat_w = {} + + def stat_row(row_id, label): + w = urwid.Text(("value", self._format_stat(row_id, self.stats))) + self.stat_w[row_id] = w + return urwid.Columns([(12, urwid.Text(("key", label))), w]) + + rows = [urwid.Text(("interface_title", "Statistics"), align="left")] + for row_id, label in self._stat_rows_spec(self.stats): + rows.append(stat_row(row_id, label)) + + radio = self._radio_rows_spec(self.stats) + if radio: + rows.append(urwid.Divider("-")) + rows.append(urwid.Text(("interface_title", "Radio"), align="left")) + for row_id, label in radio: + rows.append(stat_row(row_id, label)) + + return rows + + def _refresh_stats(self, stats): + for row_id, w in self.stat_w.items(): + try: + w.set_text(("value", self._format_stat(row_id, stats))) + except Exception: + pass + def update_status_display(self): if self.is_enabled: self.status_indicator.set_text(("connected_status", self.parent.g['selected'])) self.status_text.set_text(("connected_status", "Enabled")) else: - self.status_indicator.set_text(("disconnected_status", self.parent.g['unselected'])) - self.status_text.set_text(("disconnected_status", "Disabled")) + self.status_indicator.set_text(("disabled_status", self.parent.g['unselected'])) + self.status_text.set_text(("disabled_status", "Disabled")) def update_connection_display(self, is_connected): old_connection_state = self.is_connected self.is_connected = is_connected - self.connection_text.set_text(("connected_status" if self.is_connected else "disconnected_status", - "Connected" if self.is_connected else "Disconnected")) + self.connection_text.set_text(connection_markup(self.parent.g, self.is_connected)) if old_connection_state != self.is_connected: body_pile = self.frame.body.body[0].original_widget @@ -2505,86 +2782,39 @@ class ShowInterface(urwid.WidgetWrap): if chart_index is not None: if self.is_connected: new_widget = self.horizontal_charts if self.is_horizontal else self.vertical_charts - if not self.started: - self.start() else: new_widget = self.disconnected_box - self.started = False body_pile.contents[chart_index] = (new_widget, body_pile.options()) def on_toggle_enabled(self, button): - action = "disable" if self.is_enabled else "enable" + self.is_enabled = not self.is_enabled - def on_confirm_yes(confirm_button): - self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget + self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") - self.is_enabled = not self.is_enabled + self.interface_config["interface_enabled"] = self.is_enabled + if "enabled" in self.interface_config: + self.interface_config["enabled"] = self.is_enabled - self.toggle_button.set_label("Disable" if self.is_enabled else "Enable") + try: + interfaces = self.parent.app.rns.config['interfaces'] + interfaces[self.iface_name] = self.interface_config + write_config_atomic(self.parent.app.rns.config) - if "interface_enabled" in self.interface_config: - self.interface_config["interface_enabled"] = self.is_enabled - else: - self.interface_config["enabled"] = self.is_enabled + self.update_status_display() - try: - interfaces = self.parent.app.rns.config['interfaces'] + for item in self.parent.interface_items: + if item.name == self.iface_name: + item.is_enabled = self.is_enabled + item.update_status_display() - interfaces[self.iface_name] = self.interface_config + if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: + self.parent.app.ui.loop.draw_screen() - self.parent.app.rns.config.write() + self.parent.mark_restart_pending() - self.update_status_display() - - for item in self.parent.interface_items: - if item.name == self.iface_name: - item.is_enabled = self.is_enabled - item.update_status_display() - - if hasattr(self.parent.app.ui, 'loop') and self.parent.app.ui.loop is not None: - self.parent.app.ui.loop.draw_screen() - - self.show_restart_required_message() - - except Exception as e: - self.show_error_message(f"Error updating interface: {str(e)}") - - def on_confirm_no(confirm_button): - self.parent.app.ui.main_display.frame.body = self.parent.app.ui.main_display.sub_displays.active().widget - - confirm_text = urwid.Text(( - "interface_title", - f"Are you sure you want to {action} the {self.iface_name} interface?" - ), align="center") - - yes_button = urwid.Button("Yes", on_press=on_confirm_yes) - no_button = urwid.Button("No", on_press=on_confirm_no) - - buttons_row = urwid.Columns([ - (urwid.WEIGHT, 0.45, yes_button), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, no_button), - ]) - - pile = urwid.Pile([ - confirm_text, - urwid.Divider(), - buttons_row - ]) - - dialog = DialogLineBox(pile, title="Confirm") - - overlay = urwid.Overlay( - dialog, - self.parent.app.ui.main_display.frame.body, - align='center', - width=50, - valign='middle', - height=7 - ) - - self.parent.app.ui.main_display.frame.body = overlay + except Exception as e: + self.show_error_message(f"Error updating interface: {str(e)}") def show_restart_required_message(self): @@ -2751,65 +2981,70 @@ class ShowInterface(urwid.WidgetWrap): break def start(self): - if not self.started and self.is_connected: + # Poll while the detail view is open, regardless of connection state, so + # that the connection indicator and live stats keep refreshing and a + # dropped link can be seen to come back up. + if not self.started: self.started = True self.parent.app.ui.loop.set_alarm_in(1, self.update_bandwidth_charts) + def _show_disconnect_overlay(self): + # Uses its own overlay object so it never collides with the interface + # list's disconnect overlay handling while both poll loops are running. + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self._disconnect_overlay = urwid.Overlay( + dialog_box, self, align='center', width=35, valign='middle', height=4) + self.parent.widget = self._disconnect_overlay + self.parent.app.ui.main_display.update_active_sub_display() + def update_bandwidth_charts(self, loop, user_data): if not self.started: return try: - interface_stats = self.parent.app.interface_stats - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - stats = stats_lookup.get(self.iface_name, {}) + # The interface list's poll loop owns the actual stats fetch; when the + # shared RNS instance drops it flags _rns_disconnected, which we surface + # here too instead of silently charting stale data. + if getattr(self.parent, "_rns_disconnected", False): + self._show_disconnect_overlay() + else: + if getattr(self, "_disconnect_overlay", None) is not None and self.parent.widget is self._disconnect_overlay: + self.parent.widget = self + self.parent.app.ui.main_display.update_active_sub_display() - tx = stats.get("txb", self.tx) - rx = stats.get("rxb", self.rx) + interface_stats = self.parent.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + stats = stats_lookup.get(self.iface_name, {}) - new_connection_status = stats.get("status", False) - if new_connection_status != self.is_connected: - self.update_connection_display(new_connection_status) + tx = stats.get("txb", self.tx) + rx = stats.get("rxb", self.rx) - if not self.is_connected: - return + new_connection_status = stats.get("status", False) + if new_connection_status != self.is_connected: + self.update_connection_display(new_connection_status) - self.tx_text.set_text(("value", format_bytes(tx))) - self.rx_text.set_text(("value", format_bytes(rx))) + self._refresh_stats(stats) - self.bandwidth_chart.update(rx, tx) + if self.is_connected: + self.bandwidth_chart.update(rx, tx) - rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) + rx_chart, tx_chart, peak_rx, peak_tx = self.bandwidth_chart.get_charts(height=8) - self.rx_chart_text.set_text(rx_chart) - self.tx_chart_text.set_text(tx_chart) - self.rx_peak_text.set_text(f"Peak: {peak_rx}") - self.tx_peak_text.set_text(f"Peak: {peak_tx}") + self.rx_chart_text.set_text(rx_chart) + self.tx_chart_text.set_text(tx_chart) + self.rx_peak_text.set_text(f"Peak: {peak_rx}") + self.tx_peak_text.set_text(f"Peak: {peak_tx}") - self.tx = tx - self.rx = rx + self.tx = tx + self.rx = rx except Exception as e: - if not hasattr(self.parent, - 'disconnect_overlay') or self.parent.widget is not self.parent.disconnect_overlay: - dialog_text = urwid.Pile([ - urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), - urwid.Text("Waiting to Reconnect...", align="center") - ]) - dialog_content = urwid.Filler(dialog_text) - dialog_box = urwid.LineBox(dialog_content) - - self.parent.disconnect_overlay = urwid.Overlay( - dialog_box, - self, - align='center', - width=35, - valign='middle', - height=4 - ) - - self.parent.widget = self.parent.disconnect_overlay - self.parent.app.ui.main_display.update_active_sub_display() - self.started = False + self._show_disconnect_overlay() finally: if self.started: loop.set_alarm_in(1, self.update_bandwidth_charts) @@ -2822,6 +3057,300 @@ class ShowInterface(urwid.WidgetWrap): self.started = False self.parent.switch_to_edit_interface(self.iface_name) +class InterfaceTile(urwid.WidgetWrap): + def __init__(self, parent, name, is_connected, is_enabled, iface_type, icon, announce_rate="0"): + self.parent = parent + self.name = name + self.icon = icon + self.g = parent.g + self.dot_style = "connected_status" if is_enabled else "disabled_status" + self.dot = self.g['selected'] if is_enabled else self.g['unselected'] + self.title_widget = urwid.Text("", wrap="clip") + typ = urwid.Text(("value", iface_type), wrap="clip") + if is_enabled: + status = urwid.Text([ + (self.dot_style, "Enabled"), + ("value", f" {self.g['sep_dot']} "), + connection_markup(self.g, is_connected, "Conn." if is_connected else "Disc."), + ], wrap="clip") + else: + status = urwid.Text([(self.dot_style, "Disabled")], wrap="clip") + inner_items = [self.title_widget, typ, status] + if is_enabled: + inner_items.append(urwid.Text([("key", "Announces "), ("value", announce_rate)], wrap="clip")) + inner = urwid.Pile(inner_items) + self.box = urwid.LineBox( + urwid.Padding(inner, left=1, right=1), + tlcorner="╭", trcorner="╮", blcorner="╰", brcorner="╯", + ) + self.attr = urwid.AttrMap(self.box, None, focus_map={None: "interface_tile_focus"}) + super().__init__(self.attr) + self._render_title(False) + + def _render_title(self, focus): + arrow = self.g['focus_arrow'] if focus else " " + style = "interface_tile_focus" if focus else "interface_title" + self.title_widget.set_text([(self.dot_style, self.dot), (style, f" {arrow} {self.icon} {self.name}")]) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_title(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key == "enter": + self.parent.switch_to_show_interface(self.name) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self.parent.switch_to_show_interface(self.name) + return True + return False + + +### PROFILE VIEWS ### +class ProfileListRow(urwid.WidgetWrap): + def __init__(self, view, pid, label, selected): + self.view = view + self.pid = pid + self.label = label + self.selected = selected + self.g = view.parent.g + self.text_widget = urwid.Text("") + super().__init__(urwid.AttrMap(self.text_widget, None)) + self._render_row(False) + + def _render_row(self, focus): + mark = self.g['selected'] if self.selected else self.g['unselected'] + arrow = self.g['focus_arrow'] if focus else " " + if focus: + style = "interface_tile_focus" + elif self.selected: + style = "connected_status" + else: + style = "interface_title" + self.text_widget.set_text((style, f" {arrow} {mark} {self.label}")) + + def selectable(self): + return True + + def render(self, size, focus=False): + self._render_row(focus) + return super().render(size, focus) + + def keypress(self, size, key): + if key in (" ", "enter"): + if self.pid is not None: + self.view.select(self.pid) + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + if self.pid is not None: + self.view.select(self.pid) + return True + return False + + +class ProfilesView(urwid.WidgetWrap): + def __init__(self, parent): + self.parent = parent + self.app = parent.app + self.profiles = parent.app.interface_profiles + self.profiles.prune() + self.status = urwid.Text("", align="center") + self.walker = urwid.SimpleFocusListWalker([]) + self.listbox = urwid.ListBox(self.walker) + body_rows = max(3, parent.list_rows - 3) + self.pile = urwid.Pile([ + ('pack', parent._make_tab_bar("profiles")), + ('pack', self.status), + ('pack', urwid.Divider()), + urwid.BoxAdapter(self.listbox, body_rows), + ]) + super().__init__(urwid.Filler(self.pile, urwid.TOP)) + self.refresh() + + def refresh(self, focus_pid="__keep__"): + prev = None + try: + w, _ = self.listbox.body.get_focus() + prev = getattr(w, "pid", None) + except Exception: + pass + self.profiles.update_default_if_custom() + active = self.profiles.active_profile_id() + items = [ProfileListRow(self, "__default__", "Default", active is None)] + for p in self.profiles.profiles: + count = self.profiles.member_count(p) + label = f"{p['name'] or '(unnamed)'} ({count} interface{'' if count == 1 else 's'})" + items.append(ProfileListRow(self, p["id"], label, p["id"] == active)) + if len(self.profiles.profiles) == 0: + items.append(urwid.Text("\n No profiles yet. Press Ctrl + A to create one.", align="left")) + self.walker[:] = items + target = focus_pid if focus_pid != "__keep__" else prev + for i, w in enumerate(self.walker): + if getattr(w, "pid", "__x__") == target: + try: self.listbox.focus_position = i + except Exception: pass + break + + def _focused(self): + try: + w, _ = self.listbox.body.get_focus() + return w if isinstance(w, ProfileListRow) else None + except Exception: + return None + + def select(self, pid): + if pid == "__default__": + if self.profiles.default_members is None: + return + count = len(self.profiles.default_members) + title = "Switch to Default" + msg = "Restore your saved manual setup?\nThis enables %d interface%s and disables the rest." % (count, "" if count == 1 else "s") + else: + p = self.profiles.get(pid) + if p is None: + return + count = self.profiles.member_count(p) + title = "Switch Profile" + msg = "Switch to profile '%s'?\nThis enables its %d interface%s and disables the rest." % (p["name"] or "(unnamed)", count, "" if count == 1 else "s") + + def yes(_b): + self.dismiss_dialog() + if pid == "__default__": + self.profiles.select_default() + else: + self.profiles.select_profile(pid) + self.parent.mark_restart_pending() + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Restart NomadNet for changes to take effect")) + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(msg, align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title), height=10) + + def _overlay(self, dialog, height=8, width=50): + frame = self.app.ui.main_display.frame + frame.body = urwid.Overlay(dialog, self, align="center", width=width, + valign="middle", height=height, min_width=20) + + def dismiss_dialog(self): + self.app.ui.main_display.frame.body = self + + def new_profile(self): + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.create(val) + self.refresh(focus_pid=pid) + + self._prompt("New Profile", edit, ok) + + def save_current(self): + n = len(self.profiles.enabled_set()) + edit = ReadlineEdit(caption="Name (%d enabled): " % n) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + pid = self.profiles.save_current_as_profile(val) + self.refresh(focus_pid=pid) + self.status.set_text(("connected_status", "Saved current setup (%d interface%s) as a profile" % (n, "" if n == 1 else "s"))) + + self._prompt("Save Current as Profile", edit, ok) + + def rename_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + edit = ReadlineEdit(caption="Name: ", edit_text=p["name"]) + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + self.profiles.rename(p["id"], val) + self.refresh(focus_pid=p["id"]) + + self._prompt("Rename Profile", edit, ok) + + def delete_profile(self): + w = self._focused() + if w is None or w.pid is None: + return + p = self.profiles.get(w.pid) + if p is None: + return + + def yes(_b): + self.dismiss_dialog() + self.profiles.delete(p["id"]) + self.refresh() + + def no(_b): + self.dismiss_dialog() + + pile = urwid.Pile([ + urwid.Text(f"Delete profile '{p['name'] or p['id']}'?\nInterfaces keep their current enabled state.", align="center"), + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=yes)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=no)), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title="Confirm"), height=10) + + def _prompt(self, title, edit, on_ok): + pile = urwid.Pile([ + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("OK", on_press=on_ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + self._overlay(DialogLineBox(urwid.Filler(pile, urwid.TOP), parent=self, title=title)) + + def keypress(self, size, key): + if key in ("tab", "esc"): + self.parent.show_interfaces(); return None + if key == "ctrl a": + self.new_profile(); return None + if key == "ctrl s": + self.save_current(); return None + if key == "ctrl e": + self.rename_profile(); return None + if key == "ctrl x": + self.delete_profile(); return None + return super().keypress(size, key) + + ### MAIN DISPLAY ### class InterfaceDisplay: def __init__(self, app): @@ -2837,87 +3366,128 @@ class InterfaceDisplay: self.iface_row_offset = 4 self.list_rows = self.terminal_rows - self.iface_row_offset - interfaces = app.rns.config['interfaces'] - processed_interfaces = {} + self.active_tab = "interfaces" + self.iface_view_mode = "rows" + self.iface_filter = "all" + self.total_interfaces = 0 + self.restart_pending = False + self._notice_shown = False + self._rns_disconnected = False + self.profiles_view = None - for interface_name, interface in interfaces.items(): - interface_data = interface.copy() + self._build_interface_items() - # handle sub-interfaces for RNodeMultiInterface - if interface_data.get("type") == "RNodeMultiInterface": - sub_interfaces = [] - for sub_name, sub_config in interface_data.items(): - if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", - "configured_bitrate"}: - if isinstance(sub_config, dict): - sub_config["name"] = sub_name - sub_interfaces.append(sub_config) - - # add sub-interfaces to the main interface data - interface_data["sub_interfaces"] = sub_interfaces - - for sub in sub_interfaces: - del interface_data[sub["name"]] - - processed_interfaces[interface_name] = interface_data - - app.interface_stats = app.rns.get_interface_stats() - interface_stats = app.interface_stats - stats_lookup = {interface['short_name']: interface for interface in interface_stats['interfaces']} - # print(stats_lookup) - for interface_name, interface_data in processed_interfaces.items(): - # configobj false values - is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') - - iface_type = interface_data.get("type", "Unknown") - icon = _get_interface_icon(self.glyphset, iface_type) - - stats_for_interface = stats_lookup.get(interface_name) - - if stats_for_interface: - tx = stats_for_interface.get("txb", 0) - rx = stats_for_interface.get("rxb", 0) - is_connected = stats_for_interface["status"] - else: - tx = 0 - rx = 0 - is_connected = False - - item = SelectableInterfaceItem( - parent=self, - name=interface_data.get("name", interface_name), - is_connected=is_connected, - is_enabled=is_enabled, - iface_type=iface_type, - tx=tx, - rx=rx, - icon=icon - ) - - self.interface_items.append(item) - - interface_header = urwid.Text(("interface_title", "Interfaces"), align="center") - if len(self.interface_items) == 0: - interface_header = urwid.Text( - ("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") - - - list_contents = [ - interface_header, - urwid.Divider(), - ] + self.interface_items - - self.list_walker = urwid.SimpleFocusListWalker(list_contents) + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) self.list_box = urwid.ListBox(self.list_walker) - self.box_adapter = urwid.BoxAdapter(self.list_box, self.list_rows) + self.body_rows = max(3, self.list_rows - 4) + self.box_adapter = urwid.BoxAdapter(self.list_box, self.body_rows) - - pile = urwid.Pile([self.box_adapter]) - self.interfaces_display = InterfaceFiller(pile, self.app) + self.tabs_pile = urwid.Pile([ + ('pack', self._make_tab_bar("interfaces")), + ('pack', self._make_filter_bar()), + ('pack', urwid.Divider()), + self.box_adapter, + ]) + self.interfaces_display = InterfaceFiller(self.tabs_pile, self.app) self.shortcuts_display = InterfaceDisplayShortcuts(self.app) self.widget = self.interfaces_display + def _empty_message(self): + if self.iface_filter != "all" and self.total_interfaces > 0: + return "No %s interfaces." % self.iface_filter + return "No interfaces found. Press Ctrl + A to add a new interface " + + def _interface_list_contents(self): + if not self.interface_items: + return [urwid.Text(("interface_title", self._empty_message()), align="center")] + return list(self.interface_items) + + def _make_filter_bar(self): + cols = [('weight', 1, urwid.Text(""))] + cols.append(('pack', urwid.Text(("interface_title", "Filter by: ")))) + for key, label in (("all", "All"), ("enabled", "Enabled"), ("disabled", "Disabled"), ("connected", "Connected"), ("disconnected", "Disconnected")): + sel = self.iface_filter == key + mark = self.g['selected'] if sel else self.g['unselected'] + style = "interface_tile_focus" if sel else "interface_title" + cols.append(('pack', ClickableIcon((style, "%s %s " % (mark, label)), on_click=lambda k=key: self._set_filter(k)))) + cols.append(('weight', 1, urwid.Text(""))) + return urwid.Columns(cols, dividechars=0) + + def _set_filter(self, key): + if key == self.iface_filter: + return + self.iface_filter = key + self._build_interface_items() + self._rebuild_list() + self.app.ui.main_display.update_active_sub_display() + + def cycle_filter(self): + order = ["all", "enabled", "disabled", "connected", "disconnected"] + i = order.index(self.iface_filter) if self.iface_filter in order else 0 + self._set_filter(order[(i + 1) % len(order)]) + + def _make_tab_bar(self, active): + iface = f" Interfaces ({self.total_interfaces}) " + prof = " Profiles " + if active == "interfaces": + iface_w = ClickableIcon(("interface_title_selected", "["+iface+"]"), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title", " "+prof+" "), on_click=self.show_profiles) + else: + iface_w = ClickableIcon(("interface_title", " "+iface+" "), on_click=self.show_interfaces) + prof_w = ClickableIcon(("interface_title_selected", "["+prof+"]"), on_click=self.show_profiles) + return urwid.Columns([ + ('weight', 1, urwid.Text("")), + ('pack', iface_w), + ('pack', urwid.Text(" ")), + ('pack', prof_w), + ('weight', 1, urwid.Text("")), + ], dividechars=0) + + def _build_grid_listbox(self): + tiles = self.interface_tiles if self.interface_tiles else [urwid.Text(self._empty_message(), align="center")] + self.iface_grid = urwid.GridFlow(tiles, cell_width=34, h_sep=2, v_sep=1, align="center") + return urwid.ListBox(urwid.SimpleFocusListWalker([self.iface_grid])) + + def _set_iface_body(self): + if self.iface_view_mode == "grid": + self.box_adapter.original_widget = self._build_grid_listbox() + else: + self.list_walker = urwid.SimpleFocusListWalker(self._interface_list_contents()) + self.list_box = urwid.ListBox(self.list_walker) + self.box_adapter.original_widget = self.list_box + + def _focused_interface_name(self): + try: + if self.iface_view_mode == "grid": + return getattr(self.iface_grid.focus, "name", None) + fw, _ = self.box_adapter._original_widget.body.get_focus() + return getattr(fw, "name", None) if isinstance(fw, SelectableInterfaceItem) else None + except Exception: + return None + + def toggle_view_mode(self): + self.iface_view_mode = "grid" if self.iface_view_mode == "rows" else "rows" + self._set_iface_body() + self.app.ui.main_display.update_active_sub_display() + + def show_profiles(self): + if getattr(self.app, "interface_profiles", None) is None: + return + self.active_tab = "profiles" + self.profiles_view = ProfilesView(self) + self.shortcuts_display.set_profiles_tab_shortcuts() + self.widget = self.profiles_view + self.app.ui.main_display.update_active_sub_display() + + def show_interfaces(self): + self.active_tab = "interfaces" + self._build_interface_items() + self._rebuild_list() + self.shortcuts_display.reset_shortcuts() + self.widget = self.interfaces_display + self.app.ui.main_display.update_active_sub_display() + def start(self): # started from Main.py self.started = True @@ -2930,14 +3500,9 @@ class InterfaceDisplay: self.app.ui.main_display.update_active_sub_display() def edit_selected_interface(self): - focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() - - if not isinstance(focus_widget, SelectableInterfaceItem): + interface_name = self._focused_interface_name() + if interface_name is None: return - - selected_item = focus_widget - interface_name = selected_item.name - self.switch_to_edit_interface(interface_name) def check_terminal_size(self, loop, user_data): @@ -2947,53 +3512,74 @@ class InterfaceDisplay: if new_rows != self.terminal_rows or new_cols != self.terminal_cols: self.terminal_cols, self.terminal_rows = new_cols, new_rows self.list_rows = self.terminal_rows - self.iface_row_offset + self.body_rows = max(3, self.list_rows - 4 - (1 if self.restart_pending else 0)) - self.box_adapter.height = self.list_rows + self.box_adapter.height = self.body_rows loop.draw_screen() if self.started: loop.set_alarm_in(5, self.check_terminal_size) + def _show_disconnect_overlay(self): + # Surfaced when the shared RNS instance (rnsd) we are attached to goes away. + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + return + if self.widget is not self.interfaces_display: + return + dialog_text = urwid.Pile([ + urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), + urwid.Text("Waiting to Reconnect...", align="center"), + ]) + dialog_box = urwid.LineBox(urwid.Filler(dialog_text)) + self.disconnect_overlay = urwid.Overlay( + dialog_box, + self.interfaces_display, + align='center', + width=35, + valign='middle', + height=4, + ) + self.widget = self.disconnect_overlay + self.app.ui.main_display.update_active_sub_display() + def poll_stats(self, loop, user_data): self.poll_scheduler = True try: - if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: - self.widget = self.interfaces_display - self.app.ui.main_display.update_active_sub_display() - - def job(): self.app.interface_stats = self.app.rns.get_interface_stats() - threading.Thread(target=job, daemon=True).start() - interface_stats = self.app.interface_stats - stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} - for item in self.interface_items: - # use interface name as the key - stats_for_interface = stats_lookup.get(item.name) - if stats_for_interface: - tx = stats_for_interface.get("txb", 0) - rx = stats_for_interface.get("rxb", 0) - item.update_stats(tx, rx) - except Exception as e: - if not hasattr(self, 'disconnect_overlay') or self.widget is not self.disconnect_overlay: - dialog_text = urwid.Pile([ - urwid.Text(("disconnected_status", "(!) RNS Instance Disconnected"), align="center"), - urwid.Text(("Waiting to Reconnect..."), align="center") - ]) - dialog_content = urwid.Filler(dialog_text) - dialog_box = urwid.LineBox(dialog_content) - - self.disconnect_overlay = urwid.Overlay( - dialog_box, - self.interfaces_display, - align='center', - width=35, - valign='middle', - height=4 - ) - - if self.widget is self.interfaces_display: - self.widget = self.disconnect_overlay + # React to the outcome of the previous (asynchronous) stats fetch. The + # fetch runs in a daemon thread so the UI never blocks on the RPC round + # trip; the thread cannot raise into this loop, so it reports failure via + # the _rns_disconnected flag instead. + if self._rns_disconnected: + self._show_disconnect_overlay() + else: + if hasattr(self, 'disconnect_overlay') and self.widget is self.disconnect_overlay: + self.widget = self.interfaces_display self.app.ui.main_display.update_active_sub_display() + + interface_stats = self.app.interface_stats + stats_lookup = {iface['short_name']: iface for iface in interface_stats['interfaces']} + for item in self.interface_items: + # use interface name as the key + stats_for_interface = stats_lookup.get(item.name) + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) + item.update_stats(tx, rx, announce_rate) + item.update_connection(stats_for_interface.get("status", False)) + else: + item.update_connection(False) + + def job(): + try: + self.app.interface_stats = self.app.rns.get_interface_stats() + self._rns_disconnected = False + except Exception: + self._rns_disconnected = True + threading.Thread(target=job, daemon=True).start() + except Exception as e: + self._show_disconnect_overlay() finally: if self.started: loop.set_alarm_in(1, self.poll_stats) @@ -3009,10 +3595,7 @@ class InterfaceDisplay: show_interface.start() def switch_to_list(self): - self.shortcuts_display.reset_shortcuts() - self.widget = self.interfaces_display - self._rebuild_list() - self.app.ui.main_display.update_active_sub_display() + self.show_interfaces() def add_interface(self): dialog_widgets = [] @@ -3024,6 +3607,10 @@ class InterfaceDisplay: item = InterfaceOptionItem(self, label, value) dialog_widgets.append(item) + add_heading("Paste Configuration") + dialog_widgets.append(InterfaceOptionItem(self, "Paste an interface config…", None, on_select=self.show_paste_config_dialog)) + dialog_widgets.append(urwid.Divider()) + # Get the icons based on plain, unicode, nerdfont glyphset network_icon = _get_interface_icon(self.glyphset, "AutoInterface") rnode_icon = _get_interface_icon(self.glyphset, "RNodeInterface") @@ -3072,28 +3659,119 @@ class InterfaceDisplay: self.widget = overlay self.app.ui.main_display.update_active_sub_display() + def show_paste_config_dialog(self): + self.paste_edit = ReadlineEdit(caption="", multiline=True) + self.paste_status = urwid.Text("") + + instructions = urwid.Text( + "Paste an interface block, including its [[Name]] header, then Create. " + "Multiple interfaces are supported.", align="left") + + editor = urwid.AttrMap( + urwid.LineBox(urwid.BoxAdapter(urwid.ListBox(urwid.SimpleListWalker([self.paste_edit])), 12)), + "list_off_focus", "list_focus") + + pile = urwid.Pile([ + instructions, + urwid.Divider(), + editor, + self.paste_status, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Create", on_press=lambda _b: self._create_interfaces_from_text(self.paste_edit.get_edit_text()))), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Paste Interface Config") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 75), + valign="middle", height=("relative", 75), + min_width=44, min_height=14, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _section_to_dict(self, sec): + out = {} + for k, v in sec.items(): + out[k] = self._section_to_dict(v) if hasattr(v, "items") else v + return out + + def _parse_pasted_interfaces(self, text): + import RNS.vendor.configobj as configobj + raw = (text or "").strip("\n") + if not raw.strip(): + return {} + for prefix in ("", "[interfaces]\n"): + try: + parsed = configobj.ConfigObj(infile=(prefix + raw).split("\n")) + except Exception: + continue + found = {} + sections = [] + ifsec = parsed.get("interfaces") + if hasattr(ifsec, "items"): + sections.extend(ifsec.items()) + for name, sec in parsed.items(): + if name != "interfaces" and hasattr(sec, "items"): + sections.append((name, sec)) + for name, sec in sections: + if not hasattr(sec, "items"): + continue + d = self._section_to_dict(sec) + if "type" in d and name not in found: + found[name] = d + if found: + return found + return {} + + def _create_interfaces_from_text(self, text): + parsed = self._parse_pasted_interfaces(text) + if not parsed: + self.paste_status.set_text(("error", " Could not find an interface with a 'type'. Include the [[Name]] header.")) + return + + interfaces = self.app.rns.config['interfaces'] + existing = [n for n in parsed if n in interfaces] + if existing: + self.paste_status.set_text(("error", " Already exists: " + ", ".join(existing))) + return + + try: + for name, conf in parsed.items(): + interfaces[name] = conf + write_config_atomic(self.app.rns.config) + except Exception as e: + self.paste_status.set_text(("error", " Failed: " + str(e))) + return + + self.mark_restart_pending() + self.dismiss_dialog() + self.show_interfaces() + def switch_to_add_interface(self, iface_type): self.add_interface_view = AddInterfaceView(self, iface_type) self.widget = self.add_interface_view self.app.ui.main_display.update_active_sub_display() def remove_selected_interface(self): - focus_widget, focus_position = self.box_adapter._original_widget.body.get_focus() - if not isinstance(focus_widget, SelectableInterfaceItem): + interface_name = self._focused_interface_name() + if interface_name is None: return - selected_item = focus_widget - interface_name = selected_item.name - def on_confirm_yes(button): try: if interface_name in self.app.rns.config['interfaces']: del self.app.rns.config['interfaces'][interface_name] - self.app.rns.config.write() - - if selected_item in self.interface_items: - self.interface_items.remove(selected_item) + write_config_atomic(self.app.rns.config) + try: self.app.interface_profiles.remove_interface(interface_name) + except Exception: pass + self.mark_restart_pending() + self._build_interface_items() self._rebuild_list() self.dismiss_dialog() @@ -3142,20 +3820,300 @@ class InterfaceDisplay: self.widget = self.interfaces_display self.app.ui.main_display.update_active_sub_display() + def _build_interface_items(self): + self.interface_items = [] + self.interface_tiles = [] + interfaces = self.app.rns.config['interfaces'] + processed_interfaces = {} + + for interface_name, interface in interfaces.items(): + interface_data = interface.copy() + + if interface_data.get("type") == "RNodeMultiInterface": + sub_interfaces = [] + for sub_name, sub_config in interface_data.items(): + if sub_name not in {"type", "port", "interface_enabled", "selected_interface_mode", + "configured_bitrate"}: + if isinstance(sub_config, dict): + sub_config["name"] = sub_name + sub_interfaces.append(sub_config) + + interface_data["sub_interfaces"] = sub_interfaces + + for sub in sub_interfaces: + del interface_data[sub["name"]] + + processed_interfaces[interface_name] = interface_data + + self.total_interfaces = len(processed_interfaces) + self.app.interface_stats = self.app.rns.get_interface_stats() + stats_lookup = {interface['short_name']: interface for interface in self.app.interface_stats['interfaces']} + + profiles = getattr(self.app, "interface_profiles", None) + + for interface_name, interface_data in processed_interfaces.items(): + is_enabled = str(interface_data.get("enabled")).lower() not in ('false', 'off', 'no', '0') and str(interface_data.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + if self.iface_filter == "enabled" and not is_enabled: + continue + if self.iface_filter == "disabled" and is_enabled: + continue + + iface_type = interface_data.get("type", "Unknown") + icon = _get_interface_icon(self.glyphset, iface_type) + + stats_for_interface = stats_lookup.get(interface_name) + + if stats_for_interface: + tx = stats_for_interface.get("txb", 0) + rx = stats_for_interface.get("rxb", 0) + is_connected = stats_for_interface["status"] + announce_rate = format_announce_rate(stats_for_interface.get("incoming_announce_frequency", 0)) + else: + tx = 0 + rx = 0 + is_connected = False + announce_rate = "-" + + if self.iface_filter == "connected" and not is_connected: + continue + if self.iface_filter == "disconnected" and is_connected: + continue + + profiles_label = profiles.label_for(interface_name) if profiles is not None else None + + item = SelectableInterfaceItem( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + tx=tx, + rx=rx, + icon=icon, + profiles_label=profiles_label, + announce_rate=announce_rate + ) + + self.interface_items.append(item) + + tile = InterfaceTile( + parent=self, + name=interface_data.get("name", interface_name), + is_connected=is_connected, + is_enabled=is_enabled, + iface_type=iface_type, + icon=icon, + announce_rate=announce_rate, + ) + self.interface_tiles.append(tile) + def _rebuild_list(self): - interface_header = urwid.Text(("interface_title", f"Interfaces ({len(self.interface_items)})"), align="center") - if len(self.interface_items) == 0: - interface_header = urwid.Text(("interface_title", "No interfaces found. Press Ctrl + A to add a new interface "), align="center") + self._set_iface_body() + self.tabs_pile.contents[0] = (self._make_tab_bar("interfaces"), self.tabs_pile.options('pack')) + self.tabs_pile.contents[1] = (self._make_filter_bar(), self.tabs_pile.options('pack')) - new_list = [ - interface_header, - urwid.Divider(), - ] + self.interface_items - # RNS.log(f"items: {self.interface_items}") + def mark_restart_pending(self): + self.restart_pending = True + if self._notice_shown: + return + try: + mark = "!" if self.glyphset == "plain" else "⚠" + banner = urwid.AttrMap( + urwid.Text("%s Interfaces have been updated. Restart required" % mark, align="center"), + "warning_text") + self.tabs_pile.contents.insert(2, (banner, self.tabs_pile.options('pack'))) + self._notice_shown = True + self.body_rows = max(3, self.body_rows - 1) + self.box_adapter.height = self.body_rows + except Exception: + pass - walker = urwid.SimpleFocusListWalker(new_list) - self.box_adapter._original_widget.body = walker - self.box_adapter._original_widget.focus_position = len(new_list) - 1 + def _focus_interface(self, name): + try: + if self.iface_view_mode == "grid": + for i, t in enumerate(self.interface_tiles): + if getattr(t, "name", None) == name: + self.iface_grid.focus_position = i + return + else: + body = self.box_adapter._original_widget.body + for i, w in enumerate(body): + if getattr(w, "name", None) == name: + self.box_adapter._original_widget.focus_position = i + return + except Exception: + pass + + def toggle_focused_interface(self): + name = self._focused_interface_name() + if name is None: + return + interfaces = self.app.rns.config['interfaces'] + if name not in interfaces: + return + iface = interfaces[name] + is_enabled = str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + value = "false" if is_enabled else "true" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + try: + write_config_atomic(self.app.rns.config) + except Exception: + return + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + self._focus_interface(name) + self.app.ui.main_display.update_active_sub_display() + + def _iface_is_enabled(self, iface): + return str(iface.get("enabled")).lower() not in ('false', 'off', 'no', '0') and \ + str(iface.get("interface_enabled")).lower() not in ('false', 'off', 'no', '0') + + def _set_iface_enabled(self, iface, enabled): + value = "true" if enabled else "false" + iface["interface_enabled"] = value + if "enabled" in iface: + iface["enabled"] = value + + def show_bulk_actions(self): + interfaces = self.app.rns.config['interfaces'] + if not interfaces: + self._bulk_notice("No interfaces configured.") + return + + dialog_widgets = [ + urwid.Text(("interface_title", "Apply an action to all interfaces"), align="left"), + urwid.Divider(), + InterfaceOptionItem(self, "Enable all interfaces", None, on_select=lambda: self._bulk_apply("enable")), + InterfaceOptionItem(self, "Disable all interfaces", None, on_select=lambda: self._bulk_apply("disable")), + InterfaceOptionItem(self, "Invert (toggle each interface)", None, on_select=lambda: self._bulk_apply("invert")), + InterfaceOptionItem(self, "Solo selected (disable all others)", None, on_select=lambda: self._bulk_apply("solo")), + ] + + listbox = urwid.ListBox(urwid.SimpleFocusListWalker(dialog_widgets)) + dialog = DialogLineBox(listbox, parent=self, title="Bulk Actions") + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=('relative', 50), + valign='middle', + height=('relative', 50), + min_width=20, + min_height=10, + left=2, + right=2 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def _bulk_apply(self, action): + interfaces = self.app.rns.config['interfaces'] + focused = self._focused_interface_name() + + if action == "solo" and focused is None: + self._bulk_notice("Select an interface first to solo it.") + return + + changed = 0 + for name, iface in interfaces.items(): + current = self._iface_is_enabled(iface) + if action == "enable": + target = True + elif action == "disable": + target = False + elif action == "invert": + target = not current + elif action == "solo": + target = (name == focused) + else: + target = current + + if target != current: + self._set_iface_enabled(iface, target) + changed += 1 + + try: + write_config_atomic(self.app.rns.config) + except Exception as e: + self._bulk_notice(f"Error applying bulk action: {str(e)}", title="Error") + return + + self.mark_restart_pending() + self._build_interface_items() + self._rebuild_list() + + verb = {"enable": "Enabled all", "disable": "Disabled all", "invert": "Inverted all", "solo": "Soloed selected"}[action] + if changed == 0: + self._bulk_notice("No interfaces needed changing.") + else: + self._bulk_notice(f"{verb} interfaces ({changed} changed).\nRestart required for changes to take effect.") + + def _bulk_notice(self, message, title="Notice"): + def dismiss_dialog(button): + self.dismiss_dialog() + + dialog = DialogLineBox( + urwid.Pile([ + urwid.Text(message, align="center"), + urwid.Divider(), + urwid.Button("OK", on_press=dismiss_dialog) + ]), + parent=self, + title=title + ) + + overlay = urwid.Overlay( + dialog, + self.interfaces_display, + align='center', + width=60, + valign='middle', + height=8, + min_width=20, + min_height=1 + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() + + def save_current_profile(self): + profiles = getattr(self.app, "interface_profiles", None) + if profiles is None: + return + n = len(profiles.enabled_set()) + edit = ReadlineEdit(caption="Name: ") + + def ok(_b): + val = edit.get_edit_text().strip() + self.dismiss_dialog() + if val: + profiles.save_current_as_profile(val) + self.show_interfaces() + + pile = urwid.Pile([ + urwid.Text("Save the %d currently enabled interface%s as a new profile." % (n, "" if n == 1 else "s"), align="left"), + urwid.Divider(), + edit, + urwid.Divider(), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=ok)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Cancel", on_press=lambda _b: self.dismiss_dialog())), + ]), + ]) + dialog = DialogLineBox(urwid.Filler(pile, valign="top"), parent=self, title="Save Current as Profile") + overlay = urwid.Overlay( + dialog, self.interfaces_display, + align="center", width=("relative", 60), + valign="middle", height=("relative", 45), min_width=44, min_height=9, + ) + self.widget = overlay + self.app.ui.main_display.update_active_sub_display() def open_config_editor(self): import platform @@ -3188,7 +4146,7 @@ class InterfaceDisplay: class InterfaceDisplayShortcuts: def __init__(self, app): self.app = app - self.default_shortcuts = "[C-a] Add Interface [C-e] Edit Interface [C-x] Remove Interface [Enter] Show Interface [C-w] Open Text Editor" + self.default_shortcuts = "[C-a] Add [C-e] Edit [C-x] Remove [C-o] On/Off [C-b] Bulk [Enter] Show [C-f] Filter [C-s] Save Profile [C-t] Grid/Rows [Tab] Profiles" self.current_shortcuts = self.default_shortcuts self.widget = urwid.AttrMap( urwid.Text(self.current_shortcuts), @@ -3212,4 +4170,8 @@ class InterfaceDisplayShortcuts: def set_edit_interface_shortcuts(self): edit_shortcuts = "[Up/Down] Navigate Fields [Enter] Select Option" - self.update_shortcuts(edit_shortcuts) \ No newline at end of file + self.update_shortcuts(edit_shortcuts) + + def set_profiles_tab_shortcuts(self): + profiles_shortcuts = "[Space] Select [C-a] New [C-s] Save Current [C-e] Rename [C-x] Delete [Tab] Interfaces" + self.update_shortcuts(profiles_shortcuts) \ No newline at end of file diff --git a/nomadnet/ui/textui/Log.py b/nomadnet/ui/textui/Log.py index bea98ef..11b5efa 100644 --- a/nomadnet/ui/textui/Log.py +++ b/nomadnet/ui/textui/Log.py @@ -55,9 +55,13 @@ class LogTerminal(urwid.WidgetWrap): def keypress(self, size, key): + if key == "ctrl q": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.show_quit_dialog() + return None + if key == "up": nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - + return super(LogTerminal, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 9f14c22..63ed0f2 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -6,9 +6,12 @@ from .Channels import * from .Directory import * from .Config import * from .Interfaces import * +from .Transport import * +from .Utilities import * from .Map import * from .Log import * from .Guide import * +from .Editor import * import urwid class SubDisplays(): @@ -20,15 +23,23 @@ class SubDisplays(): self.directory_display = DirectoryDisplay(self.app) self.config_display = ConfigDisplay(self.app) self.interface_display = InterfaceDisplay(self.app) + self.utilities_display = UtilitiesDisplay(self.app) if self._utilities_enabled(app) else None self.map_display = MapDisplay(self.app) self.log_display = LogDisplay(self.app) self.guide_display = GuideDisplay(self.app) + self.page_editor_display = PageEditorDisplay(self.app) if app.enable_node else None if app.firstrun: self.active_display = self.guide_display else: self.active_display = self.conversations_display + def _utilities_enabled(self, app): + try: + return app.config["utilities"].as_bool("enable_utilities") + except Exception: + return False + def active(self): return self.active_display @@ -85,6 +96,18 @@ class MainFrame(urwid.Frame): return super(MainFrame, self).keypress(size, key) +class QuitDialogLineBox(urwid.LineBox): + def __init__(self, *args, on_cancel=None, **kwargs): + self.on_cancel = on_cancel + super().__init__(*args, **kwargs) + + def keypress(self, size, key): + if key == "esc": + if self.on_cancel is not None: + self.on_cancel() + return None + return super().keypress(size, key) + class MainDisplay(): def __init__(self, ui, app): self.ui = ui @@ -127,6 +150,13 @@ class MainDisplay(): self.update_active_sub_display() self.sub_displays.interface_display.start() + def show_utilities(self, user_data): + if self.sub_displays.utilities_display is None: + return + self.sub_displays.active_display = self.sub_displays.utilities_display + self.update_active_sub_display() + self.sub_displays.utilities_display.start() + def show_log(self, user_data): self.sub_displays.active_display = self.sub_displays.log_display self.sub_displays.log_display.show() @@ -136,6 +166,14 @@ class MainDisplay(): self.sub_displays.active_display = self.sub_displays.guide_display self.update_active_sub_display() + def show_page_editor(self, user_data): + if not self.app.enable_node: + return + self.sub_displays.active_display = self.sub_displays.page_editor_display + self.update_active_sub_display() + self.sub_displays.page_editor_display.start() + self.frame.focus_position = "body" + def update_active_sub_display(self): self.frame.contents["body"] = (self.sub_displays.active().widget, None) self.update_active_shortcuts() @@ -156,6 +194,62 @@ class MainDisplay(): self.menu_display.start() def quit(self, sender=None): + self.show_quit_dialog() + + def show_quit_dialog(self, sender=None): + if getattr(self, "_quit_dialog_open", False): + return + + active = self.sub_displays.active_display + if active is self.sub_displays.log_display: + self.do_quit() + return + if active is self.sub_displays.config_display and self.sub_displays.config_display.editor_term is not None: + self.do_quit() + return + + def confirm(button=None): + self.do_quit() + + def cancel(button=None): + self._quit_dialog_open = False + self.app.ui.loop.widget = self.frame + + buttons = urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Yes", on_press=confirm)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("No", on_press=cancel)), + ]) + try: + + + buttons.focus_position = 2 # default to "No" + + + except Exception: + pass + + pile = urwid.Pile([ + urwid.Text("Are you sure you want to quit?", align="center"), + urwid.Divider(), + buttons, + ]) + + dialog = QuitDialogLineBox( + urwid.Filler(pile, urwid.TOP), + title="Quit NomadNet", + on_cancel=cancel, + ) + + self._quit_dialog_open = True + + self.app.ui.loop.widget = urwid.Overlay( + dialog, self.frame, + align="center", width=44, + valign="middle", height=8, min_width=20, + ) + + def do_quit(self, sender=None): logterm_pid = None if True or RNS.vendor.platformutils.is_android(): if self.sub_displays.log_display != None and self.sub_displays.log_display.log_term != None: @@ -194,14 +288,27 @@ class MenuDisplay(): button_log = (7, MenuButton("Log", on_press=handler.show_log)) button_config = (10, MenuButton("Config", on_press=handler.show_config)) button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) + button_utilities = (13, MenuButton("Utilities", on_press=handler.show_utilities)) button_guide = (9, MenuButton("Guide", on_press=handler.show_guide)) + button_mynode = (11, MenuButton("My Node", on_press=handler.show_page_editor)) button_quit = (8, MenuButton("Quit", on_press=handler.quit)) # buttons = [menu_text, button_conversations, button_node, button_directory, button_map] - if self.app.config["textui"]["hide_guide"]: - buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_quit] - else: - buttons = [menu_text, button_conversations, button_network, button_channels, button_log, button_interfaces, button_config, button_guide, button_quit] + buttons = [menu_text, button_conversations, button_network] + if self.app.enable_node: + buttons.append(button_mynode) + buttons += [button_channels, button_log, button_interfaces] + enable_utilities = False + try: + enable_utilities = self.app.config["utilities"].as_bool("enable_utilities") + except Exception: + enable_utilities = False + if enable_utilities: + buttons.append(button_utilities) + buttons.append(button_config) + if not self.app.config["textui"]["hide_guide"]: + buttons.append(button_guide) + buttons.append(button_quit) columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index 83fb6bf..0add998 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -36,6 +36,15 @@ SECTION_INDENT = 2 INDENT_RIGHT = 1 MAX_TABLE_WIDTH = 100 +DEFAULT_FOLD_GLYPHS = ("▾", "▸") + +def default_fold_glyphs(): + try: + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1])) + except Exception: + return DEFAULT_FOLD_GLYPHS + def default_state(fg=None, bg=None): global SELECTED_STYLES ensure_selected_styles() @@ -132,15 +141,26 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None if state.get("_header_pending"): state["header_rows"].append(row_index) + state.setdefault("header_levels", {})[row_index] = state["depth"] + cp = state.get("_collapsible_pending") + if cp is not None: + state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"]) + state["_collapsible_pending"] = None state["_header_pending"] = False + row_levels = state.setdefault("row_levels", {}) for display_widget in display_widgets: + row_levels[len(attrmaps)] = state["depth"] attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmaps.append(attrmap) try: setattr(attrmaps, "anchors", anchors) setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + setattr(attrmaps, "header_levels", dict(state.get("header_levels", {}))) + setattr(attrmaps, "row_levels", dict(state.get("row_levels", {}))) + setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {}))) + setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs()) except Exception: pass @@ -217,6 +237,176 @@ def render_table(lines, state, url_delegate): return widgets if widgets else None +class FormColumns(urwid.Columns): + + MIN_FIELD_WIDTH = 4 + + + @staticmethod + def _natural_width(widget, maxcol, focused): + # mirror the width urwid.Columns itself computes for a PACK column + if not isinstance(widget, urwid.Widget): + return 0 + sizing = widget.sizing() + if sizing & frozenset((urwid.FIXED, urwid.FLOW)): + width = 0 + if urwid.FIXED in sizing: + width = widget.pack((), focused)[0] + if urwid.FLOW in sizing and (not width or width > maxcol): + width = widget.pack((maxcol,), focused)[0] + return width + return widget.pack((maxcol,), focused)[0] + + def column_widths(self, size, focus=False): + contents = self.contents + field_idxs = [i for i, (w, (t, n, b)) in enumerate(contents) if t == urwid.GIVEN] + + if not field_idxs: + return super().column_widths(size, focus) + + maxcol = size[0] + nat = [] + for i, (w, (t, width, b)) in enumerate(contents): + if t == urwid.GIVEN: + nat.append(width) + elif t == urwid.PACK: + nat.append(self._natural_width(w, maxcol, focus and i == self.focus_position)) + else: + return super().column_widths(size, focus) + + dividers = self.dividechars * max(0, len(nat) - 1) + field_set = set(field_idxs) + fixed = sum(w for i, w in enumerate(nat) if i not in field_set) + requested = sum(nat[i] for i in field_idxs) + available = maxcol - dividers - fixed + + if available >= requested: + return super().column_widths(size, focus) + + if available < self.MIN_FIELD_WIDTH * len(field_idxs): + + return super().column_widths(size, focus) + + + + + + + overflow = requested - available + slack = {i: max(0, nat[i] - self.MIN_FIELD_WIDTH) for i in field_idxs} + total_slack = sum(slack.values()) + if total_slack < overflow: + return super().column_widths(size, focus) + + widths = list(nat) + removed = {i: min(slack[i], overflow * slack[i] // total_slack) for i in field_idxs} + shortfall = overflow - sum(removed.values()) + # Hand out any rounding remainder one column at a time. + pending = [i for i in field_idxs if removed[i] < slack[i]] + k = 0 + while shortfall > 0 and pending: + i = pending[k % len(pending)] + removed[i] += 1 + shortfall -= 1 + if removed[i] >= slack[i]: + pending = [j for j in pending if removed[j] < slack[j]] + k = 0 + else: + k += 1 + for i in field_idxs: + widths[i] = nat[i] - removed[i] + return widths + + +class MicronField(ReadlineEdit): + def __init__(self, *args, min_rows=1, **kwargs): + try: self._min_rows = max(1, int(min_rows)) + except (TypeError, ValueError): self._min_rows = 1 + super().__init__(*args, **kwargs) + + def rows(self, size, focus=False): + return max(self._min_rows, super().rows(size, focus)) + + def render(self, size, focus=False): + canv = super().render(size, focus) + if canv.rows() < self._min_rows: + canv = urwid.CompositeCanvas(canv) + canv.pad_trim_top_bottom(0, self._min_rows - canv.rows()) + return canv + + +class CollapsibleHeading(urwid.WidgetWrap): + _selectable = True + ignore_focus = False + + def __init__(self, segments, align, heading_style, depth, + collapsed=False, glyphs=None, delegate=None): + self._segments = list(segments) + self.align = align + self.heading_style = heading_style + self.depth = depth + self.collapsed = bool(collapsed) + self.glyphs = glyphs if glyphs is not None else default_fold_glyphs() + self.delegate = delegate + self.is_collapsible_heading = True + super().__init__(self._build()) + + def selectable(self): + return True + + def _glyph(self): + return self.glyphs[1] if self.collapsed else self.glyphs[0] + + def _glyph_col(self): + if self._segments and isinstance(self._segments[0], str): + return len(self._segments[0]) + return 0 + + def _build(self): + segs = list(self._segments) + marker = self._glyph() + " " + if segs and isinstance(segs[0], str): + segs.insert(1, marker) + else: + segs.insert(0, marker) + return urwid.AttrMap(urwid.Text(segs, align=self.align), self.heading_style) + + def set_collapsed(self, collapsed): + collapsed = bool(collapsed) + if collapsed != self.collapsed: + self.collapsed = collapsed + self._w = self._build() + self._invalidate() + + def toggle(self): + self.collapsed = not self.collapsed + self._w = self._build() + self._invalidate() + if self.delegate is not None and hasattr(self.delegate, "fold_changed"): + self.delegate.fold_changed(self) + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE or key == " ": + self.toggle() + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and is_mouse_press(event): + self.toggle() + return True + return False + + def render(self, size, focus=False): + canv = super().render(size, focus) + if focus: + canv = urwid.CompositeCanvas(canv) + gx = self._glyph_col() + if self.align == "left" and gx < size[0]: + canv.cursor = (gx, 0) + return canv + + def parse_line(line, state, url_delegate): pre_escape = False if len(line) > 0: @@ -229,6 +419,14 @@ def parse_line(line, state, url_delegate): # Only parse content if not in literal state if not state["literal"]: + collapsible_heading = False + collapsed_initial = False + if (line.startswith("`+") or line.startswith("`-")) and line[2:3] == ">": + collapsible_heading = True + collapsed_initial = line[1] == "-" + line = line[2:] + first_char = line[0] + # Apply markup sanitization if first_char == ">" and "`<" in line: # Remove heading status from lines containing fields @@ -242,6 +440,10 @@ def parse_line(line, state, url_delegate): # Check for comments elif first_char == "#": + parts = line.split() + if parts and parts[0] == "#!fold": + if len(parts) >= 3: state["fold_glyphs"] = (parts[1], parts[2]) + elif len(parts) == 2: state["fold_glyphs"] = (parts[1], parts[1]) return None # Check for tables @@ -315,6 +517,11 @@ def parse_line(line, state, url_delegate): heading_style = first_style output.insert(0, " "*left_indent(state)) + if collapsible_heading: + state["_collapsible_pending"] = {"collapsed": collapsed_initial} + glyphs = state.get("fold_glyphs") or default_fold_glyphs() + return [CollapsibleHeading(output, state["align"], heading_style, + state["depth"], collapsed_initial, glyphs, url_delegate)] return [urwid.AttrMap(urwid.Text(output, align=state["align"]), heading_style)] else: return None @@ -360,8 +567,9 @@ def parse_line(line, state, url_delegate): fd = o["data"] fn = o["name"] fs = o["style"] + frows = o.get("rows", 1) fmask = "*" if o["masked"] else None - f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f = MicronField(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask, min_rows=frows) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) @@ -396,7 +604,7 @@ def parse_line(line, state, url_delegate): - columns_widget = urwid.Columns(widgets, dividechars=0) + columns_widget = FormColumns(widgets, dividechars=0) text_widget = columns_widget # text_widget = urwid.Text("<"+output+">", align=state["align"]) @@ -679,11 +887,12 @@ def make_output(state, line, url_delegate, pre_escape=False): field_content = line[field_start:backtick_pos] field_masked = False field_width = 24 + field_rows = 1 field_type = "field" field_name = field_content field_value = "" field_data = "" - field_prechecked = False + field_prechecked = False # check if field_content contains '|' if '|' in field_content: @@ -704,10 +913,16 @@ def make_output(state, line, url_delegate, pre_escape=False): # Handle field width if len(field_flags) > 0: + width_flag, _, rows_flag = field_flags.partition("x") try: - field_width = min(int(field_flags), 256) + field_width = min(int(width_flag), 256) except ValueError: pass # Ignore invalid width + if rows_flag: + try: + field_rows = max(1, min(int(rows_flag), 256)) + except ValueError: + pass # Check for value and pre-checked flag if len(f_components) > 2: @@ -751,6 +966,7 @@ def make_output(state, line, url_delegate, pre_escape=False): "type": "field", "name": field_name, "width": field_width, + "rows": field_rows, "masked": field_masked, "data": field_data, "style": make_style(state) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index db3cb57..3525740 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -11,6 +11,7 @@ from nomadnet.util import sanitize_name from .Browser import Browser from .ReadlineEdit import ReadlineEdit +from .Helpers import qr_ascii class NetworkDisplayShortcuts(): def __init__(self, app): @@ -26,7 +27,17 @@ class NetworkDisplayShortcuts(): class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "update_conversation_list"): + delegate.update_conversation_list() + elif delegate is not None and getattr(delegate, "parent", None) is not None: + try: + delegate.dialog_open = False + options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options) + except Exception: + pass + return None else: return super(DialogLineBox, self).keypress(size, key) @@ -82,7 +93,7 @@ class AnnounceInfo(urwid.WidgetWrap): is_node = False is_pn = False if info_type == "node" or info_type == True: - type_string = "Nomad Network Node " + g["node"] + type_string = "NomadNet Node " + g["node"] is_node = True elif info_type == "pn": type_string = "LXMF Propagation Node " + g["sent"] @@ -153,7 +164,7 @@ class AnnounceInfo(urwid.WidgetWrap): display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -634,7 +645,7 @@ class KnownNodeInfo(urwid.WidgetWrap): lxmf_addr_str = "No associated Propagation Node known" - type_string = "Nomad Network Node " + g["node"] + type_string = "NomadNet Node " + g["node"] if trust_level == DirectoryEntry.UNTRUSTED: trust_str = "Untrusted" @@ -705,7 +716,7 @@ class KnownNodeInfo(urwid.WidgetWrap): display_name = op_str if not source_hash_text in [c[0] for c in existing_conversations]: - entry = DirectoryEntry(source_hash, display_name, trust_level) + entry = DirectoryEntry(op_hash, display_name, trust_level) self.app.directory.remember(entry) new_conversation = nomadnet.Conversation(source_hash_text, nomadnet.NomadNetworkApp.get_shared_instance(), initiator=True) @@ -1331,22 +1342,27 @@ class LocalPeer(urwid.WidgetWrap): announce_button = urwid.Button("Announce Now", on_press=announce_query) - self.display_widget = urwid.Pile( - [ - t_id, - i_id, - e_name, - urwid.Divider(g["divider1"]), - self.t_last_announce, - announce_button, - urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), - (urwid.WEIGHT, 0.1, urwid.Text("")), - (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), - ]), - ] - ) + peer_widgets = [ + t_id, + i_id, + e_name, + urwid.Divider(g["divider1"]), + self.t_last_announce, + announce_button, + urwid.Divider(g["divider1"]), + urwid.Columns([ + (urwid.WEIGHT, 0.45, urwid.Button("Save", on_press=save_query)), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, urwid.Button("Node Info", on_press=node_info_query)), + ]), + ] + + if self.app.enable_node: + peer_widgets.append( + urwid.AttrMap(urwid.Text(" "+g["node"]+" Node Online", align=urwid.LEFT), "shortcutbar") + ) + + self.display_widget = urwid.Pile(peer_widgets) super().__init__(urwid.LineBox(self.display_widget, title="Local Peer Info")) @@ -1420,6 +1436,37 @@ class NodeInfo(urwid.WidgetWrap): def connect_query(sender): self.parent.browser.retrieve_url(RNS.hexrep(self.app.node.destination.hash, delimit=False)) + def edit_query(sender): + self.app.ui.main_display.show_page_editor(None) + + def show_qr_query(sender): + def dismiss_dialog(sender): + self.dialog_open = False + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (NodeInfo(self.app, self.parent), options) + + dest_addr = RNS.hexrep(self.app.node.destination.hash, delimit=False) + qr_text = qr_ascii(dest_addr) + + rows = [urwid.Text("")] + if qr_text is not None: + rows.append(urwid.Text(qr_text, align=urwid.CENTER)) + else: + rows.append(urwid.Text("Node destination address:", align=urwid.CENTER)) + rows += [ + urwid.Text(""), + urwid.Text("< "+dest_addr+" >", align=urwid.CENTER), + urwid.Text(""), + urwid.Button("Close", on_press=dismiss_dialog), + ] + + dialog = DialogLineBox(urwid.Pile(rows), title="Node Destination") + dialog.delegate = self + + self.dialog_open = True + options = self.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + self.parent.left_pile.contents[1] = (dialog, options) + if NodeInfo.announce_timer == None: self.t_last_announce = NodeAnnounceTime(self.app) NodeInfo.announce_timer = self.t_last_announce @@ -1467,7 +1514,9 @@ class NodeInfo(urwid.WidgetWrap): announce_button = urwid.Button("Announce", on_press=announce_query) connect_button = urwid.Button("Browse", on_press=connect_query) + edit_button = urwid.Button("Edit", on_press=edit_query) reset_button = urwid.Button("Rst Stats", on_press=stats_query) + qr_button = urwid.Button("Show Destination", on_press=show_qr_query) if not self.app.disable_propagation: pile = urwid.Pile([ @@ -1491,6 +1540,11 @@ class NodeInfo(urwid.WidgetWrap): (urwid.WEIGHT, 8, reset_button), (urwid.WEIGHT, 0.5, urwid.Text("")), (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), ]) ]) else: @@ -1513,6 +1567,11 @@ class NodeInfo(urwid.WidgetWrap): (urwid.WEIGHT, 8, reset_button), (urwid.WEIGHT, 0.5, urwid.Text("")), (urwid.WEIGHT, 7, announce_button), + ]), + urwid.Columns([ + (urwid.WEIGHT, 4, edit_button), + (urwid.WEIGHT, 0.5, urwid.Text("")), + (urwid.WEIGHT, 9, qr_button), ]) ]) else: @@ -1640,10 +1699,11 @@ class NetworkDisplay(): self.known_nodes_display.delegate = self self.list_display = 1 + default_info_display = self.node_info_display if self.app.enable_node else self.local_peer_display self.left_pile = NetworkLeftPile([ (urwid.WEIGHT, 1, self.known_nodes_display), # (urwid.PACK, self.network_stats_display), - (urwid.PACK, self.local_peer_display), + (urwid.PACK, default_info_display), ]) self.left_pile.parent = self diff --git a/nomadnet/ui/textui/Transport.py b/nomadnet/ui/textui/Transport.py new file mode 100644 index 0000000..aaed7bb --- /dev/null +++ b/nomadnet/ui/textui/Transport.py @@ -0,0 +1,491 @@ +import RNS +import time +import urwid + +def _fmt_secs(secs): + secs = int(secs) + if secs < 0: + secs = 0 + if secs < 60: + return "%ds" % secs + if secs < 3600: + return "%dm" % (secs // 60) + if secs < 86400: + return "%dh" % (secs // 3600) + return "%dd" % (secs // 86400) + +def _rel_future(ts): + if not ts: + return "-" + remaining = ts - time.time() + if remaining <= 0: + return "expired" + return _fmt_secs(remaining) + +def _rel_past(ts): + if not ts: + return "-" + return _fmt_secs(time.time() - ts) + " ago" + +def _fmt_duration(secs): + if not secs: + return "n/a" + secs = int(secs) + if secs < 0: + secs = 0 + days = secs // 86400 + hours = (secs % 86400) // 3600 + minutes = (secs % 3600) // 60 + if days: + return "%dd %dh" % (days, hours) + if hours: + return "%dh %dm" % (hours, minutes) + if minutes: + return "%dm" % minutes + return "%ds" % secs + +def _fmt_rate(hz): + try: + hz = float(hz or 0) + except Exception: + return "0" + if hz <= 0: + return "0" + per_hour = hz * 3600 + if per_hour >= 1: + return "%.0f/h" % per_hour + return "%.1f/d" % (hz * 86400) + +def _mode_str(mode): + return {1: "Full", 2: "Point-to-Point", 3: "Access Point", 4: "Roaming", 5: "Boundary", 6: "Gateway"}.get(mode, "Full") + +class TransportRow(urwid.WidgetWrap): + def __init__(self, columns): + self._selectable = True + super().__init__(urwid.AttrMap(columns, "list_normal", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + return key + +class LazyTableWalker(urwid.ListWalker): + def __init__(self): + self.items = [] + self.factory = None + self.empty_widget = None + self.focus = 0 + + def configure(self, items, factory, empty_widget=None): + self.items = items or [] + self.factory = factory + self.empty_widget = empty_widget + if self.focus >= len(self.items): + self.focus = max(0, len(self.items) - 1) + self._modified() + + def _build(self, position): + return self.factory(self.items[position], position) + + def get_focus(self): + if not self.items: + if self.empty_widget is not None: + return (self.empty_widget, 0) + return (None, None) + if self.focus >= len(self.items): + self.focus = len(self.items) - 1 + if self.focus < 0: + self.focus = 0 + return (self._build(self.focus), self.focus) + + def set_focus(self, position): + self.focus = position + self._modified() + + def get_next(self, position): + n = position + 1 + if not self.items or n >= len(self.items): + return (None, None) + return (self._build(n), n) + + def get_prev(self, position): + p = position - 1 + if not self.items or p < 0: + return (None, None) + return (self._build(p), p) + + def positions(self, reverse=False): + count = len(self.items) + if count == 0: + return [0] if self.empty_widget is not None else [] + if reverse: + return range(count - 1, -1, -1) + return range(count) + + def __len__(self): + return len(self.items) + + def __getitem__(self, position): + if not self.items: + if position == 0 and self.empty_widget is not None: + return self.empty_widget + raise IndexError(position) + if position < 0 or position >= len(self.items): + raise IndexError(position) + return self._build(position) + +class TransportFiller(urwid.WidgetWrap): + def __init__(self, widget, display): + self.display = display + super().__init__(widget) + + def keypress(self, size, key): + if key == "tab": + self.display.cycle_tab() + return None + if key == "ctrl r" or key == "f5": + self.display.refresh() + return None + if key == "esc": + self.display.go_back() + return None + result = super().keypress(size, key) + if key == "up" and result == "up": + self.display.go_back_to_header() + return None + return result + +class TransportDisplayShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Tab] Switch Table [Up] Filter [C-r] Refresh [Esc] Back"), + "shortcutbar" + ) + +class TransportDisplay: + POLL_INTERVAL = 5 + + HASH_W = 34 + HOPS_W = 6 + VIA_W = 34 + EXP_W = 12 + LAST_W = 16 + VIOL_W = 11 + STATUS_W = 10 + PATHS_W = 8 + HELD_W = 8 + QUEUE_W = 8 + RATE_W = 10 + MODE_W = 12 + + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.started = False + self.poll_scheduler = False + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.active_tab = "paths" + self.paths = [] + self.rates = [] + self.interfaces = [] + self.paths_per_interface = {} + self.link_count = 0 + self.transport_id = None + self.network_id = None + self.transport_uptime = None + self.held_announces = 0 + self.filter_text = "" + + self.summary_text = urwid.Text("") + self.list_walker = LazyTableWalker() + self.list_box = urwid.ListBox(self.list_walker) + + self.filter_edit = urwid.Edit("", "") + urwid.connect_signal(self.filter_edit, "change", self._on_filter_change) + self.filter_row = urwid.Columns([ + (9, urwid.Text(("key", "Filter:"))), + urwid.AttrMap(self.filter_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1) + + self.pile = urwid.Pile([ + ('pack', self.summary_text), + ('pack', self._tab_bar()), + ('pack', self.filter_row), + ('pack', self._header_row()), + ('pack', urwid.Divider("─")), + ('weight', 1, self.list_box), + ]) + + self.transport_view = TransportFiller(self.pile, self) + self.shortcuts_display = TransportDisplayShortcuts(self.app) + self.widget = self.transport_view + + self._refresh_data() + self._rebuild() + + def shortcuts(self): + return self.shortcuts_display + + def _tab_bar(self): + def tab(label, key): + attr = "list_focus" if self.active_tab == key else "list_normal" + return (urwid.PACK, urwid.Text((attr, " %s " % label))) + + return urwid.Columns([ + tab("Paths", "paths"), + tab("Announce Rates", "rates"), + tab("Interfaces", "interfaces"), + (urwid.WEIGHT, 1, urwid.Text("")), + ], dividechars=1) + + def _header_row(self): + if self.active_tab == "paths": + cols = [ + (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")), + (self.HOPS_W, urwid.Text(("key", "Hops"), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")), + (self.VIA_W, urwid.Text(("key", "Via"), wrap="clip")), + (self.EXP_W, urwid.Text(("key", "Expires"), wrap="clip")), + ] + elif self.active_tab == "interfaces": + cols = [ + (urwid.WEIGHT, 1, urwid.Text(("key", "Interface"), wrap="clip")), + (self.STATUS_W, urwid.Text(("key", "Status"), wrap="clip")), + (self.PATHS_W, urwid.Text(("key", "Paths"), wrap="clip")), + (self.HELD_W, urwid.Text(("key", "Held"), wrap="clip")), + (self.QUEUE_W, urwid.Text(("key", "Queue"), wrap="clip")), + (self.RATE_W, urwid.Text(("key", "Ann in"), wrap="clip")), + (self.MODE_W, urwid.Text(("key", "Mode"), wrap="clip")), + ] + else: + cols = [ + (self.HASH_W, urwid.Text(("key", "Destination"), wrap="clip")), + (self.LAST_W, urwid.Text(("key", "Last announce"), wrap="clip")), + (self.VIOL_W, urwid.Text(("key", "Violations"), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(("key", "Blocked"), wrap="clip")), + ] + return urwid.Columns(cols, dividechars=1) + + def _refresh_data(self): + try: + self.paths = self.app.rns.get_path_table() or [] + except Exception: + self.paths = [] + try: + self.rates = self.app.rns.get_rate_table() or [] + except Exception: + self.rates = [] + try: + self.link_count = self.app.rns.get_link_count() + except Exception: + self.link_count = 0 + + try: + stats = self.app.rns.get_interface_stats() or {} + except Exception: + stats = {} + self.transport_id = stats.get("transport_id") + self.network_id = stats.get("network_id") + self.transport_uptime = stats.get("transport_uptime") + self.interfaces = stats.get("interfaces", []) or [] + try: + self.held_announces = sum(int(i.get("held_announces", 0) or 0) for i in self.interfaces) + except Exception: + self.held_announces = 0 + + self.paths_per_interface = {} + for entry in self.paths: + iface = entry.get("interface", "") + self.paths_per_interface[iface] = self.paths_per_interface.get(iface, 0) + 1 + + try: + self.interfaces.sort(key=lambda i: self.paths_per_interface.get(i.get("name", ""), 0), reverse=True) + except Exception: + pass + + try: + self.paths.sort(key=lambda e: (e.get("interface", ""), e.get("hops", 0))) + except Exception: + pass + try: + self.rates.sort(key=lambda e: e.get("last", 0), reverse=True) + except Exception: + pass + + def _make_path_row(self, entry, position): + dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?" + via = RNS.prettyhexrep(entry["via"]) if entry.get("via") else "?" + hops = entry.get("hops", 0) + iface = entry.get("interface", "") + expires = _rel_future(entry.get("expires", 0)) + + columns = urwid.Columns([ + (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")), + (self.HOPS_W, urwid.Text(str(hops), wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(iface, wrap="clip")), + (self.VIA_W, urwid.Text(via, wrap="clip")), + (self.EXP_W, urwid.Text(expires, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _make_rate_row(self, entry, position): + now = time.time() + dest = RNS.prettyhexrep(entry["hash"]) if entry.get("hash") else "?" + last = _rel_past(entry.get("last", 0)) + violations = str(entry.get("rate_violations", 0)) + blocked_until = entry.get("blocked_until", 0) or 0 + if blocked_until > now: + blocked = ("disconnected_status", "blocked %s" % _rel_future(blocked_until)) + else: + blocked = ("connected_status", "no") + + columns = urwid.Columns([ + (self.HASH_W, urwid.Text(("list_normal", dest), wrap="clip")), + (self.LAST_W, urwid.Text(last, wrap="clip")), + (self.VIOL_W, urwid.Text(violations, wrap="clip")), + (urwid.WEIGHT, 1, urwid.Text(blocked, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _make_iface_row(self, entry, position): + name = entry.get("short_name") or entry.get("name") or "?" + paths = self.paths_per_interface.get(entry.get("name", ""), 0) + held = entry.get("held_announces", 0) or 0 + queue = entry.get("announce_queue") + queue_str = "-" if queue is None else str(queue) + rate = _fmt_rate(entry.get("incoming_announce_frequency", 0)) + mode = _mode_str(entry.get("mode")) + + if entry.get("status"): + status = ("connected_status", "up") + else: + status = ("disconnected_status", "down") + + columns = urwid.Columns([ + (urwid.WEIGHT, 1, urwid.Text(("list_normal", name), wrap="clip")), + (self.STATUS_W, urwid.Text(status, wrap="clip")), + (self.PATHS_W, urwid.Text(str(paths), wrap="clip")), + (self.HELD_W, urwid.Text(str(held), wrap="clip")), + (self.QUEUE_W, urwid.Text(queue_str, wrap="clip")), + (self.RATE_W, urwid.Text(rate, wrap="clip")), + (self.MODE_W, urwid.Text(mode, wrap="clip")), + ], dividechars=1) + return TransportRow(columns) + + def _rebuild(self): + blocked_now = 0 + try: + now = time.time() + blocked_now = sum(1 for r in self.rates if (r.get("blocked_until", 0) or 0) > now) + except Exception: + pass + + transport_id = RNS.prettyhexrep(self.transport_id) if self.transport_id else "n/a" + network_id = RNS.prettyhexrep(self.network_id) if self.network_id else "n/a" + uptime = _fmt_duration(self.transport_uptime) + + summary = [ + ("key", "Transport ID: "), ("value", transport_id), + ("key", " Network ID: "), ("value", network_id), + ("key", " Uptime: "), ("value", uptime), "\n", + ("key", "Active links: "), ("value", str(self.link_count)), + ("key", " Known paths: "), ("value", str(len(self.paths))), + ("key", " Held announces: "), ("value", str(self.held_announces)), + ("key", " Announce sources: "), ("value", str(len(self.rates))), + ("key", " Rate-limited: "), ("value", str(blocked_now)), + ] + + if self.active_tab == "paths": + data = self._filtered(self.paths) + factory = self._make_path_row + empty_msg = "No paths in the routing table yet." + elif self.active_tab == "interfaces": + data = self._filtered(self.interfaces) + factory = self._make_iface_row + empty_msg = "No interface statistics available." + else: + data = self._filtered(self.rates) + factory = self._make_rate_row + empty_msg = "No announce rate data available." + + if self.filter_text.strip(): + summary.append(("inactive_text", " [filter: %d shown]" % len(data))) + self.summary_text.set_text(summary) + + self.pile.contents[1] = (self._tab_bar(), self.pile.options('pack')) + self.pile.contents[3] = (self._header_row(), self.pile.options('pack')) + + empty = urwid.Text(("inactive_text", empty_msg), align=urwid.CENTER) + self.list_walker.configure(data, factory, empty) + + def cycle_tab(self): + order = ["paths", "rates", "interfaces"] + i = order.index(self.active_tab) if self.active_tab in order else 0 + self.active_tab = order[(i + 1) % len(order)] + self._rebuild() + + def _on_filter_change(self, widget, new_text): + self.filter_text = new_text + self._rebuild() + + def _haystack(self, entry): + parts = [] + for key in ("hash", "via"): + value = entry.get(key) + if value: + parts.append(RNS.prettyhexrep(value)) + for key in ("interface", "name", "short_name"): + value = entry.get(key) + if value: + parts.append(str(value)) + return " ".join(parts).lower() + + def _filtered(self, items): + needle = self.filter_text.strip().lower() + if not needle: + return items + return [entry for entry in items if needle in self._haystack(entry)] + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _is_visible(self): + md = self.app.ui.main_display + if self.parent is not None: + return md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "transport" + return md.sub_displays.active_display is self + + def refresh(self): + self._refresh_data() + self._rebuild() + + def start(self): + self.started = True + self._refresh_data() + self._rebuild() + if not self.poll_scheduler and self.app.ui.loop is not None: + self.poll_scheduler = True + self.app.ui.loop.set_alarm_in(self.POLL_INTERVAL, self._poll) + + def _poll(self, loop, user_data): + if not self.started or not self._is_visible(): + self.poll_scheduler = False + return + self._refresh_data() + self._rebuild() + try: + loop.draw_screen() + except Exception: + pass + loop.set_alarm_in(self.POLL_INTERVAL, self._poll) diff --git a/nomadnet/ui/textui/Utilities.py b/nomadnet/ui/textui/Utilities.py new file mode 100644 index 0000000..771cd0b --- /dev/null +++ b/nomadnet/ui/textui/Utilities.py @@ -0,0 +1,1766 @@ +import RNS +import os +import time +import tempfile +import threading +import nomadnet +import urwid + +from .Transport import TransportDisplay +from .ReadlineEdit import ReadlineEdit +from .FileBrowser import FileBrowser +from nomadnet.vendor.additional_urwid_widgets.FormWidgets import Dropdown + +class OutputLog: + def __init__(self, title="Output"): + self.walker = urwid.SimpleListWalker([]) + self.listbox = urwid.ListBox(self.walker) + self.widget = urwid.LineBox( + urwid.ScrollBar(self.listbox), + title=title, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + + def log(self, message, attr="body_text"): + self.walker.append(urwid.Text((attr, message))) + try: + self.listbox.set_focus(len(self.walker) - 1) + self.listbox.set_focus_valign("bottom") + except Exception: + pass + + def clear(self): + self.walker[:] = [] + +def _utility_body(form_rows, output): + return urwid.Pile([ + ('pack', urwid.Pile(form_rows)), + ('weight', 1, output.widget), + ]) + +DEFAULT_PROBE_SIZE = 16 +DEFAULT_PROBE_TIMEOUT = 12 + +def _interface_enabled(iface): + try: + return str(iface.get("enabled")).lower() not in ("false", "off", "no", "0") and \ + str(iface.get("interface_enabled")).lower() not in ("false", "off", "no", "0") + except Exception: + return True + +def _has_active_interfaces(app): + try: + interfaces = app.rns.config["interfaces"] + except Exception: + return True + try: + return any(_interface_enabled(interfaces[name]) for name in interfaces) + except Exception: + return True + +class UtilityItem(urwid.WidgetWrap): + def __init__(self, parent, name, description, on_select): + self.parent = parent + self.name = name + self.on_select = on_select + self._selectable = True + + self.selection_txt = urwid.Text(" ") + self.title_widget = urwid.Text(("interface_title", name)) + + title_row = urwid.Columns([ + (4, self.selection_txt), + self.title_widget, + ]) + description_row = urwid.Columns([ + (4, urwid.Text(" ")), + urwid.Text(("value", description)), + ]) + + box = urwid.LineBox( + urwid.Padding(urwid.Pile([title_row, description_row]), left=2, right=2), + title=None, + tlcorner="╭", tline="─", + trcorner="╮", lline="│", + rline="│", blcorner="╰", + bline="─", brcorner="╯" + ) + super().__init__(box) + + def selectable(self): + return True + + def render(self, size, focus=False): + self.selection_txt.set_text(self.parent.g['selected'] if focus else self.parent.g['unselected']) + attr = "interface_title_selected" if focus else "interface_title" + self.title_widget.set_text((attr, self.name)) + return super().render(size, focus=focus) + + def keypress(self, size, key): + if key == "up": + if self.parent.landing_listbox.focus_position == 0: + self.parent.app.ui.main_display.frame.focus_position = "header" + return None + elif key == "enter": + self.on_select() + return None + return key + +class UtilitiesDisplayShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Enter] Open [Up/Down] Navigate"), + "shortcutbar" + ) + +class UtilitiesDisplay: + def __init__(self, app): + self.app = app + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.active = "landing" + self.transport = None + self.probe = None + self.path = None + self.identity = None + self.send = None + self.speedtest = None + + self.shortcuts_display = UtilitiesDisplayShortcuts(self.app) + self._build_landing() + self.widget = self.landing_view + + def _build_landing(self): + items = [ + UtilityItem(self, "Path Tables", "View routing, announce rate and per-interface tables", self.open_transport), + UtilityItem(self, "Path Resolver", "Look up or drop the path to a destination", self.open_path), + UtilityItem(self, "Probe", "Send probes to a destination and measure round trip", self.open_probe), + UtilityItem(self, "Identity", "Show your addresses and look up a destination's identity", self.open_identity), + UtilityItem(self, "Send File", "Send a file to a destination over Reticulum to a rncp listener", self.open_send), + UtilityItem(self, "Speed Test", "Measure throughput to a peer also running this screen", self.open_speedtest), + ] + self.landing_listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) + + self.landing_view = urwid.Pile([ + ('pack', urwid.Text(("body_text", "Select a utility to open."), align=urwid.CENTER)), + ('pack', urwid.Divider("─")), + ('weight', 1, self.landing_listbox), + ]) + + def shortcuts(self): + if self.active == "transport" and self.transport is not None: + return self.transport.shortcuts_display + if self.active == "probe" and self.probe is not None: + return self.probe.shortcuts_display + if self.active == "path" and self.path is not None: + return self.path.shortcuts_display + if self.active == "identity" and self.identity is not None: + return self.identity.shortcuts_display + if self.active == "send" and self.send is not None: + return self.send.shortcuts_display + if self.active == "speedtest" and self.speedtest is not None: + return self.speedtest.shortcuts_display + return self.shortcuts_display + + def _leave_speedtest(self): + if self.speedtest is not None and self.active == "speedtest": + self.speedtest.stop_listening() + + def start(self): + self.show_landing() + + def show_landing(self): + self._leave_speedtest() + self.active = "landing" + self.widget = self.landing_view + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_transport(self): + if self.transport is None: + self.transport = TransportDisplay(self.app, parent=self) + self.active = "transport" + self.widget = self.transport.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.transport.start() + + def open_probe(self): + if self.probe is None: + self.probe = ProbeView(self.app, parent=self) + self.probe.update_interface_status() + self.active = "probe" + self.widget = self.probe.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_path(self): + if self.path is None: + self.path = PathView(self.app, parent=self) + self.path.update_interface_status() + self.active = "path" + self.widget = self.path.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_identity(self): + if self.identity is None: + self.identity = IdentityView(self.app, parent=self) + self.active = "identity" + self.widget = self.identity.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_send(self): + if self.send is None: + self.send = SendFileView(self.app, parent=self) + self.send.update_interface_status() + self.active = "send" + self.widget = self.send.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def open_speedtest(self): + if self.speedtest is None: + self.speedtest = SpeedTestView(self.app, parent=self) + self.active = "speedtest" + self.widget = self.speedtest.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self.speedtest.update_interface_status() + self.speedtest.start_listening() + +class ProbeShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Probe / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class UtilityViewFiller(urwid.WidgetWrap): + def __init__(self, body, view): + self.view = view + super().__init__(body) + + def keypress(self, size, key): + if key == "esc": + self.view.go_back() + return None + if key == "ctrl l": + if hasattr(self.view, "clear_output"): + self.view.clear_output() + return None + result = super().keypress(size, key) + if hasattr(self.view, "after_keypress"): + self.view.after_keypress() + if key == "up" and result == "up": + self.view.go_back_to_header() + return None + return result + +class ProbeView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = ProbeShortcuts(self.app) + + self._probing = False + self._concluded = False + self._remaining = 0 + self._sent = 0 + self._replies = 0 + self._cur_receipt = None + + self.name_edit = ReadlineEdit("", "rnstransport.probe") + self.hash_edit = ReadlineEdit("", "") + self.size_edit = ReadlineEdit("", str(DEFAULT_PROBE_SIZE)) + self.count_edit = ReadlineEdit("", "1") + + self.probe_button = urwid.AttrMap(urwid.Button("Probe", on_press=self.on_probe), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.probe_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Probe a destination"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + self._field_row("Destination name:", self.name_edit), + urwid.Padding(urwid.Text(("inactive_text", "Full dotted name for example rnstransport.probe")), left=22), + self._field_row("Destination hash:", self.hash_edit), + self._field_row("Payload size:", self.size_edit), + self._field_row("Probe count:", self.count_edit), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def _field_row(self, label, edit): + return urwid.Columns([ + (20, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.AttrMap(edit, "list_normal", focus_map="list_focus"), + ], dividechars=1) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def on_probe(self, button=None): + if self._probing: + return + + self.update_interface_status() + + full_name = self.name_edit.edit_text.strip() + hexhash = self.hash_edit.edit_text.strip() + + if not full_name: + self._log("Enter a destination name (e.g. rnstransport.probe)", "error_text") + return + + try: + app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name) + except Exception as e: + self._log("Invalid destination name: %s" % str(e), "error_text") + return + + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + destination_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + try: + size = int(self.size_edit.edit_text.strip() or str(DEFAULT_PROBE_SIZE)) + except ValueError: + size = DEFAULT_PROBE_SIZE + try: + count = int(self.count_edit.edit_text.strip() or "1") + except ValueError: + count = 1 + if count < 1: + count = 1 + + self._app_name = app_name + self._aspects = aspects + self._dest_hash = destination_hash + self._size = max(1, size) + self._remaining = count + self._sent = 0 + self._replies = 0 + self._probing = True + self.probe_button.original_widget.set_label("Probing…") + self._begin_probe() + + def _begin_probe(self): + dest_hash = self._dest_hash + if not RNS.Transport.has_path(dest_hash): + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._path_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + else: + self._send_one() + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._send_one() + elif time.time() > self._path_deadline: + self._log("Path request timed out", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _send_one(self): + dest_hash = self._dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + + try: + request_destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, self._app_name, *self._aspects) + probe = RNS.Packet(request_destination, os.urandom(self._size)) + probe.pack() + except OSError: + self._log("Probe payload of %d bytes exceeds the MTU" % self._size, "error_text") + self._finish() + return + except Exception as e: + self._log("Could not create probe: %s" % str(e), "error_text") + self._finish() + return + + self._concluded = False + receipt = probe.send() + self._cur_receipt = receipt + self._sent += 1 + self._log("Sent probe %d (%d bytes) to %s …" % (self._sent, self._size, RNS.prettyhexrep(dest_hash))) + + try: + receipt.set_delivery_callback(self._on_delivered) + except Exception: + pass + self.app.ui.loop.set_alarm_in(self._timeout(), self._on_timeout, user_data=receipt) + + def _on_delivered(self, receipt): + self._schedule(lambda: self._conclude(receipt, True)) + + def _on_timeout(self, loop, user_data): + if user_data is not self._cur_receipt: + return + self._conclude(user_data, False) + + def _conclude(self, receipt, delivered): + if self._concluded: + return + if receipt is not self._cur_receipt: + return + self._concluded = True + + is_delivered = delivered + if not is_delivered and receipt is not None: + try: + is_delivered = receipt.status == RNS.PacketReceipt.DELIVERED + except Exception: + is_delivered = False + + if is_delivered and receipt is not None: + self._replies += 1 + try: + hops = RNS.Transport.hops_to(self._dest_hash) + except Exception: + hops = 0 + try: + rtt = receipt.get_rtt() + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + except Exception: + rtt_str = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Reply from %s %s over %s%s" % ( + RNS.prettyhexrep(receipt.destination.hash), rtt_str, hop_str, self._reception_stats(receipt)), + "connected_status") + else: + self._log("Probe %d timed out" % self._sent, "warning_text") + + self._next() + + def _reception_stats(self, receipt): + stats = "" + try: + proof_packet = getattr(receipt, "proof_packet", None) + if proof_packet is None: + return "" + if getattr(self.app.rns, "is_connected_to_shared_instance", False): + packet_hash = proof_packet.packet_hash + rssi = self.app.rns.get_packet_rssi(packet_hash) + snr = self.app.rns.get_packet_snr(packet_hash) + q = self.app.rns.get_packet_q(packet_hash) + else: + rssi = getattr(proof_packet, "rssi", None) + snr = getattr(proof_packet, "snr", None) + q = None + if rssi is not None: + stats += " [RSSI %s dBm]" % rssi + if snr is not None: + stats += " [SNR %s dB]" % snr + if q is not None: + stats += " [Q %s%%]" % q + except Exception: + pass + return stats + + def _next(self): + self._remaining -= 1 + if self._remaining > 0: + self._send_one() + else: + self._finish() + + def _finish(self): + if self._sent > 0: + loss = round((1 - (self._replies / self._sent)) * 100, 1) + self._log("Sent %d, received %d, packet loss %s%%" % (self._sent, self._replies, loss), "key") + self._probing = False + self.probe_button.original_widget.set_label("Probe") + self._draw() + +class PathShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Resolve / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class PathView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + + self.shortcuts_display = PathShortcuts(self.app) + + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + + self.resolve_button = urwid.AttrMap(urwid.Button("Resolve", on_press=self.on_resolve), "button_normal", focus_map="button_focus") + self.drop_button = urwid.AttrMap(urwid.Button("Drop path", on_press=self.on_drop), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.3, self.resolve_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.drop_button), + (urwid.WEIGHT, 0.05, urwid.Text("")), + (urwid.WEIGHT, 0.3, self.back_button), + ]) + + self.status_text = urwid.Text("") + + rows = [ + urwid.Text(("form_title", "Resolve a path"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Requests the path from the network if it is not already known.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _validate_hash(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return None + try: + return bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return None + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_resolve(self, button=None): + if self._resolving: + return + self.update_interface_status() + dest_hash = self._validate_hash() + if dest_hash is None: + return + self._dest_hash = dest_hash + if RNS.Transport.has_path(dest_hash): + self._show_path() + return + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.resolve_button.original_widget.set_label("Resolving…") + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._show_path() + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.resolve_button.original_widget.set_label("Resolve") + self._log("Path not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _show_path(self): + dest_hash = self._dest_hash + try: + hops = RNS.Transport.hops_to(dest_hash) + next_hop_bytes = self.app.rns.get_next_hop(dest_hash) + except Exception: + next_hop_bytes = None + hops = 0 + if not next_hop_bytes: + self._log("Path data unavailable", "warning_text") + return + try: + interface = self.app.rns.get_next_hop_if_name(dest_hash) + except Exception: + interface = "?" + hop_str = "%d hop%s" % (hops, "" if hops == 1 else "s") + self._log("Path found: %s is %s away via %s on %s" % ( + RNS.prettyhexrep(dest_hash), hop_str, RNS.prettyhexrep(next_hop_bytes), interface), + "connected_status") + + def on_drop(self, button=None): + dest_hash = self._validate_hash() + if dest_hash is None: + return + try: + dropped = self.app.rns.drop_path(dest_hash) + except Exception as e: + self._log("Could not drop path: %s" % str(e), "error_text") + return + if dropped: + self._log("Dropped path to %s" % RNS.prettyhexrep(dest_hash)) + else: + self._log("No path to %s to drop" % RNS.prettyhexrep(dest_hash), "warning_text") + +class IdentityShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Look up / Activate [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class IdentityView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = IdentityShortcuts(self.app) + self._resolving = False + + self.hash_edit = ReadlineEdit("", "") + self.lookup_button = urwid.AttrMap(urwid.Button("Look up", on_press=self.on_lookup), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.lookup_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Identity"), align=urwid.CENTER), + urwid.Divider("─"), + urwid.Text(("key", "Your addresses")), + ] + rows += self._address_rows() + rows += [ + urwid.Divider(), + urwid.Text(("key", "Look up a destination")), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Recalls the identity, requesting it from the network if needed.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + + def _addr(self, obj): + try: + return RNS.prettyhexrep(obj.hash) if obj is not None else "" + except Exception: + return "" + + def _address_rows(self): + rows = [] + + def row(label, value): + return urwid.Columns([ + (18, urwid.Text(("key", label), align=urwid.RIGHT)), + urwid.Text(("value", value)), + ], dividechars=1) + + rows.append(row("Identity:", self._addr(getattr(self.app, "identity", None)))) + rows.append(row("LXMF address:", self._addr(getattr(self.app, "lxmf_destination", None)))) + node = getattr(self.app, "node", None) + if node is not None: + rows.append(row("Node:", self._addr(getattr(node, "destination", None)))) + return rows + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def on_lookup(self, button=None): + if self._resolving: + return + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + identity = RNS.Identity.recall(dest_hash) + if identity is not None: + self._show_identity(identity) + return + + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._resolving = True + self.lookup_button.original_widget.set_label("Looking up…") + self._log("Identity unknown, requesting from the network …") + try: + self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(dest_hash) + except Exception: + self._deadline = time.time() + DEFAULT_PROBE_TIMEOUT + self.app.ui.loop.set_alarm_in(0.25, self._await_identity) + + def _await_identity(self, loop, user_data): + identity = RNS.Identity.recall(self._dest_hash) + if identity is not None: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._show_identity(identity) + self._draw() + elif time.time() > self._deadline: + self._resolving = False + self.lookup_button.original_widget.set_label("Look up") + self._log("Identity not found", "warning_text") + self._draw() + else: + loop.set_alarm_in(0.25, self._await_identity) + + def _show_identity(self, identity): + self._log("%s resolves to identity %s" % ( + RNS.prettyhexrep(self._dest_hash), RNS.prettyhexrep(identity.hash)), "connected_status") + +RNCP_APP_NAME = "rncp" + +class SendFileShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Choose / Send [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class SendFileView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SendFileShortcuts(self.app) + + self._sending = False + self._concluded = False + self._link = None + self._resource = None + self.file_path = None + + self.hash_edit = ReadlineEdit("", "") + self.file_label = urwid.Text(("inactive_text", "No file selected")) + self.status_text = urwid.Text("") + self.progress_text = urwid.Text("") + + self.choose_button = urwid.AttrMap(urwid.Button("Choose file…", on_press=self.choose_file), "button_normal", focus_map="button_focus") + self.send_button = urwid.AttrMap(urwid.Button("Send", on_press=self.on_send), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.send_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Send a file"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + urwid.Columns([ + (20, urwid.Text(("key", "File:"), align=urwid.RIGHT)), + urwid.AttrMap(self.choose_button, "button_normal", focus_map="button_focus"), + ], dividechars=1), + urwid.Padding(self.file_label, left=22), + urwid.Columns([ + (20, urwid.Text(("key", "Destination hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "The receiver must be running 'rncp --listen' and have your identity hash in the allow list.")), left=22), + urwid.Divider(), + button_row, + urwid.Divider("─"), + self.progress_text, ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + self.update_interface_status() + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def go_back(self): + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def choose_file(self, button=None): + def on_done(selected): + if selected: + self.file_path = selected[0] + self.file_label.set_text(("value", os.path.basename(self.file_path))) + self._close_browser() + + def on_cancel(): + self._close_browser() + + browser = FileBrowser(on_done=on_done, on_cancel=on_cancel, title="Select File to Send", multi_select=False) + overlay = urwid.Overlay(browser, self.widget, align=urwid.CENTER, width=("relative", 90), valign=urwid.MIDDLE, height=("relative", 80), left=2, right=2) + self.parent.widget = overlay + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + + def _close_browser(self): + self.parent.widget = self.widget + self.app.ui.main_display.update_active_sub_display() + self.app.ui.main_display.frame.focus_position = "body" + self._draw() + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def on_send(self, button=None): + if self._sending: + return + self.update_interface_status() + + if not self.file_path or not os.path.isfile(self.file_path): + self._log("Choose a file to send first", "error_text") + return + + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + self._log("Destination hash must be %d hex characters" % dest_len, "error_text") + return + try: + dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid destination hash", "error_text") + return + + self._dest_hash = dest_hash + self._sending = True + self._concluded = False + self._resource = None + self.send_button.original_widget.set_label("Sending…") + self.progress_text.set_text("") + + if RNS.Transport.has_path(dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _await_path(self, loop, user_data): + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for destination", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, RNCP_APP_NAME, "receive") + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._link = RNS.Link(destination, established_callback=self._on_link_up, closed_callback=self._on_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._resource is not None: + return + try: + active = self._link is not None and self._link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_link_up(self, link): + self._schedule(lambda: self._start_resource(link)) + + def _start_resource(self, link): + if not self._sending or self._resource is not None: + return + try: + link.identify(self.app.identity) + metadata = {"name": os.path.basename(self.file_path).encode("utf-8")} + self._resource = RNS.Resource(open(self.file_path, "rb"), link, metadata=metadata, + callback=self._on_resource_done, progress_callback=self._on_progress, auto_compress=True) + self._log("Advertising %s …" % os.path.basename(self.file_path)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._finish() + + def _on_progress(self, resource): + self._schedule(lambda: self._show_progress(resource)) + + def _show_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self.progress_text.set_text(("body_text", "Transferring… %d%%" % pct)) + + def _on_resource_done(self, resource): + self._schedule(lambda: self._resource_done(resource)) + + def _resource_done(self, resource): + if self._concluded: + return + self._concluded = True + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + self.progress_text.set_text(("connected_status", "Transferring… 100%")) + self._log("Transfer complete", "connected_status") + else: + self._log("Transfer failed or the file was not accepted", "warning_text") + try: + if self._link is not None: + self._link.teardown() + except Exception: + pass + self._finish() + + def _on_link_closed(self, link): + self._schedule(self._link_closed) + + def _link_closed(self): + if self._sending and self._resource is None and not self._concluded: + self._log("Link closed before the transfer started", "warning_text") + self._finish() + + def _finish(self): + self._sending = False + self.send_button.original_widget.set_label("Send") + self._draw() + + +SPEEDTEST_APP_NAME = "nomadnetwork" +SPEEDTEST_ASPECTS = ("utilities", "speedtest") +SPEEDTEST_FULL_NAME = ".".join((SPEEDTEST_APP_NAME,) + SPEEDTEST_ASPECTS) +SPEEDTEST_INMEM_MAX = 8 * 1024 * 1024 + +SPEEDTEST_SIZES = [ + ("2 KB", 2 * 1024), + ("8 KB", 8 * 1024), + ("32 KB", 32 * 1024), + ("128 KB", 128 * 1024), + ("512 KB", 512 * 1024), + ("1 MB", 1024 * 1024), + ("4 MB", 4 * 1024 * 1024), + ("16 MB", 16 * 1024 * 1024), + ("64 MB", 64 * 1024 * 1024), + ("256 MB", 256 * 1024 * 1024), + ("1000 MB", 1000 * 1024 * 1024), + ("2000 MB", 2000 * 1024 * 1024), +] +SPEEDTEST_SIZE_LABELS = [label for label, _ in SPEEDTEST_SIZES] +SPEEDTEST_BYTES_BY_LABEL = {label: nbytes for label, nbytes in SPEEDTEST_SIZES} +DEFAULT_SPEEDTEST_LABEL = "1 MB" + +def _fmt_bitrate(bytes_per_sec): + bits = float(bytes_per_sec) * 8 + for unit in ("bps", "Kbps", "Mbps", "Gbps"): + if bits < 1000: + return "%.1f %s" % (bits, unit) + bits /= 1000 + return "%.1f Tbps" % bits + +def _size_str(num, suffix="B"): + num = float(num) + for unit in ("", "K", "M", "G", "T"): + if abs(num) < 1024.0: + return "%.1f %s%s" % (num, unit, suffix) + num /= 1024.0 + return "%.1f P%s" % (num, suffix) + +class SpeedTestShortcuts: + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap( + urwid.Text("[Up/Down] Navigate [Enter] Start / Cancel [C-l] Clear [Esc] Back"), + "shortcutbar" + ) + +class SpeedTestView: + def __init__(self, app, parent=None): + self.app = app + self.parent = parent + self.glyphset = self.app.config["textui"]["glyphs"] + self.g = self.app.ui.glyphs + self.shortcuts_display = SpeedTestShortcuts(self.app) + + self.server_destination = None + self._listening = False + self._incoming_link = None + self._recv_link = None + self._recv_resource = None + self._recv_started = None + self._recv_polling = False + + self._sending = False + self._client_link = None + self._client_resource = None + self._send_started = None + self._send_bytes = 0 + self._send_seq = 0 + self._tmppath = None + + self.hash_edit = ReadlineEdit("", "") + self.size_dropdown = Dropdown("", SPEEDTEST_SIZE_LABELS, DEFAULT_SPEEDTEST_LABEL) + self.address_text = urwid.Text(("value", "-")) + self.status_text = urwid.Text("") + self.listen_status = urwid.Text("") + self.rate_text = urwid.Text("") + self.progress_bar = urwid.ProgressBar("progress_empty", "progress_full", current=0, done=100) + + self.start_button = urwid.AttrMap(urwid.Button("Send test file", on_press=self.on_start), "button_normal", focus_map="button_focus") + self.back_button = urwid.AttrMap(urwid.Button("Back", on_press=lambda b: self.go_back()), "button_normal", focus_map="button_focus") + button_row = urwid.Columns([ + (urwid.WEIGHT, 0.45, self.start_button), + (urwid.WEIGHT, 0.1, urwid.Text("")), + (urwid.WEIGHT, 0.45, self.back_button), + ]) + + rows = [ + urwid.Text(("form_title", "Speed test"), align=urwid.CENTER), + urwid.Divider("─"), + self.status_text, + self.listen_status, + urwid.Columns([ + (22, urwid.Text(("key", "Your speedtest hash:"), align=urwid.RIGHT)), + self.address_text, + ], dividechars=1), + urwid.Padding(urwid.Text(("inactive_text", "Both sides open this screen and enter each other's hash; then one presses Start.")), left=24), + urwid.Columns([ + (22, urwid.Text(("key", "Peer hash:"), align=urwid.RIGHT)), + urwid.AttrMap(self.hash_edit, "list_normal", focus_map="list_focus"), + ], dividechars=1), + urwid.Columns([ + (22, urwid.Text(("key", "Test size:"), align=urwid.RIGHT)), + self.size_dropdown, + ], dividechars=1), + urwid.Divider(), + button_row, + urwid.Divider("─"), + urwid.Columns([ + (22, urwid.Text(("key", "Progress:"), align=urwid.RIGHT)), + self.progress_bar, + ], dividechars=1), + urwid.Padding(self.rate_text, left=24), + ] + self.output = OutputLog() + self.widget = UtilityViewFiller(_utility_body(rows, self.output), self) + + def update_interface_status(self): + if _has_active_interfaces(self.app): + self.status_text.set_text("") + else: + self.status_text.set_text(("warning_text", "(!) No interfaces are active")) + + def _peer_hash_entered(self): + return len(self.hash_edit.edit_text.strip()) == (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + + def _update_listen_status(self): + dot = self.g.get("connected", "*") + if self._sending: + self.listen_status.set_text(("connected_status", "%s %s Sending test…" % (dot, self.g.get("arrow_u", "^")))) + elif self._recv_resource is not None or self._recv_link is not None: + self.listen_status.set_text(("connected_status", "%s %s Receiving test…" % (dot, self.g.get("arrow_d", "v")))) + elif self._listening and self.server_destination is not None: + if self._peer_hash_entered(): + self.listen_status.set_text(("connected_status", "%s Listening, ready to receive from or send to the entered peer" % dot)) + else: + self.listen_status.set_text(("warning_text", "%s Listening, enter the peer's hash to accept their test" % dot)) + else: + self.listen_status.set_text(("inactive_text", "Not listening")) + + def after_keypress(self): + self._update_listen_status() + + def _log(self, message, attr="body_text"): + self.output.log(message, attr) + + def clear_output(self): + self.output.clear() + + def _draw(self): + try: + self.app.ui.loop.draw_screen() + except Exception: + pass + + def _schedule(self, fn): + try: + self.app.ui.loop.set_alarm_in(0, lambda *a: (fn(), self._draw())) + except Exception: + try: + fn() + except Exception: + pass + + def _set_progress(self, pct): + try: + self.progress_bar.set_completion(max(0, min(100, pct))) + except Exception: + pass + + def _update_address(self): + if self.server_destination is not None: + self.address_text.set_text(("value", RNS.prettyhexrep(self.server_destination.hash))) + else: + self.address_text.set_text(("value", "-")) + + def start_listening(self): + if self.server_destination is None: + try: + self.server_destination = RNS.Destination(self.app.identity, RNS.Destination.IN, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self.server_destination.set_link_established_callback(self._on_incoming_link) + try: + self.server_destination.accepts_links(True) + except Exception: + pass + except Exception as e: + self._log("Could not start listener: %s" % str(e), "error_text") + self.server_destination = None + self._listening = True + self._update_address() + self._update_listen_status() + + def stop_listening(self): + self._listening = False + self._recv_polling = False + try: + if self._incoming_link is not None: + self._incoming_link.teardown() + except Exception: + pass + self._incoming_link = None + self._recv_link = None + self._recv_resource = None + try: + if self.server_destination is not None: + RNS.Transport.deregister_destination(self.server_destination) + except Exception: + pass + self.server_destination = None + self._update_address() + self._update_listen_status() + + def _log_link_mtu(self, link): + try: + parts = [] + mtu = getattr(link, "mtu", None) + mdu = getattr(link, "mdu", None) + if mtu: + parts.append("MTU: %s (MDU %s)" % (RNS.prettysize(mtu), RNS.prettysize(mdu) if mdu else "?")) + rtt = getattr(link, "rtt", None) + if rtt: + if hasattr(RNS, "prettyshorttime"): + rtt_str = RNS.prettyshorttime(rtt) + else: + rtt_str = "%.0f ms" % (rtt * 1000) if rtt < 1 else "%.3f s" % rtt + parts.append("RTT: %s" % rtt_str) + if parts: + self._log("Link " + ", ".join(parts)) + except Exception: + pass + + def _is_active(self): + try: + md = self.app.ui.main_display + return self._listening and md.sub_displays.active_display is self.parent and getattr(self.parent, "active", None) == "speedtest" + except Exception: + return False + + def _entered_peer_hash(self): + hexhash = self.hash_edit.edit_text.strip() + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + if len(hexhash) != dest_len: + return None + try: + return bytes.fromhex(hexhash) + except Exception: + return None + + def go_back(self): + self.cancel(silent=True) + self.stop_listening() + if self.parent is not None: + self.parent.show_landing() + + def go_back_to_header(self): + try: + self.app.ui.main_display.frame.focus_position = "header" + except Exception: + pass + + def _cleanup_temp(self): + if self._tmppath: + try: + os.remove(self._tmppath) + except Exception: + pass + self._tmppath = None + + def _on_incoming_link(self, link): + if not self._is_active(): + try: + link.teardown() + except Exception: + pass + return + self._incoming_link = link + link.set_remote_identified_callback(self._on_remote_identified) + self.app.ui.loop.set_alarm_in(8, lambda l, u: self._incoming_timeout(link)) + + def _incoming_timeout(self, link): + if self._recv_link is link: + return + try: + link.teardown() + except Exception: + pass + + def _on_remote_identified(self, link, identity): + self._schedule(lambda: self._verify_incoming(link, identity)) + + def _verify_incoming(self, link, identity): + expected_hash = self._entered_peer_hash() + try: + identified_hash = RNS.Destination.hash_from_name_and_identity(SPEEDTEST_FULL_NAME, identity) + except Exception: + identified_hash = None + if expected_hash is None or identified_hash is None or identified_hash != expected_hash: + self._log("Rejected speed test from unauthorized peer", "warning_text") + try: + link.teardown() + except Exception: + pass + return + self._recv_link = link + self._recv_started = None + self._recv_resource = None + link.set_resource_strategy(RNS.Link.ACCEPT_ALL) + link.set_resource_started_callback(self._on_recv_started) + link.set_resource_concluded_callback(self._on_recv_concluded) + self._set_progress(0) + self._log("Incoming speed test from %s" % RNS.prettyhexrep(identity.hash), "connected_status") + self._log_link_mtu(link) + + def _on_recv_started(self, resource): + self._schedule(lambda: self._recv_started_now(resource)) + + def _recv_started_now(self, resource): + self._recv_resource = resource + if self._recv_started is None: + self._recv_started = time.time() + self.rate_text.set_text(("body_text", "Receiving…")) + self._update_listen_status() + if not self._recv_polling: + self._recv_polling = True + self.app.ui.loop.set_alarm_in(0.3, self._recv_poll) + + def _recv_poll(self, loop, user_data): + if not self._recv_polling or self._recv_resource is None: + return + failed = False + try: + if self._recv_resource.status in (RNS.Resource.FAILED, RNS.Resource.CORRUPT): + failed = True + elif self._recv_link is not None and self._recv_link.status == RNS.Link.CLOSED: + failed = True + except Exception: + pass + if failed: + self._recv_failed() + return + try: + pct = int(self._recv_resource.get_progress() * 100) + except Exception: + pct = 0 + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Downloading… %d%%" % max(0, min(100, pct)))) + self._draw() + if self._recv_polling: + loop.set_alarm_in(0.3, self._recv_poll) + + def _on_recv_concluded(self, resource): + self._schedule(lambda: self._recv_concluded(resource)) + + def _recv_failed(self): + self._recv_polling = False + self._log("Incoming transfer failed", "warning_text") + self.rate_text.set_text("") + self._recv_resource = None + self._recv_link = None + self._update_listen_status() + + def _recv_concluded(self, resource): + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if not complete: + self._recv_failed() + return + try: + if resource.segment_index < resource.total_segments: + return + except Exception: + pass + self._recv_polling = False + size = self._resource_size(resource) + elapsed = max(0.001, time.time() - (self._recv_started or time.time())) + self._set_progress(100) + rate = _fmt_bitrate(size / elapsed) + self._log("Download: %s (%s in %.2fs)" % (rate, _size_str(size), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Download: %s" % rate)) + self._recv_resource = None + self._recv_link = None + self._update_listen_status() + + def _resource_size(self, resource): + for getter in ("get_data_size", "get_transfer_size"): + try: + fn = getattr(resource, getter, None) + if fn: + v = fn() + if v: + return v + except Exception: + pass + try: + return getattr(resource, "size", 0) or len(resource.data) + except Exception: + return 0 + + def on_start(self, button=None): + if self._sending: + self.cancel() + return + self.update_interface_status() + + dest_len = (RNS.Reticulum.TRUNCATED_HASHLENGTH // 8) * 2 + hexhash = self.hash_edit.edit_text.strip() + if len(hexhash) != dest_len: + self._log("Peer hash must be %d hex characters" % dest_len, "error_text") + return + try: + self._dest_hash = bytes.fromhex(hexhash) + except Exception: + self._log("Invalid peer hash", "error_text") + return + + label = self.size_dropdown.get_value() + self._send_bytes = SPEEDTEST_BYTES_BY_LABEL.get(label, 1024 * 1024) + + self._send_seq += 1 + self._sending = True + self.start_button.original_widget.set_label("Cancel") + self._set_progress(0) + self.rate_text.set_text("") + self._update_listen_status() + + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + else: + try: + RNS.Transport.request_path(self._dest_hash) + except Exception: + pass + self._log("Requesting path to %s …" % RNS.prettyhexrep(self._dest_hash)) + self._deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.25, self._await_path) + + def _timeout(self): + try: + return DEFAULT_PROBE_TIMEOUT + self.app.rns.get_first_hop_timeout(self._dest_hash) + except Exception: + return DEFAULT_PROBE_TIMEOUT + + def _await_path(self, loop, user_data): + if not self._sending: + return + if RNS.Transport.has_path(self._dest_hash): + self._establish_link() + elif time.time() > self._deadline: + self._log("Path not found", "warning_text") + self._finish() + else: + loop.set_alarm_in(0.25, self._await_path) + + def _establish_link(self): + identity = RNS.Identity.recall(self._dest_hash) + if identity is None: + self._log("Could not recall identity for peer", "error_text") + self._finish() + return + try: + destination = RNS.Destination(identity, RNS.Destination.OUT, RNS.Destination.SINGLE, SPEEDTEST_APP_NAME, *SPEEDTEST_ASPECTS) + self._log("Establishing link with %s …" % RNS.prettyhexrep(self._dest_hash)) + self._client_link = RNS.Link(destination, established_callback=self._on_client_link_up, closed_callback=self._on_client_link_closed) + except Exception as e: + self._log("Could not establish link: %s" % str(e), "error_text") + self._finish() + return + self._link_deadline = time.time() + self._timeout() + self.app.ui.loop.set_alarm_in(0.5, self._check_link) + + def _check_link(self, loop, user_data): + if not self._sending or self._client_resource is not None: + return + try: + active = self._client_link is not None and self._client_link.status == RNS.Link.ACTIVE + except Exception: + active = False + if active: + return + if time.time() > self._link_deadline: + self._log("Link establishment timed out", "warning_text") + try: + self._client_link.teardown() + except Exception: + pass + self._finish() + else: + loop.set_alarm_in(0.5, self._check_link) + + def _on_client_link_up(self, link): + self._schedule(lambda: self._client_ready(link)) + + def _client_ready(self, link): + if not self._sending: + return + try: + link.identify(self.app.identity) + except Exception: + pass + self._log_link_mtu(link) + seq = self._send_seq + self.app.ui.loop.set_alarm_in(0.6, lambda l, u: self._prepare_and_send(link, seq)) + + def _prepare_and_send(self, link, seq): + if not self._sending or seq != self._send_seq or self._client_resource is not None: + return + nbytes = self._send_bytes + if nbytes <= SPEEDTEST_INMEM_MAX: + self._begin_resource(link, os.urandom(nbytes), None, seq) + return + self._log("Preparing %s payload …" % _size_str(nbytes)) + + def worker(): + path = None + try: + fd, path = tempfile.mkstemp(prefix="nn_speedtest_") + written = 0 + with os.fdopen(fd, "wb") as f: + while written < nbytes and self._sending and seq == self._send_seq: + chunk = min(1024 * 1024, nbytes - written) + f.write(os.urandom(chunk)) + written += chunk + except Exception as e: + if path: + try: + os.remove(path) + except Exception: + pass + self._schedule(lambda: (self._log("Could not prepare payload: %s" % str(e), "error_text"), self._finish())) + return + if not self._sending or seq != self._send_seq: + if path: + try: + os.remove(path) + except Exception: + pass + return + self._schedule(lambda: self._begin_resource(link, None, path, seq)) + + threading.Thread(target=worker, daemon=True).start() + + def _begin_resource(self, link, data_bytes, tmppath, seq): + if not self._sending or seq != self._send_seq: + if tmppath: + try: + os.remove(tmppath) + except Exception: + pass + return + self._tmppath = tmppath + try: + src = data_bytes if data_bytes is not None else open(tmppath, "rb") + self._send_started = time.time() + self._client_resource = RNS.Resource(src, link, callback=self._on_send_done, progress_callback=self._on_send_progress, auto_compress=False) + self._log("Sending %s test payload …" % _size_str(self._send_bytes)) + except Exception as e: + self._log("Could not start transfer: %s" % str(e), "error_text") + self._cleanup_temp() + self._finish() + + def _on_send_progress(self, resource): + self._schedule(lambda: self._send_progress(resource)) + + def _send_progress(self, resource): + try: + pct = int(resource.get_progress() * 100) + except Exception: + pct = 0 + self._set_progress(pct) + self.rate_text.set_text(("body_text", "Uploading… %d%%" % max(0, min(100, pct)))) + + def _on_send_done(self, resource): + self._schedule(lambda: self._send_done(resource)) + + def _send_done(self, resource): + if not self._sending: + return + try: + complete = resource.status == RNS.Resource.COMPLETE + except Exception: + complete = False + if complete: + self._set_progress(100) + elapsed = max(0.001, time.time() - (self._send_started or time.time())) + rate = _fmt_bitrate(self._send_bytes / elapsed) + self._log("Upload: %s (%s in %.2fs)" % (rate, _size_str(self._send_bytes), elapsed), "connected_status") + self.rate_text.set_text(("connected_status", "Upload: %s" % rate)) + else: + self._log("Transfer failed or was not accepted", "warning_text") + self.rate_text.set_text("") + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self._cleanup_temp() + self._finish() + + def _on_client_link_closed(self, link): + self._schedule(self._client_closed) + + def _client_closed(self): + if self._sending and self._client_resource is None: + self._log("Link closed before the test started", "warning_text") + self._finish() + + def cancel(self, silent=False): + if not self._sending: + return + self._send_seq += 1 + self._sending = False + try: + if self._client_resource is not None: + self._client_resource.cancel() + except Exception: + pass + try: + if self._client_link is not None: + self._client_link.teardown() + except Exception: + pass + self._client_resource = None + self._cleanup_temp() + if not silent: + self._log("Speed test cancelled", "warning_text") + self.rate_text.set_text("") + self._set_progress(0) + self.start_button.original_widget.set_label("Send test file") + self._update_listen_status() + self._draw() + + def _finish(self): + self._sending = False + self._client_resource = None + self.start_button.original_widget.set_label("Send test file") + self._update_listen_status() + self._draw() diff --git a/nomadnet/util.py b/nomadnet/util.py index 51aa440..a7bd62c 100644 --- a/nomadnet/util.py +++ b/nomadnet/util.py @@ -1,6 +1,39 @@ +import os import re import unicodedata + +def write_config_atomic(config): + path = getattr(config, "filename", None) + if not path: + config.write() + return + + directory = os.path.dirname(path) or "." + tmp = os.path.join(directory, "."+os.path.basename(path)+".tmp") + try: + try: + existing_mode = os.stat(path).st_mode + except OSError: + existing_mode = None + + with open(tmp, "wb") as tmp_file: + config.write(tmp_file) + tmp_file.flush() + try: os.fsync(tmp_file.fileno()) + except Exception: pass + + if existing_mode is not None: + try: os.chmod(tmp, existing_mode) + except Exception: pass + + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + invalid_rendering = ["🕵️", "☝"] # Unicode blocks to strip (symbols, dingbats, emoji ranges) diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index 38a72df..9ab5dea 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -1,7 +1,14 @@ import urwid +import nomadnet from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit +def dropdown_indicator(): + try: + return nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs["dropdown"] + except Exception: + return " ▾" + class DialogLineBox(urwid.LineBox): def __init__(self, body, parent=None, title="?"): super().__init__(body, title=title) @@ -38,6 +45,13 @@ class Dropdown(urwid.WidgetWrap): self.main_button = urwid.SelectableIcon(self.main_text, 0) self.main_button = urwid.AttrMap(self.main_button, "button_normal", "button_focus") + self.indicator_widget = urwid.Text(dropdown_indicator()) + self.main_row = urwid.Columns([ + ('pack', self.main_button), + ('pack', self.indicator_widget), + ('weight', 1, urwid.Text("")), + ]) + self.option_widgets = [] for opt in options: icon = urwid.SelectableIcon(opt, 0) @@ -48,7 +62,7 @@ class Dropdown(urwid.WidgetWrap): self.options_listbox = urwid.ListBox(self.options_walker) self.dropdown_box = None # will be created on open_dropdown - self.pile = urwid.Pile([self.main_button]) + self.pile = urwid.Pile([self.main_row]) self.dropdown_visible = False super().__init__(self.pile) @@ -125,9 +139,20 @@ class FormEdit(Placeholder, FormField): self.error = "This field is required" break elif validation == "number": - if value and not value.replace('-', '').replace('.', '').isdigit(): - self.error = "This field must be a number" + try: + if value: + int(value) + except ValueError: + self.error = "This field must be a whole number" break + elif validation == "stamp_cost": + if value and value.lower() != "none": + try: + if not 1 <= int(value) <= 255: + raise ValueError + except ValueError: + self.error = "This field must be 1-255 or None" + break elif validation == "float": try: if value: @@ -158,7 +183,7 @@ class FormCheckbox(urwid.CheckBox, FormField): for validation in self.validation_types: if validation == "required": if not value: - self.error = "This field is required" + self.error = "This field is required" break self.error_widget.set_text(("error", self.error or "")) @@ -241,14 +266,25 @@ class FormMultiList(urwid.Pile, FormField): urwid.Pile.__init__(self, pile_widgets) FormField.__init__(self, config_key, transform) - def create_entry_row(self): - edit = ReadlineEdit("", "") + def create_entry_row(self, text=""): + edit = ReadlineEdit("", text) entry_row = urwid.Columns([ ('weight', 1, edit), (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), ]) return entry_row + def set_value(self, value): + self.entries = [] + if not value: + self.entries.append(self.create_entry_row()) + else: + if isinstance(value, str): + value = [value] + for item in value: + self.entries.append(self.create_entry_row(str(item))) + self.contents = [(w, self.options()) for w in self.get_pile_widgets()] + def remove_entry(self, button, entry_row): if len(self.entries) > 1: self.entries.remove(entry_row)