Compare commits

...

31 Commits

Author SHA1 Message Date
pdxlocations
852a912072 bump version 2025-01-28 18:09:18 -08:00
pdxlocations
af5fe53658 Add App Settings Menu (#89)
* init

* working changes

* working changes

* working changes

* working changes

* not working changes

* almost working changes

* working changes

* working changes

* broke save and nested menus

* working better

* changes

* working changes

* scrolling text input

* allow wide char input

* set pad bg colors

* add empty color for bg

* reload colors on save

* tab to save changes

* cleanup on isle edit_value

* dynamically create theme options
2025-01-28 16:52:20 -08:00
pdxlocations
f21269ba62 set a few pad bg colors 2025-01-28 13:18:46 -08:00
pdxlocations
3f94b9e276 Merge pull request #88 from rfschmid/fix-crash-in-node-details
Fix crash in get node details
2025-01-28 10:46:04 -08:00
Russell Schmidt
c865d6a942 Fix crash in get node details
The last heard timestamp apparently sometimes gets populated in the dict
as None, instead of just not being present, which caused a crash. Check
for None before tryint to use it.
2025-01-28 12:42:59 -06:00
pdxlocations
0bbabba77b Merge pull request #85 from rfschmid:add-node-details
Add node details
2025-01-27 16:28:54 -08:00
pdxlocations
bf43799a7d Merge pull request #86 from rfschmid/replace-clear-with-erase 2025-01-27 16:26:37 -08:00
pdxlocations
dd0ce4f098 Merge pull request #87 from rfschmid/fix-packet-log-disappearing 2025-01-27 16:24:31 -08:00
Russell Schmidt
5bd9b45753 Fix packet log disappearing 2025-01-27 17:16:56 -06:00
Russell Schmidt
4aaef5381e Replace window.clear() calls with window.erase()
https://lists.gnu.org/archive/html/bug-ncurses/2014-01/msg00007.html
2025-01-27 12:58:01 -06:00
Russell Schmidt
51dcfb5aa2 Add node details 2025-01-27 12:46:33 -06:00
pdxlocations
77b995f00f Merge pull request #81 from rfschmid/make-tab-jump-to-save-in-settings 2025-01-26 17:54:13 -08:00
pdxlocations
62cc2089db Merge pull request #82 from rfschmid:refresh-display-when-closing-dialogs
Refresh display when closing dialogs
2025-01-26 17:52:55 -08:00
pdxlocations
2eb8a17094 Merge pull request #80 from rfschmid/reduce-refresh-on-bool-input 2025-01-26 17:51:09 -08:00
pdxlocations
abe400648f Merge pull request #79 from rfschmid:fix-cancelling-input-crash
Fix crash on cancelling settings input
2025-01-26 17:49:29 -08:00
Russell Schmidt
22b2a9a50e Make refresh more efficient 2025-01-26 18:33:46 -06:00
Russell Schmidt
9a306f1553 Refresh display when closing dialogs 2025-01-26 18:12:10 -06:00
Russell Schmidt
92db3f4a30 Make tab jump to save in settings 2025-01-26 16:41:13 -06:00
Russell Schmidt
a32526e650 Reduce screen refresh on bool input 2025-01-26 16:29:48 -06:00
Russell Schmidt
1ebf1c4988 Fix crash on cancelling settings input
When backing out of entering user short name or long name, the app would
crash. Once it didn't crash, backing out would set these fields to None,
rather than cancelling the change.
2025-01-26 16:07:04 -06:00
pdxlocations
7901f00c49 Merge pull request #78 from rfschmid:fix-crash-on-long-enum-settings
Allow enum settings entry to scroll
2025-01-26 13:25:57 -08:00
Russell Schmidt
4ce279ab0d Allow enum settings entry to scroll 2025-01-26 15:01:38 -06:00
pdxlocations
e8e91f893e Merge pull request #76 from rfschmid/allow-settings-wraparound-scroll 2025-01-26 12:37:51 -08:00
pdxlocations
16c81f059d Merge pull request #75 from rfschmid:fix-settings-crash-when-height-smol
Enable scrolling settings options
2025-01-26 12:36:14 -08:00
pdxlocations
5588c6c6d9 Merge pull request #77 from rfschmid:remember-settings-menu-selected-idx
Remember settings menu stack selected index
2025-01-26 12:35:12 -08:00
Russell Schmidt
73111a46bb Fix exception adding last item in settings 2025-01-26 13:19:56 -06:00
Russell Schmidt
2d762515b4 Maybe fix crash with settings scroll 2025-01-26 11:22:59 -06:00
Russell Schmidt
6ce9707232 Remember settings menu stack selected index 2025-01-26 11:19:29 -06:00
Russell Schmidt
c33b903825 Allow settings wraparound scrolling 2025-01-26 11:12:38 -06:00
Russell Schmidt
c5327d8644 Enable scrolling settings options
Fixes crash in settings when the window height is too small to
accommodate the full list of options.
2025-01-26 10:33:56 -06:00
pdxlocations
ed1e9a3055 reorder config menu 2025-01-25 20:45:23 -08:00
10 changed files with 634 additions and 85 deletions

View File

@@ -142,27 +142,34 @@ def initialize_config():
return loaded_config
def assign_config_variables(loaded_config):
# Assign values to local variables
global db_file_path, log_file_path, message_prefix, sent_message_prefix
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
global theme, COLOR_CONFIG
db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"]
message_prefix = loaded_config["message_prefix"]
sent_message_prefix = loaded_config["sent_message_prefix"]
notification_symbol = loaded_config["notification_symbol"]
ack_implicit_str = loaded_config["ack_implicit_str"]
ack_str = loaded_config["ack_str"]
nak_str = loaded_config["nak_str"]
ack_unknown_str = loaded_config["ack_unknown_str"]
theme = loaded_config["theme"]
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
elif theme == "green":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
# Call the function when the script is imported
loaded_config = initialize_config()
# Assign values to local variables
db_file_path = loaded_config["db_file_path"]
log_file_path = loaded_config["log_file_path"]
message_prefix = loaded_config["message_prefix"]
sent_message_prefix = loaded_config["sent_message_prefix"]
notification_symbol = loaded_config["notification_symbol"]
ack_implicit_str = loaded_config["ack_implicit_str"]
ack_str = loaded_config["ack_str"]
nak_str = loaded_config["nak_str"]
ack_unknown_str = loaded_config["ack_unknown_str"]
theme = loaded_config["theme"]
if theme == "dark":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
elif theme == "light":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
elif theme == "green":
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
assign_config_variables(loaded_config)
if __name__ == "__main__":
logging.basicConfig(

View File

@@ -41,7 +41,7 @@ def get_user_input(prompt):
curses.curs_set(0)
# Clear the input window
input_win.clear()
input_win.erase()
input_win.refresh()
return user_input
@@ -60,25 +60,32 @@ def get_bool_selection(message, current_value):
bool_win.bkgd(get_color("background"))
bool_win.attrset(get_color("window_frame"))
bool_win.keypad(True)
bool_win.erase()
bool_win.border()
bool_win.addstr(1, 2, message, get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default", reverse=True))
else:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default"))
bool_win.refresh()
while True:
bool_win.clear()
bool_win.border()
bool_win.addstr(1, 2, message, get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default", reverse=True))
else:
bool_win.addstr(idx + 3, 4, option, get_color("settings_default"))
bool_win.refresh()
key = bool_win.getch()
if key == curses.KEY_UP:
selected_index = max(0, selected_index - 1)
if(selected_index > 0):
selected_index = selected_index - 1
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default"))
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default", reverse = True))
elif key == curses.KEY_DOWN:
selected_index = min(len(options) - 1, selected_index + 1)
if(selected_index < len(options) - 1):
selected_index = selected_index + 1
bool_win.chgat(0 + 3, 4, len(options[0]), get_color("settings_default"))
bool_win.chgat(1 + 3, 4, len(options[1]), get_color("settings_default", reverse = True))
elif key == ord('\n'): # Enter key
return options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
@@ -101,7 +108,7 @@ def get_repeated_input(current_value):
user_input = ""
while True:
repeated_win.clear()
repeated_win.erase()
repeated_win.border()
repeated_win.addstr(1, 2, "Enter comma-separated values:", get_color("settings_default", bold=True))
repeated_win.addstr(3, 2, f"Current: {', '.join(map(str, current_value))}", get_color("settings_default"))
@@ -126,6 +133,22 @@ def get_repeated_input(current_value):
except ValueError:
pass # Ignore invalid character inputs
def move_highlight(old_idx, new_idx, options, enum_win, enum_pad):
if old_idx == new_idx:
return # no-op
enum_pad.chgat(old_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default"))
enum_pad.chgat(new_idx, 0, enum_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
enum_win.refresh()
start_index = max(0, new_idx - (enum_win.getmaxyx()[0] - 4))
enum_win.refresh()
enum_pad.refresh(start_index, 0,
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + 4 + enum_win.getmaxyx()[1] - 4)
def get_enum_input(options, current_value):
selected_index = options.index(current_value) if current_value in options else 0
@@ -139,24 +162,35 @@ def get_enum_input(options, current_value):
enum_win.attrset(get_color("window_frame"))
enum_win.keypad(True)
enum_pad = curses.newpad(len(options) + 1, width - 8)
enum_pad.bkgd(get_color("background"))
enum_win.erase()
enum_win.border()
enum_win.addstr(1, 2, "Select an option:", get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default", reverse=True))
else:
enum_pad.addstr(idx, 0, option.ljust(width - 8), get_color("settings_default"))
enum_win.refresh()
enum_pad.refresh(0, 0,
enum_win.getbegyx()[0] + 2, enum_win.getbegyx()[1] + 4,
enum_win.getbegyx()[0] + enum_win.getmaxyx()[0] - 2, enum_win.getbegyx()[1] + enum_win.getmaxyx()[1] - 4)
while True:
enum_win.clear()
enum_win.border()
enum_win.addstr(1, 2, "Select an option:", get_color("settings_default", bold=True))
for idx, option in enumerate(options):
if idx == selected_index:
enum_win.addstr(idx + 2, 4, option, get_color("settings_default", reverse=True))
else:
enum_win.addstr(idx + 2, 4, option, get_color("settings_default"))
enum_win.refresh()
key = enum_win.getch()
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max(0, selected_index - 1)
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = min(len(options) - 1, selected_index + 1)
move_highlight(old_selected_index, selected_index, options, enum_win, enum_pad)
elif key == ord('\n'): # Enter key
return options[selected_index]
elif key == 27 or key == curses.KEY_LEFT: # ESC or Left Arrow
@@ -181,7 +215,7 @@ def get_fixed32_input(current_value):
user_input = ""
while True:
fixed32_win.clear()
fixed32_win.erase()
fixed32_win.border()
fixed32_win.addstr(1, 2, "Enter an IP address (xxx.xxx.xxx.xxx):", curses.A_BOLD)
fixed32_win.addstr(3, 2, f"Current: {current_value}")

View File

@@ -3,7 +3,7 @@
'''
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org
V 1.1.2
V 1.1.3
'''
import curses

View File

@@ -7,6 +7,7 @@ from input_handlers import get_bool_selection, get_repeated_input, get_user_inpu
from ui.colors import setup_colors, get_color
from utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface
from user_config import json_editor
import globals
width = 60
@@ -14,7 +15,7 @@ save_option = "Save Changes"
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
def display_menu(current_menu, menu_path, selected_index, show_save_option):
global menu_win
global menu_win, menu_pad
# Calculate the dynamic height based on the number of menu items
num_items = len(current_menu) + (1 if show_save_option else 0) # Add 1 for the "Save Changes" option if applicable
@@ -24,12 +25,15 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option):
# Create a new curses window with dynamic dimensions
menu_win = curses.newwin(height, width, start_y, start_x)
menu_win.clear()
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the current menu path as a header
header = " > ".join(word.title() for word in menu_path)
if len(header) > width - 4:
@@ -46,7 +50,7 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option):
try:
# Use red color for "Reboot" or "Shutdown"
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse = (idx == selected_index))
menu_win.addstr(idx + 3, 4, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error:
pass
@@ -56,6 +60,9 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option):
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse = (selected_index == len(current_menu))))
menu_win.refresh()
menu_pad.refresh(0, 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
def move_highlight(old_idx, new_idx, options, show_save_option, menu_win):
@@ -65,22 +72,28 @@ def move_highlight(old_idx, new_idx, options, show_save_option, menu_win):
max_index = len(options) + (1 if show_save_option else 0) - 1
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
menu_win.chgat(max_index + 4, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_win.chgat(old_idx + 3, 4, width - 8, get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
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"))
if show_save_option and new_idx == max_index: # special case highlight "Save" option
menu_win.chgat(max_index + 4, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse = True))
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse = True))
else:
menu_win.chgat(new_idx + 3, 4, width - 8, get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse = True))
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse = True))
menu_win.refresh()
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)) - (1 if show_save_option and new_idx == max_index else 0)) # Leave room for borders
menu_pad.refresh(start_index, 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8)
def settings_menu(stdscr, interface):
menu = generate_menu_from_protobuf(interface)
current_menu = menu["Main Menu"]
menu_path = ["Main Menu"]
menu_index = []
selected_index = 0
modified_settings = {}
@@ -107,24 +120,30 @@ def settings_menu(stdscr, interface):
# Capture user input
key = menu_win.getch()
max_index = len(options) + (1 if show_save_option else 0) - 1
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max(0, selected_index - 1)
selected_index = max_index if selected_index == 0 else selected_index - 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
max_index = len(options) + (1 if show_save_option else 0) - 1
selected_index = min(max_index, selected_index + 1)
selected_index = 0 if selected_index == max_index else selected_index + 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win)
elif key == ord("\t") and show_save_option:
old_selected_index = selected_index
selected_index = max_index
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win)
elif key == curses.KEY_RIGHT or key == ord('\n'):
need_redraw = True
menu_win.clear()
menu_win.erase()
menu_win.refresh()
if show_save_option and selected_index == len(options):
save_changes(interface, menu_path, modified_settings)
modified_settings.clear()
modified_settings.erase()
logging.info("Changes Saved")
if len(menu_path) > 1:
@@ -168,6 +187,13 @@ def settings_menu(stdscr, interface):
logging.info(f"Factory Reset Requested by menu")
break
continue
elif selected_option == "App Settings":
menu_win.clear()
menu_win.refresh()
json_editor(stdscr) # Open the App Settings menu
continue
# need_redraw = True
field_info = current_menu.get(selected_option)
if isinstance(field_info, tuple):
@@ -176,6 +202,7 @@ def settings_menu(stdscr, interface):
if selected_option in ['longName', 'shortName', 'isLicensed']:
if selected_option in ['longName', 'shortName']:
new_value = get_user_input(f"Current value for {selected_option}: {current_value}")
new_value = current_value if new_value is None else new_value
current_menu[selected_option] = (field, new_value)
elif selected_option == 'isLicensed':
@@ -221,7 +248,7 @@ def settings_menu(stdscr, interface):
modified_settings[selected_option] = new_value
# Convert enum string to int
if field.enum_type:
if field and field.enum_type:
enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
@@ -229,12 +256,13 @@ def settings_menu(stdscr, interface):
else:
current_menu = current_menu[selected_option]
menu_path.append(selected_option)
menu_index.append(selected_index)
selected_index = 0
elif key == curses.KEY_LEFT:
need_redraw = True
menu_win.clear()
menu_win.erase()
menu_win.refresh()
modified_settings.clear()
@@ -245,10 +273,10 @@ def settings_menu(stdscr, interface):
current_menu = menu["Main Menu"]
for step in menu_path[1:]:
current_menu = current_menu.get(step, {})
selected_index = 0
selected_index = menu_index.pop()
elif key == 27: # Escape key
menu_win.clear()
menu_win.erase()
menu_win.refresh()
break

View File

@@ -12,16 +12,21 @@ COLOR_MAP = {
"white": curses.COLOR_WHITE
}
def setup_colors():
def setup_colors(reinit=False):
"""
Initialize curses color pairs based on the COLOR_CONFIG.
"""
curses.start_color()
if reinit:
conf = config.initialize_config()
config.assign_config_variables(conf)
for idx, (category, (fg_name, bg_name)) in enumerate(config.COLOR_CONFIG.items(), start=1):
fg = COLOR_MAP.get(fg_name.lower(), curses.COLOR_WHITE)
bg = COLOR_MAP.get(bg_name.lower(), curses.COLOR_BLACK)
curses.init_pair(idx, fg, bg)
config.COLOR_CONFIG[category] = idx
print()
def get_color(category, bold=False, reverse=False, underline=False):

View File

@@ -1,13 +1,57 @@
import curses
import textwrap
import globals
from utilities.utils import get_name_from_number, get_channels
from utilities.utils import get_name_from_number, get_channels, get_time_ago
from settings import settings_menu
from message_handlers.tx_handler import send_message, send_traceroute
import ui.dialog
from ui.colors import setup_colors, get_color
import default_config as config
def refresh_all():
for i, box in enumerate([channel_box, messages_box, nodes_box]):
box.attrset(get_color("window_frame_selected") if globals.current_window == i else get_color("window_frame"))
box.box()
box.refresh()
refresh_pad(i)
def draw_node_details():
nodes_snapshot = list(globals.interface.nodes.values())
node = None
for node in nodes_snapshot:
if globals.node_list[globals.selected_node] == node['num']:
break
function_win.erase()
function_win.box()
nodestr = ""
width = function_win.getmaxyx()[1]
node_details_list = [f"{node['user']['longName']}"
if 'user' in node and 'longName' in node['user'] else "",
f"({node['user']['shortName']})"
if 'user' in node and 'shortName' in node['user'] else "",
f" | {node['user']['hwModel']}"
if 'user' in node and 'hwModel' in node['user'] else "",
f" | {get_time_ago(node['lastHeard'])}" if ('lastHeard' in node and node['lastHeard']) else "",
f" | Hops: {node['hopsAway']}" if 'hopsAway' in node else "",
f" | SNR: {node['snr']}dB"
if ('snr' in node and 'hopsAway' in node and node['hopsAway'] == 0)
else "",
]
for s in node_details_list:
if len(nodestr) + len(s) < width:
nodestr = nodestr + s
draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
def draw_function_win():
draw_centered_text_field(function_win,
f"↑→↓← = Select ENTER = Send ` = Settings ^P = Packet Log ESC = Quit",
0, get_color("commands"))
def get_msg_window_lines():
packetlog_height = packetlog_win.getmaxyx()[0] if globals.display_log else 0
return messages_box.getmaxyx()[0] - 2 - packetlog_height
@@ -28,6 +72,10 @@ def refresh_pad(window):
selected_item = globals.selected_message
start_index = globals.selected_message
if globals.display_log:
packetlog_win.box()
packetlog_win.refresh()
if(window == 2):
pad = nodes_pad
box = nodes_box
@@ -114,7 +162,7 @@ def draw_splash(stdscr):
def draw_channel_list():
channel_pad.clear()
channel_pad.erase()
win_height, win_width = channel_box.getmaxyx()
start_index = max(0, globals.selected_channel - (win_height - 3)) # Leave room for borders
@@ -148,7 +196,7 @@ def draw_channel_list():
def draw_messages_window(scroll_to_bottom = False):
"""Update the messages window based on the selected channel and scroll position."""
messages_pad.clear()
messages_pad.erase()
channel = globals.channel_list[globals.selected_channel]
@@ -190,7 +238,7 @@ def draw_messages_window(scroll_to_bottom = False):
draw_packetlog_win()
def draw_node_list():
nodes_pad.clear()
nodes_pad.erase()
win_height = nodes_box.getmaxyx()[0]
start_index = max(0, globals.selected_node - (win_height - 3)) # Calculate starting index based on selected node and window height
@@ -249,6 +297,8 @@ def select_node(idx):
highlight_line(True, 2, globals.selected_node)
refresh_pad(2)
draw_node_details()
def scroll_nodes(direction):
new_selected_node = globals.selected_node + direction
@@ -265,7 +315,7 @@ def draw_packetlog_win():
span = 0
if globals.display_log:
packetlog_win.clear()
packetlog_win.erase()
height, width = packetlog_win.getmaxyx()
for column in columns[:-1]:
@@ -342,7 +392,7 @@ def main_ui(stdscr):
function_win.bkgd(get_color("background"))
packetlog_win.bkgd(get_color("background"))
draw_centered_text_field(function_win, f"↑→↓← = Select ENTER = Send ` = Settings ^P = Packet Log ESC = Quit",0 ,get_color("commands"))
draw_function_win()
# Draw boxes around windows
@@ -459,6 +509,7 @@ def main_ui(stdscr):
messages_box.refresh()
refresh_pad(1)
elif old_window == 2:
draw_function_win()
nodes_box.attrset(get_color("window_frame"))
nodes_box.box()
nodes_box.refresh()
@@ -479,6 +530,7 @@ def main_ui(stdscr):
messages_box.refresh()
refresh_pad(1)
elif globals.current_window == 2:
draw_node_details()
nodes_box.attrset(get_color("window_frame_selected"))
nodes_box.box()
nodes_box.attrset(get_color("window_frame"))
@@ -496,6 +548,7 @@ def main_ui(stdscr):
curses.curs_set(0) # Hide cursor
ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
curses.curs_set(1) # Show cursor again
refresh_all()
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
if globals.current_window == 2:
@@ -519,8 +572,7 @@ def main_ui(stdscr):
# Clear entry window and reset input text
input_text = ""
entry_win.clear()
# entry_win.refresh()
entry_win.erase()
elif char in (curses.KEY_BACKSPACE, chr(127)):
if input_text:
@@ -535,6 +587,7 @@ def main_ui(stdscr):
curses.curs_set(0)
settings_menu(stdscr, globals.interface)
curses.curs_set(1)
refresh_all()
elif char == chr(16):
# Display packet log
@@ -543,7 +596,7 @@ def main_ui(stdscr):
draw_messages_window(True)
else:
globals.display_log = False
packetlog_win.clear()
packetlog_win.erase()
draw_messages_window(True)
else:
# Append typed character to input text

View File

@@ -35,6 +35,6 @@ def dialog(stdscr, title, message):
char = win.getch()
# Close dialog with enter, space, or esc
if char in(curses.KEY_ENTER, 10, 13, 32, 27):
win.clear()
win.erase()
win.refresh()
return

View File

@@ -41,16 +41,6 @@ def generate_menu_from_protobuf(interface):
# Function to generate the menu structure from protobuf messages
menu_structure = {"Main Menu": {}}
# Add Radio Settings
radio = config_pb2.Config()
current_radio_config = interface.localNode.localConfig if interface else None
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
# Add User Settings
current_node_info = interface.getMyNodeInfo() if interface else None
@@ -82,6 +72,19 @@ def generate_menu_from_protobuf(interface):
channel_config = extract_fields(channel, current_channel.settings)
menu_structure["Main Menu"]["Channels"][f"Channel {i + 1}"] = channel_config
# Add Radio Settings
radio = config_pb2.Config()
current_radio_config = interface.localNode.localConfig if interface else None
menu_structure["Main Menu"]["Radio Settings"] = extract_fields(radio, current_radio_config)
# Add Module Settings
module = module_config_pb2.ModuleConfig()
current_module_config = interface.localNode.moduleConfig if interface else None
menu_structure["Main Menu"]["Module Settings"] = extract_fields(module, current_module_config)
# Add App Settings
menu_structure["Main Menu"]["App Settings"] = {"Open": "app_settings"}
# Add additional settings options
menu_structure["Main Menu"]["Reboot"] = settings_reboot
menu_structure["Main Menu"]["Reset Node DB"] = settings_reset_nodedb

385
user_config.py Normal file
View File

@@ -0,0 +1,385 @@
import curses
import json
import os
from ui.colors import get_color, setup_colors
from default_config import format_json_single_line_arrays, loaded_config
width = 60
save_option_text = "Save Changes"
def edit_color_pair(key, current_value):
"""
Allows the user to select a foreground and background color for a key.
"""
colors = [" ", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
fg_color = select_color_from_list(f"Select Foreground Color for {key}", current_value[0], colors)
bg_color = select_color_from_list(f"Select Background Color for {key}", current_value[1], colors)
return [fg_color, bg_color]
def select_color_from_list(prompt, current_color, colors):
"""
Displays a scrollable list of colors for the user to choose from using a pad.
"""
selected_index = colors.index(current_color) if current_color in colors else 0
height = min(len(colors) + 5, curses.LINES - 2)
width = 60
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
color_win = curses.newwin(height, width, start_y, start_x)
color_win.bkgd(get_color("background"))
color_win.attrset(get_color("window_frame"))
color_win.keypad(True)
color_pad = curses.newpad(len(colors) + 1, width - 8)
color_pad.bkgd(get_color("background"))
# Render header
color_win.clear()
color_win.border()
color_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
# Render color options on the pad
for idx, color in enumerate(colors):
if idx == selected_index:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
else:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
# Initial refresh
color_win.refresh()
color_pad.refresh(0, 0,
color_win.getbegyx()[0] + 3, color_win.getbegyx()[1] + 4,
color_win.getbegyx()[0] + color_win.getmaxyx()[0] - 2, color_win.getbegyx()[1] + color_win.getmaxyx()[1] - 4)
while True:
key = color_win.getch()
if key == curses.KEY_UP:
if selected_index > 0:
selected_index -= 1
elif key == curses.KEY_DOWN:
if selected_index < len(colors) - 1:
selected_index += 1
elif key == curses.KEY_RIGHT or key == ord('\n'):
return colors[selected_index]
elif key == curses.KEY_LEFT or key == 27: # ESC key
return current_color
# Refresh the pad with updated selection and scroll offset
for idx, color in enumerate(colors):
if idx == selected_index:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default", reverse=True))
else:
color_pad.addstr(idx, 0, color.ljust(width - 8), get_color("settings_default"))
color_win.refresh()
color_pad.refresh(0, 0,
color_win.getbegyx()[0] + 3, color_win.getbegyx()[1] + 4,
color_win.getbegyx()[0] + color_win.getmaxyx()[0] - 2, color_win.getbegyx()[1] + color_win.getmaxyx()[1] - 4)
def edit_value(key, current_value):
width = 60
height = 10
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Display instructions
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.refresh()
# Handle theme selection dynamically
if key == "theme":
# 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")]
return select_color_from_list("Select Theme", current_value, theme_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
user_input = ""
scroll_offset = 0 # Determines which part of the text is visible
while True:
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
edit_win.addstr(7, 13, " " * input_width, get_color("settings_default")) # Clear previous text
edit_win.addstr(7, 13, visible_text, get_color("settings_default")) # Display text
edit_win.refresh()
edit_win.move(7, 13 + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1
curses.curs_set(0)
return user_input if user_input else current_value
def render_menu(current_data, menu_path, selected_index):
"""
Render the configuration menu with a Save button directly added to the window.
"""
# Determine menu items based on the type of current_data
if isinstance(current_data, dict):
options = list(current_data.keys())
elif isinstance(current_data, list):
options = [f"[{i}]" for i in range(len(current_data))]
else:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
num_items = len(options)
height = min(curses.LINES - 2, num_items + 6) # Include space for borders and Save button
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(height, width, start_y, start_x)
menu_win.clear()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
# Display the menu path
header = " > ".join(menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = current_data[key] if isinstance(current_data, dict) else current_data[int(key.strip("[]"))]
display_key = f"{key}"[:width // 2 - 2]
display_value = (
f"{value}"[:width // 2 - 8]
)
color = get_color("settings_default", reverse=(idx == 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
save_button_position = height - 2
menu_win.addstr(
save_button_position,
(width - len(save_option_text)) // 2,
save_option_text,
get_color("settings_save", reverse=(selected_index == len(options))),
)
# Refresh menu and pad
menu_win.refresh()
menu_pad.refresh(
0,
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
return menu_win, menu_pad, options
def move_highlight(old_idx, new_idx, options, menu_win, menu_pad):
if old_idx == new_idx:
return # no-op
show_save_option = True
max_index = len(options) + (1 if show_save_option else 0) - 1
if show_save_option and old_idx == max_index: # special case un-highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_default"))
if show_save_option and new_idx == max_index: # special case highlight "Save" option
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option_text)) // 2, len(save_option_text), get_color("settings_save", reverse = True))
else:
menu_pad.chgat(new_idx, 0,menu_pad.getmaxyx()[1], get_color("settings_default", reverse = True))
start_index = max(0, new_idx - (menu_win.getmaxyx()[0] - 6))
menu_win.refresh()
menu_pad.refresh(start_index, 0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] - 3,
menu_win.getbegyx()[1] + 4 + menu_win.getmaxyx()[1] - 4)
def json_editor(stdscr):
menu_path = ["App Settings"]
selected_index = 0 # Track the selected option
file_path = "config.json"
show_save_option = True # Always show the Save button
# Ensure the file exists
if not os.path.exists(file_path):
with open(file_path, "w") as f:
json.dump({}, f)
# Load JSON data
with open(file_path, "r") as f:
original_data = json.load(f)
data = original_data # Reference to the original data
current_data = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
need_redraw = True
while True:
if(need_redraw):
menu_win, menu_pad, options = render_menu(current_data, menu_path, selected_index)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if show_save_option else 0) - 1
key = menu_win.getch()
if key == curses.KEY_UP:
old_selected_index = selected_index
selected_index = max_index if selected_index == 0 else selected_index - 1
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key == curses.KEY_DOWN:
old_selected_index = selected_index
selected_index = 0 if selected_index == max_index else selected_index + 1
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key == ord("\t") and show_save_option:
old_selected_index = selected_index
selected_index = max_index
move_highlight(old_selected_index, selected_index, options, menu_win, menu_pad)
elif key in (curses.KEY_RIGHT, ord("\n")):
need_redraw = True
menu_win.erase()
menu_win.refresh()
if selected_index < len(options): # Handle selection of a menu item
selected_key = options[selected_index]
# Handle nested data
if isinstance(current_data, dict):
if selected_key in current_data:
selected_data = current_data[selected_key]
else:
continue # Skip invalid key
elif isinstance(current_data, list):
selected_data = current_data[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)
current_data[selected_key] = new_value
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
menu_path.append(str(selected_key))
current_data = selected_data
selected_index = 0 # Reset the selected index
else:
# General value editing
new_value = edit_value(selected_key, selected_data)
current_data[selected_key] = new_value
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
stdscr.refresh()
continue
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
need_redraw = True
menu_win.erase()
menu_win.refresh()
# Navigate back in the menu
if len(menu_path) > 1:
menu_path.pop()
current_data = data
for path in menu_path[1:]:
current_data = current_data[path] if isinstance(current_data, dict) else current_data[int(path.strip("[]"))]
selected_index = 0
else:
# Exit the editor
menu_win.clear()
menu_win.refresh()
break
def save_json(file_path, data):
formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
def main(stdscr):
curses.curs_set(0)
stdscr.keypad(True)
setup_colors()
json_editor(stdscr)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -1,4 +1,5 @@
import globals
from datetime import datetime
from meshtastic.protobuf import config_pb2
import re
@@ -68,4 +69,37 @@ def get_name_from_number(number, type='long'):
else:
pass
# If no match is found, use the ID as a string
return str(decimal_to_hex(number))
return str(decimal_to_hex(number))
def get_time_ago(timestamp):
now = datetime.now()
dt = datetime.fromtimestamp(timestamp)
delta = now - dt
value = 0
unit = ""
if delta.days > 365:
value = delta.days // 365
unit = "y"
elif delta.days > 30:
value = delta.days // 30
unit = "mon"
elif delta.days > 7:
value = delta.days // 7
unit = "w"
elif delta.days > 0:
value = delta.days
unit = "d"
elif delta.seconds > 3600:
value = delta.seconds // 3600
unit = "h"
elif delta.seconds > 60:
value = delta.seconds // 60
unit = "min"
if len(unit) > 0:
return f"{value} {unit} ago"
return "now"