From 61812aa6ae3e88a7745125ddd16d7567420b5f6f Mon Sep 17 00:00:00 2001 From: zenith <157907903+RFnexus@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:00:46 -0400 Subject: [PATCH] Add a built-in Micron page/file editor under [ My Node ], extend Micron with nestable collapsible section folding and multi-row form inputs, misc changes --- nomadnet/examples/various/input_fields.py | 2 + nomadnet/ui/TextUI.py | 4 + nomadnet/ui/textui/Browser.py | 106 ++- nomadnet/ui/textui/Conversations.py | 19 +- nomadnet/ui/textui/Editor.py | 1053 +++++++++++++++++++++ nomadnet/ui/textui/Guide.py | 37 + nomadnet/ui/textui/Helpers.py | 15 + nomadnet/ui/textui/Main.py | 22 +- nomadnet/ui/textui/MicronParser.py | 142 ++- nomadnet/ui/textui/Network.py | 120 ++- 10 files changed, 1459 insertions(+), 61 deletions(-) create mode 100644 nomadnet/ui/textui/Editor.py diff --git a/nomadnet/examples/various/input_fields.py b/nomadnet/examples/various/input_fields.py index 5d1eccc..87f4d87 100644 --- a/nomadnet/examples/various/input_fields.py +++ b/nomadnet/examples/various/input_fields.py @@ -25,6 +25,8 @@ An masked field : `B444``b An small field : `B444`<8|small`test>`b, and some more text. +A multi-row field : `B444`<40x4|notes`>`b + Two fields : `B444`<8|one`One>`b `B444`<8|two`Two>`b The data can be `!`[submitted`:/page/input_fields.mu`username|two]`!. diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index 15abd7b..9ba9d7d 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -169,6 +169,10 @@ GLYPHS = { ("audio", "[~]", "\u266b", "\uf1c7"), ("pin", "*", "\u2605", "\uf08d"), ("copy", "[C]", "\u29c9", "\uf0c5"), + ("folder", "[+]", "\u25b8", "\uf07b"), + ("folder_open", "[-]", "\u25be", "\uf07c"), + ("fold_open", "-", "▾", "▾"), + ("fold_closed", "+", "▸", "▸"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 90eaff4..cb637d6 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -347,6 +347,8 @@ class Browser: if target_idx is None: return + self.reveal_index(int(target_idx)) + row_offset = self._rows_above(int(target_idx), cols) try: @@ -363,7 +365,7 @@ class Browser: browser_cols = screen_cols-lw-2 cols = browser_cols - except Exception: + except Exception as e: RNS.log(f"Could not calculate browser width, anchors will be inaccurate: {e}", RNS.LOG_WARNING) RNS.trace_exception(e) cols = 100 @@ -373,8 +375,10 @@ class Browser: def _rows_above(self, index, cols): if index <= 0 or not self.attr_maps: return 0 + hidden = self.hidden_indices() total = 0 for i in range(min(index, len(self.attr_maps))): + if i in hidden: continue try: total += self.attr_maps[i].rows((cols,)) except Exception: total += 1 @@ -627,6 +631,106 @@ class Browser: self.page_partials = {} self.browser_body = urwid.AttrMap(ScrollBar(Scrollable(pile, force_forward_keypress=True), thumb_char="\u2503", trough_char=" "), "scrollbar") self.detect_partials() + self.init_folds() + + # render an arbitrary markup buffer (used by the page editor preview) and + # return the content widget, with partials/folds/fields wired as usual + def render_markup_buffer(self, markup): + self.status = Browser.DONE + self.markup = markup + self.page_background_color = None + self.page_foreground_color = None + for tag, attr in (("#!bg=", "page_background_color"), ("#!fg=", "page_foreground_color")): + pos = markup.find(tag) + if pos >= 0: + endpos = markup.find("\n", pos) + if endpos < 0: endpos = len(markup) + if endpos-(pos+len(tag)) in (3, 6): + setattr(self, attr, markup[pos+len(tag):endpos]) + try: + self.attr_maps = markup_to_attrmaps(strip_modifiers(markup), url_delegate=self, fg_color=self.page_foreground_color, bg_color=self.page_background_color) + except Exception as e: + self.attr_maps = [urwid.AttrMap(urwid.Text(f"Could not render preview: {e}"), "browser_inactive")] + self.update_page_display() + return self.browser_body + + def init_folds(self): + self.fold_sections = {} + self.collapsed_headings = set() + attrmaps = self.attr_maps + header_levels = getattr(attrmaps, "header_levels", None) or {} + row_levels = getattr(attrmaps, "row_levels", None) or {} + collapsibles = getattr(attrmaps, "collapsibles", None) or {} + if not collapsibles: + return + + n = len(attrmaps) + for idx in sorted(collapsibles.keys()): + widget = self.collapsible_widget_at(idx) + if widget is None: continue + depth = header_levels.get(idx, 1) + end = n + for i in range(idx+1, n): + if row_levels.get(i, depth) < depth or \ + (i in header_levels and header_levels.get(i, 1) <= depth): + end = i; break + self.fold_sections[widget] = (idx+1, end) + if collapsibles[idx]: + self.collapsed_headings.add(widget) + try: widget.set_collapsed(True) + except Exception: pass + + if self.collapsed_headings: + self.rebuild_visible() + + def collapsible_widget_at(self, idx): + try: + o = self.attr_maps[idx]._original_widget + if getattr(o, "is_collapsible_heading", False): return o + except Exception: pass + return None + + def hidden_indices(self): + hidden = set() + for widget in getattr(self, "collapsed_headings", ()): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng: hidden.update(range(rng[0], rng[1])) + return hidden + + def rebuild_visible(self): + pile = getattr(self, "page_pile", None) + if pile is None: return + hidden = self.hidden_indices() + opts = pile.options() + pile.contents = [(w, opts) for i, w in enumerate(self.attr_maps) if i not in hidden] + try: + if pile.focus_position >= len(pile.contents): + pile.focus_position = max(0, len(pile.contents)-1) + except Exception: pass + + def fold_changed(self, heading_widget): + if getattr(heading_widget, "collapsed", False): + self.collapsed_headings.add(heading_widget) + else: + self.collapsed_headings.discard(heading_widget) + self.rebuild_visible() + try: + for pos, (w, _) in enumerate(self.page_pile.contents): + if getattr(w, "_original_widget", None) is heading_widget: + self.page_pile.focus_position = pos + break + except Exception: pass + + def reveal_index(self, idx): + changed = False + for widget in list(getattr(self, "collapsed_headings", ())): + rng = getattr(self, "fold_sections", {}).get(widget) + if rng and rng[0] <= idx < rng[1]: + self.collapsed_headings.discard(widget) + try: widget.set_collapsed(False) + except Exception: pass + changed = True + if changed: self.rebuild_visible() def parse_url(self, url): path = None diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index f022206..4d30068 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -20,7 +20,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 @@ -639,22 +639,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() diff --git a/nomadnet/ui/textui/Editor.py b/nomadnet/ui/textui/Editor.py new file mode 100644 index 0000000..bc3d4a3 --- /dev/null +++ b/nomadnet/ui/textui/Editor.py @@ -0,0 +1,1053 @@ +import os +import json +import shutil +import urwid +import RNS +import nomadnet + +from .ReadlineEdit import ReadlineEdit +from .Browser import Browser +from nomadnet.vendor.additional_urwid_widgets import IndicativeListBox + +PREVIEW_DELAY = 0.4 +PANE_WEIGHTS = {"files": 0.22, "editor": 0.40, "preview": 0.38} +PANE_ORDER = ["files", "editor", "preview"] + + + + +SNIPPETS = [ + ("Heading", ">Heading\n"), + ("Sub-heading", ">>Sub heading\n"), + ("Collapsible", "`+>Section\nbody\n<\n"), + ("Bold", "`!bold`!"), + ("Italic", "`*italic`*"), + ("Underline", "`_underline`_"), + ("Foreground col", "`F00ftext`f"), + ("Background col", "`B400text`b"), + ("Link", "`[label`:/page/index.mu]"), + ("Anchor", "`:anchor-name"), + ("Anchor link", "`[label`#anchor-name]"), + ("Input field", "`<24|name`>"), + ("Masked field", "`"), + ("Checkbox", "`"), + ("Radio", "`<^|name|value`>"), + ("Divider", "-\n"), + ("Table", "`t\n| A | B |\n| 1 | 2 |\n`t\n"), + ("Literal block", "`=\nliteral text\n`=\n"), +] + + + + + + + +EDITOR_STYLES = [ + ("editor_comment", "dark gray", "default", None, "#888", "default"), + ("editor_heading", "light cyan,bold", "default", None, "#0cc", "default"), + ("editor_tag", "light magenta", "default", None, "#c6c", "default"), + ("editor_field", "yellow", "default", None, "#cc0", "default"), + ("editor_link", "light green", "default", None, "#0c6", "default"), + ("editor_dir", "light blue,bold", "default", None, "#39f", "default"), + ("editor_exec", "dark gray", "default", None, "#666", "default"), + ("editor_lineno", "dark gray", "default", None, "#555", "default"), +] + + +def is_executable(path): + try: + return (not RNS.vendor.platformutils.is_windows()) and os.path.isfile(path) and os.access(path, os.X_OK) + except Exception: + return False + + +def _scan_tag(text, i, n): + # text[i] == "`"; return (end_index, attr) + if i + 1 >= n: + return i + 1, "editor_tag" + c = text[i + 1] + if c == "<": + j = text.find(">", i + 2); return (n if j < 0 else j + 1), "editor_field" + if c == "[": + j = text.find("]", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c == "{": + j = text.find("}", i + 2); return (n if j < 0 else j + 1), "editor_link" + if c in "FB": + if i + 2 < n and text[i + 2] == "T": + return min(i + 9, n), "editor_tag" + return min(i + 5, n), "editor_tag" + if c == ":": + j = i + 2 + while j < n and (text[j].isalnum() or text[j] in "_-"): + j += 1 + return j, "editor_tag" + return i + 2, "editor_tag" + + +def highlight_micron(text): + n = len(text) + attrs = [None] * n + i = 0 + line_start = True + while i < n: + c = text[i] + if line_start and c == "#": + j = text.find("\n", i); j = n if j < 0 else j + for k in range(i, j): attrs[k] = "editor_comment" + i = j; continue + if line_start and c == ">": + j = i + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if line_start and c == "`" and i + 2 < n and text[i + 1] in "+-" and text[i + 2] == ">": + j = i + 2 + while j < n and text[j] == ">": j += 1 + for k in range(i, j): attrs[k] = "editor_heading" + i = j; line_start = False; continue + if c == "\n": + i += 1; line_start = True; continue + if c == "\\" and i + 1 < n: + i += 2; line_start = False; continue + if c == "`": + j, attr = _scan_tag(text, i, n) + for k in range(i, min(j, n)): attrs[k] = attr + i = j; line_start = False; continue + i += 1; line_start = False + + out = [] + run_attr = attrs[0] if n else None + run_len = 0 + for a in attrs: + if a == run_attr: + run_len += 1 + else: + + out.append((run_attr, run_len)); run_attr = a; run_len = 1 + if run_len: + out.append((run_attr, run_len)) + return out + + +class MicronEdit(ReadlineEdit): + def __init__(self, *args, **kwargs): + self._hl_text = None + self._hl_attrib = [] + super().__init__(*args, **kwargs) + + def get_text(self): + text = self.caption + self.edit_text + if text != self._hl_text: + self._hl_text = text + self._hl_attrib = highlight_micron(text) + return text, self._hl_attrib + + +class GutterEdit(MicronEdit): + # MicronEdit with a wrap-aware line-number gutter on the left + def _gutter_width(self, maxcol): + return max(3, len(str(self.edit_text.count("\n") + 1)) + 2) + + def rows(self, size, focus=False): + gw = self._gutter_width(size[0]) + return super().rows((max(1, size[0] - gw),), focus) + + def get_cursor_coords(self, size): + gw = self._gutter_width(size[0]) + coords = super().get_cursor_coords((max(1, size[0] - gw),)) + if coords is None: + return None + return coords[0] + gw, coords[1] + + def move_cursor_to_coords(self, size, x, y): + gw = self._gutter_width(size[0]) + return super().move_cursor_to_coords((max(1, size[0] - gw),), max(0, x - gw), y) + + def render(self, size, focus=False): + maxcol = size[0] + gw = self._gutter_width(maxcol) + tw = max(1, maxcol - gw) + + + + edit_canv = super().render((tw,), focus=False) + h = edit_canv.rows() + rows = [] + for i, line in enumerate(self.edit_text.split("\n"), 1): + rows.append(str(i).rjust(gw - 1) + " ") + rows.extend([""] * (max(1, urwid.Text(line).rows((tw,))) - 1)) + rows = (rows + [""] * h)[:h] + + + + + gutter_canv = urwid.Text([("editor_lineno", "\n".join(r.ljust(gw) for r in rows))], wrap="clip").render((gw,)) + joined = urwid.CanvasJoin([(gutter_canv, None, False, gw), (edit_canv, None, focus, tw)]) + if focus: + coords = super().get_cursor_coords((tw,)) + if coords is not None: + joined.cursor = (coords[0] + gw, coords[1]) + return joined + + def mouse_event(self, size, event, button, x, y, focus): + gw = self._gutter_width(size[0]) + if x < gw: + return True + return super().mouse_event((max(1, size[0] - gw),), event, button, x - gw, y, focus) + + +class FileEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, executable=False): + self.path = path + self.executable = executable + text = (" " * depth) + "· " + label + (" *" if executable else "") + style = "editor_exec" if executable else None + super().__init__(urwid.AttrMap(urwid.Text(text), style, focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class FolderEntry(urwid.WidgetWrap): + signals = ["click"] + + def __init__(self, label, path, depth, expanded, glyphs): + self.path = path + icon = glyphs.get("folder_open" if expanded else "folder", "[-]" if expanded else "[+]") + text = (" " * depth) + icon + " " + label + "/" + super().__init__(urwid.AttrMap(urwid.Text(text), "editor_dir", focus_map="list_focus")) + + def selectable(self): + return True + + def keypress(self, size, key): + if self._command_map[key] == urwid.ACTIVATE: + self._emit("click") + return None + return key + + def mouse_event(self, size, event, button, x, y, focus): + if button == 1 and urwid.util.is_mouse_press(event): + self._emit("click") + return True + return False + + +class PathDialog(urwid.WidgetWrap): + def __init__(self, title, default, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=default) + pile = urwid.Pile([ + urwid.Text(title), + urwid.Divider(), + urwid.AttrMap(self.edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("OK", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + if key == "enter" and self._w.original_widget.original_widget.focus_position == 2: + self.on_ok(self.edit.edit_text); return None + return super().keypress(size, key) + + +class InfoDialog(urwid.WidgetWrap): + def __init__(self, title, message, on_ok): + self.on_ok = on_ok + pile = urwid.Pile([ + urwid.Text(message), + urwid.Divider(), + urwid.Button("OK", on_press=lambda b: on_ok()), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key in ("esc", "enter"): + self.on_ok(); return None + return super().keypress(size, key) + + +class ConfirmDialog(urwid.WidgetWrap): + def __init__(self, title, message, buttons, on_cancel=None): + self.on_cancel = on_cancel + cols = [urwid.Button(label, on_press=lambda b, c=cb: c()) for label, cb in buttons] + pile = urwid.Pile([urwid.Text(message), urwid.Divider(), urwid.Columns(cols, dividechars=2)]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc" and self.on_cancel: + self.on_cancel(); return None + return super().keypress(size, key) + + +class SnippetDialog(urwid.WidgetWrap): + def __init__(self, on_pick, on_cancel): + self.on_cancel = on_cancel + items = [urwid.AttrMap(urwid.Button(name, on_press=lambda b, t=text: on_pick(t)), None, focus_map="list_focus") + for name, text in SNIPPETS] + super().__init__(urwid.LineBox(urwid.ListBox(urwid.SimpleFocusListWalker(items)), title="Insert Micron snippet")) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class FindDialog(urwid.WidgetWrap): + def __init__(self, ed, on_close): + self.ed = ed + self.on_close = on_close + self.find_edit = ReadlineEdit(caption="Find: ") + self.repl_edit = ReadlineEdit(caption="Replace: ") + pile = urwid.Pile([ + urwid.AttrMap(self.find_edit, "list_off_focus", "list_focus"), + urwid.AttrMap(self.repl_edit, "list_off_focus", "list_focus"), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Next", on_press=lambda b: ed.find_next(self.find_edit.edit_text)), + urwid.Button("Replace", on_press=lambda b: ed.replace_one(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("All", on_press=lambda b: ed.replace_all(self.find_edit.edit_text, self.repl_edit.edit_text)), + urwid.Button("Close", on_press=lambda b: on_close()), + ], dividechars=1), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title="Find / Replace")) + + def keypress(self, size, key): + if key == "esc": + self.on_close(); return None + return super().keypress(size, key) + + +class PermissionsDialog(urwid.WidgetWrap): + def __init__(self, title, note, content, on_ok, on_cancel): + self.on_ok = on_ok + self.on_cancel = on_cancel + self.edit = ReadlineEdit(caption="", edit_text=content, multiline=True) + pile = urwid.Pile([ + urwid.Text(note), + urwid.Divider(), + urwid.BoxAdapter(urwid.AttrMap(urwid.ListBox(urwid.SimpleListWalker([self.edit])), "list_off_focus", "list_focus"), 7), + urwid.Divider(), + urwid.Columns([ + urwid.Button("Save", on_press=lambda b: self.on_ok(self.edit.edit_text)), + urwid.Button("Cancel", on_press=lambda b: self.on_cancel()), + ], dividechars=2), + ]) + super().__init__(urwid.LineBox(urwid.Filler(pile, valign="top"), title=title)) + + def keypress(self, size, key): + if key == "esc": + self.on_cancel(); return None + return super().keypress(size, key) + + +class EditorColumns(urwid.Columns): + def keypress(self, size, key): + ed = self.editor_display + if key == "ctrl s": + ed.save_or_saveas(); return None + if key == "ctrl n": + ed.new_file_dialog(); return None + if key == "ctrl f": + ed.find_dialog(); return None + if key == "ctrl g": + ed.snippet_dialog(); return None + if key == "ctrl p": + ed.permissions_dialog(); return None + if key == "f2" and ed.files_focused(): + ed.rename_dialog(); return None + if key == "delete" and ed.files_focused(): + ed.delete_dialog(); return None + if key == "ctrl t": + ed.toggle_pane("files"); return None + if key == "ctrl b": + ed.toggle_pane("editor"); return None + if key == "ctrl r": + ed.toggle_pane("preview"); return None + if key == "tab": + self.focus_position = (self.focus_position + 1) % len(self.contents); return None + if key == "shift tab": + self.focus_position = (self.focus_position - 1) % len(self.contents); return None + if key == "esc": + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" + return None + return super().keypress(size, key) + + +class PageEditorShortcuts(): + def __init__(self, app): + self.app = app + self.status = urwid.Text("", align=urwid.RIGHT) + base = "[C-n]New [C-s]Save [C-f]Find [C-g]Snippet [C-p]Perms [F2]Rename [Del]Delete [Tab]Pane [C-t/b/r]Toggle [Esc]Menu" + self.widget = urwid.AttrMap(urwid.Columns([urwid.Text(base), self.status], dividechars=2), "shortcutbar") + + def set_status(self, msg): + self.status.set_text(msg) + + +class PageEditorDisplay(): + def __init__(self, app): + self.app = app + self.current_path = None + self.editable = False + self.is_exec = False + self.dirty = False + self.preview_alarm = None + self._loading = False + self._find = None + self._last_file = None + self.expanded = set(p for p in (app.pagespath, app.filespath) if p) + self.filter_mu = False + self.pane_visible = {"files": True, "editor": True, "preview": True} + self.load_state() + self.register_styles() + + self.preview_browser = Browser(self.app, "nomadnetwork", "node", delegate=None) + self.preview_browser.handle_link = self._preview_handle_link + self.preview_browser.marked_link = lambda *a, **k: None + self.preview_browser._content_cols = self._preview_cols + # don't fetch partials in the preview + self.preview_browser.detect_partials = lambda: None + + self.editor = GutterEdit(caption="", edit_text="", multiline=True) + urwid.connect_signal(self.editor, "postchange", self.on_edit_change) + + self.filter_checkbox = urwid.CheckBox("Show only .mu", state=self.filter_mu, on_state_change=self.on_filter_change) + self.tree_walker = urwid.SimpleFocusListWalker(self.build_entries()) + self.tree_listbox = IndicativeListBox(self.tree_walker, initialization_is_selection_change=False, highlight_offFocus="list_off_focus") + self.files_pile = urwid.Pile([ + (urwid.PACK, urwid.AttrMap(self.filter_checkbox, None, focus_map="list_focus")), + (urwid.WEIGHT, 1, self.tree_listbox), + ]) + try: self.files_pile.focus_position = 1 + except Exception: pass + + self.pane_widgets = { + "files": urwid.LineBox(self.files_pile, title="Files"), + "editor": urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker([self.editor])), title="Editor"), + "preview": urwid.LineBox(urwid.SolidFill(" "), title="Preview"), + } + + self.columns = EditorColumns([], dividechars=0) + self.columns.editor_display = self + self._visible = [] + self.rebuild_columns() + + self.shortcuts_display = PageEditorShortcuts(self.app) + self.body = urwid.WidgetPlaceholder(self.columns) + self.widget = self.body + + def register_styles(self): + screen = self.app.ui.screen + for entry in EDITOR_STYLES: + try: screen.register_palette_entry(*entry) + except Exception: pass + + def _preview_handle_link(self, target, link_data=None): + # in the preview follow in page anchors but ignore external navigation + # TODO + + if isinstance(target, str) and target.startswith("#"): + name = target[1:] + self.app.ui.loop.set_alarm_in(0.0, lambda l, d: self.preview_browser._jump_to_anchor(name)) + + def _preview_cols(self): + try: + cols = self.app.ui.loop.screen.get_cols_rows()[0] + visible = self.visible_panes() + total = sum(PANE_WEIGHTS[n] for n in visible) + frac = PANE_WEIGHTS["preview"] / total if "preview" in visible else PANE_WEIGHTS["preview"] + return max(20, int(cols * frac) - 3) + except Exception: + return 80 + + def shortcuts(self): + return self.shortcuts_display + + def start(self): + self.refresh_tree() + if self._last_file and os.path.isfile(self._last_file) and self.current_path is None: + self.load_file(self._last_file) + self._last_file = None + + def files_focused(self): + try: return self.visible_panes()[self.columns.focus_position] == "files" + except Exception: return False + + def _state_file(self): + d = getattr(self.app, "configdir", None) + return os.path.join(d, "editor_state.json") if d else None + + def load_state(self): + p = self._state_file() + if not p or not os.path.isfile(p): + return + try: + with open(p) as f: + st = json.load(f) + self.filter_mu = bool(st.get("filter_mu", self.filter_mu)) + if isinstance(st.get("expanded"), list): + self.expanded = set(st["expanded"]) + pv = st.get("pane_visible") + if isinstance(pv, dict): + for k in self.pane_visible: + if k in pv: self.pane_visible[k] = bool(pv[k]) + self._last_file = st.get("last_file") + except Exception: + pass + + def save_state(self): + p = self._state_file() + if not p: + return + try: + self._atomic_write(p, json.dumps({"filter_mu": self.filter_mu, "expanded": sorted(self.expanded), + "pane_visible": self.pane_visible, "last_file": self.current_path})) + except Exception: + pass + + def _selected_tree(self): + try: + sel = self.tree_listbox.get_selected_item() + return sel.original_widget if sel else None + except Exception: + return None + + def _roots(self): + return [r for r in (self.app.pagespath, self.app.filespath) if r] + + def _is_root(self, path): + try: + rp = os.path.realpath(path) + except Exception: + return False + return any(rp == os.path.realpath(r) for r in self._roots()) + + def _confine(self, path, base=None): + # Never save outside of the Nomad storage directory + path = (path or "").strip() + if not path: + return None + path = os.path.expanduser(path) + if not os.path.isabs(path): + base = base or self.app.pagespath or self.app.filespath + if not base: + return None + path = os.path.join(base, path) + norm = os.path.normpath(path) + for root in self._roots(): + rp = os.path.normpath(root) + if norm == rp or norm.startswith(rp + os.sep): + return norm + return None + + def _atomic_write(self, path, text): + d = os.path.dirname(path) or "." + tmp = os.path.join(d, "." + os.path.basename(path) + ".tmp") + try: + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + f.flush() + try: os.fsync(f.fileno()) + except Exception: pass + os.replace(tmp, path) + except Exception: + try: os.remove(tmp) + except Exception: pass + raise + + def _confirm_overwrite(self, dest, proceed): + self.show_dialog(ConfirmDialog("Overwrite", + "%s already exists.\nOverwrite it?" % os.path.basename(dest), + [("Overwrite", proceed), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def visible_panes(self): + return [n for n in PANE_ORDER if self.pane_visible[n]] or ["editor"] + + def rebuild_columns(self): + focused = None + try: focused = self._visible[self.columns.focus_position] + except Exception: pass + visible = self.visible_panes() + self.columns.contents = [ + (self.pane_widgets[n], self.columns.options(urwid.WEIGHT, PANE_WEIGHTS[n], box_widget=True)) + for n in visible + ] + self._visible = visible + # the rpeview starts non-selectable + target = focused if focused in visible else ("files" if "files" in visible else visible[0]) + try: self.columns.focus_position = visible.index(target) + except Exception: pass + + def toggle_pane(self, name): + visible = self.visible_panes() + if self.pane_visible[name] and len(visible) <= 1: + return + self.pane_visible[name] = not self.pane_visible[name] + self.rebuild_columns() + self.save_state() + + def refresh_tree(self): + focus_path = None + try: + sel = self.tree_listbox.get_selected_item() + if sel is not None: + focus_path = getattr(sel.original_widget, "path", None) + except Exception: + pass + entries = self.build_entries() + # indicativelistbox wraps items in attrmap for highlighting + self.tree_walker[:] = [urwid.AttrMap(e, None) for e in entries] + if focus_path is not None: + for i, e in enumerate(entries): + if getattr(e, "path", None) == focus_path: + try: self.tree_listbox.select_item(i) + except Exception: pass + break + + def build_entries(self): + entries = [] + for label, root in (("pages", self.app.pagespath), ("files", self.app.filespath)): + if not root or not os.path.isdir(root): + continue + entries.extend(self.walk_dir(label, root, 0)) + if not entries: + entries = [urwid.Text("no files")] + return entries + + def walk_dir(self, name, path, depth): + out = [] + expanded = path in self.expanded + folder = FolderEntry(name, path, depth, expanded, self.app.ui.glyphs) + urwid.connect_signal(folder, "click", self.on_folder_click) + out.append(folder) + if not expanded: + return out + try: + names = sorted(os.listdir(path), key=str.lower) + except Exception: + return out + dirs = [n for n in names if os.path.isdir(os.path.join(path, n)) and not n.startswith(".") and n != "__pycache__"] + files = [n for n in names if not os.path.isdir(os.path.join(path, n)) and not n.startswith(".")] + if self.filter_mu: + files = [n for n in files if n.endswith(".mu")] + dirs = [d for d in dirs if self._dir_has_mu(os.path.join(path, d))] + for d in dirs: + out.extend(self.walk_dir(d, os.path.join(path, d), depth + 1)) + for fn in files: + full = os.path.join(path, fn) + entry = FileEntry(fn, full, depth + 1, executable=is_executable(full)) + urwid.connect_signal(entry, "click", self.on_file_click) + out.append(entry) + return out + + def _dir_has_mu(self, path): + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if any(f.endswith(".mu") for f in files): + return True + return False + + def on_filter_change(self, checkbox, state): + self.filter_mu = state + self.refresh_tree() + self.save_state() + + def on_folder_click(self, folder): + if folder.path in self.expanded: + self.expanded.discard(folder.path) + else: + self.expanded.add(folder.path) + self.refresh_tree() + self.save_state() + + def on_file_click(self, entry): + self.open_path(entry.path) + + def open_path(self, path): + if self.dirty: + self.confirm_discard(lambda: self._do_open(path)) + else: + self._do_open(path) + + def _do_open(self, path): + self.load_file(path) + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + + def confirm_discard(self, proceed): + def save_then(): + self.close_dialog() + if self.current_path and self.editable: + self.save_file(self.current_path) + proceed() + name = os.path.basename(self.current_path) if self.current_path else "this file" + self.show_dialog(ConfirmDialog("Unsaved changes", "Discard unsaved changes to %s?" % name, + [("Save", save_then), ("Discard", lambda: (self.close_dialog(), proceed())), ("Cancel", self.close_dialog)], + on_cancel=self.close_dialog)) + + def load_file(self, path): + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.editable = True + except UnicodeDecodeError: + content = "" + self.editable = False + except Exception as e: + self.set_status("open failed: %s" % e); return + + self.current_path = path + self.is_exec = is_executable(path) + self.dirty = False + self._loading = True + self.editor.set_edit_text(content) + self.editor.set_edit_pos(0) + self._loading = False + + name = os.path.basename(path) + self.update_title() + self.save_state() + if not self.editable: + self.set_status("binary file (not editable)") + elif self.is_exec: + self.set_preview_note(name + " is an executable page. the node runs it and serves its output.") + self.set_status("executable page") + self.show_info_dialog("Executable page", name + " is marked executable (chmod +x). NomadNet runs it and serves its output as the page, so the preview shows the source rather than the rendered output.") + else: + self.render_preview() + self.set_status("opened " + name) + + def update_title(self, modified=False): + + + + if self.current_path is None: + self.pane_widgets["editor"].set_title("Editor"); return + suffix = " (binary)" if not self.editable else (" (executable)" if self.is_exec else "") + if modified: + suffix += " *" + self.pane_widgets["editor"].set_title("Editor: " + os.path.basename(self.current_path) + suffix) + + def set_preview_note(self, msg): + self.pane_widgets["preview"].original_widget = urwid.Filler(urwid.Text(msg), urwid.TOP) + + def show_info_dialog(self, title, message): + self.show_dialog(InfoDialog(title, message, self.close_dialog)) + + def save_or_saveas(self): + if self.current_path is None: + self.save_as_dialog(); return + self.save_file(self.current_path) + + def save_file(self, path): + if not self.editable and self.current_path is not None: + self.set_status("cannot save binary file"); return + try: + self._atomic_write(path, self.editor.edit_text) + self.current_path = path + self.editable = True + self.dirty = False + self.set_modified(False) + self.set_status("saved " + os.path.basename(path)) + self.refresh_tree() + self.save_state() + except Exception as e: + self.set_status("save failed: %s" % e) + + def new_file_dialog(self): + default = (self.app.pagespath or "") + os.sep + self.show_dialog(PathDialog("New (end with / for a folder)", default, self._do_new, self.close_dialog)) + + def _do_new(self, path): + self.close_dialog() + raw = (path or "").strip() + if not raw: + return + is_folder = raw.endswith("/") or raw.endswith(os.sep) + target = self._confine(raw) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + try: + if is_folder: + os.makedirs(target, exist_ok=True) + self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("created folder " + os.path.basename(target)) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + if not os.path.exists(target): + self._atomic_write(target, "") + self.refresh_tree() + self.open_path(target) + except Exception as e: + self.set_status("new failed: %s" % e) + + def rename_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to rename"); return + if self._is_root(w.path): + self.set_status("cannot rename the pages/files root"); return + self.show_dialog(PathDialog("Rename", w.path, lambda p, old=w.path: self._do_rename(old, p), self.close_dialog)) + + def _do_rename(self, old, new): + self.close_dialog() + target = self._confine(new, base=os.path.dirname(old)) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + if target == os.path.normpath(old): + return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + os.rename(old, target) + if self.current_path == old: + self.current_path = target; self.update_title() + if old in self.expanded: + self.expanded.discard(old); self.expanded.add(target) + self.refresh_tree(); self.save_state() + self.set_status("renamed to " + os.path.basename(target)) + except Exception as e: + self.set_status("rename failed: %s" % e) + + if os.path.exists(target) and os.path.realpath(target) != os.path.realpath(old): + self._confirm_overwrite(target, proceed) + else: + proceed() + + def delete_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder to delete"); return + path = w.path + if self._is_root(path): + self.set_status("cannot delete the pages/files root"); return + self.show_dialog(ConfirmDialog("Delete", "Delete %s?" % os.path.basename(path), + [("Delete", lambda: self._do_delete(path)), ("Cancel", self.close_dialog)], on_cancel=self.close_dialog)) + + def _do_delete(self, path): + self.close_dialog() + try: + if os.path.isdir(path): + shutil.rmtree(path); self.expanded.discard(path) + else: + os.remove(path) + if self.current_path == path: + self.current_path = None; self.editable = False; self.is_exec = False; self.dirty = False + self._loading = True; self.editor.set_edit_text(""); self._loading = False + self.update_title(); self.set_preview_note("") + self.refresh_tree(); self.save_state() + self.set_status("deleted " + os.path.basename(path)) + except Exception as e: + self.set_status("delete failed: %s" % e) + + def permissions_dialog(self): + w = self._selected_tree() + if not w or not getattr(w, "path", None): + self.set_status("select a file/folder for permissions"); return + path = w.path + is_dir = os.path.isdir(path) + content = "" + if not is_dir and os.path.isfile(path + ".allowed"): + try: content = open(path + ".allowed", encoding="utf-8").read() + except Exception: content = "" + note = ("Allowed identity hashes per line. Empty = public.\n" + + ("Applies to every page in this folder." if is_dir else "Applys to this page.")) + title = "Permissions: " + os.path.basename(path) + ("/" if is_dir else "") + self.show_dialog(PermissionsDialog(title, note, content, + lambda txt: self._save_permissions(path, is_dir, txt), self.close_dialog)) + + def _save_permissions(self, path, is_dir, text): + self.close_dialog() + body = "\n".join(ln.strip() for ln in text.splitlines() if ln.strip()) + try: + if is_dir: + count = 0 + for root, dirs, files in os.walk(path): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".allowed"): + continue + self._write_allowed(os.path.join(root, fn), body) + count += 1 + self.set_status("permissions applied to %d files" % count) + else: + self._write_allowed(path, body) + self.set_status("permissions saved" if body else "permissions cleared (public)") + self.refresh_tree() + except Exception as e: + self.set_status("permissions failed: %s" % e) + + def _write_allowed(self, path, body): + ap = path + ".allowed" + if body: + self._atomic_write(ap, body + "\n") + elif os.path.exists(ap): + os.remove(ap) + + def snippet_dialog(self): + self.show_dialog(SnippetDialog(self._insert_snippet, self.close_dialog)) + + def _insert_snippet(self, text): + self.close_dialog() + if self.pane_visible["editor"]: + try: self.columns.focus_position = self.visible_panes().index("editor") + except Exception: pass + pos = self.editor.edit_pos + t = self.editor.edit_text + self.editor.set_edit_text(t[:pos] + text + t[pos:]) + self.editor.set_edit_pos(pos + len(text)) + + def find_dialog(self): + if self._find is None: + self._find = FindDialog(self, self.close_dialog) + self.show_dialog(self._find) + + def find_next(self, term): + if not term: + return + t = self.editor.edit_text + idx = t.find(term, self.editor.edit_pos) + if idx < 0: + idx = t.find(term, 0) + if idx < 0: + self.set_status("'%s' not found" % term); return + self.editor.set_edit_pos(idx + len(term)) + self.set_status("found at %d" % idx) + + def replace_one(self, term, repl): + if not term: + return + t = self.editor.edit_text + end = self.editor.edit_pos + start = end - len(term) + if start >= 0 and t[start:end] == term: + self.editor.set_edit_text(t[:start] + repl + t[end:]) + self.editor.set_edit_pos(start + len(repl)) + self.set_status("replaced") + else: + self.find_next(term) + + def replace_all(self, term, repl): + if not term: + return + cnt = self.editor.edit_text.count(term) + if cnt: + self.editor.set_edit_text(self.editor.edit_text.replace(term, repl)) + self.set_status("replaced %d" % cnt) + else: + self.set_status("'%s' not found" % term) + + def save_as_dialog(self): + default = self.current_path or ((self.app.pagespath or "") + os.sep) + self.show_dialog(PathDialog("Save as", default, self._do_saveas, self.close_dialog)) + + def _do_saveas(self, path): + self.close_dialog() + target = self._confine(path) + if target is None: + self.set_status("path must be inside pages/ or files/"); return + + def proceed(): + self.close_dialog() + try: + os.makedirs(os.path.dirname(target), exist_ok=True) + except Exception: + pass + self.editable = True + self.save_file(target) + + already = self.current_path is None or os.path.realpath(target) != os.path.realpath(self.current_path) + if os.path.exists(target) and already: + self._confirm_overwrite(target, proceed) + else: + proceed() + + def show_dialog(self, dialog): + self.dialog = dialog + overlay = urwid.Overlay( + dialog, self.columns, + align=urwid.CENTER, width=(urwid.RELATIVE, 60), + valign=urwid.MIDDLE, height=(urwid.RELATIVE, 40), + min_width=30, min_height=8, + ) + self.body.original_widget = overlay + + def close_dialog(self): + self.dialog = None + self.body.original_widget = self.columns + + def on_edit_change(self, widget, old_text): + if self._loading or not self.editable: + return + self.dirty = True + self.set_modified(True) + if self.is_exec: + return + if self.preview_alarm is not None: + try: self.app.ui.loop.remove_alarm(self.preview_alarm) + except Exception: pass + self.preview_alarm = self.app.ui.loop.set_alarm_in(PREVIEW_DELAY, self._render_preview_alarm) + + def _render_preview_alarm(self, loop, user_data): + self.preview_alarm = None + self.render_preview() + + def render_preview(self): + pos = self._preview_scrollpos() + try: + body = self.preview_browser.render_markup_buffer(self.editor.edit_text) + except Exception as e: + body = urwid.Filler(urwid.Text("preview error: %s" % e), urwid.TOP) + self.pane_widgets["preview"].original_widget = body + self._set_preview_scrollpos(pos) + + def _preview_scrollable(self): + try: + return self.preview_browser.browser_body.original_widget.original_widget + except Exception: + return None + + def _preview_scrollpos(self): + s = self._preview_scrollable() + try: return s.get_scrollpos() + except Exception: return None + + def _set_preview_scrollpos(self, pos): + if pos is None: return + s = self._preview_scrollable() + try: s.set_scrollpos(pos) + except Exception: pass + + def set_modified(self, modified): + self.update_title(modified) + + def set_status(self, msg): + self.shortcuts_display.set_status(msg) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 4316266..5ac7241 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -1420,6 +1420,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 +1588,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 +1808,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 +1844,8 @@ A sized input field: `B444`<16|with_size`>`B333 A masked input field: `B444``B333 +A multi-row input field: `B444`<40x5|notes`>`B333 + Full control: `B444``B333 `b diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py index ec0816e..ea97bee 100644 --- a/nomadnet/ui/textui/Helpers.py +++ b/nomadnet/ui/textui/Helpers.py @@ -17,6 +17,21 @@ def osc52_copy(text): return False +def qr_ascii(data): + try: + import qrcode + import io + qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=1, border=1) + qr.add_data(data) + qr.make() + buf = io.StringIO() + qr.print_ascii(out=buf, invert=False) + return buf.getvalue().rstrip("\n") + except Exception as e: + RNS.log("QR generation failed: "+str(e), RNS.LOG_ERROR) + return None + + class ClickableIcon(urwid.Text): _selectable = False diff --git a/nomadnet/ui/textui/Main.py b/nomadnet/ui/textui/Main.py index 9f14c22..a50d8b7 100644 --- a/nomadnet/ui/textui/Main.py +++ b/nomadnet/ui/textui/Main.py @@ -9,6 +9,7 @@ from .Interfaces import * from .Map import * from .Log import * from .Guide import * +from .Editor import * import urwid class SubDisplays(): @@ -23,6 +24,7 @@ class SubDisplays(): 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 @@ -136,6 +138,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() @@ -195,13 +205,17 @@ class MenuDisplay(): button_config = (10, MenuButton("Config", on_press=handler.show_config)) button_interfaces = (14, MenuButton("Interfaces", on_press=handler.show_interfaces)) 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, button_config] + if not self.app.config["textui"]["hide_guide"]: + buttons.append(button_guide) + buttons.append(button_quit) columns = MenuColumns(buttons, dividechars=1) columns.handler = handler diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index c274b66..0add998 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -36,6 +36,15 @@ SECTION_INDENT = 2 INDENT_RIGHT = 1 MAX_TABLE_WIDTH = 100 +DEFAULT_FOLD_GLYPHS = ("▾", "▸") + +def default_fold_glyphs(): + try: + g = nomadnet.NomadNetworkApp.get_shared_instance().ui.glyphs + return (g.get("fold_open", DEFAULT_FOLD_GLYPHS[0]), g.get("fold_closed", DEFAULT_FOLD_GLYPHS[1])) + except Exception: + return DEFAULT_FOLD_GLYPHS + def default_state(fg=None, bg=None): global SELECTED_STYLES ensure_selected_styles() @@ -132,15 +141,26 @@ def markup_to_attrmaps(markup, url_delegate = None, fg_color=None, bg_color=None if state.get("_header_pending"): state["header_rows"].append(row_index) + state.setdefault("header_levels", {})[row_index] = state["depth"] + cp = state.get("_collapsible_pending") + if cp is not None: + state.setdefault("collapsibles", {})[row_index] = bool(cp["collapsed"]) + state["_collapsible_pending"] = None state["_header_pending"] = False + row_levels = state.setdefault("row_levels", {}) for display_widget in display_widgets: + row_levels[len(attrmaps)] = state["depth"] attrmap = urwid.AttrMap(display_widget, make_style(state)) attrmaps.append(attrmap) try: setattr(attrmaps, "anchors", anchors) setattr(attrmaps, "header_rows", list(state.get("header_rows", []))) + setattr(attrmaps, "header_levels", dict(state.get("header_levels", {}))) + setattr(attrmaps, "row_levels", dict(state.get("row_levels", {}))) + setattr(attrmaps, "collapsibles", dict(state.get("collapsibles", {}))) + setattr(attrmaps, "fold_glyphs", state.get("fold_glyphs") or default_fold_glyphs()) except Exception: pass @@ -222,6 +242,7 @@ 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): @@ -297,6 +318,95 @@ class FormColumns(urwid.Columns): 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: @@ -309,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 @@ -322,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 @@ -395,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 @@ -440,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)) @@ -759,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: @@ -784,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: @@ -831,6 +966,7 @@ def make_output(state, line, url_delegate, pre_escape=False): "type": "field", "name": field_name, "width": field_width, + "rows": field_rows, "masked": field_masked, "data": field_data, "style": make_style(state) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 0332139..3818f6c 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -11,6 +11,7 @@ from nomadnet.util import sanitize_name from .Browser import Browser from .ReadlineEdit import ReadlineEdit +from .Helpers import qr_ascii class NetworkDisplayShortcuts(): def __init__(self, app): @@ -26,7 +27,17 @@ class NetworkDisplayShortcuts(): class DialogLineBox(urwid.LineBox): def keypress(self, size, key): if key == "esc": - self.delegate.update_conversation_list() + delegate = getattr(self, "delegate", None) + if delegate is not None and hasattr(delegate, "update_conversation_list"): + delegate.update_conversation_list() + elif delegate is not None and getattr(delegate, "parent", None) is not None: + try: + delegate.dialog_open = False + options = delegate.parent.left_pile.options(height_type=urwid.PACK, height_amount=None) + delegate.parent.left_pile.contents[1] = (type(delegate)(delegate.app, delegate.parent), options) + except Exception: + pass + return None else: return super(DialogLineBox, self).keypress(size, key) @@ -82,7 +93,7 @@ class AnnounceInfo(urwid.WidgetWrap): is_node = False is_pn = False if info_type == "node" or info_type == True: - type_string = "Nomad Network Node " + g["node"] + type_string = "NomadNet Node " + g["node"] is_node = True elif info_type == "pn": type_string = "LXMF Propagation Node " + g["sent"] @@ -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" @@ -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([ @@ -1483,14 +1532,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 6, connect_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 8, reset_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 7, announce_button), + urwid.Pile([ + urwid.Button("Peer Info", on_press=show_peer_info), + connect_button, + edit_button, + reset_button, + announce_button, + qr_button, ]) ]) else: @@ -1505,14 +1553,13 @@ class NodeInfo(urwid.WidgetWrap): self.t_total_pages, self.t_total_files, urwid.Divider(g["divider1"]), - urwid.Columns([ - (urwid.WEIGHT, 5, urwid.Button("Back", on_press=show_peer_info)), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 6, connect_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 8, reset_button), - (urwid.WEIGHT, 0.5, urwid.Text("")), - (urwid.WEIGHT, 7, announce_button), + urwid.Pile([ + urwid.Button("Peer Info", on_press=show_peer_info), + connect_button, + edit_button, + reset_button, + announce_button, + qr_button, ]) ]) else: @@ -1640,10 +1687,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