Compare commits

..

3 Commits

Author SHA1 Message Date
pdxlocations
480c32ba56 try fix shutdown 2026-03-21 21:13:53 -07:00
pdxlocations
b4b084b627 bump version to 1.5.4 in pyproject.toml 2026-03-19 15:47:51 -07:00
pdxlocations
5940c9b02b fix content margins 2026-03-19 15:19:24 -07:00
7 changed files with 143 additions and 17 deletions

View File

@@ -6,19 +6,26 @@ import sys
import traceback
import contact.ui.default_config as config
from contact.utilities.input_handlers import get_list_input
from contact.utilities.i18n import t
from contact.ui.dialog import dialog
from contact.utilities.i18n import t
from contact.ui.colors import setup_colors
from contact.ui.splash import draw_splash
from contact.ui.control_ui import set_region, settings_menu
from contact.ui.dialog import dialog
from contact.ui.splash import draw_splash
from contact.utilities.arg_parser import setup_parser
from contact.utilities.i18n import t
from contact.utilities.input_handlers import get_list_input
from contact.utilities.interfaces import initialize_interface, reconnect_interface
def close_interface(interface: object) -> None:
if interface is None:
return
with contextlib.suppress(Exception):
interface.close()
def main(stdscr: curses.window) -> None:
output_capture = io.StringIO()
interface = None
try:
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(output_capture):
setup_colors()
@@ -39,7 +46,7 @@ def main(stdscr: curses.window) -> None:
)
if confirmation == "Yes":
set_region(interface)
interface.close()
close_interface(interface)
draw_splash(stdscr)
interface = reconnect_interface(args)
stdscr.clear()
@@ -52,6 +59,8 @@ def main(stdscr: curses.window) -> None:
logging.error("Traceback: %s", traceback.format_exc())
logging.error("Console output before crash:\n%s", console_output)
raise
finally:
close_interface(interface)
def ensure_min_rows(stdscr: curses.window, min_rows: int = 11) -> None:

View File

@@ -143,7 +143,7 @@ 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] - 2)
width = max(0, nodes_pad.getmaxyx()[1] - 4)
if 0 <= old_index < len(ui_state.node_list):
try:
@@ -175,7 +175,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
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] - 2)
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)
@@ -1089,7 +1089,7 @@ def draw_channel_list() -> None:
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
# Truncate the channel name if it's too long to fit in the window
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 3)
truncated_channel = truncate_with_ellipsis(f"{channel}{notification}", win_width - 4)
color = get_color("channel_list")
if idx == ui_state.selected_channel:
@@ -1184,7 +1184,7 @@ def draw_node_list() -> None:
node_name = get_node_display_name(node_num, node)
# Future node name custom formatting possible
node_str = pad_to_width(f"{status_icon} {node_name}", box_width - 2)
node_str = truncate_with_ellipsis(f"{status_icon} {node_name}", box_width - 4)
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
paint_frame(nodes_win, selected=(ui_state.current_window == 2))

View File

@@ -457,8 +457,8 @@ def highlight_line(
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
elif ui_state.current_window == 2:
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 2, get_node_color(new_idx, reverse=True))
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
menu_win.refresh()

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.5.3"
version = "1.5.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"}

View File

@@ -3,6 +3,7 @@ from unittest import mock
import contact.ui.default_config as config
from contact.ui import contact_ui
from contact.ui.nav_utils import text_width
from contact.utilities.singleton import ui_state
from tests.test_support import reset_singletons, restore_config, snapshot_config
@@ -69,7 +70,7 @@ class ContactUiTests(unittest.TestCase):
self.assertFalse(ui_state.redraw_channels)
self.assertFalse(ui_state.redraw_messages)
def test_refresh_node_selection_highlights_full_row_width(self) -> None:
def test_refresh_node_selection_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101, 202]
ui_state.selected_node = 1
ui_state.start_index = [0, 0, 0]
@@ -89,11 +90,52 @@ class ContactUiTests(unittest.TestCase):
self.assertEqual(
contact_ui.nodes_pad.chgat.call_args_list,
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)
refresh_pad.assert_called_once_with(2)
draw_window_arrows.assert_called_once_with(2)
def test_draw_channel_list_reserves_scroll_arrow_column(self) -> None:
ui_state.channel_list = ["VeryLongChannelName"]
ui_state.notifications = []
ui_state.selected_channel = 0
ui_state.current_window = 0
contact_ui.channel_pad = mock.Mock()
contact_ui.channel_win = mock.Mock()
contact_ui.channel_win.getmaxyx.return_value = (10, 20)
with mock.patch.object(contact_ui, "get_color", return_value=1):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
with mock.patch.object(contact_ui, "remove_notification"):
contact_ui.draw_channel_list()
text = contact_ui.channel_pad.addstr.call_args.args[2]
self.assertEqual(len(text), 16)
def test_draw_node_list_reserves_scroll_arrow_column(self) -> None:
ui_state.node_list = [101]
ui_state.current_window = 2
contact_ui.nodes_pad = mock.Mock()
contact_ui.nodes_win = mock.Mock()
contact_ui.nodes_win.getmaxyx.return_value = (10, 20)
contact_ui.entry_win = mock.Mock()
interface = mock.Mock()
interface.nodesByNum = {101: {"user": {"longName": "VeryLongNodeName", "publicKey": ""}}}
with mock.patch("contact.ui.contact_ui.interface_state.interface", interface):
with mock.patch.object(contact_ui, "get_node_row_color", return_value=1):
with mock.patch.object(contact_ui.curses, "curs_set"):
with mock.patch.object(contact_ui, "paint_frame"):
with mock.patch.object(contact_ui, "refresh_pad"):
with mock.patch.object(contact_ui, "draw_window_arrows"):
contact_ui.draw_node_list()
text = contact_ui.nodes_pad.addstr.call_args.args[2]
self.assertEqual(text_width(text), 16)
self.assertIn("", text)
def test_handle_resize_single_pane_keeps_full_width_windows(self) -> None:
stdscr = mock.Mock()
stdscr.getmaxyx.return_value = (24, 80)

View File

@@ -18,7 +18,7 @@ class NavUtilsTests(unittest.TestCase):
def test_truncate_with_ellipsis_respects_display_width(self) -> None:
self.assertEqual(truncate_with_ellipsis("🔐Alpha", 5), "🔐Al…")
def test_highlight_line_uses_full_node_row_width(self) -> None:
def test_highlight_line_reserves_scroll_arrow_column_for_nodes(self) -> None:
ui_state.current_window = 2
ui_state.start_index = [0, 0, 0]
menu_win = mock.Mock()
@@ -32,5 +32,5 @@ class NavUtilsTests(unittest.TestCase):
self.assertEqual(
menu_pad.chgat.call_args_list,
[mock.call(0, 1, 18, 11), mock.call(1, 1, 18, 22)],
[mock.call(0, 1, 16, 11), mock.call(1, 1, 16, 22)],
)

75
tests/test_settings.py Normal file
View File

@@ -0,0 +1,75 @@
from argparse import Namespace
from types import SimpleNamespace
import unittest
from unittest import mock
import contact.settings as settings
class SettingsRuntimeTests(unittest.TestCase):
def test_main_closes_interface_after_normal_settings_exit(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu") as settings_menu:
setup_parser.return_value.parse_args.return_value = args
settings.main(stdscr)
settings_menu.assert_called_once_with(stdscr, interface)
interface.close.assert_called_once_with()
def test_main_closes_reconnected_interface_after_region_reset(self) -> None:
stdscr = mock.Mock()
args = Namespace()
old_interface = mock.Mock()
old_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=0)))
new_interface = mock.Mock()
new_interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=old_interface):
with mock.patch.object(settings, "get_list_input", return_value="Yes"):
with mock.patch.object(settings, "set_region") as set_region:
with mock.patch.object(
settings, "reconnect_interface", return_value=new_interface
) as reconnect_interface:
with mock.patch.object(settings, "settings_menu") as settings_menu:
setup_parser.return_value.parse_args.return_value = args
settings.main(stdscr)
set_region.assert_called_once_with(old_interface)
reconnect_interface.assert_called_once_with(args)
settings_menu.assert_called_once_with(stdscr, new_interface)
old_interface.close.assert_called_once_with()
new_interface.close.assert_called_once_with()
def test_main_closes_interface_when_settings_menu_raises(self) -> None:
stdscr = mock.Mock()
args = Namespace()
interface = mock.Mock()
interface.localNode = SimpleNamespace(localConfig=SimpleNamespace(lora=SimpleNamespace(region=1)))
with mock.patch.object(settings, "setup_colors"):
with mock.patch.object(settings, "ensure_min_rows"):
with mock.patch.object(settings, "draw_splash"):
with mock.patch.object(settings.curses, "curs_set"):
with mock.patch.object(settings, "setup_parser") as setup_parser:
with mock.patch.object(settings, "initialize_interface", return_value=interface):
with mock.patch.object(settings, "settings_menu", side_effect=RuntimeError("boom")):
setup_parser.return_value.parse_args.return_value = args
with self.assertRaises(RuntimeError):
settings.main(stdscr)
interface.close.assert_called_once_with()