Files
richandClaude f7f0036d0f Send a long chat message as numbered parts
A message that is too long for one packet now goes out as up to
message_parts_max parts (default 3, 1 turns it off), each ending with
its part number: [1/3], [2/3], [3/3]. Each part, with its part number,
fits in the byte limit of the conversation (130 for a channel, 150 for
a direct message). A cut goes at a space, and a word with no space to
cut at is cut between characters, never inside an emoji sequence or
after a combining accent.

Channel parts wait the transmit cooldown between them, so that the
floods do not go out as a burst. A direct part waits for the
acknowledgement of the part before it, or the end of its wait. An
unacknowledged part still lets the rest go out, and ^R retries each
one. A hard failure stops the rest and puts their text back in the
compose line. The byte gauge shows the number of parts and the bytes
of the last part. Rooms are not changed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-15 21:26:49 -04:00

177 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Cut a message that is too long for one packet into parts that each fit.
A MeshCore message must fit in one LoRa packet (refer to
:data:`~meshterm.ui.tui.prompt.DM_BYTE_LIMIT`). When the text of a message is longer than
this limit, MeshTerm can send it as more than one message. Each of these messages is a
**part**, and each part ends with its **part number**: ``[1/3]``, ``[2/3]``, ``[3/3]``. The
part number goes at the end, so that the start of the first part (the ``@[mention]`` of a
reply) stays where a person reads first. Each part, with its part number, fits in the
limit.
A cut goes at a space when the window of the part has one. Thus each word stays complete.
A word that is longer than a part (a long URL) has no space to cut at, and MeshTerm cuts it
between two characters. Such a cut never divides a character, and it does not go into a
sequence that shows as one symbol: an accent after its letter, a skin tone or a variation
selector after its emoji, or the two sides of a zero-width joiner.
The receiver needs nothing to read the parts. Each part is an ordinary message.
"""
from __future__ import annotations
import re
import unicodedata
#: The marks that join to the character before them. A cut before one of these divides a
#: symbol that the user sees as one: U+200D zero-width joiner, U+FE0E and U+FE0F variation
#: selectors, U+20E3 combining keycap.
_JOINERS = frozenset("‍︎️⃣")
_SPACE = re.compile(r"\s")
def _bytes(text: str) -> int:
"""The UTF-8 length of ``text``: what counts against the limit of a packet."""
return len(text.encode("utf-8"))
def part_number(index: int, total: int) -> str:
"""The text that ends part ``index`` of ``total``: a space, then ``[index/total]``."""
return f" [{index}/{total}]"
def _joins_previous(ch: str) -> bool:
"""Whether ``ch`` joins to the character before it, so that a cut cannot go before it."""
code = ord(ch)
return (
ch in _JOINERS
or unicodedata.category(ch) in ("Mn", "Me", "Mc")
or 0x1F3FB <= code <= 0x1F3FF # skin tone modifiers
or 0xE0020 <= code <= 0xE007F # tag characters (subdivision flags)
)
def _hard_cut(text: str, end: int) -> int:
"""Move a cut at ``end`` back, so that it does not divide a symbol.
Returns the new cut. It is never less than 1, so that each part has text.
"""
cut = end
while cut > 1 and (_joins_previous(text[cut]) or text[cut - 1] == "‍"):
cut -= 1
return cut if cut > 1 else end
def _fit(text: str, start: int, budget: int) -> int:
"""The end of the longest run of ``text`` from ``start`` that fits in ``budget`` bytes."""
used = 0
end = start
while end < len(text):
used += _bytes(text[end])
if used > budget:
break
end += 1
return end
def _spans(text: str, budget: int) -> list[tuple[int, int]]:
"""Cut ``text`` into runs of ``budget`` bytes or less, as ``(start, end)`` indexes.
The runs have no whitespace at their two ends. The whitespace between two runs is not
in a run.
"""
spans: list[tuple[int, int]] = []
start = 0
while start < len(text) and text[start].isspace():
start += 1
while start < len(text):
end = _fit(text, start, budget)
if end == len(text):
spans.append((start, end))
break
if end == start: # one character is larger than the budget: it cannot fit at all
end = start + 1
# Look for the last space in the window, and also at the first character after it:
# a space there means that the window ends at the end of a word.
space = next(
(i for i in range(end, start, -1) if _SPACE.match(text[i])),
None,
)
if space is not None:
stop = space
while stop > start and text[stop - 1].isspace():
stop -= 1
spans.append((start, stop))
start = space
else:
cut = start + _hard_cut(text[start:], end - start)
spans.append((start, cut))
start = cut
while start < len(text) and text[start].isspace():
start += 1
return spans
def _plan(text: str, limit: int, max_parts: int) -> tuple[list[tuple[int, int]], int]:
"""Cut ``text`` for ``max_parts`` parts at most, as spans and the total of the cut.
The size of a part number changes with the total (``[9/9]`` is shorter than
``[10/10]``). Thus the budget for the text of a part comes from a total, and the cut
gives a new total. The plan starts at two parts and goes up until the two agree. The
returned spans can be more than ``max_parts``: the caller then knows where the text
that does not fit starts.
"""
total = 2
while True:
budget = limit - _bytes(part_number(total, total))
spans = _spans(text, max(1, budget))
if len(spans) <= total or total >= max_parts:
return spans, total
total = min(max_parts, max(total + 1, len(spans)))
def split(text: str, limit: int, max_parts: int) -> list[str] | None:
"""Cut ``text`` into the parts that MeshTerm sends, each with its part number.
Args:
text: The text of the message.
limit: The UTF-8 byte limit of one message.
max_parts: The largest number of parts. With 1, MeshTerm never cuts a message.
Returns:
The texts to send, in order. A text that fits in ``limit`` is one part with no part
number. ``None`` when the text needs more than ``max_parts`` parts.
"""
if _bytes(text) <= limit:
return [text]
if max_parts < 2:
return None
spans, _ = _plan(text, limit, max_parts)
if len(spans) > max_parts:
return None
total = len(spans)
return [text[a:b] + part_number(i, total) for i, (a, b) in enumerate(spans, 1)]
def overflow_at(text: str, limit: int, max_parts: int) -> int | None:
"""The index of the first character of ``text`` that does not fit, or ``None``.
With ``max_parts`` of 1, this is the first character whose bytes go past ``limit``.
With more, it is the start of the text that would go in the part after the last one.
"""
if _bytes(text) <= limit:
return None
if max_parts < 2:
return _fit(text, 0, limit)
spans, _ = _plan(text, limit, max_parts)
if len(spans) <= max_parts:
return None
return spans[max_parts][0]
def unnumbered(part: str, index: int, total: int) -> str:
"""The text of ``part`` without its part number (``part`` itself when it has none)."""
tail = part_number(index, total)
return part[: -len(tail)] if total > 1 and part.endswith(tail) else part