diff --git a/nomadnet/ui/TextUI.py b/nomadnet/ui/TextUI.py index eb4d2ae..15abd7b 100644 --- a/nomadnet/ui/TextUI.py +++ b/nomadnet/ui/TextUI.py @@ -168,6 +168,7 @@ GLYPHS = { ("image", "[I]", "\u25a3", "\uf1c5"), ("audio", "[~]", "\u266b", "\uf1c7"), ("pin", "*", "\u2605", "\uf08d"), + ("copy", "[C]", "\u29c9", "\uf0c5"), } class TextUI: diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index a08d8e6..46e11f2 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -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) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index f5f5b2b..291c7f0 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -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 diff --git a/nomadnet/ui/textui/Helpers.py b/nomadnet/ui/textui/Helpers.py index e69de29..ec0816e 100644 --- a/nomadnet/ui/textui/Helpers.py +++ b/nomadnet/ui/textui/Helpers.py @@ -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 diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index c2e3150..17130e2 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -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):