Browser/Conversations: add ability to copy browser URL and conversation message text

This commit is contained in:
Jeremy O'Brien
2026-05-21 16:16:06 -04:00
parent f8c57e675e
commit cf25ed8a64
5 changed files with 114 additions and 6 deletions
+1
View File
@@ -168,6 +168,7 @@ GLYPHS = {
("image", "[I]", "\u25a3", "\uf1c5"),
("audio", "[~]", "\u266b", "\uf1c7"),
("pin", "*", "\u2605", "\uf08d"),
("copy", "[C]", "\u29c9", "\uf0c5"),
}
class TextUI:
+40 -1
View File
@@ -14,6 +14,7 @@ from nomadnet.Directory import DirectoryEntry
from nomadnet.vendor.Scrollable import *
from nomadnet.util import strip_modifiers
from nomadnet.util import sanitize_name
from .Helpers import ClickableIcon, osc52_copy
class BrowserFrame(urwid.Frame):
def keypress(self, size, key):
@@ -33,6 +34,8 @@ class BrowserFrame(urwid.Frame):
self.delegate.save_node_dialog()
elif key == "ctrl g":
nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.sub_displays.network_display.toggle_fullscreen()
elif key == "ctrl y":
self.delegate.copy_url()
elif self.focus_position == "body":
if key == "down" or key == "up":
try:
@@ -488,7 +491,15 @@ class Browser:
return urwid.AttrMap(widget, "browser_controls")
def make_control_widget(self):
return urwid.AttrMap(urwid.Pile([urwid.Text(self.g["node"]+" "+self.current_url()), urwid.Divider(self.g["divider1"])]), "browser_controls")
url_text = urwid.Text(self.g["node"]+" "+self.current_url())
copy_glyph = self.g.get("copy", "[C]")
copy_icon = ClickableIcon(copy_glyph, on_click=lambda: self.copy_url())
copy_width = len(copy_glyph) + 2
header_row = urwid.Columns([
("weight", 1, url_text),
(copy_width, urwid.Padding(copy_icon, left=1, right=1)),
])
return urwid.AttrMap(urwid.Pile([header_row, urwid.Divider(self.g["divider1"])]), "browser_controls")
def make_request_failed_widget(self):
def back_action(sender):
@@ -1067,6 +1078,34 @@ class Browser:
self.uncache_page(self.current_url())
self.load_page()
def copy_url(self):
url = self.current_url()
if not url:
return
if not osc52_copy(url):
return
try:
notice = urwid.AttrMap(urwid.Pile([urwid.Divider(self.g["divider1"]), urwid.Text("Copied URL to clipboard")]), "browser_controls")
if self.page_background_color != None or self.page_foreground_color != None:
style_name = make_style(default_state(fg=self.page_foreground_color, bg=self.page_background_color))
notice.set_attr_map({None: style_name})
self.browser_footer = notice
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
def restore_footer(loop, user_data):
if self.status == Browser.DONE:
self.browser_footer = self.make_status_widget()
else:
self.browser_footer = urwid.Text("")
if self.frame is not None:
self.frame.contents["footer"] = (self.browser_footer, self.frame.options())
self.app.ui.loop.set_alarm_in(2.0, restore_footer)
except Exception as e:
RNS.log("Could not update footer after URL copy: "+str(e), RNS.LOG_ERROR)
def close_dialogs(self):
options = self.delegate.columns.options(urwid.WEIGHT, self.delegate.right_area_width)
self.delegate.columns.contents[1] = (self.display_widget, options)
+41 -4
View File
@@ -20,6 +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 nomadnet.util import strip_modifiers, strip_micron, strip_escaped_micron, unescape_micron, strip_non_formatting_tags
from nomadnet.ui import THEME_DARK, THEME_LIGHT
@@ -2235,7 +2236,7 @@ class ConversationWidget(urwid.WidgetWrap):
added_hashes.append(message_hash)
was_loaded = message.loaded
try:
message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"])
message_widget = LXMessageWidget(message, theme=self.app.config["textui"]["theme"], conversation_widget=self)
except Exception as e:
RNS.log("Skipping message loading for "+str(message.file_path)+" due to error: "+str(e), RNS.LOG_WARNING)
message.unload()
@@ -2550,9 +2551,10 @@ class ConversationWidget(urwid.WidgetWrap):
class LXMessageWidget(urwid.WidgetWrap):
mdc = MarkdownToMicron(max_width=80, syntax_highlighter=SyntaxHighlighter(), url_scope=None)
def __init__(self, message, theme=THEME_DARK):
def __init__(self, message, theme=THEME_DARK, conversation_widget=None):
app = nomadnet.NomadNetworkApp.get_shared_instance()
g = app.ui.glyphs
self._conversation_widget = conversation_widget
self.timestamp = message.get_timestamp()
self.sort_timestamp = message.sort_timestamp
self.transfer_done = False
@@ -2629,12 +2631,47 @@ class LXMessageWidget(urwid.WidgetWrap):
attachment_strings.append(g[atype if atype != "file" else "file"]+" "+aname)
title_string += " | " + " ".join(attachment_strings)
title = urwid.AttrMap(urwid.Text(title_string), header_style)
content_text = message.get_content()
if content_text:
copy_glyph = g.get("copy", "[C]")
check_glyph = g.get("check", "v").center(len(copy_glyph))
copy_icon = ClickableIcon(copy_glyph)
conv_widget = self._conversation_widget
def on_copy_click(icon=copy_icon, content=content_text, cg=copy_glyph, kg=check_glyph, cw=conv_widget):
osc52_copy(content)
icon.set_text(kg)
def _restore(loop, user_data):
icon.set_text(cg)
try:
app.ui.loop.set_alarm_in(2.0, _restore)
except Exception:
icon.set_text(cg)
if cw is not None and cw.frame is not None:
def _refocus(loop, user_data):
try:
cw.frame.focus_position = "footer"
except Exception:
pass
try:
app.ui.loop.set_alarm_in(0, _refocus)
except Exception:
pass
copy_icon._on_click = on_copy_click
copy_width = len(copy_glyph) + 2
title_row = urwid.Columns([
("weight", 1, urwid.Text(title_string)),
(copy_width, urwid.Padding(copy_icon, left=1, right=1)),
])
title = urwid.AttrMap(title_row, header_style)
else:
title = urwid.AttrMap(urwid.Text(title_string), header_style)
self.progress_widget = urwid.Text("")
self.progress_attr = urwid.AttrMap(self.progress_widget, "progress_full")
content_text = message.get_content()
content_lines = content_text.split("\n")
markdown = renderer == LXMF.RENDERER_MARKDOWN
+31
View File
@@ -0,0 +1,31 @@
import sys
import base64
import urwid
import RNS
def osc52_copy(text):
if not text:
return False
try:
encoded = base64.b64encode(text.encode("utf-8")).decode("ascii")
sys.stdout.write("\x1b]52;c;" + encoded + "\x07")
sys.stdout.flush()
return True
except Exception as e:
RNS.log("Could not emit clipboard escape sequence: "+str(e), RNS.LOG_ERROR)
return False
class ClickableIcon(urwid.Text):
_selectable = False
def __init__(self, text, on_click=None):
super().__init__(text)
self._on_click = on_click
def mouse_event(self, size, event, button, x, y, focus):
if button == 1 and "press" in event and self._on_click is not None:
self._on_click()
return True
return False
+1 -1
View File
@@ -16,7 +16,7 @@ class NetworkDisplayShortcuts():
self.app = app
g = app.ui.glyphs
self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar")
self.widget = urwid.AttrMap(urwid.Text("[C-l] Nodes/Announces [C-x] Remove [C-w] Disconnect [C-d] Back [C-f] Forward [C-r] Reload [C-u] URL [C-y] Copy [C-g] Fullscreen [C-s / C-b] Save Node"), "shortcutbar")
class DialogLineBox(urwid.LineBox):
def keypress(self, size, key):