diff --git a/CHANGELOG.md b/CHANGELOG.md index b222a59..94f2c47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ can change how things behave. It does not only add new things. message has the same mark, so a message that names you is easy to see. On the PicoCalc, the console cannot draw a light fill, so there the `★` alone marks your messages. Before, your messages showed only the `★`. +- **In a chat, a blank line comes after the messages of each author.** Thus the + conversation is easier to follow when many people write. The Cardputer Zero has no + blank line there, because its screen has only 14 rows and each row shows a message. ## [0.11.1] - 2026-10-13 diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index b57051c..608d4cb 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -75,11 +75,26 @@ _URL_GUTTER = 2 _URL_CODES = False +#: Whether a blank line closes the messages of each author. It costs one row for each +#: group. The PicoCalc (26 rows) and a terminal (24) have the rows for it. The 14 rows of +#: the Cardputer Zero do not: there a blank line on each group is a message less on the +#: screen (JP, 2026-10-13). +_GROUP_GAP = True + +#: The fewest rows (``Platform.readable_rows``) for which a transcript has that blank line. +_GROUP_GAP_MIN_ROWS = 20 + + @on_platform def _bind(platform: Platform) -> None: - """Hang codes under URLs where the platform draws them (now and at each switch).""" - global _URL_CODES + """Bind the transcript to the platform (now and at each switch). + + The platform says whether codes hang under URLs, and whether it has the rows for a + blank line after each author. + """ + global _URL_CODES, _GROUP_GAP _URL_CODES = platform.url_codes + _GROUP_GAP = platform.readable_rows >= _GROUP_GAP_MIN_ROWS #: The maximum width of a footer hint, in cells (refer to the UX standards). @@ -613,16 +628,21 @@ class ChatScreen(Screen): and cached_ids[idx] == id(message) and cached_acked[idx] == message.acked ) + # A blank line closes the messages of an author, where the platform has the rows + # for it (:data:`_GROUP_GAP`). It is the first line of the slice of the message + # that starts the next group, so a cached slice brings it back with it. + gap = _GROUP_GAP and bool(lines) and (new_day or group != prev_group) if reusable: start = cached_boundaries[idx - 1] if idx > 0 else 0 slice_lines = cached_lines[start : cached_boundaries[idx]] if new_day: - self._sticky_headers.append((len(lines), [slice_lines[0]])) + at = int(gap) # the divider comes after the gap + self._sticky_headers.append((len(lines) + at, [slice_lines[at]])) lines += slice_lines else: + if gap: + lines.append("") if new_day: - if lines: - lines += render_lines(Text(""), width) # A day divider is the section heading of this transcript: it pins to the # top row while its day scrolls under it, and ^PgUp/^PgDn step by it. # Thus it has the heading style of the app (``── Label ──`` in the bold @@ -634,8 +654,6 @@ class ChatScreen(Screen): self._sticky_headers.append((len(lines), [divider[0]])) lines += divider if new_day or group != prev_group: - if not new_day and lines: - lines += render_lines(Text(""), width) # gap between sender groups header = self._group_header(sender, message) lines += render_lines(header, width) if selected: diff --git a/tests/test_chat.py b/tests/test_chat.py index 4f8d5bc..26ab10f 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -1545,6 +1545,47 @@ def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None: assert rendered.count("you") == 1 and rendered.count("★ Me") == 1 +def test_a_blank_line_closes_each_author_where_the_rows_allow() -> None: + """A terminal and the PicoCalc put a blank line after the messages of each author. + + The blank line also comes before the divider of a new day, and never before the first + line. The 14 rows of the Cardputer Zero have no row to spare for it. A second paint + reads the cached lines, and the divider of the second day still pins at its own line, + not at the blank line before it. + """ + from datetime import datetime, timedelta, timezone + + from meshterm.platforms import CARDPUTER_ZERO, REGULAR, set_platform + + base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc) + + def at(minutes: int) -> datetime: + return base + timedelta(minutes=minutes) + + def transcript() -> list[ChatMessage]: + return [ + ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0, created_at=at(0)), + ChatMessage(text="Alice: again", is_channel=True, channel_idx=0, created_at=at(1)), + ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0, created_at=at(2)), + ChatMessage(text="Bob: later", is_channel=True, channel_idx=0, created_at=at(1440)), + ] + + set_platform(REGULAR) + screen = _channel_screen(transcript()) + lines = [_strip_ansi(line) for line in screen._render_grouped(53)] + assert lines[0].startswith("──") and lines[1] == "Alice" + assert lines[4] == "" and lines[5] == "Bob" # after the two messages of Alice + assert lines[7] == "" and lines[8].startswith("── Mon") # after Bob, before the new day + assert lines.count("") == 2 + first = list(screen._sticky_headers) + screen._render_grouped(53) # the cached lines + assert screen._sticky_headers == first and [at for at, _ in first] == [0, 8] + + set_platform(CARDPUTER_ZERO) + screen = _channel_screen(transcript()) + assert "" not in [_strip_ansi(line) for line in screen._render_grouped(53)] + + def test_a_mention_of_our_name_takes_our_chip() -> None: """A message that names us is easy to see. A mention of each other node is as before.""" screen = _channel_screen([], self_name="Me", self_key=_OUR_KEY)