mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
286b5a531b | ||
|
|
004868c7fc | ||
|
|
b43e3f4868 | ||
|
|
2e8e21f5ba | ||
|
|
09f0d626df | ||
|
|
8c37f2394b | ||
|
|
568f29ee29 | ||
|
|
87127adef1 | ||
|
|
dd0eb1473c | ||
|
|
7d9703a548 | ||
|
|
68b8d15181 | ||
|
|
4ef87871df | ||
|
|
18df7d326a | ||
|
|
c7b54caf45 | ||
|
|
773f43edd8 | ||
|
|
6af1c46bd3 | ||
|
|
7e3e44df24 | ||
|
|
45626f5e83 | ||
|
|
e9181972b2 | ||
|
|
795ab84ef5 |
@@ -77,7 +77,7 @@ help.node_info, "F5 = Full node info", ""
|
|||||||
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
help.archive_chat, "Ctrl+D = Archive chat / remove node", ""
|
||||||
help.favorite, "Ctrl+F = Favorite", ""
|
help.favorite, "Ctrl+F = Favorite", ""
|
||||||
help.ignore, "Ctrl+G = Ignore", ""
|
help.ignore, "Ctrl+G = Ignore", ""
|
||||||
help.search, "Ctrl+/ = Search", ""
|
help.search, "Ctrl+/ or / = Search", ""
|
||||||
help.help, "Ctrl+K = Help", ""
|
help.help, "Ctrl+K = Help", ""
|
||||||
help.no_help, "No help available.", ""
|
help.no_help, "No help available.", ""
|
||||||
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
|
confirm.remove_from_nodedb, "Remove {name} from nodedb?", ""
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ help.node_info, "F5 = Полная информация об узле", ""
|
|||||||
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
help.archive_chat, "Ctrl+D = Архив чата / удалить узел", ""
|
||||||
help.favorite, "Ctrl+F = Избранное", ""
|
help.favorite, "Ctrl+F = Избранное", ""
|
||||||
help.ignore, "Ctrl+G = Игнорировать", ""
|
help.ignore, "Ctrl+G = Игнорировать", ""
|
||||||
help.search, "Ctrl+/ = Поиск", ""
|
help.search, "Ctrl+/ или / = Поиск", ""
|
||||||
help.help, "Ctrl+K = Справка", ""
|
help.help, "Ctrl+K = Справка", ""
|
||||||
help.no_help, "Нет справки.", ""
|
help.no_help, "Нет справки.", ""
|
||||||
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
|
confirm.remove_from_nodedb, "Удалить {name} из базы узлов?", ""
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import shutil
|
|||||||
import time
|
import time
|
||||||
import subprocess
|
import subprocess
|
||||||
import threading
|
import threading
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
# Debounce notification sounds so a burst of queued messages only plays once.
|
# Debounce notification sounds so a burst of queued messages only plays once.
|
||||||
_SOUND_DEBOUNCE_SECONDS = 0.8
|
_SOUND_DEBOUNCE_SECONDS = 0.8
|
||||||
_sound_timer: threading.Timer | None = None
|
_sound_timer: Optional[threading.Timer] = None
|
||||||
_sound_timer_lock = threading.Lock()
|
_sound_timer_lock = threading.Lock()
|
||||||
_last_sound_request = 0.0
|
_last_sound_request = 0.0
|
||||||
|
|
||||||
@@ -42,8 +43,6 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
|
|||||||
_sound_timer = threading.Timer(delay, _fire, args=(now,))
|
_sound_timer = threading.Timer(delay, _fire, args=(now,))
|
||||||
_sound_timer.daemon = True
|
_sound_timer.daemon = True
|
||||||
_sound_timer.start()
|
_sound_timer.start()
|
||||||
from typing import Any, Dict
|
|
||||||
|
|
||||||
from contact.utilities.utils import (
|
from contact.utilities.utils import (
|
||||||
refresh_node_list,
|
refresh_node_list,
|
||||||
add_new_message,
|
add_new_message,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from contact.ui.colors import get_color
|
|||||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
|
from contact.utilities.emoji_utils import normalize_message_text
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
import contact.ui.dialog
|
import contact.ui.dialog
|
||||||
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines, wrap_text
|
||||||
@@ -19,7 +20,9 @@ from contact.utilities.singleton import ui_state, interface_state, menu_state
|
|||||||
|
|
||||||
|
|
||||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||||
|
RESIZE_DEBOUNCE_MS = 250
|
||||||
root_win = None
|
root_win = None
|
||||||
|
nodes_pad = None
|
||||||
|
|
||||||
|
|
||||||
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
||||||
@@ -62,6 +65,72 @@ def paint_frame(win, selected: bool) -> None:
|
|||||||
win.refresh()
|
win.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_row_color(index: int) -> int:
|
||||||
|
if index == ui_state.selected_channel:
|
||||||
|
if ui_state.current_window == 0:
|
||||||
|
return get_color("channel_list", reverse=True)
|
||||||
|
return get_color("channel_selected")
|
||||||
|
return get_color("channel_list")
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||||
|
node_num = ui_state.node_list[index]
|
||||||
|
node = interface_state.interface.nodesByNum.get(node_num, {})
|
||||||
|
color = "node_list"
|
||||||
|
if node.get("isFavorite"):
|
||||||
|
color = "node_favorite"
|
||||||
|
if node.get("isIgnored"):
|
||||||
|
color = "node_ignored"
|
||||||
|
reverse = index == ui_state.selected_node and (ui_state.current_window == 2 or highlight)
|
||||||
|
return get_color(color, reverse=reverse)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||||
|
if nodes_pad is None or not ui_state.node_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||||
|
|
||||||
|
if 0 <= old_index < len(ui_state.node_list):
|
||||||
|
try:
|
||||||
|
nodes_pad.chgat(old_index, 1, width, get_node_row_color(old_index, highlight=highlight))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if 0 <= ui_state.selected_node < len(ui_state.node_list):
|
||||||
|
try:
|
||||||
|
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node, highlight=highlight))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||||
|
refresh_pad(2)
|
||||||
|
draw_window_arrows(2)
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||||
|
if window_id == 0:
|
||||||
|
paint_frame(channel_win, selected=selected)
|
||||||
|
if ui_state.channel_list:
|
||||||
|
width = max(0, channel_pad.getmaxyx()[1] - 4)
|
||||||
|
channel_pad.chgat(ui_state.selected_channel, 1, width, get_channel_row_color(ui_state.selected_channel))
|
||||||
|
refresh_pad(0)
|
||||||
|
elif window_id == 1:
|
||||||
|
paint_frame(messages_win, selected=selected)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif window_id == 2:
|
||||||
|
paint_frame(nodes_win, selected=selected)
|
||||||
|
if ui_state.node_list and nodes_pad is not None:
|
||||||
|
width = max(0, nodes_pad.getmaxyx()[1] - 4)
|
||||||
|
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_display_name(node_num: int, node: dict) -> str:
|
||||||
|
user = node.get("user") or {}
|
||||||
|
return user.get("longName") or get_name_from_database(node_num, "long")
|
||||||
|
|
||||||
|
|
||||||
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."""
|
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||||
@@ -161,6 +230,24 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def drain_resize_events(input_win: curses.window) -> Union[str, int, None]:
|
||||||
|
"""Wait for resize events to settle and preserve one queued non-resize key."""
|
||||||
|
input_win.timeout(RESIZE_DEBOUNCE_MS)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
next_char = input_win.get_wch()
|
||||||
|
except curses.error:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if next_char == curses.KEY_RESIZE:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return next_char
|
||||||
|
finally:
|
||||||
|
input_win.timeout(-1)
|
||||||
|
|
||||||
|
|
||||||
def main_ui(stdscr: curses.window) -> None:
|
def main_ui(stdscr: curses.window) -> None:
|
||||||
"""Main UI loop for the curses interface."""
|
"""Main UI loop for the curses interface."""
|
||||||
global input_text
|
global input_text
|
||||||
@@ -168,6 +255,7 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
|
|
||||||
root_win = stdscr
|
root_win = stdscr
|
||||||
input_text = ""
|
input_text = ""
|
||||||
|
queued_char = None
|
||||||
stdscr.keypad(True)
|
stdscr.keypad(True)
|
||||||
get_channels()
|
get_channels()
|
||||||
handle_resize(stdscr, True)
|
handle_resize(stdscr, True)
|
||||||
@@ -176,7 +264,11 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
draw_text_field(entry_win, f"Message: {(input_text or '')[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
draw_text_field(entry_win, f"Message: {(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()
|
if queued_char is None:
|
||||||
|
char = entry_win.get_wch()
|
||||||
|
else:
|
||||||
|
char = queued_char
|
||||||
|
queued_char = None
|
||||||
|
|
||||||
# draw_debug(f"Keypress: {char}")
|
# draw_debug(f"Keypress: {char}")
|
||||||
|
|
||||||
@@ -224,12 +316,16 @@ def main_ui(stdscr: curses.window) -> None:
|
|||||||
|
|
||||||
elif char == curses.KEY_RESIZE:
|
elif char == curses.KEY_RESIZE:
|
||||||
input_text = ""
|
input_text = ""
|
||||||
|
queued_char = drain_resize_events(entry_win)
|
||||||
handle_resize(stdscr, False)
|
handle_resize(stdscr, False)
|
||||||
|
continue
|
||||||
|
|
||||||
elif char == chr(4): # Ctrl + D to delete current channel or node
|
elif char == chr(4): # Ctrl + D to delete current channel or node
|
||||||
handle_ctrl_d()
|
handle_ctrl_d()
|
||||||
|
|
||||||
elif char == chr(31): # Ctrl + / to search
|
elif char == chr(31) or (
|
||||||
|
char == "/" and not input_text and ui_state.current_window in (0, 2)
|
||||||
|
): # Ctrl + / or / to search in channel/node lists
|
||||||
handle_ctrl_fslash()
|
handle_ctrl_fslash()
|
||||||
|
|
||||||
elif char == chr(11): # Ctrl + K for Help
|
elif char == chr(11): # Ctrl + K for Help
|
||||||
@@ -333,31 +429,16 @@ def handle_leftright(char: int) -> None:
|
|||||||
delta = -1 if char == curses.KEY_LEFT else 1
|
delta = -1 if char == curses.KEY_LEFT else 1
|
||||||
old_window = ui_state.current_window
|
old_window = ui_state.current_window
|
||||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||||
handle_resize(root_win, False)
|
if ui_state.single_pane_mode:
|
||||||
|
handle_resize(root_win, False)
|
||||||
|
return
|
||||||
|
|
||||||
if old_window == 0:
|
refresh_main_window(old_window, selected=False)
|
||||||
paint_frame(channel_win, selected=False)
|
|
||||||
refresh_pad(0)
|
|
||||||
if old_window == 1:
|
|
||||||
paint_frame(messages_win, selected=False)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif old_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=False)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
if ui_state.current_window == 0:
|
refresh_main_window(ui_state.current_window, selected=True)
|
||||||
paint_frame(channel_win, selected=True)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
paint_frame(messages_win, selected=True)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=True)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -378,31 +459,16 @@ def handle_function_keys(char: int) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
ui_state.current_window = target
|
ui_state.current_window = target
|
||||||
handle_resize(root_win, False)
|
if ui_state.single_pane_mode:
|
||||||
|
handle_resize(root_win, False)
|
||||||
|
return
|
||||||
|
|
||||||
if old_window == 0:
|
refresh_main_window(old_window, selected=False)
|
||||||
paint_frame(channel_win, selected=False)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif old_window == 1:
|
|
||||||
paint_frame(messages_win, selected=False)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif old_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=False)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
if ui_state.current_window == 0:
|
refresh_main_window(ui_state.current_window, selected=True)
|
||||||
paint_frame(channel_win, selected=True)
|
|
||||||
refresh_pad(0)
|
|
||||||
elif ui_state.current_window == 1:
|
|
||||||
paint_frame(messages_win, selected=True)
|
|
||||||
refresh_pad(1)
|
|
||||||
elif ui_state.current_window == 2:
|
|
||||||
paint_frame(nodes_win, selected=True)
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -454,34 +520,34 @@ def handle_enter(input_text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def handle_f5_key(stdscr: curses.window) -> None:
|
def handle_f5_key(stdscr: curses.window) -> None:
|
||||||
node = None
|
if not ui_state.node_list:
|
||||||
try:
|
return
|
||||||
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
|
||||||
|
|
||||||
|
def build_node_details() -> tuple[str, list[str]]:
|
||||||
|
node = interface_state.interface.nodesByNum[ui_state.node_list[ui_state.selected_node]]
|
||||||
message_parts = []
|
message_parts = []
|
||||||
|
|
||||||
message_parts.append("**📋 Basic Information:**")
|
message_parts.append("**📋 Basic Information:**")
|
||||||
message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}")
|
message_parts.append(f"• Device: {node.get('user', {}).get('longName', 'Unknown')}")
|
||||||
message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}")
|
message_parts.append(f"• Short name: {node.get('user', {}).get('shortName', 'Unknown')}")
|
||||||
message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
|
message_parts.append(f"• Hardware: {node.get('user', {}).get('hwModel', 'Unknown')}")
|
||||||
|
message_parts.append(f"• Role: {node.get('user', {}).get('role', 'Unknown')}")
|
||||||
role = f"{node.get('user', {}).get('role', 'Unknown')}"
|
message_parts.append(f"Public key: {node.get('user', {}).get('publicKey')}")
|
||||||
message_parts.append(f"• Role: {role}")
|
|
||||||
|
|
||||||
pk = f"{node.get('user', {}).get('publicKey')}"
|
|
||||||
message_parts.append(f"Public key: {pk}")
|
|
||||||
|
|
||||||
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
|
message_parts.append(f"• Node ID: {node.get('num', 'Unknown')}")
|
||||||
|
|
||||||
if "position" in node:
|
if "position" in node:
|
||||||
pos = node["position"]
|
pos = node["position"]
|
||||||
if pos.get("latitude") and pos.get("longitude"):
|
has_coords = pos.get("latitude") and pos.get("longitude")
|
||||||
|
if has_coords:
|
||||||
message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}")
|
message_parts.append(f"• Position: {pos['latitude']:.4f}, {pos['longitude']:.4f}")
|
||||||
if pos.get("altitude"):
|
if pos.get("altitude"):
|
||||||
message_parts.append(f"• Altitude: {pos['altitude']}m")
|
message_parts.append(f"• Altitude: {pos['altitude']}m")
|
||||||
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
if has_coords:
|
||||||
|
message_parts.append(f"https://maps.google.com/?q={pos['latitude']:.4f},{pos['longitude']:.4f}")
|
||||||
|
|
||||||
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
|
if any(key in node for key in ["snr", "hopsAway", "lastHeard"]):
|
||||||
message_parts.append("\n**🌐 Network Metrics:**")
|
message_parts.append("")
|
||||||
|
message_parts.append("**🌐 Network Metrics:**")
|
||||||
|
|
||||||
if "snr" in node:
|
if "snr" in node:
|
||||||
snr = node["snr"]
|
snr = node["snr"]
|
||||||
@@ -501,12 +567,13 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
|||||||
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
hop_emoji = "📡" if hops == 0 else "🔄" if hops == 1 else "⏩"
|
||||||
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
message_parts.append(f"• Hops away: {hop_emoji} {hops}")
|
||||||
|
|
||||||
if "lastHeard" in node and node["lastHeard"]:
|
if node.get("lastHeard"):
|
||||||
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
message_parts.append(f"• Last heard: 🕐 {get_time_ago(node['lastHeard'])}")
|
||||||
|
|
||||||
if node.get("deviceMetrics"):
|
if node.get("deviceMetrics"):
|
||||||
metrics = node["deviceMetrics"]
|
metrics = node["deviceMetrics"]
|
||||||
message_parts.append("\n**📊 Device Metrics:**")
|
message_parts.append("")
|
||||||
|
message_parts.append("**📊 Device Metrics:**")
|
||||||
|
|
||||||
if "batteryLevel" in metrics:
|
if "batteryLevel" in metrics:
|
||||||
battery = metrics["batteryLevel"]
|
battery = metrics["batteryLevel"]
|
||||||
@@ -527,21 +594,128 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
|||||||
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
air_emoji = "🔴" if air_util > 80 else "🟡" if air_util > 50 else "🟢"
|
||||||
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
message_parts.append(f"• Air utilization TX: {air_emoji} {air_util:.2f}%")
|
||||||
|
|
||||||
message = "\n".join(message_parts)
|
title = t(
|
||||||
|
"ui.dialog.node_details_title",
|
||||||
contact.ui.dialog.dialog(
|
default="📡 Node Details: {name}",
|
||||||
t(
|
name=node.get("user", {}).get("shortName", "Unknown"),
|
||||||
"ui.dialog.node_details_title",
|
|
||||||
default="📡 Node Details: {name}",
|
|
||||||
name=node.get("user", {}).get("shortName", "Unknown"),
|
|
||||||
),
|
|
||||||
message,
|
|
||||||
)
|
)
|
||||||
curses.curs_set(1) # Show cursor again
|
return title, message_parts
|
||||||
handle_resize(stdscr, False)
|
|
||||||
|
previous_window = ui_state.current_window
|
||||||
|
ui_state.current_window = 4
|
||||||
|
scroll_offset = 0
|
||||||
|
dialog_win = None
|
||||||
|
|
||||||
|
curses.curs_set(0)
|
||||||
|
refresh_node_selection(highlight=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
curses.update_lines_cols()
|
||||||
|
height, width = curses.LINES, curses.COLS
|
||||||
|
title, message_lines = build_node_details()
|
||||||
|
|
||||||
|
max_line_length = max(len(title), *(len(line) for line in message_lines))
|
||||||
|
dialog_width = min(max(max_line_length + 4, 20), max(10, width - 2))
|
||||||
|
dialog_height = min(max(len(message_lines) + 4, 6), max(6, height - 2))
|
||||||
|
x = max(0, (width - dialog_width) // 2)
|
||||||
|
y = max(0, (height - dialog_height) // 2)
|
||||||
|
viewport_h = max(1, dialog_height - 4)
|
||||||
|
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||||
|
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||||
|
|
||||||
|
if dialog_win is None:
|
||||||
|
dialog_win = curses.newwin(dialog_height, dialog_width, y, x)
|
||||||
|
else:
|
||||||
|
dialog_win.erase()
|
||||||
|
dialog_win.refresh()
|
||||||
|
dialog_win.resize(dialog_height, dialog_width)
|
||||||
|
dialog_win.mvwin(y, x)
|
||||||
|
|
||||||
|
dialog_win.keypad(True)
|
||||||
|
dialog_win.bkgd(get_color("background"))
|
||||||
|
dialog_win.attrset(get_color("window_frame"))
|
||||||
|
dialog_win.border(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dialog_win.addstr(0, 2, title[: max(0, dialog_width - 4)], get_color("settings_default"))
|
||||||
|
hint = f" {ui_state.selected_node + 1}/{len(ui_state.node_list)} "
|
||||||
|
dialog_win.addstr(0, max(2, dialog_width - len(hint) - 2), hint, get_color("commands"))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg_win = dialog_win.derwin(viewport_h + 2, dialog_width - 2, 1, 1)
|
||||||
|
msg_win.erase()
|
||||||
|
|
||||||
|
for row, line in enumerate(message_lines[scroll_offset : scroll_offset + viewport_h], start=1):
|
||||||
|
trimmed = line[: max(0, dialog_width - 6)]
|
||||||
|
try:
|
||||||
|
msg_win.addstr(row, 1, trimmed, get_color("settings_default"))
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(message_lines) > viewport_h:
|
||||||
|
old_index = ui_state.start_index[4] if len(ui_state.start_index) > 4 else 0
|
||||||
|
while len(ui_state.start_index) <= 4:
|
||||||
|
ui_state.start_index.append(0)
|
||||||
|
ui_state.start_index[4] = scroll_offset
|
||||||
|
draw_main_arrows(msg_win, len(message_lines) - 1, window=4)
|
||||||
|
ui_state.start_index[4] = old_index
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok_text = " Up/Down: Nodes PgUp/PgDn: Scroll Esc: Close "
|
||||||
|
dialog_win.addstr(
|
||||||
|
dialog_height - 2,
|
||||||
|
max(1, (dialog_width - len(ok_text)) // 2),
|
||||||
|
ok_text[: max(0, dialog_width - 2)],
|
||||||
|
get_color("settings_default", reverse=True),
|
||||||
|
)
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
dialog_win.refresh()
|
||||||
|
msg_win.noutrefresh()
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
dialog_win.timeout(200)
|
||||||
|
char = dialog_win.getch()
|
||||||
|
|
||||||
|
if menu_state.need_redraw:
|
||||||
|
menu_state.need_redraw = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if char in (27, curses.KEY_LEFT, curses.KEY_ENTER, 10, 13, 32):
|
||||||
|
break
|
||||||
|
if char == curses.KEY_UP:
|
||||||
|
old_selected_node = ui_state.selected_node
|
||||||
|
ui_state.selected_node = (ui_state.selected_node - 1) % len(ui_state.node_list)
|
||||||
|
scroll_offset = 0
|
||||||
|
refresh_node_selection(old_selected_node, highlight=True)
|
||||||
|
elif char == curses.KEY_DOWN:
|
||||||
|
old_selected_node = ui_state.selected_node
|
||||||
|
ui_state.selected_node = (ui_state.selected_node + 1) % len(ui_state.node_list)
|
||||||
|
scroll_offset = 0
|
||||||
|
refresh_node_selection(old_selected_node, highlight=True)
|
||||||
|
elif char == curses.KEY_PPAGE:
|
||||||
|
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||||
|
elif char == curses.KEY_NPAGE:
|
||||||
|
scroll_offset = min(max_scroll, scroll_offset + viewport_h)
|
||||||
|
elif char == curses.KEY_HOME:
|
||||||
|
scroll_offset = 0
|
||||||
|
elif char == curses.KEY_END:
|
||||||
|
scroll_offset = max_scroll
|
||||||
|
elif char == curses.KEY_RESIZE:
|
||||||
|
continue
|
||||||
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return
|
return
|
||||||
|
finally:
|
||||||
|
if dialog_win is not None:
|
||||||
|
dialog_win.erase()
|
||||||
|
dialog_win.refresh()
|
||||||
|
ui_state.current_window = previous_window
|
||||||
|
curses.curs_set(1)
|
||||||
|
handle_resize(stdscr, False)
|
||||||
|
|
||||||
|
|
||||||
def handle_ctrl_t(stdscr: curses.window) -> None:
|
def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||||
@@ -598,6 +772,7 @@ def handle_backtick(stdscr: curses.window) -> None:
|
|||||||
ui_state.current_window = 4
|
ui_state.current_window = 4
|
||||||
settings_menu(stdscr, interface_state.interface)
|
settings_menu(stdscr, interface_state.interface)
|
||||||
ui_state.current_window = previous_window
|
ui_state.current_window = previous_window
|
||||||
|
ui_state.single_pane_mode = config.single_pane_mode.lower() == "true"
|
||||||
curses.curs_set(1)
|
curses.curs_set(1)
|
||||||
refresh_node_list()
|
refresh_node_list()
|
||||||
handle_resize(stdscr, False)
|
handle_resize(stdscr, False)
|
||||||
@@ -839,7 +1014,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 = normalize_message_text(f"{prefix}{message}")
|
||||||
wrapped_lines = wrap_text(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])
|
||||||
@@ -881,10 +1056,8 @@ def draw_node_list() -> None:
|
|||||||
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
if nodes_pad is None:
|
||||||
# if nodes_pad is None:
|
nodes_pad = curses.newpad(1, 1)
|
||||||
# nodes_pad = curses.newpad(1, 1)
|
|
||||||
nodes_pad = curses.newpad(1, 1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes_pad.erase()
|
nodes_pad.erase()
|
||||||
@@ -898,28 +1071,12 @@ def draw_node_list() -> None:
|
|||||||
node = interface_state.interface.nodesByNum[node_num]
|
node = interface_state.interface.nodesByNum[node_num]
|
||||||
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
||||||
status_icon = "🔐" if secure else "🔓"
|
status_icon = "🔐" if secure else "🔓"
|
||||||
node_name = get_name_from_database(node_num, "long")
|
node_name = get_node_display_name(node_num, node)
|
||||||
user_name = node["user"]["shortName"]
|
|
||||||
|
|
||||||
uptime_str = ""
|
|
||||||
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]:
|
|
||||||
uptime_str = f" / Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
|
|
||||||
|
|
||||||
last_heard_str = f" ■ {get_time_ago(node['lastHeard'])}" if node.get("lastHeard") else ""
|
|
||||||
hops_str = f" ■ Hops: {node['hopsAway']}" if "hopsAway" in node else ""
|
|
||||||
snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else ""
|
|
||||||
|
|
||||||
# Future node name custom formatting possible
|
# Future node name custom formatting possible
|
||||||
node_str = f"{status_icon} {node_name}"
|
node_str = f"{status_icon} {node_name}"
|
||||||
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
|
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
|
||||||
color = "node_list"
|
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
||||||
if "isFavorite" in node and node["isFavorite"]:
|
|
||||||
color = "node_favorite"
|
|
||||||
if "isIgnored" in node and node["isIgnored"]:
|
|
||||||
color = "node_ignored"
|
|
||||||
nodes_pad.addstr(
|
|
||||||
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
|
|
||||||
)
|
|
||||||
|
|
||||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||||
nodes_win.refresh()
|
nodes_win.refresh()
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ config_folder = os.path.abspath(config.node_configs_file_path)
|
|||||||
# Load translations
|
# Load translations
|
||||||
field_mapping, help_text = parse_ini_file(translation_file)
|
field_mapping, help_text = parse_ini_file(translation_file)
|
||||||
|
|
||||||
|
def _is_repeated_field(field_desc) -> bool:
|
||||||
|
"""Return True if the protobuf field is repeated.
|
||||||
|
Protobuf 6.31.0 and later use an is_repeated property, while older versions compare against the label field.
|
||||||
|
"""
|
||||||
|
if hasattr(field_desc, "is_repeated"):
|
||||||
|
return bool(field_desc.is_repeated)
|
||||||
|
return field_desc.label == field_desc.LABEL_REPEATED
|
||||||
|
|
||||||
def reload_translations() -> None:
|
def reload_translations() -> None:
|
||||||
global translation_file, field_mapping, help_text
|
global translation_file, field_mapping, help_text
|
||||||
@@ -564,7 +571,7 @@ def settings_menu(stdscr: object, interface: object) -> None:
|
|||||||
new_value = new_value == "True" or new_value is True
|
new_value = new_value == "True" or new_value is True
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|
||||||
elif field.label == field.LABEL_REPEATED: # Handle repeated field - Not currently used
|
elif _is_repeated_field(field): # Handle repeated field - Not currently used
|
||||||
new_value = get_repeated_input(current_value)
|
new_value = get_repeated_input(current_value)
|
||||||
new_value = current_value if new_value is None else new_value.split(", ")
|
new_value = current_value if new_value is None else new_value.split(", ")
|
||||||
menu_state.start_index.pop()
|
menu_state.start_index.pop()
|
||||||
|
|||||||
@@ -566,7 +566,7 @@ def save_json(file_path: str, data: Dict[str, Any]) -> None:
|
|||||||
formatted_json = config.format_json_single_line_arrays(data)
|
formatted_json = config.format_json_single_line_arrays(data)
|
||||||
with open(file_path, "w", encoding="utf-8") as f:
|
with open(file_path, "w", encoding="utf-8") as f:
|
||||||
f.write(formatted_json)
|
f.write(formatted_json)
|
||||||
setup_colors(reinit=True)
|
config.reload_config()
|
||||||
reload_translations(data.get("language"))
|
reload_translations(data.get("language"))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ from meshtastic.util import camel_to_snake, snake_to_camel, fromStr
|
|||||||
# defs are from meshtastic/python/main
|
# defs are from meshtastic/python/main
|
||||||
|
|
||||||
|
|
||||||
|
def _is_repeated_field(field_desc) -> bool:
|
||||||
|
"""Return True if the protobuf field is repeated.
|
||||||
|
|
||||||
|
Protobuf 6.31.0+ exposes `is_repeated`, while older versions require
|
||||||
|
checking `label == LABEL_REPEATED`.
|
||||||
|
"""
|
||||||
|
if hasattr(field_desc, "is_repeated"):
|
||||||
|
return bool(field_desc.is_repeated)
|
||||||
|
return field_desc.label == field_desc.LABEL_REPEATED
|
||||||
|
|
||||||
|
|
||||||
def traverseConfig(config_root, config, interface_config) -> bool:
|
def traverseConfig(config_root, config, interface_config) -> bool:
|
||||||
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
|
"""Iterate through current config level preferences and either traverse deeper if preference is a dict or set preference"""
|
||||||
snake_name = camel_to_snake(config_root)
|
snake_name = camel_to_snake(config_root)
|
||||||
@@ -89,7 +100,7 @@ def setPref(config, comp_name, raw_val) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# repeating fields need to be handled with append, not setattr
|
# repeating fields need to be handled with append, not setattr
|
||||||
if pref.label != pref.LABEL_REPEATED:
|
if not _is_repeated_field(pref):
|
||||||
try:
|
try:
|
||||||
if config_type.message_type is not None:
|
if config_type.message_type is not None:
|
||||||
config_values = getattr(config_part, config_type.name)
|
config_values = getattr(config_part, config_type.name)
|
||||||
|
|||||||
54
contact/utilities/emoji_utils.py
Normal file
54
contact/utilities/emoji_utils.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
|
||||||
|
|
||||||
|
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
|
||||||
|
EMOJI_MODIFIER_REPLACEMENTS = {
|
||||||
|
"\u200d": "",
|
||||||
|
"\u20e3": "",
|
||||||
|
"\ufe0e": "",
|
||||||
|
"\ufe0f": "",
|
||||||
|
"\U0001F3FB": "",
|
||||||
|
"\U0001F3FC": "",
|
||||||
|
"\U0001F3FD": "",
|
||||||
|
"\U0001F3FE": "",
|
||||||
|
"\U0001F3FF": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
|
||||||
|
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
|
||||||
|
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
|
||||||
|
|
||||||
|
|
||||||
|
def _regional_indicator_to_letter(char: str) -> str:
|
||||||
|
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_flag_emoji(text: str) -> str:
|
||||||
|
"""Convert flag emoji built from regional indicators into ASCII country codes."""
|
||||||
|
normalized = []
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < len(text):
|
||||||
|
current = text[index]
|
||||||
|
current_ord = ord(current)
|
||||||
|
|
||||||
|
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
|
||||||
|
next_char = text[index + 1]
|
||||||
|
next_ord = ord(next_char)
|
||||||
|
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
|
||||||
|
normalized.append(_regional_indicator_to_letter(current))
|
||||||
|
normalized.append(_regional_indicator_to_letter(next_char))
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized.append(current)
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
return "".join(normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_message_text(text: str) -> str:
|
||||||
|
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))
|
||||||
@@ -68,19 +68,19 @@ def get_chunks(data):
|
|||||||
# Leave it string as last resort
|
# Leave it string as last resort
|
||||||
value = value
|
value = value
|
||||||
|
|
||||||
match key:
|
# Python 3.9-compatible alternative to match/case.
|
||||||
|
if key == "uptime_seconds":
|
||||||
# convert seconds to hours, for our sanity
|
# convert seconds to hours, for our sanity
|
||||||
case "uptime_seconds":
|
value = round(value / 60 / 60, 1)
|
||||||
value = round(value / 60 / 60, 1)
|
elif key in ("longitude_i", "latitude_i"):
|
||||||
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
|
# Convert position to degrees (humanize), as per Meshtastic protobuf comment for this telemetry
|
||||||
# truncate to 6th digit after floating point, which would be still accurate
|
# truncate to 6th digit after floating point, which would be still accurate
|
||||||
case "longitude_i" | "latitude_i":
|
value = round(value * 1e-7, 6)
|
||||||
value = round(value * 1e-7, 6)
|
elif key == "wind_direction":
|
||||||
# Convert wind direction from degrees to abbreviation
|
# Convert wind direction from degrees to abbreviation
|
||||||
case "wind_direction":
|
value = humanize_wind_direction(value)
|
||||||
value = humanize_wind_direction(value)
|
elif key == "time":
|
||||||
case "time":
|
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
|
||||||
value = datetime.datetime.fromtimestamp(int(value)).strftime("%d.%m.%Y %H:%m")
|
|
||||||
|
|
||||||
if key in sensors:
|
if key in sensors:
|
||||||
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
|
parsed+= f"{sensors[key.strip()]['icon']}{value}{sensors[key]['unit']} "
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "contact"
|
name = "contact"
|
||||||
version = "1.4.14"
|
version = "1.5.0"
|
||||||
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||||
|
|||||||
Reference in New Issue
Block a user