Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jobs:
# Threshold 100: ifutil.py, keelcli.py, keelbanner.py, dbscreen.py,
# wgcli.py, wgscreen.py, keelfirstboot.py and the Instance menu
# entries, the database mode, overlay and Keel Cloud screens
# included, measure 100 percent (646 tests, lines and branches). The
# included, measure 100 percent (682 tests, lines and branches). The
# package list names the modules with tests; a module is appended as
# its tests land (COVERAGE.md plan) and the threshold is only ever
# raised (decision 0006). Baseline before the first test: 0 percent.
Expand Down
20 changes: 20 additions & 0 deletions COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,26 @@ api_key: skip`, a key file reference) to a real `keel spec validate`.
lose the TKLBAM and TurnKey Hub footer and gain one that checks neither
is shown and that the screen runs no command.

## Measured on 2026-10-01 with the maintainer's art in tiers

| File | Tests | Stmts | Branches | Cover |
|------|-------|-------|----------|-------|
| keelbanner.py | 92 in tests/test_keelbanner.py, up from 60: the tiers wide, full, small and none by width and by height on synthetic block character marks, the locale (LC_ALL, LC_CTYPE, LANG, spelled every way glibc accepts, and an empty setting skipped), the two ladders and their paths, `available` (the backtitle and the shadow, measured with dialog on trixie), `text_rows` (dialog's wrap of a real IPv6 usage line), `box_width`, and `read` of a UTF-8 file and of one that is not | 80 | 22 | 100 percent, 0 missed, 0 partial |
| confconsole.py | `TestUsageMark` has 12, up from 8: the box never taller than the room, 80 by 24 dual stack keeping the screen whole, the UTF-8 ladder in a UTF-8 locale, the wide mark widening the box, and the C locale never drawing a UTF-8 mark | | | not measured, see above; `usage` and `Console.msgbox` have 0 missed lines |

Total 682 tests with a keel 0.11 at hand (677 passed and 5 skipped
without one), 100 percent over the measured package, unchanged.
Threshold in the caller: 100, unchanged.

The tier is chosen from the room the dialog has, not from the terminal:
dialog centres the box on the whole screen, so the backtitle costs four
rows and the shadow four columns, and the box needs its text, wrapped as
dialog wraps it, and five rows of frame, the blank row
`Console._wrapper` puts above the text, and the button. The 25 row floor
of the first version is gone: on 80 by 24 a 20 row box is what fits, so
the small mark goes above a usage text of up to seven rows, and a dual
stack usage screen keeps no mark.

## Plan to reach 90 percent per file

Priority order (size: small under 30 lines of test, medium under 150,
Expand Down
44 changes: 28 additions & 16 deletions confconsole.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,14 +276,16 @@ def msgbox(
button_label: str = "ok",
autosize: bool = False,
height: int | None = None,
width: int | None = None,
) -> str:
# `height` overrides the default for a box that carries more than
# the usual text, such as the usage screen with the mark above it.
# `height` and `width` override the default for a box that carries
# more than the usual text, such as the usage screen with the mark
# above it.
if autosize:
text += "\n "
height, width = 0, 0
else:
height, width = height or self.height, self.width
height, width = height or self.height, width or self.width

v = self._wrapper(
"msgbox", text, height, width, title=title, ok_label=button_label
Expand Down Expand Up @@ -663,28 +665,38 @@ def usage(self) -> str:
f"Usage started - hostname: {hostname} ipv6: {ipv6_addr}"
f" ip: {ip_addr}"
)
# The mark above the usage text, when the terminal has room for
# it over and above the rows the screen already uses: the box
# grows by what the mark takes, so not one line of what the
# appliance already says is lost, and the mark is dropped to the
# small one and then to nothing before that happens. The mark is
# centred on the columns the box leaves inside its frame, the
# same width keelbanner measures it against; the usage text keeps
# The mark above the usage text, the largest tier (wide, full,
# small) the dialog has room for on this screen over and above
# the rows the text needs, so not one line of what the appliance
# already says is lost: a smaller tier, and then none, is taken
# before that happens. The UTF-8 tiers in a UTF-8 locale, the
# ASCII ones otherwise. The box widens to a mark wider than it
# and grows by the rows the mark takes; the mark is centred on the
# columns the box leaves inside its frame, the usage text keeps
# its own left margin.
height = self.height
rows, cols = keelbanner.terminal_size()
cols = min(cols, self.width)
mark = keelbanner.choose(rows, cols, height)
height, width = self.height, self.width
room_rows, room_cols = keelbanner.available(
*keelbanner.terminal_size()
)
inside = keelbanner.inner_width(min(width, room_cols))
used = keelbanner.text_rows(text, inside) + keelbanner.BOX_CHROME
mark = keelbanner.choose(
room_rows, room_cols, used, keelbanner.marks(os.environ)
)
if mark is not None:
centred = keelbanner.center(mark, keelbanner.inner_width(cols))
width = keelbanner.box_width(mark, width, room_cols)
centred = keelbanner.center(mark, keelbanner.inner_width(width))
text = keelbanner.above(text, centred)
height += keelbanner.added_rows(mark)
height = max(
used + keelbanner.added_rows(mark), min(height, room_rows)
)

retcode = self.console.msgbox(
f"{hostname} appliance services",
text,
button_label=default_button_label,
height=height,
width=width,
)

if retcode is not self.OK:
Expand Down
17 changes: 17 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
confconsole (2.2.3+keel9) trixie; urgency=low

* Usage screen: the maintainer's console art in tiers. keelbanner picks
the largest that the dialog has room for, in the order wide (the mark
with the KEEL LINUX lettering, the tagline and KEELLINUX.ORG), full,
small, none, from the files keel-core installs in /etc/keel.
* The room is measured as dialog uses it: the screen less the backtitle
and the shadow, and the box's own text wrapped at its width plus its
frame and button. The box widens for a mark wider than it and grows
by the mark's rows, never past the room, so the addresses keep every
line. The 24 row floor and the fixed 25 row box of the first version
no longer decide.
* UTF-8 tiers when the locale dialog inherits is UTF-8 (LC_ALL,
LC_CTYPE, LANG), the ASCII ones otherwise.

-- Marcos Méndez <mendez.foto@gmail.com> Thu, 01 Oct 2026 21:00:00 +0000

confconsole (2.2.3+keel8) trixie; urgency=low

* keelfirstboot.py, the first boot screens handbook decision 0020
Expand Down
140 changes: 107 additions & 33 deletions keelbanner.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,110 @@
"""The Keel mark above the usage screen.

The console is a brand surface: an operator meets a Keel appliance on the
container console or over SSH long before any web page. The mark is
installed by the core overlay as ``/etc/keel/banner.txt`` and
``/etc/keel/banner-small.txt``, the same two files the login banner
(``/etc/update-motd.d/00-keel-banner``) reads, both plain ASCII with no
colour escape so a serial console and a recovery shell render them.
container console or over SSH long before any web page. The mark is the
maintainer's console art, installed by the core overlay in ``/etc/keel``,
the same files the login banner (``/etc/update-motd.d/00-keel-banner``)
reads: a wide mark with the KEEL LINUX lettering, the tagline and
KEELLINUX.ORG, a full mark and a small one. The wide mark is drawn in
UTF-8 block and shade characters only; the full and the small mark come
in UTF-8 and in plain ASCII. None of them carries a colour escape.

How many rows and columns a mark takes is whatever the installed files
carry: the art is drawn in the overlay and redrawn there, and nothing
here assumes a size. This module measures the file it reads, decides which
of the two marks, if either, goes above the usage text, centres it on the
width the dialog leaves inside its frame and puts it there. It opens no
dialog and formats no address: the usage text is rendered by
``confconsole.render_usage``, is left as it is and is never centred.
here assumes a size. This module measures the file it reads, decides
which tier, if any, goes above the usage text, in the order wide, full,
small, none, from the room the dialog has on the screen, and centres it
on the width of the box. It opens no dialog and formats no address: the
usage text is rendered by ``confconsole.render_usage``, is left as it is
and is never centred.

The rule the module enforces is the identity's: the mark never costs the
screen a line of what it already says, so it is dropped to the small one,
and then to nothing, rather than pushing the addresses out of the box.
screen a line of what it already says, so a smaller tier is taken, and
then none, rather than pushing the addresses out of the box.
"""

import os
import shutil
import textwrap
from collections.abc import Mapping

MARK_WIDE = "/etc/keel/banner-wide.txt"
MARK_UTF8 = "/etc/keel/banner-utf8.txt"
MARK_SMALL_UTF8 = "/etc/keel/banner-small-utf8.txt"
MARK = "/etc/keel/banner.txt"
MARK_SMALL = "/etc/keel/banner-small.txt"
MARKS = (MARK, MARK_SMALL)

# Under this the usage screen alone is already tight and the mark is
# dropped: the addresses win.
# The two ladders, largest first. There is no ASCII wide mark.
MARKS_UTF8 = (MARK_WIDE, MARK_UTF8, MARK_SMALL_UTF8)
MARKS_ASCII = (MARK, MARK_SMALL)

MIN_ROWS = 24
DEFAULT_COLS = 80

# One blank line between the mark and the first line of the usage text.
SEPARATOR_ROWS = 1

# The dialog frame and its padding, left and right, top and bottom.
# The dialog frame and its padding, left and right.
FRAME = 4

# The rows of a message box that are not text: the top border, the blank
# row Console._wrapper puts above every text, the rule above the button,
# the button and the bottom border.
BOX_CHROME = 5

# What the screen keeps from the box. dialog centres a box on the whole
# screen, so the backtitle and the rule under it, two rows at the top,
# cost two rows at the bottom too, and the shadow, two columns on the
# right, costs two on the left. Measured with dialog 1.3 on trixie: on
# 24 rows a box of 20 is the tallest drawn clear of the backtitle.
SCREEN_ROWS = 4
SCREEN_COLS = 4


def terminal_size() -> tuple[int, int]:
"""The terminal as (rows, columns), 24 by 80 when it does not say."""
size = shutil.get_terminal_size(fallback=(DEFAULT_COLS, MIN_ROWS))
return size.lines, size.columns


def available(rows: int, cols: int) -> tuple[int, int]:
"""The rows and columns a dialog box may take on a terminal of
`rows` by `cols`, the backtitle and the shadow kept clear."""
return max(0, rows - SCREEN_ROWS), max(0, cols - SCREEN_COLS)


def is_utf8(environ: Mapping[str, str]) -> bool:
"""Whether the locale of `environ` is UTF-8: LC_ALL, then LC_CTYPE,
then LANG, the first set and not empty, as the C library reads them.

This is the environment the dialog child inherits. Python run in the
C locale with LANG=C coerces LC_CTYPE to C.UTF-8 (PEP 538), and dialog
then does draw UTF-8, so the UTF-8 marks are right there too; LC_ALL=C
turns the coercion off and gets the ASCII ones.
"""
for name in ("LC_ALL", "LC_CTYPE", "LANG"):
value = environ.get(name, "")
if value:
codeset = value.partition(".")[2].partition("@")[0].lower()
return codeset in ("utf-8", "utf8")
return False


def marks(environ: Mapping[str, str]) -> tuple[str, ...]:
"""The ladder for the locale of `environ`, largest first."""
return MARKS_UTF8 if is_utf8(environ) else MARKS_ASCII


def mark_size(mark: str) -> tuple[int, int]:
"""The rows and columns an ASCII mark occupies, (0, 0) when empty."""
"""The rows and columns a mark occupies, (0, 0) when empty. Every
character of the art, a block or a shade as much as a "#", is one
column wide."""
lines = mark.splitlines()
return len(lines), max((len(line) for line in lines), default=0)


def inner_width(cols: int) -> int:
"""The columns a terminal of `cols` leaves inside the dialog frame.
"""The columns a box `cols` wide leaves inside its frame.

One definition, used by `fits` to decide whether a mark is too wide
and by the caller to centre it, so the width the mark is measured
Expand All @@ -60,10 +113,28 @@ def inner_width(cols: int) -> int:
return cols - FRAME


def text_rows(text: str, width: int) -> int:
"""The rows `text` takes inside a box whose inside is `width` wide:
dialog wraps a longer line at a space, and breaks a word longer than
the whole width."""
rows = 0
for line in text.split("\n"):
rows += max(1, len(textwrap.wrap(line, width)))
return rows


def box_width(mark: str, width: int, room: int) -> int:
"""The width of a box of `width` columns once it holds `mark`: wide
enough for the mark inside its frame, never wider than `room`."""
return min(room, max(width, mark_size(mark)[1] + FRAME))


def fits(mark: str, rows: int, cols: int, used_rows: int) -> bool:
"""Whether `mark` fits above a box of `used_rows` rows in a terminal
of `rows` by `cols`, the blank line between them counted. An empty
file never fits, so a truncated mark costs the screen nothing."""
"""Whether `mark` fits above a box of `used_rows` rows in a room of
`rows` by `cols` (`available`), the blank line between them counted.
The box may widen to the room, so the mark needs only its own
columns and the frame. An empty file never fits, so a truncated mark
costs the screen nothing."""
mark_rows, mark_cols = mark_size(mark)
if mark_rows == 0:
return False
Expand All @@ -77,29 +148,32 @@ def read(path: str) -> str | None:
is not an error: the appliance was built before the overlay carried
the mark, or an operator removed it."""
try:
with open(path) as fob:
with open(path, encoding="utf-8") as fob:
return fob.read()
except OSError:
except (OSError, UnicodeDecodeError):
return None


def choose(
rows: int,
cols: int,
used_rows: int,
paths: tuple[str, ...] = MARKS,
paths: tuple[str, ...] | None = None,
reader=None,
) -> str | None:
"""The first mark of `paths` that fits, or None.

`paths` is ordered largest first, so a tall terminal gets the full
mark, a shorter one the small mark and a terminal under `MIN_ROWS`
none at all, whatever its width. `reader` defaults to `read`, looked
up when the call is made and not when this function was defined, so a
test can replace it on the module.
"""The first mark of `paths` that fits a room of `rows` by `cols`
above a box of `used_rows` rows, or None.

`rows` and `cols` are the room the dialog has (`available`), not the
terminal. `paths` is ordered largest first and defaults to the ladder
of the locale (`marks`), so the widest terminal gets the wide mark, a
narrower or shorter one the full mark, then the small mark, and a
terminal with room for none of them none at all. `reader` defaults to
`read`, looked up when the call is made and not when this function
was defined, so a test can replace it on the module.
"""
if rows < MIN_ROWS:
return None
if paths is None:
paths = marks(os.environ)
if reader is None:
reader = read
for path in paths:
Expand Down
Loading
Loading