Merged The Deluxe Nomadnet Expansion Pack from Zenith

This commit is contained in:
Mark Qvist
2026-05-21 01:27:51 +02:00
10 changed files with 1827 additions and 139 deletions
+43 -13
View File
@@ -11,6 +11,7 @@ from LXMF import display_name_from_app_data
class Conversation:
cached_conversations = {}
unread_conversations = {}
failed_conversations = {}
created_callback = None
aspect_filter = "lxmf.delivery"
@@ -59,7 +60,7 @@ class Conversation:
source_hash = lxmessage.destination_hash
else:
source_hash = lxmessage.source_hash
source_hash_path = RNS.hexrep(source_hash, delimit=False)
conversation_path = app.conversationpath + "/" + source_hash_path
@@ -80,17 +81,34 @@ class Conversation:
conversation = Conversation.cached_conversations[RNS.hexrep(source_hash, delimit=False)]
conversation.scan_storage()
if source_hash in Conversation.unread_conversations:
Conversation.unread_conversations[source_hash] += 1
else:
Conversation.unread_conversations[source_hash] = 1
lxm_state = getattr(lxmessage, "state", None)
is_failed = lxm_state in (LXMF.LXMessage.FAILED, LXMF.LXMessage.REJECTED)
try:
dirname = RNS.hexrep(source_hash, delimit=False)
with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf:
uf.write(str(Conversation.unread_conversations[source_hash]))
except Exception as e:
pass
if not originator:
if source_hash in Conversation.unread_conversations:
Conversation.unread_conversations[source_hash] += 1
else:
Conversation.unread_conversations[source_hash] = 1
try:
dirname = RNS.hexrep(source_hash, delimit=False)
with open(app.conversationpath + "/" + dirname + "/unread", "w") as uf:
uf.write(str(Conversation.unread_conversations[source_hash]))
except Exception:
pass
elif is_failed:
if source_hash in Conversation.failed_conversations:
Conversation.failed_conversations[source_hash] += 1
else:
Conversation.failed_conversations[source_hash] = 1
try:
dirname = RNS.hexrep(source_hash, delimit=False)
with open(app.conversationpath + "/" + dirname + "/failed", "w") as ff:
ff.write(str(Conversation.failed_conversations[source_hash]))
except Exception:
pass
if Conversation.created_callback != None:
Conversation.created_callback()
@@ -120,6 +138,18 @@ class Conversation:
unread = 1
Conversation.unread_conversations[source_hash] = unread
failed = 0
if source_hash in Conversation.failed_conversations:
failed = Conversation.failed_conversations[source_hash]
elif os.path.isfile(app.conversationpath + "/" + dirname + "/failed"):
try:
with open(app.conversationpath + "/" + dirname + "/failed", "r") as ff:
content = ff.read().strip()
failed = int(content) if content else 1
except Exception:
failed = 1
Conversation.failed_conversations[source_hash] = failed
if display_name == None and app_data:
display_name = LXMF.display_name_from_app_data(app_data)
@@ -127,7 +157,7 @@ class Conversation:
sort_name = ""
else:
sort_name = display_name
trust_level = app.directory.trust_level(source_hash, display_name)
conversation_dir = app.conversationpath + "/" + dirname
@@ -136,7 +166,7 @@ class Conversation:
except Exception:
last_activity = 0
entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity)
entry = (source_hash_text, display_name, trust_level, sort_name, unread, last_activity, failed)
conversations.append(entry)
except Exception as e:
+9 -3
View File
@@ -90,7 +90,7 @@ class Directory:
packed_list = []
for source_hash in self.directory_entries:
e = self.directory_entries[source_hash]
packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank))
packed_list.append((e.source_hash, e.display_name, e.trust_level, e.hosts_node, e.preferred_delivery, e.identify, e.sort_rank, getattr(e, "notes", "")))
directory = {
"entry_list": packed_list,
@@ -138,7 +138,12 @@ class Directory:
else:
sort_rank = None
entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank)
if len(e) > 7:
notes = e[7]
else:
notes = None
entries[e[0]] = DirectoryEntry(e[0], e[1], e[2], hosts_node, preferred_delivery=preferred_delivery, identify_on_connect=identify, sort_rank=sort_rank, notes=notes)
self.directory_entries = entries
@@ -412,7 +417,7 @@ class DirectoryEntry:
DIRECT = 0x01
PROPAGATED = 0x02
def __init__(self, source_hash, display_name=None, trust_level=UNKNOWN, hosts_node=False, preferred_delivery=None, identify_on_connect=False, sort_rank=None):
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:
self.source_hash = source_hash
self.display_name = display_name
@@ -426,5 +431,6 @@ class DirectoryEntry:
self.trust_level = trust_level
self.hosts_node = hosts_node
self.identify = identify_on_connect
self.notes = notes or ""
else:
raise TypeError("Attempt to add invalid source hash to directory")
+59 -5
View File
@@ -546,6 +546,52 @@ class NomadNetworkApp:
if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE:
self.message_router.cancel_propagation_node_requests()
def _persist_ignored_list(self):
try:
with open(self.ignoredpath, "wb") as fh:
for h in self.ignored_list:
fh.write((RNS.hexrep(h, delimit=False)+"\n").encode("utf-8"))
except Exception as e:
RNS.log("Could not persist ignored list: "+str(e), RNS.LOG_ERROR)
def block_destination(self, dest_hash, reason=None):
if not isinstance(dest_hash, (bytes, bytearray)):
return False
dest_hash = bytes(dest_hash)
try:
identity = RNS.Identity.recall(dest_hash)
if identity is not None:
RNS.Transport.blackhole_identity(identity.hash, reason=reason)
except Exception as e:
RNS.log("Could not blackhole identity: "+str(e), RNS.LOG_ERROR)
if dest_hash not in self.ignored_list:
self.ignored_list.append(dest_hash)
self._persist_ignored_list()
try:
self.message_router.ignore_destination(dest_hash)
except Exception:
pass
return True
def unblock_destination(self, dest_hash):
if not isinstance(dest_hash, (bytes, bytearray)):
return False
dest_hash = bytes(dest_hash)
try:
identity = RNS.Identity.recall(dest_hash)
if identity is not None:
RNS.Transport.unblackhole_identity(identity.hash)
except Exception as e:
RNS.log("Could not lift blackhole on identity: "+str(e), RNS.LOG_ERROR)
if dest_hash in self.ignored_list:
self.ignored_list.remove(dest_hash)
self._persist_ignored_list()
try:
self.message_router.unignore_destination(dest_hash)
except Exception:
pass
return True
def announce_now(self):
RNS.log("Sending lxmf.delivery announce", RNS.LOG_VERBOSE)
self.message_router.set_inbound_stamp_cost(self.lxmf_destination.hash, self.required_stamp_cost)
@@ -707,16 +753,24 @@ class NomadNetworkApp:
return False
def conversation_is_unread(self, source_hash):
if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations:
src_bytes = bytes.fromhex(source_hash)
if src_bytes in nomadnet.Conversation.unread_conversations:
return True
else:
return False
if src_bytes in nomadnet.Conversation.failed_conversations:
return True
return False
def mark_conversation_read(self, source_hash):
if bytes.fromhex(source_hash) in nomadnet.Conversation.unread_conversations:
nomadnet.Conversation.unread_conversations.pop(bytes.fromhex(source_hash))
src_bytes = bytes.fromhex(source_hash)
if src_bytes in nomadnet.Conversation.unread_conversations:
nomadnet.Conversation.unread_conversations.pop(src_bytes)
if os.path.isfile(self.conversationpath + "/" + source_hash + "/unread"):
os.unlink(self.conversationpath + "/" + source_hash + "/unread")
if src_bytes in nomadnet.Conversation.failed_conversations:
nomadnet.Conversation.failed_conversations.pop(src_bytes)
if os.path.isfile(self.conversationpath + "/" + source_hash + "/failed"):
try: os.unlink(self.conversationpath + "/" + source_hash + "/failed")
except Exception: pass
def notify_message_recieved(self):
if self.uimode == nomadnet.ui.UI_TEXT:
+12 -1
View File
@@ -216,6 +216,7 @@ class RRCHub:
self.welcomed = False
self.hub_name = None
self.hub_version = None
self.motd = None
self.max_nick_bytes = DEFAULT_MAX_NICK_BYTES
self.max_room_name_bytes = DEFAULT_MAX_ROOM_BYTES
@@ -453,6 +454,7 @@ class RRCHub:
with self._lock:
self.link = None
self.welcomed = False
self.motd = None
self.members.clear()
self._resource_expectations.clear()
self._pending_joins.clear()
@@ -1043,9 +1045,14 @@ class RRCHub:
self.manager._notify_change(self)
if silent_who:
return
room_n = room.strip().lower() if isinstance(room, str) else None
if room_n is None and isinstance(body, str) and body.strip():
with self._lock:
self.motd = body
self.manager._notify_change(self)
msg = RRCMessage(
"notice",
room.strip().lower() if isinstance(room, str) else None,
room_n,
bytes(src) if isinstance(src, (bytes, bytearray)) else None,
None,
body,
@@ -1172,6 +1179,10 @@ class RRCHub:
text = data.decode(encoding, errors="replace")
except Exception:
return
if kind == RES_KIND_MOTD:
with self._lock:
self.motd = text
self.manager._notify_change(self)
msg = RRCMessage("notice", room, None, None, text, _now_ms())
self._record_notice(msg)
except Exception as e:
+1
View File
@@ -163,6 +163,7 @@ GLYPHS = {
("file", "[F]", "\u25a4", "\uf15b"),
("image", "[I]", "\u25a3", "\uf1c5"),
("audio", "[~]", "\u266b", "\uf1c7"),
("pin", "*", "\u2605", "\uf08d"),
}
class TextUI:
+55
View File
@@ -253,6 +253,11 @@ class Browser:
recurse_down(self.attr_maps)
RNS.log("Including request data: "+str(request_data), RNS.LOG_DEBUG)
# In-document anchor link (#name or empty #)
if link_target.startswith("#"):
self._jump_to_anchor(link_target[1:])
return
# rrc://<hex>[:<dest_name>]/<room> URL form
if link_target.startswith("rrc://"):
self.handle_rrc_link(link_target[6:])
@@ -300,6 +305,56 @@ class Browser:
self.browser_footer = urwid.Text("Could not open link: "+"No known handler for destination type "+str(destination_type))
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
def _jump_to_anchor(self, name):
anchors = getattr(self.attr_maps, "anchors", None) or {}
header_rows = getattr(self.attr_maps, "header_rows", None) or []
cols = self._content_cols()
target_idx = None
if name:
target_idx = anchors.get(name)
if target_idx is None:
self.browser_footer = urwid.Text("Unknown anchor: #"+name)
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
return
else:
current = 0
try:
current = self.browser_body.original_widget.original_widget.get_scrollpos()
except Exception:
current = 0
for hr in header_rows:
if self._rows_above(hr, cols) > current:
target_idx = hr
break
if target_idx is None:
return
try:
scrollable = self.browser_body.original_widget.original_widget
scrollable.set_scrollpos(self._rows_above(int(target_idx), cols))
except Exception as e:
RNS.log("Anchor jump failed: "+str(e), RNS.LOG_ERROR)
def _content_cols(self):
try:
cols = self.app.ui.loop.screen.get_cols_rows()[0]
except Exception:
cols = 100
return max(40, cols - 3)
def _rows_above(self, index, cols):
if index <= 0 or not self.attr_maps:
return 0
total = 0
for i in range(min(index, len(self.attr_maps))):
try:
total += self.attr_maps[i].rows((cols,))
except Exception:
total += 1
return total
def handle_lxmf_link(self, link_target):
try:
def san(name):
+386 -33
View File
@@ -15,6 +15,7 @@ from RNS.Utilities.rngit.highlight import SyntaxHighlighter
from .MicronParser import markup_to_attrmaps
from nomadnet.util import sanitize_name, strip_modifiers, strip_micron
from nomadnet.util import strip_escaped_micron, unescape_micron, strip_non_formatting_tags
from nomadnet.vendor.Scrollable import Scrollable, ScrollBar
theme_dark = { "text": "ddd",
@@ -180,13 +181,13 @@ def _format_ts(ts_ms):
class ChannelsListShortcuts():
def __init__(self, app):
self.app = app
self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove"), "shortcutbar")
self.widget = urwid.AttrMap(urwid.Text("[C-n] New Hub [C-a] Add Room [C-r] Connect [C-w] Disconnect [C-t] Auto-reconnect [C-e] Edit Hub [C-x] Remove [C-y] Toggle Channels"), "shortcutbar")
class ChannelsRoomShortcuts():
def __init__(self, app):
self.app = app
self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave Room [C-k] Clear Editor [C-u] Toggle Users [Tab] Switch Focus"), "shortcutbar")
self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-l] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar")
class ChannelsDialogLineBox(urwid.LineBox):
@@ -214,7 +215,101 @@ class ChannelListEntry(urwid.Text):
return True
class ChannelsExpandGutter(urwid.WidgetWrap):
def __init__(self, app, delegate):
self.app = app
self.delegate = delegate
glyph = app.ui.glyphs.get("arrow_r", ">")
if len(glyph) > 1:
glyph = ">"
self._inner = urwid.SolidFill(glyph)
super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus"))
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and urwid.util.is_mouse_press(event):
try:
self.delegate.toggle_channel_list()
return True
except Exception:
pass
return False
def selectable(self):
return False
class UsersExpandGutter(urwid.WidgetWrap):
def __init__(self, app, delegate):
self.app = app
self.delegate = delegate
glyph = app.ui.glyphs.get("arrow_l", "<")
if len(glyph) > 1:
glyph = "<"
self._inner = urwid.SolidFill(glyph)
super().__init__(urwid.AttrMap(self._inner, "shortcutbar", "list_focus"))
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and urwid.util.is_mouse_press(event):
try:
self.delegate.toggle_users()
return True
except Exception:
pass
return False
def selectable(self):
return False
class UsersBox(urwid.LineBox):
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and urwid.util.is_mouse_press(event) and row == 0:
try:
self.delegate.toggle_users()
return True
except Exception:
pass
return super().mouse_event(size, event, button, col, row, focus)
def keypress(self, size, key):
if key == "tab":
rw = getattr(self, "delegate", None)
if rw is not None:
try:
rw.columns.focus_position = 0
rw.frame.focus_position = "footer"
return None
except Exception:
pass
if key == "ctrl u":
rw = getattr(self, "delegate", None)
if rw is not None:
try:
rw.toggle_users()
return None
except Exception:
pass
if key == "ctrl y":
rw = getattr(self, "delegate", None)
if rw is not None:
try:
rw.display.toggle_channel_list()
return None
except Exception:
pass
return super().keypress(size, key)
class ChannelsListArea(urwid.LineBox):
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and urwid.util.is_mouse_press(event) and row == 0:
try:
self.delegate.toggle_channel_list()
return True
except Exception:
pass
return super().mouse_event(size, event, button, col, row, focus)
def keypress(self, size, key):
if key == "ctrl n":
self.delegate.new_hub_dialog()
@@ -230,6 +325,12 @@ class ChannelsListArea(urwid.LineBox):
self.delegate.edit_hub_dialog()
elif key == "ctrl x":
self.delegate.remove_selected_dialog()
elif key == "ctrl y":
self.delegate.toggle_channel_list()
return None
elif key == "f8":
self.delegate.toggle_join_part_collapse()
return None
elif key == "tab":
self.delegate.app.ui.main_display.frame.focus_position = "header"
elif key == "up" and (self.delegate.ilb.first_item_is_selected() or self.delegate.ilb.body_is_empty()):
@@ -261,6 +362,12 @@ class HubInfoArea(urwid.LineBox):
if key == "ctrl x":
self.delegate.remove_selected_dialog()
return None
if key == "ctrl y":
self.delegate.toggle_channel_list()
return None
if key == "f8":
self.delegate.toggle_join_part_collapse()
return None
return super(HubInfoArea, self).keypress(size, key)
@@ -283,6 +390,10 @@ class RoomMessageEdit(urwid.Edit):
self.delegate.leave_room()
elif key == "ctrl u":
self.delegate.toggle_users()
elif key == "ctrl y":
self.delegate.display.toggle_channel_list()
elif key == "f8":
self.delegate.display.toggle_join_part_collapse()
elif key == "up":
y = self.get_cursor_coords(size)[1]
if y == 0:
@@ -367,11 +478,31 @@ class RoomFrame(urwid.Frame):
if key == "ctrl u":
self.delegate.toggle_users()
return None
if key == "ctrl y":
self.delegate.display.toggle_channel_list()
return None
if key == "f8":
self.delegate.display.toggle_join_part_collapse()
return None
if key == "tab":
rw = self.delegate
users_focusable = (
rw is not None
and getattr(rw, "users_visible", False)
and len(rw.columns.contents) > 1
and rw.columns.contents[1][0] is rw.users_box
)
if self.focus_position == "body":
self.focus_position = "footer"
elif users_focusable:
try:
rw.columns.focus_position = 1
return None
except Exception:
self.focus_position = "body"
else:
self.focus_position = "body"
return None
elif self.focus_position == "body":
if key == "down" and getattr(self.delegate, "messagelist", None) is not None and self.delegate.messagelist.bottom_is_visible:
self.focus_position = "footer"
@@ -415,8 +546,11 @@ class RoomWidget(urwid.WidgetWrap):
self.frame.delegate = self
self.chat_box = urwid.LineBox(self.frame)
self.users_pile = urwid.Pile([urwid.Text("")])
self.users_box = urwid.LineBox(urwid.Filler(self.users_pile, "top"), title="Users")
self.users_walker = urwid.SimpleFocusListWalker([urwid.Text("")])
self.users_listbox = urwid.ListBox(self.users_walker)
self.users_box = UsersBox(self.users_listbox, title="Users")
self.users_box.delegate = self
self.users_gutter = UsersExpandGutter(self.app, self)
self._refresh_users_pane()
self.users_visible = self.display.users_visible
@@ -437,35 +571,75 @@ class RoomWidget(urwid.WidgetWrap):
]
else:
self.columns.contents = [
(self.chat_box, self.columns.options(urwid.WEIGHT, 1)),
(self.chat_box, self.columns.options(urwid.WEIGHT, 1)),
(self.users_gutter, self.columns.options(urwid.GIVEN, 1)),
]
self.columns.focus_position = 0
def _refresh_users_pane(self):
g = self.app.ui.glyphs
walker = self.users_walker
if self.hub is None or self.room is None:
self.users_pile.contents = [(urwid.Text(""), self.users_pile.options())]
walker[:] = [urwid.Text("")]
return
members = self.hub.get_members(self.room)
own_hash = self.app.identity.hash if self.app.identity is not None else None
names = []
for m in members:
if own_hash is not None and m == own_hash:
names.append((self.hub.display_name_for(m), True))
else:
names.append((self.hub.display_name_for(m), False))
names.sort(key=lambda x: x[0].lower())
def _safe_name(raw):
if not raw: return ""
try:
if self.app.config["textui"]["sanitize_names"]:
return sanitize_name(str(raw)) or ""
return strip_modifiers(str(raw)) or ""
except Exception:
return str(raw or "")
rows = [urwid.Text(" "+str(len(names))+" user"+("s" if len(names) != 1 else ""))]
for name, is_self in names:
name = sanitize_name(name); name = name[:15]+"" if len(name) > 16 else name
entries = []
for m in members:
safe_name = _safe_name(self.hub.display_name_for(m))
safe_name = safe_name[:15]+"" if len(safe_name) > 16 else safe_name
entries.append((safe_name, m, own_hash is not None and m == own_hash))
entries.sort(key=lambda x: x[0].lower())
prev_focus_key = None
try:
prev_idx = walker.focus
if prev_idx is not None and 0 <= prev_idx < len(walker):
prev_focus_key = getattr(walker[prev_idx], "user_hash", None)
except Exception:
prev_focus_key = None
rows = [urwid.Text(" "+str(len(entries))+" user"+("s" if len(entries) != 1 else ""))]
for name, peer_hash, is_self in entries:
if is_self:
rows.append(urwid.AttrMap(urwid.Text(" "+g["arrow_r"]+" "+name), "list_trusted"))
label = " "+g["arrow_r"]+" "+name
style = "list_trusted"
else:
rows.append(urwid.AttrMap(urwid.Text(" "+g["peer"]+" "+name), "connected_status"))
if not names:
label = " "+g["peer"]+" "+name
style = "connected_status"
entry = ChannelListEntry(label)
urwid.connect_signal(entry, "click", self.display.show_user_info, (self.hub, peer_hash, name))
row = urwid.AttrMap(entry, style, "list_focus")
row.user_hash = peer_hash
rows.append(row)
if not entries:
rows.append(urwid.Text(" (no members)"))
self.users_pile.contents = [(w, self.users_pile.options()) for w in rows]
walker[:] = rows
new_focus = None
if prev_focus_key is not None:
for idx, w in enumerate(walker):
if getattr(w, "user_hash", None) == prev_focus_key:
new_focus = idx
break
if new_focus is None:
for idx, w in enumerate(walker):
if hasattr(w, "user_hash"):
new_focus = idx
break
if new_focus is not None:
try: walker.set_focus(new_focus)
except Exception: pass
def _update_peer_info(self):
if self.hub is None or self.room is None:
@@ -491,8 +665,22 @@ class RoomWidget(urwid.WidgetWrap):
def update_messages(self, replace=False):
msgs = self.hub.get_messages(self.room) if (self.hub is not None and self.room is not None) else []
widgets = []
collapse = getattr(self.display, "collapse_join_part", False)
run = []
def flush_run():
if not run:
return
widgets.append(_collapsed_joinpart_widget(self.app, len(run)))
run.clear()
for m in msgs:
if collapse and _is_joinpart_system(m):
run.append(m)
continue
flush_run()
widgets.append(_message_widget(self.app, self.hub, m, link_delegate=self.link_delegate))
flush_run()
if not widgets:
widgets = [urwid.Text([("irc_system", " "+self.app.ui.glyphs["info"]+" No messages yet")])]
@@ -508,7 +696,7 @@ class RoomWidget(urwid.WidgetWrap):
pass
if replace and hasattr(self, "frame"):
self.frame.contents["body"] = (self.messagelist, None)
if hasattr(self, "users_pile"):
if hasattr(self, "users_walker"):
self._refresh_users_pane()
def append_message(self, msg):
@@ -559,7 +747,7 @@ class RoomWidget(urwid.WidgetWrap):
except Exception as e:
RNS.log("Incremental append failed, falling back: "+str(e), RNS.LOG_DEBUG)
self.update_messages(replace=True)
if hasattr(self, "users_pile"):
if hasattr(self, "users_walker"):
self._refresh_users_pane()
def _on_editor_change(self, editor, old_text):
@@ -909,6 +1097,32 @@ class _ChatLinkDelegate:
except Exception as e:
RNS.log("Could not open page link: "+str(e), RNS.LOG_ERROR)
_MOTD_ROOM_RE = re.compile(r"(?<!\[)(?<!\w)#([A-Za-z0-9][A-Za-z0-9_\-]{0,62})")
def _linkify_motd(text):
if not text:
return text or ""
def repl(m):
name = m.group(1)
return "`["+m.group(0)+"`room://"+name+"]"
return _MOTD_ROOM_RE.sub(repl, text)
def _is_joinpart_system(m):
if getattr(m, "kind", None) != "system":
return False
text = (getattr(m, "text", "") or "").strip()
if not text:
return False
if text.startswith("You "):
return False
return text.endswith(" joined") or text.endswith(" left")
def _collapsed_joinpart_widget(app, n):
label = ""+str(n)+" join/leave event"+("" if n == 1 else "s")+""
return urwid.Padding(urwid.AttrMap(urwid.Text(label, align=urwid.CENTER), "irc_system"), left=1)
def get_nick_color(sender_hash, theme, app, shift=15):
if app.rrc_nick_colors_theme: nick_colors = app.rrc_nick_colors_theme
else: nick_colors = theme["nick_colors"]
@@ -1044,8 +1258,11 @@ class ChannelsDisplay():
self.selected_key = None
self.current_room_widget = None
self.users_visible = True
self.channel_list_visible = True
self.collapse_join_part = False
self._build_listbox()
self.gutter = ChannelsExpandGutter(self.app, self)
self.list_shortcuts = ChannelsListShortcuts(self.app)
self.room_shortcuts = ChannelsRoomShortcuts(self.app)
@@ -1075,6 +1292,44 @@ class ChannelsDisplay():
self.app.rrc.set_change_callback(self._on_rrc_change)
self.app.rrc.set_message_callback(self._on_rrc_message)
def _set_right_widget(self, widget):
self.right = widget
self._apply_channel_list_visibility(focus_right=True)
def toggle_channel_list(self):
if self.channel_list_visible and self.right is self.placeholder:
return
self.channel_list_visible = not self.channel_list_visible
self._apply_channel_list_visibility()
def toggle_join_part_collapse(self):
self.collapse_join_part = not self.collapse_join_part
if self.current_room_widget is not None:
try:
self.current_room_widget.update_messages(replace=True)
except Exception:
pass
def _apply_channel_list_visibility(self, focus_right=False):
list_opts = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
gutter_opts = self.columns_widget.options(urwid.GIVEN, 1)
right_opts = self.columns_widget.options(urwid.WEIGHT, 1)
if self.channel_list_visible:
self.columns_widget.contents = [
(self.listbox, list_opts),
(self.right, right_opts),
]
if focus_right:
try: self.columns_widget.focus_position = 1
except Exception: pass
else:
self.columns_widget.contents = [
(self.gutter, gutter_opts),
(self.right, right_opts),
]
try: self.columns_widget.focus_position = 1
except Exception: pass
def start(self):
self.update_list()
@@ -1083,7 +1338,7 @@ class ChannelsDisplay():
focus_path = self.columns_widget.get_focus_path()
except Exception:
focus_path = None
if focus_path and focus_path[0] == 1:
if focus_path and focus_path[0] == 1 and self.current_room_widget is not None:
return self.room_shortcuts
return self.list_shortcuts
@@ -1175,8 +1430,8 @@ class ChannelsDisplay():
self.listbox = ChannelsListArea(urwid.Filler(self.ilb, height=urwid.RELATIVE_100), title="Channels")
self.listbox.delegate = self
options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
if not self.dialog_open:
if not self.dialog_open and self.channel_list_visible:
options = self.columns_widget.options(urwid.GIVEN, ChannelsDisplay.given_list_width)
self.columns_widget.contents[0] = (self.listbox, options)
if prev_key is not None:
@@ -1291,6 +1546,17 @@ class ChannelsDisplay():
else:
lines.append(urwid.Text(" Use Ctrl-R to connect."))
if hub.motd:
lines.append(urwid.Divider(g["divider1"]))
lines.append(urwid.Text(" MOTD:"))
motd_delegate = _ChatLinkDelegate(self, hub)
try:
motd_widgets = markup_to_attrmaps(_linkify_motd(hub.motd), url_delegate=motd_delegate)
except Exception:
motd_widgets = [urwid.Text(hub.motd)]
for w in motd_widgets:
lines.append(urwid.Padding(w, left=2))
if hub.rooms:
lines.append(urwid.Divider(g["divider1"]))
lines.append(urwid.Text(" Joined rooms:"))
@@ -1314,28 +1580,26 @@ class ChannelsDisplay():
urwid.connect_signal(entry, "click", self._select_room, (hub, name))
lines.append(urwid.AttrMap(entry, "list_unknown", "list_focus"))
info = HubInfoArea(urwid.Filler(urwid.Pile(lines), "top"), title=hub.name)
body = ScrollBar(Scrollable(urwid.Pile(lines)), thumb_char="", trough_char=" ")
info = HubInfoArea(urwid.AttrMap(body, "scrollbar"), title=hub.name)
info.delegate = self
self.current_room_widget = None
options = self.columns_widget.options(urwid.WEIGHT, 1)
self.columns_widget.contents[1] = (info, options)
self._set_right_widget(info)
self.shortcuts_display = self.list_shortcuts
self.app.ui.main_display.update_active_shortcuts()
def show_placeholder(self):
self.current_room_widget = None
self.selected_key = None
options = self.columns_widget.options(urwid.WEIGHT, 1)
self.columns_widget.contents[1] = (self.placeholder, options)
self._set_right_widget(self.placeholder)
self.shortcuts_display = self.list_shortcuts
self.app.ui.main_display.update_active_shortcuts()
def _show_room(self, hub, room):
widget = RoomWidget(self, hub, room)
self.current_room_widget = widget
options = self.columns_widget.options(urwid.WEIGHT, 1)
self.columns_widget.contents[1] = (widget, options)
self.columns_widget.focus_position = 1
self._set_right_widget(widget)
self.columns_widget.focus_position = len(self.columns_widget.contents)-1
self.shortcuts_display = self.room_shortcuts
self.app.ui.main_display.update_active_shortcuts()
@@ -1608,6 +1872,87 @@ class ChannelsDisplay():
dialog.delegate = self
self._show_dialog_overlay(dialog)
def show_user_info(self, sender, payload):
try:
hub, peer_hash, display_name = payload
except Exception:
return
if not isinstance(peer_hash, (bytes, bytearray)):
return
peer_hash = bytes(peer_hash)
identity_hex = RNS.hexrep(peer_hash, delimit=False)
own_hash = self.app.identity.hash if self.app.identity is not None else None
is_self = (own_hash is not None and peer_hash == own_hash)
lxmf_hex = None
try:
peer_identity = RNS.Identity.recall(peer_hash, from_identity_hash=True)
if peer_identity is not None:
lxmf_dest = RNS.Destination.hash_from_name_and_identity("lxmf.delivery", peer_identity)
lxmf_hex = RNS.hexrep(lxmf_dest, delimit=False)
except Exception:
pass
def on_close(_b):
self.close_dialog()
def on_open(_b):
self.close_dialog()
if lxmf_hex is None:
return
try:
_ChatLinkDelegate(self, hub)._open_lxmf(lxmf_hex)
except Exception as e:
RNS.log("Could not open conversation: "+str(e), RNS.LOG_ERROR)
safe_name = ""
try:
if display_name:
if self.app.config["textui"]["sanitize_names"]:
safe_name = sanitize_name(str(display_name)) or ""
else:
safe_name = strip_modifiers(str(display_name)) or ""
except Exception:
safe_name = ""
lines = [
urwid.Text(""),
urwid.Text(" Nick : "+safe_name),
urwid.Text(" Identity : "+identity_hex),
]
if lxmf_hex:
lines.append(urwid.Text(" LXMF : "+lxmf_hex))
if is_self:
lines.append(urwid.Text(""))
lines.append(urwid.Text(" (This is you)", align=urwid.CENTER))
lines.append(urwid.Text(""))
lines.append(urwid.Columns([
(urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)),
]))
else:
if lxmf_hex is None:
lines.append(urwid.Text(""))
lines.append(urwid.Text(" Identity not in local cache;", align=urwid.CENTER))
lines.append(urwid.Text(" conversation can't be opened until", align=urwid.CENTER))
lines.append(urwid.Text(" the peer announces.", align=urwid.CENTER))
lines.append(urwid.Text(""))
lines.append(urwid.Columns([
(urwid.WEIGHT, 1, urwid.Button("Close", on_press=on_close)),
]))
else:
lines.append(urwid.Text(""))
lines.append(urwid.Columns([
(urwid.WEIGHT, 0.55, urwid.Button("Open Conversation", on_press=on_open)),
(urwid.WEIGHT, 0.05, urwid.Text("")),
(urwid.WEIGHT, 0.40, urwid.Button("Close", on_press=on_close)),
]))
dialog = ChannelsDialogLineBox(urwid.Pile(lines), title="User Info")
dialog.delegate = self
self._show_dialog_overlay(dialog)
def _show_dialog_overlay(self, dialog):
self.dialog_open = True
overlay = urwid.Overlay(
@@ -1659,6 +2004,14 @@ class ChannelsDisplay():
self.current_room_widget._refresh_users_pane()
except Exception:
pass
elif (self.selected_key
and self.selected_key[0] == "hub"
and self.selected_key[1] == hub.hub_hash
and self.selected_key[2] == hub.dest_name):
try:
self._show_hub_info(hub)
except Exception:
pass
self._wake(action)
def _on_rrc_message(self, hub, msg):
File diff suppressed because it is too large Load Diff
+253 -4
View File
@@ -60,6 +60,47 @@ class SelectText(urwid.Text):
self._emit('click')
return True
def _rows_above(attrmaps, index, cols):
if index <= 0 or not attrmaps:
return 0
total = 0
for i in range(min(index, len(attrmaps))):
try:
total += attrmaps[i].rows((cols,))
except Exception:
total += 1
return total
class GuideLinkDelegate:
def __init__(self, app, reader=None):
self.app = app
self.reader = reader
self.last_keypress = 0
def marked_link(self, target, fields=None):
pass
def micron_released_focus(self):
pass
def handle_link(self, target, fields=None):
if not target:
return
if target.startswith("#"):
if self.reader is not None:
try:
self.reader.jump_to_anchor(target[1:])
except Exception as e:
RNS.log("Guide anchor jump failed: "+str(e), RNS.LOG_ERROR)
return
try:
self.app.ui.main_display.show_network(None)
self.app.ui.main_display.sub_displays.network_display.browser.handle_link(target, fields)
except Exception as e:
RNS.log("Could not open guide link: "+str(e), RNS.LOG_ERROR)
class GuideEntry(urwid.WidgetWrap):
def __init__(self, app, parent, reader, topic_name):
self.app = app
@@ -79,7 +120,7 @@ class GuideEntry(urwid.WidgetWrap):
def display_topic(self, event, topic):
markup = TOPICS[topic]
attrmaps = markup_to_attrmaps(markup, url_delegate=None)
attrmaps = markup_to_attrmaps(markup, url_delegate=GuideLinkDelegate(self.app, reader=self.reader))
topic_position = None
index = 0
@@ -109,6 +150,7 @@ class TopicList(urwid.WidgetWrap):
self.topic_list = [
GuideEntry(self.app, self, guide_display, "Introduction"),
GuideEntry(self.app, self, guide_display, "Concepts & Terminology"),
GuideEntry(self.app, self, guide_display, "Channels & RRC"),
GuideEntry(self.app, self, guide_display, "Interfaces"),
GuideEntry(self.app, self, guide_display, "Hosting a Node"),
GuideEntry(self.app, self, guide_display, "Configuration Options"),
@@ -166,10 +208,53 @@ class GuideDisplay():
def set_content_widgets(self, new_content):
options = self.columns.options(width_type=urwid.WEIGHT, width_amount=1-GuideDisplay.list_width, box_widget=True)
pile = urwid.Pile(new_content)
content = urwid.LineBox(urwid.AttrMap(ScrollBar(Scrollable(pile), thumb_char="\u2503", trough_char=" "), "scrollbar"))
self._content_attrmaps = new_content
self._content_scrollable = Scrollable(pile)
content = urwid.LineBox(urwid.AttrMap(ScrollBar(self._content_scrollable, thumb_char="\u2503", trough_char=" "), "scrollbar"))
self.columns.contents[1] = (content, options)
def jump_to_anchor(self, name):
scrollable = getattr(self, "_content_scrollable", None)
attrmaps = getattr(self, "_content_attrmaps", None)
if scrollable is None or attrmaps is None:
return
anchors = getattr(attrmaps, "anchors", None) or {}
header_rows = getattr(attrmaps, "header_rows", None) or []
cols = self._content_cols()
target_idx = None
if name:
target_idx = anchors.get(name)
if target_idx is None:
return
else:
try: current = scrollable.get_scrollpos()
except Exception: current = 0
for hr in header_rows:
if _rows_above(attrmaps, hr, cols) > current:
target_idx = hr
break
if target_idx is None:
return
row_offset = _rows_above(attrmaps, target_idx, cols)
try:
scrollable.set_scrollpos(int(row_offset))
except Exception:
pass
def _content_cols(self):
try:
cols = self.app.ui.loop.screen.get_cols_rows()[0]
except Exception:
cols = 100
try:
widths = self.columns.column_widths((cols,))
if len(widths) > 1 and widths[1] > 0:
return max(40, widths[1] - 3)
except Exception:
pass
return max(40, int(cols * (1 - GuideDisplay.list_width)) - 3)
def shortcuts(self):
return self.shortcuts_display
@@ -191,7 +276,7 @@ Nomad Network is build on LXMF and Reticulum, which together provides the crypto
Nomad Network does not need any connections to the public internet to work. In fact, it doesn't even need an IP or Ethernet network. You can use it entirely over packet radio, LoRa or even serial lines. But if you wish, you can bridge islanded Reticulum networks over the Internet or private ethernet networks, or you can build networks running completely over the Internet. The choice is yours.
The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project on GitHub.
The current version of the program should be considered a beta release. The program works well, but there will most probably be bugs and possibly sub-optimal performance in some scenarios. On the other hand, this is the best time to have an influence on the direction of the development of Nomad Network. To do so, join the discussion on the Nomad Network project at `_`F00f`[Aleph git`a8d24177d946de4f1f0a0fe1af9a1338:/page/index.mu]`f`_.
'''
@@ -612,6 +697,61 @@ Conversations in Nomad Network
'''
TOPIC_CHANNELS = '''>Channels & RRC
NomadNet includes a built-in client for `*RRC`* (Reticulum Relay Chat), a real-time text chat protocol that runs over Reticulum. The reference RRC server implementation lives at https://github.com/kc1awv/rrcd. Each RRC server is called a `!hub`!, and each hub hosts one or more `!rooms`! (channels) you can join. Hubs are addressed by a Reticulum destination hash, just like nodes and conversations.
The Channels section of NomadNet (accessible from the main menu, or by pressing the corresponding shortcut) is where you manage your hubs, view connection status, and chat in rooms.
>>Joining a hub
To start chatting on a hub you must first add it to your hub list:
>>>
- Open the `![ Channels ]`! section.
- Press the shortcut to open the `*New Hub`* dialog (shown in the shortcut bar at the bottom).
- Enter the `!hub address`! (the destination hash of the hub, typically 16 hex characters / 8 bytes) and an optional display name.
- Confirm the dialog. The hub will appear in your hub list.
<
You can sometimes receive a hub link from another user or from a node page. Activating such a link will pre-fill the New Hub dialog (and optionally a room name) for you.
>>Connecting and listing rooms
Once a hub is added:
>>>
- Select the hub in the list and connect to it. Hubs can also be set to auto-reconnect.
- When connected, run `*/list`* to see public rooms hosted on this hub.
- Run `*/join <room>`* to join a room. Joined rooms appear under the hub in your channel list.
<
>>Chatting
Inside a room, anything you type that does not start with a `*/`* is sent as a message to everyone in the room. The most common in-room commands are:
>>>
- `*/help`* show the full list of commands
- `*/who`* list users in the current room
- `*/nick <name>`* set your display name on this hub
- `*/topic <room> [text]`* view or set the room topic
- `*/part [room]`* leave a room (defaults to the current one)
- `*/quit`* disconnect from the hub
<
Messages support both `*markdown`* and `*micron`* formatting, depending on your render settings (see the `*Configuration Options`* topic). Because the backtick is the micron control character, you must use the `!broken-bar`! character `*¦`* in place of `*\\``* when typing micron formatting in a message. The renderer converts `*¦`* back to `*\\``* for display.
>>Mentions and privacy
You will be notified (and the bell may ring, depending on your configuration) when another user mentions your nick with `*@yournick`*. RRC traffic is end-to-end encrypted by Reticulum between you and the hub, but other users in the same room can read what you write there. Treat rooms as semi-public spaces.
>>Hub etiquette
Each hub is operated independently and may have its own rules, MOTD, ban list and operator team. Be respecful, follow the hub's MOTD, and remember that operators can kick, ban and set modes on rooms they own.
'''
TOPIC_FIRST_RUN = '''>First Time Information
Hi there. This first run message will only appear once. It contains a few pointers on getting started with Nomad Network, and getting the most out of the program.
@@ -622,7 +762,33 @@ To get the most out of Nomad Network, you will need a terminal that supports UTF
It is recommended to use a terminal size of at least 135x32. Nomad Network will work with smaller terminal sizes, but the interface might feel a bit cramped.
If you don't already have a Nerd Font installed (see https://www.nerdfonts.com/), I also highly recommend to do so, since it will greatly expand the amount of glyphs, icons and graphics that Nomad Network can use. Once you have your terminal set up with a Nerd Font, go to the `![ Config ]`! menu item and enable Nerd Fonts in the configuration instead of normal unicode glyphs.
Nerd Fonts and true-color are enabled by default. To get the full visual experience you should have a Nerd Font installed in your terminal. You can verify this by visiting the `*Display Test`* topic in this guide and checking the `!Nerd Font Rendering Test`! section. If those glyphs render correctly, you are good to go.
If they do not render, install a Nerd Font and configure your terminal to use it. A few common approaches:
>>>
- `!Download from nerdfonts.com`!
Visit https://www.nerdfonts.com/font-downloads, pick a font you like
(Jet Brains Mono, Hack and Meslo are popular choices), unzip it
into `*~/.local/share/fonts`* (Linux) or install via Font Book (macOS),
then refresh the font cache with `*fc-cache -fv`* on Linux.
- `!Package manager`!
Many distros ship Nerd Font packages. On Arch: `*pacman -S ttf-nerd-fonts-symbols`*
or one of the per-family packages. On Debian/Ubuntu look for `*fonts-firacode`*
plus the symbols-only Nerd Font package, or install manually from the
nerdfonts.com release.
- `!Homebrew (macOS)`!
`*brew install --cask font-fira-code-nerd-font`* (or any other family).
- `!Configure your terminal`!
After installing, set your terminal emulator's font to the Nerd Font
variant (it usually has "Nerd Font" in the name). Restart NomadNet
so the new font are picked up.
<
If for some reason you cannot or do not want to use a Nerd Font, you can disable Nerd Font glyphs in the `![ Config ]`! menu and NomadNet will fall back to plain unicode symbols.
Nomad Network expects that you are already connected to some form of Reticulum network. That could be as simple as the default one that Reticulum auto-generates on your local ethernet/WiFi network, or something much more complex. This short guide won't go into any details on building networks, but you will find other entries in the guide that deal with network setup and configuration.
@@ -1085,6 +1251,23 @@ The following line should contain a grayscale gradient bar:
Unicode Glyphs : \u2713 \u2715 \u26a0 \u24c3 \u2193
Nerd Font Glyphs : \uf484 \U000f04c5 \U000f0219 \U000f0002 \uf415 \uf023 \uf06e
>>Nerd Font Rendering Test
`cSince Nerd Font glyphs and true-color are enabled by default, the rows below should render as crisp icons rather than empty squares, question marks, or fallback ASCII. If any glyph appears blank or boxed, your terminal is not using a Nerd Font, or the font is missing the required glyph range.
``
>>>
Common UI icons : \uf484 \uf415 \uf023 \uf06e \uf055 \uf056 \uf059
Status / state : \U000f04c5 \U000f0219 \U000f0002 \U000f1397 \U000f12fc \U000f0156
Network / radio : \U000f05a9 \U000f05aa \U000f05ab \U000f05ac \uf1eb \uf012
People / messaging: \uf0c0 \uf007 \uf2bd \uf0e0 \uf27a \uf086
Devices / files : \uf233 \uf07b \uf15b \uf019 \uf093 \uf1c0
<
`cIf the above renders correctly, you have a working Nerd Font setup and can leave the defaults as-is. If not, see the `*First Time Information`* topic for install instructions.
``
'''
@@ -1143,6 +1326,22 @@ Nomad Network supports a simple and functional markup language called `*micron`*
With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals.
>Table of Contents
`F00f`_`[A Few Demo Outputs`#a-few-demo-outputs]`_`f
`F00f`_`[Micron Tags`#micron-tags]`_`f
`F00f`_`[High Level Stuff`#high-level-stuff]`_`f
`F00f`_`[Colors`#colors]`_`f
`F00f`_`[Page Foreground and Background Colors`#page-foreground-and-background-colors]`_`f
`F00f`_`[Links`#links]`_`f
`F00f`_`[Anchors`#anchors]`_`f
`F00f`_`[Tables`#tables]`_`f
`F00f`_`[Fields & Requests`#fields-requests]`_`f
`F00f`_`[Comments`#comments]`_`f
`F00f`_`[Partials`#partials]`_`f
`F00f`_`[Literals`#literals]`_`f
`F00f`_`[Closing Remarks`#closing-remarks]`_`f
>>Recommendations and Requirements
While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal `*must`* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better.
@@ -1354,6 +1553,55 @@ Here is `F00f`_`[a more visible link`72914442a3689add83a09a767963f57c:/page/inde
When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL.
>Anchors
Anchors let you create jump points within a single page similar to anchors in HTML. You declare a position in the page with a name, then link to it from anywhere on the same page.
>>Auto-anchors from headers
Every section heading you write also becomes an anchor automatically. The anchor name is the heading text after `*slugifying`*: lowercased, with any run of non-alphanumeric characters replaced by a single hyphen, and leading or trailing hyphens stripped. So `*>Hello World`* becomes the anchor `*hello-world`*, and `*>Introduction & Setup`* becomes `*introduction-setup`*.
>>Explicit anchors
If you want an anchor, that isn't tied to a heading, place one anywhere in your text with the \\`: tag, followed by a name. Names may contain the characters `*A-Z`*, `*a-z`*, `*0-9`*, `*_`* and `*-`*, and end at any other character (a space, a newline, or punctuation).
The anchor itself takes up no space and does not render. Is's just a position marker. An explicit anchor declared on an otherwise empty line binds to that line's position.
`Faaa
`=
`:install-notes
Some installation notes for the user.
You can also drop one mid-line. Example: see `:tip-3 tip 3 below for caveats.
`=
``
>>Linking to an anchor
Reuse the standard link syntax, with a `*#`*-prefixed URL:
`Faaa
`=
`[Jump to Install Notes`#install-notes]
`=
``
When the user activates the link the browser scrolls the current page to the anchor's row.
>>Jumping to the next section
If the URL is just `*#`* with no name after it, the link jumps to the next \\`> header that appears after the link's own position in the document. This is convenient for "Continue ↓" buttons after a long paragraph, without having to name every section:
`Faaa
`=
`[Continue`#]
`=
``
>>Notes on namespaces and collisions
Auto-anchors from headings and explicit \\`: anchors share a single namespace per page. If an explicit anchor collides with a heading slug, the first one declared is where it will jump to.
>Tables
You can include rendered tables by enclosing them in \\`t tags. Optionally, you can also specify alignment and max rendering width by adding these properties to the opening \\`t tag, like \\`tc30. Here's an example:
@@ -1576,6 +1824,7 @@ TOPIC_MARKUP += "\n`=\n\n>Closing Remarks\n\nIf you made it all the way here, yo
TOPICS = {
"Introduction": TOPIC_INTRODUCTION,
"Concepts & Terminology": TOPIC_CONCEPTS,
"Channels & RRC": TOPIC_CHANNELS,
"Conversations": TOPIC_CONVERSATIONS,
"Interfaces": TOPIC_INTERFACES,
"Hosting a Node": TOPIC_HOSTING,
+67 -5
View File
@@ -1,6 +1,7 @@
import nomadnet
import urwid
import random
import re
import time
import RNS
from urwid.util import is_mouse_press
@@ -57,10 +58,32 @@ def default_state(fg=None, bg=None):
"align": "left",
"default_fg": fg,
"default_bg": bg,
"anchors": {},
"pending_anchors": [],
"header_rows": [],
}
return state
def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None):
_MICRON_STRIP_RE = re.compile(
r"`[FB]T[0-9a-fA-F]{6}"
r"|`[FB][0-9a-fA-F]{3}"
r"|`:[A-Za-z0-9_\-]*"
r"|`[!*_=fbacrl`<>{]"
)
def slugify_micron(text):
if text is None: return ""
stripped = _MICRON_STRIP_RE.sub("", text)
s = re.sub(r"[^A-Za-z0-9]+", "-", stripped).strip("-").lower()
return s
class _AttrMapList(list):
"""list subclass that allows attaching parser metadata like `anchors`."""
pass
def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None, link_class=None, anchors=None):
global LINK_CLASS
if link_class: LINK_CLASS = link_class
else: LINK_CLASS = LinkableText
@@ -71,13 +94,17 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None
else:
SELECTED_STYLES = STYLES_LIGHT
attrmaps = []
attrmaps = _AttrMapList()
fgc = None; bgc = DEFAULT_BG
if bg_color != None: bgc = bg_color
if fg_color != None: fgc = fg_color
state = default_state(fgc, bgc)
if anchors is None:
anchors = state["anchors"]
else:
state["anchors"] = anchors
# Split entire document into lines for
# processing.
@@ -88,12 +115,30 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None
display_widgets = parse_line(line, state, url_delegate)
else:
display_widgets = [urwid.Text("")]
if display_widgets != None and len(display_widgets) != 0:
row_index = len(attrmaps)
pending = state.get("pending_anchors") or []
if pending:
for name in pending:
if name and name not in anchors:
anchors[name] = row_index
state["pending_anchors"] = []
if state.get("_header_pending"):
state["header_rows"].append(row_index)
state["_header_pending"] = False
for display_widget in display_widgets:
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", [])))
except Exception:
pass
return attrmaps
def parse_partial(line):
@@ -239,7 +284,7 @@ def parse_line(line, state, url_delegate):
while i < len(line) and line[i] == ">":
i += 1
state["depth"] = i
for j in range(1, i+1):
wanted_style = "heading"+str(i)
if wanted_style in SELECTED_STYLES:
@@ -252,9 +297,14 @@ def parse_line(line, state, url_delegate):
heading_style = make_style(state)
output = make_output(state, line, url_delegate)
style_to_state(latched_style, state)
slug = slugify_micron(line)
if slug:
state.setdefault("pending_anchors", []).append(slug)
state["_header_pending"] = True
if len(output) > 0:
first_style = output[0][0]
@@ -599,6 +649,18 @@ def make_output(state, line, url_delegate, pre_escape=False):
elif c == "a":
state["align"] = state["default_align"]
elif c == ":":
# Anchor declaration: `:anchor-name (terminated by any non-name char). The anchor is a zero width position narker bound to the current line by markup_to_attrmaps
name_start = i + 1
name_end = name_start
while name_end < len(line) and (line[name_end].isalnum() or line[name_end] in "_-"):
name_end += 1
anchor_name = line[name_start:name_end]
if anchor_name:
state.setdefault("pending_anchors", []).append(anchor_name)
skip = (name_end - i) - 1
if skip < 0: skip = 0
elif c == '<':
if len(part) > 0:
output.append(make_part(state, part))