diff --git a/contact/ui/control_ui.py b/contact/ui/control_ui.py index f7f42d1..00e58f8 100644 --- a/contact/ui/control_ui.py +++ b/contact/ui/control_ui.py @@ -303,6 +303,7 @@ def settings_menu(stdscr: object, interface: object) -> None: file.write(config_text) logging.info(f"Config file saved to {yaml_file_path}") dialog("Config File Saved:", yaml_file_path) + menu_state.need_redraw = True menu_state.start_index.pop() continue except PermissionError: @@ -319,6 +320,7 @@ def settings_menu(stdscr: object, interface: object) -> None: # Check if folder exists and is not empty if not os.path.exists(config_folder) or not any(os.listdir(config_folder)): dialog("", " No config files found. Export a config first.") + menu_state.need_redraw = True continue # Return to menu file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))] @@ -326,6 +328,7 @@ def settings_menu(stdscr: object, interface: object) -> None: # Ensure file_list is not empty before proceeding if not file_list: dialog("", " No config files found. Export a config first.") + menu_state.need_redraw = True continue filename = get_list_input("Choose a config file", None, file_list) @@ -510,6 +513,27 @@ def settings_menu(stdscr: object, interface: object) -> None: menu_state.selected_index = 0 elif key == curses.KEY_LEFT: + + # If we are at the main menu and there are unsaved changes, prompt to save + if len(menu_state.menu_path) == 3 and modified_settings: + + current_section = menu_state.menu_path[-1] + save_prompt = get_list_input( + f"You have unsaved changes in {current_section}. Save before exiting?", + None, + ["Yes", "No", "Cancel"], + mandatory=True, + ) + if save_prompt == "Cancel": + continue # Stay in the menu without doing anything + elif save_prompt == "Yes": + save_changes(interface, modified_settings, menu_state) + logging.info("Changes Saved") + + modified_settings.clear() + menu = rebuild_menu_at_current_path(interface, menu_state) + pass + menu_state.need_redraw = True menu_win.erase() @@ -521,8 +545,8 @@ def settings_menu(stdscr: object, interface: object) -> None: menu_win.refresh() help_win.refresh() - if len(menu_state.menu_path) < 2: - modified_settings.clear() + # if len(menu_state.menu_path) < 2: + # modified_settings.clear() # Navigate back to the previous menu if len(menu_state.menu_path) > 1: @@ -539,6 +563,16 @@ def settings_menu(stdscr: object, interface: object) -> None: break +def rebuild_menu_at_current_path(interface, menu_state): + """Rebuild menus from the device and re-point current_menu to the same path.""" + new_menu = generate_menu_from_protobuf(interface) + cur = new_menu["Main Menu"] + for step in menu_state.menu_path[1:]: + cur = cur.get(step, {}) + menu_state.current_menu = cur + return new_menu + + def set_region(interface: object) -> None: node = interface.getNode("^local") device_config = node.localConfig diff --git a/contact/ui/user_config.py b/contact/ui/user_config.py index 807581c..825f109 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -215,6 +215,7 @@ def display_menu() -> tuple[Any, Any, List[str]]: def json_editor(stdscr: curses.window, menu_state: Any) -> None: menu_state.selected_index = 0 # Track the selected option + made_changes = False # Track if any changes were made script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) @@ -297,11 +298,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: if isinstance(selected_data, list) and len(selected_data) == 2: # Edit color pair + old = selected_data new_value = edit_color_pair(selected_key, selected_data) menu_state.menu_path.pop() menu_state.start_index.pop() menu_state.menu_index.pop() menu_state.current_menu[selected_key] = new_value + if new_value != old: + made_changes = True elif isinstance(selected_data, (dict, list)): # Navigate into nested data @@ -310,12 +314,15 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: else: # General value editing + old = selected_data new_value = edit_value(selected_key, selected_data) menu_state.menu_path.pop() menu_state.menu_index.pop() menu_state.start_index.pop() menu_state.current_menu[selected_key] = new_value menu_state.need_redraw = True + if new_value != old: + made_changes = True else: # Save button selected @@ -347,6 +354,19 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None: else: # Exit the editor + if made_changes: + save_prompt = get_list_input( + "You have unsaved changes. Save before exiting?", + None, + ["Yes", "No", "Cancel"], + mandatory=True, + ) + if save_prompt == "Cancel": + continue # Stay in the menu without doing anything + elif save_prompt == "Yes": + save_json(file_path, data) + made_changes = False + menu_win.clear() menu_win.refresh() diff --git a/contact/utilities/input_handlers.py b/contact/utilities/input_handlers.py index bf6ccbb..5099a39 100644 --- a/contact/utilities/input_handlers.py +++ b/contact/utilities/input_handlers.py @@ -109,6 +109,8 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option return None elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): + menu_state.need_redraw = True + if not user_input.strip(): invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win) continue @@ -293,10 +295,10 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]: return None elif key == ord("\n"): # Enter key to save and return + menu_state.need_redraw = True if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes curses.noecho() curses.curs_set(0) - menu_state.need_redraw = True return user_values # Return the edited Base64 values else: invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!" @@ -446,16 +448,15 @@ def get_fixed32_input(current_value: int) -> int: elif key in ("\n", curses.KEY_ENTER): octets = user_input.split(".") + menu_state.need_redraw = True if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets): curses.noecho() curses.curs_set(0) - menu_state.need_redraw = True return int(ipaddress.ip_address(user_input)) else: fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True)) fixed32_win.refresh() curses.napms(1500) - menu_state.need_redraw = True user_input = "" elif key in (curses.KEY_BACKSPACE, 127): @@ -470,7 +471,15 @@ def get_fixed32_input(current_value: int) -> int: pass # Ignore unprintable inputs -def get_list_input(prompt: str, current_option: Optional[str], list_options: List[str]) -> Optional[str]: +from typing import List, Optional # ensure Optional is imported + + +def get_list_input( + prompt: str, current_option: Optional[str], list_options: List[str], mandatory: bool = False +) -> Optional[str]: + """ + List selector. + """ selected_index = list_options.index(current_option) if current_option in list_options else 0 height = min(len(list_options) + 5, curses.LINES) @@ -495,11 +504,9 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: Lis list_win.border() list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True)) - for idx, color in enumerate(list_options): - if idx == selected_index: - list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True)) - else: - list_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default")) + 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_win.refresh() list_pad.refresh( @@ -510,7 +517,6 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: Lis 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) # Initial draw @@ -524,22 +530,27 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: Lis try: key = list_win.getch() except curses.error: - continue # Graceful timeout handling + continue if key == curses.KEY_UP: old_selected_index = selected_index selected_index = max(0, selected_index - 1) 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, list_options, list_win, list_pad, selected_index=selected_index) - elif key == ord("\n"): # Enter key + + elif key == ord("\n"): # Enter list_win.clear() list_win.refresh() menu_state.need_redraw = True return list_options[selected_index] - elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow + + elif key == 27 or key == curses.KEY_LEFT: # ESC or Left + if mandatory: + continue list_win.clear() list_win.refresh() menu_state.need_redraw = True