From d7eec6de6ebeaff73ff9079f8989f243f93afedc Mon Sep 17 00:00:00 2001 From: Ben Lipsey Date: Sat, 12 Apr 2025 21:53:27 -0700 Subject: [PATCH] current state --- contact/ui/menus.py | 2 +- contact/ui/ui_state.py | 14 ++- contact/ui/user_config.py | 172 ++++++++++++++-------------- contact/utilities/config_io.py | 5 +- contact/utilities/input_handlers.py | 28 +++-- contact/utilities/save_to_radio.py | 12 +- 6 files changed, 123 insertions(+), 110 deletions(-) diff --git a/contact/ui/menus.py b/contact/ui/menus.py index 47fad4a..b4c521a 100644 --- a/contact/ui/menus.py +++ b/contact/ui/menus.py @@ -12,7 +12,7 @@ from meshtastic.protobuf import channel_pb2, config_pb2, module_config_pb2 locals_dir = os.path.dirname(os.path.abspath(__file__)) translation_file = os.path.join(locals_dir, "localisations", "en.ini") -def encode_if_bytes(value: Any) -> str | Any: +def encode_if_bytes(value: Any) -> str: """Encode byte values to base64 string.""" if isinstance(value, bytes): return base64.b64encode(value).decode('utf-8') diff --git a/contact/ui/ui_state.py b/contact/ui/ui_state.py index c794eb5..462b077 100644 --- a/contact/ui/ui_state.py +++ b/contact/ui/ui_state.py @@ -1,8 +1,10 @@ +from typing import Any + class MenuState: def __init__(self): - self.menu_index = [] # Row we left the previous menus - self.start_index = [0] # Row to start the menu if it doesn't all fit - self.selected_index = 0 # Selected Row - self.current_menu = {} # Contents of the current menu - self.menu_path = [] # Menu Path - self.show_save_option = False \ No newline at end of file + self.menu_index: list[int]= [] # Row we left the previous menus + self.start_index: list[int] = [0] # Row to start the menu if it doesn't all fit + self.selected_index: int = 0 # Selected Row + self.current_menu: dict[str, Any] | list[Any] | str | int = {} # Contents of the current menu + self.menu_path: list[str] = [] # Menu Path + self.show_save_option: bool = False # Display 'Save' \ No newline at end of file diff --git a/contact/ui/user_config.py b/contact/ui/user_config.py index 0b665bc..e7a6b7f 100644 --- a/contact/ui/user_config.py +++ b/contact/ui/user_config.py @@ -96,17 +96,17 @@ def edit_value(key: str, current_value: str) -> str: return user_input if user_input else current_value -def display_menu(state: Any) -> tuple[Any, Any, list[str]]: +def display_menu(menu_state: Any) -> tuple[Any, Any, list[str]]: """ Render the configuration menu with a Save button directly added to the window. """ - num_items = len(state.current_menu) + (1 if state.show_save_option else 0) + num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0) # Determine menu items based on the type of current_menu - if isinstance(state.current_menu, dict): - options = list(state.current_menu.keys()) - elif isinstance(state.current_menu, list): - options = [f"[{i}]" for i in range(len(state.current_menu))] + if isinstance(menu_state.current_menu, dict): + options = list(menu_state.current_menu.keys()) + elif isinstance(menu_state.current_menu, list): + options = [f"[{i}]" for i in range(len(menu_state.current_menu))] else: options = [] # Fallback in case of unexpected data types @@ -130,39 +130,39 @@ def display_menu(state: Any) -> tuple[Any, Any, list[str]]: menu_pad.bkgd(get_color("background")) # Display the menu path - header = " > ".join(state.menu_path) + header = " > ".join(menu_state.menu_path) if len(header) > width - 4: header = header[:width - 7] + "..." menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) # Populate the pad with menu options for idx, key in enumerate(options): - value = state.current_menu[key] if isinstance(state.current_menu, dict) else state.current_menu[int(key.strip("[]"))] + value = menu_state.current_menu[key] 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] ) - color = get_color("settings_default", reverse=(idx == state.selected_index)) + 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) # Add Save button to the main window - if state.show_save_option: + if menu_state.show_save_option: save_position = menu_height - 2 - menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu)))) + menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu)))) menu_win.refresh() menu_pad.refresh( - state.start_index[-1], 0, + menu_state.start_index[-1], 0, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, - menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0), + menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4 ) - max_index = num_items + (1 if state.show_save_option else 0) - 1 - visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0) + 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, state) + draw_arrows(menu_win, visible_height, max_index, menu_state) return menu_win, menu_pad, options @@ -172,80 +172,80 @@ def move_highlight( options: list[str], menu_win: curses.window, menu_pad: curses.window, - state: Any + menu_state: Any ) -> None: - if old_idx == state.selected_index: # No-op + if old_idx == menu_state.selected_index: # No-op return - max_index = len(options) + (1 if state.show_save_option else 0) - 1 - visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0) + 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 state.start_index only when moving out of visible range - if state.selected_index == max_index and state.show_save_option: + # 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 state.selected_index < state.start_index[-1]: # Moving above the visible area - state.start_index[-1] = state.selected_index - elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area - state.start_index[-1] = state.selected_index - visible_height + 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 state.start_index is within bounds - state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1)) + # 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 state.show_save_option and old_idx == max_index: + 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 state.show_save_option and state.selected_index == max_index: + 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(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True)) + 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(state.start_index[-1], 0, + 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, state) + draw_arrows(menu_win, visible_height, max_index, menu_state) def draw_arrows( win: curses.window, visible_height: int, max_index: int, - state: any + menu_state: any ) -> None: - mi = max_index - (2 if state.show_save_option else 0) + mi = max_index - (2 if menu_state.show_save_option else 0) if visible_height < mi: - if state.start_index[-1] > 0: + 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 - state.start_index[-1] >= visible_height + (0 if state.show_save_option else 1) : + 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, state: Any) -> None: +def json_editor(stdscr: curses.window, menu_state: Any) -> None: - state.selected_index = 0 # Track the selected option + menu_state.selected_index = 0 # Track the selected option script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir)) file_path = os.path.join(parent_dir, "config.json") - state.show_save_option = True # Always show the Save button + menu_state.show_save_option = True # Always show the Save button # Ensure the file exists if not os.path.exists(file_path): @@ -257,37 +257,37 @@ def json_editor(stdscr: curses.window, state: Any) -> None: original_data = json.load(f) data = original_data # Reference to the original data - state.current_menu = data # Track the current level of the menu + menu_state.current_menu = data # Track the current level of the menu # Render the menu - menu_win, menu_pad, options = display_menu(state) + menu_win, menu_pad, options = display_menu(menu_state) need_redraw = True while True: if(need_redraw): - menu_win, menu_pad, options = display_menu(state) + menu_win, menu_pad, options = display_menu(menu_state) menu_win.refresh() need_redraw = False - max_index = len(options) + (1 if state.show_save_option else 0) - 1 + max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1 key = menu_win.getch() if key == curses.KEY_UP: - old_selected_index = state.selected_index - state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, state) + 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) elif key == curses.KEY_DOWN: - old_selected_index = state.selected_index - state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1 - move_highlight(old_selected_index, options, menu_win, menu_pad, state) + 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) - elif key == ord("\t") and state.show_save_option: - old_selected_index = state.selected_index - state.selected_index = max_index - move_highlight(old_selected_index, options, menu_win, menu_pad, state) + 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) elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return @@ -295,41 +295,41 @@ def json_editor(stdscr: curses.window, state: Any) -> None: menu_win.erase() menu_win.refresh() - if state.selected_index < len(options): # Handle selection of a menu item - selected_key = options[state.selected_index] - state.menu_path.append(str(selected_key)) - state.start_index.append(0) - state.menu_index.append(state.selected_index) + if menu_state.selected_index < len(options): # Handle selection of a menu item + selected_key = options[menu_state.selected_index] + menu_state.menu_path.append(str(selected_key)) + menu_state.start_index.append(0) + menu_state.menu_index.append(menu_state.selected_index) # Handle nested data - if isinstance(state.current_menu, dict): - if selected_key in state.current_menu: - selected_data = state.current_menu[selected_key] + if isinstance(menu_state.current_menu, dict): + if selected_key in menu_state.current_menu: + selected_data = menu_state.current_menu[selected_key] else: continue # Skip invalid key - elif isinstance(state.current_menu, list): - selected_data = state.current_menu[int(selected_key.strip("[]"))] + elif isinstance(menu_state.current_menu, list): + selected_data = menu_state.current_menu[int(selected_key.strip("[]"))] if isinstance(selected_data, list) and len(selected_data) == 2: # Edit color pair new_value = edit_color_pair(selected_key, selected_data) - state.menu_path.pop() - state.start_index.pop() - state.menu_index.pop() - state.current_menu[selected_key] = new_value + menu_state.menu_path.pop() + menu_state.start_index.pop() + menu_state.menu_index.pop() + menu_state.current_menu[selected_key] = new_value elif isinstance(selected_data, (dict, list)): # Navigate into nested data - state.current_menu = selected_data - state.selected_index = 0 # Reset the selected index + menu_state.current_menu = selected_data + menu_state.selected_index = 0 # Reset the selected index else: # General value editing - new_value = edit_value(selected_key, selected_data, state) - state.menu_path.pop() - state.menu_index.pop() - state.start_index.pop() - state.current_menu[selected_key] = new_value + 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 need_redraw = True else: @@ -344,16 +344,16 @@ def json_editor(stdscr: curses.window, state: Any) -> None: menu_win.erase() menu_win.refresh() - # state.selected_index = state.menu_index[-1] + # menu_state.selected_index = menu_state.menu_index[-1] # Navigate back in the menu - if len(state.menu_path) > 2: - state.menu_path.pop() - state.start_index.pop() - state.current_menu = data + if len(menu_state.menu_path) > 2: + menu_state.menu_path.pop() + menu_state.start_index.pop() + menu_state.current_menu = data - for path in state.menu_path[2:]: - state.current_menu = state.current_menu[path] if isinstance(state.current_menu, dict) else state.current_menu[int(path.strip("[]"))] + for path in menu_state.menu_path[2:]: + menu_state.current_menu = menu_state.current_menu[path] if isinstance(menu_state.current_menu, dict) else menu_state.current_menu[int(path.strip("[]"))] else: # Exit the editor @@ -372,14 +372,14 @@ def save_json(file_path: str, data: dict[str, Any]) -> None: def main(stdscr: curses.window) -> None: from contact.ui.ui_state import MenuState - state = MenuState() - if len(state.menu_path) == 0: - state.menu_path = ["App Settings"] # Initialize if not set + menu_state = MenuState() + if len(menu_state.menu_path) == 0: + menu_state.menu_path = ["App Settings"] # Initialize if not set curses.curs_set(0) stdscr.keypad(True) setup_colors() - json_editor(stdscr, state) + json_editor(stdscr, menu_state) if __name__ == "__main__": curses.wrapper(main) \ No newline at end of file diff --git a/contact/utilities/config_io.py b/contact/utilities/config_io.py index 009da6e..22b5657 100644 --- a/contact/utilities/config_io.py +++ b/contact/utilities/config_io.py @@ -1,7 +1,6 @@ import yaml import logging -from typing import List from google.protobuf.json_format import MessageToDict from meshtastic import mt_config from meshtastic.util import camel_to_snake, snake_to_camel, fromStr @@ -20,9 +19,9 @@ def traverseConfig(config_root, config, interface_config) -> bool: return True -def splitCompoundName(comp_name: str) -> List[str]: +def splitCompoundName(comp_name: str) -> list[str]: """Split compound (dot separated) preference name into parts""" - name: List[str] = comp_name.split(".") + name: list[str] = comp_name.split(".") if len(name) < 2: name[0] = comp_name name.append(comp_name) diff --git a/contact/utilities/input_handlers.py b/contact/utilities/input_handlers.py index 847b9f0..3c1835b 100644 --- a/contact/utilities/input_handlers.py +++ b/contact/utilities/input_handlers.py @@ -3,9 +3,10 @@ import binascii import curses import ipaddress import re +from typing import Any, Optional from contact.ui.colors import get_color -def wrap_text(text, wrap_width): +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 = [] @@ -40,7 +41,7 @@ def wrap_text(text, wrap_width): return wrapped_lines -def get_text_input(prompt): +def get_text_input(prompt: str) -> Optional[str]: """Handles user input with wrapped text for long prompts.""" height = 8 width = 80 @@ -128,7 +129,7 @@ def get_text_input(prompt): return user_input -def get_admin_key_input(current_value): +def get_admin_key_input(current_value: list[bytes]) -> Optional[list[str]]: def to_base64(byte_strings): """Convert byte values to Base64-encoded strings.""" return [base64.b64encode(b).decode() for b in byte_strings] @@ -212,7 +213,7 @@ def get_admin_key_input(current_value): -def get_repeated_input(current_value): +def get_repeated_input(current_value: list[str]) -> Optional[str]: height = 9 width = 80 start_y = (curses.LINES - height) // 2 @@ -279,7 +280,7 @@ def get_repeated_input(current_value): pass # Ignore invalid character inputs -def get_fixed32_input(current_value): +def get_fixed32_input(current_value: int) -> int: cvalue = current_value current_value = str(ipaddress.IPv4Address(current_value)) height = 10 @@ -336,7 +337,7 @@ def get_fixed32_input(current_value): pass # Ignore invalid inputs -def get_list_input(prompt, current_option, list_options): +def get_list_input(prompt: str, current_option: Optional[str], list_options: list[str]) -> Optional[str]: """ Displays a scrollable list of list_options for the user to choose from. """ @@ -399,7 +400,13 @@ def get_list_input(prompt, current_option, list_options): return current_option -def move_highlight(old_idx, new_idx, options, list_win, list_pad): +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(): @@ -439,7 +446,12 @@ def move_highlight(old_idx, new_idx, options, list_win, list_pad): return scroll_offset # Return updated scroll_offset to be stored externally -def draw_arrows(win, visible_height, max_index, start_index): +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: diff --git a/contact/utilities/save_to_radio.py b/contact/utilities/save_to_radio.py index 7a5b4c4..85f5de0 100644 --- a/contact/utilities/save_to_radio.py +++ b/contact/utilities/save_to_radio.py @@ -4,7 +4,7 @@ import logging import base64 import time -def save_changes(interface, modified_settings, state): +def save_changes(interface, modified_settings, menu_state): """ Save changes to the device based on modified settings. :param interface: Meshtastic interface instance @@ -52,8 +52,8 @@ def save_changes(interface, modified_settings, state): if not modified_settings: return - if state.menu_path[1] == "Radio Settings" or state.menu_path[1] == "Module Settings": - config_category = state.menu_path[2].lower() # for radio and module configs + if menu_state.menu_path[1] == "Radio Settings" or menu_state.menu_path[1] == "Module Settings": + config_category = menu_state.menu_path[2].lower() # for radio and module configs if {'latitude', 'longitude', 'altitude'} & modified_settings.keys(): lat = float(modified_settings.get('latitude', 0.0)) @@ -64,7 +64,7 @@ def save_changes(interface, modified_settings, state): logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") return - elif state.menu_path[1] == "User Settings": # for user configs + elif menu_state.menu_path[1] == "User Settings": # for user configs config_category = "User Settings" long_name = modified_settings.get("longName") short_name = modified_settings.get("shortName") @@ -77,11 +77,11 @@ def save_changes(interface, modified_settings, state): return - elif state.menu_path[1] == "Channels": # for channel configs + elif menu_state.menu_path[1] == "Channels": # for channel configs config_category = "Channels" try: - channel = state.menu_path[-1] + channel = menu_state.menu_path[-1] channel_num = int(channel.split()[-1]) - 1 except (IndexError, ValueError) as e: channel_num = None