mirror of
https://github.com/pdxlocations/contact.git
synced 2026-10-31 16:21:57 +01:00
Compare commits
6
Commits
1.6.7
...
flicker-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a89d4f8c0d | ||
|
|
808fee4fe0 | ||
|
|
94d0a9053e | ||
|
|
f38167db1a | ||
|
|
57caf463bc | ||
|
|
36408a1f37 |
No files matched your search
+4
-2
@@ -121,7 +121,7 @@ def interface_is_ready(interface: object) -> bool:
|
||||
|
||||
def initialize_runtime_interface_with_retry(stdscr: curses.window, args: object):
|
||||
while True:
|
||||
interface = initialize_runtime_interface(args)
|
||||
interface = initialize_runtime_interface(args, stdscr)
|
||||
if getattr(args, "demo_screenshot", False) or interface_is_ready(interface):
|
||||
return interface
|
||||
|
||||
@@ -162,10 +162,12 @@ def initialize_globals(seed_demo: bool = False) -> None:
|
||||
load_messages_from_db()
|
||||
|
||||
|
||||
def initialize_runtime_interface(args: object):
|
||||
def initialize_runtime_interface(args: object, stdscr=None):
|
||||
if getattr(args, "demo_screenshot", False):
|
||||
configure_demo_database()
|
||||
return build_demo_interface()
|
||||
if stdscr is not None:
|
||||
return initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
|
||||
return initialize_interface(args)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ def main(stdscr: curses.window) -> None:
|
||||
|
||||
parser = setup_parser()
|
||||
args = parser.parse_args()
|
||||
interface = initialize_interface(args)
|
||||
interface = initialize_interface(args, status_callback=lambda status: draw_splash(stdscr, status))
|
||||
|
||||
if interface.localNode.localConfig.lora.region == 0:
|
||||
confirmation = get_list_input(
|
||||
|
||||
+142
-34
@@ -35,6 +35,8 @@ from contact.ui.nav_utils import (
|
||||
wrap_text,
|
||||
truncate_with_ellipsis,
|
||||
pad_to_width,
|
||||
text_width,
|
||||
slice_to_width,
|
||||
)
|
||||
from contact.utilities.singleton import ui_state, interface_state, menu_state, app_state
|
||||
|
||||
@@ -43,6 +45,8 @@ MIN_COL = 1 # "effectively zero" without breaking curses
|
||||
RESIZE_DEBOUNCE_MS = 250
|
||||
root_win = None
|
||||
nodes_pad = None
|
||||
entry_win = None
|
||||
input_text = ""
|
||||
|
||||
|
||||
def refresh_log_viewer() -> None:
|
||||
@@ -242,11 +246,14 @@ def compute_widths(total_w: int, focus: int):
|
||||
return MIN_COL, MIN_COL, total_w - 2 * MIN_COL
|
||||
|
||||
|
||||
def paint_frame(win, selected: bool) -> None:
|
||||
def paint_frame(win, selected: bool, refresh: bool = True) -> None:
|
||||
if win is not entry_win and ui_state.input_focused:
|
||||
selected = False
|
||||
win.attrset(get_color("window_frame_selected") if selected else get_color("window_frame"))
|
||||
win.box()
|
||||
win.attrset(get_color("window_frame"))
|
||||
win.refresh()
|
||||
if refresh:
|
||||
win.refresh()
|
||||
|
||||
|
||||
def get_channel_row_color(index: int) -> int:
|
||||
@@ -269,7 +276,7 @@ def get_node_row_color(index: int, highlight: bool = False) -> int:
|
||||
return get_color(color, reverse=reverse)
|
||||
|
||||
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None:
|
||||
def refresh_node_selection(old_index: int = -1, highlight: bool = False, refresh: bool = True) -> None:
|
||||
if nodes_pad is None or not ui_state.node_list:
|
||||
return
|
||||
|
||||
@@ -288,8 +295,9 @@ def refresh_node_selection(old_index: int = -1, highlight: bool = False) -> None
|
||||
pass
|
||||
|
||||
ui_state.start_index[2] = max(0, ui_state.selected_node - (nodes_win.getmaxyx()[0] - 3))
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
if refresh:
|
||||
refresh_pad(2)
|
||||
draw_window_arrows(2)
|
||||
|
||||
|
||||
def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
@@ -300,7 +308,7 @@ def refresh_main_window(window_id: int, selected: bool) -> None:
|
||||
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)
|
||||
paint_frame(messages_win, selected=selected, refresh=False)
|
||||
refresh_pad(1)
|
||||
elif window_id == 2:
|
||||
paint_frame(nodes_win, selected=selected)
|
||||
@@ -491,6 +499,8 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
root_win = stdscr
|
||||
input_text = ""
|
||||
ui_state.input_cursor = 0
|
||||
ui_state.input_focused = False
|
||||
queued_char = None
|
||||
stdscr.keypad(True)
|
||||
get_channels()
|
||||
@@ -555,8 +565,7 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
with app_state.lock:
|
||||
process_pending_ui_updates(stdscr)
|
||||
entry_display = f"{ui_state.reply_context}{input_text or ''}"
|
||||
draw_text_field(entry_win, f"Message: {entry_display[-(stdscr.getmaxyx()[1] - 10):]}", get_color("input"))
|
||||
draw_message_input(input_text)
|
||||
|
||||
# Get user input from entry window
|
||||
try:
|
||||
@@ -596,6 +605,7 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
|
||||
elif char in (chr(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))
|
||||
|
||||
elif char in (curses.KEY_F4, chr(20)): # Ctrl + t and F4 for Traceroute
|
||||
handle_ctrl_t(stdscr)
|
||||
@@ -603,9 +613,14 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
elif char == curses.KEY_F5:
|
||||
handle_f5_key(stdscr)
|
||||
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127)):
|
||||
elif char in (curses.KEY_BACKSPACE, chr(127), chr(8)):
|
||||
input_text = handle_backspace(entry_win, input_text)
|
||||
|
||||
elif char == curses.KEY_DC:
|
||||
focus_message_input()
|
||||
position = ui_state.input_cursor
|
||||
input_text = input_text[:position] + input_text[position + 1:]
|
||||
|
||||
elif char in (curses.KEY_F12, "`"): # ` Launch the settings interface
|
||||
handle_backtick(stdscr)
|
||||
|
||||
@@ -619,7 +634,6 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
entry_win.erase()
|
||||
|
||||
elif char == curses.KEY_RESIZE:
|
||||
input_text = ""
|
||||
queued_char = drain_resize_events(entry_win)
|
||||
handle_resize(stdscr, False)
|
||||
continue
|
||||
@@ -645,15 +659,66 @@ def main_ui(stdscr: curses.window) -> None:
|
||||
break
|
||||
|
||||
else:
|
||||
# Append typed character to input text
|
||||
if isinstance(char, str):
|
||||
input_text += char
|
||||
else:
|
||||
input_text += chr(char)
|
||||
if isinstance(char, str) and char.isprintable():
|
||||
input_text = insert_input_text(input_text, char)
|
||||
|
||||
|
||||
def focus_message_input() -> None:
|
||||
if ui_state.input_focused:
|
||||
return
|
||||
# Keep the conversation visible in single-pane mode while composing.
|
||||
handle_function_keys(curses.KEY_F2)
|
||||
ui_state.input_focused = True
|
||||
refresh_message_highlight()
|
||||
refresh_main_window(1, selected=False)
|
||||
|
||||
|
||||
def leave_message_input() -> None:
|
||||
ui_state.input_focused = False
|
||||
ui_state.current_window = 1
|
||||
refresh_message_highlight()
|
||||
refresh_main_window(1, selected=True)
|
||||
|
||||
|
||||
def insert_input_text(draft: str, text: str) -> str:
|
||||
focus_message_input()
|
||||
position = min(ui_state.input_cursor, len(draft))
|
||||
ui_state.input_cursor = position + len(text)
|
||||
return draft[:position] + text + draft[position:]
|
||||
|
||||
|
||||
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]
|
||||
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
|
||||
cursor = len(ui_state.reply_context) + ui_state.input_cursor
|
||||
start = cursor
|
||||
used = 0
|
||||
while start > 0 and used + text_width(content[start - 1]) < available:
|
||||
start -= 1
|
||||
used += text_width(content[start])
|
||||
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"))
|
||||
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:
|
||||
pass
|
||||
entry_win.refresh()
|
||||
|
||||
|
||||
def handle_up() -> None:
|
||||
"""Handle key up events to scroll the current window."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
scroll_channels(-1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -664,6 +729,9 @@ def handle_up() -> None:
|
||||
|
||||
def handle_down() -> None:
|
||||
"""Handle key down events to scroll the current window."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
scroll_channels(1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -674,6 +742,9 @@ def handle_down() -> None:
|
||||
|
||||
def handle_home() -> None:
|
||||
"""Handle home key events to select the first item in the current window."""
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = 0
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(0)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -688,6 +759,9 @@ def handle_home() -> None:
|
||||
|
||||
def handle_end() -> None:
|
||||
"""Handle end key events to select the last item in the current window."""
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = len(input_text)
|
||||
return
|
||||
if ui_state.current_window == 0:
|
||||
select_channel(len(ui_state.channel_list) - 1)
|
||||
elif ui_state.current_window == 1:
|
||||
@@ -728,6 +802,9 @@ def handle_pagedown() -> None:
|
||||
def handle_leftright(char: int) -> None:
|
||||
"""Handle left/right key events to switch between windows."""
|
||||
delta = -1 if char == curses.KEY_LEFT else 1
|
||||
if ui_state.input_focused:
|
||||
ui_state.input_cursor = max(0, min(len(input_text), ui_state.input_cursor + delta))
|
||||
return
|
||||
old_window = ui_state.current_window
|
||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||
if ui_state.single_pane_mode:
|
||||
@@ -748,6 +825,8 @@ def handle_leftright(char: int) -> None:
|
||||
|
||||
def handle_function_keys(char: int) -> None:
|
||||
"""Switch windows using F1/F2/F3."""
|
||||
if ui_state.input_focused:
|
||||
leave_message_input()
|
||||
if char == curses.KEY_F1:
|
||||
target = 0
|
||||
elif char == curses.KEY_F2:
|
||||
@@ -858,6 +937,28 @@ def handle_enter(input_text: str) -> str:
|
||||
return input_text
|
||||
|
||||
|
||||
def stage_node_details_background(stdscr, previous_window):
|
||||
"""Restore the panes behind a moved dialog without displaying an intermediate frame."""
|
||||
current_window = ui_state.current_window
|
||||
try:
|
||||
ui_state.current_window = previous_window
|
||||
stdscr.touchwin()
|
||||
stdscr.noutrefresh()
|
||||
windows = (channel_win, messages_win, nodes_win)
|
||||
for index, win in enumerate(windows):
|
||||
if ui_state.single_pane_mode and index != previous_window:
|
||||
continue
|
||||
win.touchwin()
|
||||
refresh_pad(index, commit=False)
|
||||
if ui_state.display_log and (not ui_state.single_pane_mode or previous_window == 1):
|
||||
packetlog_win.touchwin()
|
||||
packetlog_win.noutrefresh()
|
||||
entry_win.touchwin()
|
||||
entry_win.noutrefresh()
|
||||
finally:
|
||||
ui_state.current_window = current_window
|
||||
|
||||
|
||||
def handle_f5_key(stdscr: curses.window) -> None:
|
||||
if not ui_state.node_list:
|
||||
return
|
||||
@@ -955,6 +1056,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
ui_state.current_window = 4
|
||||
scroll_offset = 0
|
||||
dialog_win = None
|
||||
previous_layout = None
|
||||
|
||||
curses.curs_set(0)
|
||||
refresh_node_selection(highlight=True)
|
||||
@@ -974,11 +1076,15 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
max_scroll = max(0, len(message_lines) - viewport_h)
|
||||
scroll_offset = max(0, min(scroll_offset, max_scroll))
|
||||
|
||||
layout = (dialog_height, dialog_width, y, x, ui_state.selected_node, height, width)
|
||||
if previous_layout is not None and layout != previous_layout:
|
||||
stage_node_details_background(stdscr, previous_window)
|
||||
previous_layout = layout
|
||||
|
||||
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)
|
||||
|
||||
@@ -1023,7 +1129,7 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
dialog_win.refresh()
|
||||
dialog_win.noutrefresh()
|
||||
msg_win.noutrefresh()
|
||||
curses.doupdate()
|
||||
|
||||
@@ -1043,12 +1149,12 @@ def handle_f5_key(stdscr: curses.window) -> None:
|
||||
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)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
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)
|
||||
refresh_node_selection(old_selected_node, highlight=True, refresh=False)
|
||||
elif char == curses.KEY_PPAGE:
|
||||
scroll_offset = max(0, scroll_offset - viewport_h)
|
||||
elif char == curses.KEY_NPAGE:
|
||||
@@ -1107,14 +1213,12 @@ def handle_ctrl_t(stdscr: curses.window) -> None:
|
||||
|
||||
|
||||
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()
|
||||
"""Delete before the draft cursor; the next frame redraws the entire line."""
|
||||
focus_message_input()
|
||||
position = ui_state.input_cursor
|
||||
if position > 0:
|
||||
input_text = input_text[:position - 1] + input_text[position:]
|
||||
ui_state.input_cursor -= 1
|
||||
return input_text
|
||||
|
||||
|
||||
@@ -1567,7 +1671,7 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
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))
|
||||
paint_frame(messages_win, selected=(ui_state.current_window == 1), refresh=False)
|
||||
|
||||
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
|
||||
|
||||
@@ -1582,7 +1686,6 @@ def draw_messages_window(scroll_to_bottom: bool = False, preserve_selection: boo
|
||||
|
||||
refresh_message_highlight()
|
||||
|
||||
messages_win.refresh()
|
||||
refresh_pad(1)
|
||||
draw_packetlog_win()
|
||||
draw_window_arrows(1)
|
||||
@@ -1596,7 +1699,7 @@ def refresh_message_highlight() -> None:
|
||||
previous_range = ui_state.highlighted_message_range
|
||||
width = max(0, messages_win.getmaxyx()[1] - 2)
|
||||
|
||||
if not ui_state.channel_list or ui_state.current_window != 1:
|
||||
if not ui_state.channel_list or ui_state.current_window != 1 or ui_state.input_focused:
|
||||
if previous_range:
|
||||
start, end, color = previous_range
|
||||
for row in range(start, end):
|
||||
@@ -1912,7 +2015,7 @@ def search(win: int) -> None:
|
||||
entry_win.erase()
|
||||
|
||||
|
||||
def refresh_pad(window: int) -> None:
|
||||
def refresh_pad(window: int, commit: bool = True) -> None:
|
||||
|
||||
# If in single-pane mode and this isn't the focused window, skip refreshing its (collapsed) pad
|
||||
if ui_state.single_pane_mode and window != ui_state.current_window:
|
||||
@@ -1929,7 +2032,7 @@ def refresh_pad(window: int) -> None:
|
||||
|
||||
if ui_state.display_log:
|
||||
packetlog_win.box()
|
||||
packetlog_win.refresh()
|
||||
packetlog_win.noutrefresh()
|
||||
|
||||
elif window == 2:
|
||||
pad = nodes_pad
|
||||
@@ -1972,9 +2075,12 @@ def refresh_pad(window: int) -> None:
|
||||
return
|
||||
|
||||
draw_frame_title(box, get_window_title(window))
|
||||
box.refresh()
|
||||
box.noutrefresh()
|
||||
|
||||
pad.refresh(
|
||||
# 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,
|
||||
@@ -1982,6 +2088,8 @@ def refresh_pad(window: int) -> None:
|
||||
bottom,
|
||||
right,
|
||||
)
|
||||
if commit:
|
||||
curses.doupdate()
|
||||
|
||||
|
||||
def add_notification(channel_number: int) -> None:
|
||||
|
||||
+10
-5
@@ -1,8 +1,9 @@
|
||||
import curses
|
||||
import textwrap
|
||||
from contact.ui.colors import get_color
|
||||
|
||||
|
||||
def draw_splash(stdscr: object) -> None:
|
||||
def draw_splash(stdscr: object, status: str = "connecting...") -> None:
|
||||
"""Draw the splash screen with a logo and connecting message."""
|
||||
curses.curs_set(0)
|
||||
|
||||
@@ -13,16 +14,20 @@ def draw_splash(stdscr: object) -> None:
|
||||
message_1 = "/ Λ"
|
||||
message_2 = "/ / \\"
|
||||
message_3 = "P W R D"
|
||||
message_4 = "connecting..."
|
||||
|
||||
start_x = width // 2 - len(message_1) // 2
|
||||
start_x2 = width // 2 - len(message_4) // 2
|
||||
start_y = height // 2 - 1
|
||||
stdscr.addstr(start_y, start_x, message_1, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 1, start_x - 1, message_2, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 2, start_x - 2, message_3, get_color("splash_logo", bold=True))
|
||||
stdscr.addstr(start_y + 4, start_x2, message_4, get_color("splash_text"))
|
||||
stdscr.move(start_y + 5, start_x2)
|
||||
status_lines = []
|
||||
for line in status.splitlines():
|
||||
status_lines.extend(textwrap.wrap(line, max(1, width - 4)) or [""])
|
||||
for offset, line in enumerate(status_lines):
|
||||
row = start_y + 4 + offset
|
||||
if row >= height - 1:
|
||||
break
|
||||
stdscr.addstr(row, max(1, (width - len(line)) // 2), line, get_color("splash_text"))
|
||||
|
||||
stdscr.attrset(get_color("window_frame"))
|
||||
stdscr.box()
|
||||
|
||||
@@ -25,6 +25,8 @@ class ChatUIState:
|
||||
selected_message: int = 0
|
||||
selected_node: int = 0
|
||||
current_window: int = 0
|
||||
input_focused: bool = False
|
||||
input_cursor: int = 0
|
||||
last_sent_time: float = 0.0
|
||||
last_traceroute_time: float = 0.0
|
||||
selected_index: int = 0
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import contextlib
|
||||
import io
|
||||
import time
|
||||
import meshtastic.serial_interface, meshtastic.tcp_interface, meshtastic.ble_interface
|
||||
|
||||
@@ -25,7 +27,7 @@ def interface_is_connected(interface) -> bool:
|
||||
return getattr(interface, "localNode", None) is not None
|
||||
|
||||
|
||||
def initialize_interface(args):
|
||||
def initialize_interface(args, status_callback=None):
|
||||
try:
|
||||
|
||||
if args.ble:
|
||||
@@ -43,7 +45,10 @@ def initialize_interface(args):
|
||||
logging.error(f"Error connecting to {args.host}. {ex}")
|
||||
else:
|
||||
try:
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
# The library prints its fallback notice directly to stdout,
|
||||
# which would overwrite the curses splash screen.
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
client = meshtastic.serial_interface.SerialInterface(args.port)
|
||||
except FileNotFoundError as ex:
|
||||
logging.error(f"The serial device at '{args.port}' was not found. {ex}")
|
||||
except PermissionError as ex:
|
||||
@@ -56,6 +61,8 @@ def initialize_interface(args):
|
||||
logging.error(f"The serial device couldn't be opened, it might be in use by another process. {ex}")
|
||||
if client.devPath is None:
|
||||
try:
|
||||
if status_callback:
|
||||
status_callback("No serial device found.\nConnecting via TCP to localhost…")
|
||||
client = meshtastic.tcp_interface.TCPInterface("localhost")
|
||||
except Exception as ex:
|
||||
logging.error(f"Error connecting to localhost:{ex}")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.6.7"
|
||||
version = "1.6.9"
|
||||
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"}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
@@ -11,6 +12,52 @@ from tests.test_support import reset_singletons, restore_config, snapshot_config
|
||||
|
||||
|
||||
class ContactUiTests(unittest.TestCase):
|
||||
def test_f5_restores_background_when_switching_between_different_size_nodes(self):
|
||||
ui_state.node_list = [101, 102]
|
||||
ui_state.current_window = 2
|
||||
interface = SimpleNamespace(nodesByNum={
|
||||
101: {"user": {"shortName": "BIG", "longName": "A" * 60}, "position": {"altitude": 100}},
|
||||
102: {"user": {"shortName": "SMALL", "longName": "B"}},
|
||||
})
|
||||
win = mock.Mock()
|
||||
win.getch.side_effect = [contact_ui.curses.KEY_DOWN, contact_ui.curses.KEY_UP, 27]
|
||||
events = mock.Mock()
|
||||
events.attach_mock(win.noutrefresh, "dialog")
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(mock.patch.object(contact_ui.interface_state, "interface", interface))
|
||||
for name, value in (("LINES", 40), ("COLS", 100)):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name, value, create=True))
|
||||
for name in ("curs_set", "update_lines_cols", "doupdate"):
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui.curses, "newwin", return_value=win))
|
||||
for name in ("get_color", "refresh_node_selection", "handle_resize"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, return_value=0))
|
||||
restore = stack.enter_context(mock.patch.object(contact_ui, "stage_node_details_background"))
|
||||
events.attach_mock(restore, "restore")
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
self.assertEqual(events.mock_calls, [
|
||||
mock.call.dialog(), mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
mock.call.restore(stdscr, 2), mock.call.dialog(),
|
||||
])
|
||||
self.assertNotEqual(win.resize.call_args_list[0], win.resize.call_args_list[1])
|
||||
self.assertEqual(win.refresh.call_count, 1) # Only on close.
|
||||
|
||||
def test_background_restore_stages_panes_without_flushing(self):
|
||||
ui_state.current_window = 4
|
||||
ui_state.single_pane_mode = True
|
||||
stdscr = mock.Mock()
|
||||
with ExitStack() as stack:
|
||||
for name in ("channel_win", "messages_win", "nodes_win", "entry_win"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name, mock.Mock(), create=True))
|
||||
refresh = stack.enter_context(mock.patch.object(contact_ui, "refresh_pad"))
|
||||
update = stack.enter_context(mock.patch.object(contact_ui.curses, "doupdate"))
|
||||
contact_ui.stage_node_details_background(stdscr, 2)
|
||||
refresh.assert_called_once_with(2, commit=False)
|
||||
update.assert_not_called()
|
||||
stdscr.noutrefresh.assert_called_once()
|
||||
self.assertEqual(ui_state.current_window, 4)
|
||||
|
||||
def setUp(self) -> None:
|
||||
reset_singletons()
|
||||
self.saved_config = snapshot_config("single_pane_mode")
|
||||
@@ -437,6 +484,8 @@ class ContactUiTests(unittest.TestCase):
|
||||
self.assertEqual(contact_ui.get_window_title(1), "Primary")
|
||||
|
||||
def test_refresh_pad_draws_selected_channel_title_on_message_frame(self) -> None:
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
mock.patch.object(contact_ui, "packetlog_win", mock.Mock(), create=True).start()
|
||||
ui_state.single_pane_mode = True
|
||||
ui_state.current_window = 1
|
||||
ui_state.channel_list = ["Primary"]
|
||||
@@ -453,9 +502,16 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.messages_win.getmaxyx.return_value = (10, 20)
|
||||
|
||||
with mock.patch.object(contact_ui, "get_msg_window_lines", return_value=4):
|
||||
contact_ui.refresh_pad(1)
|
||||
with mock.patch.object(contact_ui.curses, "doupdate") as update:
|
||||
contact_ui.refresh_pad(1)
|
||||
|
||||
contact_ui.messages_win.addstr.assert_called_once_with(0, 2, " Primary ", contact_ui.curses.A_BOLD)
|
||||
contact_ui.messages_win.refresh.assert_not_called()
|
||||
contact_ui.messages_pad.refresh.assert_not_called()
|
||||
contact_ui.messages_win.noutrefresh.assert_called_once()
|
||||
contact_ui.messages_pad.touchwin.assert_called_once()
|
||||
contact_ui.messages_pad.noutrefresh.assert_called_once()
|
||||
update.assert_called_once()
|
||||
|
||||
def test_search_ignores_no_input_from_curses(self) -> None:
|
||||
ui_state.node_list = [101]
|
||||
@@ -508,6 +564,9 @@ class ContactUiTests(unittest.TestCase):
|
||||
contact_ui.handle_f5_key(stdscr)
|
||||
|
||||
self.assertEqual(dialog_win.getch.call_count, 2)
|
||||
# Idle polling must never present an erased dialog before its text.
|
||||
self.assertEqual(dialog_win.noutrefresh.call_count, 2)
|
||||
self.assertEqual(dialog_win.refresh.call_count, 1) # Closing the dialog only.
|
||||
handle_resize.assert_called_once_with(stdscr, False)
|
||||
|
||||
def test_f5_node_details_tolerates_none_metrics(self) -> None:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
from contextlib import ExitStack
|
||||
from unittest import mock
|
||||
|
||||
from contact.ui import contact_ui
|
||||
from contact.utilities.singleton import ui_state
|
||||
from tests.test_support import reset_singletons
|
||||
|
||||
|
||||
class MessageInputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
reset_singletons()
|
||||
ui_state.current_window = 1
|
||||
for name in ("refresh_message_highlight", "refresh_main_window", "get_color"):
|
||||
patcher = mock.patch.object(contact_ui, name, return_value=1)
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.addCleanup(reset_singletons)
|
||||
|
||||
def test_keyboard_loop_sends_edited_draft_after_resize(self):
|
||||
win = mock.Mock()
|
||||
win.get_wch.side_effect = [
|
||||
"a", "b", "c", contact_ui.curses.KEY_LEFT,
|
||||
contact_ui.curses.KEY_DC, "d", contact_ui.curses.KEY_BACKSPACE,
|
||||
"e", contact_ui.curses.KEY_RESIZE, "\n", "\x1b",
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(mock.patch.object(contact_ui, "entry_win", win))
|
||||
for name in ("get_channels", "handle_resize", "process_pending_ui_updates", "draw_message_input"):
|
||||
stack.enter_context(mock.patch.object(contact_ui, name))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "interface_is_connected", return_value=True))
|
||||
stack.enter_context(mock.patch.object(contact_ui, "drain_resize_events", return_value=None))
|
||||
send = stack.enter_context(mock.patch.object(contact_ui, "handle_enter", return_value=""))
|
||||
contact_ui.main_ui(mock.Mock())
|
||||
send.assert_called_once_with("abe")
|
||||
self.assertEqual(contact_ui.input_text, "")
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
|
||||
def test_typing_focuses_input_and_edits_at_cursor(self):
|
||||
draft = contact_ui.insert_input_text("", "helo")
|
||||
self.assertTrue(ui_state.input_focused)
|
||||
with mock.patch.object(contact_ui, "input_text", draft):
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
|
||||
draft = contact_ui.insert_input_text(draft, "l")
|
||||
self.assertEqual(draft, "hello")
|
||||
self.assertEqual(ui_state.input_cursor, 4)
|
||||
draft = contact_ui.handle_backspace(mock.Mock(), draft)
|
||||
self.assertEqual(draft, "helo")
|
||||
self.assertEqual(ui_state.input_cursor, 3)
|
||||
|
||||
def test_up_and_down_return_to_messages_without_scrolling(self):
|
||||
for handler in (contact_ui.handle_up, contact_ui.handle_down):
|
||||
ui_state.input_focused = True
|
||||
ui_state.input_cursor = 2
|
||||
with mock.patch.object(contact_ui, "scroll_messages") as scroll:
|
||||
handler()
|
||||
self.assertFalse(ui_state.input_focused)
|
||||
self.assertEqual(ui_state.current_window, 1)
|
||||
self.assertEqual(ui_state.input_cursor, 2)
|
||||
scroll.assert_not_called()
|
||||
|
||||
def test_cursor_stops_at_draft_boundaries(self):
|
||||
ui_state.input_focused = True
|
||||
with mock.patch.object(contact_ui, "input_text", "abc"):
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_LEFT)
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
self.assertEqual(contact_ui.handle_backspace(mock.Mock(), "abc"), "abc")
|
||||
contact_ui.handle_end()
|
||||
contact_ui.handle_leftright(contact_ui.curses.KEY_RIGHT)
|
||||
self.assertEqual(ui_state.input_cursor, 3)
|
||||
contact_ui.handle_home()
|
||||
self.assertEqual(ui_state.input_cursor, 0)
|
||||
|
||||
def test_redraw_clears_line_and_shows_text_after_cursor(self):
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 30)
|
||||
ui_state.input_focused = True
|
||||
ui_state.input_cursor = 2
|
||||
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
contact_ui.draw_message_input("hello")
|
||||
win.addstr.assert_any_call(1, 1, " " * 28, 1)
|
||||
win.addstr.assert_any_call(1, 1, "Message: hello", 1)
|
||||
win.move.assert_called_once_with(1, 12)
|
||||
contact_ui.get_color.assert_any_call("window_frame_selected")
|
||||
|
||||
def test_long_draft_scrolls_to_cursor_with_reply_context(self):
|
||||
win = mock.Mock()
|
||||
win.getmaxyx.return_value = (3, 24)
|
||||
ui_state.reply_context = "Reply to someone: "
|
||||
ui_state.input_cursor = 20
|
||||
with mock.patch.object(contact_ui, "entry_win", win), mock.patch.object(contact_ui.curses, "curs_set"):
|
||||
contact_ui.draw_message_input("x" * 20)
|
||||
self.assertLess(win.move.call_args.args[1], 23)
|
||||
rendered = [call.args[2] for call in win.addstr.call_args_list if call.args[:2] == (1, 1)][-1]
|
||||
self.assertEqual(rendered, "Message: " + "x" * 12)
|
||||
Reference in new issue
Block a user