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:
1 parent
b54a93e5c4
commit
820740c9a0
17 files changed
+231
-124
No files matched your search
+9
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
Reference in new issue
Block a user