diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index d2c4d45..c505f15 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -14,6 +14,7 @@ from contact.ui.dialog import dialog from contact.utilities.control_utils import parse_ini_file, transform_menu_path from contact.ui.user_config import json_editor from contact.ui.ui_state import MenuState +from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window menu_state = MenuState() @@ -37,9 +38,6 @@ config_folder = os.path.join(locals_dir, "node-configs") # Load translations field_mapping, help_text = parse_ini_file(translation_file) -# Aliases -Segment = tuple[str, str, bool, bool] -WrappedLine = list[Segment] def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.window or pad types @@ -107,7 +105,7 @@ def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.wind max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) - draw_arrows(menu_win, visible_height, max_index, menu_state) + draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, show_save_option=False) return menu_win, menu_pad @@ -131,231 +129,6 @@ def draw_help_window( help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x) -def update_help_window( - help_win: object, # curses window or None - help_text: dict[str, str], - transformed_path: list[str], - selected_option: str | None, - max_help_lines: int, - width: int, - help_y: int, - 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) - - 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 - if help_y + help_height > curses.LINES: - help_y = curses.LINES - help_height - - # Create or update the help window - if help_win is None: - help_win = curses.newwin(help_height, 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.bkgd(get_color("background")) - help_win.attrset(get_color("window_frame")) - help_win.border() - - for idx, line_segments in enumerate(wrapped_help): - x_pos = 2 # Start after border - for text, color, bold, underline in line_segments: - try: - attr = get_color(color, bold=bold, underline=underline) - help_win.addstr(1 + idx, x_pos, text, attr) - x_pos += len(text) - except curses.error: - pass # Prevent crashes - - help_win.refresh() - return help_win - - -def get_wrapped_help_text( - help_text: dict[str, str], - transformed_path: list[str], - selected_option: str | None, - width: int, - max_lines: int -) -> list[WrappedLine]: - """Fetches and formats help text for display, ensuring it fits within the allowed lines.""" - - full_help_key = '.'.join(transformed_path + [selected_option]) if selected_option else None - help_content = help_text.get(full_help_key, "No help available.") - - wrap_width = max(width - 6, 10) # Ensure a valid wrapping width - - # Color replacements - color_mappings = { - r'\[warning\](.*?)\[/warning\]': ('settings_warning', True, False), # Red for warnings - r'\[note\](.*?)\[/note\]': ('settings_note', True, False), # Green for notes - r'\[underline\](.*?)\[/underline\]': ('settings_default', False, True), # Underline - - r'\\033\[31m(.*?)\\033\[0m': ('settings_warning', True, False), # Red text - r'\\033\[32m(.*?)\\033\[0m': ('settings_note', True, False), # Green text - r'\\033\[4m(.*?)\\033\[0m': ('settings_default', False, True) # Underline - } - - def extract_ansi_segments(text: str) -> list[Segment]: - """Extracts and replaces ANSI color codes, ensuring spaces are preserved.""" - matches = [] - last_pos = 0 - pattern_matches = [] - - # Find all matches and store their positions - for pattern, (color, bold, underline) in color_mappings.items(): - for match in re.finditer(pattern, text): - pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline)) - - # Sort matches by start position to process sequentially - pattern_matches.sort(key=lambda x: x[0]) - - for start, end, content, color, bold, underline in pattern_matches: - # Preserve non-matching text including spaces - if last_pos < start: - segment = text[last_pos:start] - matches.append((segment, "settings_default", False, False)) - - # Append the colored segment - matches.append((content, color, bold, underline)) - last_pos = end - - # Preserve any trailing text - if last_pos < len(text): - matches.append((text[last_pos:], "settings_default", False, False)) - - return matches - - def wrap_ansi_text(segments: list[Segment], wrap_width: int) -> list[WrappedLine]: - """Wraps text while preserving ANSI formatting and spaces.""" - wrapped_lines = [] - line_buffer = [] - line_length = 0 - - for text, color, bold, underline in segments: - words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately - - for word in words: - word_length = len(word) - - if line_length + word_length > wrap_width and word.strip(): - # If the word (ignoring spaces) exceeds width, wrap the line - wrapped_lines.append(line_buffer) - line_buffer = [] - line_length = 0 - - line_buffer.append((word, color, bold, underline)) - line_length += word_length - - if line_buffer: - wrapped_lines.append(line_buffer) - - return wrapped_lines - - raw_lines = help_content.split("\\n") # Preserve new lines - wrapped_help = [] - - for raw_line in raw_lines: - color_segments = extract_ansi_segments(raw_line) - wrapped_segments = wrap_ansi_text(color_segments, wrap_width) - wrapped_help.extend(wrapped_segments) - pass - - # Trim and add ellipsis if needed - if len(wrapped_help) > max_lines: - wrapped_help = wrapped_help[:max_lines] - wrapped_help[-1].append(("...", "settings_default", False, False)) - - return wrapped_help - - -def move_highlight( - old_idx: int, - options: list[str], - menu_win: object, - menu_pad: object, - help_win: object, - help_text: dict[str, str], - max_help_lines: int, - menu_state: MenuState -) -> None: - - if old_idx == menu_state.selected_index: # No-op - return - - max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 - visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) - - # Adjust menu_state.start_index only when moving out of visible range - if menu_state.selected_index == max_index and menu_state.show_save_option: - pass - elif menu_state.selected_index < menu_state.start_index[-1]: # Moving above the visible area - menu_state.start_index[-1] = menu_state.selected_index - elif menu_state.selected_index >= menu_state.start_index[-1] + visible_height: # Moving below the visible area - menu_state.start_index[-1] = menu_state.selected_index - visible_height - pass - - # Ensure menu_state.start_index is within bounds - menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1)) - - # Clear old selection - if menu_state.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")) - else: - menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) - - # Highlight new selection - if menu_state.show_save_option and menu_state.selected_index == max_index: - menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) - else: - menu_pad.chgat(menu_state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[menu_state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True)) - - menu_win.refresh() - - # Refresh pad only if scrolling is needed - menu_pad.refresh(menu_state.start_index[-1], 0, - menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + visible_height, - menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4) - - # Update help window - transformed_path = transform_menu_path(menu_state.menu_path) - selected_option = options[menu_state.selected_index] if menu_state.selected_index < len(options) else None - help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) - - draw_arrows(menu_win, visible_height, max_index, menu_state) - - -def draw_arrows( - win: object, - visible_height: int, - max_index: int, - menu_state: MenuState -) -> None: - - # vh = visible_height + (1 if show_save_option else 0) - mi = max_index - (2 if menu_state.show_save_option else 0) - - if visible_height < mi: - if menu_state.start_index[-1] > 0: - win.addstr(3, 2, "▲", get_color("settings_default")) - else: - win.addstr(3, 2, " ", get_color("settings_default")) - - if mi - menu_state.start_index[-1] >= visible_height + (0 if menu_state.show_save_option else 1) : - win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) - else: - win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) - def settings_menu(stdscr: object, interface: object) -> None: curses.update_lines_cols() @@ -396,12 +169,12 @@ def settings_menu(stdscr: object, interface: object) -> None: if key == curses.KEY_UP: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state) + move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines) elif key == curses.KEY_DOWN: old_selected_index = menu_state.selected_index menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state) + move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines) elif key == curses.KEY_RESIZE: need_redraw = True @@ -416,7 +189,7 @@ def settings_menu(stdscr: object, interface: object) -> None: elif key == ord("\t") and menu_state.show_save_option: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index - move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, menu_state) + move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, help_win=help_win, help_text=help_text, max_help_lines=max_help_lines) elif key == curses.KEY_RIGHT or key == ord('\n'): need_redraw = True diff --git a/contact/ui/nav_utils.py b/contact/ui/nav_utils.py new file mode 100644 index 0000000..ccc1606 --- /dev/null +++ b/contact/ui/nav_utils.py @@ -0,0 +1,294 @@ +import curses +import re +from contact.ui.colors import get_color +from contact.utilities.control_utils import transform_menu_path +from typing import Any + +# Aliases +Segment = tuple[str, str, bool, bool] +WrappedLine = list[Segment] + +width = 80 +sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] +save_option = "Save Changes" + + +def move_highlight( + old_idx: int, + options: list[str], + menu_win: curses.window, + menu_pad: curses.window, + **kwargs: Any +) -> None: + + show_save_option = None + start_index = [0] + help_text = None + max_help_lines = 0 + help_win = None + + if "help_win" in kwargs: + help_win = kwargs["help_win"] + + if "menu_state" in kwargs: + new_idx = kwargs["menu_state"].selected_index + show_save_option = kwargs["menu_state"].show_save_option + start_index = kwargs["menu_state"].start_index + transformed_path = transform_menu_path(kwargs["menu_state"].menu_path) + else: + new_idx = kwargs["selected_index"] + transformed_path = [] + + if "help_text" in kwargs: + help_text = kwargs["help_text"] + + if "max_help_lines" in kwargs: + max_help_lines = kwargs["max_help_lines"] + if old_idx == new_idx: # No-op + return + + + max_index = len(options) + (1 if show_save_option else 0) - 1 + visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) + + # Adjust menu_state.start_index only when moving out of visible range + if new_idx == max_index and show_save_option: + pass + elif new_idx < start_index[-1]: # Moving above the visible area + start_index[-1] = new_idx + elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area + start_index[-1] = new_idx- visible_height + + # Ensure menu_state.start_index is within bounds + start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1)) + + # 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")) + else: + menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) + + # Highlight new selection + if show_save_option and new_idx == max_index: + menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) + else: + menu_pad.chgat(new_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True)) + + menu_win.refresh() + + # Refresh pad only if scrolling is needed + menu_pad.refresh(start_index[-1], 0, + menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, + menu_win.getbegyx()[0] + 3 + visible_height, + menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4) + + # Update help window only if help_text is populated + 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: + help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) + + draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option=False) + + +def draw_arrows( + win: object, + visible_height: int, + max_index: int, + start_index: list[int], + show_save_option: bool +) -> None: + + # vh = visible_height + (1 if show_save_option else 0) + mi = max_index - (2 if show_save_option else 0) + + if visible_height < mi: + if start_index[-1] > 0: + win.addstr(3, 2, "▲", get_color("settings_default")) + else: + win.addstr(3, 2, " ", get_color("settings_default")) + + if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1) : + win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) + else: + win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) + + +def update_help_window( + help_win: object, # curses window or None + help_text: dict[str, str], + transformed_path: list[str], + selected_option: str | None, + max_help_lines: int, + width: int, + help_y: int, + 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) + + 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 + if help_y + help_height > curses.LINES: + help_y = curses.LINES - help_height + + # Create or update the help window + if help_win is None: + help_win = curses.newwin(help_height, 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.bkgd(get_color("background")) + help_win.attrset(get_color("window_frame")) + help_win.border() + + for idx, line_segments in enumerate(wrapped_help): + x_pos = 2 # Start after border + for text, color, bold, underline in line_segments: + try: + attr = get_color(color, bold=bold, underline=underline) + help_win.addstr(1 + idx, x_pos, text, attr) + x_pos += len(text) + except curses.error: + pass # Prevent crashes + + help_win.refresh() + return help_win + +def get_wrapped_help_text( + help_text: dict[str, str], + transformed_path: list[str], + selected_option: str | None, + width: int, + max_lines: int +) -> list[WrappedLine]: + """Fetches and formats help text for display, ensuring it fits within the allowed lines.""" + + full_help_key = '.'.join(transformed_path + [selected_option]) if selected_option else None + help_content = help_text.get(full_help_key, "No help available.") + + wrap_width = max(width - 6, 10) # Ensure a valid wrapping width + + # Color replacements + color_mappings = { + r'\[warning\](.*?)\[/warning\]': ('settings_warning', True, False), # Red for warnings + r'\[note\](.*?)\[/note\]': ('settings_note', True, False), # Green for notes + r'\[underline\](.*?)\[/underline\]': ('settings_default', False, True), # Underline + + r'\\033\[31m(.*?)\\033\[0m': ('settings_warning', True, False), # Red text + r'\\033\[32m(.*?)\\033\[0m': ('settings_note', True, False), # Green text + r'\\033\[4m(.*?)\\033\[0m': ('settings_default', False, True) # Underline + } + + def extract_ansi_segments(text: str) -> list[Segment]: + """Extracts and replaces ANSI color codes, ensuring spaces are preserved.""" + matches = [] + last_pos = 0 + pattern_matches = [] + + # Find all matches and store their positions + for pattern, (color, bold, underline) in color_mappings.items(): + for match in re.finditer(pattern, text): + pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline)) + + # Sort matches by start position to process sequentially + pattern_matches.sort(key=lambda x: x[0]) + + for start, end, content, color, bold, underline in pattern_matches: + # Preserve non-matching text including spaces + if last_pos < start: + segment = text[last_pos:start] + matches.append((segment, "settings_default", False, False)) + + # Append the colored segment + matches.append((content, color, bold, underline)) + last_pos = end + + # Preserve any trailing text + if last_pos < len(text): + matches.append((text[last_pos:], "settings_default", False, False)) + + return matches + + def wrap_ansi_text(segments: list[Segment], wrap_width: int) -> list[WrappedLine]: + """Wraps text while preserving ANSI formatting and spaces.""" + wrapped_lines = [] + line_buffer = [] + line_length = 0 + + for text, color, bold, underline in segments: + words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately + + for word in words: + word_length = len(word) + + if line_length + word_length > wrap_width and word.strip(): + # If the word (ignoring spaces) exceeds width, wrap the line + wrapped_lines.append(line_buffer) + line_buffer = [] + line_length = 0 + + line_buffer.append((word, color, bold, underline)) + line_length += word_length + + if line_buffer: + wrapped_lines.append(line_buffer) + + return wrapped_lines + + raw_lines = help_content.split("\\n") # Preserve new lines + wrapped_help = [] + + for raw_line in raw_lines: + color_segments = extract_ansi_segments(raw_line) + wrapped_segments = wrap_ansi_text(color_segments, wrap_width) + wrapped_help.extend(wrapped_segments) + pass + + # Trim and add ellipsis if needed + if len(wrapped_help) > max_lines: + wrapped_help = wrapped_help[:max_lines] + wrapped_help[-1].append(("...", "settings_default", False, False)) + + return wrapped_help + +def wrap_text(text: str, wrap_width: int) -> list[str]: + """Wraps text while preserving spaces and breaking long words.""" + words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately + wrapped_lines = [] + line_buffer = "" + line_length = 0 + margin = 2 # Left and right margin + wrap_width -= margin + + for word in words: + word_length = len(word) + + if word_length > wrap_width: # Break long words + if line_buffer: + wrapped_lines.append(line_buffer) + line_buffer = "" + line_length = 0 + for i in range(0, word_length, wrap_width): + wrapped_lines.append(word[i:i+wrap_width]) + continue + + if line_length + word_length > wrap_width and word.strip(): + wrapped_lines.append(line_buffer) + line_buffer = "" + line_length = 0 + + line_buffer += word + line_length += word_length + + if line_buffer: + wrapped_lines.append(line_buffer) + + return wrapped_lines + \ No newline at end of file diff --git a/contact/ui/user_config.py b/contact/ui/user_config.py index 21820df..cfbc5bd 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -5,8 +5,11 @@ from typing import Any from contact.ui.colors import get_color, setup_colors, COLOR_MAP from contact.ui.default_config import format_json_single_line_arrays, loaded_config from contact.utilities.input_handlers import get_list_input +from contact.ui.nav_utils import move_highlight, draw_arrows + width = 80 +max_help_lines = 6 save_option = "Save Changes" sensitive_settings = [] @@ -162,79 +165,11 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]: max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) - draw_arrows(menu_win, visible_height, max_index, menu_state) + draw_arrows(menu_win, visible_height, max_index, menu_state.start_index, show_save_option=False) return menu_win, menu_pad, options -def move_highlight( - old_idx: int, - options: list[str], - menu_win: curses.window, - menu_pad: curses.window, - menu_state: Any -) -> None: - - if old_idx == menu_state.selected_index: # No-op - return - - max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 - visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) - - # Adjust menu_state.start_index only when moving out of visible range - if menu_state.selected_index == max_index and menu_state.show_save_option: - pass - elif menu_state.selected_index < menu_state.start_index[-1]: # Moving above the visible area - menu_state.start_index[-1] = menu_state.selected_index - elif menu_state.selected_index >= menu_state.start_index[-1] + visible_height: # Moving below the visible area - menu_state.start_index[-1] = menu_state.selected_index - visible_height - pass - - # Ensure menu_state.start_index is within bounds - menu_state.start_index[-1] = max(0, min(menu_state.start_index[-1], max_index - visible_height + 1)) - - # Clear old selection - if menu_state.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")) - else: - menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) - - # Highlight new selection - if menu_state.show_save_option and menu_state.selected_index == max_index: - menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) - else: - menu_pad.chgat(menu_state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[menu_state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True)) - - menu_win.refresh() - - # Refresh pad only if scrolling is needed - menu_pad.refresh(menu_state.start_index[-1], 0, - menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + visible_height, - menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4) - - draw_arrows(menu_win, visible_height, max_index, menu_state) - - -def draw_arrows( - win: curses.window, - visible_height: int, - max_index: int, - menu_state: any -) -> None: - - mi = max_index - (2 if menu_state.show_save_option else 0) - - if visible_height < mi: - if menu_state.start_index[-1] > 0: - win.addstr(3, 2, "▲", get_color("settings_default")) - else: - win.addstr(3, 2, " ", get_color("settings_default")) - - if mi - menu_state.start_index[-1] >= visible_height + (0 if menu_state.show_save_option else 1) : - win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) - else: - win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) def json_editor(stdscr: curses.window, menu_state: Any) -> None: @@ -246,6 +181,8 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: file_path = os.path.join(parent_dir, "config.json") menu_state.show_save_option = True # Always show the Save button + menu_state.help_win = None + menu_state.help_text = {} # Ensure the file exists if not os.path.exists(file_path): @@ -276,18 +213,18 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index if menu_state.selected_index == 0 else menu_state.selected_index - 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state) + menu_state.help_win = move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines) elif key == curses.KEY_DOWN: old_selected_index = menu_state.selected_index menu_state.selected_index = 0 if menu_state.selected_index == max_index else menu_state.selected_index + 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state) + menu_state.help_win = move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines) elif key == ord("\t") and menu_state.show_save_option: old_selected_index = menu_state.selected_index menu_state.selected_index = max_index - move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state) + menu_state.help_win = move_highlight(old_selected_index, options, menu_win, menu_pad, menu_state=menu_state, max_help_lines=max_help_lines) elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return diff --git a/contact/utilities/input_handlers.py b/contact/utilities/input_handlers.py index 3c1835b..f8ce285 100644 --- a/contact/utilities/input_handlers.py +++ b/contact/utilities/input_handlers.py @@ -5,41 +5,8 @@ import ipaddress import re from typing import Any, Optional from contact.ui.colors import get_color +from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text -def wrap_text(text: str, wrap_width: int) -> list[str]: - """Wraps text while preserving spaces and breaking long words.""" - words = re.findall(r'\S+|\s+', text) # Capture words and spaces separately - wrapped_lines = [] - line_buffer = "" - line_length = 0 - margin = 2 # Left and right margin - wrap_width -= margin - - for word in words: - word_length = len(word) - - if word_length > wrap_width: # Break long words - if line_buffer: - wrapped_lines.append(line_buffer) - line_buffer = "" - line_length = 0 - for i in range(0, word_length, wrap_width): - wrapped_lines.append(word[i:i+wrap_width]) - continue - - if line_length + word_length > wrap_width and word.strip(): - wrapped_lines.append(line_buffer) - line_buffer = "" - line_length = 0 - - line_buffer += word - line_length += word_length - - if line_buffer: - wrapped_lines.append(line_buffer) - - return wrapped_lines - def get_text_input(prompt: str) -> Optional[str]: """Handles user input with wrapped text for long prompts.""" @@ -377,7 +344,7 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: lis max_index = len(list_options) - 1 visible_height = list_win.getmaxyx()[0] - 5 - draw_arrows(list_win, visible_height, max_index, 0) + draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False) # Initial call to draw arrows while True: key = list_win.getch() @@ -385,11 +352,11 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: lis if key == curses.KEY_UP: old_selected_index = selected_index selected_index = max(0, selected_index - 1) - move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) + move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) elif key == curses.KEY_DOWN: old_selected_index = selected_index selected_index = min(len(list_options) - 1, selected_index + 1) - move_highlight(old_selected_index, selected_index, list_options, list_win, list_pad) + move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index) elif key == ord('\n'): # Enter key list_win.clear() list_win.refresh() @@ -398,69 +365,3 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: lis list_win.clear() list_win.refresh() return current_option - - -def move_highlight( - old_idx: int, - new_idx: int, - options: list[str], - list_win: curses.window, - list_pad: curses.window -) -> int: - - global scroll_offset - if 'scroll_offset' not in globals(): - scroll_offset = 0 # Initialize if not set - - if old_idx == new_idx: - return # No-op - - max_index = len(options) - 1 - visible_height = list_win.getmaxyx()[0] - 5 - - # Adjust scroll_offset only when moving out of visible range - if new_idx < scroll_offset: # Moving above the visible area - scroll_offset = new_idx - elif new_idx >= scroll_offset + visible_height: # Moving below the visible area - scroll_offset = new_idx - visible_height - - # Ensure scroll_offset is within bounds - scroll_offset = max(0, min(scroll_offset, max_index - visible_height + 1)) - - # Clear old highlight - list_pad.chgat(old_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default")) - - # Highlight new selection - list_pad.chgat(new_idx, 0, list_pad.getmaxyx()[1], get_color("settings_default", reverse=True)) - - list_win.refresh() - - # Refresh pad only if scrolling is needed - list_pad.refresh(scroll_offset, 0, - list_win.getbegyx()[0] + 3, list_win.getbegyx()[1] + 4, - list_win.getbegyx()[0] + 3 + visible_height, - list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4) - - draw_arrows(list_win, visible_height, max_index, scroll_offset) - - return scroll_offset # Return updated scroll_offset to be stored externally - - -def draw_arrows( - win: curses.window, - visible_height: int, - max_index: int, - start_index: int -) -> None: - - if visible_height < max_index: - if start_index > 0: - win.addstr(3, 2, "▲", get_color("settings_default")) - else: - win.addstr(3, 2, " ", get_color("settings_default")) - - if max_index - start_index > visible_height: - win.addstr(visible_height + 3, 2, "▼", get_color("settings_default")) - else: - win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) - \ No newline at end of file