mirror of
https://github.com/pdxlocations/contact.git
synced 2026-05-09 14:54:27 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ea958ce8a | |||
| c5e93fdd2d | |||
| 35a0ff5834 |
+81
-73
@@ -12,17 +12,15 @@ from contact.ui.colors import get_color
|
|||||||
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
from contact.utilities.db_handler import get_name_from_database, update_node_info_in_db, is_chat_archived
|
||||||
from contact.utilities.input_handlers import get_list_input
|
from contact.utilities.input_handlers import get_list_input
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
from contact.utilities.emoji_utils import normalize_message_text
|
|
||||||
import contact.ui.default_config as config
|
import contact.ui.default_config as config
|
||||||
import contact.ui.dialog
|
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
|
from contact.utilities.singleton import ui_state, interface_state, menu_state
|
||||||
|
|
||||||
|
|
||||||
MIN_COL = 1 # "effectively zero" without breaking curses
|
MIN_COL = 1 # "effectively zero" without breaking curses
|
||||||
RESIZE_DEBOUNCE_MS = 250
|
RESIZE_DEBOUNCE_MS = 250
|
||||||
root_win = None
|
root_win = None
|
||||||
nodes_pad = None
|
|
||||||
|
|
||||||
|
|
||||||
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
# Draw arrows for a specific window id (0=channel,1=messages,2=nodes).
|
||||||
@@ -65,48 +63,6 @@ def paint_frame(win, selected: bool) -> None:
|
|||||||
win.refresh()
|
win.refresh()
|
||||||
|
|
||||||
|
|
||||||
def get_channel_row_color(index: int) -> int:
|
|
||||||
if index == ui_state.selected_channel:
|
|
||||||
if ui_state.current_window == 0:
|
|
||||||
return get_color("channel_list", reverse=True)
|
|
||||||
return get_color("channel_selected")
|
|
||||||
return get_color("channel_list")
|
|
||||||
|
|
||||||
|
|
||||||
def get_node_row_color(index: int) -> int:
|
|
||||||
node_num = ui_state.node_list[index]
|
|
||||||
node = interface_state.interface.nodesByNum.get(node_num, {})
|
|
||||||
color = "node_list"
|
|
||||||
if node.get("isFavorite"):
|
|
||||||
color = "node_favorite"
|
|
||||||
if node.get("isIgnored"):
|
|
||||||
color = "node_ignored"
|
|
||||||
return get_color(color, reverse=index == ui_state.selected_node and ui_state.current_window == 2)
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_main_window(window_id: int, selected: bool) -> None:
|
|
||||||
if window_id == 0:
|
|
||||||
paint_frame(channel_win, selected=selected)
|
|
||||||
if ui_state.channel_list:
|
|
||||||
width = max(0, channel_pad.getmaxyx()[1] - 4)
|
|
||||||
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)
|
|
||||||
refresh_pad(1)
|
|
||||||
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] - 4)
|
|
||||||
nodes_pad.chgat(ui_state.selected_node, 1, width, get_node_row_color(ui_state.selected_node))
|
|
||||||
refresh_pad(2)
|
|
||||||
|
|
||||||
|
|
||||||
def get_node_display_name(node_num: int, node: dict) -> str:
|
|
||||||
user = node.get("user") or {}
|
|
||||||
return user.get("longName") or get_name_from_database(node_num, "long")
|
|
||||||
|
|
||||||
|
|
||||||
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
|
||||||
"""Handle terminal resize events and redraw the UI accordingly."""
|
"""Handle terminal resize events and redraw the UI accordingly."""
|
||||||
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
global messages_pad, messages_win, nodes_pad, nodes_win, channel_pad, channel_win, packetlog_win, entry_win
|
||||||
@@ -403,16 +359,31 @@ def handle_leftright(char: int) -> None:
|
|||||||
delta = -1 if char == curses.KEY_LEFT else 1
|
delta = -1 if char == curses.KEY_LEFT else 1
|
||||||
old_window = ui_state.current_window
|
old_window = ui_state.current_window
|
||||||
ui_state.current_window = (ui_state.current_window + delta) % 3
|
ui_state.current_window = (ui_state.current_window + delta) % 3
|
||||||
if ui_state.single_pane_mode:
|
handle_resize(root_win, False)
|
||||||
handle_resize(root_win, False)
|
|
||||||
return
|
|
||||||
|
|
||||||
refresh_main_window(old_window, selected=False)
|
if old_window == 0:
|
||||||
|
paint_frame(channel_win, selected=False)
|
||||||
|
refresh_pad(0)
|
||||||
|
if old_window == 1:
|
||||||
|
paint_frame(messages_win, selected=False)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif old_window == 2:
|
||||||
|
paint_frame(nodes_win, selected=False)
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
refresh_main_window(ui_state.current_window, selected=True)
|
if ui_state.current_window == 0:
|
||||||
|
paint_frame(channel_win, selected=True)
|
||||||
|
refresh_pad(0)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
paint_frame(messages_win, selected=True)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
paint_frame(nodes_win, selected=True)
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -433,16 +404,31 @@ def handle_function_keys(char: int) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
ui_state.current_window = target
|
ui_state.current_window = target
|
||||||
if ui_state.single_pane_mode:
|
handle_resize(root_win, False)
|
||||||
handle_resize(root_win, False)
|
|
||||||
return
|
|
||||||
|
|
||||||
refresh_main_window(old_window, selected=False)
|
if old_window == 0:
|
||||||
|
paint_frame(channel_win, selected=False)
|
||||||
|
refresh_pad(0)
|
||||||
|
elif old_window == 1:
|
||||||
|
paint_frame(messages_win, selected=False)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif old_window == 2:
|
||||||
|
paint_frame(nodes_win, selected=False)
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
if not ui_state.single_pane_mode:
|
if not ui_state.single_pane_mode:
|
||||||
draw_window_arrows(old_window)
|
draw_window_arrows(old_window)
|
||||||
|
|
||||||
refresh_main_window(ui_state.current_window, selected=True)
|
if ui_state.current_window == 0:
|
||||||
|
paint_frame(channel_win, selected=True)
|
||||||
|
refresh_pad(0)
|
||||||
|
elif ui_state.current_window == 1:
|
||||||
|
paint_frame(messages_win, selected=True)
|
||||||
|
refresh_pad(1)
|
||||||
|
elif ui_state.current_window == 2:
|
||||||
|
paint_frame(nodes_win, selected=True)
|
||||||
|
refresh_pad(2)
|
||||||
|
|
||||||
draw_window_arrows(ui_state.current_window)
|
draw_window_arrows(ui_state.current_window)
|
||||||
|
|
||||||
|
|
||||||
@@ -842,9 +828,7 @@ def draw_channel_list() -> None:
|
|||||||
notification = " " + config.notification_symbol if idx in ui_state.notifications else ""
|
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
|
# Truncate the channel name if it's too long to fit in the window
|
||||||
truncated_channel = (
|
truncated_channel = fit_text(f"{channel}{notification}", win_width - 3, suffix="-")
|
||||||
(channel[: win_width - 5] + "-" if len(channel) > win_width - 5 else channel) + notification
|
|
||||||
).ljust(win_width - 3)
|
|
||||||
|
|
||||||
color = get_color("channel_list")
|
color = get_color("channel_list")
|
||||||
if idx == ui_state.selected_channel:
|
if idx == ui_state.selected_channel:
|
||||||
@@ -879,7 +863,7 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
|
|||||||
|
|
||||||
row = 0
|
row = 0
|
||||||
for prefix, message in messages:
|
for prefix, message in messages:
|
||||||
full_message = normalize_message_text(f"{prefix}{message}")
|
full_message = f"{prefix}{message}"
|
||||||
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
wrapped_lines = wrap_text(full_message, messages_win.getmaxyx()[1] - 2)
|
||||||
msg_line_count += len(wrapped_lines)
|
msg_line_count += len(wrapped_lines)
|
||||||
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
messages_pad.resize(msg_line_count, messages_win.getmaxyx()[1])
|
||||||
@@ -921,8 +905,10 @@ def draw_node_list() -> None:
|
|||||||
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
if ui_state.current_window != 2 and ui_state.single_pane_mode:
|
||||||
return
|
return
|
||||||
|
|
||||||
if nodes_pad is None:
|
# This didn't work, for some reason an error is thown on startup, so we just create the pad every time
|
||||||
nodes_pad = curses.newpad(1, 1)
|
# if nodes_pad is None:
|
||||||
|
# nodes_pad = curses.newpad(1, 1)
|
||||||
|
nodes_pad = curses.newpad(1, 1)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
nodes_pad.erase()
|
nodes_pad.erase()
|
||||||
@@ -936,12 +922,27 @@ def draw_node_list() -> None:
|
|||||||
node = interface_state.interface.nodesByNum[node_num]
|
node = interface_state.interface.nodesByNum[node_num]
|
||||||
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
secure = "user" in node and "publicKey" in node["user"] and node["user"]["publicKey"]
|
||||||
status_icon = "🔐" if secure else "🔓"
|
status_icon = "🔐" if secure else "🔓"
|
||||||
node_name = get_node_display_name(node_num, node)
|
node_name = get_name_from_database(node_num, "long")
|
||||||
|
user_name = node["user"]["shortName"]
|
||||||
|
|
||||||
|
uptime_str = ""
|
||||||
|
if "deviceMetrics" in node and "uptimeSeconds" in node["deviceMetrics"]:
|
||||||
|
uptime_str = f" / Up: {get_readable_duration(node['deviceMetrics']['uptimeSeconds'])}"
|
||||||
|
|
||||||
|
last_heard_str = f" ■ {get_time_ago(node['lastHeard'])}" if node.get("lastHeard") else ""
|
||||||
|
hops_str = f" ■ Hops: {node['hopsAway']}" if "hopsAway" in node else ""
|
||||||
|
snr_str = f" ■ SNR: {node['snr']}dB" if node.get("hopsAway") == 0 and "snr" in node else ""
|
||||||
|
|
||||||
# Future node name custom formatting possible
|
# Future node name custom formatting possible
|
||||||
node_str = f"{status_icon} {node_name}"
|
node_str = fit_text(f"{status_icon} {node_name}", box_width - 2)
|
||||||
node_str = node_str.ljust(box_width - 4)[: box_width - 2]
|
color = "node_list"
|
||||||
nodes_pad.addstr(i, 1, node_str, get_node_row_color(i))
|
if "isFavorite" in node and node["isFavorite"]:
|
||||||
|
color = "node_favorite"
|
||||||
|
if "isIgnored" in node and node["isIgnored"]:
|
||||||
|
color = "node_ignored"
|
||||||
|
nodes_pad.addstr(
|
||||||
|
i, 1, node_str, get_color(color, reverse=ui_state.selected_node == i and ui_state.current_window == 2)
|
||||||
|
)
|
||||||
|
|
||||||
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
paint_frame(nodes_win, selected=(ui_state.current_window == 2))
|
||||||
nodes_win.refresh()
|
nodes_win.refresh()
|
||||||
@@ -1062,9 +1063,16 @@ def draw_packetlog_win() -> None:
|
|||||||
span += column
|
span += column
|
||||||
|
|
||||||
# Add headers
|
# 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(
|
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
|
) # Truncate headers if they exceed window width
|
||||||
|
|
||||||
for i, packet in enumerate(reversed(ui_state.packet_buffer)):
|
for i, packet in enumerate(reversed(ui_state.packet_buffer)):
|
||||||
@@ -1072,22 +1080,22 @@ def draw_packetlog_win() -> None:
|
|||||||
break
|
break
|
||||||
|
|
||||||
# Format each field
|
# 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 = (
|
to_id = (
|
||||||
"BROADCAST".ljust(columns[1])
|
fit_text("BROADCAST", columns[1])
|
||||||
if str(packet["to"]) == "4294967295"
|
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:
|
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)
|
parsed_payload = parse_protobuf(packet)
|
||||||
else:
|
else:
|
||||||
port = "NO KEY".ljust(columns[2])
|
port = fit_text("NO KEY", columns[2])
|
||||||
parsed_payload = "NO KEY"
|
parsed_payload = "NO KEY"
|
||||||
|
|
||||||
# Combine and truncate if necessary
|
# Combine and truncate if necessary
|
||||||
logString = f"{from_id} {to_id} {port} {parsed_payload}"
|
logString = f"{from_id} {to_id} {port} {parsed_payload}"
|
||||||
logString = logString[: width - 3]
|
logString = fit_text(logString, width - 3)
|
||||||
|
|
||||||
# Add to the window
|
# Add to the window
|
||||||
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
packetlog_win.addstr(i + 2, 1, logString, get_color("log"))
|
||||||
|
|||||||
+69
-16
@@ -1,11 +1,12 @@
|
|||||||
import curses
|
import curses
|
||||||
import re
|
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.ui.colors import get_color
|
||||||
from contact.utilities.i18n import t
|
from contact.utilities.i18n import t
|
||||||
from contact.utilities.control_utils import transform_menu_path
|
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
|
from contact.utilities.singleton import interface_state, ui_state
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +329,65 @@ def get_wrapped_help_text(
|
|||||||
|
|
||||||
|
|
||||||
def text_width(text: str) -> int:
|
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]:
|
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
|
wrap_width -= margin
|
||||||
|
|
||||||
for word in words:
|
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
|
for chunk in word_chunks:
|
||||||
if line_buffer:
|
chunk_length = text_width(chunk)
|
||||||
|
|
||||||
|
if line_length + chunk_length > wrap_width and chunk.strip():
|
||||||
wrapped_lines.append(line_buffer.strip())
|
wrapped_lines.append(line_buffer.strip())
|
||||||
line_buffer = ""
|
line_buffer = ""
|
||||||
line_length = 0
|
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():
|
line_buffer += chunk
|
||||||
wrapped_lines.append(line_buffer.strip())
|
line_length += chunk_length
|
||||||
line_buffer = ""
|
|
||||||
line_length = 0
|
|
||||||
|
|
||||||
line_buffer += word
|
|
||||||
line_length += word_length
|
|
||||||
|
|
||||||
if line_buffer:
|
if line_buffer:
|
||||||
wrapped_lines.append(line_buffer.strip())
|
wrapped_lines.append(line_buffer.strip())
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""Helpers for normalizing emoji sequences in width-sensitive message rendering."""
|
|
||||||
|
|
||||||
# Strip zero-width and presentation modifiers that make terminal cell width inconsistent.
|
|
||||||
EMOJI_MODIFIER_REPLACEMENTS = {
|
|
||||||
"\u200d": "",
|
|
||||||
"\u20e3": "",
|
|
||||||
"\ufe0e": "",
|
|
||||||
"\ufe0f": "",
|
|
||||||
"\U0001F3FB": "",
|
|
||||||
"\U0001F3FC": "",
|
|
||||||
"\U0001F3FD": "",
|
|
||||||
"\U0001F3FE": "",
|
|
||||||
"\U0001F3FF": "",
|
|
||||||
}
|
|
||||||
|
|
||||||
_EMOJI_MODIFIER_TRANSLATION = str.maketrans(EMOJI_MODIFIER_REPLACEMENTS)
|
|
||||||
_REGIONAL_INDICATOR_START = ord("\U0001F1E6")
|
|
||||||
_REGIONAL_INDICATOR_END = ord("\U0001F1FF")
|
|
||||||
|
|
||||||
|
|
||||||
def _regional_indicator_to_letter(char: str) -> str:
|
|
||||||
return chr(ord("A") + ord(char) - _REGIONAL_INDICATOR_START)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_flag_emoji(text: str) -> str:
|
|
||||||
"""Convert flag emoji built from regional indicators into ASCII country codes."""
|
|
||||||
normalized = []
|
|
||||||
index = 0
|
|
||||||
|
|
||||||
while index < len(text):
|
|
||||||
current = text[index]
|
|
||||||
current_ord = ord(current)
|
|
||||||
|
|
||||||
if _REGIONAL_INDICATOR_START <= current_ord <= _REGIONAL_INDICATOR_END and index + 1 < len(text):
|
|
||||||
next_char = text[index + 1]
|
|
||||||
next_ord = ord(next_char)
|
|
||||||
if _REGIONAL_INDICATOR_START <= next_ord <= _REGIONAL_INDICATOR_END:
|
|
||||||
normalized.append(_regional_indicator_to_letter(current))
|
|
||||||
normalized.append(_regional_indicator_to_letter(next_char))
|
|
||||||
index += 2
|
|
||||||
continue
|
|
||||||
|
|
||||||
normalized.append(current)
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
return "".join(normalized)
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_message_text(text: str) -> str:
|
|
||||||
"""Strip modifiers and rewrite flag emoji into stable terminal-friendly text."""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return _normalize_flag_emoji(text.translate(_EMOJI_MODIFIER_TRANSLATION))
|
|
||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "contact"
|
name = "contact"
|
||||||
version = "1.4.22"
|
version = "1.4.19"
|
||||||
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."
|
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 = [
|
authors = [
|
||||||
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}
|
||||||
@@ -9,7 +9,8 @@ license = "GPL-3.0-only"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9,<3.15"
|
requires-python = ">=3.9,<3.15"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"meshtastic (>=2.7.5,<3.0.0)"
|
"meshtastic (>=2.7.5,<3.0.0)",
|
||||||
|
"wcwidth (>=0.2.13,<1.0.0)"
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
meshtastic
|
meshtastic
|
||||||
|
wcwidth>=0.2.13,<1.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user