1
0
forked from iarv/contact

Compare commits

..

58 Commits

Author SHA1 Message Date
pdxlocations
1f270b5ba5 devpath 2025-04-08 20:50:30 -07:00
pdxlocations
4abe9611e3 bump version 2025-04-06 22:03:05 -07:00
pdxlocations
4d20df17fe Update README.md 2025-04-06 21:59:01 -07:00
pdxlocations
3bb57b9420 Merge pull request #163 from pdxlocations:localhost-fallback
Fallback to localhost not meshtastic.local
2025-04-06 21:44:32 -07:00
pdxlocations
e305bb4464 use localhost not meshtastic.local 2025-04-06 21:44:04 -07:00
pdxlocations
636b27cf9b fix typo 2025-04-06 21:28:07 -07:00
pdxlocations
8e500cb305 bump version 2025-04-06 20:19:44 -07:00
pdxlocations
0878937194 correct instructions for launching control 2025-04-06 20:17:03 -07:00
pdxlocations
ac2016322b update en.ini 2025-04-05 22:35:35 -07:00
pdxlocations
031d74a290 Fix options "not set" displaying values 2025-04-05 22:20:19 -07:00
pdxlocations
14913ce5ae fix new_idx 2025-04-05 21:22:19 -07:00
pdxlocations
c9e39d89b0 rename curses_ui 2025-04-05 19:55:33 -07:00
pdxlocations
dc27e9e02f Refactor into MenuState Class (#162)
* rename state

* changes

* not working changes

* working changes

* not working changes

* working changes

* comments
2025-04-05 19:48:38 -07:00
pdxlocations
4f64131d2e Scroll Arrows for User Config (#161)
* almost working

* likely working changes

* fix width and launch

* unused UI state
2025-04-04 22:49:40 -07:00
pdxlocations
a55d68a828 change debug level 2025-04-03 21:26:12 -07:00
pdxlocations
bd41870567 Merge pull request #160 from rfschmid/make-add-remove-favorite-default-to-yes
Make favorite confirmations default to "Yes"
2025-04-03 18:46:29 -07:00
Russell Schmidt
5a722cbf7d Make favorite confirmations default to "Yes"
Putting the highlight on "no" when pushing the dialog and requiring
scrolling to "yes" feels unnecessary.

Change case on yes/no dialogs to be more consistent.
2025-04-03 17:41:45 -05:00
pdxlocations
9cbc2d51f8 Merge pull request #159 from rfschmid/rm-node-from-db
Rm node from db
2025-04-03 09:00:31 -07:00
Russell Schmidt
5ce3e62fdb Merge 'upstream/main' into rm-node-from-db 2025-04-03 07:27:42 -05:00
pdxlocations
5628758de0 add settings to readme 2025-04-02 22:14:24 -07:00
pdxlocations
890a3b6dc4 cant add multiple authors? 2025-04-02 22:12:32 -07:00
pdxlocations
db01d241c7 bump version 2025-04-02 22:04:53 -07:00
pdxlocations
9044d8d380 Merge pull request #158 from pdxlocations:settings-flag
add settings flag
2025-04-02 22:03:56 -07:00
pdxlocations
0288a1d190 add settings flag 2025-04-02 22:03:28 -07:00
pdxlocations
3674afc216 remove version from main 2025-04-02 21:27:36 -07:00
pdxlocations
da24902bd0 Add Authors 2025-04-02 21:24:51 -07:00
pdxlocations
f9bc7f9be9 Merge pull request #157 from rfschmid:show-favorite-ignored-nodes
Color favorite/ignored nodes
2025-04-02 21:16:45 -07:00
pdxlocations
ffd28c02a3 Merge pull request #156 from rfschmid:rename-main-__main__
Rename main to __main__
2025-04-02 21:14:15 -07:00
Russell Schmidt
d22b3abc2f Make removing node from DB work
Since the Python API doesn't update the nodes table itself, we can just
modify it ourselves. This fixes removing a node so it doesn't just pop
right back up immediately and seems to actually work now.
2025-04-02 15:30:33 -05:00
Russell Schmidt
3c9b81f391 Merge branch 'rename-main-__main__' into rm-node-from-db 2025-04-02 13:17:28 -05:00
Russell Schmidt
ecc360dba9 Color favorite/ignored nodes
Show favorite nodes in color node_favorite (green by default) and
ignored nodes in color node_favorite (red by default). Sort ignored
nodes at the bottom of the node list.
2025-04-02 12:16:15 -05:00
Russell Schmidt
696370308f Rename main to __main__
Most commonly, the __main__.py file is used to provide a command-line
interface for a package. __main__.py will be executed when the package
itself is invoked directly from the command line using the -m flag.
2025-04-02 12:05:01 -05:00
pdxlocations
5999deac1a bump version 2025-04-01 22:07:21 -07:00
pdxlocations
492c1d30d6 Merge pull request #149 from pdxlocations/pyproject-update
add home page
2025-04-01 22:05:38 -07:00
pdxlocations
9e3b684a5f add home page 2025-04-01 22:04:16 -07:00
pdxlocations
25f388ed23 Merge pull request #145 from rfschmid/fix-updating-data-for-nodes-not-working 2025-04-01 21:57:29 -07:00
pdxlocations
07fbdb92e3 Merge pull request #147 from rfschmid/add-ignore-node-support 2025-04-01 21:31:37 -07:00
Russell Schmidt
7c4cc1dd2f Merge 'upstream/main' into fix-updating-data-for-nodes-not-working 2025-04-01 22:52:39 -05:00
Russell Schmidt
06ce9f7ac2 Merge 'upstream/main' into add-ignore-node-support 2025-04-01 22:43:23 -05:00
pdxlocations
f4115e48ad Merge pull request #148 from pdxlocations:poetry
Package for Poetry and Pypi
2025-04-01 15:53:20 -07:00
pdxlocations
857d8d0c04 update readme 2025-04-01 15:52:57 -07:00
pdxlocations
ec0554df14 working changes 2025-04-01 14:58:35 -07:00
pdxlocations
372204a684 mcontact -> contact 2025-04-01 14:57:51 -07:00
Russell Schmidt
8ff55c3de9 Add ignore node support
Press Ctrl+G to ignore/unignore a node.
2025-03-31 21:56:23 -05:00
Russell Schmidt
d9088ccd68 Add favorite node support
Press Ctrl+F to favorite/unfavorite a node. Favorite nodes always appear
at the top of the node list
2025-03-31 21:35:15 -05:00
Russell Schmidt
db8496b2e3 Fix updating data on existing nodes
Since 4bc1654 changed the defaults of the parameters to
update_node_info_in_db(), any call to that funciton that didn't specify
a value for chat_archived would cause chat_archived to be set to 0,
because 0 is not None, we wouldn't preserve the existing value stored in
the DB. Update to use None paramters so we can tell what the caller
specified and did not specify again.
2025-03-31 19:23:15 -05:00
pdxlocations
2b0f6515af update workflow 2025-03-30 19:15:59 -07:00
pdxlocations
4cd7c4e24d change email 2025-03-30 16:28:26 -07:00
pdxlocations
92a30ad8b0 flix user config location 2025-03-27 21:14:51 -07:00
pdxlocations
2960553fef rm extra file 2025-03-27 21:08:26 -07:00
pdxlocations
37c5a7dbc3 fix some imports 2025-03-27 21:07:15 -07:00
pdxlocations
25240f211b add release.yaml 2025-03-27 20:55:50 -07:00
pdxlocations
c236a386a5 mcontact 2025-03-26 22:30:35 -07:00
pdxlocations
66a9954149 mtcontact to mcontact 2025-03-26 22:27:03 -07:00
pdxlocations
3df012dd69 add launch.json 2025-03-26 21:59:33 -07:00
vidplace7
6d204581ce Package with poetry 2025-03-25 09:00:37 -04:00
pdxlocations
c100539ff9 Merge remote-tracking branch 'origin/main' into rm-node-from-db 2025-02-03 18:10:05 -08:00
pdxlocations
5e1ede0bea init 2025-02-03 18:01:38 -08:00
29 changed files with 944 additions and 536 deletions

143
.github/workflows/release.yaml vendored Normal file
View File

@@ -0,0 +1,143 @@
name: release
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+a[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+b[0-9]+"
- "[0-9]+.[0-9]+.[0-9]+rc[0-9]+"
env:
PACKAGE_NAME: "contact"
OWNER: "pdxlocations"
jobs:
details:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.release.outputs.new_version }}
suffix: ${{ steps.release.outputs.suffix }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: actions/checkout@v2
- name: Extract tag and Details
id: release
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
TAG_NAME=${GITHUB_REF#refs/tags/}
NEW_VERSION=$(echo $TAG_NAME | awk -F'-' '{print $1}')
SUFFIX=$(echo $TAG_NAME | grep -oP '[a-z]+[0-9]+' || echo "")
echo "new_version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "suffix=$SUFFIX" >> "$GITHUB_OUTPUT"
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "Version is $NEW_VERSION"
echo "Suffix is $SUFFIX"
echo "Tag name is $TAG_NAME"
else
echo "No tag found"
exit 1
fi
check_pypi:
needs: details
runs-on: ubuntu-latest
steps:
- name: Fetch information from PyPI
run: |
response=$(curl -s https://pypi.org/pypi/${{ env.PACKAGE_NAME }}/json || echo "{}")
latest_previous_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last")
if [ -z "$latest_previous_version" ]; then
echo "Package not found on PyPI."
latest_previous_version="0.0.0"
fi
echo "Latest version on PyPI: $latest_previous_version"
echo "latest_previous_version=$latest_previous_version" >> $GITHUB_ENV
- name: Compare versions and exit if not newer
run: |
NEW_VERSION=${{ needs.details.outputs.new_version }}
LATEST_VERSION=$latest_previous_version
if [ "$(printf '%s\n' "$LATEST_VERSION" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ] || [ "$NEW_VERSION" == "$LATEST_VERSION" ]; then
echo "The new version $NEW_VERSION is not greater than the latest version $LATEST_VERSION on PyPI."
exit 1
else
echo "The new version $NEW_VERSION is greater than the latest version $LATEST_VERSION on PyPI."
fi
setup_and_build:
needs: [details, check_pypi]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.12"
- name: Install Poetry
run: |
curl -sSL https://install.python-poetry.org | python3 -
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set project version with Poetry
run: |
poetry version ${{ needs.details.outputs.new_version }}
- name: Install dependencies
run: poetry install --sync --no-interaction
- name: Build source and wheel distribution
run: |
poetry build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
pypi_publish:
name: Upload release to PyPI
needs: [setup_and_build, details]
runs-on: ubuntu-latest
environment:
name: release
permissions:
id-token: write
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github_release:
name: Create GitHub Release
needs: [setup_and_build, details]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Create GitHub Release
id: create_release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create ${{ needs.details.outputs.tag_name }} dist/* --title ${{ needs.details.outputs.tag_name }} --generate-notes

3
.gitignore vendored
View File

@@ -7,4 +7,5 @@ client.db
client.log client.log
settings.log settings.log
config.json config.json
default_config.log default_config.log
dist/

13
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"version": "0.1.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"cwd": "${workspaceFolder}",
"module": "contact.__main__",
"args": []
}
]
}

View File

@@ -9,9 +9,9 @@ This Python curses client for Meshtastic is a terminal-based client designed to
<img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4"> <img width="846" alt="Contact - Main UI Screenshot" src="https://github.com/user-attachments/assets/d2996bfb-2c6d-46a8-b820-92a9143375f4">
<br><br> <br><br>
The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `settings.py` The settings dialogue can be accessed within the client or may be run standalone to configure your node by launching `contact --settings` or `contact -c`
<img width="441" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dd47f52a-d4d8-4e40-8001-9ea53d87f816" /> <img width="573" alt="Contact - Settings Dialogue" src="https://github.com/user-attachments/assets/dbe1287b-5558-407c-84b8-2a1bc913dec8" />
## Message Persistence ## Message Persistence
@@ -48,17 +48,18 @@ Optional arguments to specify a device to connect to and how.
- `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`. - `--port`, `--serial`, `-s`: The port to connect to via serial, e.g. `/dev/ttyUSB0`.
- `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed. - `--host`, `--tcp`, `-t`: The hostname or IP address to connect to using TCP, will default to localhost if no host is passed.
- `--ble`, `-b`: The BLE device MAC address or name to connect to. - `--ble`, `-b`: The BLE device MAC address or name to connect to.
- `--settings`, `--set`, `--control`, `-c`: Launch directly into the settings.
If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost. If no connection arguments are specified, the client will attempt a serial connection and then a TCP connection to localhost.
### Example Usage ### Example Usage
```sh ```sh
python main.py --port /dev/ttyUSB0 contact --port /dev/ttyUSB0
python main.py --host 192.168.1.1 contact --host 192.168.1.1
python main.py --ble BlAddressOfDevice contact --ble BlAddressOfDevice
``` ```
To quickly connect to localhost, use: To quickly connect to localhost, use:
```sh ```sh
python main.py -t contact -t
``` ```

View File

@@ -3,7 +3,6 @@
''' '''
Contact - A Console UI for Meshtastic by http://github.com/pdxlocations Contact - A Console UI for Meshtastic by http://github.com/pdxlocations
Powered by Meshtastic.org Powered by Meshtastic.org
V 1.2.2
Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk. Meshtastic® is a registered trademark of Meshtastic LLC. Meshtastic software components are released under various licenses, see GitHub for details. No warranty is provided - use at your own risk.
''' '''
@@ -15,21 +14,22 @@ from pubsub import pub
import sys import sys
import io import io
import logging import logging
import subprocess
import traceback import traceback
import threading import threading
from utilities.db_handler import init_nodedb, load_messages_from_db from contact.utilities.db_handler import init_nodedb, load_messages_from_db
from message_handlers.rx_handler import on_receive from contact.message_handlers.rx_handler import on_receive
from settings import set_region from contact.settings import set_region
from ui.curses_ui import main_ui from contact.ui.contact_ui import main_ui
from ui.colors import setup_colors from contact.ui.colors import setup_colors
from ui.splash import draw_splash from contact.ui.splash import draw_splash
import ui.default_config as config import contact.ui.default_config as config
from utilities.arg_parser import setup_parser from contact.utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface from contact.utilities.interfaces import initialize_interface
from utilities.input_handlers import get_list_input from contact.utilities.input_handlers import get_list_input
from utilities.utils import get_channels, get_node_list, get_nodeNum from contact.utilities.utils import get_channels, get_node_list, get_nodeNum
import globals import contact.globals as globals
# Set ncurses compatibility settings # Set ncurses compatibility settings
os.environ["NCURSES_NO_UTF8_ACS"] = "1" os.environ["NCURSES_NO_UTF8_ACS"] = "1"
@@ -58,6 +58,11 @@ def main(stdscr):
parser = setup_parser() parser = setup_parser()
args = parser.parse_args() args = parser.parse_args()
# Check if --settings was passed and run settings.py as a subprocess
if getattr(args, 'settings', False):
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
return
logging.info("Initializing interface %s", args) logging.info("Initializing interface %s", args)
with globals.lock: with globals.lock:
globals.interface = initialize_interface(args) globals.interface = initialize_interface(args)
@@ -87,7 +92,7 @@ def main(stdscr):
logging.error("Console output before crash:\n%s", console_output) logging.error("Console output before crash:\n%s", console_output)
raise # Re-raise only unexpected errors raise # Re-raise only unexpected errors
if __name__ == "__main__": def start():
log_file = config.log_file_path log_file = config.log_file_path
log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes log_f = open(log_file, "a", buffering=1) # Enable line-buffering for immediate log writes
@@ -103,4 +108,7 @@ if __name__ == "__main__":
except Exception as e: except Exception as e:
logging.error("Fatal error in curses wrapper: %s", e) logging.error("Fatal error in curses wrapper: %s", e)
logging.error("Traceback: %s", traceback.format_exc()) logging.error("Traceback: %s", traceback.format_exc())
sys.exit(1) # Exit with an error code sys.exit(1) # Exit with an error code
if __name__ == "__main__":
start()

View File

@@ -78,6 +78,13 @@ dns, "IPv4 DNS server", ""
rsyslog_server, "RSyslog server", "" rsyslog_server, "RSyslog server", ""
enabled_protocols, "Enabled protocols", "" enabled_protocols, "Enabled protocols", ""
[config.network.ipv4_config]
title, "IPv4 Config", ""
ip, "IP", ""
gateway, "Gateway", ""
subnet, "Subnet", ""
dns, "DNS", ""
[config.display] [config.display]
title, "Display" title, "Display"
screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received." screen_on_secs, "Screen on duration", "How long the screen remains on in seconds after the user button is pressed or messages are received."
@@ -105,6 +112,35 @@ theme, "Theme", ""
alert_enabled, "Alert enabled", "" alert_enabled, "Alert enabled", ""
banner_enabled, "Banner enabled", "" banner_enabled, "Banner enabled", ""
ring_tone_id, "Ring tone ID", "" ring_tone_id, "Ring tone ID", ""
language, "Language", ""
node_filter, "Node Filter", ""
node_highlight, "Node Highlight", ""
calibration_data, "Calibration Data", ""
map_data, "Map Data", ""
[config.device_ui.node_filter]
title, "Node Filter"
unknown_switch, "Unknown Switch", ""
offline_switch, "Offline Switch", ""
public_key_switch, "Public Key Switch", ""
hops_away, "Hops Away", ""
position_switch, "Position Switch", ""
node_name, "Node Name", ""
channel, "Channel", ""
[config.device_ui.node_highlight]
title, "Node Highlight"
chat_switch, "Chat Switch", ""
position_switch, "Position Switch", ""
telemetry_switch, "Telemetry Switch", ""
iaq_switch, "IAQ Switch", ""
node_name, "Node Name", ""
[config.device_ui.map_data]
title, "Map Data"
home, "Home", ""
style, "Style", ""
follow_gps, "Follow GPS", ""
[config.lora] [config.lora]
title, "LoRa" title, "LoRa"

View File

@@ -1,11 +1,11 @@
import logging import logging
import time import time
from utilities.utils import refresh_node_list from contact.utilities.utils import refresh_node_list
from datetime import datetime from datetime import datetime
from ui.curses_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification from contact.ui.contact_ui import draw_packetlog_win, draw_node_list, draw_messages_window, draw_channel_list, add_notification
from utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db from contact.utilities.db_handler import save_message_to_db, maybe_store_nodeinfo_in_db, get_name_from_database, update_node_info_in_db
import ui.default_config as config import contact.ui.default_config as config
import globals import contact.globals as globals
from datetime import datetime from datetime import datetime

View File

@@ -3,16 +3,16 @@ import google.protobuf.json_format
from meshtastic import BROADCAST_NUM from meshtastic import BROADCAST_NUM
from meshtastic.protobuf import mesh_pb2, portnums_pb2 from meshtastic.protobuf import mesh_pb2, portnums_pb2
from utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db from contact.utilities.db_handler import save_message_to_db, update_ack_nak, get_name_from_database, is_chat_archived, update_node_info_in_db
import ui.default_config as config import contact.ui.default_config as config
import globals import contact.globals as globals
ack_naks = {} ack_naks = {}
# Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention # Note "onAckNak" has special meaning to the API, thus the nonstandard naming convention
# See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462 # See https://github.com/meshtastic/python/blob/master/meshtastic/mesh_interface.py#L462
def onAckNak(packet): def onAckNak(packet):
from ui.curses_ui import draw_messages_window from contact.ui.contact_ui import draw_messages_window
request = packet['decoded']['requestId'] request = packet['decoded']['requestId']
if(request not in ack_naks): if(request not in ack_naks):
return return
@@ -43,7 +43,7 @@ def onAckNak(packet):
def on_response_traceroute(packet): def on_response_traceroute(packet):
"""on response for trace route""" """on response for trace route"""
from ui.curses_ui import draw_channel_list, draw_messages_window, add_notification from contact.ui.contact_ui import draw_channel_list, draw_messages_window, add_notification
refresh_channels = False refresh_channels = False
refresh_messages = False refresh_messages = False

View File

@@ -5,13 +5,13 @@ import logging
import sys import sys
import traceback import traceback
import ui.default_config as config import contact.ui.default_config as config
from utilities.input_handlers import get_list_input from contact.utilities.input_handlers import get_list_input
from ui.colors import setup_colors from contact.ui.colors import setup_colors
from ui.splash import draw_splash from contact.ui.splash import draw_splash
from ui.control_ui import set_region, settings_menu from contact.ui.control_ui import set_region, settings_menu
from utilities.arg_parser import setup_parser from contact.utilities.arg_parser import setup_parser
from utilities.interfaces import initialize_interface from contact.utilities.interfaces import initialize_interface
def main(stdscr): def main(stdscr):

View File

@@ -1,5 +1,5 @@
import curses import curses
import ui.default_config as config import contact.ui.default_config as config
COLOR_MAP = { COLOR_MAP = {
"black": curses.COLOR_BLACK, "black": curses.COLOR_BLACK,

View File

@@ -1,15 +1,17 @@
import curses import curses
import textwrap import textwrap
import time
import logging import logging
import traceback import traceback
from 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 settings import settings_menu from contact.settings import settings_menu
from message_handlers.tx_handler import send_message, send_traceroute from contact.message_handlers.tx_handler import send_message, send_traceroute
from ui.colors import setup_colors, get_color from contact.ui.colors import setup_colors, get_color
from 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
import ui.default_config as config from contact.utilities.input_handlers import get_list_input
import ui.dialog import contact.ui.default_config as config
import globals import contact.ui.dialog
import contact.globals as globals
def handle_resize(stdscr, firstrun): def handle_resize(stdscr, firstrun):
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, function_win, packetlog_win, entry_win
@@ -212,7 +214,7 @@ def main_ui(stdscr):
elif char == chr(20): elif char == chr(20):
send_traceroute() send_traceroute()
curses.curs_set(0) # Hide cursor curses.curs_set(0) # Hide cursor
ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.") contact.ui.dialog.dialog(stdscr, "Traceroute Sent", "Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.")
curses.curs_set(1) # Show cursor again curses.curs_set(1) # Show cursor again
handle_resize(stdscr, False) handle_resize(stdscr, False)
@@ -293,10 +295,79 @@ def main_ui(stdscr):
draw_channel_list() draw_channel_list()
draw_messages_window() draw_messages_window()
if(globals.current_window == 2):
curses.curs_set(0)
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from nodedb?", "No", ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.removeNode(globals.node_list[globals.selected_node])
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
del(globals.interface.nodesByNum[globals.node_list[globals.selected_node]])
# Convert to "!hex" representation that interface.nodes uses
hexid = f"!{hex(globals.node_list[globals.selected_node])[2:]}"
del(globals.interface.nodes[hexid])
globals.node_list.pop(globals.selected_node)
draw_messages_window()
draw_node_list()
else:
draw_messages_window()
curses.curs_set(1)
continue
# ^/
elif char == chr(31): elif char == chr(31):
if(globals.current_window == 2 or globals.current_window == 0): if(globals.current_window == 2 or globals.current_window == 0):
search(globals.current_window) search(globals.current_window)
# ^F
elif char == chr(6):
if globals.current_window == 2:
selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
curses.curs_set(0)
if 'isFavorite' not in selectedNode or selectedNode['isFavorite'] == False:
confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Favorite?", None, ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.setFavorite(globals.node_list[globals.selected_node])
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = True
refresh_node_list()
else:
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Favorites?", None, ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.removeFavorite(globals.node_list[globals.selected_node])
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isFavorite'] = False
refresh_node_list()
handle_resize(stdscr, False)
elif char == chr(7):
if globals.current_window == 2:
selectedNode = globals.interface.nodesByNum[globals.node_list[globals.selected_node]]
curses.curs_set(0)
if 'isIgnored' not in selectedNode or selectedNode['isIgnored'] == False:
confirmation = get_list_input(f"Set {get_name_from_database(globals.node_list[globals.selected_node])} as Ignored?", "No", ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.setIgnored(globals.node_list[globals.selected_node])
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = True
else:
confirmation = get_list_input(f"Remove {get_name_from_database(globals.node_list[globals.selected_node])} from Ignored?", "No", ["Yes", "No"])
if confirmation == "Yes":
globals.interface.localNode.removeIgnored(globals.node_list[globals.selected_node])
globals.interface.nodesByNum[globals.node_list[globals.selected_node]]['isIgnored'] = False
handle_resize(stdscr, False)
else: else:
# Append typed character to input text # Append typed character to input text
if(isinstance(char, str)): if(isinstance(char, str)):
@@ -411,7 +482,12 @@ def draw_node_list():
node = globals.interface.nodesByNum[node_num] node = globals.interface.nodesByNum[node_num]
secure = 'user' in node and 'publicKey' in node['user'] and node['user']['publicKey'] secure = 'user' in node and 'publicKey' in node['user'] and node['user']['publicKey']
node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2] node_str = f"{'🔐' if secure else '🔓'} {get_name_from_database(node_num, 'long')}".ljust(box_width - 2)[:box_width - 2]
nodes_pad.addstr(i, 1, node_str, get_color("node_list", reverse=globals.selected_node == i and globals.current_window == 2)) color = "node_list"
if 'isFavorite' in node and node['isFavorite']:
color = "node_favorite"
if 'isIgnored' in node and node['isIgnored']:
color = "node_ignored"
nodes_pad.addstr(i, 1, node_str, get_color(color, reverse=globals.selected_node == i and globals.current_window == 2))
nodes_win.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame")) nodes_win.attrset(get_color("window_frame_selected") if globals.current_window == 2 else get_color("window_frame"))
nodes_win.box() nodes_win.box()
@@ -618,7 +694,7 @@ def draw_node_details():
draw_centered_text_field(function_win, nodestr, 0, get_color("commands")) draw_centered_text_field(function_win, nodestr, 0, get_color("commands"))
def draw_help(): def draw_help():
cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat"] cmds = ["↑→↓← = Select", " ENTER = Send", " ` = Settings", " ^P = Packet Log", " ESC = Quit", " ^t = Traceroute", " ^d = Archive Chat", " ^f = Favorite", " ^g = Ignore"]
function_str = "" function_str = ""
for s in cmds: for s in cmds:
if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2): if(len(function_str) + len(s) < function_win.getmaxyx()[1] - 2):
@@ -672,9 +748,18 @@ def refresh_pad(window):
def highlight_line(highlight, window, line): def highlight_line(highlight, window, line):
pad = nodes_pad pad = nodes_pad
color = get_color("node_list") color = get_color("node_list")
select_len = nodes_win.getmaxyx()[1] - 2 select_len = nodes_win.getmaxyx()[1] - 2
if window == 2:
node_num = globals.node_list[line]
node = globals.interface.nodesByNum[node_num]
if 'isFavorite' in node and node['isFavorite']:
color = get_color("node_favorite")
if 'isIgnored' in node and node['isIgnored']:
color = get_color("node_ignored")
if(window == 0): if(window == 0):
pad = channel_pad pad = channel_pad
color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list") color = get_color("channel_selected" if (line == globals.selected_channel and highlight == False) else "channel_list")
@@ -703,4 +788,4 @@ def draw_centered_text_field(win, text, y_offset, color):
def draw_debug(value): def draw_debug(value):
function_win.addstr(1, 1, f"debug: {value} ") function_win.addstr(1, 1, f"debug: {value} ")
function_win.refresh() function_win.refresh()

View File

@@ -5,14 +5,18 @@ import os
import re import re
import sys import sys
from utilities.save_to_radio import save_changes from contact.utilities.save_to_radio import save_changes
from utilities.config_io import config_export, config_import from contact.utilities.config_io import config_export, config_import
from utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input from contact.utilities.input_handlers import get_repeated_input, get_text_input, get_fixed32_input, get_list_input, get_admin_key_input
from ui.menus import generate_menu_from_protobuf from contact.ui.menus import generate_menu_from_protobuf
from ui.colors import get_color from contact.ui.colors import get_color
from ui.dialog import dialog from contact.ui.dialog import dialog
from utilities.control_utils import parse_ini_file, transform_menu_path from contact.utilities.control_utils import parse_ini_file, transform_menu_path
from ui.user_config import json_editor from contact.ui.user_config import json_editor
from contact.ui.ui_state import MenuState
state = MenuState()
# Constants # Constants
width = 80 width = 80
@@ -27,20 +31,18 @@ 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(locals_dir, "localisations", "en.ini") translation_file = os.path.join(parent_dir, "localisations", "en.ini")
config_folder = os.path.join(parent_dir, "node-configs")
config_folder = os.path.join(locals_dir, "node-configs")
# 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(current_menu, menu_path, selected_index, show_save_option, help_text): def display_menu(state):
min_help_window_height = 6 min_help_window_height = 6
num_items = len(current_menu) + (1 if show_save_option else 0) num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Track visible range
global start_index
if 'start_index' not in globals():
start_index = [0] # Initialize if not set
# Determine the available height for the menu # Determine the available height for the menu
max_menu_height = curses.LINES max_menu_height = curses.LINES
@@ -60,18 +62,18 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help
menu_win.border() menu_win.border()
menu_win.keypad(True) menu_win.keypad(True)
menu_pad = curses.newpad(len(current_menu) + 1, width - 8) menu_pad = curses.newpad(len(state.current_menu) + 1, width - 8)
menu_pad.bkgd(get_color("background")) menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_path) header = " > ".join(word.title() for word in state.menu_path)
if len(header) > width - 4: if len(header) > width - 4:
header = header[:width - 7] + "..." header = header[:width - 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_path) transformed_path = transform_menu_path(state.menu_path)
for idx, option in enumerate(current_menu): for idx, option in enumerate(state.current_menu):
field_info = current_menu[option] field_info = state.current_menu[option]
current_value = field_info[1] if isinstance(field_info, tuple) else "" current_value = field_info[1] if isinstance(field_info, tuple) else ""
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)
@@ -80,41 +82,41 @@ def display_menu(current_menu, menu_path, selected_index, show_save_option, help
display_value = f"{current_value}"[:width // 2 - 4] display_value = f"{current_value}"[:width // 2 - 4]
try: try:
color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == selected_index)) color = get_color("settings_sensitive" if option in sensitive_settings else "settings_default", reverse=(idx == 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:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
except curses.error: except curses.error:
pass pass
if show_save_option: if state.show_save_option:
save_position = menu_height - 2 save_position = menu_height - 2
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(selected_index == len(current_menu)))) menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(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, current_menu, selected_index, transformed_path) draw_help_window(start_y, start_x, menu_height, max_help_lines, transformed_path, state)
menu_win.refresh() menu_win.refresh()
menu_pad.refresh( menu_pad.refresh(
start_index[-1], 0, state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0), menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 8 menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
) )
max_index = num_items + (1 if show_save_option else 0) - 1 max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option) draw_arrows(menu_win, visible_height, max_index, state)
return menu_win, menu_pad return menu_win, menu_pad
def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, current_menu, selected_index, transformed_path): def draw_help_window(menu_start_y, menu_start_x, menu_height, max_help_lines, transformed_path, state):
global help_win global help_win
if 'help_win' not in globals(): if 'help_win' not in globals():
help_win = None # Initialize if it does not exist help_win = None # Initialize if it does not exist
selected_option = list(current_menu.keys())[selected_index] if current_menu else None selected_option = list(state.current_menu.keys())[state.selected_index] if state.current_menu else None
help_y = menu_start_y + menu_height help_y = menu_start_y + menu_height
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x) help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x)
@@ -249,66 +251,66 @@ def get_wrapped_help_text(help_text, transformed_path, selected_option, width, m
return wrapped_help return wrapped_help
def move_highlight(old_idx, new_idx, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines): def move_highlight(old_idx, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state):
if old_idx == new_idx: # No-op if old_idx == state.selected_index: # No-op
return return
max_index = len(options) + (1 if show_save_option else 0) - 1 max_index = len(options) + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0) visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
# Adjust start_index only when moving out of visible range # Adjust state.start_index only when moving out of visible range
if new_idx == max_index and show_save_option: if state.selected_index == max_index and state.show_save_option:
pass pass
elif new_idx < start_index[-1]: # Moving above the visible area elif state.selected_index < state.start_index[-1]: # Moving above the visible area
start_index[-1] = new_idx state.start_index[-1] = state.selected_index
elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
start_index[-1] = new_idx - visible_height state.start_index[-1] = state.selected_index - visible_height
pass pass
# Ensure start_index is within bounds # Ensure state.start_index is within bounds
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1)) state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
# Clear old selection # Clear old selection
if show_save_option and old_idx == max_index: if state.show_save_option and old_idx == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")) menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else: else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default")) menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# Highlight new selection # Highlight new selection
if show_save_option and new_idx == max_index: if state.show_save_option and state.selected_index == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True)) menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
else: else:
menu_pad.chgat(new_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[new_idx] in sensitive_settings else get_color("settings_default", reverse=True)) menu_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh() menu_win.refresh()
# Refresh pad only if scrolling is needed # Refresh pad only if scrolling is needed
menu_pad.refresh(start_index[-1], 0, menu_pad.refresh(state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4, menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height, menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4) menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
# Update help window # Update help window
transformed_path = transform_menu_path(menu_path) transformed_path = transform_menu_path(state.menu_path)
selected_option = options[new_idx] if new_idx < len(options) else None selected_option = options[state.selected_index] if state.selected_index < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0] help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1]) help_win = update_help_window(help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_win.getbegyx()[1])
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option) draw_arrows(menu_win, visible_height, max_index, state)
def draw_arrows(win, visible_height, max_index, start_index, show_save_option): def draw_arrows(win, visible_height, max_index, state):
# vh = visible_height + (1 if show_save_option else 0) # vh = visible_height + (1 if show_save_option else 0)
mi = max_index - (2 if show_save_option else 0) mi = max_index - (2 if state.show_save_option else 0)
if visible_height < mi: if visible_height < mi:
if start_index[-1] > 0: if state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default")) win.addstr(3, 2, "", get_color("settings_default"))
else: else:
win.addstr(3, 2, " ", get_color("settings_default")) win.addstr(3, 2, " ", get_color("settings_default"))
if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1) : if mi - state.start_index[-1] >= visible_height + (0 if state.show_save_option else 1) :
win.addstr(visible_height + 3, 2, "", get_color("settings_default")) win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else: else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default")) win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
@@ -318,47 +320,47 @@ def settings_menu(stdscr, interface):
curses.update_lines_cols() curses.update_lines_cols()
menu = generate_menu_from_protobuf(interface) menu = generate_menu_from_protobuf(interface)
current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
menu_path = ["Main Menu"] state.menu_path = ["Main Menu"]
menu_index = []
selected_index = 0
modified_settings = {} modified_settings = {}
need_redraw = True need_redraw = True
show_save_option = False state.show_save_option = False
while True: while True:
if(need_redraw): if(need_redraw):
options = list(current_menu.keys()) options = list(state.current_menu.keys())
show_save_option = ( state.show_save_option = (
len(menu_path) > 2 and ("Radio Settings" in menu_path or "Module Settings" in menu_path) len(state.menu_path) > 2 and ("Radio Settings" in state.menu_path or "Module Settings" in state.menu_path)
) or ( ) or (
len(menu_path) == 2 and "User Settings" in menu_path len(state.menu_path) == 2 and "User Settings" in state.menu_path
) or ( ) or (
len(menu_path) == 3 and "Channels" in menu_path len(state.menu_path) == 3 and "Channels" in state.menu_path
) )
# Display the menu # Display the menu
menu_win, menu_pad = display_menu(current_menu, menu_path, selected_index, show_save_option, help_text) menu_win, menu_pad = display_menu(state)
need_redraw = False need_redraw = False
# Capture user input # Capture user input
key = menu_win.getch() key = menu_win.getch()
max_index = len(options) + (1 if show_save_option else 0) - 1 max_index = len(options) + (1 if state.show_save_option else 0) - 1
# max_help_lines = 4 # max_help_lines = 4
if key == curses.KEY_UP: if key == curses.KEY_UP:
old_selected_index = selected_index old_selected_index = state.selected_index
selected_index = max_index if selected_index == 0 else selected_index - 1 state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path,max_help_lines) move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
elif key == curses.KEY_DOWN: elif key == curses.KEY_DOWN:
old_selected_index = selected_index old_selected_index = state.selected_index
selected_index = 0 if selected_index == max_index else selected_index + 1 state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines) move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
elif key == curses.KEY_RESIZE: elif key == curses.KEY_RESIZE:
need_redraw = True need_redraw = True
@@ -370,36 +372,36 @@ def settings_menu(stdscr, interface):
menu_win.refresh() menu_win.refresh()
help_win.refresh() help_win.refresh()
elif key == ord("\t") and show_save_option: elif key == ord("\t") and state.show_save_option:
old_selected_index = selected_index old_selected_index = state.selected_index
selected_index = max_index state.selected_index = max_index
move_highlight(old_selected_index, selected_index, options, show_save_option, menu_win, menu_pad, help_win, help_text, menu_path, max_help_lines) move_highlight(old_selected_index, options, menu_win, menu_pad, help_win, help_text, max_help_lines, state)
elif key == curses.KEY_RIGHT or key == ord('\n'): elif key == curses.KEY_RIGHT or key == ord('\n'):
need_redraw = True need_redraw = True
start_index.append(0) state.start_index.append(0)
menu_win.erase() menu_win.erase()
help_win.erase() help_win.erase()
# draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, current_menu, selected_index, transform_menu_path(menu_path)) # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
menu_win.refresh() menu_win.refresh()
help_win.refresh() help_win.refresh()
if show_save_option and selected_index == len(options): if state.show_save_option and state.selected_index == len(options):
save_changes(interface, menu_path, modified_settings) save_changes(interface, modified_settings, state)
modified_settings.clear() modified_settings.clear()
logging.info("Changes Saved") logging.info("Changes Saved")
if len(menu_path) > 1: if len(state.menu_path) > 1:
menu_path.pop() state.menu_path.pop()
current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
for step in menu_path[1:]: for step in state.menu_path[1:]:
current_menu = current_menu.get(step, {}) state.current_menu = state.current_menu.get(step, {})
selected_index = 0 state.selected_index = 0
continue continue
selected_option = options[selected_index] selected_option = options[state.selected_index]
if selected_option == "Exit": if selected_option == "Exit":
break break
@@ -408,7 +410,7 @@ def settings_menu(stdscr, interface):
filename = get_text_input("Enter a filename for the config file") filename = get_text_input("Enter a filename for the config file")
if not filename: if not filename:
logging.info("Export aborted: No filename provided.") logging.info("Export aborted: No filename provided.")
start_index.pop() state.start_index.pop()
continue # Go back to the menu continue # Go back to the menu
if not filename.lower().endswith(".yaml"): if not filename.lower().endswith(".yaml"):
filename += ".yaml" filename += ".yaml"
@@ -421,14 +423,14 @@ def settings_menu(stdscr, interface):
overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"]) overwrite = get_list_input(f"{filename} already exists. Overwrite?", None, ["Yes", "No"])
if overwrite == "No": if overwrite == "No":
logging.info("Export cancelled: User chose not to overwrite.") logging.info("Export cancelled: User chose not to overwrite.")
start_index.pop() state.start_index.pop()
continue # Return to menu continue # Return to menu
os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True) os.makedirs(os.path.dirname(yaml_file_path), exist_ok=True)
with open(yaml_file_path, "w", encoding="utf-8") as file: with open(yaml_file_path, "w", encoding="utf-8") as file:
file.write(config_text) file.write(config_text)
logging.info(f"Config file saved to {yaml_file_path}") logging.info(f"Config file saved to {yaml_file_path}")
dialog(stdscr, "Config File Saved:", yaml_file_path) dialog(stdscr, "Config File Saved:", yaml_file_path)
start_index.pop() state.start_index.pop()
continue continue
except PermissionError: except PermissionError:
logging.error(f"Permission denied: Unable to write to {yaml_file_path}") logging.error(f"Permission denied: Unable to write to {yaml_file_path}")
@@ -436,7 +438,7 @@ def settings_menu(stdscr, interface):
logging.error(f"OS error while saving config: {e}") logging.error(f"OS error while saving config: {e}")
except Exception as e: except Exception as e:
logging.error(f"Unexpected error: {e}") logging.error(f"Unexpected error: {e}")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Load Config File": elif selected_option == "Load Config File":
@@ -459,7 +461,7 @@ def settings_menu(stdscr, interface):
overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"]) overwrite = get_list_input(f"Are you sure you want to load {filename}?", None, ["Yes", "No"])
if overwrite == "Yes": if overwrite == "Yes":
config_import(interface, file_path) config_import(interface, file_path)
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Config URL": elif selected_option == "Config URL":
@@ -471,7 +473,7 @@ def settings_menu(stdscr, interface):
if overwrite == "Yes": if overwrite == "Yes":
interface.localNode.setURL(new_value) interface.localNode.setURL(new_value)
logging.info(f"New Config URL sent to node") logging.info(f"New Config URL sent to node")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Reboot": elif selected_option == "Reboot":
@@ -479,7 +481,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.reboot() interface.localNode.reboot()
logging.info(f"Node Reboot Requested by menu") logging.info(f"Node Reboot Requested by menu")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Reset Node DB": elif selected_option == "Reset Node DB":
@@ -487,7 +489,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.resetNodeDb() interface.localNode.resetNodeDb()
logging.info(f"Node DB Reset Requested by menu") logging.info(f"Node DB Reset Requested by menu")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Shutdown": elif selected_option == "Shutdown":
@@ -495,7 +497,7 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.shutdown() interface.localNode.shutdown()
logging.info(f"Node Shutdown Requested by menu") logging.info(f"Node Shutdown Requested by menu")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "Factory Reset": elif selected_option == "Factory Reset":
@@ -503,22 +505,28 @@ def settings_menu(stdscr, interface):
if confirmation == "Yes": if confirmation == "Yes":
interface.localNode.factoryReset() interface.localNode.factoryReset()
logging.info(f"Factory Reset Requested by menu") logging.info(f"Factory Reset Requested by menu")
start_index.pop() state.start_index.pop()
continue continue
elif selected_option == "App Settings": elif selected_option == "App Settings":
menu_win.clear() menu_win.clear()
menu_win.refresh() menu_win.refresh()
json_editor(stdscr) # Open the App Settings menu state.menu_path.append("App Settings")
state.menu_index.append(state.selected_index)
json_editor(stdscr, state) # Open the App Settings menu
state.current_menu = menu["Main Menu"]
state.menu_path = ["Main Menu"]
state.start_index.pop()
state.selected_index = 4
continue continue
# need_redraw = True # need_redraw = True
field_info = current_menu.get(selected_option) field_info = state.current_menu.get(selected_option)
if isinstance(field_info, tuple): if isinstance(field_info, tuple):
field, current_value = field_info field, current_value = field_info
# Transform the menu path to get the full key # Transform the menu path to get the full key
transformed_path = transform_menu_path(menu_path) transformed_path = transform_menu_path(state.menu_path)
full_key = '.'.join(transformed_path + [selected_option]) full_key = '.'.join(transformed_path + [selected_option])
# Fetch human-readable name from field_mapping # Fetch human-readable name from field_mapping
@@ -528,70 +536,73 @@ def settings_menu(stdscr, interface):
if selected_option in ['longName', 'shortName']: if selected_option in ['longName', 'shortName']:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
elif selected_option == 'isLicensed': elif selected_option == 'isLicensed':
new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"]) new_value = get_list_input(f"{human_readable_name} is currently: {current_value}", str(current_value), ["True", "False"])
new_value = new_value == "True" new_value = new_value == "True"
current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
for option, (field, value) in current_menu.items(): for option, (field, value) in state.current_menu.items():
modified_settings[option] = value modified_settings[option] = value
start_index.pop() state.start_index.pop()
elif selected_option in ['latitude', 'longitude', 'altitude']: elif selected_option in ['latitude', 'longitude', 'altitude']:
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
for option in ['latitude', 'longitude', 'altitude']: for option in ['latitude', 'longitude', 'altitude']:
if option in current_menu: if option in state.current_menu:
modified_settings[option] = current_menu[option][1] modified_settings[option] = state.current_menu[option][1]
start_index.pop() state.start_index.pop()
elif selected_option == "admin_key": elif selected_option == "admin_key":
new_values = get_admin_key_input(current_value) new_values = get_admin_key_input(current_value)
new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values] new_value = current_value if new_values is None else [base64.b64decode(key) for key in new_values]
start_index.pop() state.start_index.pop()
elif field.type == 8: # Handle boolean type elif field.type == 8: # Handle boolean type
new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"]) new_value = get_list_input(human_readable_name, str(current_value), ["True", "False"])
new_value = new_value == "True" or new_value is True if new_value == "Not Set":
start_index.pop() pass # Leave it as-is
else:
new_value = new_value == "True" or new_value is True
state.start_index.pop()
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
new_value = get_repeated_input(current_value) new_value = get_repeated_input(current_value)
new_value = current_value if new_value is None else new_value.split(", ") new_value = current_value if new_value is None else new_value.split(", ")
start_index.pop() state.start_index.pop()
elif field.enum_type: # Enum field elif field.enum_type: # Enum field
enum_options = {v.name: v.number for v in field.enum_type.values} enum_options = {v.name: v.number for v in field.enum_type.values}
new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys())) new_value_name = get_list_input(human_readable_name, current_value, list(enum_options.keys()))
new_value = enum_options.get(new_value_name, current_value) new_value = enum_options.get(new_value_name, current_value)
start_index.pop() state.start_index.pop()
elif field.type == 7: # Field type 7 corresponds to FIXED32 elif field.type == 7: # Field type 7 corresponds to FIXED32
new_value = get_fixed32_input(current_value) new_value = get_fixed32_input(current_value)
start_index.pop() state.start_index.pop()
elif field.type == 13: # Field type 13 corresponds to UINT32 elif field.type == 13: # Field type 13 corresponds to UINT32
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else int(new_value) new_value = current_value if new_value is None else int(new_value)
start_index.pop() state.start_index.pop()
elif field.type == 2: # Field type 13 corresponds to INT64 elif field.type == 2: # Field type 13 corresponds to INT64
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else float(new_value) new_value = current_value if new_value is None else float(new_value)
start_index.pop() state.start_index.pop()
else: # Handle other field types else: # Handle other field types
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}") new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
new_value = current_value if new_value is None else new_value new_value = current_value if new_value is None else new_value
start_index.pop() state.start_index.pop()
for key in menu_path[3:]: # Skip "Main Menu" for key in state.menu_path[3:]: # Skip "Main Menu"
modified_settings = modified_settings.setdefault(key, {}) modified_settings = modified_settings.setdefault(key, {})
# Add the new value to the appropriate level # Add the new value to the appropriate level
@@ -602,12 +613,12 @@ def settings_menu(stdscr, interface):
enum_value_descriptor = field.enum_type.values_by_number.get(new_value) enum_value_descriptor = field.enum_type.values_by_number.get(new_value)
new_value = enum_value_descriptor.name if enum_value_descriptor else new_value new_value = enum_value_descriptor.name if enum_value_descriptor else new_value
current_menu[selected_option] = (field, new_value) state.current_menu[selected_option] = (field, new_value)
else: else:
current_menu = current_menu[selected_option] state.current_menu = state.current_menu[selected_option]
menu_path.append(selected_option) state.menu_path.append(selected_option)
menu_index.append(selected_index) state.menu_index.append(state.selected_index)
selected_index = 0 state.selected_index = 0
elif key == curses.KEY_LEFT: elif key == curses.KEY_LEFT:
@@ -617,22 +628,22 @@ def settings_menu(stdscr, interface):
help_win.erase() 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, current_menu, selected_index, transform_menu_path(menu_path)) # draw_help_window(menu_win.getbegyx()[0], menu_win.getbegyx()[1], menu_win.getmaxyx()[0], max_help_lines, state.current_menu, selected_index, transform_menu_path(state.menu_path))
menu_win.refresh() menu_win.refresh()
help_win.refresh() help_win.refresh()
if len(menu_path) < 2: if len(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_path) > 1: if len(state.menu_path) > 1:
menu_path.pop() state.menu_path.pop()
current_menu = menu["Main Menu"] state.current_menu = menu["Main Menu"]
for step in menu_path[1:]: for step in state.menu_path[1:]:
current_menu = current_menu.get(step, {}) state.current_menu = state.current_menu.get(step, {})
selected_index = menu_index.pop() state.selected_index = state.menu_index.pop()
start_index.pop() state.start_index.pop()
elif key == 27: # Escape key elif key == 27: # Escape key
menu_win.erase() menu_win.erase()

View File

@@ -65,7 +65,9 @@ def initialize_config():
"settings_save": ["green", "black"], "settings_save": ["green", "black"],
"settings_breadcrumbs": ["white", "black"], "settings_breadcrumbs": ["white", "black"],
"settings_warning": ["red", "black"], "settings_warning": ["red", "black"],
"settings_note": ["green", "black"] "settings_note": ["green", "black"],
"node_favorite": ["green", "black"],
"node_ignored": ["red", "black"]
} }
COLOR_CONFIG_LIGHT = { COLOR_CONFIG_LIGHT = {
"default": ["black", "white"], "default": ["black", "white"],
@@ -89,7 +91,9 @@ def initialize_config():
"settings_save": ["green", "white"], "settings_save": ["green", "white"],
"settings_breadcrumbs": ["black", "white"], "settings_breadcrumbs": ["black", "white"],
"settings_warning": ["red", "white"], "settings_warning": ["red", "white"],
"settings_note": ["green", "white"] "settings_note": ["green", "white"],
"node_favorite": ["green", "white"],
"node_ignored": ["red", "white"]
} }
COLOR_CONFIG_GREEN = { COLOR_CONFIG_GREEN = {
"default": ["green", "black"], "default": ["green", "black"],
@@ -115,7 +119,9 @@ def initialize_config():
"settings_save": ["green", "black"], "settings_save": ["green", "black"],
"settings_breadcrumbs": ["green", "black"], "settings_breadcrumbs": ["green", "black"],
"settings_warning": ["green", "black"], "settings_warning": ["green", "black"],
"settings_note": ["green", "black"] "settings_note": ["green", "black"],
"node_favorite": ["cyan", "white"],
"node_ignored": ["red", "white"]
} }
default_config_variables = { default_config_variables = {
"db_file_path": db_file_path, "db_file_path": db_file_path,

View File

@@ -1,5 +1,5 @@
import curses import curses
from ui.colors import get_color from contact.ui.colors import get_color
def dialog(stdscr, title, message): def dialog(stdscr, title, message):
height, width = stdscr.getmaxyx() height, width = stdscr.getmaxyx()

View File

@@ -1,5 +1,5 @@
import curses import curses
from ui.colors import get_color from contact.ui.colors import get_color
def draw_splash(stdscr): def draw_splash(stdscr):
curses.curs_set(0) curses.curs_set(0)

8
contact/ui/ui_state.py Normal file
View File

@@ -0,0 +1,8 @@
class MenuState:
def __init__(self):
self.menu_index = [] # Row we left the previous menus
self.start_index = [0] # Row to start the menu if it doesn't all fit
self.selected_index = 0 # Selected Row
self.current_menu = {} # Contents of the current menu
self.menu_path = [] # Menu Path
self.show_save_option = False

373
contact/ui/user_config.py Normal file
View File

@@ -0,0 +1,373 @@
import os
import json
import curses
from contact.ui.colors import get_color, setup_colors, COLOR_MAP
from contact.ui.default_config import format_json_single_line_arrays, loaded_config
from contact.utilities.input_handlers import get_list_input
width = 80
save_option = "Save Changes"
sensitive_settings = []
def edit_color_pair(key, current_value):
"""
Allows the user to select a foreground and background color for a key.
"""
color_list = [" "] + list(COLOR_MAP.keys())
fg_color = get_list_input(f"Select Foreground Color for {key}", current_value[0], color_list)
bg_color = get_list_input(f"Select Background Color for {key}", current_value[1], color_list)
return [fg_color, bg_color]
def edit_value(key, current_value, state):
height = 10
input_width = width - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
# Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
# Display instructions
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding
wrapped_lines = [current_value[i:i+wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
edit_win.addstr(4 + i, 2, line, get_color("settings_default"))
edit_win.refresh()
# Handle theme selection dynamically
if key == "theme":
# Load theme names dynamically from the JSON
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
return get_list_input("Select Theme", current_value, theme_options)
elif key == "node_sort":
sort_options = ['lastHeard', 'name', 'hops']
return get_list_input("Sort By", current_value, sort_options)
# Standard Input Mode (Scrollable)
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
curses.curs_set(1)
scroll_offset = 0 # Determines which part of the text is visible
user_input = ""
input_position = (7, 13) # Tuple for row and column
row, col = input_position # Unpack tuple
while True:
visible_text = user_input[scroll_offset:scroll_offset + input_width] # Only show what fits
edit_win.addstr(row, col, " " * input_width, get_color("settings_default")) # Clear previous text
edit_win.addstr(row, col, visible_text, get_color("settings_default")) # Display text
edit_win.refresh()
edit_win.move(row, col + min(len(user_input) - scroll_offset, input_width)) # Adjust cursor position
key = edit_win.get_wch()
if key in (chr(27), curses.KEY_LEFT): # ESC or Left Arrow
curses.curs_set(0)
return current_value # Exit without returning a value
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
break
elif key in (curses.KEY_BACKSPACE, chr(127)): # Backspace
if user_input: # Only process if there's something to delete
user_input = user_input[:-1]
if scroll_offset > 0 and len(user_input) < scroll_offset + input_width:
scroll_offset -= 1 # Move back if text is shorter than scrolled area
else:
if isinstance(key, str):
user_input += key
else:
user_input += chr(key)
if len(user_input) > input_width: # Scroll if input exceeds visible area
scroll_offset += 1
curses.curs_set(0)
return user_input if user_input else current_value
def display_menu(state):
"""
Render the configuration menu with a Save button directly added to the window.
"""
num_items = len(state.current_menu) + (1 if state.show_save_option else 0)
# Determine menu items based on the type of current_menu
if isinstance(state.current_menu, dict):
options = list(state.current_menu.keys())
elif isinstance(state.current_menu, list):
options = [f"[{i}]" for i in range(len(state.current_menu))]
else:
options = [] # Fallback in case of unexpected data types
# Calculate dynamic dimensions for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
start_y = (curses.LINES - menu_height) // 2
start_x = (curses.COLS - width) // 2
# Create the window
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad.bkgd(get_color("background"))
# Display the menu path
header = " > ".join(state.menu_path)
if len(header) > width - 4:
header = header[:width - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
for idx, key in enumerate(options):
value = state.current_menu[key] if isinstance(state.current_menu, dict) else state.current_menu[int(key.strip("[]"))]
display_key = f"{key}"[:width // 2 - 2]
display_value = (
f"{value}"[:width // 2 - 8]
)
color = get_color("settings_default", reverse=(idx == state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
# Add Save button to the main window
if state.show_save_option:
save_position = menu_height - 2
menu_win.addstr(save_position, (width - len(save_option)) // 2, save_option, get_color("settings_save", reverse=(state.selected_index == len(state.current_menu))))
menu_win.refresh()
menu_pad.refresh(
state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0),
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4
)
max_index = num_items + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
draw_arrows(menu_win, visible_height, max_index, state)
return menu_win, menu_pad, options
def move_highlight(old_idx, options, menu_win, menu_pad, state):
if old_idx == state.selected_index: # No-op
return
max_index = len(options) + (1 if state.show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if state.show_save_option else 0)
# Adjust state.start_index only when moving out of visible range
if state.selected_index == max_index and state.show_save_option:
pass
elif state.selected_index < state.start_index[-1]: # Moving above the visible area
state.start_index[-1] = state.selected_index
elif state.selected_index >= state.start_index[-1] + visible_height: # Moving below the visible area
state.start_index[-1] = state.selected_index - visible_height
pass
# Ensure state.start_index is within bounds
state.start_index[-1] = max(0, min(state.start_index[-1], max_index - visible_height + 1))
# Clear old selection
if state.show_save_option and old_idx == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_pad.chgat(old_idx, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive") if options[old_idx] in sensitive_settings else get_color("settings_default"))
# Highlight new selection
if state.show_save_option and state.selected_index == max_index:
menu_win.chgat(menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save", reverse=True))
else:
menu_pad.chgat(state.selected_index, 0, menu_pad.getmaxyx()[1], get_color("settings_sensitive", reverse=True) if options[state.selected_index] in sensitive_settings else get_color("settings_default", reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(state.start_index[-1], 0,
menu_win.getbegyx()[0] + 3, menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4)
draw_arrows(menu_win, visible_height, max_index, state)
def draw_arrows(win, visible_height, max_index, state):
mi = max_index - (2 if state.show_save_option else 0)
if visible_height < mi:
if state.start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - state.start_index[-1] >= visible_height + (0 if state.show_save_option else 1) :
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def json_editor(stdscr, state):
state.selected_index = 0 # Track the selected option
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
file_path = os.path.join(parent_dir, "config.json")
state.show_save_option = True # Always show the Save button
# Ensure the file exists
if not os.path.exists(file_path):
with open(file_path, "w") as f:
json.dump({}, f)
# Load JSON data
with open(file_path, "r") as f:
original_data = json.load(f)
data = original_data # Reference to the original data
state.current_menu = data # Track the current level of the menu
# Render the menu
menu_win, menu_pad, options = display_menu(state)
need_redraw = True
while True:
if(need_redraw):
menu_win, menu_pad, options = display_menu(state)
menu_win.refresh()
need_redraw = False
max_index = len(options) + (1 if state.show_save_option else 0) - 1
key = menu_win.getch()
if key == curses.KEY_UP:
old_selected_index = state.selected_index
state.selected_index = max_index if state.selected_index == 0 else state.selected_index - 1
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
elif key == curses.KEY_DOWN:
old_selected_index = state.selected_index
state.selected_index = 0 if state.selected_index == max_index else state.selected_index + 1
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
elif key == ord("\t") and state.show_save_option:
old_selected_index = state.selected_index
state.selected_index = max_index
move_highlight(old_selected_index, options, menu_win, menu_pad, state)
elif key in (curses.KEY_RIGHT, 10, 13): # 10 = \n, 13 = carriage return
need_redraw = True
menu_win.erase()
menu_win.refresh()
if state.selected_index < len(options): # Handle selection of a menu item
selected_key = options[state.selected_index]
state.menu_path.append(str(selected_key))
state.start_index.append(0)
state.menu_index.append(state.selected_index)
# Handle nested data
if isinstance(state.current_menu, dict):
if selected_key in state.current_menu:
selected_data = state.current_menu[selected_key]
else:
continue # Skip invalid key
elif isinstance(state.current_menu, list):
selected_data = state.current_menu[int(selected_key.strip("[]"))]
if isinstance(selected_data, list) and len(selected_data) == 2:
# Edit color pair
new_value = edit_color_pair(selected_key, selected_data)
state.menu_path.pop()
state.start_index.pop()
state.menu_index.pop()
state.current_menu[selected_key] = new_value
elif isinstance(selected_data, (dict, list)):
# Navigate into nested data
state.current_menu = selected_data
state.selected_index = 0 # Reset the selected index
else:
# General value editing
new_value = edit_value(selected_key, selected_data, state)
state.menu_path.pop()
state.menu_index.pop()
state.start_index.pop()
state.current_menu[selected_key] = new_value
need_redraw = True
else:
# Save button selected
save_json(file_path, data)
stdscr.refresh()
continue
elif key in (27, curses.KEY_LEFT): # Escape or Left Arrow
need_redraw = True
menu_win.erase()
menu_win.refresh()
# state.selected_index = state.menu_index[-1]
# Navigate back in the menu
if len(state.menu_path) > 2:
state.menu_path.pop()
state.start_index.pop()
state.current_menu = data
for path in state.menu_path[2:]:
state.current_menu = state.current_menu[path] if isinstance(state.current_menu, dict) else state.current_menu[int(path.strip("[]"))]
else:
# Exit the editor
menu_win.clear()
menu_win.refresh()
break
def save_json(file_path, data):
formatted_json = format_json_single_line_arrays(data)
with open(file_path, "w", encoding="utf-8") as f:
f.write(formatted_json)
setup_colors(reinit=True)
def main(stdscr):
from contact.ui.ui_state import MenuState
state = MenuState()
if len(state.menu_path) == 0:
state.menu_path = ["App Settings"] # Initialize if not set
curses.curs_set(0)
stdscr.keypad(True)
setup_colors()
json_editor(stdscr, state)
if __name__ == "__main__":
curses.wrapper(main)

View File

@@ -33,5 +33,14 @@ def setup_parser():
default=None, default=None,
const="any" const="any"
) )
parser.add_argument(
"--settings",
"--set",
"--control",
"-c",
help="Launch directly into the settings",
action="store_true"
)
return parser return parser

View File

@@ -3,9 +3,9 @@ import time
import logging import logging
from datetime import datetime from datetime import datetime
from utilities.utils import decimal_to_hex from contact.utilities.utils import decimal_to_hex
import ui.default_config as config import contact.ui.default_config as config
import globals import contact.globals as globals
def get_table_name(channel): def get_table_name(channel):
# Construct the table name # Construct the table name
@@ -190,21 +190,15 @@ def maybe_store_nodeinfo_in_db(packet):
except Exception as e: except Exception as e:
logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}") logging.error(f"Unexpected error in maybe_store_nodeinfo_in_db: {e}")
def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model="UNSET", is_licensed=0, role="CLIENT", public_key="", chat_archived=0): def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model=None, is_licensed=None, role=None, public_key=None, chat_archived=None):
"""Update or insert node information into the database, preserving unchanged fields.""" """Update or insert node information into the database, preserving unchanged fields."""
try: try:
ensure_node_table_exists() # Ensure the table exists before any operation ensure_node_table_exists() # Ensure the table exists before any operation
if long_name == None:
long_name = "Meshtastic " + str(decimal_to_hex(user_id)[-4:])
if short_name == None:
short_name = str(decimal_to_hex(user_id)[-4:])
with sqlite3.connect(config.db_file_path) as db_connection: with sqlite3.connect(config.db_file_path) as db_connection:
db_cursor = db_connection.cursor() db_cursor = db_connection.cursor()
table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names table_name = f'"{globals.myNodeNum}_nodedb"' # Quote in case of numeric names
table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')] table_columns = [i[1] for i in db_cursor.execute(f'PRAGMA table_info({table_name})')]
if "chat_archived" not in table_columns: if "chat_archived" not in table_columns:
update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER" update_table_query = f"ALTER TABLE {table_name} ADD COLUMN chat_archived INTEGER"
@@ -225,6 +219,14 @@ def update_node_info_in_db(user_id, long_name=None, short_name=None, hw_model="U
public_key = public_key if public_key is not None else existing_public_key public_key = public_key if public_key is not None else existing_public_key
chat_archived = chat_archived if chat_archived is not None else existing_chat_archived chat_archived = chat_archived if chat_archived is not None else existing_chat_archived
long_name = long_name if long_name is not None else "Meshtastic " + str(decimal_to_hex(user_id)[-4:])
short_name = short_name if short_name is not None else str(decimal_to_hex(user_id)[-4:])
hw_model = hw_model if hw_model is not None else "UNSET"
is_licensed = is_licensed if is_licensed is not None else 0
role = role if role is not None else "CLIENT"
public_key = public_key if public_key is not None else ""
chat_archived = chat_archived if chat_archived is not None else 0
# Upsert logic # Upsert logic
upsert_query = f''' upsert_query = f'''
INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived) INSERT INTO {table_name} (user_id, long_name, short_name, hw_model, is_licensed, role, public_key, chat_archived)

View File

@@ -3,7 +3,7 @@ import binascii
import curses import curses
import ipaddress import ipaddress
import re import re
from ui.colors import get_color from contact.ui.colors import get_color
def wrap_text(text, wrap_width): def wrap_text(text, wrap_width):
"""Wraps text while preserving spaces and breaking long words.""" """Wraps text while preserving spaces and breaking long words."""

View File

@@ -1,6 +1,6 @@
import logging import logging
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
import globals import contact.globals as globals
def initialize_interface(args): def initialize_interface(args):
try: try:
@@ -10,14 +10,15 @@ def initialize_interface(args):
return meshtastic.tcp_interface.TCPInterface(args.host) return meshtastic.tcp_interface.TCPInterface(args.host)
else: else:
try: try:
return meshtastic.serial_interface.SerialInterface(args.port) client = meshtastic.serial_interface.SerialInterface(args.port)
except PermissionError as ex: except PermissionError as ex:
logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}") logging.error(f"You probably need to add yourself to the `dialout` group to use a serial connection. {ex}")
except Exception as ex: except Exception as ex:
logging.error(f"Unexpected error initializing interface: {ex}") logging.error(f"Unexpected error initializing interface: {ex}")
if globals.interface.devPath is None: if client.devPath is None:
return meshtastic.tcp_interface.TCPInterface("meshtastic.local") client = meshtastic.tcp_interface.TCPInterface("localhost")
return client
except Exception as ex: except Exception as ex:
logging.critical(f"Fatal error initializing interface: {ex}") logging.critical(f"Fatal error initializing interface: {ex}")

View File

@@ -4,7 +4,7 @@ import logging
import base64 import base64
import time import time
def save_changes(interface, menu_path, modified_settings): def save_changes(interface, modified_settings, state):
""" """
Save changes to the device based on modified settings. Save changes to the device based on modified settings.
:param interface: Meshtastic interface instance :param interface: Meshtastic interface instance
@@ -52,8 +52,8 @@ def save_changes(interface, menu_path, modified_settings):
if not modified_settings: if not modified_settings:
return return
if menu_path[1] == "Radio Settings" or menu_path[1] == "Module Settings": if state.menu_path[1] == "Radio Settings" or state.menu_path[1] == "Module Settings":
config_category = menu_path[2].lower() # for radio and module configs config_category = state.menu_path[2].lower() # for radio and module configs
if {'latitude', 'longitude', 'altitude'} & modified_settings.keys(): if {'latitude', 'longitude', 'altitude'} & modified_settings.keys():
lat = float(modified_settings.get('latitude', 0.0)) lat = float(modified_settings.get('latitude', 0.0))
@@ -64,7 +64,7 @@ def save_changes(interface, menu_path, modified_settings):
logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}") logging.info(f"Updated {config_category} with Latitude: {lat} and Longitude {lon} and Altitude {alt}")
return return
elif menu_path[1] == "User Settings": # for user configs elif state.menu_path[1] == "User Settings": # for user configs
config_category = "User Settings" config_category = "User Settings"
long_name = modified_settings.get("longName") long_name = modified_settings.get("longName")
short_name = modified_settings.get("shortName") short_name = modified_settings.get("shortName")
@@ -77,11 +77,11 @@ def save_changes(interface, menu_path, modified_settings):
return return
elif menu_path[1] == "Channels": # for channel configs elif state.menu_path[1] == "Channels": # for channel configs
config_category = "Channels" config_category = "Channels"
try: try:
channel = menu_path[-1] channel = state.menu_path[-1]
channel_num = int(channel.split()[-1]) - 1 channel_num = int(channel.split()[-1]) - 1
except (IndexError, ValueError) as e: except (IndexError, ValueError) as e:
channel_num = None channel_num = None

View File

@@ -1,7 +1,7 @@
import globals import contact.globals as globals
import datetime import datetime
from meshtastic.protobuf import config_pb2 from meshtastic.protobuf import config_pb2
import ui.default_config as config import contact.ui.default_config as config
def get_channels(): def get_channels():
"""Retrieve channels from the node and update globals.channel_list and globals.all_messages.""" """Retrieve channels from the node and update globals.channel_list and globals.all_messages."""
@@ -47,7 +47,15 @@ def get_node_list():
return node['hopsAway'] if 'hopsAway' in node else 100 return node['hopsAway'] if 'hopsAway' in node else 100
else: else:
return node return node
sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort) sorted_nodes = sorted(globals.interface.nodes.values(), key = node_sort)
# Move favorite nodes to the beginning
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isFavorite'] if 'isFavorite' in node else False, reverse = True)
# Move ignored nodes to the end
sorted_nodes = sorted(sorted_nodes, key = lambda node: node['isIgnored'] if 'isIgnored' in node else False)
node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num] node_list = [node['num'] for node in sorted_nodes if node['num'] != my_node_num]
return [my_node_num] + node_list # Ensuring your node is always first return [my_node_num] + node_list # Ensuring your node is always first
return [] return []

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[project]
name = "contact"
version = "1.3.4"
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 = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
]
license = "GPL-3.0-only"
readme = "README.md"
requires-python = ">=3.9,<3.14"
dependencies = [
"meshtastic (>=2.6.0,<3.0.0)"
]
[project.urls]
Homepage = "https://github.com/pdxlocations/contact"
Issues = "https://github.com/pdxlocations/contact/issues"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.scripts]
contact = "contact.__main__:start"

View File

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