diff --git a/contact/ui/contact_ui.py b/contact/ui/contact_ui.py index bdf10dd..4ea5a67 100644 --- a/contact/ui/contact_ui.py +++ b/contact/ui/contact_ui.py @@ -14,7 +14,7 @@ from contact.utilities.input_handlers import get_list_input from contact.utilities.i18n import t import contact.ui.default_config as config 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 draw_main_arrows, fit_text, get_msg_window_lines, move_main_highlight, wrap_text from contact.utilities.singleton import ui_state, interface_state, menu_state @@ -828,9 +828,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 = ( - (channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification - ).ljust(win_width - 3) + truncated_channel = fit_text(f"{channel}{notification}", win_width - 3, suffix="-") color = get_color("channel_list") if idx == ui_state.selected_channel: @@ -936,8 +934,7 @@ def draw_node_list() -> None: snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else "" # Future node name custom formatting possible - node_str = f"{status_icon} {node_name}" - node_str = node_str.ljust(box_width - 4)[: box_width - 2] + node_str = fit_text(f"{status_icon} {node_name}", box_width - 2) color = "node_list" if "isFavorite" in node and node["isFavorite"]: color = "node_favorite" @@ -1066,9 +1063,16 @@ def draw_packetlog_win() -> None: span += column # Add headers - headers = f"{'From':<{columns[0]}} {'To':<{columns[1]}} {'Port':<{columns[2]}} {'Payload':<{width-span}}" + headers = " ".join( + [ + fit_text("From", columns[0]), + fit_text("To", columns[1]), + fit_text("Port", columns[2]), + fit_text("Payload", max(1, width - span - 3)), + ] + ) packetlog_win.addstr( - 1, 1, headers[: width - 2], get_color("log_header", underline=True) + 1, 1, fit_text(headers, width - 2), get_color("log_header", underline=True) ) # Truncate headers if they exceed window width for i, packet in enumerate(reversed(ui_state.packet_buffer)): @@ -1076,22 +1080,22 @@ def draw_packetlog_win() -> None: break # Format each field - from_id = get_name_from_database(packet["from"], "short").ljust(columns[0]) + from_id = fit_text(get_name_from_database(packet["from"], "short"), columns[0]) to_id = ( - "BROADCAST".ljust(columns[1]) + fit_text("BROADCAST", columns[1]) if str(packet["to"]) == "4294967295" - else get_name_from_database(packet["to"], "short").ljust(columns[1]) + else fit_text(get_name_from_database(packet["to"], "short"), columns[1]) ) if "decoded" in packet: - port = str(packet["decoded"].get("portnum", "")).ljust(columns[2]) + port = fit_text(str(packet["decoded"].get("portnum", "")), columns[2]) parsed_payload = parse_protobuf(packet) else: - port = "NO KEY".ljust(columns[2]) + port = fit_text("NO KEY", columns[2]) parsed_payload = "NO KEY" # Combine and truncate if necessary logString = f"{from_id} {to_id} {port} {parsed_payload}" - logString = logString[: width - 3] + logString = fit_text(logString, width - 3) # Add to the window packetlog_win.addstr(i + 2, 1, logString, get_color("log")) diff --git a/contact/ui/nav_utils.py b/contact/ui/nav_utils.py index 59c1300..ebdd768 100644 --- a/contact/ui/nav_utils.py +++ b/contact/ui/nav_utils.py @@ -1,11 +1,12 @@ import curses import re -from unicodedata import east_asian_width +from typing import Any, Optional, List, Dict + +from wcwidth import wcwidth, wcswidth from contact.ui.colors import get_color from contact.utilities.i18n import t from contact.utilities.control_utils import transform_menu_path -from typing import Any, Optional, List, Dict from contact.utilities.singleton import interface_state, ui_state @@ -328,7 +329,65 @@ def get_wrapped_help_text( def text_width(text: str) -> int: - return sum(2 if east_asian_width(c) in "FW" else 1 for c in text) + width = wcswidth(text) + if width >= 0: + return width + return sum(max(wcwidth(char), 0) for char in text) + + +def slice_text_to_width(text: str, max_width: int) -> str: + """Return the longest prefix that fits within max_width terminal cells.""" + if max_width <= 0: + return "" + + chunk = "" + for char in text: + candidate = chunk + char + if text_width(candidate) > max_width: + break + chunk = candidate + + return chunk + + +def fit_text(text: str, width: int, suffix: str = "") -> str: + """Trim and pad text so its terminal display width fits exactly.""" + if width <= 0: + return "" + + if text_width(text) > width: + suffix = slice_text_to_width(suffix, width) + available = max(0, width - text_width(suffix)) + text = slice_text_to_width(text, available).rstrip() + suffix + + padding = max(0, width - text_width(text)) + return text + (" " * padding) + + +def split_text_to_width(text: str, max_width: int) -> List[str]: + """Split text into chunks that each fit within max_width terminal cells.""" + if max_width <= 0: + return [""] + + chunks: List[str] = [] + chunk = "" + + for char in text: + candidate = chunk + char + if chunk and text_width(candidate) > max_width: + chunks.append(chunk) + chunk = char + else: + chunk = candidate + + if text_width(chunk) > max_width: + chunks.append(slice_text_to_width(chunk, max_width)) + chunk = "" + + if chunk: + chunks.append(chunk) + + return chunks or [""] def wrap_text(text: str, wrap_width: int) -> List[str]: @@ -346,24 +405,18 @@ def wrap_text(text: str, wrap_width: int) -> List[str]: wrap_width -= margin for word in words: - word_length = text_width(word) + word_chunks = split_text_to_width(word, wrap_width) if text_width(word) > wrap_width else [word] - if word_length > wrap_width: # Break long words - if line_buffer: + for chunk in word_chunks: + chunk_length = text_width(chunk) + + if line_length + chunk_length > wrap_width and chunk.strip(): wrapped_lines.append(line_buffer.strip()) line_buffer = "" line_length = 0 - for i in range(0, word_length, wrap_width): - wrapped_lines.append(word[i : i + wrap_width]) - continue - if line_length + word_length > wrap_width and word.strip(): - wrapped_lines.append(line_buffer.strip()) - line_buffer = "" - line_length = 0 - - line_buffer += word - line_length += word_length + line_buffer += chunk + line_length += chunk_length if line_buffer: wrapped_lines.append(line_buffer.strip()) diff --git a/pyproject.toml b/pyproject.toml index 2e960d3..5fd428e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ license = "GPL-3.0-only" readme = "README.md" requires-python = ">=3.9,<3.15" dependencies = [ - "meshtastic (>=2.7.5,<3.0.0)" + "meshtastic (>=2.7.5,<3.0.0)", + "wcwidth (>=0.2.13,<1.0.0)" ] [project.urls] diff --git a/requirements.txt b/requirements.txt index ec2459a..3ecb148 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ meshtastic +wcwidth>=0.2.13,<1.0.0