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/message_handlers/rx_handler.py b/contact/message_handlers/rx_handler.py index f9101e7..c979b9a 100644 --- a/contact/message_handlers/rx_handler.py +++ b/contact/message_handlers/rx_handler.py @@ -24,7 +24,7 @@ from contact.utilities.db_handler import ( ) import contact.ui.default_config as config -from contact.utilities.singleton import ui_state, interface_state, app_state +from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state def play_sound(): @@ -84,6 +84,9 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None: if ui_state.display_log: draw_packetlog_win() + + if ui_state.current_window == 4: + menu_state.need_redraw = True try: if "decoded" not in packet: return diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index 9bba87b..995ac86 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -15,6 +15,49 @@ 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_win = None # set in main_ui + + +# Draw arrows for a specific window id (0=channel,1=messages,2=nodes). +def draw_window_arrows(window_id: int) -> None: + + if window_id == 0: + draw_main_arrows(channel_win, len(ui_state.channel_list), window=0) + channel_win.refresh() + elif window_id == 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 window_id == 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 + + +def paint_frame(win, selected: bool) -> None: + win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame")) + win.box() + win.attrset(get_color("window_frame")) + win.refresh() + def handle_resize(stdscr: curses.window, firstrun: bool) -> None: """Handle terminal resize events and redraw the UI accordingly.""" @@ -23,25 +66,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_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 +127,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 +154,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None: draw_channel_list() draw_messages_window(True) draw_node_list() + draw_window_arrows(ui_state.current_window) except: # Resize events can come faster than we can re-draw, which can cause a curses error. @@ -103,6 +165,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() @@ -209,6 +274,8 @@ def handle_home() -> None: elif ui_state.current_window == 2: select_node(0) + draw_window_arrows(ui_state.current_window) + def handle_end() -> None: """Handle end key events to select the last item in the current window.""" @@ -220,29 +287,27 @@ def handle_end() -> None: refresh_pad(1) elif ui_state.current_window == 2: select_node(len(ui_state.node_list) - 1) + draw_window_arrows(ui_state.current_window) def handle_pageup() -> None: """Handle page up key events to scroll the current window by a page.""" if ui_state.current_window == 0: - select_channel( - ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2) - ) # select_channel will bounds check for us + select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: ui_state.selected_message = max( ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0 ) refresh_pad(1) elif ui_state.current_window == 2: - select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us + select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) + draw_window_arrows(ui_state.current_window) def handle_pagedown() -> None: """Handle page down key events to scroll the current window down.""" if ui_state.current_window == 0: - select_channel( - ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2) - ) # select_channel will bounds check for us + select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)) elif ui_state.current_window == 1: msg_line_count = messages_pad.getmaxyx()[0] ui_state.selected_message = min( @@ -251,7 +316,8 @@ def handle_pagedown() -> None: ) refresh_pad(1) elif ui_state.current_window == 2: - select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us + select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) + draw_window_arrows(ui_state.current_window) def handle_leftright(char: int) -> None: @@ -259,44 +325,36 @@ 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")) - channel_win.box() - channel_win.refresh() + paint_frame(channel_win, selected=False) refresh_pad(0) if old_window == 1: - messages_win.attrset(get_color("window_frame")) - messages_win.box() - messages_win.refresh() + paint_frame(messages_win, selected=False) refresh_pad(1) elif old_window == 2: draw_function_win() - nodes_win.attrset(get_color("window_frame")) - nodes_win.box() - nodes_win.refresh() + paint_frame(nodes_win, selected=False) refresh_pad(2) + if not ui_state.single_pane_mode: + draw_window_arrows(old_window) + if ui_state.current_window == 0: - channel_win.attrset(get_color("window_frame_selected")) - channel_win.box() - channel_win.attrset(get_color("window_frame")) - channel_win.refresh() + paint_frame(channel_win, selected=True) refresh_pad(0) elif ui_state.current_window == 1: - messages_win.attrset(get_color("window_frame_selected")) - messages_win.box() - messages_win.attrset(get_color("window_frame")) - messages_win.refresh() + paint_frame(messages_win, selected=True) refresh_pad(1) elif ui_state.current_window == 2: draw_function_win() - nodes_win.attrset(get_color("window_frame_selected")) - nodes_win.box() - nodes_win.attrset(get_color("window_frame")) - nodes_win.refresh() + paint_frame(nodes_win, selected=True) refresh_pad(2) + # Draw arrows last; force even in multi-pane to avoid flicker + draw_window_arrows(ui_state.current_window) + def handle_enter(input_text: str) -> str: """Handle Enter key events to send messages or select channels.""" @@ -315,9 +373,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_window_arrows(ui_state.current_window) return input_text elif len(input_text) > 0: @@ -330,8 +390,12 @@ 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() + + if ui_state.current_window == 0: + ui_state.current_window = 1 + handle_resize(root_win, False) + return "" return input_text @@ -499,6 +563,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] @@ -533,20 +601,18 @@ def draw_channel_list() -> None: channel_pad.addstr(idx, 1, truncated_channel, color) idx += 1 - channel_win.attrset( - get_color("window_frame_selected") if ui_state.current_window == 0 else get_color("window_frame") - ) - channel_win.box() - channel_win.attrset((get_color("window_frame"))) - - draw_main_arrows(channel_win, len(ui_state.channel_list), window=0) - channel_win.refresh() - + paint_frame(channel_win, selected=(ui_state.current_window == 0)) refresh_pad(0) + draw_window_arrows(0) + channel_win.refresh() 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] @@ -574,33 +640,21 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None: messages_pad.addstr(row, 1, line, color) row += 1 - messages_win.attrset( - get_color("window_frame_selected") if ui_state.current_window == 1 else get_color("window_frame") - ) - messages_win.box() - messages_win.attrset(get_color("window_frame")) - messages_win.refresh() + paint_frame(messages_win, selected=(ui_state.current_window == 1)) visible_lines = get_msg_window_lines(messages_win, packetlog_win) 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) - draw_main_arrows( - messages_win, - msg_line_count, - window=1, - log_height=packetlog_win.getmaxyx()[0], - ) messages_win.refresh() - refresh_pad(1) - draw_packetlog_win() + draw_window_arrows(1) + messages_win.refresh() if ui_state.current_window == 4: menu_state.need_redraw = True @@ -609,6 +663,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) @@ -637,16 +694,11 @@ def draw_node_list() -> None: i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2) ) - nodes_win.attrset( - get_color("window_frame_selected") if ui_state.current_window == 2 else get_color("window_frame") - ) - nodes_win.box() - nodes_win.attrset(get_color("window_frame")) - - draw_main_arrows(nodes_win, len(ui_state.node_list), window=2) + paint_frame(nodes_win, selected=(ui_state.current_window == 2)) nodes_win.refresh() - refresh_pad(2) + draw_window_arrows(2) + nodes_win.refresh() # Restore cursor to input field entry_win.keypad(True) @@ -713,15 +765,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_window_arrows(ui_state.current_window) def select_node(idx: int) -> None: @@ -758,6 +804,9 @@ def draw_packetlog_win() -> None: columns = [10, 10, 15, 30] span = 0 + if ui_state.current_window != 1 and ui_state.single_pane_mode: + return + if ui_state.display_log: packetlog_win.erase() height, width = packetlog_win.getmaxyx() @@ -796,9 +845,7 @@ def draw_packetlog_win() -> None: # Add to the window packetlog_win.addstr(i + 2, 1, logString, get_color("log")) - packetlog_win.attrset(get_color("window_frame")) - packetlog_win.box() - packetlog_win.refresh() + paint_frame(packetlog_win, selected=False) # Restore cursor to input field entry_win.keypad(True) @@ -949,7 +996,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 +1024,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/control_ui.py b/contact/ui/control_ui.py index 6ec28c2..ea35f94 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -23,13 +23,22 @@ from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window from contact.ui.user_config import json_editor from contact.utilities.singleton import menu_state -# Constants -width = 80 +# Setup Variables +MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals save_option = "Save Changes" max_help_lines = 0 help_win = None sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] + +# Compute the effective menu width for the current terminal +def get_menu_width() -> int: + # Leave at least 2 columns for borders; clamp to >= 20 for usability + return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2)) + + +sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] + # Get the parent directory of the script script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) @@ -45,35 +54,39 @@ config_folder = os.path.abspath(config.node_configs_file_path) field_mapping, help_text = parse_ini_file(translation_file) -def display_menu() -> tuple[object, object]: # curses.window or pad types +def display_menu() -> tuple[object, object]: + if help_win: + min_help_window_height = 6 + else: + min_help_window_height = 0 - min_help_window_height = 6 num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) # Determine the available height for the menu max_menu_height = curses.LINES menu_height = min(max_menu_height - min_help_window_height, num_items + 5) + w = get_menu_width() start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) - start_x = (curses.COLS - width) // 2 + start_x = (curses.COLS - w) // 2 # Calculate remaining space for help window global max_help_lines remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help - menu_win = curses.newwin(menu_height, width, start_y, start_x) + menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win.erase() menu_win.bkgd(get_color("background")) menu_win.attrset(get_color("window_frame")) menu_win.border() menu_win.keypad(True) - menu_pad = curses.newpad(len(menu_state.current_menu) + 1, width - 8) + menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8) menu_pad.bkgd(get_color("background")) header = " > ".join(word.title() for word in menu_state.menu_path) - if len(header) > width - 4: - header = header[: width - 7] + "..." + if len(header) > w - 4: + header = header[: w - 7] + "..." menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) transformed_path = transform_menu_path(menu_state.menu_path) @@ -84,15 +97,15 @@ def display_menu() -> tuple[object, object]: # curses.window or pad types full_key = ".".join(transformed_path + [option]) display_name = field_mapping.get(full_key, option) - display_option = f"{display_name}"[: width // 2 - 2] - display_value = f"{current_value}"[: width // 2 - 4] + display_option = f"{display_name}"[: w // 2 - 2] + display_value = f"{current_value}"[: w // 2 - 4] try: color = get_color( "settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == menu_state.selected_index), ) - menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) + menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color) except curses.error: pass @@ -100,7 +113,7 @@ def display_menu() -> tuple[object, object]: # curses.window or pad types save_position = menu_height - 2 menu_win.addstr( save_position, - (width - len(save_option)) // 2, + (w - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), ) @@ -134,7 +147,6 @@ def draw_help_window( max_help_lines: int, transformed_path: List[str], ) -> None: - global help_win if "help_win" not in globals(): @@ -145,8 +157,9 @@ def draw_help_window( ) help_y = menu_start_y + menu_height + # Use current terminal width for the help window width calculation help_win = update_help_window( - help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x + help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x ) @@ -231,10 +244,12 @@ def settings_menu(stdscr: object, interface: object) -> None: curses.update_lines_cols() menu_win.erase() - help_win.erase() + if help_win: + help_win.erase() menu_win.refresh() - help_win.refresh() + if help_win: + help_win.refresh() elif key == ord("\t") and menu_state.show_save_option: old_selected_index = menu_state.selected_index @@ -254,12 +269,14 @@ def settings_menu(stdscr: object, interface: object) -> None: menu_state.need_redraw = True menu_state.start_index.append(0) menu_win.erase() - help_win.erase() + if help_win: + help_win.erase() # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) menu_win.refresh() - help_win.refresh() + if help_win: + help_win.refresh() if menu_state.show_save_option and menu_state.selected_index == len(options): save_changes(interface, modified_settings, menu_state) @@ -538,13 +555,15 @@ def settings_menu(stdscr: object, interface: object) -> None: menu_state.need_redraw = True menu_win.erase() - help_win.erase() + if help_win: + help_win.erase() # max_help_lines = 4 # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) menu_win.refresh() - help_win.refresh() + if help_win: + help_win.refresh() # if len(menu_state.menu_path) < 2: # modified_settings.clear() 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/nav_utils.py b/contact/ui/nav_utils.py index 57f2e6d..384a1ac 100644 --- a/contact/ui/nav_utils.py +++ b/contact/ui/nav_utils.py @@ -22,9 +22,9 @@ def get_node_color(node_index: int, reverse: bool = False): Segment = tuple[str, str, bool, bool] WrappedLine = List[Segment] -width = 80 sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] save_option = "Save Changes" +MIN_HEIGHT_FOR_HELP = 20 def move_highlight( @@ -73,9 +73,8 @@ def move_highlight( # Clear old selection if show_save_option and old_idx == max_index: - menu_win.chgat( - menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save") - ) + win_h, win_w = menu_win.getmaxyx() + menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save")) else: menu_pad.chgat( old_idx, @@ -90,9 +89,10 @@ def move_highlight( # Highlight new selection if show_save_option and new_idx == max_index: + win_h, win_w = menu_win.getmaxyx() menu_win.chgat( - menu_win.getmaxyx()[0] - 2, - (width - len(save_option)) // 2, + win_h - 2, + (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True), ) @@ -124,13 +124,14 @@ def move_highlight( selected_option = options[new_idx] if new_idx < len(options) else None help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] if help_win: + win_h, win_w = menu_win.getmaxyx() help_win = update_help_window( help_win, help_text, transformed_path, selected_option, max_help_lines, - width, + win_w, help_y, menu_win.getbegyx()[1], ) @@ -167,23 +168,46 @@ def update_help_window( help_x: int, ) -> object: # returns a curses window """Handles rendering the help window consistently.""" - wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines) + + if curses.LINES < MIN_HEIGHT_FOR_HELP: + return None + + # Clamp target position and width to the current terminal size + help_x = max(0, help_x) + help_y = max(0, help_y) + # Ensure requested width fits on screen from help_x + max_w_from_x = max(1, curses.COLS - help_x) + safe_width = min(width, max_w_from_x) + # Always leave a minimal border area; enforce a minimum usable width of 3 + safe_width = max(3, safe_width) + + wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines) help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border) - # Ensure help window does not exceed screen size + # Re-clamp Y to keep the window visible if help_y + help_height > curses.LINES: - help_y = curses.LINES - help_height + help_y = max(0, curses.LINES - help_height) + + # If width would overflow the screen, shrink it + if help_x + safe_width > curses.COLS: + safe_width = max(3, curses.COLS - help_x) # Create or update the help window if help_win is None: - help_win = curses.newwin(help_height, width, help_y, help_x) + help_win = curses.newwin(help_height, safe_width, help_y, help_x) else: help_win.erase() help_win.refresh() - help_win.resize(help_height, width) - help_win.mvwin(help_y, help_x) + help_win.resize(help_height, safe_width) + try: + help_win.mvwin(help_y, help_x) + except curses.error: + # If moving fails due to edge conditions, pin to (0,0) as a fallback + help_y = 0 + help_x = 0 + help_win.mvwin(help_y, help_x) help_win.bkgd(get_color("background")) help_win.attrset(get_color("window_frame")) @@ -295,14 +319,16 @@ def get_wrapped_help_text( return wrapped_help + def text_width(text: str) -> int: return sum(2 if east_asian_width(c) in "FW" else 1 for c in text) + def wrap_text(text: str, wrap_width: int) -> List[str]: """Wraps text while preserving spaces and breaking long words.""" - whitespace = '\t\n\x0b\x0c\r ' - whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(' ')) + whitespace = "\t\n\x0b\x0c\r " + whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" ")) text = text.translate(whitespace_trans) words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately 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..30f5875 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -10,11 +10,17 @@ from contact.utilities.input_handlers import get_list_input from contact.utilities.singleton import menu_state -width = 80 +MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals max_help_lines = 6 save_option = "Save Changes" +# Compute an effective width that fits the current terminal +def get_effective_width() -> int: + # Leave space for borders; ensure a sane minimum + return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2)) + + def edit_color_pair(key: str, current_value: List[str]) -> List[str]: """ Allows the user to select a foreground and background color for a key. @@ -28,13 +34,14 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]: def edit_value(key: str, current_value: str) -> str: + w = get_effective_width() height = 10 - input_width = width - 16 # Allow space for "New Value: " + input_width = w - 16 # Allow space for "New Value: " start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + start_x = max(0, (curses.COLS - w) // 2) # Create a centered window - edit_win = curses.newwin(height, width, start_y, start_x) + edit_win = curses.newwin(height, w, start_y, start_x) edit_win.bkgd(get_color("background")) edit_win.attrset(get_color("window_frame")) edit_win.border() @@ -43,7 +50,7 @@ def edit_value(key: str, current_value: str) -> str: edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True)) edit_win.addstr(3, 2, "Current Value:", get_color("settings_default")) - wrap_width = width - 4 # Account for border and padding + wrap_width = w - 4 # Account for border and padding wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)] for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height @@ -67,6 +74,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) @@ -82,7 +93,7 @@ def edit_value(key: str, current_value: str) -> str: menu_state.need_redraw = False # Re-create the window to fully reset state - edit_win = curses.newwin(height, width, start_y, start_x) + edit_win = curses.newwin(height, w, start_y, start_x) edit_win.timeout(200) edit_win.bkgd(get_color("background")) edit_win.attrset(get_color("window_frame")) @@ -150,11 +161,12 @@ def display_menu() -> tuple[Any, Any, List[str]]: max_menu_height = curses.LINES menu_height = min(max_menu_height, num_items + 5) num_items = len(options) + w = get_effective_width() start_y = (curses.LINES - menu_height) // 2 - start_x = (curses.COLS - width) // 2 + start_x = max(0, (curses.COLS - w) // 2) # Create the window - menu_win = curses.newwin(menu_height, width, start_y, start_x) + menu_win = curses.newwin(menu_height, w, start_y, start_x) menu_win.erase() menu_win.bkgd(get_color("background")) menu_win.attrset(get_color("window_frame")) @@ -162,13 +174,13 @@ def display_menu() -> tuple[Any, Any, List[str]]: menu_win.keypad(True) # Create the pad for scrolling - menu_pad = curses.newpad(num_items + 1, width - 8) + menu_pad = curses.newpad(num_items + 1, w - 8) menu_pad.bkgd(get_color("background")) # Display the menu path header = " > ".join(menu_state.menu_path) - if len(header) > width - 4: - header = header[: width - 7] + "..." + if len(header) > w - 4: + header = header[: w - 7] + "..." menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) # Populate the pad with menu options @@ -178,18 +190,18 @@ def display_menu() -> tuple[Any, Any, List[str]]: if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(key.strip("[]"))] ) - display_key = f"{key}"[: width // 2 - 2] - display_value = f"{value}"[: width // 2 - 8] + display_key = f"{key}"[: w // 2 - 2] + display_value = f"{value}"[: w // 2 - 8] color = get_color("settings_default", reverse=(idx == menu_state.selected_index)) - menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) + menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color) # Add Save button to the main window if menu_state.show_save_option: save_position = menu_height - 2 menu_win.addstr( save_position, - (width - len(save_option)) // 2, + (w - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), ) @@ -327,9 +339,10 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: else: # Save button selected save_json(file_path, data) + made_changes = False stdscr.refresh() # config.reload() # This isn't refreshing the file paths as expected - continue + break elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow diff --git a/contact/utilities/input_handlers.py b/contact/utilities/input_handlers.py index 5099a39..8d0b9f4 100644 --- a/contact/utilities/input_handlers.py +++ b/contact/utilities/input_handlers.py @@ -4,6 +4,20 @@ import curses import ipaddress from typing import Any, Optional, List +# Dialogs should be at most 80 cols, but shrink on small terminals +MAX_DIALOG_WIDTH = 80 +MIN_DIALOG_WIDTH = 20 + + +def get_dialog_width() -> int: + # Leave 2 columns for borders and clamp to a sane minimum + try: + return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2)) + except Exception: + # Fallback if curses not ready yet + return MAX_DIALOG_WIDTH + + from contact.ui.colors import get_color from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text from contact.ui.dialog import dialog @@ -45,13 +59,13 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option input_win.refresh() height = 8 - width = 80 + width = get_dialog_width() margin = 2 # Left and right margin input_width = width - (2 * margin) # Space available for text max_input_rows = height - 4 # Space for input - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + start_y = max(0, (curses.LINES - height) // 2) + start_x = max(0, (curses.COLS - width) // 2) input_win = curses.newwin(height, width, start_y, start_x) input_win.timeout(200) @@ -204,9 +218,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option # Clear only the input area (without touching prompt text) for i in range(max_input_rows): if row + 1 + i < height - 1: - input_win.addstr( - row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default") - ) + input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default")) # Redraw the prompt text so it never disappears input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) @@ -244,9 +256,9 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]: cvalue = to_base64(current_value) # Convert current values to Base64 height = 9 - width = 80 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + width = get_dialog_width() + start_y = max(0, (curses.LINES - height) // 2) + start_x = max(0, (curses.COLS - width) // 2) admin_key_win = curses.newwin(height, width, start_y, start_x) admin_key_win.timeout(200) @@ -322,9 +334,9 @@ from contact.utilities.singleton import menu_state # Required if not already im def get_repeated_input(current_value: List[str]) -> Optional[str]: height = 9 - width = 80 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + width = get_dialog_width() + start_y = max(0, (curses.LINES - height) // 2) + start_x = max(0, (curses.COLS - width) // 2) repeated_win = curses.newwin(height, width, start_y, start_x) repeated_win.timeout(200) @@ -344,15 +356,17 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]: repeated_win.border() repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True)) + win_h, win_w = repeated_win.getmaxyx() for i, line in enumerate(user_values): prefix = "→ " if i == cursor_pos else " " repeated_win.addstr( 3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos)) ) - repeated_win.addstr(3 + i, 18, line[: width - 20]) # Prevent overflow + repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow if invalid_input: - repeated_win.addstr(7, 2, invalid_input[: width - 4], get_color("settings_default", bold=True)) + win_h, win_w = repeated_win.getmaxyx() + repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], get_color("settings_default", bold=True)) repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) repeated_win.refresh() @@ -404,9 +418,9 @@ def get_fixed32_input(current_value: int) -> int: original_value = current_value ip_string = str(ipaddress.IPv4Address(current_value)) height = 10 - width = 80 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + width = get_dialog_width() + start_y = max(0, (curses.LINES - height) // 2) + start_x = max(0, (curses.COLS - width) // 2) fixed32_win = curses.newwin(height, width, start_y, start_x) fixed32_win.bkgd(get_color("background")) @@ -483,9 +497,9 @@ def get_list_input( selected_index = list_options.index(current_option) if current_option in list_options else 0 height = min(len(list_options) + 5, curses.LINES) - width = 80 - start_y = (curses.LINES - height) // 2 - start_x = (curses.COLS - width) // 2 + width = get_dialog_width() + start_y = max(0, (curses.LINES - height) // 2) + start_x = max(0, (curses.COLS - width) // 2) list_win = curses.newwin(height, width, start_y, start_x) list_win.timeout(200) @@ -493,7 +507,7 @@ def get_list_input( list_win.attrset(get_color("window_frame")) list_win.keypad(True) - list_pad = curses.newpad(len(list_options) + 1, width - 8) + list_pad = curses.newpad(len(list_options) + 1, max(1, width - 8)) list_pad.bkgd(get_color("background")) max_index = len(list_options) - 1 @@ -504,9 +518,11 @@ def get_list_input( list_win.border() list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True)) + win_h, win_w = list_win.getmaxyx() + pad_w = max(1, win_w - 8) for idx, item in enumerate(list_options): color = get_color("settings_default", reverse=(idx == selected_index)) - list_pad.addstr(idx, 0, item.ljust(width - 8), color) + list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color) list_win.refresh() list_pad.refresh( @@ -517,7 +533,9 @@ def get_list_input( list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2, list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4, ) - draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False) + # Recompute visible height each draw in case of resize + vis_h = list_win.getmaxyx()[0] - 5 + draw_arrows(list_win, vis_h, max_index, [0], show_save_option=False) # Initial draw redraw_list_ui()