Merge branch 'zenith_new_features' into new_features

This commit is contained in:
Mark Qvist
2026-09-02 12:25:06 +02:00
22 changed files with 7390 additions and 607 deletions
+57 -5
View File
@@ -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):
+3 -2
View File
@@ -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:
+240
View File
@@ -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()
+18
View File
@@ -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
View File
@@ -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
+105 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
+249 -218
View File
@@ -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
+234
View File
@@ -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
+61
View File
@@ -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
+15
View File
@@ -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
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -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
View File
@@ -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
+220 -4
View File
@@ -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)
+82 -22
View File
@@ -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
+491
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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)