mirror of
https://github.com/markqvist/NomadNet.git
synced 2026-10-06 01:37:34 +00:00
Merge branch 'zenith_new_features' into new_features
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,8 @@ An masked field : `B444`<!|password`Value of Field>`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]`!.
|
||||
|
||||
+33
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1205
-37
File diff suppressed because it is too large
Load Diff
+249
-218
@@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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`<!|masked_demo`hidden text>`B333
|
||||
|
||||
A multi-row input field: `B444`<40x5|notes`>`B333
|
||||
|
||||
Full control: `B444`<!32|all_options`hidden text>`B333
|
||||
`b
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+1266
-304
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
|
||||
+111
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
+42
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user