From fcb1a42ef2650db1652e03d834322db1fb4d88d7 Mon Sep 17 00:00:00 2001 From: pdxlocations Date: Wed, 20 Aug 2025 23:49:17 -0700 Subject: [PATCH] init --- contact/__main__.py | 1 + contact/ui/contact_ui.py | 190 ++++++++++++++++++++++++++++------- contact/ui/default_config.py | 4 +- contact/ui/ui_state.py | 1 + contact/ui/user_config.py | 4 + 5 files changed, 161 insertions(+), 39 deletions(-) diff --git a/contact/__main__.py b/contact/__main__.py index ffd5d48..689e686 100644 --- a/contact/__main__.py +++ b/contact/__main__.py @@ -72,6 +72,7 @@ def initialize_globals() -> None: interface_state.myNodeNum = get_nodeNum() ui_state.channel_list = get_channels() ui_state.node_list = get_node_list() + ui_state.single_pane_mode = config.single_pane_mode.lower() == "true" pub.subscribe(on_receive, "meshtastic.receive") init_nodedb() diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 9bba87b..659e71c 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -15,6 +15,57 @@ import contact.ui.dialog from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text from contact.utilities.singleton import ui_state, interface_state, menu_state +MIN_COL = 1 # "effectively zero" without breaking curses + +# Root screen window, set in main_ui +root_win = None # set in main_ui + + +# Draw arrows for the focused pane. If always=True, draw regardless of single-pane mode. +def draw_focus_arrows(always: bool = False) -> None: + if not always and not ui_state.single_pane_mode: + return + if ui_state.current_window == 0: + draw_main_arrows(channel_win, len(ui_state.channel_list), window=0) + channel_win.refresh() + elif ui_state.current_window == 1: + msg_line_count = messages_pad.getmaxyx()[0] + draw_main_arrows( + messages_win, + msg_line_count, + window=1, + log_height=packetlog_win.getmaxyx()[0], + ) + messages_win.refresh() + elif ui_state.current_window == 2: + draw_main_arrows(nodes_win, len(ui_state.node_list), window=2) + nodes_win.refresh() + + +def compute_widths(total_w: int, focus: int): + # focus: 0=channel, 1=messages, 2=nodes + if total_w < 3 * MIN_COL: + # tiny terminals: allocate something, anything + return max(1, total_w), 0, 0 + + if focus == 0: + return total_w - 2 * MIN_COL, MIN_COL, MIN_COL + if focus == 1: + return MIN_COL, total_w - 2 * MIN_COL, MIN_COL + return MIN_COL, MIN_COL, total_w - 2 * MIN_COL + + +# Single-pane mode: Focused pane gets all width, others are zero (unless terminal is tiny) +def compute_single_pane_widths(total_w: int, focus: int): + # focus: 0=channel, 1=messages, 2=nodes + if total_w < 3 * MIN_COL: + return max(1, total_w), 0, 0 + if focus == 0: + return total_w - 2 * MIN_COL, MIN_COL, MIN_COL + if focus == 1: + return MIN_COL, total_w - 2 * MIN_COL, MIN_COL + return MIN_COL, MIN_COL, total_w - 2 * MIN_COL + def handle_resize(stdscr: curses.window, firstrun: bool) -> None: """Handle terminal resize events and redraw the UI accordingly.""" @@ -23,25 +74,43 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: # Calculate window max dimensions height, width = stdscr.getmaxyx() - # Define window dimensions and positions - 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 + if ui_state.single_pane_mode: + channel_width, messages_width, nodes_width = compute_single_pane_widths(width, ui_state.current_window) + 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_width = max(MIN_COL, channel_width) + messages_width = max(MIN_COL, messages_width) + nodes_width = max(MIN_COL, nodes_width) + + # 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: + delta = total - width + if ui_state.current_window == 0: + channel_width = max(MIN_COL, channel_width - delta) + elif ui_state.current_window == 1: + messages_width = max(MIN_COL, messages_width - delta) + else: + nodes_width = max(MIN_COL, nodes_width - delta) entry_height = 3 function_height = 3 y_pad = entry_height + function_height - packet_log_height = int(height / 3) + content_h = max(1, height - y_pad) + pkt_h = max(1, int(height / 3)) if firstrun: entry_win = curses.newwin(entry_height, width, 0, 0) - channel_win = curses.newwin(height - y_pad, channel_width, entry_height, 0) - messages_win = curses.newwin(height - y_pad, messages_width, entry_height, channel_width) - nodes_win = curses.newwin(height - y_pad, nodes_width, entry_height, channel_width + messages_width) + + channel_win = curses.newwin(content_h, channel_width, entry_height, 0) + messages_win = curses.newwin(content_h, messages_width, entry_height, channel_width) + nodes_win = curses.newwin(content_h, nodes_width, entry_height, channel_width + messages_width) + function_win = curses.newwin(function_height, width, height - function_height, 0) - packetlog_win = curses.newwin( - packet_log_height, messages_width, height - packet_log_height - function_height, channel_width - ) + packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - function_height, channel_width) # Will be resized to what we need when drawn messages_pad = curses.newpad(1, 1) @@ -66,19 +135,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: entry_win.resize(3, width) - channel_win.resize(height - y_pad, channel_width) + channel_win.resize(content_h, channel_width) - messages_win.resize(height - y_pad, messages_width) + messages_win.resize(content_h, messages_width) messages_win.mvwin(3, channel_width) - nodes_win.resize(height - y_pad, nodes_width) + nodes_win.resize(content_h, nodes_width) nodes_win.mvwin(entry_height, channel_width + messages_width) function_win.resize(3, width) function_win.mvwin(height - function_height, 0) - packetlog_win.resize(packet_log_height, messages_width) - packetlog_win.mvwin(height - packet_log_height - function_height, channel_width) + packetlog_win.resize(pkt_h, messages_width) + packetlog_win.mvwin(height - pkt_h - function_height, channel_width) # Draw window borders for win in [channel_win, entry_win, nodes_win, messages_win, function_win]: @@ -93,6 +162,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: draw_channel_list() draw_messages_window(True) draw_node_list() + draw_focus_arrows(always=True) except: # Resize events can come faster than we can re-draw, which can cause a curses error. @@ -103,6 +173,9 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: def main_ui(stdscr: curses.window) -> None: """Main UI loop for the curses interface.""" global input_text + global root_win + + root_win = stdscr input_text = "" stdscr.keypad(True) get_channels() @@ -206,6 +279,7 @@ def handle_home() -> None: elif ui_state.current_window == 1: ui_state.selected_message = 0 refresh_pad(1) + draw_focus_arrows() elif ui_state.current_window == 2: select_node(0) @@ -218,6 +292,7 @@ def handle_end() -> None: msg_line_count = messages_pad.getmaxyx()[0] ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0) refresh_pad(1) + draw_focus_arrows() elif ui_state.current_window == 2: select_node(len(ui_state.node_list) - 1) @@ -233,6 +308,7 @@ def handle_pageup() -> None: ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0 ) refresh_pad(1) + draw_focus_arrows() elif ui_state.current_window == 2: select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us @@ -250,6 +326,7 @@ def handle_pagedown() -> None: msg_line_count - get_msg_window_lines(messages_win, packetlog_win), ) refresh_pad(1) + draw_focus_arrows() elif ui_state.current_window == 2: select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us @@ -259,6 +336,7 @@ def handle_leftright(char: int) -> None: delta = -1 if char == curses.KEY_LEFT else 1 old_window = ui_state.current_window ui_state.current_window = (ui_state.current_window + delta) % 3 + handle_resize(root_win, False) if old_window == 0: channel_win.attrset(get_color("window_frame")) @@ -297,6 +375,8 @@ def handle_leftright(char: int) -> None: nodes_win.refresh() refresh_pad(2) + draw_focus_arrows() + def handle_enter(input_text: str) -> str: """Handle Enter key events to send messages or select channels.""" @@ -315,9 +395,11 @@ def handle_enter(input_text: str) -> str: ui_state.selected_node = 0 ui_state.current_window = 0 + handle_resize(root_win, False) draw_node_list() draw_channel_list() draw_messages_window(True) + draw_focus_arrows() return input_text elif len(input_text) > 0: @@ -330,7 +412,6 @@ def handle_enter(input_text: str) -> str: send_message(input_text, channel=ui_state.selected_channel) draw_messages_window(True) ui_state.last_sent_time = now - # Clear entry window and reset input text entry_win.erase() return "" return input_text @@ -499,6 +580,10 @@ def handle_ctlr_g(stdscr: curses.window) -> None: def draw_channel_list() -> None: """Update the channel list window and pad based on the current state.""" + + if ui_state.current_window != 0 and ui_state.single_pane_mode: + return + channel_pad.erase() win_width = channel_win.getmaxyx()[1] @@ -539,14 +624,18 @@ def draw_channel_list() -> None: channel_win.box() channel_win.attrset((get_color("window_frame"))) + channel_win.refresh() + refresh_pad(0) draw_main_arrows(channel_win, len(ui_state.channel_list), window=0) channel_win.refresh() - refresh_pad(0) - def draw_messages_window(scroll_to_bottom: bool = False) -> None: """Update the messages window based on the selected channel and scroll position.""" + + if ui_state.current_window != 1 and ui_state.single_pane_mode: + return + messages_pad.erase() channel = ui_state.channel_list[ui_state.selected_channel] @@ -586,10 +675,12 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: if scroll_to_bottom: ui_state.selected_message = max(msg_line_count - visible_lines, 0) ui_state.start_index[1] = max(msg_line_count - visible_lines, 0) - pass else: ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0) + messages_win.refresh() + refresh_pad(1) + draw_packetlog_win() draw_main_arrows( messages_win, msg_line_count, @@ -597,10 +688,6 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: log_height=packetlog_win.getmaxyx()[0], ) messages_win.refresh() - - refresh_pad(1) - - draw_packetlog_win() if ui_state.current_window == 4: menu_state.need_redraw = True @@ -609,6 +696,9 @@ def draw_node_list() -> None: """Update the nodes list window and pad based on the current state.""" global nodes_pad + if ui_state.current_window != 2 and ui_state.single_pane_mode: + return + # This didn't work, for some reason an error is thown on startup, so we just create the pad every time # if nodes_pad is None: # nodes_pad = curses.newpad(1, 1) @@ -643,11 +733,11 @@ def draw_node_list() -> None: nodes_win.box() nodes_win.attrset(get_color("window_frame")) + nodes_win.refresh() + refresh_pad(2) draw_main_arrows(nodes_win, len(ui_state.node_list), window=2) nodes_win.refresh() - refresh_pad(2) - # Restore cursor to input field entry_win.keypad(True) curses.curs_set(1) @@ -713,15 +803,9 @@ def scroll_messages(direction: int) -> None: 0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1) ) - draw_main_arrows( - messages_win, - msg_line_count, - ui_state.current_window, - log_height=packetlog_win.getmaxyx()[0], - ) messages_win.refresh() - refresh_pad(1) + draw_focus_arrows() def select_node(idx: int) -> None: @@ -949,7 +1033,7 @@ def draw_function_win() -> None: def refresh_pad(window: int) -> None: - + # Derive the target box and pad for the requested window win_height = channel_win.getmaxyx()[0] if window == 1: @@ -977,13 +1061,43 @@ def refresh_pad(window: int) -> None: selected_item = ui_state.selected_channel start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders + # If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad + if ui_state.single_pane_mode and window != ui_state.current_window: + return + + # Compute inner drawable area of the box + box_y, box_x = box.getbegyx() + box_h, box_w = box.getmaxyx() + inner_h = max(0, box_h - 2) # minus borders + inner_w = max(0, box_w - 2) + + if inner_h <= 0 or inner_w <= 0: + return + + # Clamp lines to available inner height + lines = max(0, min(lines, inner_h)) + + # Clamp start_index within the pad's height + pad_h, pad_w = pad.getmaxyx() + if pad_h <= 0: + return + start_index = max(0, min(start_index, max(0, pad_h - 1))) + + top = box_y + 1 + left = box_x + 1 + bottom = box_y + min(inner_h, lines) # inclusive + right = box_x + min(inner_w, box_w - 2) + + if bottom < top or right < left: + return + pad.refresh( start_index, 0, - box.getbegyx()[0] + 1, - box.getbegyx()[1] + 1, - box.getbegyx()[0] + lines, - box.getbegyx()[1] + box.getmaxyx()[1] - 3, + top, + left, + bottom, + right, ) diff --git a/contact/ui/default_config.py b/contact/ui/default_config.py index b8d2792..49644d4 100644 --- a/contact/ui/default_config.py +++ b/contact/ui/default_config.py @@ -183,6 +183,7 @@ def initialize_config() -> Dict[str, object]: default_config_variables = { "channel_list_16ths": "3", "node_list_16ths": "5", + "single_pane_mode": "False", "db_file_path": db_file_path, "log_file_path": log_file_path, "node_configs_file_path": node_configs_file_path, @@ -228,12 +229,13 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None: global db_file_path, log_file_path, node_configs_file_path, message_prefix, sent_message_prefix global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str - global node_list_16ths, channel_list_16ths + global node_list_16ths, channel_list_16ths, single_pane_mode global theme, COLOR_CONFIG global node_sort, notification_sound channel_list_16ths = loaded_config["channel_list_16ths"] node_list_16ths = loaded_config["node_list_16ths"] + single_pane_mode = loaded_config["single_pane_mode"] db_file_path = loaded_config["db_file_path"] log_file_path = loaded_config["log_file_path"] node_configs_file_path = loaded_config.get("node_configs_file_path") diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index b888040..34f3c4c 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -31,6 +31,7 @@ class ChatUIState: start_index: List[int] = field(default_factory=lambda: [0, 0, 0]) show_save_option: bool = False menu_path: List[str] = field(default_factory=list) + single_pane_mode: bool = False @dataclass diff --git a/contact/ui/user_config.py b/contact/ui/user_config.py index 825f109..562ca99 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -67,6 +67,10 @@ def edit_value(key: str, current_value: str) -> str: sound_options = ["True", "False"] return get_list_input("Notification Sound", current_value, sound_options) + elif key == "single_pane_mode": + sound_options = ["True", "False"] + return get_list_input("Single-Pane Mode", current_value, sound_options) + # Standard Input Mode (Scrollable) edit_win.addstr(7, 2, "New Value: ", get_color("settings_default")) curses.curs_set(1)