mirror of
https://github.com/pdxlocations/contact.git
synced 2026-03-28 17:12:35 +01:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8be086cf6 | ||
|
|
90c8e5cc27 | ||
|
|
9bd5372970 | ||
|
|
8ea958ce8a |
@@ -1,5 +1,6 @@
|
||||
import curses
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any, Optional, List, Dict
|
||||
|
||||
from wcwidth import wcwidth, wcswidth
|
||||
@@ -9,6 +10,9 @@ from contact.utilities.i18n import t
|
||||
from contact.utilities.control_utils import transform_menu_path
|
||||
from contact.utilities.singleton import interface_state, ui_state
|
||||
|
||||
ZWJ = "\u200d"
|
||||
KEYCAP = "\u20e3"
|
||||
|
||||
|
||||
def get_node_color(node_index: int, reverse: bool = False):
|
||||
node_num = ui_state.node_list[node_index]
|
||||
@@ -328,7 +332,90 @@ def get_wrapped_help_text(
|
||||
return wrapped_help
|
||||
|
||||
|
||||
def _is_regional_indicator(char: str) -> bool:
|
||||
codepoint = ord(char)
|
||||
return 0x1F1E6 <= codepoint <= 0x1F1FF
|
||||
|
||||
|
||||
def _is_variation_selector(char: str) -> bool:
|
||||
codepoint = ord(char)
|
||||
return 0xFE00 <= codepoint <= 0xFE0F or 0xE0100 <= codepoint <= 0xE01EF
|
||||
|
||||
|
||||
def _is_emoji_modifier(char: str) -> bool:
|
||||
codepoint = ord(char)
|
||||
return 0x1F3FB <= codepoint <= 0x1F3FF
|
||||
|
||||
|
||||
def _is_display_modifier(char: str) -> bool:
|
||||
return unicodedata.category(char) in {"Mn", "Mc", "Me"} or _is_emoji_modifier(char)
|
||||
|
||||
|
||||
def iter_display_units(text: str) -> List[str]:
|
||||
"""Split text into display units so emoji sequences stay intact."""
|
||||
units: List[str] = []
|
||||
index = 0
|
||||
|
||||
while index < len(text):
|
||||
unit = text[index]
|
||||
index += 1
|
||||
|
||||
if _is_regional_indicator(unit) and index < len(text) and _is_regional_indicator(text[index]):
|
||||
unit += text[index]
|
||||
index += 1
|
||||
|
||||
while index < len(text) and _is_display_modifier(text[index]):
|
||||
unit += text[index]
|
||||
index += 1
|
||||
|
||||
while index < len(text) and text[index] == ZWJ and index + 1 < len(text):
|
||||
unit += text[index]
|
||||
index += 1
|
||||
unit += text[index]
|
||||
index += 1
|
||||
|
||||
while index < len(text) and _is_display_modifier(text[index]):
|
||||
unit += text[index]
|
||||
index += 1
|
||||
|
||||
units.append(unit)
|
||||
|
||||
return units
|
||||
|
||||
|
||||
def sanitize_for_curses(text: str) -> str:
|
||||
"""Collapse complex emoji sequences to stable fallbacks before rendering."""
|
||||
sanitized: List[str] = []
|
||||
|
||||
for unit in iter_display_units(text):
|
||||
if ZWJ not in unit and KEYCAP not in unit and not any(
|
||||
_is_variation_selector(char) or _is_emoji_modifier(char) for char in unit
|
||||
):
|
||||
sanitized.append(unit)
|
||||
continue
|
||||
|
||||
visible = [
|
||||
char
|
||||
for char in unit
|
||||
if char != ZWJ and char != KEYCAP and not _is_variation_selector(char) and not _is_emoji_modifier(char)
|
||||
]
|
||||
|
||||
if KEYCAP in unit and visible:
|
||||
sanitized.append(visible[0])
|
||||
elif ZWJ in unit and visible:
|
||||
sanitized.append(visible[0])
|
||||
elif any(_is_emoji_modifier(char) for char in unit) and visible:
|
||||
sanitized.append(visible[0])
|
||||
elif visible:
|
||||
sanitized.append("".join(visible))
|
||||
else:
|
||||
sanitized.append(unit)
|
||||
|
||||
return "".join(sanitized)
|
||||
|
||||
|
||||
def text_width(text: str) -> int:
|
||||
text = sanitize_for_curses(text)
|
||||
width = wcswidth(text)
|
||||
if width >= 0:
|
||||
return width
|
||||
@@ -337,12 +424,13 @@ def text_width(text: str) -> int:
|
||||
|
||||
def slice_text_to_width(text: str, max_width: int) -> str:
|
||||
"""Return the longest prefix that fits within max_width terminal cells."""
|
||||
text = sanitize_for_curses(text)
|
||||
if max_width <= 0:
|
||||
return ""
|
||||
|
||||
chunk = ""
|
||||
for char in text:
|
||||
candidate = chunk + char
|
||||
for unit in iter_display_units(text):
|
||||
candidate = chunk + unit
|
||||
if text_width(candidate) > max_width:
|
||||
break
|
||||
chunk = candidate
|
||||
@@ -352,6 +440,8 @@ def slice_text_to_width(text: str, max_width: int) -> str:
|
||||
|
||||
def fit_text(text: str, width: int, suffix: str = "") -> str:
|
||||
"""Trim and pad text so its terminal display width fits exactly."""
|
||||
text = sanitize_for_curses(text)
|
||||
suffix = sanitize_for_curses(suffix)
|
||||
if width <= 0:
|
||||
return ""
|
||||
|
||||
@@ -366,17 +456,18 @@ def fit_text(text: str, width: int, suffix: str = "") -> str:
|
||||
|
||||
def split_text_to_width(text: str, max_width: int) -> List[str]:
|
||||
"""Split text into chunks that each fit within max_width terminal cells."""
|
||||
text = sanitize_for_curses(text)
|
||||
if max_width <= 0:
|
||||
return [""]
|
||||
|
||||
chunks: List[str] = []
|
||||
chunk = ""
|
||||
|
||||
for char in text:
|
||||
candidate = chunk + char
|
||||
for unit in iter_display_units(text):
|
||||
candidate = chunk + unit
|
||||
if chunk and text_width(candidate) > max_width:
|
||||
chunks.append(chunk)
|
||||
chunk = char
|
||||
chunk = unit
|
||||
else:
|
||||
chunk = candidate
|
||||
|
||||
@@ -392,6 +483,7 @@ def split_text_to_width(text: str, max_width: int) -> List[str]:
|
||||
|
||||
def wrap_text(text: str, wrap_width: int) -> List[str]:
|
||||
"""Wraps text while preserving spaces and breaking long words."""
|
||||
text = sanitize_for_curses(text)
|
||||
|
||||
whitespace = "\t\n\x0b\x0c\r "
|
||||
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(" "))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "contact"
|
||||
version = "1.4.19"
|
||||
version = "1.4.20"
|
||||
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"}
|
||||
|
||||
Reference in New Issue
Block a user