mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
15 Commits
input-vali
...
confirm-un
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
640955656f | ||
|
|
8f248f4b5b | ||
|
|
c10905e954 | ||
|
|
d1b93263fa | ||
|
|
623708c2a1 | ||
|
|
9b8abdb344 | ||
|
|
8c3e00b52b | ||
|
|
81ebd1b95f | ||
|
|
ae75d85741 | ||
|
|
b6767f423e | ||
|
|
b1252fec6c | ||
|
|
43d1152074 | ||
|
|
786a7b03c5 | ||
|
|
8d111c5df7 | ||
|
|
b314a24a0c |
@@ -33,6 +33,7 @@ By navigating to Settings -> App Settings, you may customize your UI's icons, co
|
|||||||
- `CTRL` + `p` = Hide/show a log of raw received packets.
|
- `CTRL` + `p` = Hide/show a log of raw received packets.
|
||||||
- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node
|
- `CTRL` + `t` = With the Node List highlighted, send a traceroute to the selected node
|
||||||
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
- `CTRL` + `d` = With the Channel List hightlighted, archive a chat to reduce UI clutter. Messages will be saved in the db and repopulate if you send or receive a DM from this user.
|
||||||
|
- `CTRL` + `d` = With the Note List highlghted, remove a node from your nodedb.
|
||||||
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
|
- `ESC` = Exit out of the Settings Dialogue, or Quit the application if settings are not displayed.
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
@@ -67,3 +68,11 @@ To quickly connect to localhost, use:
|
|||||||
```sh
|
```sh
|
||||||
contact -t
|
contact -t
|
||||||
```
|
```
|
||||||
|
## Install in development (editable) mode:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/pdxlocations/contact.git
|
||||||
|
cd contact
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import curses
|
import curses
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Union
|
from typing import Union
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ from contact.utilities.input_handlers import get_list_input
|
|||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
import contact.ui.dialog
|
import contact.ui.dialog
|
||||||
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
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
|
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
||||||
|
|
||||||
|
|
||||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||||
@@ -320,10 +321,15 @@ def handle_enter(input_text: str) -> str:
|
|||||||
return input_text
|
return input_text
|
||||||
|
|
||||||
elif len(input_text) > 0:
|
elif len(input_text) > 0:
|
||||||
|
# TODO: This is a hack to prevent sending messages too quickly. Let's get errors from the node.
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - ui_state.last_sent_time < 2.5:
|
||||||
|
contact.ui.dialog.dialog("Slow down", "Please wait 2 seconds between messages.")
|
||||||
|
return input_text
|
||||||
# Enter key pressed, send user input as message
|
# Enter key pressed, send user input as message
|
||||||
send_message(input_text, channel=ui_state.selected_channel)
|
send_message(input_text, channel=ui_state.selected_channel)
|
||||||
draw_messages_window(True)
|
draw_messages_window(True)
|
||||||
|
ui_state.last_sent_time = now
|
||||||
# Clear entry window and reset input text
|
# Clear entry window and reset input text
|
||||||
entry_win.erase()
|
entry_win.erase()
|
||||||
return ""
|
return ""
|
||||||
@@ -335,7 +341,6 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
|
|||||||
send_traceroute()
|
send_traceroute()
|
||||||
curses.curs_set(0) # Hide cursor
|
curses.curs_set(0) # Hide cursor
|
||||||
contact.ui.dialog.dialog(
|
contact.ui.dialog.dialog(
|
||||||
stdscr,
|
|
||||||
f"Traceroute Sent To: {get_name_from_database(ui_state.node_list[ui_state.selected_node])}",
|
f"Traceroute Sent To: {get_name_from_database(ui_state.node_list[ui_state.selected_node])}",
|
||||||
"Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.",
|
"Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.",
|
||||||
)
|
)
|
||||||
@@ -358,7 +363,10 @@ def handle_backspace(entry_win: curses.window, input_text: str) -> str:
|
|||||||
def handle_backtick(stdscr: curses.window) -> None:
|
def handle_backtick(stdscr: curses.window) -> None:
|
||||||
"""Handle backtick key events to open the settings menu."""
|
"""Handle backtick key events to open the settings menu."""
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
|
previous_window = ui_state.current_window
|
||||||
|
ui_state.current_window = 4
|
||||||
settings_menu(stdscr, interface_state.interface)
|
settings_menu(stdscr, interface_state.interface)
|
||||||
|
ui_state.current_window = previous_window
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
refresh_node_list()
|
refresh_node_list()
|
||||||
handle_resize(stdscr, False)
|
handle_resize(stdscr, False)
|
||||||
@@ -593,6 +601,8 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
|||||||
refresh_pad(1)
|
refresh_pad(1)
|
||||||
|
|
||||||
draw_packetlog_win()
|
draw_packetlog_win()
|
||||||
|
if ui_state.current_window == 4:
|
||||||
|
menu_state.need_redraw = True
|
||||||
|
|
||||||
|
|
||||||
def draw_node_list() -> None:
|
def draw_node_list() -> None:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sys
|
|||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from contact.utilities.save_to_radio import save_changes
|
from contact.utilities.save_to_radio import save_changes
|
||||||
|
import contact.ui.default_config as config
|
||||||
from contact.utilities.config_io import config_export, config_import
|
from contact.utilities.config_io import config_export, config_import
|
||||||
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
|
from contact.utilities.control_utils import parse_ini_file, transform_menu_path
|
||||||
from contact.utilities.input_handlers import (
|
from contact.utilities.input_handlers import (
|
||||||
@@ -20,9 +21,7 @@ from contact.ui.dialog import dialog
|
|||||||
from contact.ui.menus import generate_menu_from_protobuf
|
from contact.ui.menus import generate_menu_from_protobuf
|
||||||
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
from contact.ui.nav_utils import move_highlight, draw_arrows, update_help_window
|
||||||
from contact.ui.user_config import json_editor
|
from contact.ui.user_config import json_editor
|
||||||
from contact.ui.ui_state import MenuState
|
from contact.utilities.singleton import menu_state
|
||||||
|
|
||||||
menu_state = MenuState()
|
|
||||||
|
|
||||||
# Constants
|
# Constants
|
||||||
width = 80
|
width = 80
|
||||||
@@ -36,16 +35,17 @@ script_dir = os.path.dirname(os.path.abspath(__file__))
|
|||||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
|
# locals_dir = os.path.dirname(os.path.abspath(sys.argv[0])) # Current script directory
|
||||||
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
|
translation_file = os.path.join(parent_dir, "localisations", "en.ini")
|
||||||
|
|
||||||
config_folder = os.path.join(locals_dir, "node-configs")
|
# config_folder = os.path.join(locals_dir, "node-configs")
|
||||||
|
config_folder = os.path.abspath(config.node_configs_file_path)
|
||||||
|
|
||||||
# Load translations
|
# Load translations
|
||||||
field_mapping, help_text = parse_ini_file(translation_file)
|
field_mapping, help_text = parse_ini_file(translation_file)
|
||||||
|
|
||||||
|
|
||||||
def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.window or pad types
|
def display_menu() -> tuple[object, object]: # curses.window or pad types
|
||||||
|
|
||||||
min_help_window_height = 6
|
min_help_window_height = 6
|
||||||
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
|
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
|
||||||
@@ -106,7 +106,7 @@ def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.wind
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Draw help window with dynamically updated max_help_lines
|
# Draw help window with dynamically updated max_help_lines
|
||||||
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, menu_state)
|
draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path)
|
||||||
|
|
||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
menu_pad.refresh(
|
menu_pad.refresh(
|
||||||
@@ -132,7 +132,6 @@ def draw_help_window(
|
|||||||
menu_height: int,
|
menu_height: int,
|
||||||
max_help_lines: int,
|
max_help_lines: int,
|
||||||
transformed_path: List[str],
|
transformed_path: List[str],
|
||||||
menu_state: MenuState,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
global help_win
|
global help_win
|
||||||
@@ -150,6 +149,15 @@ def draw_help_window(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_type_for_field(field) -> type:
|
||||||
|
if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64):
|
||||||
|
return int
|
||||||
|
elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE):
|
||||||
|
return float
|
||||||
|
else:
|
||||||
|
return str
|
||||||
|
|
||||||
|
|
||||||
def settings_menu(stdscr: object, interface: object) -> None:
|
def settings_menu(stdscr: object, interface: object) -> None:
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
|
|
||||||
@@ -159,29 +167,32 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
modified_settings = {}
|
modified_settings = {}
|
||||||
|
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
menu_state.show_save_option = False
|
menu_state.show_save_option = False
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if need_redraw:
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
options = list(menu_state.current_menu.keys())
|
options = list(menu_state.current_menu.keys())
|
||||||
|
|
||||||
|
# Determine if save option should be shown
|
||||||
|
path = menu_state.menu_path
|
||||||
menu_state.show_save_option = (
|
menu_state.show_save_option = (
|
||||||
(
|
(len(path) > 2 and ("Radio Settings" in path or "Module Settings" in path))
|
||||||
len(menu_state.menu_path) > 2
|
or (len(path) == 2 and "User Settings" in path)
|
||||||
and ("Radio Settings" in menu_state.menu_path or "Module Settings" in menu_state.menu_path)
|
or (len(path) == 3 and "Channels" in path)
|
||||||
)
|
|
||||||
or (len(menu_state.menu_path) == 2 and "User Settings" in menu_state.menu_path)
|
|
||||||
or (len(menu_state.menu_path) == 3 and "Channels" in menu_state.menu_path)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Display the menu
|
# Display the menu
|
||||||
menu_win, menu_pad = display_menu(menu_state)
|
menu_win, menu_pad = display_menu()
|
||||||
|
|
||||||
need_redraw = False
|
if menu_win is None:
|
||||||
|
continue # Skip if menu_win is not initialized
|
||||||
|
|
||||||
# Capture user input
|
menu_win.timeout(200) # wait up to 200 ms for a keypress (or less if key is pressed)
|
||||||
key = menu_win.getch()
|
key = menu_win.getch()
|
||||||
|
if key == -1:
|
||||||
|
continue
|
||||||
|
|
||||||
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
||||||
# max_help_lines = 4
|
# max_help_lines = 4
|
||||||
@@ -215,7 +226,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif key == curses.KEY_RESIZE:
|
elif key == curses.KEY_RESIZE:
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
|
|
||||||
menu_win.erase()
|
menu_win.erase()
|
||||||
@@ -239,7 +250,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif key == curses.KEY_RIGHT or key == ord("\n"):
|
elif key == curses.KEY_RIGHT or key == ord("\n"):
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
menu_state.start_index.append(0)
|
menu_state.start_index.append(0)
|
||||||
menu_win.erase()
|
menu_win.erase()
|
||||||
help_win.erase()
|
help_win.erase()
|
||||||
@@ -268,7 +279,8 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
elif selected_option == "Export Config File":
|
elif selected_option == "Export Config File":
|
||||||
filename = get_text_input("Enter a filename for the config file")
|
|
||||||
|
filename = get_text_input("Enter a filename for the config file", None, None)
|
||||||
if not filename:
|
if not filename:
|
||||||
logging.info("Export aborted: No filename provided.")
|
logging.info("Export aborted: No filename provided.")
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
@@ -290,7 +302,8 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
||||||
file.write(config_text)
|
file.write(config_text)
|
||||||
logging.info(f"Config file saved to {yaml_file_path}")
|
logging.info(f"Config file saved to {yaml_file_path}")
|
||||||
dialog(stdscr, "Config File Saved:", yaml_file_path)
|
dialog("Config File Saved:", yaml_file_path)
|
||||||
|
menu_state.need_redraw = True
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
continue
|
continue
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -306,14 +319,16 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
# Check if folder exists and is not empty
|
# Check if folder exists and is not empty
|
||||||
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
||||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
dialog("", " No config files found. Export a config first.")
|
||||||
|
menu_state.need_redraw = True
|
||||||
continue # Return to menu
|
continue # Return to menu
|
||||||
|
|
||||||
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
|
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
|
||||||
|
|
||||||
# Ensure file_list is not empty before proceeding
|
# Ensure file_list is not empty before proceeding
|
||||||
if not file_list:
|
if not file_list:
|
||||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
dialog("", " No config files found. Export a config first.")
|
||||||
|
menu_state.need_redraw = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filename = get_list_input("Choose a config file", None, file_list)
|
filename = get_list_input("Choose a config file", None, file_list)
|
||||||
@@ -327,7 +342,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
elif selected_option == "Config URL":
|
elif selected_option == "Config URL":
|
||||||
current_value = interface.localNode.getURL()
|
current_value = interface.localNode.getURL()
|
||||||
new_value = get_text_input(f"Config URL is currently: {current_value}")
|
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
|
||||||
if new_value is not None:
|
if new_value is not None:
|
||||||
current_value = new_value
|
current_value = new_value
|
||||||
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
||||||
@@ -380,7 +395,6 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
menu_state.selected_index = 4
|
menu_state.selected_index = 4
|
||||||
continue
|
continue
|
||||||
# need_redraw = True
|
|
||||||
|
|
||||||
field_info = menu_state.current_menu.get(selected_option)
|
field_info = menu_state.current_menu.get(selected_option)
|
||||||
if isinstance(field_info, tuple):
|
if isinstance(field_info, tuple):
|
||||||
@@ -395,7 +409,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||||
if selected_option in ["longName", "shortName"]:
|
if selected_option in ["longName", "shortName"]:
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.current_menu[selected_option] = (field, new_value)
|
menu_state.current_menu[selected_option] = (field, new_value)
|
||||||
|
|
||||||
@@ -414,7 +430,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif selected_option in ["latitude", "longitude", "altitude"]:
|
elif selected_option in ["latitude", "longitude", "altitude"]:
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, float
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.current_menu[selected_option] = (field, new_value)
|
menu_state.current_menu[selected_option] = (field, new_value)
|
||||||
|
|
||||||
@@ -453,17 +471,26 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else int(new_value)
|
new_value = current_value if new_value is None else int(new_value)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else float(new_value)
|
new_value = current_value if new_value is None else float(new_value)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
else: # Handle other field types
|
else: # Handle other field types
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
@@ -486,7 +513,28 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.selected_index = 0
|
menu_state.selected_index = 0
|
||||||
|
|
||||||
elif key == curses.KEY_LEFT:
|
elif key == curses.KEY_LEFT:
|
||||||
need_redraw = True
|
|
||||||
|
# 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()
|
menu_win.erase()
|
||||||
help_win.erase()
|
help_win.erase()
|
||||||
@@ -497,8 +545,8 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
help_win.refresh()
|
help_win.refresh()
|
||||||
|
|
||||||
if len(menu_state.menu_path) < 2:
|
# if len(menu_state.menu_path) < 2:
|
||||||
modified_settings.clear()
|
# modified_settings.clear()
|
||||||
|
|
||||||
# Navigate back to the previous menu
|
# Navigate back to the previous menu
|
||||||
if len(menu_state.menu_path) > 1:
|
if len(menu_state.menu_path) > 1:
|
||||||
@@ -515,6 +563,16 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
break
|
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:
|
def set_region(interface: object) -> None:
|
||||||
node = interface.getNode("^local")
|
node = interface.getNode("^local")
|
||||||
device_config = node.localConfig
|
device_config = node.localConfig
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
from contact.ui.colors import setup_colors
|
||||||
|
|
||||||
# Get the parent directory of the script
|
# Get the parent directory of the script
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
@@ -13,6 +14,12 @@ parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
|||||||
# parent_dir = "/tmp/test_nonwritable"
|
# parent_dir = "/tmp/test_nonwritable"
|
||||||
|
|
||||||
|
|
||||||
|
def reload_config() -> None:
|
||||||
|
loaded_config = initialize_config()
|
||||||
|
assign_config_variables(loaded_config)
|
||||||
|
setup_colors(reinit=True)
|
||||||
|
|
||||||
|
|
||||||
def _is_writable_dir(path: str) -> bool:
|
def _is_writable_dir(path: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Return True if we can create & delete a temp file in `path`.
|
Return True if we can create & delete a temp file in `path`.
|
||||||
@@ -57,6 +64,7 @@ config_root = _get_config_root(parent_dir)
|
|||||||
json_file_path = os.path.join(config_root, "config.json")
|
json_file_path = os.path.join(config_root, "config.json")
|
||||||
log_file_path = os.path.join(config_root, "client.log")
|
log_file_path = os.path.join(config_root, "client.log")
|
||||||
db_file_path = os.path.join(config_root, "client.db")
|
db_file_path = os.path.join(config_root, "client.db")
|
||||||
|
node_configs_file_path = os.path.join(config_root, "node-configs/")
|
||||||
|
|
||||||
|
|
||||||
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
|
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
|
||||||
@@ -177,6 +185,7 @@ def initialize_config() -> Dict[str, object]:
|
|||||||
"node_list_16ths": "5",
|
"node_list_16ths": "5",
|
||||||
"db_file_path": db_file_path,
|
"db_file_path": db_file_path,
|
||||||
"log_file_path": log_file_path,
|
"log_file_path": log_file_path,
|
||||||
|
"node_configs_file_path": node_configs_file_path,
|
||||||
"message_prefix": ">>",
|
"message_prefix": ">>",
|
||||||
"sent_message_prefix": ">> Sent",
|
"sent_message_prefix": ">> Sent",
|
||||||
"notification_symbol": "*",
|
"notification_symbol": "*",
|
||||||
@@ -217,7 +226,7 @@ def initialize_config() -> Dict[str, object]:
|
|||||||
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
||||||
# Assign values to local variables
|
# Assign values to local variables
|
||||||
|
|
||||||
global db_file_path, log_file_path, message_prefix, sent_message_prefix
|
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 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
|
||||||
global theme, COLOR_CONFIG
|
global theme, COLOR_CONFIG
|
||||||
@@ -227,6 +236,7 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
|||||||
node_list_16ths = loaded_config["node_list_16ths"]
|
node_list_16ths = loaded_config["node_list_16ths"]
|
||||||
db_file_path = loaded_config["db_file_path"]
|
db_file_path = loaded_config["db_file_path"]
|
||||||
log_file_path = loaded_config["log_file_path"]
|
log_file_path = loaded_config["log_file_path"]
|
||||||
|
node_configs_file_path = loaded_config.get("node_configs_file_path")
|
||||||
message_prefix = loaded_config["message_prefix"]
|
message_prefix = loaded_config["message_prefix"]
|
||||||
sent_message_prefix = loaded_config["sent_message_prefix"]
|
sent_message_prefix = loaded_config["sent_message_prefix"]
|
||||||
notification_symbol = loaded_config["notification_symbol"]
|
notification_symbol = loaded_config["notification_symbol"]
|
||||||
@@ -258,6 +268,7 @@ if __name__ == "__main__":
|
|||||||
print("\nLoaded Configuration:")
|
print("\nLoaded Configuration:")
|
||||||
print(f"Database File Path: {db_file_path}")
|
print(f"Database File Path: {db_file_path}")
|
||||||
print(f"Log File Path: {log_file_path}")
|
print(f"Log File Path: {log_file_path}")
|
||||||
|
print(f"Configs File Path: {node_configs_file_path}")
|
||||||
print(f"Message Prefix: {message_prefix}")
|
print(f"Message Prefix: {message_prefix}")
|
||||||
print(f"Sent Message Prefix: {sent_message_prefix}")
|
print(f"Sent Message Prefix: {sent_message_prefix}")
|
||||||
print(f"Notification Symbol: {notification_symbol}")
|
print(f"Notification Symbol: {notification_symbol}")
|
||||||
|
|||||||
@@ -1,44 +1,63 @@
|
|||||||
import curses
|
import curses
|
||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
|
from contact.utilities.singleton import menu_state, ui_state
|
||||||
|
|
||||||
|
|
||||||
def dialog(stdscr: curses.window, title: str, message: str) -> None:
|
def dialog(title: str, message: str) -> None:
|
||||||
height, width = stdscr.getmaxyx()
|
"""Display a dialog with a title and message."""
|
||||||
|
|
||||||
# Calculate dialog dimensions
|
previous_window = ui_state.current_window
|
||||||
max_line_lengh = 0
|
ui_state.current_window = 4
|
||||||
|
|
||||||
|
curses.update_lines_cols()
|
||||||
|
height, width = curses.LINES, curses.COLS
|
||||||
|
|
||||||
|
# Parse message into lines and calculate dimensions
|
||||||
message_lines = message.splitlines()
|
message_lines = message.splitlines()
|
||||||
for l in message_lines:
|
max_line_length = max(len(l) for l in message_lines)
|
||||||
max_line_length = max(len(l), max_line_lengh)
|
|
||||||
dialog_width = max(len(title) + 4, max_line_length + 4)
|
dialog_width = max(len(title) + 4, max_line_length + 4)
|
||||||
dialog_height = len(message_lines) + 4
|
dialog_height = len(message_lines) + 4
|
||||||
x = (width - dialog_width) // 2
|
x = (width - dialog_width) // 2
|
||||||
y = (height - dialog_height) // 2
|
y = (height - dialog_height) // 2
|
||||||
|
|
||||||
# Create dialog window
|
def draw_window():
|
||||||
|
win.erase()
|
||||||
|
win.bkgd(get_color("background"))
|
||||||
|
win.attrset(get_color("window_frame"))
|
||||||
|
win.border(0)
|
||||||
|
|
||||||
|
win.addstr(0, 2, title, get_color("settings_default"))
|
||||||
|
|
||||||
|
for i, line in enumerate(message_lines):
|
||||||
|
msg_x = (dialog_width - len(line)) // 2
|
||||||
|
win.addstr(2 + i, msg_x, line, get_color("settings_default"))
|
||||||
|
|
||||||
|
ok_text = " Ok "
|
||||||
|
win.addstr(
|
||||||
|
dialog_height - 2,
|
||||||
|
(dialog_width - len(ok_text)) // 2,
|
||||||
|
ok_text,
|
||||||
|
get_color("settings_default", reverse=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
win.refresh()
|
||||||
|
|
||||||
win = curses.newwin(dialog_height, dialog_width, y, x)
|
win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||||
win.bkgd(get_color("background"))
|
draw_window()
|
||||||
win.attrset(get_color("window_frame"))
|
|
||||||
win.border(0)
|
|
||||||
|
|
||||||
# Add title
|
|
||||||
win.addstr(0, 2, title, get_color("settings_default"))
|
|
||||||
|
|
||||||
# Add message
|
|
||||||
for i, l in enumerate(message_lines):
|
|
||||||
win.addstr(2 + i, 2, l, get_color("settings_default"))
|
|
||||||
|
|
||||||
# Add button
|
|
||||||
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", get_color("settings_default", reverse=True))
|
|
||||||
|
|
||||||
# Refresh dialog window
|
|
||||||
win.refresh()
|
|
||||||
|
|
||||||
# Get user input
|
|
||||||
while True:
|
while True:
|
||||||
|
win.timeout(200)
|
||||||
char = win.getch()
|
char = win.getch()
|
||||||
# Close dialog with enter, space, or esc
|
|
||||||
if char in (curses.KEY_ENTER, 10, 13, 32, 27):
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
draw_window()
|
||||||
|
|
||||||
|
if char in (curses.KEY_ENTER, 10, 13, 32, 27): # Enter, space, Esc
|
||||||
win.erase()
|
win.erase()
|
||||||
win.refresh()
|
win.refresh()
|
||||||
|
ui_state.current_window = previous_window
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if char == -1:
|
||||||
|
continue
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class MenuState:
|
|||||||
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict)
|
current_menu: Union[Dict[str, Any], List[Any], str, int] = field(default_factory=dict)
|
||||||
menu_path: List[str] = field(default_factory=list)
|
menu_path: List[str] = field(default_factory=list)
|
||||||
show_save_option: bool = False
|
show_save_option: bool = False
|
||||||
|
need_redraw: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -24,6 +25,7 @@ class ChatUIState:
|
|||||||
selected_message: int = 0
|
selected_message: int = 0
|
||||||
selected_node: int = 0
|
selected_node: int = 0
|
||||||
current_window: int = 0
|
current_window: int = 0
|
||||||
|
last_sent_time: float = 0.0
|
||||||
|
|
||||||
selected_index: int = 0
|
selected_index: int = 0
|
||||||
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import curses
|
|||||||
from typing import Any, List, Dict
|
from typing import Any, List, Dict
|
||||||
|
|
||||||
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
|
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
|
||||||
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
|
import contact.ui.default_config as config
|
||||||
from contact.ui.nav_utils import move_highlight, draw_arrows
|
from contact.ui.nav_utils import move_highlight, draw_arrows
|
||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
|
from contact.utilities.singleton import menu_state
|
||||||
|
|
||||||
|
|
||||||
width = 80
|
width = 80
|
||||||
@@ -53,7 +54,9 @@ def edit_value(key: str, current_value: str) -> str:
|
|||||||
# Handle theme selection dynamically
|
# Handle theme selection dynamically
|
||||||
if key == "theme":
|
if key == "theme":
|
||||||
# Load theme names dynamically from the JSON
|
# Load theme names dynamically from the JSON
|
||||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
theme_options = [
|
||||||
|
k.split("_", 2)[2].lower() for k in config.loaded_config.keys() if k.startswith("COLOR_CONFIG")
|
||||||
|
]
|
||||||
return get_list_input("Select Theme", current_value, theme_options)
|
return get_list_input("Select Theme", current_value, theme_options)
|
||||||
|
|
||||||
elif key == "node_sort":
|
elif key == "node_sort":
|
||||||
@@ -72,41 +75,64 @@ def edit_value(key: str, current_value: str) -> str:
|
|||||||
user_input = ""
|
user_input = ""
|
||||||
input_position = (7, 13) # Tuple for row and column
|
input_position = (7, 13) # Tuple for row and column
|
||||||
row, col = input_position # Unpack tuple
|
row, col = input_position # Unpack tuple
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
visible_text = user_input[scroll_offset : scroll_offset + input_width] # Only show what fits
|
if menu_state.need_redraw:
|
||||||
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
|
curses.update_lines_cols()
|
||||||
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
|
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.timeout(200)
|
||||||
|
edit_win.bkgd(get_color("background"))
|
||||||
|
edit_win.attrset(get_color("window_frame"))
|
||||||
|
edit_win.border()
|
||||||
|
|
||||||
|
# Redraw static content
|
||||||
|
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"))
|
||||||
|
for i, line in enumerate(wrapped_lines[:4]):
|
||||||
|
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
|
||||||
|
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||||
|
|
||||||
|
visible_text = user_input[scroll_offset : scroll_offset + input_width]
|
||||||
|
edit_win.addstr(row, col, " " * input_width, get_color("settings_default"))
|
||||||
|
edit_win.addstr(row, col, visible_text, get_color("settings_default"))
|
||||||
edit_win.refresh()
|
edit_win.refresh()
|
||||||
|
|
||||||
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
|
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width))
|
||||||
key = edit_win.get_wch()
|
|
||||||
|
|
||||||
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
|
try:
|
||||||
|
key = edit_win.get_wch()
|
||||||
|
except curses.error:
|
||||||
|
continue # window not ready — skip this loop
|
||||||
|
|
||||||
|
if key in (chr(27), curses.KEY_LEFT):
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return current_value # Exit without returning a value
|
return current_value
|
||||||
|
|
||||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||||
break
|
break
|
||||||
|
|
||||||
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
|
elif key in (curses.KEY_BACKSPACE, chr(127)):
|
||||||
if user_input: # Only process if there's something to delete
|
if user_input:
|
||||||
user_input = user_input[:-1]
|
user_input = user_input[:-1]
|
||||||
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
|
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
|
||||||
scroll_offset -= 1 # Move back if text is shorter than scrolled area
|
scroll_offset -= 1
|
||||||
else:
|
else:
|
||||||
if isinstance(key, str):
|
if isinstance(key, str):
|
||||||
user_input += key
|
user_input += key
|
||||||
else:
|
else:
|
||||||
user_input += chr(key)
|
user_input += chr(key)
|
||||||
|
|
||||||
if len(user_input) > input_width: # Scroll if input exceeds visible area
|
if len(user_input) > input_width:
|
||||||
scroll_offset += 1
|
scroll_offset += 1
|
||||||
|
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return user_input if user_input else current_value
|
return user_input if user_input else current_value
|
||||||
|
|
||||||
|
|
||||||
def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
|
def display_menu() -> tuple[Any, Any, List[str]]:
|
||||||
"""
|
"""
|
||||||
Render the configuration menu with a Save button directly added to the window.
|
Render the configuration menu with a Save button directly added to the window.
|
||||||
"""
|
"""
|
||||||
@@ -189,6 +215,7 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
|
|||||||
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
||||||
|
|
||||||
menu_state.selected_index = 0 # Track the selected option
|
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__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||||
@@ -211,16 +238,18 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
menu_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
|
# Render the menu
|
||||||
menu_win, menu_pad, options = display_menu(menu_state)
|
menu_win, menu_pad, options = display_menu()
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if need_redraw:
|
if menu_state.need_redraw:
|
||||||
menu_win, menu_pad, options = display_menu(menu_state)
|
menu_state.need_redraw = False
|
||||||
|
menu_win, menu_pad, options = display_menu()
|
||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
need_redraw = False
|
|
||||||
|
|
||||||
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
max_index = len(options) + (1 if menu_state.show_save_option else 0) - 1
|
||||||
|
|
||||||
|
menu_win.timeout(200)
|
||||||
key = menu_win.getch()
|
key = menu_win.getch()
|
||||||
|
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
@@ -248,7 +277,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
|
|
||||||
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
|
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
|
||||||
|
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
menu_win.erase()
|
menu_win.erase()
|
||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
|
|
||||||
@@ -269,11 +298,14 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
|
|
||||||
if isinstance(selected_data, list) and len(selected_data) == 2:
|
if isinstance(selected_data, list) and len(selected_data) == 2:
|
||||||
# Edit color pair
|
# Edit color pair
|
||||||
|
old = selected_data
|
||||||
new_value = edit_color_pair(selected_key, selected_data)
|
new_value = edit_color_pair(selected_key, selected_data)
|
||||||
menu_state.menu_path.pop()
|
menu_state.menu_path.pop()
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
menu_state.menu_index.pop()
|
menu_state.menu_index.pop()
|
||||||
menu_state.current_menu[selected_key] = new_value
|
menu_state.current_menu[selected_key] = new_value
|
||||||
|
if new_value != old:
|
||||||
|
made_changes = True
|
||||||
|
|
||||||
elif isinstance(selected_data, (dict, list)):
|
elif isinstance(selected_data, (dict, list)):
|
||||||
# Navigate into nested data
|
# Navigate into nested data
|
||||||
@@ -282,22 +314,26 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# General value editing
|
# General value editing
|
||||||
|
old = selected_data
|
||||||
new_value = edit_value(selected_key, selected_data)
|
new_value = edit_value(selected_key, selected_data)
|
||||||
menu_state.menu_path.pop()
|
menu_state.menu_path.pop()
|
||||||
menu_state.menu_index.pop()
|
menu_state.menu_index.pop()
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
menu_state.current_menu[selected_key] = new_value
|
menu_state.current_menu[selected_key] = new_value
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
|
if new_value != old:
|
||||||
|
made_changes = True
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Save button selected
|
# Save button selected
|
||||||
save_json(file_path, data)
|
save_json(file_path, data)
|
||||||
stdscr.refresh()
|
stdscr.refresh()
|
||||||
|
# config.reload() # This isn't refreshing the file paths as expected
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
|
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
|
||||||
|
|
||||||
need_redraw = True
|
menu_state.need_redraw = True
|
||||||
menu_win.erase()
|
menu_win.erase()
|
||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
|
|
||||||
@@ -318,6 +354,19 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
# Exit the editor
|
# 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.clear()
|
||||||
menu_win.refresh()
|
menu_win.refresh()
|
||||||
|
|
||||||
@@ -325,7 +374,7 @@ def json_editor(stdscr: curses.window, menu_state: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
||||||
formatted_json = format_json_single_line_arrays(data)
|
formatted_json = config.format_json_single_line_arrays(data)
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(formatted_json)
|
f.write(formatted_json)
|
||||||
setup_colors(reinit=True)
|
setup_colors(reinit=True)
|
||||||
@@ -334,7 +383,6 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
|||||||
def main(stdscr: curses.window) -> None:
|
def main(stdscr: curses.window) -> None:
|
||||||
from contact.ui.ui_state import MenuState
|
from contact.ui.ui_state import MenuState
|
||||||
|
|
||||||
menu_state = MenuState()
|
|
||||||
if len(menu_state.menu_path) == 0:
|
if len(menu_state.menu_path) == 0:
|
||||||
menu_state.menu_path = ["App Settings"] # Initialize if not set
|
menu_state.menu_path = ["App Settings"] # Initialize if not set
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,44 @@ from typing import Any, Optional, List
|
|||||||
|
|
||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||||
|
from contact.ui.dialog import dialog
|
||||||
|
from contact.utilities.validation_rules import get_validation_for
|
||||||
|
from contact.utilities.singleton import menu_state
|
||||||
|
|
||||||
|
|
||||||
def get_text_input(prompt: str) -> Optional[str]:
|
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None:
|
||||||
|
"""Displays an invalid input message in the given window and redraws if needed."""
|
||||||
|
cursor_y, cursor_x = window.getyx()
|
||||||
|
curses.curs_set(0)
|
||||||
|
dialog("Invalid Input", message)
|
||||||
|
if redraw_func:
|
||||||
|
redraw_func() # Redraw the original window content that got obscured
|
||||||
|
else:
|
||||||
|
window.refresh()
|
||||||
|
window.move(cursor_y, cursor_x)
|
||||||
|
curses.curs_set(1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
|
||||||
"""Handles user input with wrapped text for long prompts."""
|
"""Handles user input with wrapped text for long prompts."""
|
||||||
|
|
||||||
|
def redraw_input_win():
|
||||||
|
"""Redraw the input window with the current prompt and user input."""
|
||||||
|
input_win.erase()
|
||||||
|
input_win.border()
|
||||||
|
row = 1
|
||||||
|
for line in wrapped_prompt:
|
||||||
|
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||||
|
row += 1
|
||||||
|
if row >= height - 3:
|
||||||
|
break
|
||||||
|
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||||
|
input_win.addstr(row + 1, col_start, user_input[:first_line_width], get_color("settings_default"))
|
||||||
|
for i, line in enumerate(wrap_text(user_input[first_line_width:], wrap_width=input_width)):
|
||||||
|
if row + 2 + i < height - 1:
|
||||||
|
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
|
||||||
|
input_win.refresh()
|
||||||
|
|
||||||
height = 8
|
height = 8
|
||||||
width = 80
|
width = 80
|
||||||
margin = 2 # Left and right margin
|
margin = 2 # Left and right margin
|
||||||
@@ -20,6 +54,7 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
start_x = (curses.COLS - width) // 2
|
start_x = (curses.COLS - width) // 2
|
||||||
|
|
||||||
input_win = curses.newwin(height, width, start_y, start_x)
|
input_win = curses.newwin(height, width, start_y, start_x)
|
||||||
|
input_win.timeout(200)
|
||||||
input_win.bkgd(get_color("background"))
|
input_win.bkgd(get_color("background"))
|
||||||
input_win.attrset(get_color("window_frame"))
|
input_win.attrset(get_color("window_frame"))
|
||||||
input_win.border()
|
input_win.border()
|
||||||
@@ -27,6 +62,7 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
# Wrap the prompt text
|
# Wrap the prompt text
|
||||||
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
||||||
row = 1
|
row = 1
|
||||||
|
|
||||||
for line in wrapped_prompt:
|
for line in wrapped_prompt:
|
||||||
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||||
row += 1
|
row += 1
|
||||||
@@ -39,34 +75,125 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
|
|
||||||
max_length = 4 if "shortName" in prompt else None
|
min_value = 0
|
||||||
user_input = ""
|
max_value = 4294967295
|
||||||
|
min_length = 0
|
||||||
|
max_length = None
|
||||||
|
|
||||||
# Start user input after the prompt text
|
if selected_config is not None:
|
||||||
|
validation = get_validation_for(selected_config) or {}
|
||||||
|
min_value = validation.get("min_value", 0)
|
||||||
|
max_value = validation.get("max_value", 4294967295)
|
||||||
|
min_length = validation.get("min_length", 0)
|
||||||
|
max_length = validation.get("max_length")
|
||||||
|
|
||||||
|
user_input = ""
|
||||||
col_start = margin + len(prompt_text)
|
col_start = margin + len(prompt_text)
|
||||||
first_line_width = input_width - len(prompt_text) # Available space for first line
|
first_line_width = input_width - len(prompt_text)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
key = input_win.get_wch() # Waits for user input
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
redraw_input_win()
|
||||||
|
|
||||||
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
|
try:
|
||||||
|
key = input_win.get_wch()
|
||||||
|
except curses.error:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == chr(27) or key == curses.KEY_LEFT:
|
||||||
input_win.erase()
|
input_win.erase()
|
||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return None # Exit without saving
|
menu_state.need_redraw = True
|
||||||
|
return None
|
||||||
|
|
||||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key
|
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||||
break
|
menu_state.need_redraw = True
|
||||||
|
|
||||||
|
if not user_input.strip():
|
||||||
|
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
|
||||||
|
length = len(user_input)
|
||||||
|
if min_length == max_length and max_length is not None:
|
||||||
|
if length != min_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if length < min_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Value must be at least {min_length} characters long.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if max_length is not None and length > max_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Value must be no more than {max_length} characters long.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if input_type is int:
|
||||||
|
if not user_input.isdigit():
|
||||||
|
invalid_input(input_win, "Only numeric digits (0–9) allowed.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
|
||||||
|
int_val = int(user_input)
|
||||||
|
if not (min_value <= int_val <= max_value):
|
||||||
|
invalid_input(
|
||||||
|
input_win, f"Enter a number between {min_value} and {max_value}.", redraw_func=redraw_input_win
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
curses.curs_set(0)
|
||||||
|
return int_val
|
||||||
|
|
||||||
|
elif input_type is float:
|
||||||
|
try:
|
||||||
|
float_val = float(user_input)
|
||||||
|
if not (min_value <= float_val <= max_value):
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Enter a number between {min_value} and {max_value}.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
invalid_input(input_win, "Must be a valid floating point number.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
curses.curs_set(0)
|
||||||
|
return float_val
|
||||||
|
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
|
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
|
||||||
if user_input:
|
if user_input:
|
||||||
user_input = user_input[:-1] # Remove last character
|
user_input = user_input[:-1] # Remove last character
|
||||||
|
|
||||||
elif max_length is None or len(user_input) < max_length: # Enforce max length
|
elif max_length is None or len(user_input) < max_length:
|
||||||
if isinstance(key, str):
|
try:
|
||||||
user_input += key
|
char = chr(key) if not isinstance(key, str) else key
|
||||||
else:
|
if input_type is int:
|
||||||
user_input += chr(key)
|
if char.isdigit() or (char == "-" and len(user_input) == 0):
|
||||||
|
user_input += char
|
||||||
|
elif input_type is float:
|
||||||
|
if (
|
||||||
|
char.isdigit()
|
||||||
|
or (char == "." and "." not in user_input)
|
||||||
|
or (char == "-" and len(user_input) == 0)
|
||||||
|
):
|
||||||
|
user_input += char
|
||||||
|
else:
|
||||||
|
user_input += char
|
||||||
|
except ValueError:
|
||||||
|
pass # Ignore invalid input
|
||||||
|
|
||||||
# First line must be manually handled before using wrap_text()
|
# First line must be manually handled before using wrap_text()
|
||||||
first_line = user_input[:first_line_width] # Cut to max first line width
|
first_line = user_input[:first_line_width] # Cut to max first line width
|
||||||
@@ -95,10 +222,12 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
input_win.erase()
|
input_win.erase()
|
||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
return user_input
|
return user_input.strip()
|
||||||
|
|
||||||
|
|
||||||
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||||
|
"""Handles user input for editing up to 3 Admin Keys in Base64 format."""
|
||||||
|
|
||||||
def to_base64(byte_strings):
|
def to_base64(byte_strings):
|
||||||
"""Convert byte values to Base64-encoded strings."""
|
"""Convert byte values to Base64-encoded strings."""
|
||||||
return [base64.b64encode(b).decode() for b in byte_strings]
|
return [base64.b64encode(b).decode() for b in byte_strings]
|
||||||
@@ -119,10 +248,11 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
start_y = (curses.LINES - height) // 2
|
start_y = (curses.LINES - height) // 2
|
||||||
start_x = (curses.COLS - width) // 2
|
start_x = (curses.COLS - width) // 2
|
||||||
|
|
||||||
repeated_win = curses.newwin(height, width, start_y, start_x)
|
admin_key_win = curses.newwin(height, width, start_y, start_x)
|
||||||
repeated_win.bkgd(get_color("background"))
|
admin_key_win.timeout(200)
|
||||||
repeated_win.attrset(get_color("window_frame"))
|
admin_key_win.bkgd(get_color("background"))
|
||||||
repeated_win.keypad(True) # Enable keypad for special keys
|
admin_key_win.attrset(get_color("window_frame"))
|
||||||
|
admin_key_win.keypad(True) # Enable keypad for special keys
|
||||||
|
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
@@ -130,46 +260,48 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
# Editable list of values (max 3 values)
|
# Editable list of values (max 3 values)
|
||||||
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
|
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
|
||||||
cursor_pos = 0 # Track which value is being edited
|
cursor_pos = 0 # Track which value is being edited
|
||||||
error_message = ""
|
invalid_input = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
repeated_win.erase()
|
admin_key_win.erase()
|
||||||
repeated_win.border()
|
admin_key_win.border()
|
||||||
repeated_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
admin_key_win.addstr(1, 2, "Edit up to 3 Admin Keys:", get_color("settings_default", bold=True))
|
||||||
|
|
||||||
# Display current values, allowing editing
|
# Display current values, allowing editing
|
||||||
for i, line in enumerate(user_values):
|
for i, line in enumerate(user_values):
|
||||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
||||||
repeated_win.addstr(
|
admin_key_win.addstr(
|
||||||
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
3 + i, 2, f"{prefix}Admin Key {i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||||
)
|
)
|
||||||
repeated_win.addstr(3 + i, 18, line) # Align text for easier editing
|
admin_key_win.addstr(3 + i, 18, line) # Align text for easier editing
|
||||||
|
|
||||||
# Move cursor to the correct position inside the field
|
# Move cursor to the correct position inside the field
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
admin_key_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||||
|
|
||||||
# Show error message if needed
|
# Show error message if needed
|
||||||
if error_message:
|
if invalid_input:
|
||||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
admin_key_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
|
||||||
|
|
||||||
repeated_win.refresh()
|
admin_key_win.refresh()
|
||||||
key = repeated_win.getch()
|
key = admin_key_win.getch()
|
||||||
|
|
||||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
||||||
repeated_win.erase()
|
admin_key_win.erase()
|
||||||
repeated_win.refresh()
|
admin_key_win.refresh()
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
|
menu_state.need_redraw = True
|
||||||
return None
|
return None
|
||||||
|
|
||||||
elif key == ord("\n"): # Enter key to save and return
|
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
|
if all(is_valid_base64(val) for val in user_values): # Ensure all values are valid Base64 and 32 bytes
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return user_values # Return the edited Base64 values
|
return user_values # Return the edited Base64 values
|
||||||
else:
|
else:
|
||||||
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
|
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||||
elif key == curses.KEY_UP: # Move cursor up
|
elif key == curses.KEY_UP: # Move cursor up
|
||||||
cursor_pos = (cursor_pos - 1) % len(user_values)
|
cursor_pos = (cursor_pos - 1) % len(user_values)
|
||||||
elif key == curses.KEY_DOWN: # Move cursor down
|
elif key == curses.KEY_DOWN: # Move cursor down
|
||||||
@@ -180,11 +312,14 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||||
error_message = "" # Clear error if user starts fixing input
|
invalid_input = "" # Clear error if user starts fixing input
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Ignore invalid character inputs
|
pass # Ignore invalid character inputs
|
||||||
|
|
||||||
|
|
||||||
|
from contact.utilities.singleton import menu_state # Required if not already imported
|
||||||
|
|
||||||
|
|
||||||
def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
||||||
height = 9
|
height = 9
|
||||||
width = 80
|
width = 80
|
||||||
@@ -192,71 +327,82 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
|||||||
start_x = (curses.COLS - width) // 2
|
start_x = (curses.COLS - width) // 2
|
||||||
|
|
||||||
repeated_win = curses.newwin(height, width, start_y, start_x)
|
repeated_win = curses.newwin(height, width, start_y, start_x)
|
||||||
|
repeated_win.timeout(200)
|
||||||
repeated_win.bkgd(get_color("background"))
|
repeated_win.bkgd(get_color("background"))
|
||||||
repeated_win.attrset(get_color("window_frame"))
|
repeated_win.attrset(get_color("window_frame"))
|
||||||
repeated_win.keypad(True) # Enable keypad for special keys
|
repeated_win.keypad(True)
|
||||||
|
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.curs_set(1) # Show the cursor
|
curses.curs_set(1)
|
||||||
|
|
||||||
# Editable list of values (max 3 values)
|
user_values = current_value[:3] + [""] * (3 - len(current_value)) # Always 3 fields
|
||||||
user_values = current_value[:3]
|
cursor_pos = 0
|
||||||
cursor_pos = 0 # Track which value is being edited
|
invalid_input = ""
|
||||||
error_message = ""
|
|
||||||
|
|
||||||
while True:
|
def redraw():
|
||||||
repeated_win.erase()
|
repeated_win.erase()
|
||||||
repeated_win.border()
|
repeated_win.border()
|
||||||
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
|
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
|
||||||
|
|
||||||
# Display current values, allowing editing
|
|
||||||
for i, line in enumerate(user_values):
|
for i, line in enumerate(user_values):
|
||||||
prefix = "→ " if i == cursor_pos else " " # Highlight the current line
|
prefix = "→ " if i == cursor_pos else " "
|
||||||
repeated_win.addstr(
|
repeated_win.addstr(
|
||||||
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
|
||||||
)
|
)
|
||||||
repeated_win.addstr(3 + i, 18, line)
|
repeated_win.addstr(3 + i, 18, line[: width - 20]) # Prevent overflow
|
||||||
|
|
||||||
# Move cursor to the correct position inside the field
|
if invalid_input:
|
||||||
curses.curs_set(1)
|
repeated_win.addstr(7, 2, invalid_input[: width - 4], get_color("settings_default", bold=True))
|
||||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
|
||||||
|
|
||||||
# Show error message if needed
|
|
||||||
if error_message:
|
|
||||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
|
||||||
|
|
||||||
|
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos]))
|
||||||
repeated_win.refresh()
|
repeated_win.refresh()
|
||||||
key = repeated_win.getch()
|
|
||||||
|
|
||||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow -> Cancel and return original
|
while True:
|
||||||
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
redraw()
|
||||||
|
|
||||||
|
redraw()
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = repeated_win.get_wch()
|
||||||
|
except curses.error:
|
||||||
|
continue # ignore timeout or input issues
|
||||||
|
|
||||||
|
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow
|
||||||
repeated_win.erase()
|
repeated_win.erase()
|
||||||
repeated_win.refresh()
|
repeated_win.refresh()
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
|
menu_state.need_redraw = True
|
||||||
return None
|
return None
|
||||||
|
elif key in ("\n", curses.KEY_ENTER):
|
||||||
elif key == ord("\n"): # Enter key to save and return
|
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return ", ".join(user_values)
|
menu_state.need_redraw = True
|
||||||
elif key == curses.KEY_UP: # Move cursor up
|
return ", ".join(user_values).strip()
|
||||||
cursor_pos = (cursor_pos - 1) % len(user_values)
|
elif key == curses.KEY_UP:
|
||||||
elif key == curses.KEY_DOWN: # Move cursor down
|
cursor_pos = (cursor_pos - 1) % 3
|
||||||
cursor_pos = (cursor_pos + 1) % len(user_values)
|
elif key == curses.KEY_DOWN:
|
||||||
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
|
cursor_pos = (cursor_pos + 1) % 3
|
||||||
if len(user_values[cursor_pos]) > 0:
|
elif key in (curses.KEY_BACKSPACE, 127):
|
||||||
user_values[cursor_pos] = user_values[cursor_pos][:-1] # Remove last character
|
user_values[cursor_pos] = user_values[cursor_pos][:-1]
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
ch = chr(key) if isinstance(key, int) else key
|
||||||
error_message = "" # Clear error if user starts fixing input
|
if ch.isprintable():
|
||||||
except ValueError:
|
user_values[cursor_pos] += ch
|
||||||
pass # Ignore invalid character inputs
|
invalid_input = ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
from contact.utilities.singleton import menu_state # Ensure this is imported
|
||||||
|
|
||||||
|
|
||||||
def get_fixed32_input(current_value: int) -> int:
|
def get_fixed32_input(current_value: int) -> int:
|
||||||
cvalue = current_value
|
original_value = current_value
|
||||||
current_value = str(ipaddress.IPv4Address(current_value))
|
ip_string = str(ipaddress.IPv4Address(current_value))
|
||||||
height = 10
|
height = 10
|
||||||
width = 80
|
width = 80
|
||||||
start_y = (curses.LINES - height) // 2
|
start_y = (curses.LINES - height) // 2
|
||||||
@@ -266,54 +412,73 @@ def get_fixed32_input(current_value: int) -> int:
|
|||||||
fixed32_win.bkgd(get_color("background"))
|
fixed32_win.bkgd(get_color("background"))
|
||||||
fixed32_win.attrset(get_color("window_frame"))
|
fixed32_win.attrset(get_color("window_frame"))
|
||||||
fixed32_win.keypad(True)
|
fixed32_win.keypad(True)
|
||||||
|
fixed32_win.timeout(200)
|
||||||
|
|
||||||
curses.echo()
|
curses.echo()
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
user_input = ""
|
user_input = ""
|
||||||
|
|
||||||
while True:
|
def redraw():
|
||||||
fixed32_win.erase()
|
fixed32_win.erase()
|
||||||
fixed32_win.border()
|
fixed32_win.border()
|
||||||
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
|
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", get_color("settings_default", bold=True))
|
||||||
fixed32_win.addstr(3, 2, f"Current: {current_value}")
|
fixed32_win.addstr(3, 2, f"Current: {ip_string}", get_color("settings_default"))
|
||||||
fixed32_win.addstr(5, 2, f"New value: {user_input}")
|
fixed32_win.addstr(5, 2, f"New value: {user_input}", get_color("settings_default"))
|
||||||
fixed32_win.refresh()
|
fixed32_win.refresh()
|
||||||
|
|
||||||
key = fixed32_win.getch()
|
while True:
|
||||||
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
redraw()
|
||||||
|
|
||||||
if key == 27 or key == curses.KEY_LEFT: # Escape or Left Arrow to cancel
|
redraw()
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = fixed32_win.get_wch()
|
||||||
|
except curses.error:
|
||||||
|
continue # ignore timeout
|
||||||
|
|
||||||
|
if key in (27, curses.KEY_LEFT): # ESC or Left Arrow to cancel
|
||||||
fixed32_win.erase()
|
fixed32_win.erase()
|
||||||
fixed32_win.refresh()
|
fixed32_win.refresh()
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return cvalue # Return the current value unchanged
|
menu_state.need_redraw = True
|
||||||
elif key == ord("\n"): # Enter key to validate and save
|
return original_value
|
||||||
# Validate IP address
|
|
||||||
|
elif key in ("\n", curses.KEY_ENTER):
|
||||||
octets = user_input.split(".")
|
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):
|
if len(octets) == 4 and all(octet.isdigit() and 0 <= int(octet) <= 255 for octet in octets):
|
||||||
curses.noecho()
|
curses.noecho()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
fixed32_address = ipaddress.ip_address(user_input)
|
return int(ipaddress.ip_address(user_input))
|
||||||
return int(fixed32_address) # Return the valid IP address
|
|
||||||
else:
|
else:
|
||||||
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", curses.A_BOLD | curses.color_pair(5))
|
fixed32_win.addstr(7, 2, "Invalid IP address. Try again.", get_color("settings_default", bold=True))
|
||||||
fixed32_win.refresh()
|
fixed32_win.refresh()
|
||||||
curses.napms(1500) # Wait for 1.5 seconds before refreshing
|
curses.napms(1500)
|
||||||
user_input = "" # Clear invalid input
|
user_input = ""
|
||||||
elif key == curses.KEY_BACKSPACE or key == 127: # Backspace key
|
|
||||||
|
elif key in (curses.KEY_BACKSPACE, 127):
|
||||||
user_input = user_input[:-1]
|
user_input = user_input[:-1]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
char = chr(key)
|
ch = chr(key) if isinstance(key, int) else key
|
||||||
if char.isdigit() or char == ".":
|
if ch.isdigit() or ch == ".":
|
||||||
user_input += char # Append only valid characters (digits or dots)
|
user_input += ch
|
||||||
except ValueError:
|
except Exception:
|
||||||
pass # Ignore invalid inputs
|
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]:
|
||||||
"""
|
"""
|
||||||
Displays a scrollable list of list_options for the user to choose from.
|
List selector.
|
||||||
"""
|
"""
|
||||||
selected_index = list_options.index(current_option) if current_option in list_options else 0
|
selected_index = list_options.index(current_option) if current_option in list_options else 0
|
||||||
|
|
||||||
@@ -323,6 +488,7 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: Lis
|
|||||||
start_x = (curses.COLS - width) // 2
|
start_x = (curses.COLS - width) // 2
|
||||||
|
|
||||||
list_win = curses.newwin(height, width, start_y, start_x)
|
list_win = curses.newwin(height, width, start_y, start_x)
|
||||||
|
list_win.timeout(200)
|
||||||
list_win.bkgd(get_color("background"))
|
list_win.bkgd(get_color("background"))
|
||||||
list_win.attrset(get_color("window_frame"))
|
list_win.attrset(get_color("window_frame"))
|
||||||
list_win.keypad(True)
|
list_win.keypad(True)
|
||||||
@@ -330,50 +496,62 @@ def get_list_input(prompt: str, current_option: Optional[str], list_options: Lis
|
|||||||
list_pad = curses.newpad(len(list_options) + 1, width - 8)
|
list_pad = curses.newpad(len(list_options) + 1, width - 8)
|
||||||
list_pad.bkgd(get_color("background"))
|
list_pad.bkgd(get_color("background"))
|
||||||
|
|
||||||
# Render header
|
|
||||||
list_win.erase()
|
|
||||||
list_win.border()
|
|
||||||
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
|
||||||
|
|
||||||
# Render options on the pad
|
|
||||||
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"))
|
|
||||||
|
|
||||||
# Initial refresh
|
|
||||||
list_win.refresh()
|
|
||||||
list_pad.refresh(
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
list_win.getbegyx()[0] + 3,
|
|
||||||
list_win.getbegyx()[1] + 4,
|
|
||||||
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2,
|
|
||||||
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4,
|
|
||||||
)
|
|
||||||
|
|
||||||
max_index = len(list_options) - 1
|
max_index = len(list_options) - 1
|
||||||
visible_height = list_win.getmaxyx()[0] - 5
|
visible_height = list_win.getmaxyx()[0] - 5
|
||||||
|
|
||||||
draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False) # Initial call to draw arrows
|
def redraw_list_ui():
|
||||||
|
list_win.erase()
|
||||||
|
list_win.border()
|
||||||
|
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
|
||||||
|
|
||||||
|
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(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
list_win.getbegyx()[0] + 3,
|
||||||
|
list_win.getbegyx()[1] + 4,
|
||||||
|
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
|
||||||
|
redraw_list_ui()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
key = list_win.getch()
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
redraw_list_ui()
|
||||||
|
|
||||||
|
try:
|
||||||
|
key = list_win.getch()
|
||||||
|
except curses.error:
|
||||||
|
continue
|
||||||
|
|
||||||
if key == curses.KEY_UP:
|
if key == curses.KEY_UP:
|
||||||
old_selected_index = selected_index
|
old_selected_index = selected_index
|
||||||
selected_index = max(0, selected_index - 1)
|
selected_index = max(0, selected_index - 1)
|
||||||
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
|
move_highlight(old_selected_index, list_options, list_win, list_pad, selected_index=selected_index)
|
||||||
|
|
||||||
elif key == curses.KEY_DOWN:
|
elif key == curses.KEY_DOWN:
|
||||||
old_selected_index = selected_index
|
old_selected_index = selected_index
|
||||||
selected_index = min(len(list_options) - 1, selected_index + 1)
|
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)
|
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.clear()
|
||||||
list_win.refresh()
|
list_win.refresh()
|
||||||
|
menu_state.need_redraw = True
|
||||||
return list_options[selected_index]
|
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.clear()
|
||||||
list_win.refresh()
|
list_win.refresh()
|
||||||
|
menu_state.need_redraw = True
|
||||||
return current_option
|
return current_option
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from contact.ui.ui_state import ChatUIState, InterfaceState, AppState
|
from contact.ui.ui_state import ChatUIState, InterfaceState, AppState, MenuState
|
||||||
|
|
||||||
ui_state = ChatUIState()
|
ui_state = ChatUIState()
|
||||||
interface_state = InterfaceState()
|
interface_state = InterfaceState()
|
||||||
app_state = AppState()
|
app_state = AppState()
|
||||||
|
menu_state = MenuState()
|
||||||
|
|||||||
23
contact/utilities/validation_rules.py
Normal file
23
contact/utilities/validation_rules.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
validation_rules = {
|
||||||
|
"shortName": {"max_length": 4},
|
||||||
|
"longName": {"max_length": 32},
|
||||||
|
"fixed_pin": {"min_length": 6, "max_length": 6},
|
||||||
|
"position_flags": {"max_length": 3},
|
||||||
|
"enabled_protocols": {"max_value": 2},
|
||||||
|
"hop_limit": {"max_value": 7},
|
||||||
|
"latitude": {"min_value": -90, "max_value": 90},
|
||||||
|
"longitude": {"min_value": -180, "max_value": 180},
|
||||||
|
"altitude": {"min_value": -4294967295, "max_value": 4294967295},
|
||||||
|
"red": {"max_value": 255},
|
||||||
|
"green": {"max_value": 255},
|
||||||
|
"blue": {"max_value": 255},
|
||||||
|
"current": {"max_value": 255},
|
||||||
|
"position_precision": {"max_value": 32},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_validation_for(key: str) -> dict:
|
||||||
|
for rule_key, config in validation_rules.items():
|
||||||
|
if rule_key in key:
|
||||||
|
return config
|
||||||
|
return {}
|
||||||
Reference in New Issue
Block a user