From 3586a8ce6a3e2482e4598f17e3e3535debc1e8a9 Mon Sep 17 00:00:00 2001 From: Jeremy O'Brien Date: Fri, 22 May 2026 20:17:47 -0400 Subject: [PATCH] Add readline shortcuts globally --- nomadnet/ui/textui/Browser.py | 6 +- nomadnet/ui/textui/Channels.py | 93 ++++++----- nomadnet/ui/textui/Conversations.py | 70 +++++--- nomadnet/ui/textui/Guide.py | 98 +++++++++--- nomadnet/ui/textui/Interfaces.py | 5 +- nomadnet/ui/textui/MicronParser.py | 3 +- nomadnet/ui/textui/Network.py | 9 +- nomadnet/ui/textui/ReadlineEdit.py | 150 ++++++++++++++++++ .../additional_urwid_widgets/FormWidgets.py | 16 +- 9 files changed, 345 insertions(+), 105 deletions(-) create mode 100644 nomadnet/ui/textui/ReadlineEdit.py diff --git a/nomadnet/ui/textui/Browser.py b/nomadnet/ui/textui/Browser.py index 1cf899b..c7630c1 100644 --- a/nomadnet/ui/textui/Browser.py +++ b/nomadnet/ui/textui/Browser.py @@ -15,6 +15,7 @@ from nomadnet.vendor.Scrollable import * from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name from .Helpers import ClickableIcon, osc52_copy +from .ReadlineEdit import ReadlineMixin, ReadlineEdit class BrowserFrame(urwid.Frame): def keypress(self, size, key): @@ -1829,12 +1830,9 @@ class UrlDialogLineBox(urwid.LineBox): else: return super(UrlDialogLineBox, self).keypress(size, key) -class UrlEdit(urwid.Edit): +class UrlEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "enter": self.confirmed(self) - elif key == "ctrl k": - self.set_edit_text("") - self.set_edit_pos(0) else: return super(UrlEdit, self).keypress(size, key) diff --git a/nomadnet/ui/textui/Channels.py b/nomadnet/ui/textui/Channels.py index 3fe6d7b..4389177 100644 --- a/nomadnet/ui/textui/Channels.py +++ b/nomadnet/ui/textui/Channels.py @@ -13,6 +13,7 @@ from nomadnet.ui.textui.MicronParser import LinkableText, LinkSpec from RNS.Utilities.rngit.util import MarkdownToMicron from RNS.Utilities.rngit.highlight import SyntaxHighlighter from .MicronParser import markup_to_attrmaps, default_state, make_style +from .ReadlineEdit import ReadlineMixin, ReadlineEdit 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 @@ -191,13 +192,19 @@ 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 [C-y] Toggle Channels"), "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"), "shortcutbar") class ChannelsRoomShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-w] Leave [C-k] Clear [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] Focus"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-x] Leave [F8] Collapse [Tab] Complete Nick"), "shortcutbar") + + +class ChannelsRoomBodyShortcuts(): + def __init__(self, app): + self.app = app + self.widget = urwid.AttrMap(urwid.Text("[C-x] Leave [C-u] Users [C-y] Channels [F8] Collapse Joins [Tab] ↓ Editor"), "shortcutbar") class ChannelsDialogLineBox(urwid.LineBox): @@ -381,7 +388,7 @@ class HubInfoArea(urwid.LineBox): return super(HubInfoArea, self).keypress(size, key) -class RoomMessageEdit(urwid.Edit): +class RoomMessageEdit(ReadlineMixin, urwid.Edit): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._tab_state = None @@ -394,14 +401,8 @@ class RoomMessageEdit(urwid.Edit): self._tab_state = None if key == "ctrl d": self.delegate.send_message() - elif key == "ctrl k": - self.set_edit_text("") - elif key == "ctrl w": + elif key == "ctrl x": 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": @@ -484,34 +485,37 @@ class RoomMessageEdit(urwid.Edit): class RoomFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + def keypress(self, size, key): - 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": + if key in ("ctrl u", "ctrl x", "ctrl y", "f8", "tab"): + result = super(RoomFrame, self).keypress(size, key) + if result != key: + return result + if key == "ctrl u": + self.delegate.toggle_users() + return None + if key == "ctrl x": + self.delegate.leave_room() + 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 self.focus_position != "footer": 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: @@ -1427,6 +1431,7 @@ class ChannelsDisplay(): self.list_shortcuts = ChannelsListShortcuts(self.app) self.room_shortcuts = ChannelsRoomShortcuts(self.app) + self.room_body_shortcuts = ChannelsRoomBodyShortcuts(self.app) self.shortcuts_display = self.list_shortcuts self.placeholder = urwid.LineBox(urwid.Filler(urwid.Text("\n Select or add a hub to begin", align=urwid.CENTER), "top")) @@ -1539,6 +1544,12 @@ class ChannelsDisplay(): except Exception: focus_path = None if focus_path and focus_path[0] == 1 and self.current_room_widget is not None: + try: + frame = self.current_room_widget.frame + if frame is not None and frame.focus_position == "body": + return self.room_body_shortcuts + except Exception: + pass return self.room_shortcuts return self.list_shortcuts @@ -1876,8 +1887,8 @@ class ChannelsDisplay(): self._show_dialog_overlay(dialog) def new_hub_dialog(self): - e_hash = urwid.Edit(caption="Hub address : ", edit_text="") - e_name = urwid.Edit(caption="Display name: ", edit_text="") + e_hash = ReadlineEdit(caption="Hub address : ", edit_text="") + e_name = ReadlineEdit(caption="Display name: ", edit_text="") error_text = urwid.Text("") def dismiss(sender): @@ -1965,7 +1976,7 @@ class ChannelsDisplay(): return hub = item.hub - e_name = urwid.Edit(caption="Display name : ", edit_text=hub.name or "") + e_name = ReadlineEdit(caption="Display name : ", edit_text=hub.name or "") cb_autorcn = urwid.CheckBox("Auto-reconnect on disconnect", state=hub.auto_reconnect) cb_autolist = urwid.CheckBox("Auto-fetch room list on connect", state=hub.auto_list) cb_autowho = urwid.CheckBox("Auto-fetch members on room join", state=hub.auto_who) @@ -2022,8 +2033,8 @@ class ChannelsDisplay(): else: return - e_room = urwid.Edit(caption="Room : #", edit_text="") - e_key = urwid.Edit(caption="Key : ", edit_text="", mask="*") + e_room = ReadlineEdit(caption="Room : #", edit_text="") + e_key = ReadlineEdit(caption="Key : ", edit_text="", mask="*") error_text = urwid.Text("") key_section_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) diff --git a/nomadnet/ui/textui/Conversations.py b/nomadnet/ui/textui/Conversations.py index 7900e98..e276bb2 100644 --- a/nomadnet/ui/textui/Conversations.py +++ b/nomadnet/ui/textui/Conversations.py @@ -21,6 +21,7 @@ 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 .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 @@ -70,7 +71,13 @@ class ConversationDisplayShortcuts(): def __init__(self, app): self.app = app - self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-a] Attach [C-s] Save [C-k] Clear [C-w] Close [C-u] Purge [C-x] Clear History [C-o] Sort"), "shortcutbar") + self.widget = urwid.AttrMap(urwid.Text("[C-d] Send [C-p] Paper Msg [C-t] Title [C-f] Attach [C-s] Save [Tab] ↑ Messages"), "shortcutbar") + +class ConversationBodyShortcuts(): + def __init__(self, app): + self.app = app + + self.widget = urwid.AttrMap(urwid.Text("[C-s] Save [C-u] Purge [C-o] Sort [C-x] Clear History [C-g] Fullscreen [C-w] Close [Tab] ↓ Editor"), "shortcutbar") class TabButton(urwid.Button): button_left = urwid.Text("[") @@ -223,6 +230,7 @@ class ConversationsDisplay(): self.list_shortcuts = ConversationListDisplayShortcuts(self.app) self.editor_shortcuts = ConversationDisplayShortcuts(self.app) + self.body_shortcuts = ConversationBodyShortcuts(self.app) self.shortcuts_display = self.list_shortcuts self.widget = urwid.WidgetPlaceholder(self.columns_widget) @@ -834,10 +842,10 @@ class ConversationsDisplay(): if display_name is None: display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash_text) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash_text) t_id = urwid.Text("Addr : "+source_hash_text) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) - e_copy = urwid.Edit(caption="Copy : ", edit_text=source_hash_text) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) + e_copy = ReadlineEdit(caption="Copy : ", edit_text=source_hash_text) selected_id_widget = t_id @@ -878,7 +886,7 @@ class ConversationsDisplay(): except Exception as e: pass - e_notes = urwid.Edit(caption="Notes: ", edit_text=notes_initial, multiline=True) + e_notes = ReadlineEdit(caption="Notes: ", edit_text=notes_initial, multiline=True) cb_pin = urwid.CheckBox("Pin to top", state=pinned_initial) trust_button_group = [] @@ -1018,8 +1026,8 @@ class ConversationsDisplay(): source_hash = "" display_name = "" - e_id = urwid.Edit(caption="Addr : ",edit_text=source_hash) - e_name = urwid.Edit(caption="Name : ",edit_text=display_name) + e_id = ReadlineEdit(caption="Addr : ",edit_text=source_hash) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_name) trust_button_group = [] r_untrusted = urwid.RadioButton(trust_button_group, "Untrusted") @@ -1110,7 +1118,7 @@ class ConversationsDisplay(): def ingest_lxm_uri(self): self.dialog_open = True lxm_uri = "" - e_uri = urwid.Edit(caption="URI : ",edit_text=lxm_uri) + e_uri = ReadlineEdit(caption="URI : ",edit_text=lxm_uri) def dismiss_dialog(sender): self.dialog_open = False @@ -1410,7 +1418,7 @@ class ConversationsDisplay(): def show_set_pn_dialog(_sender): current_pn = self.app.get_user_selected_propagation_node() current_str = RNS.hexrep(current_pn, delimit=False) if current_pn is not None else "" - pn_edit = urwid.Edit(caption="Hash : ", edit_text=current_str) + pn_edit = ReadlineEdit(caption="Hash : ", edit_text=current_str) status_text = urwid.Text("", align=urwid.CENTER) def reopen_sync(_b=None): @@ -1759,14 +1767,18 @@ class ConversationsDisplay(): focus_path = self.columns_widget.get_focus_path() except Exception: return self.list_shortcuts - if not focus_path: + if not focus_path or focus_path[0] != 1: return self.list_shortcuts - if focus_path[0] == 0: - return self.list_shortcuts - elif focus_path[0] == 1: + try: + cw = self.columns_widget.contents[1][0] + frame = cw.base_widget.frame + if frame is None: + return self.editor_shortcuts + if frame.focus_position == "footer": + return self.editor_shortcuts + return self.body_shortcuts + except Exception: return self.editor_shortcuts - else: - return self.list_shortcuts class ListEntry(urwid.Text): _selectable = True @@ -1792,18 +1804,16 @@ class ListEntry(urwid.Text): self._emit('click') return True -class MessageEdit(urwid.Edit): +class MessageEdit(ReadlineMixin, urwid.Edit): def keypress(self, size, key): if key == "ctrl d": self.delegate.send_message() elif key == "ctrl p": self.delegate.paper_message() - elif key == "ctrl a": + elif key == "ctrl f": self.delegate.attach_file() elif key == "ctrl s": self.delegate.save_focused_attachments() - elif key == "ctrl k": - self.delegate.clear_editor() elif key == "up": y = self.get_cursor_coords(size)[1] if y == 0: @@ -1820,6 +1830,18 @@ class MessageEdit(urwid.Edit): class ConversationFrame(urwid.Frame): + @property + def focus_position(self): + return urwid.Frame.focus_position.fget(self) + + @focus_position.setter + def focus_position(self, part): + urwid.Frame.focus_position.fset(self, part) + try: + nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.update_active_shortcuts() + except Exception: + pass + def keypress(self, size, key): if self.focus_position == "header": result = super(ConversationFrame, self).keypress(size, key) @@ -1846,8 +1868,6 @@ class ConversationFrame(urwid.Frame): self.focus_position = "footer" else: return super(ConversationFrame, self).keypress(size, key) - elif key == "ctrl k": - self.delegate.clear_editor() else: return super(ConversationFrame, self).keypress(size, key) @@ -2198,7 +2218,11 @@ class ConversationWidget(urwid.WidgetWrap): def keypress(self, size, key): if key == "tab": self.toggle_focus_area() - elif key == "ctrl w": + return None + key = super(ConversationWidget, self).keypress(size, key) + if key is None: + return None + if key == "ctrl w": self.close() elif key == "ctrl u": self.conversation.purge_failed() @@ -2217,7 +2241,7 @@ class ConversationWidget(urwid.WidgetWrap): elif key == "ctrl s": self.save_focused_attachments() else: - return super(ConversationWidget, self).keypress(size, key) + return key def _on_conversation_changed_from_callback(self, conversation): self.delegate._wake(lambda: self.conversation_changed(conversation)) diff --git a/nomadnet/ui/textui/Guide.py b/nomadnet/ui/textui/Guide.py index 6f58f94..1995e0f 100644 --- a/nomadnet/ui/textui/Guide.py +++ b/nomadnet/ui/textui/Guide.py @@ -190,7 +190,7 @@ class TopicList(urwid.WidgetWrap): def keypress(self, size, key): if key == "up" and (self.ilb.first_item_is_selected()): nomadnet.NomadNetworkApp.get_shared_instance().ui.main_display.frame.focus_position = "header" - + return super(TopicList, self).keypress(size, key) class GuideDisplay(): @@ -307,16 +307,68 @@ The different sections of the program has a number of keyboard shortcuts mapped, - Ctrl-E Display and edit selected peer info - Ctrl-X Delete conversation - Ctrl-R Open LXMF syncronisation dialog - ->>>Conversation Display - - Ctrl-D Send message - - Ctrl-K Clear input fields - - Ctrl-T Toggle message title field + - Ctrl-U Ingest LXMF URI + - Ctrl-O Toggle sort mode + - Ctrl-P Display own LXMF address + - Ctrl-G Toggle fullscreen + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-P Compose paper message + - Ctrl-T Toggle message title field + - Ctrl-F Attach file + - Ctrl-S Save focused attachment + - Tab Switch focus to message list + +>>>Message List + - Ctrl-W Close conversation + - Ctrl-U Purge failed messages - Ctrl-O Toggle sort mode - - Ctrl-P Purge failed messages - Ctrl-X Clear conversation history - Ctrl-G Toggle fullscreen conversation - - Ctrl-W Close conversation + - Ctrl-S Save focused attachment + - Tab Switch focus to message editor + +>>`!Channels Window`! +>>>Channel List + - Ctrl-N Add a new hub + - Ctrl-A Add (join) a room + - Ctrl-R Connect to selected hub + - Ctrl-W Disconnect from selected hub + - Ctrl-T Toggle auto-reconnect for selected hub + - Ctrl-E Edit selected hub + - Ctrl-X Remove selected hub + - F8 Toggle join/part collapse + +>>>Message Editor + - Ctrl-D Send message + - Ctrl-X Leave current room + - F8 Toggle join/part collapse + - Tab Complete nickname + +>>>Message List + - Ctrl-X Leave current room + - Ctrl-U Toggle users pane + - Ctrl-Y Toggle channel list + - F8 Toggle join/part collapse + - Tab Switch focus to message editor + +>>`!Input Field Editing`! +All text input fields support readline-style editing shortcuts. When an input +field is focused, these take precedence over any window shortcut mapped to the +same key (so, for example, Ctrl-U edits the line rather than toggling a pane): + - Ctrl-A Move to beginning of line + - Ctrl-E Move to end of line + - Ctrl-U Delete from cursor to beginning of line + - Ctrl-K Delete from cursor to end of line + - Ctrl-W Delete previous word (whitespace-delimited) + - Ctrl-L Delete the entire buffer + - Ctrl-Y Paste (yank) most recently deleted text + - Ctrl-Left Move backward one word + - Ctrl-Right Move forward one word + +Text deleted with Ctrl-U, Ctrl-K, Ctrl-W or Ctrl-L is placed in a shared yank +buffer that Ctrl-Y pastes back, so text can be moved between input fields. >>`!Network Window`! >>>Browser @@ -491,29 +543,29 @@ Links can be inserted into micron documents. See the `*Markup`* section of this TOPIC_INTERFACES = '''>Interfaces -Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. +Reticulum supports using many kinds of devices as networking interfaces, and allows you to mix and match them in any way you choose. The number of distinct network topologies you can create with Reticulum is more or less endless, but common to them all is that you will need to define one or more interfaces for Reticulum to use. The `![ Interfaces ]`! section of NomadNet lets you add, monitor, and update interfaces configured for your Reticulum instance. -If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. +If you are starting NomadNet for the first time you will find that an `!AutoInterface`! has been added by default. This interface will try to use your available network device to communicate with other peers discovered on your local network. -Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. +Interfaces come in many different types and can interact with physical mediums like LoRa radios or standard IP networks. >>Viewing Interfaces -To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. +To view more info about an interface, navigate using the `!Up`! and `!Down`! arrow keys or by clicking with the mouse. Pressing `! < Enter >`! on a selected interface will bring you to a detailed interface view, which will show configuration parameters and realtime charts. From here you can also disable or edit the interface. To change the orientation of the TX/RX charts, press `!< V >`! for a vertical layout, and `!< H >`! for a horizontal view. >>Updating Interfaces -To edit an interface, select the interface and press `!< Ctrl + E >`!. +To edit an interface, select the interface and press `!< Ctrl + E >`!. To remove an interface, select the interface and press `!< Ctrl + X >`!. You can also perform both of these actions from the details view. >>Adding Interfaces -To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. +To add a new interface, press `!< Ctrl + A >`!. From here you can select which type of interface you want to add. Each unique interface type will have different configuration options. `Ffff`! (!) Note:`! After adding or modifying interfaces, you will need to restart NomadNet or your Reticulum instance for changes to take effect.`f`b @@ -548,14 +600,14 @@ Target Port: Port number to connect to Optional Parameters: I2P Tunneled: Enable for connecting through I2P -KISS Framing: Enable for KISS framing for software modems +KISS Framing: Enable for KISS framing for software modems ``` This interface is commonly used to connect to Reticulum gateways or other persistent nodes on the Internet. >>TCPServerInterface -The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. +The TCP Server interface listens for incoming connections, allowing other Reticulum peers to connect to your node using TCPClientInterface. ``` Required Parameters: @@ -603,7 +655,7 @@ The RNode interface allows using LoRa transceivers running RNode firmware as Ret Required Parameters: Port: Serial port or BLE device path Frequency: Operating frequency in MHz -Bandwidth: Channel bandwidth +Bandwidth: Channel bandwidth TX Power: Transmit power in dBm Spreading Factor: LoRa spreading factor (7-12) Coding Rate: LoRa coding rate (4:5-4:8) @@ -614,7 +666,7 @@ ID Interval: Identification interval in seconds Airtime Limits: Control duty cycle ``` -The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. +The interface includes a parameter calculator to estimate link budget, sensitivity, and data rate on the air based on your settings. >>RNodeMultiInterface @@ -648,7 +700,7 @@ The KISS interface supports packet radio modems and TNCs using the KISS protocol Required Parameters: Port: Serial port path -Speed: Baud rate of serial device +Speed: Baud rate of serial device Databits: Number of data bits Parity: Parity setting Stopbits: Number of stop bits @@ -1445,7 +1497,7 @@ If no heading text is defined, the section will appear as a sub-section without Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\. -In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. +In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\`\\` tag anywhere in the markup, which will immediately remove any formatting previously specified. >>Alignment @@ -1646,7 +1698,7 @@ If you want to link to an anchor on another page, you can include it as a reques >>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. +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 @@ -1761,7 +1813,7 @@ Full control: `B444``B333 >>> Checkboxes -In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. +In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options. `Faaa `= @@ -1827,7 +1879,7 @@ This line will >Partials -You can include partials in pages, which will load asynchronously once the page itself has loaded. +You can include partials in pages, which will load asynchronously once the page itself has loaded. `Faaa `= diff --git a/nomadnet/ui/textui/Interfaces.py b/nomadnet/ui/textui/Interfaces.py index b295cdd..fe7d87b 100644 --- a/nomadnet/ui/textui/Interfaces.py +++ b/nomadnet/ui/textui/Interfaces.py @@ -6,6 +6,7 @@ from math import log10, pow from nomadnet.vendor.additional_urwid_widgets.FormWidgets import * from nomadnet.vendor.AsciiChart import AsciiChart +from .ReadlineEdit import ReadlineEdit ### GYLPHS ### INTERFACE_GLYPHS = { @@ -235,8 +236,8 @@ class RNodeCalculator(urwid.WidgetWrap): self.link_budget_widget = urwid.Text("Link Budget: Calculating...") self.sensitivity_widget = urwid.Text("Sensitivity: Calculating...") - self.noise_floor_edit = urwid.Edit("", "0") - self.antenna_gain_edit = urwid.Edit("", "0") + self.noise_floor_edit = ReadlineEdit("", "0") + self.antenna_gain_edit = ReadlineEdit("", "0") layout = urwid.Pile([ urwid.Divider("-"), diff --git a/nomadnet/ui/textui/MicronParser.py b/nomadnet/ui/textui/MicronParser.py index f584be2..83fb6bf 100644 --- a/nomadnet/ui/textui/MicronParser.py +++ b/nomadnet/ui/textui/MicronParser.py @@ -6,6 +6,7 @@ import time import RNS from urwid.util import is_mouse_press from urwid.text_layout import calc_coords +from .ReadlineEdit import ReadlineEdit from RNS.Utilities.rngit.util import MarkdownToMicron DEFAULT_FG_DARK = "ddd" @@ -360,7 +361,7 @@ def parse_line(line, state, url_delegate): fn = o["name"] fs = o["style"] fmask = "*" if o["masked"] else None - f = urwid.Edit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) + f = ReadlineEdit(caption="", edit_text=fd, align=state["align"], multiline=True, mask=fmask) f.field_name = fn fa = urwid.AttrMap(f, fs) widgets.append((fw, fa)) diff --git a/nomadnet/ui/textui/Network.py b/nomadnet/ui/textui/Network.py index 1e06007..0332139 100644 --- a/nomadnet/ui/textui/Network.py +++ b/nomadnet/ui/textui/Network.py @@ -10,6 +10,7 @@ from nomadnet.util import strip_modifiers from nomadnet.util import sanitize_name from .Browser import Browser +from .ReadlineEdit import ReadlineEdit class NetworkDisplayShortcuts(): def __init__(self, app): @@ -415,7 +416,7 @@ class AnnounceStream(urwid.WidgetWrap): ('weight', 3, self.tab_pn), ], dividechars=1) - self.search_edit = urwid.Edit(caption="Search: ") + self.search_edit = ReadlineEdit(caption="Search: ") urwid.connect_signal(self.search_edit, 'change', self.on_search_change) self.display_toggle = TabButton("Show: Name", on_press=self.toggle_display_mode) @@ -674,8 +675,8 @@ class KnownNodeInfo(urwid.WidgetWrap): r_unknown = urwid.RadioButton(trust_button_group, "Unknown", state=unknown_selected) r_trusted = urwid.RadioButton(trust_button_group, "Trusted", state=trusted_selected) - e_name = urwid.Edit(caption="Name : ",edit_text=display_str) - e_sort = urwid.Edit(caption="Sort Rank : ",edit_text=sort_str) + e_name = ReadlineEdit(caption="Name : ",edit_text=display_str) + e_sort = ReadlineEdit(caption="Sort Rank : ",edit_text=sort_str) node_ident = RNS.Identity.recall(source_hash) op_hash = None @@ -1269,7 +1270,7 @@ class LocalPeer(urwid.WidgetWrap): t_id = urwid.Text("LXMF Addr : "+RNS.prettyhexrep(self.app.lxmf_destination.hash)) i_id = urwid.Text("Identity : "+RNS.prettyhexrep(self.app.identity.hash)) - e_name = urwid.Edit(caption="Name : ", edit_text=display_name) + e_name = ReadlineEdit(caption="Name : ", edit_text=display_name) def save_query(sender): def dismiss_dialog(sender): diff --git a/nomadnet/ui/textui/ReadlineEdit.py b/nomadnet/ui/textui/ReadlineEdit.py new file mode 100644 index 0000000..85b8dd9 --- /dev/null +++ b/nomadnet/ui/textui/ReadlineEdit.py @@ -0,0 +1,150 @@ +import urwid + + +_KILL_KEYS = frozenset(("ctrl u", "ctrl k", "ctrl w", "ctrl l")) + + +class _KillRing: + """Module-global kill buffer shared across all ReadlineMixin widgets. + + Mirrors GNU readline: consecutive kills accumulate into the same entry + (forward kills append, backward kills prepend); any non-kill keypress + breaks the chain so the next kill replaces the buffer. + """ + text = "" + last_was_kill = False + + @classmethod + def reset_chain(cls): + cls.last_was_kill = False + + @classmethod + def kill(cls, killed, direction): + if not killed: + return + if cls.last_was_kill: + cls.text = cls.text + killed if direction == "forward" else killed + cls.text + else: + cls.text = killed + cls.last_was_kill = True + + +class ReadlineMixin: + """Mixin adding readline-style editing keys to an urwid.Edit-derived widget. + + Bindings (GNU readline defaults, plus ctrl-l as kill-whole-buffer): + ctrl-a beginning-of-line + ctrl-e end-of-line + ctrl-u unix-line-discard (kill from cursor to beginning of line) + ctrl-k kill-line (kill from cursor to end of line) + ctrl-w unix-word-rubout (kill previous whitespace-delimited word) + ctrl-l kill-whole-buffer (kill the entire edit buffer) + ctrl-y yank (insert most-recently-killed text) + ctrl-left backward-word (alphanumeric boundary) + ctrl-right forward-word (alphanumeric boundary) + + "Line" is the current logical line within the edit buffer, delimited by + newlines -- so on multiline Edits these act on the line under the cursor, + not the entire buffer. + + The kill buffer is shared across all widgets using this mixin, so text + killed in one Edit can be yanked into another. + """ + + def keypress(self, size, key): + if key == "ctrl a": self._rl_beg_of_line() + elif key == "ctrl e": self._rl_end_of_line() + elif key == "ctrl u": self._rl_kill_to_beg() + elif key == "ctrl k": self._rl_kill_to_end() + elif key == "ctrl w": self._rl_kill_word_back() + elif key == "ctrl l": self._rl_kill_whole_buffer() + elif key == "ctrl y": self._rl_yank() + elif key == "ctrl left": self._rl_backward_word() + elif key == "ctrl right": self._rl_forward_word() + else: + result = super().keypress(size, key) + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return result + if key not in _KILL_KEYS: + _KillRing.reset_chain() + return None + + def _rl_line_bounds(self): + text, pos = self.edit_text, self.edit_pos + bol = text.rfind("\n", 0, pos) + bol = 0 if bol == -1 else bol + 1 + eol = text.find("\n", pos) + eol = len(text) if eol == -1 else eol + return bol, eol + + def _rl_delete(self, start, end, kill_direction=None): + if start == end: + return + text = self.edit_text + if kill_direction is not None: + _KillRing.kill(text[start:end], kill_direction) + self.set_edit_text(text[:start] + text[end:]) + self.set_edit_pos(start) + + @staticmethod + def _rl_is_word_char(ch): + return ch.isalnum() or ch == "_" + + def _rl_beg_of_line(self): + bol, _ = self._rl_line_bounds() + self.set_edit_pos(bol) + + def _rl_end_of_line(self): + _, eol = self._rl_line_bounds() + self.set_edit_pos(eol) + + def _rl_kill_to_beg(self): + bol, _ = self._rl_line_bounds() + self._rl_delete(bol, self.edit_pos, kill_direction="backward") + + def _rl_kill_to_end(self): + _, eol = self._rl_line_bounds() + self._rl_delete(self.edit_pos, eol, kill_direction="forward") + + def _rl_kill_word_back(self): + text, pos = self.edit_text, self.edit_pos + p = pos + while p > 0 and text[p - 1].isspace(): + p -= 1 + while p > 0 and not text[p - 1].isspace(): + p -= 1 + self._rl_delete(p, pos, kill_direction="backward") + + def _rl_kill_whole_buffer(self): + self._rl_delete(0, len(self.edit_text), kill_direction="forward") + + def _rl_yank(self): + if not _KillRing.text: + return + pos = self.edit_pos + text = self.edit_text + self.set_edit_text(text[:pos] + _KillRing.text + text[pos:]) + self.set_edit_pos(pos + len(_KillRing.text)) + + def _rl_backward_word(self): + text, pos = self.edit_text, self.edit_pos + while pos > 0 and not self._rl_is_word_char(text[pos - 1]): + pos -= 1 + while pos > 0 and self._rl_is_word_char(text[pos - 1]): + pos -= 1 + self.set_edit_pos(pos) + + def _rl_forward_word(self): + text, pos = self.edit_text, self.edit_pos + n = len(text) + while pos < n and not self._rl_is_word_char(text[pos]): + pos += 1 + while pos < n and self._rl_is_word_char(text[pos]): + pos += 1 + self.set_edit_pos(pos) + + +class ReadlineEdit(ReadlineMixin, urwid.Edit): + """Drop-in urwid.Edit replacement with readline-style editing keys.""" + pass diff --git a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py index 55f9a03..38a72df 100644 --- a/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py +++ b/nomadnet/vendor/additional_urwid_widgets/FormWidgets.py @@ -1,5 +1,7 @@ import urwid +from nomadnet.ui.textui.ReadlineEdit import ReadlineMixin, ReadlineEdit + class DialogLineBox(urwid.LineBox): def __init__(self, body, parent=None, title="?"): super().__init__(body, title=title) @@ -12,7 +14,7 @@ class DialogLineBox(urwid.LineBox): return None return super().keypress(size, key) -class Placeholder(urwid.Edit): +class Placeholder(ReadlineMixin, urwid.Edit): def __init__(self, caption="", edit_text="", placeholder="", **kwargs): super().__init__(caption, edit_text, **kwargs) self.placeholder = placeholder @@ -240,7 +242,7 @@ class FormMultiList(urwid.Pile, FormField): FormField.__init__(self, config_key, transform) def create_entry_row(self): - edit = urwid.Edit("", "") + edit = ReadlineEdit("", "") entry_row = urwid.Columns([ ('weight', 1, edit), (3, urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))), @@ -318,7 +320,7 @@ class FormMultiTable(urwid.Pile, FormField): if values is None: values = {} - name_edit = urwid.Edit("", name) + name_edit = ReadlineEdit("", name) columns = [('weight', 3, name_edit)] @@ -330,9 +332,9 @@ class FormMultiTable(urwid.Pile, FormField): widget = urwid.CheckBox("", state=bool(field_value)) elif field_config.get("type") == "dropdown": # TODO: dropdown in MultiTable - widget = urwid.Edit("", str(field_value)) + widget = ReadlineEdit("", str(field_value)) else: - widget = urwid.Edit("", str(field_value)) + widget = ReadlineEdit("", str(field_value)) field_widgets[field_key] = widget columns.append(('weight', 2, widget)) @@ -451,8 +453,8 @@ class FormKeyValuePairs(urwid.Pile, FormField): FormField.__init__(self, config_key, transform) def create_entry_row(self, key="", value=""): - key_edit = urwid.Edit("", key) - value_edit = urwid.Edit("", value) + key_edit = ReadlineEdit("", key) + value_edit = ReadlineEdit("", value) remove_button = urwid.Button("×", on_press=lambda button: self.remove_entry(button, entry_row))