Compare commits

...
15 Commits
Author SHA1 Message Date
pdxlocations f375d788f1 Bump version to 1.7.4 in pyproject.toml 2026-10-27 22:01:14 -07:00
pdxlocations 4c4b8a42c5 tryfix f5 resize crash 2026-10-27 22:00:53 -07:00
pdxlocations 80667abb26 Bump version to 1.7.3 in pyproject.toml 2026-10-27 21:56:20 -07:00
pdxlocations 5210c54d2b Refactor SNR and metrics display in F5 key handler to remove emojis and simplify output 2026-10-27 21:52:15 -07:00
pdxlocations 6eaa567f48 Merge pull request #323 from pdxlocations/fix-F5-resize 2026-10-27 21:42:24 -07:00
pdxlocations 76a5a1a496 Enhance F5 key handling to rebuild background on terminal resize 2026-10-27 21:41:47 -07:00
pdxlocations 2013b23812 Merge pull request #322 from pdxlocations/silence-alert-in-messages-pane 2026-10-27 21:13:13 -07:00
pdxlocations 22d8901115 Don't play notification if chat is selected 2026-10-27 21:12:37 -07:00
pdxlocations 94a976527d Merge pull request #321 from pdxlocations/fix-enter 2026-10-27 21:04:42 -07:00
pdxlocations 3fd811d4c2 Support keypad and regular Enter key in main UI and search functions 2026-10-27 21:04:04 -07:00
pdxlocations acac61a060 bump version to 1.7.2 in pyproject.toml 2026-10-19 21:23:33 -07:00
pdxlocations fffc3e3e97 Fix single pane message crash: Refactor message rendering in draw_messages_window and add test for single pane typing behavior 2026-10-19 15:27:40 -07:00
pdxlocations c956a11672 Merge pull request #320 from vanotaras/main 2026-10-19 15:15:18 -07:00
vanotaras 950266637f Add UTF-8 byte counter to the message input field 2026-10-20 00:49:44 +05:00
vanotaras 368f464649 Fix popup console window on Windows 2026-10-20 00:45:59 +05:00
5 changed files with 274 additions and 44 deletions

No files matched your search

+20 -3
View File
@@ -13,6 +13,14 @@ _sound_timer_lock = threading.Lock()
_last_sound_request = 0.0
def chat_is_selected() -> bool:
return (
ui_state.current_window == 1
and not ui_state.display_log
and not ui_state.log_viewer_open
)
def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
"""Schedule a notification sound after a short quiet period.
@@ -38,7 +46,8 @@ def schedule_notification_sound(delay: float = _SOUND_DEBOUNCE_SECONDS) -> None:
with _sound_timer_lock:
if expected_request_time != _last_sound_request:
return
play_sound()
if not chat_is_selected():
play_sound()
_sound_timer = threading.Timer(delay, _fire, args=(now,))
_sound_timer.daemon = True
@@ -89,7 +98,12 @@ def play_sound():
elif player == "cvlc":
cmd = [executable, "--play-and-exit", sound_path]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if platform.system() == "Windows":
su = subprocess.STARTUPINFO()
su.dwFlags |= subprocess.STARTF_USESHOWWINDOW
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, creationflags=0x08000000, startupinfo=su)
else:
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return
logging.warning(
"No suitable sound player found for notification sound; install ffmpeg (ffplay), mpg123, mpv, or VLC."
@@ -145,7 +159,10 @@ def on_receive(packet: Dict[str, Any], interface: Any) -> None:
hops = hop_start - hop_limit
if str(config.notification_sound).casefold() not in {"none", "false", ""}:
if (
not chat_is_selected()
and str(config.notification_sound).casefold() not in {"none", "false", ""}
):
schedule_notification_sound()
message_bytes = packet["decoded"]["payload"]
+75 -40
View File
@@ -589,7 +589,7 @@ def main_ui(stdscr: curses.window) -> None:
elif char in (curses.KEY_F1, curses.KEY_F2, curses.KEY_F3):
handle_function_keys(char)
elif char in (chr(curses.KEY_ENTER), chr(10), chr(13)):
elif char in (curses.KEY_ENTER, chr(10), chr(13)):
input_text = handle_enter(input_text)
ui_state.input_cursor = min(ui_state.input_cursor, len(input_text))
@@ -675,11 +675,17 @@ def insert_input_text(draft: str, text: str) -> str:
def draw_message_input(draft: str) -> None:
"""Redraw the whole input row and scroll it to keep the draft cursor visible."""
width = entry_win.getmaxyx()[1]
# Получаем сразу высоту и ширину для правильного позиционирования счетчика
height, width = entry_win.getmaxyx()
prompt = slice_to_width("Message: ", max(0, width - 3))
available = max(1, width - 2 - text_width(prompt))
ui_state.input_cursor = max(0, min(ui_state.input_cursor, len(draft)))
content = ui_state.reply_context + draft
# Считаем именно байты в UTF-8 (важно для кириллицы)
byte_count = len(content.encode('utf-8'))
counter_str = f" {byte_count}/220b "
cursor = len(ui_state.reply_context) + ui_state.input_cursor
start = cursor
used = 0
@@ -689,10 +695,26 @@ def draw_message_input(draft: str) -> None:
visible = slice_to_width(content[start:], available)
draw_text_field(entry_win, prompt + visible, get_color("input"))
paint_frame(entry_win, selected=ui_state.input_focused, refresh=False)
try:
hint = " Ctrl+K Help "
if width >= len(hint) + 4:
entry_win.addstr(0, width - len(hint) - 2, hint, get_color("commands"))
# меняем цвет, если превышен лимит
if byte_count > 220:
try:
# Используем settings_warning с добавлением жирности шрифта
counter_color = get_color("settings_warning", bold=True)
except:
counter_color = get_color("commands", bold=True, reverse=True)
else:
counter_color = get_color("commands")
# Отрисовка счетчика внизу с защитой от краша (height > 2)
if width >= len(counter_str) + 4 and height > 2:
entry_win.addstr(height - 1, width - len(counter_str) - 2, counter_str, counter_color)
curses.curs_set(1 if ui_state.input_focused else 0)
entry_win.move(1, min(width - 2, 1 + text_width(prompt) + used))
except curses.error:
@@ -982,12 +1004,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
snr = node["snr"]
if isinstance(snr, Real):
snr_status = (
"🟢 Excellent"
"Excellent"
if snr > 10
else (
"🟡 Good"
"Good"
if snr > 3
else "🟠 Fair" if snr > -10 else "🔴 Poor" if snr > -20 else "💀 Very Poor"
else "Fair" if snr > -10 else "Poor" if snr > -20 else "Very Poor"
)
)
message_parts.append(f"• SNR: {snr}dB {snr_status}")
@@ -996,11 +1018,10 @@ def handle_f5_key(stdscr: curses.window) -> None:
if node.get("hopsAway") is not None:
hops = node["hopsAway"]
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: {hops}")
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"):
metrics = node["deviceMetrics"]
@@ -1009,28 +1030,23 @@ def handle_f5_key(stdscr: curses.window) -> None:
if metrics.get("batteryLevel") is not None:
battery = metrics["batteryLevel"]
battery_emoji = "🔴"
if isinstance(battery, Real):
battery_emoji = "🟢" if battery > 50 else "🟡" if battery > 20 else "🔴"
voltage_info = f" ({metrics['voltage']}v)" if "voltage" in metrics else ""
message_parts.append(f"• Battery: {battery_emoji} {battery}%{voltage_info}")
message_parts.append(f"• Battery: {battery}%{voltage_info}")
if metrics.get("uptimeSeconds") is not None:
message_parts.append(f"• Uptime: ⏱️ {get_readable_duration(metrics['uptimeSeconds'])}")
message_parts.append(f"• Uptime: {get_readable_duration(metrics['uptimeSeconds'])}")
if metrics.get("channelUtilization") is not None:
util = metrics["channelUtilization"]
if isinstance(util, Real):
util_emoji = "🔴" if util > 80 else "🟡" if util > 50 else "🟢"
message_parts.append(f"• Channel utilization: {util_emoji} {util:.2f}%")
message_parts.append(f"• Channel utilization: {util:.2f}%")
else:
message_parts.append(f"• Channel utilization: {util}%")
if metrics.get("airUtilTx") is not None:
air_util = metrics["airUtilTx"]
if isinstance(air_util, Real):
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_util:.2f}%")
else:
message_parts.append(f"• Air utilization TX: {air_util}%")
@@ -1067,6 +1083,15 @@ def handle_f5_key(stdscr: curses.window) -> None:
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
if previous_layout is not None and layout != previous_layout:
if (height, width) != previous_layout[-2:]:
# Rebuild the background before staging pads whose old
# screen coordinates may now lie outside the terminal.
ui_state.current_window = previous_window
try:
handle_resize(stdscr, False)
finally:
ui_state.current_window = 4
curses.curs_set(0)
stage_node_details_background(stdscr, previous_window)
previous_layout = layout
@@ -1629,14 +1654,17 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
return
messages_pad.erase()
# Erasing the pad also removes the old highlight. Its row range may no
# longer exist after switching conversations or rewrapping messages.
ui_state.highlighted_message_range = ()
channel = ui_state.channel_list[ui_state.selected_channel]
msg_line_count = 0
message_ranges = []
rendered_lines = []
if channel in ui_state.all_messages:
messages = ui_state.all_messages[channel]
rendered_lines = []
for prefix, message in messages:
start_line = len(rendered_lines)
full_message = normalize_message_text(f"{prefix}{message}")
@@ -1655,10 +1683,10 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
message_ranges.append((start_line, len(rendered_lines), color))
msg_line_count = len(rendered_lines)
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
messages_pad.resize(max(1, msg_line_count), messages_win.getmaxyx()[1])
for row, (line, color) in enumerate(rendered_lines):
messages_pad.addstr(row, 1, line, color)
ui_state.message_line_ranges[channel] = message_ranges
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
@@ -1955,7 +1983,7 @@ def search(win: int) -> None:
except curses.error:
break
if char in (chr(27), chr(curses.KEY_ENTER), chr(10), chr(13)):
if char in (chr(27), curses.KEY_ENTER, chr(10), chr(13)):
break
elif char == "\t":
start_idx = ui_state.selected_node + 1 if win == 2 else ui_state.selected_channel + 1
@@ -2037,34 +2065,41 @@ def refresh_pad(window: int, commit: bool = True) -> None:
# Clamp start_index within the pad's height
pad_h, pad_w = pad.getmaxyx()
if pad_h <= 0:
if pad_h <= 0 or pad_w <= 0:
return
start_index = max(0, min(start_index, max(0, pad_h - 1)))
top = box_y + 1
left = box_x + 1
bottom = box_y + min(inner_h, lines) # inclusive
right = box_x + min(inner_w, box_w - 2)
# A resize can leave the frame larger than its pad or the screen.
# noutrefresh requires both source and destination rectangles to fit.
bottom = min(top + lines - 1, top + pad_h - start_index - 1, curses.LINES - 1)
right = min(left + inner_w - 1, left + pad_w - 1, curses.COLS - 1)
if bottom < top or right < left:
return
draw_frame_title(box, get_window_title(window))
box.noutrefresh()
try:
box.noutrefresh()
# The frame overlaps the pad's viewport. Recompose even unchanged pad
# rows before committing, so its text cannot flash blank between draws.
pad.touchwin()
pad.noutrefresh(
start_index,
0,
top,
left,
bottom,
right,
)
if commit:
curses.doupdate()
# The frame overlaps the pad's viewport. Recompose even unchanged pad
# rows before committing, so its text cannot flash blank between draws.
pad.touchwin()
pad.noutrefresh(
start_index,
0,
top,
left,
bottom,
right,
)
if commit:
curses.doupdate()
except curses.error:
# The terminal can resize again between checking bounds and drawing.
# Let the next resize/redraw rebuild the windows instead of exiting.
ui_state.redraw_full_ui = True
def add_notification(channel_number: int) -> None:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.7.1"
version = "1.7.4"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
+133
View File
@@ -12,6 +12,85 @@ from tests.test_support import reset_shared_state, restore_config, snapshot_conf
class ContactUiTests(unittest.TestCase):
def test_refresh_pad_clips_to_pad_and_resized_screen(self):
for pad_size, screen_size, expected in (
((2, 5), (24, 80), (1, 0, 1, 1, 1, 5)),
((100, 100), (6, 8), (1, 0, 1, 1, 5, 7)),
):
with self.subTest(pad_size=pad_size, screen_size=screen_size), ExitStack() as stack:
box = mock.Mock()
box.getmaxyx.return_value = (40, 100)
box.getbegyx.return_value = (0, 0)
pad = mock.Mock()
pad.getmaxyx.return_value = pad_size
ui_state.selected_channel = 38
stack.enter_context(mock.patch.object(contact_ui, "channel_win", box, create=True))
stack.enter_context(mock.patch.object(contact_ui, "channel_pad", pad, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", screen_size[0], create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", screen_size[1], create=True))
contact_ui.refresh_pad(0, commit=False)
pad.noutrefresh.assert_called_once_with(*expected)
pad.noutrefresh.side_effect = contact_ui.curses.error("resize during refresh")
contact_ui.refresh_pad(0, commit=False)
self.assertTrue(ui_state.redraw_full_ui)
def test_f5_rebuilds_background_before_staging_after_terminal_resize(self):
for size in ((24, 80), (50, 140)):
with self.subTest(size=size), ExitStack() as stack:
ui_state.node_list = [101]
ui_state.current_window = 2
interface = SimpleNamespace(nodesByNum={101: {"user": {"shortName": "NODE"}}})
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
stack.enter_context(mock.patch.object(contact_ui.curses, "LINES", 40, create=True))
stack.enter_context(mock.patch.object(contact_ui.curses, "COLS", 100, create=True))
win = mock.Mock()
def read_key():
if (contact_ui.curses.LINES, contact_ui.curses.COLS) == (40, 100):
contact_ui.curses.LINES, contact_ui.curses.COLS = size
return contact_ui.curses.KEY_RESIZE
return 27
win.getch.side_effect = read_key
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
for name in ("curs_set", "update_lines_cols", "doupdate"):
stack.enter_context(mock.patch.object(contact_ui.curses, name))
for name in ("get_color", "refresh_node_selection"):
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
events = []
def resize(*args):
events.append(("resize", ui_state.current_window))
def stage(*args):
events.append(("stage", ui_state.current_window))
stack.enter_context(mock.patch.object(contact_ui, "handle_resize", side_effect=resize))
stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background", side_effect=stage))
contact_ui.handle_f5_key(mock.Mock())
self.assertEqual(events, [("resize", 2), ("stage", 4), ("resize", 2)])
self.assertEqual(ui_state.current_window, 2)
def test_main_ui_accepts_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key, "\x1b"]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
stack.enter_context(mock.patch.object(contact_ui, name))
enter = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
contact_ui.main_ui(mock.Mock())
enter.assert_called_once_with("")
def test_search_closes_on_keypad_and_regular_enter(self):
for key in (contact_ui.curses.KEY_ENTER, "\n", "\r"):
with self.subTest(key=key), ExitStack() as stack:
entry = mock.Mock()
entry.get_wch.side_effect = [key]
stack.enter_context(mock.patch.object(contact_ui, "entry_win", entry))
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=0))
stack.enter_context(mock.patch.object(contact_ui, "draw_centered_text_field"))
contact_ui.search(2)
entry.get_wch.assert_called_once_with()
self.assertEqual(entry.timeout.call_args_list, [mock.call(-1), mock.call(200)])
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
ui_state.node_list = [101, 102]
ui_state.current_window = 2
@@ -214,6 +293,58 @@ class ContactUiTests(unittest.TestCase):
contact_ui.messages_pad.resize.assert_called_once_with(2, 40)
def test_single_pane_typing_after_switching_to_shorter_conversation(self) -> None:
for messages in ([("RX: ", "hello")], [], None):
with self.subTest(messages=messages):
reset_shared_state()
ui_state.single_pane_mode = True
ui_state.current_window = 0
ui_state.channel_list = [123]
ui_state.all_messages = {123: messages} if messages is not None else {}
ui_state.highlighted_message_range = (10, 12, 20)
ui_state.message_line_ranges = {123: [(10, 12, 20)]}
pad = mock.Mock()
pad.getmaxyx.return_value = (12, 40)
def resize_pad(height, width):
pad.getmaxyx.return_value = (height, width)
def change_attributes(row, column, width, color):
height, columns = pad.getmaxyx()
if not (0 <= row < height and 0 <= column < columns and column + width <= columns):
raise contact_ui.curses.error("chgat() returned ERR")
pad.resize.side_effect = resize_pad
pad.chgat.side_effect = change_attributes
win = mock.Mock()
win.getmaxyx.return_value = (10, 40)
packetlog = mock.Mock()
packetlog.getmaxyx.return_value = (1, 40)
with ExitStack() as stack:
for name, value in (("messages_pad", pad), ("messages_win", win), ("packetlog_win", packetlog)):
stack.enter_context(mock.patch.object(contact_ui, name, value, create=True))
for name in ("paint_frame", "refresh_pad", "draw_packetlog_win", "draw_window_arrows", "refresh_main_window"):
stack.enter_context(mock.patch.object(contact_ui, name))
stack.enter_context(mock.patch.object(contact_ui, "get_color", return_value=10))
stack.enter_context(mock.patch.object(
contact_ui, "handle_resize", side_effect=lambda *args: contact_ui.draw_messages_window(True)
))
self.assertEqual(contact_ui.insert_input_text("", "a"), "a")
self.assertEqual(ui_state.current_window, 1)
self.assertTrue(ui_state.input_focused)
self.assertEqual(ui_state.highlighted_message_range, ())
pad.resize.assert_called_once_with(1, 40)
if messages:
self.assertEqual(ui_state.message_line_ranges[123], [(0, 1, 10)])
self.assertEqual(pad.chgat.call_args_list, [
mock.call(0, 1, 38, 10 | contact_ui.curses.A_REVERSE),
mock.call(0, 1, 38, 10),
])
else:
self.assertEqual(ui_state.message_line_ranges[123], [])
pad.chgat.assert_not_called()
def test_build_reply_prefix_includes_sender_and_five_character_excerpt(self) -> None:
reply = contact_ui.build_reply_prefix(
"[06:27:25] >> [6] B1G1: ",
@@ -485,6 +616,8 @@ class ContactUiTests(unittest.TestCase):
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
self.addCleanup(mock.patch.stopall)
mock.patch.object(contact_ui.curses, "LINES", 24, create=True).start()
mock.patch.object(contact_ui.curses, "COLS", 80, create=True).start()
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
ui_state.single_pane_mode = True
ui_state.current_window = 1
+45
View File
@@ -19,6 +19,51 @@ class RxHandlerTests(unittest.TestCase):
restore_config(self.saved_config)
reset_shared_state()
def test_message_sound_depends_on_selected_view(self):
config.notification_sound = "alert.mp3"
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary", "Other"]
for window, packet_log, log_viewer, channel, expected in (
(1, False, False, 0, False),
(1, False, False, 1, False),
(0, False, False, 0, True),
(2, False, False, 0, True),
(4, False, False, 0, True),
(1, True, False, 0, True),
(1, False, True, 0, True),
):
with self.subTest(window=window, packet_log=packet_log, log_viewer=log_viewer, channel=channel):
ui_state.current_window = window
ui_state.display_log = packet_log
ui_state.log_viewer_open = log_viewer
ui_state.all_messages = {"Primary": [], "Other": []}
packet = {
"from": 222, "to": 999, "channel": channel,
"decoded": {"portnum": "TEXT_MESSAGE_APP", "payload": b"hello"},
}
with mock.patch.object(rx_handler, "refresh_node_list", return_value=False), \
mock.patch.object(rx_handler, "request_ui_redraw"), \
mock.patch.object(rx_handler, "get_name_from_database", return_value="NODE"), \
mock.patch.object(rx_handler, "save_message_to_db"), \
mock.patch.object(rx_handler, "schedule_notification_sound") as sound:
rx_handler.on_receive(packet, interface=None)
self.assertEqual(sound.call_count, int(expected))
def test_pending_sound_is_suppressed_if_chat_selected_before_playback(self):
with mock.patch.object(rx_handler, "_sound_timer", None), \
mock.patch.object(rx_handler, "_last_sound_request", 0.0), \
mock.patch.object(rx_handler.threading, "Timer") as timer, \
mock.patch.object(rx_handler, "play_sound") as play:
rx_handler.schedule_notification_sound()
callback = timer.call_args.args[1]
callback_args = timer.call_args.kwargs["args"]
ui_state.current_window = 1
callback(*callback_args)
play.assert_not_called()
ui_state.current_window = 0
callback(*callback_args)
play.assert_called_once_with()
def test_on_receive_text_message_refreshes_selected_channel(self) -> None:
interface_state.myNodeNum = 111
ui_state.channel_list = ["Primary"]