Compare commits

..

27 Commits

Author SHA1 Message Date
pdxlocations
c5fa47f2ff get environment_metrics from payload 2025-08-23 00:57:34 -07:00
pdxlocations
6d6a121a56 get device_metrics from payload 2025-08-23 00:55:20 -07:00
pdxlocations
43430b3725 parse protobufs 2025-08-23 00:27:07 -07:00
pdxlocations
888cdb244c bump version 2025-08-22 23:36:22 -07:00
pdxlocations
0c8ca2eb48 Update README with single pane mode instructions
Added information about enabling single pane mode for smaller displays.
2025-08-22 23:16:38 -07:00
pdxlocations
c06017e3f9 Add Single Pane Mode and Support for Smaller Displays (#217)
* init

* shift focus on message send

* fix save check

* focus arrows fix

* fix single-pane crash

* fix packet log crash

* Bonus, redraw settings when new line in packetlog

* refactor

* allow smaller windows

* hide help on small screens
2025-08-22 23:07:31 -07:00
pdxlocations
751a143d0a Merge pull request #216 from jekeam/patch-1 2025-08-13 07:25:44 -07:00
jekeam
f7d203e97a Update README.md [Install for Window] 2025-08-13 14:48:05 +05:00
pdxlocations
de4f813b90 bump version 2025-08-12 21:55:34 -07:00
pdxlocations
e17f7e576f hide cursor 2025-08-12 21:48:21 -07:00
pdxlocations
dccdb00dcd Redraw settings menu on new node 2025-08-12 21:32:38 -07:00
pdxlocations
81fd7a26f5 Merge pull request #215 from pdxlocations:confirm-unsaved-changes
Confirm unsaved Changes
2025-08-08 00:39:17 -07:00
pdxlocations
640955656f fix no config redraw 2025-08-08 00:38:34 -07:00
pdxlocations
8f248f4b5b add confirmation to app settings 2025-08-08 00:29:11 -07:00
pdxlocations
c10905e954 don't exit dialog with left arrow 2025-08-07 23:58:47 -07:00
pdxlocations
d1b93263fa add confirmation box if settings not saved 2025-08-07 23:34:36 -07:00
pdxlocations
623708c2a1 update README.md 2025-08-07 22:30:18 -07:00
pdxlocations
9b8abdb344 fix admin key window name 2025-07-31 23:48:15 -07:00
pdxlocations
8c3e00b52b redraw other input types 2025-07-31 23:44:14 -07:00
pdxlocations
81ebd1b95f fix get_text_input 2025-07-31 00:55:19 -07:00
pdxlocations
ae75d85741 fix user settings inputs 2025-07-31 00:33:30 -07:00
pdxlocations
b6767f423e Fix settings redraw (#214)
* current window 4

* refresh settings on new message

* redraw dialog and fix traceroute

* formatting and catch

* move continue
2025-07-31 00:08:08 -07:00
pdxlocations
b1252fec6c Update README.md 2025-07-30 22:18:01 -07:00
pdxlocations
43d1152074 Update README.md 2025-07-30 22:14:46 -07:00
pdxlocations
786a7b03c5 Configure Filepath for Export Node Config (#213)
* add node config path to settings

* try reload config but failed
2025-07-29 16:51:26 -07:00
pdxlocations
8d111c5df7 Add Warning for Sending Messages Quickly (#212)
* Warn About 2-Second Message Delay

* add comment

* update lines and cols
2025-07-28 23:04:57 -07:00
pdxlocations
b314a24a0c Input Validation Framework (#211)
* init

* validation framework

* add rules

* automatic types

* changes

* fix positions

* redraw input

* check for selected_config

* tweaks

* refactor
2025-07-26 21:20:15 -07:00
14 changed files with 753 additions and 350 deletions

View File

@@ -6,6 +6,13 @@
```bash ```bash
pip install contact pip install contact
``` ```
> [!NOTE]
> Windows users must also install:
>
> ```powershell
> pip install windows-curses
> ```
> because the built-in curses module is not available on Windows.
This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores. This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores.
@@ -25,6 +32,10 @@ All messages will saved in a SQLite DB and restored upon relaunch of the app. Y
By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more! By navigating to Settings -> App Settings, you may customize your UI's icons, colors, and more!
For smaller displays you may wish to enable `single_pane_mode`:
<img width="486" height="194" alt="Screenshot 2025-08-22 at 11 15 54PM" src="https://github.com/user-attachments/assets/447c5d30-0850-4a4f-b0d4-976e4c5e329d" />
## Commands ## Commands
- `↑→↓←` = Navigate around the UI. - `↑→↓←` = Navigate around the UI.
@@ -33,6 +44,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
@@ -62,8 +74,17 @@ If no connection arguments are specified, the client will attempt a serial conne
contact --port /dev/ttyUSB0 contact --port /dev/ttyUSB0
contact --host 192.168.1.1 contact --host 192.168.1.1
contact --ble BlAddressOfDevice contact --ble BlAddressOfDevice
contact --port COM3
``` ```
To quickly connect to localhost, use: 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 .
```

View File

@@ -72,6 +72,7 @@ def initialize_globals() -> None:
interface_state.myNodeNum = get_nodeNum() interface_state.myNodeNum = get_nodeNum()
ui_state.channel_list = get_channels() ui_state.channel_list = get_channels()
ui_state.node_list = get_node_list() ui_state.node_list = get_node_list()
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
pub.subscribe(on_receive, "meshtastic.receive") pub.subscribe(on_receive, "meshtastic.receive")
init_nodedb() init_nodedb()

View File

@@ -24,7 +24,7 @@ from contact.utilities.db_handler import (
) )
import contact.ui.default_config as config import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state, app_state from contact.utilities.singleton import ui_state, interface_state, app_state, menu_state
def play_sound(): def play_sound():
@@ -84,6 +84,9 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
if ui_state.display_log: if ui_state.display_log:
draw_packetlog_win() draw_packetlog_win()
if ui_state.current_window == 4:
menu_state.need_redraw = True
try: try:
if "decoded" not in packet: if "decoded" not in packet:
return return

View File

@@ -1,18 +1,64 @@
import curses import curses
import logging import logging
import time
import traceback import traceback
from typing import Union from typing import Union
from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list from contact.utilities.utils import get_channels, get_readable_duration, get_time_ago, refresh_node_list
from contact.settings import settings_menu from contact.settings import settings_menu
from contact.message_handlers.tx_handler import send_message, send_traceroute from contact.message_handlers.tx_handler import send_message, send_traceroute
from contact.utilities.utils import parse_protobuf
from contact.ui.colors import get_color from contact.ui.colors import get_color
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
from contact.utilities.input_handlers import get_list_input 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
MIN_COL = 1 # "effectively zero" without breaking curses
root_win = None # set in main_ui
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
def draw_window_arrows(window_id: int) -> None:
if window_id == 0:
draw_main_arrows(channel_win, len(ui_state.channel_list), window=0)
channel_win.refresh()
elif window_id == 1:
msg_line_count = messages_pad.getmaxyx()[0]
draw_main_arrows(
messages_win,
msg_line_count,
window=1,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh()
elif window_id == 2:
draw_main_arrows(nodes_win, len(ui_state.node_list), window=2)
nodes_win.refresh()
def compute_widths(total_w: int, focus: int):
# focus: 0=channel, 1=messages, 2=nodes
if total_w < 3 * MIN_COL:
# tiny terminals: allocate something, anything
return max(1, total_w), 0, 0
if focus == 0:
return total_w - 2 * MIN_COL, MIN_COL, MIN_COL
if focus == 1:
return MIN_COL, total_w - 2 * MIN_COL, MIN_COL
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
def paint_frame(win, selected: bool) -> None:
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
win.box()
win.attrset(get_color("window_frame"))
win.refresh()
def handle_resize(stdscr: curses.window, firstrun: bool) -> None: def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
@@ -22,25 +68,43 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
# Calculate window max dimensions # Calculate window max dimensions
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()
# Define window dimensions and positions if ui_state.single_pane_mode:
channel_width = int(config.channel_list_16ths) * (width // 16) channel_width, messages_width, nodes_width = compute_widths(width, ui_state.current_window)
nodes_width = int(config.node_list_16ths) * (width // 16) else:
messages_width = width - channel_width - nodes_width channel_width = int(config.channel_list_16ths) * (width // 16)
nodes_width = int(config.node_list_16ths) * (width // 16)
messages_width = width - channel_width - nodes_width
channel_width = max(MIN_COL, channel_width)
messages_width = max(MIN_COL, messages_width)
nodes_width = max(MIN_COL, nodes_width)
# Ensure the three widths sum exactly to the terminal width by adjusting the focused pane
total = channel_width + messages_width + nodes_width
if total != width:
delta = total - width
if ui_state.current_window == 0:
channel_width = max(MIN_COL, channel_width - delta)
elif ui_state.current_window == 1:
messages_width = max(MIN_COL, messages_width - delta)
else:
nodes_width = max(MIN_COL, nodes_width - delta)
entry_height = 3 entry_height = 3
function_height = 3 function_height = 3
y_pad = entry_height + function_height y_pad = entry_height + function_height
packet_log_height = int(height / 3) content_h = max(1, height - y_pad)
pkt_h = max(1, int(height / 3))
if firstrun: if firstrun:
entry_win = curses.newwin(entry_height, width, 0, 0) entry_win = curses.newwin(entry_height, width, 0, 0)
channel_win = curses.newwin(height - y_pad, channel_width, entry_height, 0)
messages_win = curses.newwin(height - y_pad, messages_width, entry_height, channel_width) channel_win = curses.newwin(content_h, channel_width, entry_height, 0)
nodes_win = curses.newwin(height - y_pad, nodes_width, entry_height, channel_width + messages_width) messages_win = curses.newwin(content_h, messages_width, entry_height, channel_width)
nodes_win = curses.newwin(content_h, nodes_width, entry_height, channel_width + messages_width)
function_win = curses.newwin(function_height, width, height - function_height, 0) function_win = curses.newwin(function_height, width, height - function_height, 0)
packetlog_win = curses.newwin( packetlog_win = curses.newwin(pkt_h, messages_width, height - pkt_h - function_height, channel_width)
packet_log_height, messages_width, height - packet_log_height - function_height, channel_width
)
# Will be resized to what we need when drawn # Will be resized to what we need when drawn
messages_pad = curses.newpad(1, 1) messages_pad = curses.newpad(1, 1)
@@ -65,19 +129,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
entry_win.resize(3, width) entry_win.resize(3, width)
channel_win.resize(height - y_pad, channel_width) channel_win.resize(content_h, channel_width)
messages_win.resize(height - y_pad, messages_width) messages_win.resize(content_h, messages_width)
messages_win.mvwin(3, channel_width) messages_win.mvwin(3, channel_width)
nodes_win.resize(height - y_pad, nodes_width) nodes_win.resize(content_h, nodes_width)
nodes_win.mvwin(entry_height, channel_width + messages_width) nodes_win.mvwin(entry_height, channel_width + messages_width)
function_win.resize(3, width) function_win.resize(3, width)
function_win.mvwin(height - function_height, 0) function_win.mvwin(height - function_height, 0)
packetlog_win.resize(packet_log_height, messages_width) packetlog_win.resize(pkt_h, messages_width)
packetlog_win.mvwin(height - packet_log_height - function_height, channel_width) packetlog_win.mvwin(height - pkt_h - function_height, channel_width)
# Draw window borders # Draw window borders
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]: for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
@@ -92,6 +156,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
draw_channel_list() draw_channel_list()
draw_messages_window(True) draw_messages_window(True)
draw_node_list() draw_node_list()
draw_window_arrows(ui_state.current_window)
except: except:
# Resize events can come faster than we can re-draw, which can cause a curses error. # Resize events can come faster than we can re-draw, which can cause a curses error.
@@ -102,6 +167,9 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
def main_ui(stdscr: curses.window) -> None: def main_ui(stdscr: curses.window) -> None:
"""Main UI loop for the curses interface.""" """Main UI loop for the curses interface."""
global input_text global input_text
global root_win
root_win = stdscr
input_text = "" input_text = ""
stdscr.keypad(True) stdscr.keypad(True)
get_channels() get_channels()
@@ -208,6 +276,8 @@ def handle_home() -> None:
elif ui_state.current_window == 2: elif ui_state.current_window == 2:
select_node(0) select_node(0)
draw_window_arrows(ui_state.current_window)
def handle_end() -> None: def handle_end() -> None:
"""Handle end key events to select the last item in the current window.""" """Handle end key events to select the last item in the current window."""
@@ -219,29 +289,27 @@ def handle_end() -> None:
refresh_pad(1) refresh_pad(1)
elif ui_state.current_window == 2: elif ui_state.current_window == 2:
select_node(len(ui_state.node_list) - 1) select_node(len(ui_state.node_list) - 1)
draw_window_arrows(ui_state.current_window)
def handle_pageup() -> None: def handle_pageup() -> None:
"""Handle page up key events to scroll the current window by a page.""" """Handle page up key events to scroll the current window by a page."""
if ui_state.current_window == 0: if ui_state.current_window == 0:
select_channel( select_channel(ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2))
ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)
) # select_channel will bounds check for us
elif ui_state.current_window == 1: elif ui_state.current_window == 1:
ui_state.selected_message = max( ui_state.selected_message = max(
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0 ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
) )
refresh_pad(1) refresh_pad(1)
elif ui_state.current_window == 2: elif ui_state.current_window == 2:
select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2))
draw_window_arrows(ui_state.current_window)
def handle_pagedown() -> None: def handle_pagedown() -> None:
"""Handle page down key events to scroll the current window down.""" """Handle page down key events to scroll the current window down."""
if ui_state.current_window == 0: if ui_state.current_window == 0:
select_channel( select_channel(ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2))
ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)
) # select_channel will bounds check for us
elif ui_state.current_window == 1: elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0] msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = min( ui_state.selected_message = min(
@@ -250,7 +318,8 @@ def handle_pagedown() -> None:
) )
refresh_pad(1) refresh_pad(1)
elif ui_state.current_window == 2: elif ui_state.current_window == 2:
select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2))
draw_window_arrows(ui_state.current_window)
def handle_leftright(char: int) -> None: def handle_leftright(char: int) -> None:
@@ -258,44 +327,36 @@ def handle_leftright(char: int) -> None:
delta = -1 if char == curses.KEY_LEFT else 1 delta = -1 if char == curses.KEY_LEFT else 1
old_window = ui_state.current_window old_window = ui_state.current_window
ui_state.current_window = (ui_state.current_window + delta) % 3 ui_state.current_window = (ui_state.current_window + delta) % 3
handle_resize(root_win, False)
if old_window == 0: if old_window == 0:
channel_win.attrset(get_color("window_frame")) paint_frame(channel_win, selected=False)
channel_win.box()
channel_win.refresh()
refresh_pad(0) refresh_pad(0)
if old_window == 1: if old_window == 1:
messages_win.attrset(get_color("window_frame")) paint_frame(messages_win, selected=False)
messages_win.box()
messages_win.refresh()
refresh_pad(1) refresh_pad(1)
elif old_window == 2: elif old_window == 2:
draw_function_win() draw_function_win()
nodes_win.attrset(get_color("window_frame")) paint_frame(nodes_win, selected=False)
nodes_win.box()
nodes_win.refresh()
refresh_pad(2) refresh_pad(2)
if not ui_state.single_pane_mode:
draw_window_arrows(old_window)
if ui_state.current_window == 0: if ui_state.current_window == 0:
channel_win.attrset(get_color("window_frame_selected")) paint_frame(channel_win, selected=True)
channel_win.box()
channel_win.attrset(get_color("window_frame"))
channel_win.refresh()
refresh_pad(0) refresh_pad(0)
elif ui_state.current_window == 1: elif ui_state.current_window == 1:
messages_win.attrset(get_color("window_frame_selected")) paint_frame(messages_win, selected=True)
messages_win.box()
messages_win.attrset(get_color("window_frame"))
messages_win.refresh()
refresh_pad(1) refresh_pad(1)
elif ui_state.current_window == 2: elif ui_state.current_window == 2:
draw_function_win() draw_function_win()
nodes_win.attrset(get_color("window_frame_selected")) paint_frame(nodes_win, selected=True)
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
nodes_win.refresh()
refresh_pad(2) refresh_pad(2)
# Draw arrows last; force even in multi-pane to avoid flicker
draw_window_arrows(ui_state.current_window)
def handle_enter(input_text: str) -> str: def handle_enter(input_text: str) -> str:
"""Handle Enter key events to send messages or select channels.""" """Handle Enter key events to send messages or select channels."""
@@ -314,18 +375,29 @@ def handle_enter(input_text: str) -> str:
ui_state.selected_node = 0 ui_state.selected_node = 0
ui_state.current_window = 0 ui_state.current_window = 0
handle_resize(root_win, False)
draw_node_list() draw_node_list()
draw_channel_list() draw_channel_list()
draw_messages_window(True) draw_messages_window(True)
draw_window_arrows(ui_state.current_window)
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
entry_win.erase() entry_win.erase()
if ui_state.current_window == 0:
ui_state.current_window = 1
handle_resize(root_win, False)
return "" return ""
return input_text return input_text
@@ -357,7 +429,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)
@@ -490,6 +565,10 @@ def handle_ctlr_g(stdscr: curses.window) -> None:
def draw_channel_list() -> None: def draw_channel_list() -> None:
"""Update the channel list window and pad based on the current state.""" """Update the channel list window and pad based on the current state."""
if ui_state.current_window != 0 and ui_state.single_pane_mode:
return
channel_pad.erase() channel_pad.erase()
win_width = channel_win.getmaxyx()[1] win_width = channel_win.getmaxyx()[1]
@@ -524,20 +603,18 @@ def draw_channel_list() -> None:
channel_pad.addstr(idx, 1, truncated_channel, color) channel_pad.addstr(idx, 1, truncated_channel, color)
idx += 1 idx += 1
channel_win.attrset( paint_frame(channel_win, selected=(ui_state.current_window == 0))
get_color("window_frame_selected") if ui_state.current_window == 0 else get_color("window_frame")
)
channel_win.box()
channel_win.attrset((get_color("window_frame")))
draw_main_arrows(channel_win, len(ui_state.channel_list), window=0)
channel_win.refresh()
refresh_pad(0) refresh_pad(0)
draw_window_arrows(0)
channel_win.refresh()
def draw_messages_window(scroll_to_bottom: bool = False) -> None: def draw_messages_window(scroll_to_bottom: bool = False) -> None:
"""Update the messages window based on the selected channel and scroll position.""" """Update the messages window based on the selected channel and scroll position."""
if ui_state.current_window != 1 and ui_state.single_pane_mode:
return
messages_pad.erase() messages_pad.erase()
channel = ui_state.channel_list[ui_state.selected_channel] channel = ui_state.channel_list[ui_state.selected_channel]
@@ -565,39 +642,32 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
messages_pad.addstr(row, 1, line, color) messages_pad.addstr(row, 1, line, color)
row += 1 row += 1
messages_win.attrset( paint_frame(messages_win, selected=(ui_state.current_window == 1))
get_color("window_frame_selected") if ui_state.current_window == 1 else get_color("window_frame")
)
messages_win.box()
messages_win.attrset(get_color("window_frame"))
messages_win.refresh()
visible_lines = get_msg_window_lines(messages_win, packetlog_win) visible_lines = get_msg_window_lines(messages_win, packetlog_win)
if scroll_to_bottom: if scroll_to_bottom:
ui_state.selected_message = max(msg_line_count - visible_lines, 0) ui_state.selected_message = max(msg_line_count - visible_lines, 0)
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0) ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
pass
else: else:
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0) ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
draw_main_arrows(
messages_win,
msg_line_count,
window=1,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh() messages_win.refresh()
refresh_pad(1) refresh_pad(1)
draw_packetlog_win() draw_packetlog_win()
draw_window_arrows(1)
messages_win.refresh()
if ui_state.current_window == 4:
menu_state.need_redraw = True
def draw_node_list() -> None: def draw_node_list() -> None:
"""Update the nodes list window and pad based on the current state.""" """Update the nodes list window and pad based on the current state."""
global nodes_pad global nodes_pad
if ui_state.current_window != 2 and ui_state.single_pane_mode:
return
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time # This didn't work, for some reason an error is thown on startup, so we just create the pad every time
# if nodes_pad is None: # if nodes_pad is None:
# nodes_pad = curses.newpad(1, 1) # nodes_pad = curses.newpad(1, 1)
@@ -626,22 +696,20 @@ def draw_node_list() -> None:
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2) i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
) )
nodes_win.attrset( paint_frame(nodes_win, selected=(ui_state.current_window == 2))
get_color("window_frame_selected") if ui_state.current_window == 2 else get_color("window_frame")
)
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
draw_main_arrows(nodes_win, len(ui_state.node_list), window=2)
nodes_win.refresh() nodes_win.refresh()
refresh_pad(2) refresh_pad(2)
draw_window_arrows(2)
nodes_win.refresh()
# Restore cursor to input field # Restore cursor to input field
entry_win.keypad(True) entry_win.keypad(True)
curses.curs_set(1) curses.curs_set(1)
entry_win.refresh() entry_win.refresh()
if ui_state.current_window == 4:
menu_state.need_redraw = True
def select_channel(idx: int) -> None: def select_channel(idx: int) -> None:
"""Select a channel by index and update the UI state accordingly.""" """Select a channel by index and update the UI state accordingly."""
@@ -699,15 +767,9 @@ def scroll_messages(direction: int) -> None:
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1) 0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
) )
draw_main_arrows(
messages_win,
msg_line_count,
ui_state.current_window,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh() messages_win.refresh()
refresh_pad(1) refresh_pad(1)
draw_window_arrows(ui_state.current_window)
def select_node(idx: int) -> None: def select_node(idx: int) -> None:
@@ -744,6 +806,9 @@ def draw_packetlog_win() -> None:
columns = [10, 10, 15, 30] columns = [10, 10, 15, 30]
span = 0 span = 0
if ui_state.current_window != 1 and ui_state.single_pane_mode:
return
if ui_state.display_log: if ui_state.display_log:
packetlog_win.erase() packetlog_win.erase()
height, width = packetlog_win.getmaxyx() height, width = packetlog_win.getmaxyx()
@@ -769,22 +834,20 @@ def draw_packetlog_win() -> None:
else get_name_from_database(packet["to"], "short").ljust(columns[1]) else get_name_from_database(packet["to"], "short").ljust(columns[1])
) )
if "decoded" in packet: if "decoded" in packet:
port = packet["decoded"]["portnum"].ljust(columns[2]) port = str(packet["decoded"].get("portnum", "")).ljust(columns[2])
payload = (packet["decoded"]["payload"]).ljust(columns[3]) parsed_payload = parse_protobuf(packet)
else: else:
port = "NO KEY".ljust(columns[2]) port = "NO KEY".ljust(columns[2])
payload = "NO KEY".ljust(columns[3]) parsed_payload = "NO KEY"
# Combine and truncate if necessary # Combine and truncate if necessary
logString = f"{from_id} {to_id} {port} {payload}" logString = f"{from_id} {to_id} {port} {parsed_payload}"
logString = logString[: width - 3] logString = logString[: width - 3]
# Add to the window # Add to the window
packetlog_win.addstr(i + 2, 1, logString, get_color("log")) packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
packetlog_win.attrset(get_color("window_frame")) paint_frame(packetlog_win, selected=False)
packetlog_win.box()
packetlog_win.refresh()
# Restore cursor to input field # Restore cursor to input field
entry_win.keypad(True) entry_win.keypad(True)
@@ -935,7 +998,7 @@ def draw_function_win() -> None:
def refresh_pad(window: int) -> None: def refresh_pad(window: int) -> None:
# Derive the target box and pad for the requested window
win_height = channel_win.getmaxyx()[0] win_height = channel_win.getmaxyx()[0]
if window == 1: if window == 1:
@@ -963,13 +1026,43 @@ def refresh_pad(window: int) -> None:
selected_item = ui_state.selected_channel selected_item = ui_state.selected_channel
start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders start_index = max(0, selected_item - (win_height - 3)) # Leave room for borders
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
if ui_state.single_pane_mode and window != ui_state.current_window:
return
# Compute inner drawable area of the box
box_y, box_x = box.getbegyx()
box_h, box_w = box.getmaxyx()
inner_h = max(0, box_h - 2) # minus borders
inner_w = max(0, box_w - 2)
if inner_h <= 0 or inner_w <= 0:
return
# Clamp lines to available inner height
lines = max(0, min(lines, inner_h))
# Clamp start_index within the pad's height
pad_h, pad_w = pad.getmaxyx()
if pad_h <= 0:
return
start_index = max(0, min(start_index, max(0, pad_h - 1)))
top = box_y + 1
left = box_x + 1
bottom = box_y + min(inner_h, lines) # inclusive
right = box_x + min(inner_w, box_w - 2)
if bottom < top or right < left:
return
pad.refresh( pad.refresh(
start_index, start_index,
0, 0,
box.getbegyx()[0] + 1, top,
box.getbegyx()[1] + 1, left,
box.getbegyx()[0] + lines, bottom,
box.getbegyx()[1] + box.getmaxyx()[1] - 3, right,
) )

View File

@@ -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,60 +21,72 @@ 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() # Setup Variables
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
# Constants
width = 80
save_option = "Save Changes" save_option = "Save Changes"
max_help_lines = 0 max_help_lines = 0
help_win = None help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Compute the effective menu width for the current terminal
def get_menu_width() -> int:
# Leave at least 2 columns for borders; clamp to >= 20 for usability
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# 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__))
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]:
if help_win:
min_help_window_height = 6
else:
min_help_window_height = 0
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)
# Determine the available height for the menu # Determine the available height for the menu
max_menu_height = curses.LINES max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5) menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
w = get_menu_width()
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2) start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = (curses.COLS - width) // 2 start_x = (curses.COLS - w) // 2
# Calculate remaining space for help window # Calculate remaining space for help window
global max_help_lines global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding
max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
menu_win = curses.newwin(menu_height, width, start_y, start_x) menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win.erase() menu_win.erase()
menu_win.bkgd(get_color("background")) menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame")) menu_win.attrset(get_color("window_frame"))
menu_win.border() menu_win.border()
menu_win.keypad(True) menu_win.keypad(True)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, width - 8) menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8)
menu_pad.bkgd(get_color("background")) menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_state.menu_path) header = " > ".join(word.title() for word in menu_state.menu_path)
if len(header) > width - 4: if len(header) > w - 4:
header = header[: width - 7] + "..." header = header[: w - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path) transformed_path = transform_menu_path(menu_state.menu_path)
@@ -84,15 +97,15 @@ def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.wind
full_key = ".".join(transformed_path + [option]) full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option) display_name = field_mapping.get(full_key, option)
display_option = f"{display_name}"[: width // 2 - 2] display_option = f"{display_name}"[: w // 2 - 2]
display_value = f"{current_value}"[: width // 2 - 4] display_value = f"{current_value}"[: w // 2 - 4]
try: try:
color = get_color( color = get_color(
"settings_sensitive" if option in sensitive_settings else "settings_default", "settings_sensitive" if option in sensitive_settings else "settings_default",
reverse=(idx == menu_state.selected_index), reverse=(idx == menu_state.selected_index),
) )
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
except curses.error: except curses.error:
pass pass
@@ -100,13 +113,13 @@ def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.wind
save_position = menu_height - 2 save_position = menu_height - 2
menu_win.addstr( menu_win.addstr(
save_position, save_position,
(width - len(save_option)) // 2, (w - len(save_option)) // 2,
save_option, save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
) )
# 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(
@@ -117,6 +130,7 @@ def display_menu(menu_state: MenuState) -> tuple[object, object]: # curses.wind
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0), menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4, menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
) )
curses.curs_set(0)
max_index = num_items + (1 if menu_state.show_save_option else 0) - 1 max_index = num_items + (1 if menu_state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0) visible_height = menu_win.getmaxyx()[0] - 5 - (2 if menu_state.show_save_option else 0)
@@ -132,9 +146,7 @@ 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
if "help_win" not in globals(): if "help_win" not in globals():
@@ -145,8 +157,9 @@ def draw_help_window(
) )
help_y = menu_start_y + menu_height help_y = menu_start_y + menu_height
# Use current terminal width for the help window width calculation
help_win = update_help_window( help_win = update_help_window(
help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x
) )
@@ -168,29 +181,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
@@ -224,14 +240,16 @@ 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()
help_win.erase() if help_win:
help_win.erase()
menu_win.refresh() menu_win.refresh()
help_win.refresh() if help_win:
help_win.refresh()
elif key == ord("\t") and menu_state.show_save_option: elif key == ord("\t") and menu_state.show_save_option:
old_selected_index = menu_state.selected_index old_selected_index = menu_state.selected_index
@@ -248,15 +266,17 @@ 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() if help_win:
help_win.erase()
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh() menu_win.refresh()
help_win.refresh() if help_win:
help_win.refresh()
if menu_state.show_save_option and menu_state.selected_index == len(options): if menu_state.show_save_option and menu_state.selected_index == len(options):
save_changes(interface, modified_settings, menu_state) save_changes(interface, modified_settings, menu_state)
@@ -301,6 +321,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
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("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:
@@ -317,6 +338,7 @@ 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("", " 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))]
@@ -324,6 +346,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
# 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("", " 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)
@@ -390,7 +413,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):
@@ -509,19 +531,42 @@ 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() if help_win:
help_win.erase()
# max_help_lines = 4 # max_help_lines = 4
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path)) # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, menu_state.current_menu, selected_index, transform_menu_path(menu_state.menu_path))
menu_win.refresh() menu_win.refresh()
help_win.refresh() if help_win:
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:
@@ -538,6 +583,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

View File

@@ -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:
@@ -175,8 +183,10 @@ def initialize_config() -> Dict[str, object]:
default_config_variables = { default_config_variables = {
"channel_list_16ths": "3", "channel_list_16ths": "3",
"node_list_16ths": "5", "node_list_16ths": "5",
"single_pane_mode": "False",
"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,16 +227,18 @@ 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, single_pane_mode
global theme, COLOR_CONFIG global theme, COLOR_CONFIG
global node_sort, notification_sound global node_sort, notification_sound
channel_list_16ths = loaded_config["channel_list_16ths"] channel_list_16ths = loaded_config["channel_list_16ths"]
node_list_16ths = loaded_config["node_list_16ths"] node_list_16ths = loaded_config["node_list_16ths"]
single_pane_mode = loaded_config["single_pane_mode"]
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 +270,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}")

View File

@@ -1,12 +1,18 @@
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(title: str, message: str) -> None: def dialog(title: str, message: str) -> None:
"""Display a dialog with a title and message."""
previous_window = ui_state.current_window
ui_state.current_window = 4
curses.update_lines_cols()
height, width = curses.LINES, curses.COLS height, width = curses.LINES, curses.COLS
# Calculate dialog dimensions # Parse message into lines and calculate dimensions
message_lines = message.splitlines() message_lines = message.splitlines()
max_line_length = max(len(l) for l in message_lines) max_line_length = max(len(l) for l in message_lines)
dialog_width = max(len(title) + 4, max_line_length + 4) dialog_width = max(len(title) + 4, max_line_length + 4)
@@ -14,36 +20,44 @@ def dialog(title: str, message: str) -> None:
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 (centered)
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"))
# Add centered OK button
ok_text = " Ok "
win.addstr(
dialog_height - 2,
(dialog_width - len(ok_text)) // 2,
ok_text,
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()
if char in (curses.KEY_ENTER, 10, 13, 32, 27): # Enter, space, or Esc
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

View File

@@ -22,9 +22,9 @@ def get_node_color(node_index: int, reverse: bool = False):
Segment = tuple[str, str, bool, bool] Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment] WrappedLine = List[Segment]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"] sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes" save_option = "Save Changes"
MIN_HEIGHT_FOR_HELP = 20
def move_highlight( def move_highlight(
@@ -73,9 +73,8 @@ def move_highlight(
# Clear old selection # Clear old selection
if show_save_option and old_idx == max_index: if show_save_option and old_idx == max_index:
menu_win.chgat( win_h, win_w = menu_win.getmaxyx()
menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save") menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
)
else: else:
menu_pad.chgat( menu_pad.chgat(
old_idx, old_idx,
@@ -90,9 +89,10 @@ def move_highlight(
# Highlight new selection # Highlight new selection
if show_save_option and new_idx == max_index: if show_save_option and new_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat( menu_win.chgat(
menu_win.getmaxyx()[0] - 2, win_h - 2,
(width - len(save_option)) // 2, (win_w - len(save_option)) // 2,
len(save_option), len(save_option),
get_color("settings_save", reverse=True), get_color("settings_save", reverse=True),
) )
@@ -124,13 +124,14 @@ def move_highlight(
selected_option = options[new_idx] if new_idx < len(options) else None selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win: if help_win:
win_h, win_w = menu_win.getmaxyx()
help_win = update_help_window( help_win = update_help_window(
help_win, help_win,
help_text, help_text,
transformed_path, transformed_path,
selected_option, selected_option,
max_help_lines, max_help_lines,
width, win_w,
help_y, help_y,
menu_win.getbegyx()[1], menu_win.getbegyx()[1],
) )
@@ -167,23 +168,46 @@ def update_help_window(
help_x: int, help_x: int,
) -> object: # returns a curses window ) -> object: # returns a curses window
"""Handles rendering the help window consistently.""" """Handles rendering the help window consistently."""
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
if curses.LINES < MIN_HEIGHT_FOR_HELP:
return None
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)
# Ensure requested width fits on screen from help_x
max_w_from_x = max(1, curses.COLS - help_x)
safe_width = min(width, max_w_from_x)
# Always leave a minimal border area; enforce a minimum usable width of 3
safe_width = max(3, safe_width)
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_width, max_help_lines)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border) help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size # Re-clamp Y to keep the window visible
if help_y + help_height > curses.LINES: if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height help_y = max(0, curses.LINES - help_height)
# If width would overflow the screen, shrink it
if help_x + safe_width > curses.COLS:
safe_width = max(3, curses.COLS - help_x)
# Create or update the help window # Create or update the help window
if help_win is None: if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x) help_win = curses.newwin(help_height, safe_width, help_y, help_x)
else: else:
help_win.erase() help_win.erase()
help_win.refresh() help_win.refresh()
help_win.resize(help_height, width) help_win.resize(help_height, safe_width)
help_win.mvwin(help_y, help_x) try:
help_win.mvwin(help_y, help_x)
except curses.error:
# If moving fails due to edge conditions, pin to (0,0) as a fallback
help_y = 0
help_x = 0
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background")) help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame")) help_win.attrset(get_color("window_frame"))
@@ -295,14 +319,16 @@ def get_wrapped_help_text(
return wrapped_help return wrapped_help
def text_width(text: str) -> int: def text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text) return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def wrap_text(text: str, wrap_width: int) -> List[str]: def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words.""" """Wraps text while preserving spaces and breaking long words."""
whitespace = '\t\n\x0b\x0c\r ' whitespace = "\t\n\x0b\x0c\r "
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(' ')) whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" "))
text = text.translate(whitespace_trans) text = text.translate(whitespace_trans)
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately

View File

@@ -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,11 +25,13 @@ 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])
show_save_option: bool = False show_save_option: bool = False
menu_path: List[str] = field(default_factory=list) menu_path: List[str] = field(default_factory=list)
single_pane_mode: bool = False
@dataclass @dataclass

View File

@@ -4,16 +4,23 @@ 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 MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6 max_help_lines = 6
save_option = "Save Changes" save_option = "Save Changes"
# Compute an effective width that fits the current terminal
def get_effective_width() -> int:
# Leave space for borders; ensure a sane minimum
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def edit_color_pair(key: str, current_value: List[str]) -> List[str]: def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
""" """
Allows the user to select a foreground and background color for a key. Allows the user to select a foreground and background color for a key.
@@ -27,13 +34,14 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
def edit_value(key: str, current_value: str) -> str: def edit_value(key: str, current_value: str) -> str:
w = get_effective_width()
height = 10 height = 10
input_width = width - 16 # Allow space for "New Value: " input_width = w - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2 start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2 start_x = max(0, (curses.COLS - w) // 2)
# Create a centered window # Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x) edit_win = curses.newwin(height, w, start_y, start_x)
edit_win.bkgd(get_color("background")) edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame")) edit_win.attrset(get_color("window_frame"))
edit_win.border() edit_win.border()
@@ -42,7 +50,7 @@ def edit_value(key: str, current_value: str) -> str:
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True)) 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")) edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding wrap_width = w - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)] 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 for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
@@ -53,7 +61,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":
@@ -64,6 +74,10 @@ def edit_value(key: str, current_value: str) -> str:
sound_options = ["True", "False"] sound_options = ["True", "False"]
return get_list_input("Notification Sound", current_value, sound_options) return get_list_input("Notification Sound", current_value, sound_options)
elif key == "single_pane_mode":
sound_options = ["True", "False"]
return get_list_input("Single-Pane Mode", current_value, sound_options)
# Standard Input Mode (Scrollable) # Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default")) edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1) curses.curs_set(1)
@@ -72,41 +86,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, w, 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.
""" """
@@ -124,11 +161,12 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
max_menu_height = curses.LINES max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5) menu_height = min(max_menu_height, num_items + 5)
num_items = len(options) num_items = len(options)
w = get_effective_width()
start_y = (curses.LINES - menu_height) // 2 start_y = (curses.LINES - menu_height) // 2
start_x = (curses.COLS - width) // 2 start_x = max(0, (curses.COLS - w) // 2)
# Create the window # Create the window
menu_win = curses.newwin(menu_height, width, start_y, start_x) menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win.erase() menu_win.erase()
menu_win.bkgd(get_color("background")) menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame")) menu_win.attrset(get_color("window_frame"))
@@ -136,13 +174,13 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
menu_win.keypad(True) menu_win.keypad(True)
# Create the pad for scrolling # Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8) menu_pad = curses.newpad(num_items + 1, w - 8)
menu_pad.bkgd(get_color("background")) menu_pad.bkgd(get_color("background"))
# Display the menu path # Display the menu path
header = " > ".join(menu_state.menu_path) header = " > ".join(menu_state.menu_path)
if len(header) > width - 4: if len(header) > w - 4:
header = header[: width - 7] + "..." header = header[: w - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True)) menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options # Populate the pad with menu options
@@ -152,18 +190,18 @@ def display_menu(menu_state: Any) -> tuple[Any, Any, List[str]]:
if isinstance(menu_state.current_menu, dict) if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))] else menu_state.current_menu[int(key.strip("[]"))]
) )
display_key = f"{key}"[: width // 2 - 2] display_key = f"{key}"[: w // 2 - 2]
display_value = f"{value}"[: width // 2 - 8] display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index)) color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color) menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
# Add Save button to the main window # Add Save button to the main window
if menu_state.show_save_option: if menu_state.show_save_option:
save_position = menu_height - 2 save_position = menu_height - 2
menu_win.addstr( menu_win.addstr(
save_position, save_position,
(width - len(save_option)) // 2, (w - len(save_option)) // 2,
save_option, save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))), get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
) )
@@ -189,6 +227,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 +250,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 +289,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 +310,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 +326,27 @@ 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)
made_changes = False
stdscr.refresh() stdscr.refresh()
continue # config.reload() # This isn't refreshing the file paths as expected
break
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 +367,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 +387,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 +396,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

View File

@@ -4,10 +4,25 @@ import curses
import ipaddress import ipaddress
from typing import Any, Optional, List from typing import Any, Optional, List
# Dialogs should be at most 80 cols, but shrink on small terminals
MAX_DIALOG_WIDTH = 80
MIN_DIALOG_WIDTH = 20
def get_dialog_width() -> int:
# Leave 2 columns for borders and clamp to a sane minimum
try:
return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2))
except Exception:
# Fallback if curses not ready yet
return MAX_DIALOG_WIDTH
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.ui.dialog import dialog
from contact.utilities.validation_rules import get_validation_for from contact.utilities.validation_rules import get_validation_for
from contact.utilities.singleton import menu_state
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None: def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None:
@@ -44,15 +59,16 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
input_win.refresh() input_win.refresh()
height = 8 height = 8
width = 80 width = get_dialog_width()
margin = 2 # Left and right margin margin = 2 # Left and right margin
input_width = width - (2 * margin) # Space available for text input_width = width - (2 * margin) # Space available for text
max_input_rows = height - 4 # Space for input max_input_rows = height - 4 # Space for input
start_y = (curses.LINES - height) // 2 start_y = max(0, (curses.LINES - height) // 2)
start_x = (curses.COLS - width) // 2 start_x = max(0, (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()
@@ -90,15 +106,25 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
first_line_width = input_width - len(prompt_text) first_line_width = input_width - len(prompt_text)
while True: while True:
key = input_win.get_wch() if menu_state.need_redraw:
menu_state.need_redraw = False
redraw_input_win()
try:
key = input_win.get_wch()
except curses.error:
continue
if key == chr(27) or key == curses.KEY_LEFT: 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)
menu_state.need_redraw = True
return None return None
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
menu_state.need_redraw = True
if not user_input.strip(): if not user_input.strip():
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win) invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
continue continue
@@ -192,9 +218,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
# Clear only the input area (without touching prompt text) # Clear only the input area (without touching prompt text)
for i in range(max_input_rows): for i in range(max_input_rows):
if row + 1 + i < height - 1: if row + 1 + i < height - 1:
input_win.addstr( input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default"))
row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default")
)
# Redraw the prompt text so it never disappears # Redraw the prompt text so it never disappears
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default")) input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
@@ -232,14 +256,15 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
cvalue = to_base64(current_value) # Convert current values to Base64 cvalue = to_base64(current_value) # Convert current values to Base64
height = 9 height = 9
width = 80 width = get_dialog_width()
start_y = (curses.LINES - height) // 2 start_y = max(0, (curses.LINES - height) // 2)
start_x = (curses.COLS - width) // 2 start_x = max(0, (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)
@@ -250,37 +275,39 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
invalid_input = "" 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 invalid_input: if invalid_input:
repeated_win.addstr(7, 2, invalid_input, 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)
@@ -302,195 +329,247 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
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 = get_dialog_width()
start_y = (curses.LINES - height) // 2 start_y = max(0, (curses.LINES - height) // 2)
start_x = (curses.COLS - width) // 2 start_x = max(0, (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 = "" invalid_input = ""
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 win_h, win_w = repeated_win.getmaxyx()
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[: max(0, win_w - 20)]) # Prevent overflow
# Move cursor to the correct position inside the field
curses.curs_set(1)
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
# Show error message if needed
if invalid_input: if invalid_input:
repeated_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True)) win_h, win_w = repeated_win.getmaxyx()
repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], 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
invalid_input = "" # 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 = get_dialog_width()
start_y = (curses.LINES - height) // 2 start_y = max(0, (curses.LINES - height) // 2)
start_x = (curses.COLS - width) // 2 start_x = max(0, (curses.COLS - width) // 2)
fixed32_win = curses.newwin(height, width, start_y, start_x) fixed32_win = curses.newwin(height, width, start_y, start_x)
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
height = min(len(list_options) + 5, curses.LINES) height = min(len(list_options) + 5, curses.LINES)
width = 80 width = get_dialog_width()
start_y = (curses.LINES - height) // 2 start_y = max(0, (curses.LINES - height) // 2)
start_x = (curses.COLS - width) // 2 start_x = max(0, (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)
list_pad = curses.newpad(len(list_options) + 1, width - 8) list_pad = curses.newpad(len(list_options) + 1, max(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))
win_h, win_w = list_win.getmaxyx()
pad_w = max(1, win_w - 8)
for idx, item in enumerate(list_options):
color = get_color("settings_default", reverse=(idx == selected_index))
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), 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,
)
# Recompute visible height each draw in case of resize
vis_h = list_win.getmaxyx()[0] - 5
draw_arrows(list_win, vis_h, 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

View File

@@ -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()

View File

@@ -1,8 +1,11 @@
import datetime import datetime
import time import time
from meshtastic.protobuf import config_pb2 from typing import Optional, Union
import contact.ui.default_config as config from google.protobuf.message import DecodeError
from meshtastic import protocols
from meshtastic.protobuf import config_pb2, mesh_pb2, portnums_pb2
import contact.ui.default_config as config
from contact.utilities.singleton import ui_state, interface_state from contact.utilities.singleton import ui_state, interface_state
@@ -136,6 +139,7 @@ def get_time_ago(timestamp):
return f"{value} {unit} ago" return f"{value} {unit} ago"
return "now" return "now"
def add_new_message(channel_id, prefix, message): def add_new_message(channel_id, prefix, message):
if channel_id not in ui_state.all_messages: if channel_id not in ui_state.all_messages:
ui_state.all_messages[channel_id] = [] ui_state.all_messages[channel_id] = []
@@ -162,4 +166,33 @@ def add_new_message(channel_id, prefix, message):
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", "")) ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
# Add the message # Add the message
ui_state.all_messages[channel_id].append((prefix,message)) ui_state.all_messages[channel_id].append((prefix, message))
def parse_protobuf(packet: dict) -> Union[str, dict]:
"""Attempt to parse a decoded payload using the registered protobuf handler."""
try:
decoded = packet.get("decoded") or {}
portnum = decoded.get("portnum")
payload = decoded.get("payload")
if isinstance(payload, str):
return payload
handler = protocols.get(portnums_pb2.PortNum.Value(portnum)) if portnum is not None else None
if handler is not None and handler.protobufFactory is not None:
try:
pb = handler.protobufFactory()
pb.ParseFromString(bytes(payload))
if hasattr(pb, "device_metrics") and pb.HasField("device_metrics"):
return str(pb.device_metrics).replace("\n", " ").replace("\r", " ").strip()
if hasattr(pb, "environment_metrics") and pb.HasField("environment_metrics"):
return str(pb.environment_metrics).replace("\n", " ").replace("\r", " ").strip()
return str(pb).replace("\n", " ").replace("\r", " ").strip()
except DecodeError:
return payload
return payload
except Exception:
return payload

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "contact" name = "contact"
version = "1.3.16" version = "1.4.0"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores." description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [ authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"} {name = "Ben Lipsey",email = "ben@pdxlocations.com"}