From 36408a1f3740f6ca7804ad89c79b5b53b1dd4609 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Fri, 4 Sep 2026 23:30:08 -0700 Subject: [PATCH] Edit message text with arrows --- contact/ui/contact_ui.py | 111 ++++++++++++++++++++++++++++++------ contact/ui/ui_state.py | 2 + tests/test_message_input.py | 95 ++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 tests/test_message_input.py diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 54b742d..c74c81e 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -35,6 +35,8 @@ from contact.ui.nav_utils import ( wrap_text, truncate_with_ellipsis, pad_to_width, + text_width, + slice_to_width, ) from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state @@ -43,6 +45,8 @@ MIN_COL = 1 # "effectively zero" without breaking curses RESIZE_DEBOUNCE_MS = 250 root_win = None nodes_pad = None +entry_win = None +input_text = "" def refresh_log_viewer() -> None: @@ -243,6 +247,8 @@ def compute_widths(total_w: int, focus: int): def paint_frame(win, selected: bool) -> None: + if win is not entry_win and ui_state.input_focused: + selected = False win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame")) win.box() win.attrset(get_color("window_frame")) @@ -491,6 +497,8 @@ def main_ui(stdscr: curses.window) -> None: root_win = stdscr input_text = "" + ui_state.input_cursor = 0 + ui_state.input_focused = False queued_char = None stdscr.keypad(True) get_channels() @@ -555,8 +563,7 @@ def main_ui(stdscr: curses.window) -> None: with app_state.lock: process_pending_ui_updates(stdscr) - entry_display = f"{ui_state.reply_context}{input_text or ''}" - draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input")) + draw_message_input(input_text) # Get user input from entry window try: @@ -596,6 +603,7 @@ def main_ui(stdscr: curses.window) -> None: elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)): input_text = handle_enter(input_text) + ui_state.input_cursor = min(ui_state.input_cursor, len(input_text)) elif char in (curses.KEY_F4, chr(20)): # Ctrl + t and F4 for Traceroute handle_ctrl_t(stdscr) @@ -603,9 +611,14 @@ def main_ui(stdscr: curses.window) -> None: elif char == curses.KEY_F5: handle_f5_key(stdscr) - elif char in (curses.KEY_BACKSPACE, chr(127)): + elif char in (curses.KEY_BACKSPACE, chr(127), chr(8)): input_text = handle_backspace(entry_win, input_text) + elif char == curses.KEY_DC: + focus_message_input() + position = ui_state.input_cursor + input_text = input_text[:position] + input_text[position + 1:] + elif char in (curses.KEY_F12, "`"): # ` Launch the settings interface handle_backtick(stdscr) @@ -619,7 +632,6 @@ def main_ui(stdscr: curses.window) -> None: entry_win.erase() elif char == curses.KEY_RESIZE: - input_text = "" queued_char = drain_resize_events(entry_win) handle_resize(stdscr, False) continue @@ -645,15 +657,66 @@ def main_ui(stdscr: curses.window) -> None: break else: - # Append typed character to input text - if isinstance(char, str): - input_text += char - else: - input_text += chr(char) + if isinstance(char, str) and char.isprintable(): + input_text = insert_input_text(input_text, char) + + +def focus_message_input() -> None: + if ui_state.input_focused: + return + # Keep the conversation visible in single-pane mode while composing. + handle_function_keys(curses.KEY_F2) + ui_state.input_focused = True + refresh_message_highlight() + refresh_main_window(1, selected=False) + + +def leave_message_input() -> None: + ui_state.input_focused = False + ui_state.current_window = 1 + refresh_message_highlight() + refresh_main_window(1, selected=True) + + +def insert_input_text(draft: str, text: str) -> str: + focus_message_input() + position = min(ui_state.input_cursor, len(draft)) + ui_state.input_cursor = position + len(text) + return draft[:position] + text + draft[position:] + + +def draw_message_input(draft: str) -> None: + """Redraw the whole input row and scroll it to keep the draft cursor visible.""" + width = entry_win.getmaxyx()[1] + prompt = slice_to_width("Message: ", max(0, width - 3)) + available = max(1, width - 2 - text_width(prompt)) + ui_state.input_cursor = max(0, min(ui_state.input_cursor, len(draft))) + content = ui_state.reply_context + draft + cursor = len(ui_state.reply_context) + ui_state.input_cursor + start = cursor + used = 0 + while start > 0 and used + text_width(content[start - 1]) < available: + start -= 1 + used += text_width(content[start]) + visible = slice_to_width(content[start:], available) + draw_text_field(entry_win, prompt + visible, get_color("input")) + paint_frame(entry_win, selected=ui_state.input_focused) + try: + hint = " Ctrl+K Help " + if width >= len(hint) + 4: + entry_win.addstr(0, width - len(hint) - 2, hint, get_color("commands")) + curses.curs_set(1 if ui_state.input_focused else 0) + entry_win.move(1, min(width - 2, 1 + text_width(prompt) + used)) + except curses.error: + pass + entry_win.refresh() def handle_up() -> None: """Handle key up events to scroll the current window.""" + if ui_state.input_focused: + leave_message_input() + return if ui_state.current_window == 0: scroll_channels(-1) elif ui_state.current_window == 1: @@ -664,6 +727,9 @@ def handle_up() -> None: def handle_down() -> None: """Handle key down events to scroll the current window.""" + if ui_state.input_focused: + leave_message_input() + return if ui_state.current_window == 0: scroll_channels(1) elif ui_state.current_window == 1: @@ -674,6 +740,9 @@ def handle_down() -> None: def handle_home() -> None: """Handle home key events to select the first item in the current window.""" + if ui_state.input_focused: + ui_state.input_cursor = 0 + return if ui_state.current_window == 0: select_channel(0) elif ui_state.current_window == 1: @@ -688,6 +757,9 @@ def handle_home() -> None: def handle_end() -> None: """Handle end key events to select the last item in the current window.""" + if ui_state.input_focused: + ui_state.input_cursor = len(input_text) + return if ui_state.current_window == 0: select_channel(len(ui_state.channel_list) - 1) elif ui_state.current_window == 1: @@ -728,6 +800,9 @@ def handle_pagedown() -> None: def handle_leftright(char: int) -> None: """Handle left/right key events to switch between windows.""" delta = -1 if char == curses.KEY_LEFT else 1 + if ui_state.input_focused: + ui_state.input_cursor = max(0, min(len(input_text), ui_state.input_cursor + delta)) + return old_window = ui_state.current_window ui_state.current_window = (ui_state.current_window + delta) % 3 if ui_state.single_pane_mode: @@ -748,6 +823,8 @@ def handle_leftright(char: int) -> None: def handle_function_keys(char: int) -> None: """Switch windows using F1/F2/F3.""" + if ui_state.input_focused: + leave_message_input() if char == curses.KEY_F1: target = 0 elif char == curses.KEY_F2: @@ -1107,14 +1184,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None: def handle_backspace(entry_win: curses.window, input_text: str) -> str: - """Handle backspace key events to remove the last character from input text.""" - if input_text: - input_text = input_text[:-1] - y, x = entry_win.getyx() - entry_win.move(y, x - 1) - entry_win.addch(" ") # - entry_win.move(y, x - 1) - entry_win.refresh() + """Delete before the draft cursor; the next frame redraws the entire line.""" + focus_message_input() + position = ui_state.input_cursor + if position > 0: + input_text = input_text[:position - 1] + input_text[position:] + ui_state.input_cursor -= 1 return input_text @@ -1596,7 +1671,7 @@ def refresh_message_highlight() -> None: previous_range = ui_state.highlighted_message_range width = max(0, messages_win.getmaxyx()[1] - 2) - if not ui_state.channel_list or ui_state.current_window != 1: + if not ui_state.channel_list or ui_state.current_window != 1 or ui_state.input_focused: if previous_range: start, end, color = previous_range for row in range(start, end): diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index 0ca2858..40bdf1c 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -25,6 +25,8 @@ class ChatUIState: selected_message: int = 0 selected_node: int = 0 current_window: int = 0 + input_focused: bool = False + input_cursor: int = 0 last_sent_time: float = 0.0 last_traceroute_time: float = 0.0 selected_index: int = 0 diff --git a/tests/test_message_input.py b/tests/test_message_input.py new file mode 100644 index 0000000..b818f27 --- /dev/null +++ b/tests/test_message_input.py @@ -0,0 +1,95 @@ +import unittest +from contextlib import ExitStack +from unittest import mock + +from contact.ui import contact_ui +from contact.utilities.singleton import ui_state +from tests.test_support import reset_singletons + + +class MessageInputTests(unittest.TestCase): + def setUp(self): + reset_singletons() + ui_state.current_window = 1 + for name in ("refresh_message_highlight", "refresh_main_window", "get_color"): + patcher = mock.patch.object(contact_ui, name, return_value=1) + patcher.start() + self.addCleanup(patcher.stop) + self.addCleanup(reset_singletons) + + def test_keyboard_loop_sends_edited_draft_after_resize(self): + win = mock.Mock() + win.get_wch.side_effect = [ + "a", "b", "c", contact_ui.curses.KEY_LEFT, + contact_ui.curses.KEY_DC, "d", contact_ui.curses.KEY_BACKSPACE, + "e", contact_ui.curses.KEY_RESIZE, "\n", "\x1b", + ] + with ExitStack() as stack: + stack.enter_context(mock.patch.object(contact_ui, "entry_win", win)) + for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"): + stack.enter_context(mock.patch.object(contact_ui, name)) + stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True)) + stack.enter_context(mock.patch.object(contact_ui, "drain_resize_events", return_value=None)) + send = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value="")) + contact_ui.main_ui(mock.Mock()) + send.assert_called_once_with("abe") + self.assertEqual(contact_ui.input_text, "") + self.assertEqual(ui_state.input_cursor, 0) + + def test_typing_focuses_input_and_edits_at_cursor(self): + draft = contact_ui.insert_input_text("", "helo") + self.assertTrue(ui_state.input_focused) + with mock.patch.object(contact_ui, "input_text", draft): + contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT) + draft = contact_ui.insert_input_text(draft, "l") + self.assertEqual(draft, "hello") + self.assertEqual(ui_state.input_cursor, 4) + draft = contact_ui.handle_backspace(mock.Mock(), draft) + self.assertEqual(draft, "helo") + self.assertEqual(ui_state.input_cursor, 3) + + def test_up_and_down_return_to_messages_without_scrolling(self): + for handler in (contact_ui.handle_up, contact_ui.handle_down): + ui_state.input_focused = True + ui_state.input_cursor = 2 + with mock.patch.object(contact_ui, "scroll_messages") as scroll: + handler() + self.assertFalse(ui_state.input_focused) + self.assertEqual(ui_state.current_window, 1) + self.assertEqual(ui_state.input_cursor, 2) + scroll.assert_not_called() + + def test_cursor_stops_at_draft_boundaries(self): + ui_state.input_focused = True + with mock.patch.object(contact_ui, "input_text", "abc"): + contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT) + self.assertEqual(ui_state.input_cursor, 0) + self.assertEqual(contact_ui.handle_backspace(mock.Mock(), "abc"), "abc") + contact_ui.handle_end() + contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT) + self.assertEqual(ui_state.input_cursor, 3) + contact_ui.handle_home() + self.assertEqual(ui_state.input_cursor, 0) + + def test_redraw_clears_line_and_shows_text_after_cursor(self): + win = mock.Mock() + win.getmaxyx.return_value = (3, 30) + ui_state.input_focused = True + ui_state.input_cursor = 2 + with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"): + contact_ui.draw_message_input("hello") + win.addstr.assert_any_call(1, 1, " " * 28, 1) + win.addstr.assert_any_call(1, 1, "Message: hello", 1) + win.move.assert_called_once_with(1, 12) + contact_ui.get_color.assert_any_call("window_frame_selected") + + def test_long_draft_scrolls_to_cursor_with_reply_context(self): + win = mock.Mock() + win.getmaxyx.return_value = (3, 24) + ui_state.reply_context = "Reply to someone: " + ui_state.input_cursor = 20 + with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"): + contact_ui.draw_message_input("x" * 20) + self.assertLess(win.move.call_args.args[1], 23) + rendered = [call.args[2] for call in win.addstr.call_args_list if call.args[:2] == (1, 1)][-1] + self.assertEqual(rendered, "Message: " + "x" * 12)