diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 64d55c5..ca7fc81 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -185,6 +185,43 @@ def get_node_display_name(node_num: int, node: dict) -> str: return user.get("longName") or get_name_from_database(node_num, "long") +def get_selected_channel_title() -> str: + if not ui_state.channel_list: + return "" + + channel = ui_state.channel_list[min(ui_state.selected_channel, len(ui_state.channel_list) - 1)] + if isinstance(channel, int): + return get_name_from_database(channel, "long") or get_name_from_database(channel, "short") or str(channel) + return str(channel) + + +def get_window_title(window: int) -> str: + if window == 2: + return f"Nodes: {len(ui_state.node_list)}" + if ui_state.single_pane_mode and window == 1: + return get_selected_channel_title() + return "" + + +def draw_frame_title(box: curses.window, title: str) -> None: + if not title: + return + + _, box_w = box.getmaxyx() + max_title_width = max(0, box_w - 6) + if max_title_width <= 0: + return + + clipped_title = truncate_with_ellipsis(title, max_title_width).rstrip() + if not clipped_title: + return + + try: + box.addstr(0, 2, f" {clipped_title} ", curses.A_BOLD) + except curses.error: + pass + + def handle_resize(stdscr: curses.window, firstrun: bool) -> None: """Handle terminal resize events and redraw the UI accordingly.""" global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win @@ -193,11 +230,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: height, width = stdscr.getmaxyx() if ui_state.single_pane_mode: - channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window) + channel_width = width + messages_width = width + nodes_width = width + channel_x = 0 + messages_x = 0 + nodes_x = 0 else: channel_width = int(config.channel_list_16ths) * (width // 16) nodes_width = int(config.node_list_16ths) * (width // 16) messages_width = width - channel_width - nodes_width + channel_x = 0 + messages_x = channel_width + nodes_x = channel_width + messages_width channel_width = max(MIN_COL, channel_width) messages_width = max(MIN_COL, messages_width) @@ -205,7 +250,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: # Ensure the three widths sum exactly to the terminal width by adjusting the focused pane total = channel_width + messages_width + nodes_width - if total != width: + if not ui_state.single_pane_mode and total != width: delta = total - width if ui_state.current_window == 0: channel_width = max(MIN_COL, channel_width - delta) @@ -222,11 +267,11 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: if firstrun: entry_win = curses.newwin(entry_height, width, height - entry_height, 0) - channel_win = curses.newwin(content_h, channel_width, 0, 0) - messages_win = curses.newwin(content_h, messages_width, 0, channel_width) - nodes_win = curses.newwin(content_h, nodes_width, 0, channel_width + messages_width) + channel_win = curses.newwin(content_h, channel_width, 0, channel_x) + messages_win = curses.newwin(content_h, messages_width, 0, messages_x) + nodes_win = curses.newwin(content_h, nodes_width, 0, nodes_x) - packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, channel_width) + packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - entry_height, messages_x) # Will be resized to what we need when drawn messages_pad = curses.newpad(1, 1) @@ -253,19 +298,25 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: entry_win.mvwin(height - entry_height, 0) channel_win.resize(content_h, channel_width) - channel_win.mvwin(0, 0) + channel_win.mvwin(0, channel_x) messages_win.resize(content_h, messages_width) - messages_win.mvwin(0, channel_width) + messages_win.mvwin(0, messages_x) nodes_win.resize(content_h, nodes_width) - nodes_win.mvwin(0, channel_width + messages_width) + nodes_win.mvwin(0, nodes_x) packetlog_win.resize(pkt_h, messages_width) - packetlog_win.mvwin(height - pkt_h - entry_height, channel_width) + packetlog_win.mvwin(height - pkt_h - entry_height, messages_x) # Draw window borders - for win in [channel_win, entry_win, nodes_win, messages_win]: + windows_to_draw = [entry_win] + if ui_state.single_pane_mode: + windows_to_draw.append([channel_win, messages_win, nodes_win][ui_state.current_window]) + else: + windows_to_draw.extend([channel_win, nodes_win, messages_win]) + + for win in windows_to_draw: win.box() win.refresh() @@ -1365,7 +1416,6 @@ def refresh_pad(window: int) -> None: pad = nodes_pad box = nodes_win lines = box.getmaxyx()[0] - 2 - box.addstr(0, 2, (f"Nodes: {len(ui_state.node_list)}"), curses.A_BOLD) selected_item = ui_state.selected_node start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders @@ -1402,6 +1452,9 @@ def refresh_pad(window: int) -> None: if bottom < top or right < left: return + draw_frame_title(box, get_window_title(window)) + box.refresh() + pad.refresh( start_index, 0, diff --git a/tests/test_contact_ui.py b/tests/test_contact_ui.py index db99b57..2003cb4 100644 --- a/tests/test_contact_ui.py +++ b/tests/test_contact_ui.py @@ -93,3 +93,68 @@ class ContactUiTests(unittest.TestCase): ) refresh_pad.assert_called_once_with(2) draw_window_arrows.assert_called_once_with(2) + + def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None: + stdscr = mock.Mock() + stdscr.getmaxyx.return_value = (24, 80) + ui_state.single_pane_mode = True + ui_state.current_window = 1 + + contact_ui.entry_win = mock.Mock() + contact_ui.channel_win = mock.Mock() + contact_ui.messages_win = mock.Mock() + contact_ui.nodes_win = mock.Mock() + contact_ui.packetlog_win = mock.Mock() + contact_ui.messages_pad = mock.Mock() + contact_ui.nodes_pad = mock.Mock() + contact_ui.channel_pad = mock.Mock() + + with mock.patch.object(contact_ui.curses, "curs_set"): + with mock.patch.object(contact_ui, "draw_channel_list") as draw_channel_list: + with mock.patch.object(contact_ui, "draw_messages_window") as draw_messages_window: + with mock.patch.object(contact_ui, "draw_node_list") as draw_node_list: + with mock.patch.object(contact_ui, "draw_window_arrows") as draw_window_arrows: + contact_ui.handle_resize(stdscr, False) + + contact_ui.channel_win.resize.assert_called_once_with(21, 80) + contact_ui.messages_win.resize.assert_called_once_with(21, 80) + contact_ui.nodes_win.resize.assert_called_once_with(21, 80) + contact_ui.channel_win.mvwin.assert_called_once_with(0, 0) + contact_ui.messages_win.mvwin.assert_called_once_with(0, 0) + contact_ui.nodes_win.mvwin.assert_called_once_with(0, 0) + contact_ui.channel_win.box.assert_not_called() + contact_ui.nodes_win.box.assert_not_called() + contact_ui.messages_win.box.assert_called_once_with() + draw_channel_list.assert_called_once_with() + draw_messages_window.assert_called_once_with(True) + draw_node_list.assert_called_once_with() + draw_window_arrows.assert_called_once_with(1) + + def test_get_window_title_uses_selected_channel_only_for_messages_in_single_pane_mode(self) -> None: + ui_state.single_pane_mode = True + ui_state.channel_list = ["Primary"] + ui_state.selected_channel = 0 + + self.assertEqual(contact_ui.get_window_title(0), "") + self.assertEqual(contact_ui.get_window_title(1), "Primary") + + def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None: + ui_state.single_pane_mode = True + ui_state.current_window = 1 + ui_state.channel_list = ["Primary"] + ui_state.selected_channel = 0 + ui_state.start_index = [0, 0, 0] + ui_state.display_log = False + + contact_ui.channel_win = mock.Mock() + contact_ui.channel_win.getmaxyx.return_value = (10, 20) + contact_ui.messages_pad = mock.Mock() + contact_ui.messages_pad.getmaxyx.return_value = (5, 20) + contact_ui.messages_win = mock.Mock() + contact_ui.messages_win.getbegyx.return_value = (0, 0) + contact_ui.messages_win.getmaxyx.return_value = (10, 20) + + with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4): + contact_ui.refresh_pad(1) + + contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)