mirror of
https://github.com/pdxlocations/contact.git
synced 2026-11-08 03:42:22 +01:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4137347f | ||
|
|
12f81f6af6 | ||
|
|
684d298fac | ||
|
|
4179a3f8d0 | ||
|
|
7a61808f47 | ||
|
|
a8680ac0ed | ||
|
|
818575939a | ||
|
|
acaad849b0 | ||
|
|
0b25dda4af | ||
|
|
18329f0128 | ||
|
|
4378f3045c | ||
|
|
a451d1d7d6 | ||
|
|
fe1f027219 | ||
|
|
43435cbe04 | ||
|
|
fe98075582 | ||
|
|
8716ea6fe1 | ||
|
|
a8bdcbb7e6 | ||
|
|
02742b27f3 | ||
|
|
ae028032a0 | ||
|
|
30402f4906 | ||
|
|
324e0b03e7 | ||
|
|
056db12911 | ||
|
|
685a2d4bf8 | ||
|
|
6ed0cc8c9f | ||
|
|
fc208a9258 | ||
|
|
eaf9381bca | ||
|
|
367af9044c | ||
|
|
d8183d9009 | ||
|
|
3fb1335be3 | ||
|
|
8b05072786 | ||
|
|
4455781e6c | ||
|
|
0c8aaee415 | ||
|
|
b97d9f4649 | ||
|
|
4152fb6a21 | ||
|
|
384e36dac2 | ||
|
|
65bca84fe6 | ||
|
|
16fa2830fd | ||
|
|
c8f1da99e3 | ||
|
|
702250c329 | ||
|
|
6291082405 | ||
|
|
4fa5148664 | ||
|
|
d62ec09eea | ||
|
|
61026dcc73 | ||
|
|
1362d3a219 | ||
|
|
981d72e688 | ||
|
|
0b5ec0b3d7 | ||
|
|
cbb4ef9e34 | ||
|
|
fecd71f4b7 | ||
|
|
59edfab451 | ||
|
|
39159099e1 | ||
|
|
02e5368c61 | ||
|
|
9d234a75d8 | ||
|
|
c7edd602ec | ||
|
|
00226c5b4d | ||
|
|
243079f8eb | ||
|
|
1e0432642c | ||
|
|
71f37065bf |
No files matched your search
+50
-43
@@ -53,22 +53,21 @@ logging.basicConfig(
|
|||||||
|
|
||||||
app_state.lock = threading.Lock()
|
app_state.lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# Main Program Logic
|
# Main Program Logic
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
def prompt_region_if_unset(args: object) -> None:
|
||||||
|
"""Prompt user to set region if it is unset."""
|
||||||
|
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
||||||
|
if confirmation == "Yes":
|
||||||
|
set_region(interface_state.interface)
|
||||||
|
interface_state.interface.close()
|
||||||
|
interface_state.interface = initialize_interface(args)
|
||||||
|
|
||||||
|
|
||||||
def initialize_globals(args) -> None:
|
def initialize_globals() -> None:
|
||||||
"""Initializes interface and shared globals."""
|
"""Initializes interface and shared globals."""
|
||||||
interface_state.interface = initialize_interface(args)
|
|
||||||
|
|
||||||
# Prompt for region if unset
|
|
||||||
if interface_state.interface.localNode.localConfig.lora.region == 0:
|
|
||||||
confirmation = get_list_input("Your region is UNSET. Set it now?", "Yes", ["Yes", "No"])
|
|
||||||
if confirmation == "Yes":
|
|
||||||
set_region(interface_state.interface)
|
|
||||||
interface_state.interface.close()
|
|
||||||
interface_state.interface = initialize_interface(args)
|
|
||||||
|
|
||||||
interface_state.myNodeNum = get_nodeNum()
|
interface_state.myNodeNum = get_nodeNum()
|
||||||
ui_state.channel_list = get_channels()
|
ui_state.channel_list = get_channels()
|
||||||
@@ -81,55 +80,63 @@ def initialize_globals(args) -> None:
|
|||||||
|
|
||||||
def main(stdscr: curses.window) -> None:
|
def main(stdscr: curses.window) -> None:
|
||||||
"""Main entry point for the curses UI."""
|
"""Main entry point for the curses UI."""
|
||||||
|
|
||||||
output_capture = io.StringIO()
|
output_capture = io.StringIO()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
setup_colors()
|
||||||
setup_colors()
|
draw_splash(stdscr)
|
||||||
draw_splash(stdscr)
|
|
||||||
|
|
||||||
args = setup_parser().parse_args()
|
args = setup_parser().parse_args()
|
||||||
|
|
||||||
if getattr(args, "settings", False):
|
if getattr(args, "settings", False):
|
||||||
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
subprocess.run([sys.executable, "-m", "contact.settings"], check=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
logging.info("Initializing interface...")
|
logging.info("Initializing interface...")
|
||||||
with app_state.lock:
|
with app_state.lock:
|
||||||
initialize_globals(args)
|
interface_state.interface = initialize_interface(args)
|
||||||
logging.info("Starting main UI")
|
|
||||||
|
|
||||||
main_ui(stdscr)
|
if interface_state.interface.localNode.localConfig.lora.region == 0:
|
||||||
|
prompt_region_if_unset(args)
|
||||||
|
|
||||||
except Exception as e:
|
initialize_globals()
|
||||||
console_output = output_capture.getvalue()
|
logging.info("Starting main UI")
|
||||||
logging.error("Uncaught exception: %s", e)
|
|
||||||
logging.error("Traceback: %s", traceback.format_exc())
|
try:
|
||||||
logging.error("Console output:\n%s", console_output)
|
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
|
||||||
|
main_ui(stdscr)
|
||||||
|
except Exception:
|
||||||
|
console_output = output_capture.getvalue()
|
||||||
|
logging.error("Uncaught exception inside main_ui")
|
||||||
|
logging.error("Traceback:\n%s", traceback.format_exc())
|
||||||
|
logging.error("Console output:\n%s", console_output)
|
||||||
|
return
|
||||||
|
|
||||||
|
except Exception:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def start() -> None:
|
def start() -> None:
|
||||||
"""Launch curses wrapper and redirect logs to file."""
|
"""Entry point for the application."""
|
||||||
|
|
||||||
if "--help" in sys.argv or "-h" in sys.argv:
|
if "--help" in sys.argv or "-h" in sys.argv:
|
||||||
setup_parser().print_help()
|
setup_parser().print_help()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
with open(config.log_file_path, "a", buffering=1) as log_f:
|
try:
|
||||||
sys.stdout = log_f
|
curses.wrapper(main)
|
||||||
sys.stderr = log_f
|
except KeyboardInterrupt:
|
||||||
|
logging.info("User exited with Ctrl+C")
|
||||||
with contextlib.redirect_stdout(log_f), contextlib.redirect_stderr(log_f):
|
sys.exit(0)
|
||||||
try:
|
except Exception as e:
|
||||||
curses.wrapper(main)
|
logging.critical("Fatal error", exc_info=True)
|
||||||
except KeyboardInterrupt:
|
try:
|
||||||
logging.info("User exited with Ctrl+C")
|
curses.endwin()
|
||||||
sys.exit(0)
|
except Exception:
|
||||||
except Exception as e:
|
pass
|
||||||
logging.error("Fatal error: %s", e)
|
print("Fatal error:", e)
|
||||||
logging.error("Traceback: %s", traceback.format_exc())
|
traceback.print_exc()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import os
|
||||||
from datetime import datetime
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
from contact.utilities.utils import refresh_node_list
|
from contact.utilities.utils import (
|
||||||
|
refresh_node_list,
|
||||||
|
add_new_message,
|
||||||
|
)
|
||||||
from contact.ui.contact_ui import (
|
from contact.ui.contact_ui import (
|
||||||
draw_packetlog_win,
|
draw_packetlog_win,
|
||||||
draw_node_list,
|
draw_node_list,
|
||||||
@@ -22,6 +27,46 @@ import contact.ui.default_config as config
|
|||||||
from contact.utilities.singleton import ui_state, interface_state, app_state
|
from contact.utilities.singleton import ui_state, interface_state, app_state
|
||||||
|
|
||||||
|
|
||||||
|
def play_sound():
|
||||||
|
try:
|
||||||
|
system = platform.system()
|
||||||
|
sound_path = None
|
||||||
|
executable = None
|
||||||
|
|
||||||
|
if system == "Darwin": # macOS
|
||||||
|
sound_path = "/System/Library/Sounds/Ping.aiff"
|
||||||
|
executable = "afplay"
|
||||||
|
|
||||||
|
elif system == "Linux":
|
||||||
|
ogg_path = "/usr/share/sounds/freedesktop/stereo/complete.oga"
|
||||||
|
wav_path = "/usr/share/sounds/alsa/Front_Center.wav" # common fallback
|
||||||
|
|
||||||
|
if shutil.which("paplay") and os.path.exists(ogg_path):
|
||||||
|
executable = "paplay"
|
||||||
|
sound_path = ogg_path
|
||||||
|
elif shutil.which("ffplay") and os.path.exists(ogg_path):
|
||||||
|
executable = "ffplay"
|
||||||
|
sound_path = ogg_path
|
||||||
|
elif shutil.which("aplay") and os.path.exists(wav_path):
|
||||||
|
executable = "aplay"
|
||||||
|
sound_path = wav_path
|
||||||
|
else:
|
||||||
|
logging.warning("No suitable sound player or sound file found on Linux")
|
||||||
|
|
||||||
|
if executable and sound_path:
|
||||||
|
cmd = [executable, sound_path]
|
||||||
|
if executable == "ffplay":
|
||||||
|
cmd = [executable, "-nodisp", "-autoexit", sound_path]
|
||||||
|
|
||||||
|
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
return
|
||||||
|
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logging.error(f"Sound playback failed: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Unexpected error: {e}")
|
||||||
|
|
||||||
|
|
||||||
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
||||||
"""
|
"""
|
||||||
Handles an incoming packet from a Meshtastic interface.
|
Handles an incoming packet from a Meshtastic interface.
|
||||||
@@ -53,6 +98,10 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
|||||||
maybe_store_nodeinfo_in_db(packet)
|
maybe_store_nodeinfo_in_db(packet)
|
||||||
|
|
||||||
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
|
elif packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
|
||||||
|
|
||||||
|
if config.notification_sound == "True":
|
||||||
|
play_sound()
|
||||||
|
|
||||||
message_bytes = packet["decoded"]["payload"]
|
message_bytes = packet["decoded"]["payload"]
|
||||||
message_string = message_bytes.decode("utf-8")
|
message_string = message_bytes.decode("utf-8")
|
||||||
|
|
||||||
@@ -76,7 +125,9 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
|||||||
|
|
||||||
channel_number = ui_state.channel_list.index(packet["from"])
|
channel_number = ui_state.channel_list.index(packet["from"])
|
||||||
|
|
||||||
if ui_state.channel_list[channel_number] != ui_state.channel_list[ui_state.selected_channel]:
|
channel_id = ui_state.channel_list[channel_number]
|
||||||
|
|
||||||
|
if channel_id != ui_state.channel_list[ui_state.selected_channel]:
|
||||||
add_notification(channel_number)
|
add_notification(channel_number)
|
||||||
refresh_channels = True
|
refresh_channels = True
|
||||||
else:
|
else:
|
||||||
@@ -86,40 +137,14 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
|
|||||||
message_from_id = packet["from"]
|
message_from_id = packet["from"]
|
||||||
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
|
message_from_string = get_name_from_database(message_from_id, type="short") + ":"
|
||||||
|
|
||||||
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
|
add_new_message(channel_id, f"{config.message_prefix} {message_from_string} ", message_string)
|
||||||
ui_state.all_messages[ui_state.channel_list[channel_number]] = []
|
|
||||||
|
|
||||||
# Timestamp handling
|
|
||||||
current_timestamp = time.time()
|
|
||||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
|
|
||||||
|
|
||||||
# Retrieve the last timestamp if available
|
|
||||||
channel_messages = ui_state.all_messages[ui_state.channel_list[channel_number]]
|
|
||||||
if channel_messages:
|
|
||||||
# Check the last entry for a timestamp
|
|
||||||
for entry in reversed(channel_messages):
|
|
||||||
if entry[0].startswith("--"):
|
|
||||||
last_hour = entry[0].strip("- ").strip()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
last_hour = None
|
|
||||||
else:
|
|
||||||
last_hour = None
|
|
||||||
|
|
||||||
# Add a new timestamp if it's a new hour
|
|
||||||
if last_hour != current_hour:
|
|
||||||
ui_state.all_messages[ui_state.channel_list[channel_number]].append((f"-- {current_hour} --", ""))
|
|
||||||
|
|
||||||
ui_state.all_messages[ui_state.channel_list[channel_number]].append(
|
|
||||||
(f"{config.message_prefix} {message_from_string} ", message_string)
|
|
||||||
)
|
|
||||||
|
|
||||||
if refresh_channels:
|
if refresh_channels:
|
||||||
draw_channel_list()
|
draw_channel_list()
|
||||||
if refresh_messages:
|
if refresh_messages:
|
||||||
draw_messages_window(True)
|
draw_messages_window(True)
|
||||||
|
|
||||||
save_message_to_db(ui_state.channel_list[channel_number], message_from_id, message_string)
|
save_message_to_db(channel_id, message_from_id, message_string)
|
||||||
|
|
||||||
except KeyError as e:
|
except KeyError as e:
|
||||||
logging.error(f"Error processing packet: {e}")
|
logging.error(f"Error processing packet: {e}")
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict
|
||||||
|
|
||||||
import google.protobuf.json_format
|
import google.protobuf.json_format
|
||||||
@@ -16,6 +15,8 @@ import contact.ui.default_config as config
|
|||||||
|
|
||||||
from contact.utilities.singleton import ui_state, interface_state
|
from contact.utilities.singleton import ui_state, interface_state
|
||||||
|
|
||||||
|
from contact.utilities.utils import add_new_message
|
||||||
|
|
||||||
ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
|
ack_naks: Dict[str, Dict[str, Any]] = {} # requestId -> {channel, messageIndex, timestamp}
|
||||||
|
|
||||||
|
|
||||||
@@ -146,8 +147,9 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
|||||||
update_node_info_in_db(packet["from"], chat_archived=False)
|
update_node_info_in_db(packet["from"], chat_archived=False)
|
||||||
|
|
||||||
channel_number = ui_state.channel_list.index(packet["from"])
|
channel_number = ui_state.channel_list.index(packet["from"])
|
||||||
|
channel_id = ui_state.channel_list[channel_number]
|
||||||
|
|
||||||
if ui_state.channel_list[channel_number] == ui_state.channel_list[ui_state.selected_channel]:
|
if channel_id == ui_state.channel_list[ui_state.selected_channel]:
|
||||||
refresh_messages = True
|
refresh_messages = True
|
||||||
else:
|
else:
|
||||||
add_notification(channel_number)
|
add_notification(channel_number)
|
||||||
@@ -155,18 +157,14 @@ def on_response_traceroute(packet: Dict[str, Any]) -> None:
|
|||||||
|
|
||||||
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
|
message_from_string = get_name_from_database(packet["from"], type="short") + ":\n"
|
||||||
|
|
||||||
if ui_state.channel_list[channel_number] not in ui_state.all_messages:
|
add_new_message(channel_id, f"{config.message_prefix} {message_from_string}", msg_str)
|
||||||
ui_state.all_messages[ui_state.channel_list[channel_number]] = []
|
|
||||||
ui_state.all_messages[ui_state.channel_list[channel_number]].append(
|
|
||||||
(f"{config.message_prefix} {message_from_string}", msg_str)
|
|
||||||
)
|
|
||||||
|
|
||||||
if refresh_channels:
|
if refresh_channels:
|
||||||
draw_channel_list()
|
draw_channel_list()
|
||||||
if refresh_messages:
|
if refresh_messages:
|
||||||
draw_messages_window(True)
|
draw_messages_window(True)
|
||||||
save_message_to_db(ui_state.channel_list[channel_number], packet["from"], msg_str)
|
|
||||||
|
|
||||||
|
save_message_to_db(channel_id, packet["from"], msg_str)
|
||||||
|
|
||||||
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
|
def send_message(message: str, destination: int = BROADCAST_NUM, channel: int = 0) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -190,32 +188,7 @@ def send_message(message: str, destination: int = BROADCAST_NUM, channel: int =
|
|||||||
channelIndex=send_on_channel,
|
channelIndex=send_on_channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add sent message to the messages dictionary
|
add_new_message(channel_id, config.sent_message_prefix + config.ack_unknown_str + ": ", message)
|
||||||
if channel_id not in ui_state.all_messages:
|
|
||||||
ui_state.all_messages[channel_id] = []
|
|
||||||
|
|
||||||
# Handle timestamp logic
|
|
||||||
current_timestamp = int(datetime.now().timestamp()) # Get current timestamp
|
|
||||||
current_hour = datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
|
|
||||||
|
|
||||||
# Retrieve the last timestamp if available
|
|
||||||
channel_messages = ui_state.all_messages[channel_id]
|
|
||||||
if channel_messages:
|
|
||||||
# Check the last entry for a timestamp
|
|
||||||
for entry in reversed(channel_messages):
|
|
||||||
if entry[0].startswith("--"):
|
|
||||||
last_hour = entry[0].strip("- ").strip()
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
last_hour = None
|
|
||||||
else:
|
|
||||||
last_hour = None
|
|
||||||
|
|
||||||
# Add a new timestamp if it's a new hour
|
|
||||||
if last_hour != current_hour:
|
|
||||||
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
|
||||||
|
|
||||||
ui_state.all_messages[channel_id].append((config.sent_message_prefix + config.ack_unknown_str + ": ", message))
|
|
||||||
|
|
||||||
timestamp = save_message_to_db(channel_id, myid, message)
|
timestamp = save_message_to_db(channel_id, myid, message)
|
||||||
|
|
||||||
@@ -230,10 +203,14 @@ def send_traceroute() -> None:
|
|||||||
"""
|
"""
|
||||||
Sends a RouteDiscovery protobuf to the selected node.
|
Sends a RouteDiscovery protobuf to the selected node.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
channel_id = ui_state.node_list[ui_state.selected_node]
|
||||||
|
add_new_message(channel_id, f"{config.message_prefix} Sent Traceroute", "")
|
||||||
|
|
||||||
r = mesh_pb2.RouteDiscovery()
|
r = mesh_pb2.RouteDiscovery()
|
||||||
interface_state.interface.sendData(
|
interface_state.interface.sendData(
|
||||||
r,
|
r,
|
||||||
destinationId=ui_state.node_list[ui_state.selected_node],
|
destinationId=channel_id,
|
||||||
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
|
portNum=portnums_pb2.PortNum.TRACEROUTE_APP,
|
||||||
wantResponse=True,
|
wantResponse=True,
|
||||||
onResponse=on_response_traceroute,
|
onResponse=on_response_traceroute,
|
||||||
|
|||||||
+381
-300
@@ -1,5 +1,4 @@
|
|||||||
import curses
|
import curses
|
||||||
import textwrap
|
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from typing import Union
|
from typing import Union
|
||||||
@@ -12,28 +11,36 @@ from contact.utilities.db_handler import get_name_from_database, update_node_inf
|
|||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
import contact.ui.dialog
|
import contact.ui.dialog
|
||||||
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines
|
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
||||||
from contact.utilities.singleton import ui_state, interface_state
|
from contact.utilities.singleton import ui_state, interface_state
|
||||||
|
|
||||||
|
|
||||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||||
|
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||||
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
|
||||||
|
|
||||||
# Calculate window max dimensions
|
# Calculate window max dimensions
|
||||||
height, width = stdscr.getmaxyx()
|
height, width = stdscr.getmaxyx()
|
||||||
|
|
||||||
# Define window dimensions and positions
|
# Define window dimensions and positions
|
||||||
channel_width = 3 * (width // 16)
|
channel_width = int(config.channel_list_16ths) * (width // 16)
|
||||||
nodes_width = 5 * (width // 16)
|
nodes_width = int(config.node_list_16ths) * (width // 16)
|
||||||
messages_width = width - channel_width - nodes_width
|
messages_width = width - channel_width - nodes_width
|
||||||
|
|
||||||
|
entry_height = 3
|
||||||
|
function_height = 3
|
||||||
|
y_pad = entry_height + function_height
|
||||||
|
packet_log_height = int(height / 3)
|
||||||
|
|
||||||
if firstrun:
|
if firstrun:
|
||||||
entry_win = curses.newwin(3, width, 0, 0)
|
entry_win = curses.newwin(entry_height, width, 0, 0)
|
||||||
channel_win = curses.newwin(height - 6, channel_width, 3, 0)
|
channel_win = curses.newwin(height - y_pad, channel_width, entry_height, 0)
|
||||||
messages_win = curses.newwin(height - 6, messages_width, 3, channel_width)
|
messages_win = curses.newwin(height - y_pad, messages_width, entry_height, channel_width)
|
||||||
nodes_win = curses.newwin(height - 6, nodes_width, 3, channel_width + messages_width)
|
nodes_win = curses.newwin(height - y_pad, nodes_width, entry_height, channel_width + messages_width)
|
||||||
function_win = curses.newwin(3, width, height - 3, 0)
|
function_win = curses.newwin(function_height, width, height - function_height, 0)
|
||||||
packetlog_win = curses.newwin(int(height / 3), messages_width, height - int(height / 3) - 3, channel_width)
|
packetlog_win = curses.newwin(
|
||||||
|
packet_log_height, messages_width, height - packet_log_height - function_height, channel_width
|
||||||
|
)
|
||||||
|
|
||||||
# Will be resized to what we need when drawn
|
# Will be resized to what we need when drawn
|
||||||
messages_pad = curses.newpad(1, 1)
|
messages_pad = curses.newpad(1, 1)
|
||||||
@@ -58,19 +65,19 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
|||||||
|
|
||||||
entry_win.resize(3, width)
|
entry_win.resize(3, width)
|
||||||
|
|
||||||
channel_win.resize(height - 6, channel_width)
|
channel_win.resize(height - y_pad, channel_width)
|
||||||
|
|
||||||
messages_win.resize(height - 6, messages_width)
|
messages_win.resize(height - y_pad, messages_width)
|
||||||
messages_win.mvwin(3, channel_width)
|
messages_win.mvwin(3, channel_width)
|
||||||
|
|
||||||
nodes_win.resize(height - 6, nodes_width)
|
nodes_win.resize(height - y_pad, nodes_width)
|
||||||
nodes_win.mvwin(3, channel_width + messages_width)
|
nodes_win.mvwin(entry_height, channel_width + messages_width)
|
||||||
|
|
||||||
function_win.resize(3, width)
|
function_win.resize(3, width)
|
||||||
function_win.mvwin(height - 3, 0)
|
function_win.mvwin(height - function_height, 0)
|
||||||
|
|
||||||
packetlog_win.resize(int(height / 3), messages_width)
|
packetlog_win.resize(packet_log_height, messages_width)
|
||||||
packetlog_win.mvwin(height - int(height / 3) - 3, channel_width)
|
packetlog_win.mvwin(height - packet_log_height - function_height, channel_width)
|
||||||
|
|
||||||
# Draw window borders
|
# Draw window borders
|
||||||
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
for win in [channel_win, entry_win, nodes_win, messages_win, function_win]:
|
||||||
@@ -93,6 +100,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main_ui(stdscr: curses.window) -> None:
|
def main_ui(stdscr: curses.window) -> None:
|
||||||
|
"""Main UI loop for the curses interface."""
|
||||||
global input_text
|
global input_text
|
||||||
input_text = ""
|
input_text = ""
|
||||||
stdscr.keypad(True)
|
stdscr.keypad(True)
|
||||||
@@ -100,7 +108,7 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
handle_resize(stdscr, True)
|
handle_resize(stdscr, True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
draw_text_field(entry_win, f"Input: {input_text[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
draw_text_field(entry_win, f"Input: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||||
|
|
||||||
# Get user input from entry window
|
# Get user input from entry window
|
||||||
char = entry_win.get_wch()
|
char = entry_win.get_wch()
|
||||||
@@ -108,307 +116,59 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
# draw_debug(f"Keypress: {char}")
|
# draw_debug(f"Keypress: {char}")
|
||||||
|
|
||||||
if char == curses.KEY_UP:
|
if char == curses.KEY_UP:
|
||||||
if ui_state.current_window == 0:
|
handle_up()
|
||||||
scroll_channels(-1)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
scroll_messages(-1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
scroll_nodes(-1)
|
|
||||||
|
|
||||||
elif char == curses.KEY_DOWN:
|
elif char == curses.KEY_DOWN:
|
||||||
if ui_state.current_window == 0:
|
handle_down()
|
||||||
scroll_channels(1)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
scroll_messages(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
scroll_nodes(1)
|
|
||||||
|
|
||||||
elif char == curses.KEY_HOME:
|
elif char == curses.KEY_HOME:
|
||||||
if ui_state.current_window == 0:
|
handle_home()
|
||||||
select_channel(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
ui_state.selected_message = 0
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
select_node(0)
|
|
||||||
|
|
||||||
elif char == curses.KEY_END:
|
elif char == curses.KEY_END:
|
||||||
if ui_state.current_window == 0:
|
handle_end()
|
||||||
select_channel(len(ui_state.channel_list) - 1)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
msg_line_count = messages_pad.getmaxyx()[0]
|
|
||||||
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
select_node(len(ui_state.node_list) - 1)
|
|
||||||
|
|
||||||
elif char == curses.KEY_PPAGE:
|
elif char == curses.KEY_PPAGE:
|
||||||
if ui_state.current_window == 0:
|
handle_pageup()
|
||||||
select_channel(
|
|
||||||
ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)
|
|
||||||
) # select_channel will bounds check for us
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
ui_state.selected_message = max(
|
|
||||||
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
|
|
||||||
)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
select_node(
|
|
||||||
ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)
|
|
||||||
) # select_node will bounds check for us
|
|
||||||
|
|
||||||
elif char == curses.KEY_NPAGE:
|
elif char == curses.KEY_NPAGE:
|
||||||
if ui_state.current_window == 0:
|
handle_pagedown()
|
||||||
select_channel(
|
|
||||||
ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)
|
|
||||||
) # select_channel will bounds check for us
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
msg_line_count = messages_pad.getmaxyx()[0]
|
|
||||||
ui_state.selected_message = min(
|
|
||||||
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
|
|
||||||
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
|
|
||||||
)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
select_node(
|
|
||||||
ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)
|
|
||||||
) # select_node will bounds check for us
|
|
||||||
|
|
||||||
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
|
elif char == curses.KEY_LEFT or char == curses.KEY_RIGHT:
|
||||||
delta = -1 if char == curses.KEY_LEFT else 1
|
handle_leftright(char)
|
||||||
|
|
||||||
old_window = ui_state.current_window
|
|
||||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
|
||||||
|
|
||||||
if old_window == 0:
|
|
||||||
channel_win.attrset(get_color("window_frame"))
|
|
||||||
channel_win.box()
|
|
||||||
channel_win.refresh()
|
|
||||||
refresh_pad(0)
|
|
||||||
if old_window == 1:
|
|
||||||
messages_win.attrset(get_color("window_frame"))
|
|
||||||
messages_win.box()
|
|
||||||
messages_win.refresh()
|
|
||||||
refresh_pad(1)
|
|
||||||
elif old_window == 2:
|
|
||||||
draw_function_win()
|
|
||||||
nodes_win.attrset(get_color("window_frame"))
|
|
||||||
nodes_win.box()
|
|
||||||
nodes_win.refresh()
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
if ui_state.current_window == 0:
|
|
||||||
channel_win.attrset(get_color("window_frame_selected"))
|
|
||||||
channel_win.box()
|
|
||||||
channel_win.attrset(get_color("window_frame"))
|
|
||||||
channel_win.refresh()
|
|
||||||
refresh_pad(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
messages_win.attrset(get_color("window_frame_selected"))
|
|
||||||
messages_win.box()
|
|
||||||
messages_win.attrset(get_color("window_frame"))
|
|
||||||
messages_win.refresh()
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
draw_function_win()
|
|
||||||
nodes_win.attrset(get_color("window_frame_selected"))
|
|
||||||
nodes_win.box()
|
|
||||||
nodes_win.attrset(get_color("window_frame"))
|
|
||||||
nodes_win.refresh()
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
# Check for Esc
|
|
||||||
elif char == chr(27):
|
|
||||||
break
|
|
||||||
|
|
||||||
# Check for Ctrl + t
|
|
||||||
elif char == chr(20):
|
|
||||||
send_traceroute()
|
|
||||||
curses.curs_set(0) # Hide cursor
|
|
||||||
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
|
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
|
||||||
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||||
if ui_state.current_window == 2:
|
input_text = handle_enter(input_text)
|
||||||
node_list = ui_state.node_list
|
|
||||||
if node_list[ui_state.selected_node] not in ui_state.channel_list:
|
|
||||||
ui_state.channel_list.append(node_list[ui_state.selected_node])
|
|
||||||
if node_list[ui_state.selected_node] not in ui_state.all_messages:
|
|
||||||
ui_state.all_messages[node_list[ui_state.selected_node]] = []
|
|
||||||
|
|
||||||
ui_state.selected_channel = ui_state.channel_list.index(node_list[ui_state.selected_node])
|
elif char == chr(20): # Ctrl + t for Traceroute
|
||||||
|
handle_ctrl_t(stdscr)
|
||||||
if is_chat_archived(ui_state.channel_list[ui_state.selected_channel]):
|
|
||||||
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=False)
|
|
||||||
|
|
||||||
ui_state.selected_node = 0
|
|
||||||
ui_state.current_window = 0
|
|
||||||
|
|
||||||
draw_node_list()
|
|
||||||
draw_channel_list()
|
|
||||||
draw_messages_window(True)
|
|
||||||
|
|
||||||
elif len(input_text) > 0:
|
|
||||||
# Enter key pressed, send user input as message
|
|
||||||
send_message(input_text, channel=ui_state.selected_channel)
|
|
||||||
draw_messages_window(True)
|
|
||||||
|
|
||||||
# Clear entry window and reset input text
|
|
||||||
input_text = ""
|
|
||||||
entry_win.erase()
|
|
||||||
|
|
||||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||||
if input_text:
|
input_text = handle_backspace(entry_win, input_text)
|
||||||
input_text = input_text[:-1]
|
|
||||||
y, x = entry_win.getyx()
|
|
||||||
entry_win.move(y, x - 1)
|
|
||||||
entry_win.addch(" ") #
|
|
||||||
entry_win.move(y, x - 1)
|
|
||||||
entry_win.refresh()
|
|
||||||
|
|
||||||
elif char == "`": # ` Launch the settings interface
|
elif char == "`": # ` Launch the settings interface
|
||||||
curses.curs_set(0)
|
handle_backtick(stdscr)
|
||||||
settings_menu(stdscr, interface_state.interface)
|
|
||||||
curses.curs_set(1)
|
|
||||||
refresh_node_list()
|
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
|
||||||
elif char == chr(16):
|
elif char == chr(16): # Ctrl + P for Packet Log
|
||||||
# Display packet log
|
handle_ctrl_p()
|
||||||
if ui_state.display_log is False:
|
|
||||||
ui_state.display_log = True
|
|
||||||
draw_messages_window(True)
|
|
||||||
else:
|
|
||||||
ui_state.display_log = False
|
|
||||||
packetlog_win.erase()
|
|
||||||
draw_messages_window(True)
|
|
||||||
|
|
||||||
elif char == curses.KEY_RESIZE:
|
elif char == curses.KEY_RESIZE:
|
||||||
input_text = ""
|
input_text = ""
|
||||||
handle_resize(stdscr, False)
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
# ^D
|
elif char == chr(4): # Ctrl + D to delete current channel or node
|
||||||
elif char == chr(4):
|
handle_ctrl_d()
|
||||||
if ui_state.current_window == 0:
|
|
||||||
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
|
|
||||||
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=True)
|
|
||||||
|
|
||||||
# Shift notifications up to account for deleted item
|
elif char == chr(31): # Ctrl + / to search
|
||||||
for i in range(len(ui_state.notifications)):
|
handle_ctrl_fslash()
|
||||||
if ui_state.notifications[i] > ui_state.selected_channel:
|
|
||||||
ui_state.notifications[i] -= 1
|
|
||||||
|
|
||||||
del ui_state.channel_list[ui_state.selected_channel]
|
elif char == chr(6): # Ctrl + F to toggle favorite
|
||||||
ui_state.selected_channel = min(ui_state.selected_channel, len(ui_state.channel_list) - 1)
|
handle_ctrl_f(stdscr)
|
||||||
select_channel(ui_state.selected_channel)
|
|
||||||
draw_channel_list()
|
|
||||||
draw_messages_window()
|
|
||||||
|
|
||||||
if ui_state.current_window == 2:
|
elif char == chr(7): # Ctrl + G to toggle ignored
|
||||||
curses.curs_set(0)
|
handle_ctlr_g(stdscr)
|
||||||
confirmation = get_list_input(
|
|
||||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from nodedb?",
|
|
||||||
"No",
|
|
||||||
["Yes", "No"],
|
|
||||||
)
|
|
||||||
if confirmation == "Yes":
|
|
||||||
interface_state.interface.localNode.removeNode(ui_state.node_list[ui_state.selected_node])
|
|
||||||
|
|
||||||
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
|
elif char == chr(27): # Escape to exit
|
||||||
del interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
break
|
||||||
|
|
||||||
# Convert to "!hex" representation that interface.nodes uses
|
|
||||||
hexid = f"!{hex(ui_state.node_list[ui_state.selected_node])[2:]}"
|
|
||||||
del interface_state.interface.nodes[hexid]
|
|
||||||
|
|
||||||
ui_state.node_list.pop(ui_state.selected_node)
|
|
||||||
|
|
||||||
draw_messages_window()
|
|
||||||
draw_node_list()
|
|
||||||
else:
|
|
||||||
draw_messages_window()
|
|
||||||
curses.curs_set(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ^/
|
|
||||||
elif char == chr(31):
|
|
||||||
if ui_state.current_window == 2 or ui_state.current_window == 0:
|
|
||||||
search(ui_state.current_window)
|
|
||||||
|
|
||||||
# ^F
|
|
||||||
elif char == chr(6):
|
|
||||||
if ui_state.current_window == 2:
|
|
||||||
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.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(ui_state.node_list[ui_state.selected_node])} as Favorite?",
|
|
||||||
None,
|
|
||||||
["Yes", "No"],
|
|
||||||
)
|
|
||||||
if confirmation == "Yes":
|
|
||||||
interface_state.interface.localNode.setFavorite(ui_state.node_list[ui_state.selected_node])
|
|
||||||
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
|
||||||
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
|
|
||||||
"isFavorite"
|
|
||||||
] = True
|
|
||||||
|
|
||||||
refresh_node_list()
|
|
||||||
|
|
||||||
else:
|
|
||||||
confirmation = get_list_input(
|
|
||||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Favorites?",
|
|
||||||
None,
|
|
||||||
["Yes", "No"],
|
|
||||||
)
|
|
||||||
if confirmation == "Yes":
|
|
||||||
interface_state.interface.localNode.removeFavorite(ui_state.node_list[ui_state.selected_node])
|
|
||||||
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
|
||||||
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
|
|
||||||
"isFavorite"
|
|
||||||
] = False
|
|
||||||
|
|
||||||
refresh_node_list()
|
|
||||||
|
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
|
||||||
elif char == chr(7):
|
|
||||||
if ui_state.current_window == 2:
|
|
||||||
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.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(ui_state.node_list[ui_state.selected_node])} as Ignored?",
|
|
||||||
"No",
|
|
||||||
["Yes", "No"],
|
|
||||||
)
|
|
||||||
if confirmation == "Yes":
|
|
||||||
interface_state.interface.localNode.setIgnored(ui_state.node_list[ui_state.selected_node])
|
|
||||||
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
|
|
||||||
"isIgnored"
|
|
||||||
] = True
|
|
||||||
else:
|
|
||||||
confirmation = get_list_input(
|
|
||||||
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Ignored?",
|
|
||||||
"No",
|
|
||||||
["Yes", "No"],
|
|
||||||
)
|
|
||||||
if confirmation == "Yes":
|
|
||||||
interface_state.interface.localNode.removeIgnored(ui_state.node_list[ui_state.selected_node])
|
|
||||||
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]][
|
|
||||||
"isIgnored"
|
|
||||||
] = False
|
|
||||||
|
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Append typed character to input text
|
# Append typed character to input text
|
||||||
@@ -418,7 +178,318 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
input_text += chr(char)
|
input_text += chr(char)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_up() -> None:
|
||||||
|
"""Handle key up events to scroll the current window."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
scroll_channels(-1)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
scroll_messages(-1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
scroll_nodes(-1)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_down() -> None:
|
||||||
|
"""Handle key down events to scroll the current window."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
scroll_channels(1)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
scroll_messages(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
scroll_nodes(1)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_home() -> None:
|
||||||
|
"""Handle home key events to select the first item in the current window."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
select_channel(0)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
ui_state.selected_message = 0
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
select_node(0)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_end() -> None:
|
||||||
|
"""Handle end key events to select the last item in the current window."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
select_channel(len(ui_state.channel_list) - 1)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
msg_line_count = messages_pad.getmaxyx()[0]
|
||||||
|
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
select_node(len(ui_state.node_list) - 1)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_pageup() -> None:
|
||||||
|
"""Handle page up key events to scroll the current window by a page."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
select_channel(
|
||||||
|
ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)
|
||||||
|
) # select_channel will bounds check for us
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
ui_state.selected_message = max(
|
||||||
|
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
|
||||||
|
)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
select_node(ui_state.selected_node - (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||||
|
|
||||||
|
|
||||||
|
def handle_pagedown() -> None:
|
||||||
|
"""Handle page down key events to scroll the current window down."""
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
select_channel(
|
||||||
|
ui_state.selected_channel + (channel_win.getmaxyx()[0] - 2)
|
||||||
|
) # select_channel will bounds check for us
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
msg_line_count = messages_pad.getmaxyx()[0]
|
||||||
|
ui_state.selected_message = min(
|
||||||
|
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
|
||||||
|
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
|
||||||
|
)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
select_node(ui_state.selected_node + (nodes_win.getmaxyx()[0] - 2)) # select_node will bounds check for us
|
||||||
|
|
||||||
|
|
||||||
|
def handle_leftright(char: int) -> None:
|
||||||
|
"""Handle left/right key events to switch between windows."""
|
||||||
|
delta = -1 if char == curses.KEY_LEFT else 1
|
||||||
|
old_window = ui_state.current_window
|
||||||
|
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||||
|
|
||||||
|
if old_window == 0:
|
||||||
|
channel_win.attrset(get_color("window_frame"))
|
||||||
|
channel_win.box()
|
||||||
|
channel_win.refresh()
|
||||||
|
refresh_pad(0)
|
||||||
|
if old_window == 1:
|
||||||
|
messages_win.attrset(get_color("window_frame"))
|
||||||
|
messages_win.box()
|
||||||
|
messages_win.refresh()
|
||||||
|
refresh_pad(1)
|
||||||
|
elif old_window == 2:
|
||||||
|
draw_function_win()
|
||||||
|
nodes_win.attrset(get_color("window_frame"))
|
||||||
|
nodes_win.box()
|
||||||
|
nodes_win.refresh()
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
channel_win.attrset(get_color("window_frame_selected"))
|
||||||
|
channel_win.box()
|
||||||
|
channel_win.attrset(get_color("window_frame"))
|
||||||
|
channel_win.refresh()
|
||||||
|
refresh_pad(0)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
messages_win.attrset(get_color("window_frame_selected"))
|
||||||
|
messages_win.box()
|
||||||
|
messages_win.attrset(get_color("window_frame"))
|
||||||
|
messages_win.refresh()
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
draw_function_win()
|
||||||
|
nodes_win.attrset(get_color("window_frame_selected"))
|
||||||
|
nodes_win.box()
|
||||||
|
nodes_win.attrset(get_color("window_frame"))
|
||||||
|
nodes_win.refresh()
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_enter(input_text: str) -> str:
|
||||||
|
"""Handle Enter key events to send messages or select channels."""
|
||||||
|
if ui_state.current_window == 2:
|
||||||
|
node_list = ui_state.node_list
|
||||||
|
if node_list[ui_state.selected_node] not in ui_state.channel_list:
|
||||||
|
ui_state.channel_list.append(node_list[ui_state.selected_node])
|
||||||
|
if node_list[ui_state.selected_node] not in ui_state.all_messages:
|
||||||
|
ui_state.all_messages[node_list[ui_state.selected_node]] = []
|
||||||
|
|
||||||
|
ui_state.selected_channel = ui_state.channel_list.index(node_list[ui_state.selected_node])
|
||||||
|
|
||||||
|
if is_chat_archived(ui_state.channel_list[ui_state.selected_channel]):
|
||||||
|
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=False)
|
||||||
|
|
||||||
|
ui_state.selected_node = 0
|
||||||
|
ui_state.current_window = 0
|
||||||
|
|
||||||
|
draw_node_list()
|
||||||
|
draw_channel_list()
|
||||||
|
draw_messages_window(True)
|
||||||
|
return input_text
|
||||||
|
|
||||||
|
elif len(input_text) > 0:
|
||||||
|
# Enter key pressed, send user input as message
|
||||||
|
send_message(input_text, channel=ui_state.selected_channel)
|
||||||
|
draw_messages_window(True)
|
||||||
|
|
||||||
|
# Clear entry window and reset input text
|
||||||
|
entry_win.erase()
|
||||||
|
return ""
|
||||||
|
return input_text
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||||
|
"""Handle Ctrl + T key events to send a traceroute."""
|
||||||
|
send_traceroute()
|
||||||
|
curses.curs_set(0) # Hide cursor
|
||||||
|
contact.ui.dialog.dialog(
|
||||||
|
f"Traceroute Sent To: {get_name_from_database(ui_state.node_list[ui_state.selected_node])}",
|
||||||
|
"Results will appear in messages window.\nNote: Traceroute is limited to once every 30 seconds.",
|
||||||
|
)
|
||||||
|
curses.curs_set(1) # Show cursor again
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_backspace(entry_win: curses.window, input_text: str) -> str:
|
||||||
|
"""Handle backspace key events to remove the last character from input text."""
|
||||||
|
if input_text:
|
||||||
|
input_text = input_text[:-1]
|
||||||
|
y, x = entry_win.getyx()
|
||||||
|
entry_win.move(y, x - 1)
|
||||||
|
entry_win.addch(" ") #
|
||||||
|
entry_win.move(y, x - 1)
|
||||||
|
entry_win.refresh()
|
||||||
|
return input_text
|
||||||
|
|
||||||
|
|
||||||
|
def handle_backtick(stdscr: curses.window) -> None:
|
||||||
|
"""Handle backtick key events to open the settings menu."""
|
||||||
|
curses.curs_set(0)
|
||||||
|
settings_menu(stdscr, interface_state.interface)
|
||||||
|
curses.curs_set(1)
|
||||||
|
refresh_node_list()
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctrl_p() -> None:
|
||||||
|
"""Handle Ctrl + P key events to toggle the packet log display."""
|
||||||
|
# Display packet log
|
||||||
|
if ui_state.display_log is False:
|
||||||
|
ui_state.display_log = True
|
||||||
|
draw_messages_window(True)
|
||||||
|
else:
|
||||||
|
ui_state.display_log = False
|
||||||
|
packetlog_win.erase()
|
||||||
|
draw_messages_window(True)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctrl_d() -> None:
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
if isinstance(ui_state.channel_list[ui_state.selected_channel], int):
|
||||||
|
update_node_info_in_db(ui_state.channel_list[ui_state.selected_channel], chat_archived=True)
|
||||||
|
|
||||||
|
# Shift notifications up to account for deleted item
|
||||||
|
for i in range(len(ui_state.notifications)):
|
||||||
|
if ui_state.notifications[i] > ui_state.selected_channel:
|
||||||
|
ui_state.notifications[i] -= 1
|
||||||
|
|
||||||
|
del ui_state.channel_list[ui_state.selected_channel]
|
||||||
|
ui_state.selected_channel = min(ui_state.selected_channel, len(ui_state.channel_list) - 1)
|
||||||
|
select_channel(ui_state.selected_channel)
|
||||||
|
draw_channel_list()
|
||||||
|
draw_messages_window()
|
||||||
|
|
||||||
|
if ui_state.current_window == 2:
|
||||||
|
curses.curs_set(0)
|
||||||
|
confirmation = get_list_input(
|
||||||
|
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from nodedb?",
|
||||||
|
"No",
|
||||||
|
["Yes", "No"],
|
||||||
|
)
|
||||||
|
if confirmation == "Yes":
|
||||||
|
interface_state.interface.localNode.removeNode(ui_state.node_list[ui_state.selected_node])
|
||||||
|
|
||||||
|
# Directly modifying the interface from client code - good? Bad? If it's stupid but it works, it's not supid?
|
||||||
|
del interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||||
|
|
||||||
|
# Convert to "!hex" representation that interface.nodes uses
|
||||||
|
hexid = f"!{hex(ui_state.node_list[ui_state.selected_node])[2:]}"
|
||||||
|
del interface_state.interface.nodes[hexid]
|
||||||
|
|
||||||
|
ui_state.node_list.pop(ui_state.selected_node)
|
||||||
|
|
||||||
|
draw_messages_window()
|
||||||
|
draw_node_list()
|
||||||
|
else:
|
||||||
|
draw_messages_window()
|
||||||
|
curses.curs_set(1)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctrl_fslash() -> None:
|
||||||
|
"""Handle Ctrl + / key events to search in the current window."""
|
||||||
|
if ui_state.current_window == 2 or ui_state.current_window == 0:
|
||||||
|
search(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctrl_f(stdscr: curses.window) -> None:
|
||||||
|
"""Handle Ctrl + F key events to toggle favorite status of the selected node."""
|
||||||
|
if ui_state.current_window == 2:
|
||||||
|
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.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(ui_state.node_list[ui_state.selected_node])} as Favorite?",
|
||||||
|
None,
|
||||||
|
["Yes", "No"],
|
||||||
|
)
|
||||||
|
if confirmation == "Yes":
|
||||||
|
interface_state.interface.localNode.setFavorite(ui_state.node_list[ui_state.selected_node])
|
||||||
|
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
||||||
|
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isFavorite"] = True
|
||||||
|
|
||||||
|
refresh_node_list()
|
||||||
|
|
||||||
|
else:
|
||||||
|
confirmation = get_list_input(
|
||||||
|
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Favorites?",
|
||||||
|
None,
|
||||||
|
["Yes", "No"],
|
||||||
|
)
|
||||||
|
if confirmation == "Yes":
|
||||||
|
interface_state.interface.localNode.removeFavorite(ui_state.node_list[ui_state.selected_node])
|
||||||
|
# Maybe we shouldn't be modifying the nodedb, but maybe it should update itself
|
||||||
|
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isFavorite"] = False
|
||||||
|
|
||||||
|
refresh_node_list()
|
||||||
|
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ctlr_g(stdscr: curses.window) -> None:
|
||||||
|
"""Handle Ctrl + G key events to toggle ignored status of the selected node."""
|
||||||
|
if ui_state.current_window == 2:
|
||||||
|
selectedNode = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.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(ui_state.node_list[ui_state.selected_node])} as Ignored?",
|
||||||
|
"No",
|
||||||
|
["Yes", "No"],
|
||||||
|
)
|
||||||
|
if confirmation == "Yes":
|
||||||
|
interface_state.interface.localNode.setIgnored(ui_state.node_list[ui_state.selected_node])
|
||||||
|
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = True
|
||||||
|
else:
|
||||||
|
confirmation = get_list_input(
|
||||||
|
f"Remove {get_name_from_database(ui_state.node_list[ui_state.selected_node])} from Ignored?",
|
||||||
|
"No",
|
||||||
|
["Yes", "No"],
|
||||||
|
)
|
||||||
|
if confirmation == "Yes":
|
||||||
|
interface_state.interface.localNode.removeIgnored(ui_state.node_list[ui_state.selected_node])
|
||||||
|
interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]["isIgnored"] = False
|
||||||
|
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
def draw_channel_list() -> None:
|
def draw_channel_list() -> None:
|
||||||
|
"""Update the channel list window and pad based on the current state."""
|
||||||
channel_pad.erase()
|
channel_pad.erase()
|
||||||
win_width = channel_win.getmaxyx()[1]
|
win_width = channel_win.getmaxyx()[1]
|
||||||
|
|
||||||
@@ -479,7 +550,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
|||||||
row = 0
|
row = 0
|
||||||
for prefix, message in messages:
|
for prefix, message in messages:
|
||||||
full_message = f"{prefix}{message}"
|
full_message = f"{prefix}{message}"
|
||||||
wrapped_lines = textwrap.wrap(full_message, messages_win.getmaxyx()[1] - 2)
|
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
||||||
msg_line_count += len(wrapped_lines)
|
msg_line_count += len(wrapped_lines)
|
||||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||||
|
|
||||||
@@ -524,6 +595,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def draw_node_list() -> None:
|
def draw_node_list() -> None:
|
||||||
|
"""Update the nodes list window and pad based on the current state."""
|
||||||
global nodes_pad
|
global nodes_pad
|
||||||
|
|
||||||
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
||||||
@@ -572,6 +644,7 @@ def draw_node_list() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def select_channel(idx: int) -> None:
|
def select_channel(idx: int) -> None:
|
||||||
|
"""Select a channel by index and update the UI state accordingly."""
|
||||||
old_selected_channel = ui_state.selected_channel
|
old_selected_channel = ui_state.selected_channel
|
||||||
ui_state.selected_channel = max(0, min(idx, len(ui_state.channel_list) - 1))
|
ui_state.selected_channel = max(0, min(idx, len(ui_state.channel_list) - 1))
|
||||||
draw_messages_window(True)
|
draw_messages_window(True)
|
||||||
@@ -593,6 +666,7 @@ def select_channel(idx: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def scroll_channels(direction: int) -> None:
|
def scroll_channels(direction: int) -> None:
|
||||||
|
"""Scroll through the channel list by a given direction."""
|
||||||
new_selected_channel = ui_state.selected_channel + direction
|
new_selected_channel = ui_state.selected_channel + direction
|
||||||
|
|
||||||
if new_selected_channel < 0:
|
if new_selected_channel < 0:
|
||||||
@@ -604,6 +678,7 @@ def scroll_channels(direction: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def scroll_messages(direction: int) -> None:
|
def scroll_messages(direction: int) -> None:
|
||||||
|
"""Scroll through the messages in the current channel by a given direction."""
|
||||||
ui_state.selected_message += direction
|
ui_state.selected_message += direction
|
||||||
|
|
||||||
msg_line_count = messages_pad.getmaxyx()[0]
|
msg_line_count = messages_pad.getmaxyx()[0]
|
||||||
@@ -636,6 +711,7 @@ def scroll_messages(direction: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def select_node(idx: int) -> None:
|
def select_node(idx: int) -> None:
|
||||||
|
"""Select a node by index and update the UI state accordingly."""
|
||||||
old_selected_node = ui_state.selected_node
|
old_selected_node = ui_state.selected_node
|
||||||
ui_state.selected_node = max(0, min(idx, len(ui_state.node_list) - 1))
|
ui_state.selected_node = max(0, min(idx, len(ui_state.node_list) - 1))
|
||||||
|
|
||||||
@@ -652,6 +728,7 @@ def select_node(idx: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def scroll_nodes(direction: int) -> None:
|
def scroll_nodes(direction: int) -> None:
|
||||||
|
"""Scroll through the node list by a given direction."""
|
||||||
new_selected_node = ui_state.selected_node + direction
|
new_selected_node = ui_state.selected_node + direction
|
||||||
|
|
||||||
if new_selected_node < 0:
|
if new_selected_node < 0:
|
||||||
@@ -663,7 +740,7 @@ def scroll_nodes(direction: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def draw_packetlog_win() -> None:
|
def draw_packetlog_win() -> None:
|
||||||
|
"""Draw the packet log window with the latest packets."""
|
||||||
columns = [10, 10, 15, 30]
|
columns = [10, 10, 15, 30]
|
||||||
span = 0
|
span = 0
|
||||||
|
|
||||||
@@ -716,6 +793,7 @@ def draw_packetlog_win() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def search(win: int) -> None:
|
def search(win: int) -> None:
|
||||||
|
"""Search for a node or channel based on user input."""
|
||||||
start_idx = ui_state.selected_node
|
start_idx = ui_state.selected_node
|
||||||
select_func = select_node
|
select_func = select_node
|
||||||
|
|
||||||
@@ -764,6 +842,7 @@ def search(win: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def draw_node_details() -> None:
|
def draw_node_details() -> None:
|
||||||
|
"""Draw the details of the selected node in the function window."""
|
||||||
node = None
|
node = None
|
||||||
try:
|
try:
|
||||||
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||||
@@ -827,16 +906,18 @@ def draw_node_details() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def draw_help() -> None:
|
def draw_help() -> None:
|
||||||
|
"""Draw the help text in the function window."""
|
||||||
cmds = [
|
cmds = [
|
||||||
"↑→↓← = Select",
|
"↑→↓← = Select",
|
||||||
" ENTER = Send",
|
" ENTER = Send",
|
||||||
" ` = Settings",
|
" ` = Settings",
|
||||||
" ^P = Packet Log",
|
" ESC = Quit",
|
||||||
" ESC = Quit",
|
" ^P = Packet Log",
|
||||||
" ^t = Traceroute",
|
" ^t = Traceroute",
|
||||||
" ^d = Archive Chat",
|
" ^d = Archive Chat",
|
||||||
" ^f = Favorite",
|
" ^f = Favorite",
|
||||||
" ^g = Ignore",
|
" ^g = Ignore",
|
||||||
|
" ^/ = Search",
|
||||||
]
|
]
|
||||||
function_str = ""
|
function_str = ""
|
||||||
for s in cmds:
|
for s in cmds:
|
||||||
|
|||||||
+33
-10
@@ -150,6 +150,15 @@ def draw_help_window(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_input_type_for_field(field) -> type:
|
||||||
|
if field.type in (field.TYPE_INT32, field.TYPE_UINT32, field.TYPE_INT64):
|
||||||
|
return int
|
||||||
|
elif field.type in (field.TYPE_FLOAT, field.TYPE_DOUBLE):
|
||||||
|
return float
|
||||||
|
else:
|
||||||
|
return str
|
||||||
|
|
||||||
|
|
||||||
def settings_menu(stdscr: object, interface: object) -> None:
|
def settings_menu(stdscr: object, interface: object) -> None:
|
||||||
curses.update_lines_cols()
|
curses.update_lines_cols()
|
||||||
|
|
||||||
@@ -268,7 +277,8 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
elif selected_option == "Export Config File":
|
elif selected_option == "Export Config File":
|
||||||
filename = get_text_input("Enter a filename for the config file")
|
|
||||||
|
filename = get_text_input("Enter a filename for the config file", None, None)
|
||||||
if not filename:
|
if not filename:
|
||||||
logging.info("Export aborted: No filename provided.")
|
logging.info("Export aborted: No filename provided.")
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
@@ -290,7 +300,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
with open(yaml_file_path, "w", encoding="utf-8") as file:
|
||||||
file.write(config_text)
|
file.write(config_text)
|
||||||
logging.info(f"Config file saved to {yaml_file_path}")
|
logging.info(f"Config file saved to {yaml_file_path}")
|
||||||
dialog(stdscr, "Config File Saved:", yaml_file_path)
|
dialog("Config File Saved:", yaml_file_path)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
continue
|
continue
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
@@ -306,14 +316,14 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
# Check if folder exists and is not empty
|
# Check if folder exists and is not empty
|
||||||
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
if not os.path.exists(config_folder) or not any(os.listdir(config_folder)):
|
||||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
dialog("", " No config files found. Export a config first.")
|
||||||
continue # Return to menu
|
continue # Return to menu
|
||||||
|
|
||||||
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
|
file_list = [f for f in os.listdir(config_folder) if os.path.isfile(os.path.join(config_folder, f))]
|
||||||
|
|
||||||
# Ensure file_list is not empty before proceeding
|
# Ensure file_list is not empty before proceeding
|
||||||
if not file_list:
|
if not file_list:
|
||||||
dialog(stdscr, "", " No config files found. Export a config first.")
|
dialog("", " No config files found. Export a config first.")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
filename = get_list_input("Choose a config file", None, file_list)
|
filename = get_list_input("Choose a config file", None, file_list)
|
||||||
@@ -327,7 +337,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
elif selected_option == "Config URL":
|
elif selected_option == "Config URL":
|
||||||
current_value = interface.localNode.getURL()
|
current_value = interface.localNode.getURL()
|
||||||
new_value = get_text_input(f"Config URL is currently: {current_value}")
|
new_value = get_text_input(f"Config URL is currently: {current_value}", None, str)
|
||||||
if new_value is not None:
|
if new_value is not None:
|
||||||
current_value = new_value
|
current_value = new_value
|
||||||
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
overwrite = get_list_input(f"Are you sure you want to load this config?", None, ["Yes", "No"])
|
||||||
@@ -395,7 +405,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
|
|
||||||
if selected_option in ["longName", "shortName", "isLicensed"]:
|
if selected_option in ["longName", "shortName", "isLicensed"]:
|
||||||
if selected_option in ["longName", "shortName"]:
|
if selected_option in ["longName", "shortName"]:
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, None
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.current_menu[selected_option] = (field, new_value)
|
menu_state.current_menu[selected_option] = (field, new_value)
|
||||||
|
|
||||||
@@ -414,7 +426,9 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif selected_option in ["latitude", "longitude", "altitude"]:
|
elif selected_option in ["latitude", "longitude", "altitude"]:
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, float
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.current_menu[selected_option] = (field, new_value)
|
menu_state.current_menu[selected_option] = (field, new_value)
|
||||||
|
|
||||||
@@ -453,17 +467,26 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif field.type == 13: # Field type 13 corresponds to UINT32
|
elif field.type == 13: # Field type 13 corresponds to UINT32
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else int(new_value)
|
new_value = current_value if new_value is None else int(new_value)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif field.type == 2: # Field type 13 corresponds to INT64
|
elif field.type == 2: # Field type 13 corresponds to INT64
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else float(new_value)
|
new_value = current_value if new_value is None else float(new_value)
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
else: # Handle other field types
|
else: # Handle other field types
|
||||||
new_value = get_text_input(f"{human_readable_name} is currently: {current_value}")
|
input_type = get_input_type_for_field(field)
|
||||||
|
new_value = get_text_input(
|
||||||
|
f"{human_readable_name} is currently: {current_value}", selected_option, input_type
|
||||||
|
)
|
||||||
new_value = current_value if new_value is None else new_value
|
new_value = current_value if new_value is None else new_value
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,56 @@ from typing import Dict
|
|||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
|
||||||
|
|
||||||
# Paths
|
# To test writting to a non-writable directory, you can uncomment the following lines:
|
||||||
json_file_path = os.path.join(parent_dir, "config.json")
|
# mkdir /tmp/test_nonwritable
|
||||||
log_file_path = os.path.join(parent_dir, "client.log")
|
# chmod -w /tmp/test_nonwritable
|
||||||
db_file_path = os.path.join(parent_dir, "client.db")
|
# parent_dir = "/tmp/test_nonwritable"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_writable_dir(path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Return True if we can create & delete a temp file in `path`.
|
||||||
|
"""
|
||||||
|
if not os.path.isdir(path):
|
||||||
|
return False
|
||||||
|
test_path = os.path.join(path, ".perm_test_tmp")
|
||||||
|
try:
|
||||||
|
with open(test_path, "w", encoding="utf-8") as _tmp:
|
||||||
|
_tmp.write("ok")
|
||||||
|
os.remove(test_path)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_root(preferred_dir: str, fallback_name: str = ".contact_client") -> str:
|
||||||
|
"""
|
||||||
|
Choose a writable directory for config artifacts.
|
||||||
|
"""
|
||||||
|
if _is_writable_dir(preferred_dir):
|
||||||
|
return preferred_dir
|
||||||
|
|
||||||
|
home = os.path.expanduser("~")
|
||||||
|
fallback_dir = os.path.join(home, fallback_name)
|
||||||
|
# Ensure the fallback exists.
|
||||||
|
os.makedirs(fallback_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# If *that* still isn't writable, last-ditch: use a system temp dir.
|
||||||
|
if not _is_writable_dir(fallback_dir):
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fallback_dir = tempfile.mkdtemp(prefix="contact_client_")
|
||||||
|
|
||||||
|
return fallback_dir
|
||||||
|
|
||||||
|
|
||||||
|
# Pick the root now.
|
||||||
|
config_root = _get_config_root(parent_dir)
|
||||||
|
|
||||||
|
# Paths (derived from the chosen root)
|
||||||
|
json_file_path = os.path.join(config_root, "config.json")
|
||||||
|
log_file_path = os.path.join(config_root, "client.log")
|
||||||
|
db_file_path = os.path.join(config_root, "client.db")
|
||||||
|
|
||||||
|
|
||||||
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
|
def format_json_single_line_arrays(data: Dict[str, object], indent: int = 4) -> str:
|
||||||
@@ -123,15 +169,18 @@ def initialize_config() -> Dict[str, object]:
|
|||||||
"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_favorite": ["cyan", "green"],
|
||||||
"node_ignored": ["red", "white"],
|
"node_ignored": ["red", "black"],
|
||||||
}
|
}
|
||||||
default_config_variables = {
|
default_config_variables = {
|
||||||
|
"channel_list_16ths": "3",
|
||||||
|
"node_list_16ths": "5",
|
||||||
"db_file_path": db_file_path,
|
"db_file_path": db_file_path,
|
||||||
"log_file_path": log_file_path,
|
"log_file_path": log_file_path,
|
||||||
"message_prefix": ">>",
|
"message_prefix": ">>",
|
||||||
"sent_message_prefix": ">> Sent",
|
"sent_message_prefix": ">> Sent",
|
||||||
"notification_symbol": "*",
|
"notification_symbol": "*",
|
||||||
|
"notification_sound": "True",
|
||||||
"ack_implicit_str": "[◌]",
|
"ack_implicit_str": "[◌]",
|
||||||
"ack_str": "[✓]",
|
"ack_str": "[✓]",
|
||||||
"nak_str": "[x]",
|
"nak_str": "[x]",
|
||||||
@@ -170,18 +219,23 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
|||||||
|
|
||||||
global db_file_path, log_file_path, message_prefix, sent_message_prefix
|
global db_file_path, log_file_path, message_prefix, sent_message_prefix
|
||||||
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
global notification_symbol, ack_implicit_str, ack_str, nak_str, ack_unknown_str
|
||||||
|
global node_list_16ths, channel_list_16ths
|
||||||
global theme, COLOR_CONFIG
|
global theme, COLOR_CONFIG
|
||||||
global node_sort
|
global node_sort, notification_sound
|
||||||
|
|
||||||
|
channel_list_16ths = loaded_config["channel_list_16ths"]
|
||||||
|
node_list_16ths = loaded_config["node_list_16ths"]
|
||||||
db_file_path = loaded_config["db_file_path"]
|
db_file_path = loaded_config["db_file_path"]
|
||||||
log_file_path = loaded_config["log_file_path"]
|
log_file_path = loaded_config["log_file_path"]
|
||||||
message_prefix = loaded_config["message_prefix"]
|
message_prefix = loaded_config["message_prefix"]
|
||||||
sent_message_prefix = loaded_config["sent_message_prefix"]
|
sent_message_prefix = loaded_config["sent_message_prefix"]
|
||||||
notification_symbol = loaded_config["notification_symbol"]
|
notification_symbol = loaded_config["notification_symbol"]
|
||||||
|
notification_sound = loaded_config["notification_sound"]
|
||||||
ack_implicit_str = loaded_config["ack_implicit_str"]
|
ack_implicit_str = loaded_config["ack_implicit_str"]
|
||||||
ack_str = loaded_config["ack_str"]
|
ack_str = loaded_config["ack_str"]
|
||||||
nak_str = loaded_config["nak_str"]
|
nak_str = loaded_config["nak_str"]
|
||||||
ack_unknown_str = loaded_config["ack_unknown_str"]
|
ack_unknown_str = loaded_config["ack_unknown_str"]
|
||||||
|
node_sort = loaded_config["node_sort"]
|
||||||
theme = loaded_config["theme"]
|
theme = loaded_config["theme"]
|
||||||
if theme == "dark":
|
if theme == "dark":
|
||||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
|
COLOR_CONFIG = loaded_config["COLOR_CONFIG_DARK"]
|
||||||
@@ -189,7 +243,6 @@ def assign_config_variables(loaded_config: Dict[str, object]) -> None:
|
|||||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
|
COLOR_CONFIG = loaded_config["COLOR_CONFIG_LIGHT"]
|
||||||
elif theme == "green":
|
elif theme == "green":
|
||||||
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
|
COLOR_CONFIG = loaded_config["COLOR_CONFIG_GREEN"]
|
||||||
node_sort = loaded_config["node_sort"]
|
|
||||||
|
|
||||||
|
|
||||||
# Call the function when the script is imported
|
# Call the function when the script is imported
|
||||||
|
|||||||
+17
-12
@@ -2,14 +2,13 @@ import curses
|
|||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
|
|
||||||
|
|
||||||
def dialog(stdscr: curses.window, title: str, message: str) -> None:
|
def dialog(title: str, message: str) -> None:
|
||||||
height, width = stdscr.getmaxyx()
|
|
||||||
|
height, width = curses.LINES, curses.COLS
|
||||||
|
|
||||||
# Calculate dialog dimensions
|
# Calculate dialog dimensions
|
||||||
max_line_lengh = 0
|
|
||||||
message_lines = message.splitlines()
|
message_lines = message.splitlines()
|
||||||
for l in message_lines:
|
max_line_length = max(len(l) for l in message_lines)
|
||||||
max_line_length = max(len(l), max_line_lengh)
|
|
||||||
dialog_width = max(len(title) + 4, max_line_length + 4)
|
dialog_width = max(len(title) + 4, max_line_length + 4)
|
||||||
dialog_height = len(message_lines) + 4
|
dialog_height = len(message_lines) + 4
|
||||||
x = (width - dialog_width) // 2
|
x = (width - dialog_width) // 2
|
||||||
@@ -24,12 +23,19 @@ def dialog(stdscr: curses.window, title: str, message: str) -> None:
|
|||||||
# Add title
|
# Add title
|
||||||
win.addstr(0, 2, title, get_color("settings_default"))
|
win.addstr(0, 2, title, get_color("settings_default"))
|
||||||
|
|
||||||
# Add message
|
# Add message (centered)
|
||||||
for i, l in enumerate(message_lines):
|
for i, line in enumerate(message_lines):
|
||||||
win.addstr(2 + i, 2, l, get_color("settings_default"))
|
msg_x = (dialog_width - len(line)) // 2
|
||||||
|
win.addstr(2 + i, msg_x, line, get_color("settings_default"))
|
||||||
|
|
||||||
# Add button
|
# Add centered OK button
|
||||||
win.addstr(dialog_height - 2, (dialog_width - 4) // 2, " Ok ", get_color("settings_default", reverse=True))
|
ok_text = " Ok "
|
||||||
|
win.addstr(
|
||||||
|
dialog_height - 2,
|
||||||
|
(dialog_width - len(ok_text)) // 2,
|
||||||
|
ok_text,
|
||||||
|
get_color("settings_default", reverse=True),
|
||||||
|
)
|
||||||
|
|
||||||
# Refresh dialog window
|
# Refresh dialog window
|
||||||
win.refresh()
|
win.refresh()
|
||||||
@@ -37,8 +43,7 @@ def dialog(stdscr: curses.window, title: str, message: str) -> None:
|
|||||||
# Get user input
|
# Get user input
|
||||||
while True:
|
while True:
|
||||||
char = win.getch()
|
char = win.getch()
|
||||||
# Close dialog with enter, space, or esc
|
if char in (curses.KEY_ENTER, 10, 13, 32, 27): # Enter, space, or Esc
|
||||||
if char in (curses.KEY_ENTER, 10, 13, 32, 27):
|
|
||||||
win.erase()
|
win.erase()
|
||||||
win.refresh()
|
win.refresh()
|
||||||
return
|
return
|
||||||
+13
-4
@@ -1,5 +1,7 @@
|
|||||||
import curses
|
import curses
|
||||||
import re
|
import re
|
||||||
|
from unicodedata import east_asian_width
|
||||||
|
|
||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
from contact.utilities.control_utils import transform_menu_path
|
from contact.utilities.control_utils import transform_menu_path
|
||||||
from typing import Any, Optional, List, Dict
|
from typing import Any, Optional, List, Dict
|
||||||
@@ -293,9 +295,16 @@ def get_wrapped_help_text(
|
|||||||
|
|
||||||
return wrapped_help
|
return wrapped_help
|
||||||
|
|
||||||
|
def text_width(text: str) -> int:
|
||||||
|
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
|
||||||
|
|
||||||
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||||
"""Wraps text while preserving spaces and breaking long words."""
|
"""Wraps text while preserving spaces and breaking long words."""
|
||||||
|
|
||||||
|
whitespace = '\t\n\x0b\x0c\r '
|
||||||
|
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(' '))
|
||||||
|
text = text.translate(whitespace_trans)
|
||||||
|
|
||||||
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
|
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
|
||||||
wrapped_lines = []
|
wrapped_lines = []
|
||||||
line_buffer = ""
|
line_buffer = ""
|
||||||
@@ -304,11 +313,11 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
|
|||||||
wrap_width -= margin
|
wrap_width -= margin
|
||||||
|
|
||||||
for word in words:
|
for word in words:
|
||||||
word_length = len(word)
|
word_length = text_width(word)
|
||||||
|
|
||||||
if word_length > wrap_width: # Break long words
|
if word_length > wrap_width: # Break long words
|
||||||
if line_buffer:
|
if line_buffer:
|
||||||
wrapped_lines.append(line_buffer)
|
wrapped_lines.append(line_buffer.strip())
|
||||||
line_buffer = ""
|
line_buffer = ""
|
||||||
line_length = 0
|
line_length = 0
|
||||||
for i in range(0, word_length, wrap_width):
|
for i in range(0, word_length, wrap_width):
|
||||||
@@ -316,7 +325,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if line_length + word_length > wrap_width and word.strip():
|
if line_length + word_length > wrap_width and word.strip():
|
||||||
wrapped_lines.append(line_buffer)
|
wrapped_lines.append(line_buffer.strip())
|
||||||
line_buffer = ""
|
line_buffer = ""
|
||||||
line_length = 0
|
line_length = 0
|
||||||
|
|
||||||
@@ -324,7 +333,7 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
|
|||||||
line_length += word_length
|
line_length += word_length
|
||||||
|
|
||||||
if line_buffer:
|
if line_buffer:
|
||||||
wrapped_lines.append(line_buffer)
|
wrapped_lines.append(line_buffer.strip())
|
||||||
|
|
||||||
return wrapped_lines
|
return wrapped_lines
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,15 @@ def edit_value(key: str, current_value: str) -> str:
|
|||||||
# Load theme names dynamically from the JSON
|
# Load theme names dynamically from the JSON
|
||||||
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
theme_options = [k.split("_", 2)[2].lower() for k in loaded_config.keys() if k.startswith("COLOR_CONFIG")]
|
||||||
return get_list_input("Select Theme", current_value, theme_options)
|
return get_list_input("Select Theme", current_value, theme_options)
|
||||||
|
|
||||||
elif key == "node_sort":
|
elif key == "node_sort":
|
||||||
sort_options = ["lastHeard", "name", "hops"]
|
sort_options = ["lastHeard", "name", "hops"]
|
||||||
return get_list_input("Sort By", current_value, sort_options)
|
return get_list_input("Sort By", current_value, sort_options)
|
||||||
|
|
||||||
|
elif key == "notification_sound":
|
||||||
|
sound_options = ["True", "False"]
|
||||||
|
return get_list_input("Notification Sound", current_value, sound_options)
|
||||||
|
|
||||||
# Standard Input Mode (Scrollable)
|
# Standard Input Mode (Scrollable)
|
||||||
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
edit_win.addstr(7, 2, "New Value: ", get_color("settings_default"))
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import yaml
|
import yaml
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import List
|
from typing import List
|
||||||
from google.protobuf.json_format import MessageToDict
|
from google.protobuf.json_format import MessageToDict
|
||||||
from meshtastic import mt_config
|
from meshtastic import mt_config
|
||||||
@@ -133,24 +134,29 @@ def config_import(interface, filename):
|
|||||||
logging.info(f"Setting device owner to {configuration['owner']}")
|
logging.info(f"Setting device owner to {configuration['owner']}")
|
||||||
waitForAckNak = True
|
waitForAckNak = True
|
||||||
interface.getNode("^local", False).setOwner(configuration["owner"])
|
interface.getNode("^local", False).setOwner(configuration["owner"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "owner_short" in configuration:
|
if "owner_short" in configuration:
|
||||||
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
logging.info(f"Setting device owner short to {configuration['owner_short']}")
|
||||||
waitForAckNak = True
|
waitForAckNak = True
|
||||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["owner_short"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "ownerShort" in configuration:
|
if "ownerShort" in configuration:
|
||||||
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
logging.info(f"Setting device owner short to {configuration['ownerShort']}")
|
||||||
waitForAckNak = True
|
waitForAckNak = True
|
||||||
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
interface.getNode("^local", False).setOwner(long_name=None, short_name=configuration["ownerShort"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "channel_url" in configuration:
|
if "channel_url" in configuration:
|
||||||
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
logging.info(f"Setting channel url to {configuration['channel_url']}")
|
||||||
interface.getNode("^local").setURL(configuration["channel_url"])
|
interface.getNode("^local").setURL(configuration["channel_url"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "channelUrl" in configuration:
|
if "channelUrl" in configuration:
|
||||||
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
logging.info(f"Setting channel url to {configuration['channelUrl']}")
|
||||||
interface.getNode("^local").setURL(configuration["channelUrl"])
|
interface.getNode("^local").setURL(configuration["channelUrl"])
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "location" in configuration:
|
if "location" in configuration:
|
||||||
alt = 0
|
alt = 0
|
||||||
@@ -169,12 +175,14 @@ def config_import(interface, filename):
|
|||||||
logging.info(f"Fixing longitude at {lon} degrees")
|
logging.info(f"Fixing longitude at {lon} degrees")
|
||||||
logging.info("Setting device position")
|
logging.info("Setting device position")
|
||||||
interface.localNode.setFixedPosition(lat, lon, alt)
|
interface.localNode.setFixedPosition(lat, lon, alt)
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "config" in configuration:
|
if "config" in configuration:
|
||||||
localConfig = interface.getNode("^local").localConfig
|
localConfig = interface.getNode("^local").localConfig
|
||||||
for section in configuration["config"]:
|
for section in configuration["config"]:
|
||||||
traverseConfig(section, configuration["config"][section], localConfig)
|
traverseConfig(section, configuration["config"][section], localConfig)
|
||||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
if "module_config" in configuration:
|
if "module_config" in configuration:
|
||||||
moduleConfig = interface.getNode("^local").moduleConfig
|
moduleConfig = interface.getNode("^local").moduleConfig
|
||||||
@@ -185,6 +193,7 @@ def config_import(interface, filename):
|
|||||||
moduleConfig,
|
moduleConfig,
|
||||||
)
|
)
|
||||||
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
interface.getNode("^local").writeConfig(camel_to_snake(section))
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
interface.getNode("^local", False).commitSettingsTransaction()
|
interface.getNode("^local", False).commitSettingsTransaction()
|
||||||
logging.info("Writing modified configuration to device")
|
logging.info("Writing modified configuration to device")
|
||||||
|
|||||||
@@ -116,7 +116,14 @@ def load_messages_from_db() -> None:
|
|||||||
|
|
||||||
# Add messages to ui_state.all_messages grouped by hourly timestamp
|
# Add messages to ui_state.all_messages grouped by hourly timestamp
|
||||||
hourly_messages = {}
|
hourly_messages = {}
|
||||||
for user_id, message, timestamp, ack_type in db_messages:
|
for row in db_messages:
|
||||||
|
user_id, message, timestamp, ack_type = row
|
||||||
|
|
||||||
|
# Only ack_type is allowed to be None
|
||||||
|
if user_id is None or message is None or timestamp is None:
|
||||||
|
logging.warning(f"Skipping row with NULL required field(s): {row}")
|
||||||
|
continue
|
||||||
|
|
||||||
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
|
hour = datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:00")
|
||||||
if hour not in hourly_messages:
|
if hour not in hourly_messages:
|
||||||
hourly_messages[hour] = []
|
hourly_messages[hour] = []
|
||||||
@@ -130,11 +137,13 @@ def load_messages_from_db() -> None:
|
|||||||
ack_str = config.nak_str
|
ack_str = config.nak_str
|
||||||
|
|
||||||
if user_id == str(interface_state.myNodeNum):
|
if user_id == str(interface_state.myNodeNum):
|
||||||
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", message)
|
sanitized_message = message.replace("\x00", "")
|
||||||
|
formatted_message = (f"{config.sent_message_prefix}{ack_str}: ", sanitized_message)
|
||||||
else:
|
else:
|
||||||
|
sanitized_message = message.replace("\x00", "")
|
||||||
formatted_message = (
|
formatted_message = (
|
||||||
f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
|
f"{config.message_prefix} {get_name_from_database(int(user_id), 'short')}: ",
|
||||||
message,
|
sanitized_message,
|
||||||
)
|
)
|
||||||
|
|
||||||
hourly_messages[hour].append(formatted_message)
|
hourly_messages[hour].append(formatted_message)
|
||||||
|
|||||||
@@ -6,10 +6,43 @@ from typing import Any, Optional, List
|
|||||||
|
|
||||||
from contact.ui.colors import get_color
|
from contact.ui.colors import get_color
|
||||||
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
|
||||||
|
from contact.ui.dialog import dialog
|
||||||
|
from contact.utilities.validation_rules import get_validation_for
|
||||||
|
|
||||||
|
|
||||||
def get_text_input(prompt: str) -> Optional[str]:
|
def invalid_input(window: curses.window, message: str, redraw_func: Optional[callable] = None) -> None:
|
||||||
|
"""Displays an invalid input message in the given window and redraws if needed."""
|
||||||
|
cursor_y, cursor_x = window.getyx()
|
||||||
|
curses.curs_set(0)
|
||||||
|
dialog("Invalid Input", message)
|
||||||
|
if redraw_func:
|
||||||
|
redraw_func() # Redraw the original window content that got obscured
|
||||||
|
else:
|
||||||
|
window.refresh()
|
||||||
|
window.move(cursor_y, cursor_x)
|
||||||
|
curses.curs_set(1)
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_input(prompt: str, selected_config: str, input_type: str) -> Optional[str]:
|
||||||
"""Handles user input with wrapped text for long prompts."""
|
"""Handles user input with wrapped text for long prompts."""
|
||||||
|
|
||||||
|
def redraw_input_win():
|
||||||
|
"""Redraw the input window with the current prompt and user input."""
|
||||||
|
input_win.erase()
|
||||||
|
input_win.border()
|
||||||
|
row = 1
|
||||||
|
for line in wrapped_prompt:
|
||||||
|
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||||
|
row += 1
|
||||||
|
if row >= height - 3:
|
||||||
|
break
|
||||||
|
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
|
||||||
|
input_win.addstr(row + 1, col_start, user_input[:first_line_width], get_color("settings_default"))
|
||||||
|
for i, line in enumerate(wrap_text(user_input[first_line_width:], wrap_width=input_width)):
|
||||||
|
if row + 2 + i < height - 1:
|
||||||
|
input_win.addstr(row + 2 + i, margin, line[:input_width], get_color("settings_default"))
|
||||||
|
input_win.refresh()
|
||||||
|
|
||||||
height = 8
|
height = 8
|
||||||
width = 80
|
width = 80
|
||||||
margin = 2 # Left and right margin
|
margin = 2 # Left and right margin
|
||||||
@@ -27,6 +60,7 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
# Wrap the prompt text
|
# Wrap the prompt text
|
||||||
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
wrapped_prompt = wrap_text(prompt, wrap_width=input_width)
|
||||||
row = 1
|
row = 1
|
||||||
|
|
||||||
for line in wrapped_prompt:
|
for line in wrapped_prompt:
|
||||||
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
input_win.addstr(row, margin, line[:input_width], get_color("settings_default", bold=True))
|
||||||
row += 1
|
row += 1
|
||||||
@@ -39,34 +73,115 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
|
|
||||||
max_length = 4 if "shortName" in prompt else None
|
min_value = 0
|
||||||
user_input = ""
|
max_value = 4294967295
|
||||||
|
min_length = 0
|
||||||
|
max_length = None
|
||||||
|
|
||||||
# Start user input after the prompt text
|
if selected_config is not None:
|
||||||
|
validation = get_validation_for(selected_config) or {}
|
||||||
|
min_value = validation.get("min_value", 0)
|
||||||
|
max_value = validation.get("max_value", 4294967295)
|
||||||
|
min_length = validation.get("min_length", 0)
|
||||||
|
max_length = validation.get("max_length")
|
||||||
|
|
||||||
|
user_input = ""
|
||||||
col_start = margin + len(prompt_text)
|
col_start = margin + len(prompt_text)
|
||||||
first_line_width = input_width - len(prompt_text) # Available space for first line
|
first_line_width = input_width - len(prompt_text)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
key = input_win.get_wch() # Waits for user input
|
key = input_win.get_wch()
|
||||||
|
|
||||||
if key == chr(27) or key == curses.KEY_LEFT: # ESC or Left Arrow
|
if key == chr(27) or key == curses.KEY_LEFT:
|
||||||
input_win.erase()
|
input_win.erase()
|
||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return None # Exit without saving
|
return None
|
||||||
|
|
||||||
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)): # Enter key
|
elif key in (chr(curses.KEY_ENTER), chr(10), chr(13)):
|
||||||
break
|
if not user_input.strip():
|
||||||
|
invalid_input(input_win, "Value cannot be empty.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
|
||||||
|
length = len(user_input)
|
||||||
|
if min_length == max_length and max_length is not None:
|
||||||
|
if length != min_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win, f"Value must be exactly {min_length} characters long.", redraw_func=redraw_input_win
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if length < min_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Value must be at least {min_length} characters long.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if max_length is not None and length > max_length:
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Value must be no more than {max_length} characters long.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if input_type is int:
|
||||||
|
if not user_input.isdigit():
|
||||||
|
invalid_input(input_win, "Only numeric digits (0–9) allowed.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
|
||||||
|
int_val = int(user_input)
|
||||||
|
if not (min_value <= int_val <= max_value):
|
||||||
|
invalid_input(
|
||||||
|
input_win, f"Enter a number between {min_value} and {max_value}.", redraw_func=redraw_input_win
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
curses.curs_set(0)
|
||||||
|
return int_val
|
||||||
|
|
||||||
|
elif input_type is float:
|
||||||
|
try:
|
||||||
|
float_val = float(user_input)
|
||||||
|
if not (min_value <= float_val <= max_value):
|
||||||
|
invalid_input(
|
||||||
|
input_win,
|
||||||
|
f"Enter a number between {min_value} and {max_value}.",
|
||||||
|
redraw_func=redraw_input_win,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except ValueError:
|
||||||
|
invalid_input(input_win, "Must be a valid floating point number.", redraw_func=redraw_input_win)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
curses.curs_set(0)
|
||||||
|
return float_val
|
||||||
|
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
|
elif key in (curses.KEY_BACKSPACE, chr(127)): # Handle Backspace
|
||||||
if user_input:
|
if user_input:
|
||||||
user_input = user_input[:-1] # Remove last character
|
user_input = user_input[:-1] # Remove last character
|
||||||
|
|
||||||
elif max_length is None or len(user_input) < max_length: # Enforce max length
|
elif max_length is None or len(user_input) < max_length:
|
||||||
if isinstance(key, str):
|
try:
|
||||||
user_input += key
|
char = chr(key) if not isinstance(key, str) else key
|
||||||
else:
|
if input_type is int:
|
||||||
user_input += chr(key)
|
if char.isdigit() or (char == "-" and len(user_input) == 0):
|
||||||
|
user_input += char
|
||||||
|
elif input_type is float:
|
||||||
|
if (
|
||||||
|
char.isdigit()
|
||||||
|
or (char == "." and "." not in user_input)
|
||||||
|
or (char == "-" and len(user_input) == 0)
|
||||||
|
):
|
||||||
|
user_input += char
|
||||||
|
else:
|
||||||
|
user_input += char
|
||||||
|
except ValueError:
|
||||||
|
pass # Ignore invalid input
|
||||||
|
|
||||||
# First line must be manually handled before using wrap_text()
|
# First line must be manually handled before using wrap_text()
|
||||||
first_line = user_input[:first_line_width] # Cut to max first line width
|
first_line = user_input[:first_line_width] # Cut to max first line width
|
||||||
@@ -95,10 +210,12 @@ def get_text_input(prompt: str) -> Optional[str]:
|
|||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
input_win.erase()
|
input_win.erase()
|
||||||
input_win.refresh()
|
input_win.refresh()
|
||||||
return user_input
|
return user_input.strip()
|
||||||
|
|
||||||
|
|
||||||
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
||||||
|
"""Handles user input for editing up to 3 Admin Keys in Base64 format."""
|
||||||
|
|
||||||
def to_base64(byte_strings):
|
def to_base64(byte_strings):
|
||||||
"""Convert byte values to Base64-encoded strings."""
|
"""Convert byte values to Base64-encoded strings."""
|
||||||
return [base64.b64encode(b).decode() for b in byte_strings]
|
return [base64.b64encode(b).decode() for b in byte_strings]
|
||||||
@@ -130,7 +247,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
# Editable list of values (max 3 values)
|
# Editable list of values (max 3 values)
|
||||||
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
|
user_values = cvalue[:3] + [""] * (3 - len(cvalue)) # Ensure always 3 fields
|
||||||
cursor_pos = 0 # Track which value is being edited
|
cursor_pos = 0 # Track which value is being edited
|
||||||
error_message = ""
|
invalid_input = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
repeated_win.erase()
|
repeated_win.erase()
|
||||||
@@ -150,8 +267,8 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||||
|
|
||||||
# Show error message if needed
|
# Show error message if needed
|
||||||
if error_message:
|
if invalid_input:
|
||||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
repeated_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
|
||||||
|
|
||||||
repeated_win.refresh()
|
repeated_win.refresh()
|
||||||
key = repeated_win.getch()
|
key = repeated_win.getch()
|
||||||
@@ -169,7 +286,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
curses.curs_set(0)
|
curses.curs_set(0)
|
||||||
return user_values # Return the edited Base64 values
|
return user_values # Return the edited Base64 values
|
||||||
else:
|
else:
|
||||||
error_message = "Error: Each key must be valid Base64 and 32 bytes long!"
|
invalid_input = "Error: Each key must be valid Base64 and 32 bytes long!"
|
||||||
elif key == curses.KEY_UP: # Move cursor up
|
elif key == curses.KEY_UP: # Move cursor up
|
||||||
cursor_pos = (cursor_pos - 1) % len(user_values)
|
cursor_pos = (cursor_pos - 1) % len(user_values)
|
||||||
elif key == curses.KEY_DOWN: # Move cursor down
|
elif key == curses.KEY_DOWN: # Move cursor down
|
||||||
@@ -180,7 +297,7 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||||
error_message = "" # Clear error if user starts fixing input
|
invalid_input = "" # Clear error if user starts fixing input
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Ignore invalid character inputs
|
pass # Ignore invalid character inputs
|
||||||
|
|
||||||
@@ -202,7 +319,7 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
|||||||
# Editable list of values (max 3 values)
|
# Editable list of values (max 3 values)
|
||||||
user_values = current_value[:3]
|
user_values = current_value[:3]
|
||||||
cursor_pos = 0 # Track which value is being edited
|
cursor_pos = 0 # Track which value is being edited
|
||||||
error_message = ""
|
invalid_input = ""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
repeated_win.erase()
|
repeated_win.erase()
|
||||||
@@ -222,8 +339,8 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
|||||||
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos])) # Position cursor at end of text
|
||||||
|
|
||||||
# Show error message if needed
|
# Show error message if needed
|
||||||
if error_message:
|
if invalid_input:
|
||||||
repeated_win.addstr(7, 2, error_message, get_color("settings_default", bold=True))
|
repeated_win.addstr(7, 2, invalid_input, get_color("settings_default", bold=True))
|
||||||
|
|
||||||
repeated_win.refresh()
|
repeated_win.refresh()
|
||||||
key = repeated_win.getch()
|
key = repeated_win.getch()
|
||||||
@@ -249,7 +366,7 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
|
|||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
user_values[cursor_pos] += chr(key) # Append valid character input to the selected field
|
||||||
error_message = "" # Clear error if user starts fixing input
|
invalid_input = "" # Clear error if user starts fixing input
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass # Ignore invalid character inputs
|
pass # Ignore invalid character inputs
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import time
|
||||||
from meshtastic.protobuf import config_pb2
|
from meshtastic.protobuf import config_pb2
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
|
|
||||||
@@ -134,3 +135,31 @@ def get_time_ago(timestamp):
|
|||||||
if unit != "s":
|
if unit != "s":
|
||||||
return f"{value} {unit} ago"
|
return f"{value} {unit} ago"
|
||||||
return "now"
|
return "now"
|
||||||
|
|
||||||
|
def add_new_message(channel_id, prefix, message):
|
||||||
|
if channel_id not in ui_state.all_messages:
|
||||||
|
ui_state.all_messages[channel_id] = []
|
||||||
|
|
||||||
|
# Timestamp handling
|
||||||
|
current_timestamp = time.time()
|
||||||
|
current_hour = datetime.datetime.fromtimestamp(current_timestamp).strftime("%Y-%m-%d %H:00")
|
||||||
|
|
||||||
|
# Retrieve the last timestamp if available
|
||||||
|
channel_messages = ui_state.all_messages[channel_id]
|
||||||
|
if channel_messages:
|
||||||
|
# Check the last entry for a timestamp
|
||||||
|
for entry in reversed(channel_messages):
|
||||||
|
if entry[0].startswith("--"):
|
||||||
|
last_hour = entry[0].strip("- ").strip()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
last_hour = None
|
||||||
|
else:
|
||||||
|
last_hour = None
|
||||||
|
|
||||||
|
# Add a new timestamp if it's a new hour
|
||||||
|
if last_hour != current_hour:
|
||||||
|
ui_state.all_messages[channel_id].append((f"-- {current_hour} --", ""))
|
||||||
|
|
||||||
|
# Add the message
|
||||||
|
ui_state.all_messages[channel_id].append((prefix,message))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
validation_rules = {
|
||||||
|
"shortName": {"max_length": 4},
|
||||||
|
"longName": {"max_length": 32},
|
||||||
|
"fixed_pin": {"min_length": 6, "max_length": 6},
|
||||||
|
"position_flags": {"max_length": 3},
|
||||||
|
"enabled_protocols": {"max_value": 2},
|
||||||
|
"hop_limit": {"max_value": 7},
|
||||||
|
"latitude": {"min_value": -90, "max_value": 90},
|
||||||
|
"longitude": {"min_value": -180, "max_value": 180},
|
||||||
|
"altitude": {"min_value": -4294967295, "max_value": 4294967295},
|
||||||
|
"red": {"max_value": 255},
|
||||||
|
"green": {"max_value": 255},
|
||||||
|
"blue": {"max_value": 255},
|
||||||
|
"current": {"max_value": 255},
|
||||||
|
"position_precision": {"max_value": 32},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_validation_for(key: str) -> dict:
|
||||||
|
for rule_key, config in validation_rules.items():
|
||||||
|
if rule_key in key:
|
||||||
|
return config
|
||||||
|
return {}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "contact"
|
name = "contact"
|
||||||
version = "1.3.9"
|
version = "1.3.16"
|
||||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||||
|
|||||||
Reference in new issue
Block a user