A blank line before each section is one preference, app-wide

"Section spacing" (section_spacing_min_rows, in the Display group)
replaces "Author spacing" and "Spacing from". It is the smallest
terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never (default 24). Each section heading of a list gets the line, and
the chat keeps it between authors and before a new day.

The frame records the terminal rows on the screen before the body
renders (note_terminal_rows), and Screen.section_spacing is the one
test. section_heading marks its row as spaced, and the select list
adds the line, never at the top, after a blank line, or under a
heading. The hand spacers before headings are gone, except the seam
between the region table and its actions, which is two lists.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Haiku 5.5 committed 2026-10-15 22:28:23 -04:00
1 parent b54a93e5c4
commit 820740c9a0
17 files changed
+231 -124

No files matched your search

+9 -6
View File
@@ -35,12 +35,15 @@ can change how things behave. It does not only add new things.
a 32-byte seed in the node's `identity.key` as you give it. This is the same form that
the node uses when it makes its own key. A firmware companion still gets the full 64-byte
key, because it accepts only that form.
- **In a chat, a blank line comes after the messages of each author.** Thus the
conversation is easier to follow when many people write. By default, the line shows on
a terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero,
keep each row for a message. Two new preferences in the Display group control the line:
**Author spacing** (`auto`, `on`, or `off`) and **Spacing from** (the smallest terminal,
in rows, that gets the line with `auto`).
- **A blank line comes before each section.** In each list that has sections (the main
menu, the Messages list, the Preferences page, Device config, and the others), the line
comes before each section heading. In a chat, it comes after the messages of each author
and before each new day. Thus the parts of a screen are easier to find, and a
conversation is easier to follow when many people write. By default, the line shows on a
terminal of 24 rows or more. A smaller terminal, and the 14 rows of the Cardputer Zero,
keep each row for content. The new **Section spacing** preference in the Display group
sets the smallest terminal that gets the line: any height, 20, 24, 30, or 40 rows, or
never.
## [0.11.1] - 2026-10-13
+8
View File
@@ -276,6 +276,14 @@ deliberately, one at a time, and say why in the code.
`Separator` is no landmark. Prose written *directly under* a heading (a description of
what the section holds) is its preamble and pins with it, in order, as each row scrolls
off; prose after the section's first row labels nothing and never pins.
A heading is also *spaced*: a blank line goes before it wherever the terminal is at
least `section_spacing_min_rows` tall (one preference, read through
`Screen.section_spacing`, which the chat's author and day breaks read too) — never at
the top, after a blank line, or under another heading or a column header. So don't
hand-place a `Separator(" ")` before a heading for space: it would show where the
preference says no. A blank separator there is only ever the seam between two
*different* lists, each with its own icon column (the region editor's table and its
actions); the heading then adds no second line.
- Label + description command rows go through `menu_rows` (two cell-aligned lanes,
description muted). Editor rows (setting/value/description) go through `lane_row`.
- A row that opens further prompts ends with `…`; a row that acts immediately doesn't.
+27 -28
View File
@@ -1727,34 +1727,33 @@ If you give no subcommand, it runs `show`.
```console
$ meshterm preferences show
PREFERENCE VALUE DEFAULT DESCRIPTION
set_clock_on_connect off off Set the device clock from this computer when it connects
trace_cooldown_s 5 5 Wait this long before transmitting again
flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert
weekly_flood_advert off off Flood an advert after a week without one
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
room_relogin_minutes 30 30 Opening a room this quiet logs in again
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
watch_alerts_kept 200 200 How many past alerts to keep
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
history_days 365 365 Older ones are deleted; 0 keeps everything
chat_history_limit 200 200 Old messages to load when you open a chat
courier_history_kept 100 100 Finished outbox messages to keep
cli_time_format relative relative Whether the command line prints ages or timestamps
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
full_width auto auto Use the extra column some terminals hide
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
name_overlap_days 7 7 Grey a name two nodes used within this many days
chat_author_spacing auto auto A blank line after each author in a chat
chat_author_spacing_min_rows 24 24 With auto: the smallest terminal that gets it
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
PREFERENCE VALUE DEFAULT DESCRIPTION
set_clock_on_connect off off Set the device clock from this computer when it connects
trace_cooldown_s 5 5 Wait this long before transmitting again
flood_advert_cooldown_s 60 60 Extra wait before another mesh-wide advert
weekly_flood_advert off off Flood an advert after a week without one
advert_quiet_s 30 30 Silence to wait for before the weekly advert
direct_message_soft_retries 0 0 Times to resend a message that gets no reply
room_relogin_minutes 30 30 Opening a room this quiet logs in again
tx_opt_min 18 18 Weakest power it will try
tx_opt_max 28 28 Strongest power it will try
tx_snr_tolerance_db 1 1 Signals this close are a tie, so less power wins
watch_silence_hours 12 12 Warn when a watched node goes quiet this long
watch_alerts_kept 200 200 How many past alerts to keep
map_view_fraction 0.5 0.5 Share of nodes to fit on screen; 1 shows them all
basemap_tilejson_url https://tiles.openfreemap.org/planet https://tiles.openfreemap.org/planet Where map images come from (next launch)
history_days 365 365 Older ones are deleted; 0 keeps everything
chat_history_limit 200 200 Old messages to load when you open a chat
courier_history_kept 100 100 Finished outbox messages to keep
cli_time_format relative relative Whether the command line prints ages or timestamps
fast_render on on Faster screen updates; turn off if it looks wrong (next launch)
full_width auto auto Use the extra column some terminals hide
color_depth auto auto How many colours to send this terminal (next launch)
console_setup auto auto Move to a better terminal when this console can't draw MeshTerm
console_font 6x12 6x12 Bigger text, or more rows on the handheld's panel
name_overlap_days 7 7 Grey a name two nodes used within this many days
section_spacing_min_rows 24 24 A blank line between sections and between chat authors
log_level WARNING WARNING How much MeshTerm writes to its log file (next launch)
```
**`DESCRIPTION`** says what each preference does, because the name of a key does not give
+25 -24
View File
@@ -173,9 +173,21 @@ _QUIET_CHOICES: dict[int, str] = {s: f"{s} s" for s in QUIET_CHOICES_S}
#: column, and for the cost when MeshTerm reclaims a column that is not hidden.
_WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: When a chat puts a blank line after the messages of each author. ``auto`` puts it on a
#: terminal that has at least ``chat_author_spacing_min_rows`` rows.
_SPACING_CHOICES: dict[str, str] = {"auto": "auto", "on": "on", "off": "off"}
#: The smallest terminal, in rows, that gets a blank line before each section. The two
#: ends are numbers too, so that one comparison (``rows >= value``) gives each choice: 0 is
#: lower than each terminal, and :data:`_SPACING_NEVER` is higher than each terminal. The
#: steps between them fall between the heights that MeshTerm knows: the 14 rows of the
#: Cardputer Zero, a standard terminal of 24, the 26 and 40 rows of the two console fonts of
#: the PicoCalc, and a tall window on a desktop.
_SPACING_NEVER = 10_000
_SPACING_CHOICES: dict[int, str] = {
0: "any height",
20: "20+ rows",
24: "24+ rows",
30: "30+ rows",
40: "40+ rows",
_SPACING_NEVER: "never",
}
#: How many colours to send to the terminal. ``auto`` is the honest default, and it is the
#: only value that is not a depth. It means "take prompt_toolkit's verdict unless we can
@@ -509,30 +521,19 @@ PREFERENCES: tuple[PrefSpec, ...] = (
unit="days",
),
PrefSpec(
key="chat_author_spacing",
label="Author spacing",
help="A blank line after each author in a chat",
key="section_spacing_min_rows",
label="Section spacing",
help="A blank line between sections and between chat authors",
group="Display",
value_type="enum",
# Auto, because the line costs one row for each group of messages. A tall terminal
# has the rows, and the 14 rows of the Cardputer Zero do not: there a blank line is
# a message less on the screen. The test is the real height of the terminal, so a
# small window on a desktop drops the line too (JP, 2026-10-13).
default="auto",
choices=_SPACING_CHOICES,
),
PrefSpec(
key="chat_author_spacing_min_rows",
label="Spacing from",
help="With auto: the smallest terminal that gets it",
group="Display",
value_type="int",
# A standard terminal (24 rows) and the PicoCalc (26) get the line. The Cardputer
# Zero (14) and a smaller window do not (JP, 2026-10-13).
# The line costs one row for each section. A standard terminal (24 rows) and the
# PicoCalc (26) have the rows. The 14 rows of the Cardputer Zero do not: there a
# blank line is a row less of the list. The test is the real height of the
# terminal, so a small window on a desktop drops the line too (JP, 2026-10-13). It
# applies to each section heading of a list, to the day dividers of a chat, and to
# the messages of each author in a chat (JP, 2026-10-15).
default=24,
minimum=10,
maximum=100,
unit="rows",
choices=_SPACING_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
+9 -32
View File
@@ -25,7 +25,7 @@ from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
from ..core import links, preferences
from ..core import links
from ..core.channels import MENTION, split_channel_sender
from ..core.connection import ContactNotOnDeviceError
from ..core.events import EventKind, MeshEvent
@@ -84,21 +84,14 @@ _URL_GUTTER = 2
_URL_CODES = False
#: The rows of the terminal when the session cannot say (a stand-in session with no
#: ``terminal_rows``): the design minimum of the platform (``Platform.readable_rows``).
_FALLBACK_ROWS = 24
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the transcript to the platform (now and at each switch).
The platform says whether codes hang under URLs, and how many rows to assume when the
session cannot tell the height of the terminal.
The platform says whether codes hang under URLs.
"""
global _URL_CODES, _FALLBACK_ROWS
global _URL_CODES
_URL_CODES = platform.url_codes
_FALLBACK_ROWS = platform.readable_rows
#: The maximum width of a footer hint, in cells (refer to the UX standards).
@@ -594,7 +587,7 @@ class ChatScreen(Screen):
messages = self._messages
total = len(messages)
cache = self._render_cache
spacing = self._author_spacing()
spacing = self.section_spacing()
if cache is not None and cache["width"] == width and cache["spacing"] == spacing:
cached_ids = cache["ids"]
cached_acked = cache["acked"]
@@ -640,9 +633,11 @@ 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 preferences ask for
# it (:meth:`_author_spacing`). 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.
# A blank line closes the messages of an author and the messages of a day, where
# the preferences ask for it (:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`,
# the same test as the section headings of a list). 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 = spacing and bool(lines) and (new_day or group != prev_group)
if reusable:
start = cached_boundaries[idx - 1] if idx > 0 else 0
@@ -696,24 +691,6 @@ class ChatScreen(Screen):
}
return lines
def _author_spacing(self) -> bool:
"""Whether a blank line closes the messages of each author, at this paint.
The ``chat_author_spacing`` preference decides: ``on`` and ``off`` are fixed, and
``auto`` puts the line on a terminal of ``chat_author_spacing_min_rows`` rows or
more. The line costs one row for each group of messages, so a short terminal (the
Cardputer Zero, or a small window) keeps the transcript dense. The test reads the
rows of the terminal before the paint, and reads the preferences at each paint,
so a change on the Preferences page applies when the user comes back.
"""
prefs = preferences.current()
mode = prefs.chat_author_spacing
if mode != "auto":
return mode == "on"
rows = getattr(self._session, "terminal_rows", None)
height = rows() if callable(rows) else _FALLBACK_ROWS
return height >= prefs.chat_author_spacing_min_rows
def _sender_and_body(self, message: ChatMessage) -> tuple[str, str]:
"""Return the display sender and the clean body of a message.
-1
View File
@@ -281,7 +281,6 @@ def _menu_items(ctx: AppContext, entries: list[QueuedMessage]) -> list:
items.append(Choice(marked_label("📨", "Queue a message…", "", lane=lane), _QUEUE))
if done:
items.append(Separator(" "))
items.append(section_heading("Finished"))
for message in done[:15]:
items.append(Choice(lambda m=message: _done_row(m, lane), ("msg", message.ident)))
+6 -1
View File
@@ -657,8 +657,13 @@ def section_heading(label: str) -> Separator:
Grey, never the accent of the frame: a rule in the colour of the border looks like a
part of the frame, not like a heading in it.
The heading is also *spaced*: a blank line comes before it where the
``section_spacing_min_rows`` preference asks for one (refer to
:attr:`~meshterm.ui.tui.select.Separator.spaced`). Thus a list does not put a blank
separator before a heading for space.
"""
return Separator(f"── {command_label(label)} ──", style="heading", heading=True)
return Separator(f"── {command_label(label)} ──", style="heading", heading=True, spaced=True)
def fit_cells(text: str, width: int, *, align: str = "left") -> str:
+3 -1
View File
@@ -237,7 +237,9 @@ def region_items(node: Contact, table: RegionTable, unsaved: int) -> tuple[str,
# A blank line between the table and the commands of the page, because they are two lists
# and not one. The first row of the tree is the wildcard. Code that measures icon
# columns reads its name ``*`` as a leading mark. But the name is data, and it does not
# belong to a column that the actions keep.
# belong to a column that the actions keep. This blank line is the seam between two
# lists, not the space of a section. It stays when the section spacing is off, and the
# list adds no second line after it (Separator.spaced).
items.append(Separator(" "))
items.append(section_heading("Actions"))
items.extend(
+1 -2
View File
@@ -63,7 +63,7 @@ from .scopering import ScopeKey, ring, scope_atom, scope_chip, scope_key, step
from .theme import name_style, snr_style
from .tui.render import render_lines
from .tui.screen import CANCEL, Screen
from .tui.select import Choice, Separator
from .tui.select import Choice
from .widgets import (
ContactsSort,
_recency_style,
@@ -1504,7 +1504,6 @@ class TimeMachinePickerScreen(ContactListScreen):
prompt=prompt,
lead=[
Choice(command_label("🌐 The whole mesh — days, arrivals, the ledger"), MESH),
Separator(" "),
section_heading("Nodes"),
],
)
+5
View File
@@ -456,6 +456,7 @@ def compose_base(
# (refer to :class:`~meshterm.ui.tui.screen.ListWindow`) can size its chrome to the
# frame into which it will be sliced.
base.note_viewport(viewport)
base.note_terminal_rows(rows)
body_lines = base.render_body(cols - panel_inset(base.flush))
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
# The panel wrap is a pure function of what is between its borders. Thus cache it,
@@ -484,6 +485,7 @@ def compose_base(
# the Panel. The body gets the full terminal width and one more row.
viewport = max(1, rows - header_h - 1 - 1) # minus footer(1) and title bar(1)
base.note_viewport(viewport)
base.note_terminal_rows(rows)
body_lines = base.render_body(cols)
visible, more_above, more_below = _visible_slice(base, body_lines, viewport)
status = None if platform.header_row else header
@@ -645,6 +647,7 @@ def compose_startup(screen: Screen, cols: int, rows: int) -> str:
# measure). It is never wider than the terminal, and never narrower than the title or the
# hint that it must show.
probe = max(10, min(cols - 6, 100))
screen.note_terminal_rows(rows)
probe_lines = screen.render_body(probe)
measured = max((_ansi_width(line) for line in probe_lines), default=10)
# Size to the widest footer possible, not to the footer of this frame. A screen whose
@@ -755,6 +758,7 @@ def compose_bare(screen: Screen, cols: int, rows: int) -> str:
# share screen fits its code to the rows). Thus the height is recorded before the body
# is requested, not after, as the composers with a frame also do.
screen.note_viewport(rows)
screen.note_terminal_rows(rows)
body_lines = screen.render_body(cols)
while body_lines and not _ansi_width(body_lines[-1]):
body_lines.pop() # a trailing blank row pushes the block off its anchor
@@ -783,6 +787,7 @@ def _dialog_layout(screen: Screen, cols: int, rows: int) -> tuple[int, int, int,
# with a list window (the path composer) can size to what it can use. After that, the
# slice records the real viewport, sized to the body.
screen.note_viewport(budget)
screen.note_terminal_rows(rows)
body_lines = screen.render_body(max_w - 4)
# A grow-only screen (the packet viewer, when it pages between packets) sizes to the
# tallest body that it has shown, not to this body. Thus a shorter body keeps the larger
+35
View File
@@ -155,6 +155,9 @@ class Screen:
#: first and the last row, and the page follows to its ends. It is off where these keys
#: move a text caret instead: the compose line of the chat, the field of a prompt.
home_end_jumps: bool = True
#: The rows of the terminal at the last paint, or ``None`` before the first paint. The
#: frame records it before it asks for the body (refer to :meth:`note_terminal_rows`).
_terminal_rows: int | None = None
@property
def fkey_lane(self):
@@ -537,6 +540,38 @@ class Screen:
"""Record only the viewport height (for callers that do not know the body total)."""
self._scroll_viewport = max(1, viewport)
def note_terminal_rows(self, rows: int) -> None:
"""Record the rows of the terminal, before the body of this paint renders.
The frame calls this method, together with :meth:`note_viewport`, for each type of
frame. :meth:`section_spacing` reads it. The rows must come before the body: rows
recorded after a paint come one paint late, and the first paint of a screen then
comes out without its blank lines and then jumps.
"""
self._terminal_rows = rows
def section_spacing(self) -> bool:
"""Whether a blank line comes before each section of the page, at this paint.
The ``section_spacing_min_rows`` preference decides. A terminal of that many rows
or more gets the line. The line costs one row for each section, so a short
terminal (the Cardputer Zero, or a small window) keeps its rows for content. A
screen that the frame has not painted yet uses the design minimum of the platform
(:attr:`~meshterm.platforms.Platform.readable_rows`). The preference is read at
each paint, so a change on the Preferences page applies when the user comes back.
One rule for each place that has sections: the section headings of a select list
(refer to :attr:`~meshterm.ui.tui.select.Separator.spaced`), and the day dividers
and the authors of a chat.
"""
from ...core.preferences import current as current_preferences
from ...platforms import get_platform
rows = self._terminal_rows
if rows is None:
rows = get_platform().readable_rows
return rows >= current_preferences().section_spacing_min_rows
@property
def _page_step(self) -> int:
"""The lines that PageUp/PageDown move: the viewport, less one line for continuity."""
+36
View File
@@ -255,6 +255,16 @@ class Separator:
nothing, it takes a content line, and it hides the heading above it. It is set
through :func:`~meshterm.ui.menus.section_heading`, or by
hand on a column header that is the only landmark of its block.
spaced: Whether a blank line comes before this row, when the
``section_spacing_min_rows`` preference asks for one at this paint (refer to
:meth:`~meshterm.ui.tui.screen.Screen.section_spacing`). It is set through
:func:`~meshterm.ui.menus.section_heading`, so that each section of each list
gets the same space. A list does not put a blank separator before a heading
for space, because then the space shows also where the preference says no. A
blank separator there is only the seam between two different lists, which
keep their own icon columns (the table and the actions of the region editor).
The line never comes at the top of the list, after a blank line, or under
another heading or a column header, because there it separates nothing.
pans: The column header of a table that ←→ pan as one unit (refer to
:attr:`Choice.pans`). It is drawn in its fullest form, and it slides with the
rows under it. Thus each label stays above its lane. Its layout is the same as a
@@ -267,6 +277,7 @@ class Separator:
style: str = "muted"
pinned: bool = False
heading: bool = False
spaced: bool = False
pans: bool = False
def text(self, width: int) -> str | Text:
@@ -288,6 +299,23 @@ _UNBOUNDED = 10_000
_POINTER = 2
def _spaces(previous: Choice | Separator | None) -> bool:
"""Whether a blank line before a section separates it from ``previous``.
The line separates a section from the content above it: a row, or a note. At the top
of the list there is nothing to separate. After a blank line, the space is already
there. Under a heading or a column header, the heading belongs with the rows below it.
"""
if previous is None:
return False
if isinstance(previous, Choice):
return True
if previous.heading or previous.pinned:
return False
title = previous.title
return callable(title) or bool(str(title).strip())
def _pans(items: list) -> bool:
"""Whether one of ``items`` is a table row or a header that pans (:attr:`Choice.pans`)."""
return any(getattr(item, "pans", False) for item in items)
@@ -830,7 +858,15 @@ class SelectScreen(Screen):
# Thus the code follows the line of the highlight while it draws the rows, and does
# not calculate it from the row index.
cursor_at: int | None = None
spacing = self.section_spacing()
previous: Choice | Separator | None = None # the row drawn before this one, if any
for item in rows:
if isinstance(item, Separator) and item.spaced and spacing and _spaces(previous):
# The blank line before a section. It is not an item, so it never joins
# the block of the heading above it, and the heading still pins at its
# own line.
lines.append("")
previous = item
if isinstance(item, Separator):
# A Text title has its own spans (a column header with two colours). A plain
# string is drawn all in the style of the separator. A separator scrolls
-9
View File
@@ -2056,15 +2056,6 @@ class TuiSession:
size = self._app.output.get_size() # type: ignore[union-attr]
return max(20, size.columns), max(6, size.rows)
def terminal_rows(self) -> int:
"""The rows of the terminal now. A screen reads it before its paint, never after.
The chat uses it for the space between authors (``chat_author_spacing``). The rows
that the frame records after a paint come one paint late, so the first paint of a
chat would come out without the space and then jump.
"""
return self._size()[1]
def base_body_size(self) -> tuple[int, int]:
"""Return the ``(width, height)`` in cells that the body of the base screen can use.
-1
View File
@@ -220,7 +220,6 @@ def _menu_items(
if acked:
items.append(Choice(marked_label("🗑", "Clear acknowledged", "err", lane=lane), _CLEAR))
items.append(Separator(" "))
items.append(section_heading("Watched nodes"))
if not watched:
items.append(Separator(" none starred yet — silence and SNR rules need one"))
+11 -16
View File
@@ -41,17 +41,12 @@ from tests.conftest import plain as _strip_ansi # the only strip-and-join scree
class _StubSession:
"""A session stand-in that counts the requests for a paint."""
def __init__(self, rows: int = 24) -> None:
def __init__(self) -> None:
self.invalidations = 0
self.rows = rows
def invalidate(self) -> None:
self.invalidations += 1
def terminal_rows(self) -> int:
"""The rows of the terminal, for the space between authors."""
return self.rows
def run_detached(self, work): # noqa: ANN001, ANN201
"""Start the flow of a key handler as a task, as ``TuiSession.run_detached`` does."""
return asyncio.ensure_future(work)
@@ -1557,13 +1552,14 @@ def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None:
def test_a_blank_line_closes_each_author_where_the_rows_allow(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With ``auto``, a terminal of 24 rows or more puts a blank line after each author.
"""By default, a terminal of 24 rows or more puts a blank line after each author.
The blank line also comes before the divider of a new day, and never before the first
line. A terminal of fewer rows (the 14 of the Cardputer Zero) has 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. ``on`` and ``off`` ignore the rows,
and a change applies at the next paint.
at its own line, not at the blank line before it. The two ends of the
``section_spacing_min_rows`` preference ignore the rows, and a change applies at the
next paint.
"""
from datetime import datetime, timedelta, timezone
@@ -1584,7 +1580,8 @@ def test_a_blank_line_closes_each_author_where_the_rows_allow(
prefs = preferences.Preferences()
monkeypatch.setattr(preferences, "current", lambda: prefs)
screen = _channel_screen(transcript(), session=_StubSession(rows=24))
screen = _channel_screen(transcript())
screen.note_terminal_rows(24)
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
@@ -1594,16 +1591,14 @@ def test_a_blank_line_closes_each_author_where_the_rows_allow(
screen._render_grouped(53) # the cached lines
assert screen._sticky_headers == first and [at for at, _ in first] == [0, 8]
short = _channel_screen(transcript(), session=_StubSession(rows=14))
short = _channel_screen(transcript())
short.note_terminal_rows(14)
assert "" not in [_strip_ansi(line) for line in short._render_grouped(53)]
prefs.set("chat_author_spacing", "on") # always, also on 14 rows
prefs.set("section_spacing_min_rows", 0) # any height, also 14 rows
assert "" in [_strip_ansi(line) for line in short._render_grouped(53)]
prefs.set("chat_author_spacing", "off") # never, and the cached lines change too
prefs.set("section_spacing_min_rows", 10_000) # never, and the cached lines change too
assert "" not in [_strip_ansi(line) for line in screen._render_grouped(53)]
prefs.set("chat_author_spacing", "auto")
prefs.set("chat_author_spacing_min_rows", 14)
assert "" in [_strip_ansi(line) for line in short._render_grouped(53)]
def test_a_mention_of_our_name_takes_our_chip_with_no_star(powerline) -> None:
-3
View File
@@ -147,9 +147,6 @@ class _GallerySession:
def base_body_size(self) -> tuple[int, int]:
return self._cols, self._rows
def terminal_rows(self) -> int:
return self._rows
async def button_dialog(self, prompt, buttons, **kwargs): # noqa: ANN001
return None
+56
View File
@@ -3476,6 +3476,62 @@ def test_a_lazy_body_slices_frames_and_scrolls_exactly_as_a_list_did() -> None:
assert "── Group ──" in plain, "the section heading pins once it scrolls off"
def test_a_blank_line_comes_before_each_section_where_the_rows_allow(monkeypatch) -> None: # noqa: ANN001
"""A terminal of 24 rows or more puts a blank line before each section heading.
The line separates a section from a row or a note above it. It never comes at the top,
after a blank separator, or under another heading, because there it separates nothing.
The heading pins at its own line, not at the blank line. The frame records the rows
before the body renders, so the first paint is already correct, and a terminal of 14
rows (the Cardputer Zero) gets no line. The ``section_spacing_min_rows`` preference
moves the limit, and a change applies at the next paint.
"""
from meshterm.core import preferences
prefs = preferences.Preferences()
monkeypatch.setattr(preferences, "current", lambda: prefs)
items = [
section_heading("A"),
Choice("alpha", 1),
Separator(" a note"),
section_heading("B"),
Separator(" "),
section_heading("C"),
Choice("gamma", 3),
section_heading("D"),
section_heading("E"),
Choice("epsilon", 5),
]
screen = SelectScreen("Sections", items)
screen.floating = False
def body(rows: int) -> list[str]:
frame.compose_base(Text("hdr"), screen, "Esc back", 53, rows)
return [_plain(line).strip() for line in screen.render_body(53)]
assert body(26) == [
"── A ──",
"❯ alpha",
"a note",
"",
"── B ──",
"",
"── C ──",
"gamma",
"",
"── D ──",
"── E ──",
"epsilon",
]
assert [at for at, _ in screen._sticky_headers] == [0, 4, 6, 9, 10]
assert body(14).count("") == 1 # only the blank separator of the list itself
prefs.set("section_spacing_min_rows", 0) # any height
assert body(14).count("") == 3
prefs.set("section_spacing_min_rows", 10_000) # never
assert body(26).count("") == 1
def test_an_off_screen_rows_live_title_is_left_alone() -> None:
"""MeshTerm resolves a callable title of a row only while the row is on the screen."""
calls: list[str] = []