Move now plugin in the header in the TUI

This commit is contained in:
nicolargo committed 2026-07-25 11:17:15 +02:00
1 parent 53a262fe7e
commit dabe2d61e3
7 files changed
+473 -55

No files matched your search

@@ -0,0 +1,212 @@
# TUI v5 — `now` plugin moved to the far right of the header banner
- **Date**: 2026-07-25
- **Branch**: `develop-v5`
- **Scope**: TUI v5 layout only (no engine, no REST, no Web UI change)
## 1. Problem
In the v5 TUI, the `now` plugin (current date/time) is the last block of the
**left sidebar** (`LEFT_SLOT` in `glances/outputs/curses_renderer_v5.py`),
mirroring v4 where `now.msg_curse` is bottom-aligned in the left column
(`self.align = 'bottom'`).
Two issues with that placement:
1. The date is a header-class piece of information (like `system`, `ip`,
`uptime`), not a per-subsystem metric. It belongs in the top banner.
2. Sitting in the left sidebar, it competes for vertical space with real
metrics (`fs`, `sensors`, `network`…) that are far more valuable.
Goal: paint `now` at the **far right of the header banner**, and make it the
**least prioritary** header block when the terminal is too narrow.
## 2. Current state
`glances/outputs/curses_renderer_v5.py`:
```python
HEADER_SLOT: tuple[str, ...] = ("system", "ip", "uptime")
LEFT_SLOT: tuple[str, ...] = (..., "sensors", "now")
```
`glances/outputs/glances_curses_v5.py::_paint_header` lays the header out as:
- first block flush-left at `x = 0`
- middle blocks packed left-to-right after the first, separated by
`_HEADER_GAP = 3`
- **last** block flush-right (`right_x = max(x + 1, max_x - last.width)`)
Narrow-terminal degradation is driven by `_HEADER_DEGRADE_STEPS`, applied
cumulatively by `_fit_header` until `_header_fits(frame, max_x)` holds:
```python
_HEADER_DEGRADE_STEPS = [
("hide_os_info", True), # (1) drop the system OS-info string
("hide_ip", True), # (2) hide the ip block
("hide_uptime", True), # (3) hide the uptime block (last resort)
]
```
`glances/plugins/now/render_curses_v5.py` renders one row: the `custom` date
string padded to 23 chars (`_NOW_PAD = 23`, v4's process-list padding).
## 3. Design
### 3.1 Header slot split into two alignment groups
The header slot becomes two explicit groups. The renderer stays the single
source of truth for slot membership and order; the painter only consumes it.
```python
HEADER_SLOT_LEFT: tuple[str, ...] = ("system", "ip")
HEADER_SLOT_RIGHT: tuple[str, ...] = ("uptime", "now") # painted right-aligned
HEADER_SLOT = HEADER_SLOT_LEFT + HEADER_SLOT_RIGHT
```
`"now"` is removed from `LEFT_SLOT`, so `slot_for("now") == "header"`.
Resulting banner on a wide terminal:
```
hostname (Ubuntu 24.04 64bit) IP 192.168.1.10/24 Uptime: 3 days, 2:15:00 2026-07-25 11:30:00
└──────────── left-packed group ──────────────────┘ └────── right-aligned group ─────────────┘
```
`uptime` keeps its v4-equivalent position hugging the right edge; `now` is the
last block, flush with the right edge.
### 3.2 Degradation flag
`build_frame` gains one filter, next to the existing `hide_ip` / `hide_uptime`
ones:
```python
if view and view.get("hide_now") and plugin_name == "now":
continue
```
### 3.3 Painter — right-aligned tail group
`_paint_header` generalises "last block flush-right" into "the blocks
belonging to `HEADER_SLOT_RIGHT` are right-aligned as one group":
1. Partition the received blocks into a left group and a right group by
membership in `HEADER_SLOT_RIGHT`, preserving frame order.
2. Paint the left group packed from `x = 0`, each block separated by
`_HEADER_GAP`; stop if it runs past `max_x` (existing behaviour).
3. Compute the right group's total width
(`sum(widths) + (n - 1) * _HEADER_GAP`) and its start
`right_x = max(left_end + 1, max_x - group_width)` — the existing
never-overlap guard, applied to the group instead of a single block.
4. Paint the right group left-to-right from `right_x`, separated by
`_HEADER_GAP`.
5. Return the tallest painted block's height (unchanged, normally 1).
**Backwards compatibility**: with 1 or 2 blocks (`system`, `uptime`) or 3
blocks (`system`, `ip`, `uptime`), the output is byte-for-byte identical to the
current painter, because `uptime` alone forms the right group and a
single-element group's `right_x` reduces to `max_x - last.width`. The existing
regression guards (`test_paint_header_two_blocks_unchanged`,
`test_paint_header_packs_middle_block_between_first_and_last`) therefore stay
valid unmodified.
`_header_fits` is unchanged: `sum(widths) + (len(widths) - 1) * _HEADER_GAP`
is exactly the minimum requirement whether the blocks are packed left,
right-aligned, or split between the two groups.
### 3.4 Display priority on narrow terminals
`now` is the least prioritary header block — dropped first:
```python
_HEADER_DEGRADE_STEPS = [
("hide_now", True), # (1) drop the current date — least priority
("hide_os_info", True), # (2) drop the system OS-info string
("hide_ip", True), # (3) hide the ip block
("hide_uptime", True), # (4) hide the uptime block (last resort)
]
```
Consequence: as soon as the terminal is too narrow for the four blocks, the
banner degrades to exactly today's `system … ip … uptime` layout. Constrained
terminals see **no** regression from this change.
### 3.5 `now` renderer — drop the fixed padding
`_NOW_PAD = 23` existed to align the left-sidebar one-liner with the process
list (v4 parity). Out of the sidebar it is harmful: trailing spaces in a
right-aligned block push the visible date away from the right edge and inflate
the width measured by `_header_fits`.
```python
def render(payload, fields_desc):
custom = payload.get("custom") if payload else None
if not custom:
return []
return [Row(cells=[Cell(text=str(custom))])]
```
The empty-payload contract (no rows) is unchanged. The `iso` field remains
REST-only. Module docstring updated: header block, far right.
## 4. Files touched
| File | Change |
|---|---|
| `glances/outputs/curses_renderer_v5.py` | `HEADER_SLOT_LEFT` / `HEADER_SLOT_RIGHT` / `HEADER_SLOT`; `"now"` removed from `LEFT_SLOT`; `hide_now` filter in `build_frame`; docstrings |
| `glances/outputs/glances_curses_v5.py` | `_paint_header` right-aligned tail group; `hide_now` first in `_HEADER_DEGRADE_STEPS`; docstrings |
| `glances/plugins/now/render_curses_v5.py` | drop `_NOW_PAD`; docstring |
| `tests/test_curses_renderer_v5.py` | slot routing + `hide_now` filter tests |
| `tests/test_curses_v5.py` | 4-block painter test + degrade-order test |
| `tests/test_plugin_now_render_curses_v5.py` | no-padding assertion, test renamed |
Not touched: collection/scheduling, REST API, MCP, Web UI, config keys,
`NEWS.rst` (release-time only).
## 5. Test plan
New / updated tests:
1. `slot_for("now") == "header"` and `"now" not in LEFT_SLOT`.
2. `HEADER_SLOT == HEADER_SLOT_LEFT + HEADER_SLOT_RIGHT` (guards against the
two group tuples drifting from the flat one).
3. `build_frame(..., view={"hide_now": True})` produces no `now` block in
`frame.header`.
4. Painter, four blocks (`system`, `ip`, `uptime`, `now`) at `max_x = 120`:
- `system` at `x = 0`;
- `ip` packed strictly between `system.width` and the right group;
- `now`'s right edge is exactly `max_x` (`x == max_x - now.width`);
- `uptime` painted at `now_x - _HEADER_GAP - uptime.width`, strictly right
of the `ip` block.
5. Painter, narrow terminal: the right group never overlaps the left group
(the `max(left_end + 1, …)` guard holds).
6. `_fit_header` on a terminal too narrow for four blocks hides `now` first —
`uptime`, `ip` and the OS info are still present in the resulting frame.
7. `now` renderer returns the raw `custom` string with no trailing padding;
empty payload still yields no rows.
Existing suites must stay green, in particular the two header painter
regression guards and the full `tests/` suite
(`python -m pytest tests/ -q`).
Manual smoke check: `glances` (v5 TUI) on a wide terminal → date at the far
right of the banner, nothing left in the sidebar where it used to be; shrink
the terminal progressively → the date disappears first, then the OS info,
then the IP, then the uptime.
## 6. v4 divergence to log
In v4, `now` is the bottom block of the left sidebar (`align = 'bottom'` in
`glances/plugins/now/__init__.py`). Moving it to the header banner is a
**deliberate, approved v4 divergence**. It joins the divergence list kept for
the v5 release changelog. No user-visible configuration changes, so nothing to
document in `docs/`.
## 7. Non-goals
- No `[outputs]`-configurable slot lists (the "configurable later" note in
`curses_renderer_v5.py` stays a later item).
- No change to the `now` plugin model, its `strftime_format` config key, or
its REST payload.
- No change to the left sidebar layout beyond `now`'s removal.
+11 -4
View File
@@ -52,7 +52,12 @@ logger = logging.getLogger(__name__)
# `glances/outputs/glances_curses.py`). Plugin names not in any list
# default to LEFT (same fallback as v4). Configurable via [outputs] later.
HEADER_SLOT: tuple[str, ...] = ("system", "ip", "uptime")
# The header slot is split in two alignment groups: the LEFT group is packed
# from the left edge, the RIGHT group is painted right-aligned as a whole (see
# `glances_curses_v5._paint_header`). `now` closes the banner on the far right.
HEADER_SLOT_LEFT: tuple[str, ...] = ("system", "ip")
HEADER_SLOT_RIGHT: tuple[str, ...] = ("uptime", "now")
HEADER_SLOT: tuple[str, ...] = HEADER_SLOT_LEFT + HEADER_SLOT_RIGHT
TOP_SLOT: tuple[str, ...] = ("quicklook", "cpu", "percpu", "npu", "gpu", "mem", "memswap", "load")
LEFT_SLOT: tuple[str, ...] = (
"network",
@@ -66,7 +71,6 @@ LEFT_SLOT: tuple[str, ...] = (
"raid",
"smart",
"sensors",
"now",
)
RIGHT_SLOT: tuple[str, ...] = (
"vms",
@@ -713,8 +717,11 @@ def build_frame(
continue
if view and view.get("hide_gpu") and plugin_name == "gpu":
continue
# Header line progressive degradation (system … ip … uptime): when the
# terminal is too narrow the ip then the uptime block is dropped.
# Header line progressive degradation (system … ip … uptime … now): when
# the terminal is too narrow the now, then the ip, then the uptime block
# is dropped.
if view and view.get("hide_now") and plugin_name == "now":
continue
if view and view.get("hide_ip") and plugin_name == "ip":
continue
if view and view.get("hide_uptime") and plugin_name == "uptime":
+55 -40
View File
@@ -33,6 +33,7 @@ from typing import TYPE_CHECKING, Any
from glances import __version__
from glances.outputs.curses_renderer_v5 import (
HEADER_SLOT_RIGHT,
Cell,
ColorRole,
Frame,
@@ -66,16 +67,19 @@ _DEGRADE_STEPS: list[tuple[str, Any]] = [
("hide_gpu", True), # (g) hide gpu block (last resort)
]
# Header line (system … ip … uptime) progressive degradation, applied
# Header line (system … ip … uptime … now) progressive degradation, applied
# independently of the TOP row when the terminal is too narrow to show all
# three blocks. Cumulative, in the maintainer-specified order: first drop the
# system OS-info string, then hide ip, then hide uptime. v4 parity degrades
# only the OS-info drop (`display_system_optional`); the ip/uptime hides refine
# it for very narrow terminals.
# four blocks. Cumulative, in the maintainer-specified order: `now` is the least
# prioritary block so it goes first — which brings the banner back to exactly
# the v4 `system … ip … uptime` layout — then the system OS-info string, then
# ip, then uptime. v4 parity degrades only the OS-info drop
# (`display_system_optional`); the other hides refine it for very narrow
# terminals.
_HEADER_DEGRADE_STEPS: list[tuple[str, Any]] = [
("hide_os_info", True), # (1) drop the system OS-info string
("hide_ip", True), # (2) hide the ip block
("hide_uptime", True), # (3) hide the uptime block (last resort)
("hide_now", True), # (1) hide the now block (least priority)
("hide_os_info", True), # (2) drop the system OS-info string
("hide_ip", True), # (3) hide the ip block
("hide_uptime", True), # (4) hide the uptime block (last resort)
]
@@ -512,11 +516,12 @@ class TuiV5(threading.Thread):
return sum(widths) + (len(widths) - 1) * self._TOP_GAP_MIN <= max_x
def _header_fits(self, frame: Frame, max_x: int) -> bool:
"""True iff the header row (system … ip … uptime) fits ``max_x``.
"""True iff the header row (system … ip … uptime … now) fits ``max_x``.
Mirrors ``_paint_header``'s layout: the packed blocks plus one
``_HEADER_GAP`` between each must be within ``max_x``. An empty or
single-block header trivially fits.
Mirrors ``_paint_header``'s layout: the blocks plus one ``_HEADER_GAP``
between each must be within ``max_x`` — the same requirement whether a
block is packed left or right-aligned. An empty or single-block header
trivially fits.
"""
widths = [b.width for b in frame.header]
if len(widths) <= 1:
@@ -547,11 +552,11 @@ class TuiV5(threading.Thread):
return self._fit_proclist_width(view, frame, max_x)
def _fit_header(self, view: dict[str, Any], frame: Frame, max_x: int) -> Frame:
"""Degrade the header row (system … ip … uptime) until it fits ``max_x``.
"""Degrade the header row (system … ip … uptime … now) until it fits ``max_x``.
Independent of the TOP-row degrade above: measures the real header
block widths and applies the cumulative ``_HEADER_DEGRADE_STEPS``
(drop OS info → hide ip → hide uptime) until ``_header_fits``. Wide
(hide now → drop OS info → hide ip → hide uptime) until ``_header_fits``. Wide
terminals take the early return (no extra rebuild); the header and TOP
degrade flags coexist in the same ``view``.
"""
@@ -662,7 +667,7 @@ class TuiV5(threading.Thread):
def _paint(self, stdscr, frame: Frame) -> None:
"""Lay out the frame on the terminal, mirroring v4:
header line (hostname/OS ............ Uptime) row 0
header line (hostname/OS ...... Uptime Now) row 0
<separator line>
top blocks (cpu | mem | load | ...) side-by-side
<separator line>
@@ -711,44 +716,54 @@ class TuiV5(threading.Thread):
natural = max(natural + 2, 23)
return min(natural, 34, max(1, max_x // 2))
# Horizontal gap between header blocks packed on the left (v4 parity:
# `space_between_column = 3` between the system and ip blocks).
# Horizontal gap between two adjacent header blocks, in either alignment
# group (v4 parity: `space_between_column = 3` between system and ip).
_HEADER_GAP = 3
def _paint_header(self, stdscr, blocks: list[PluginBlock], y0: int, max_x: int) -> int:
"""Paint the header line (v4 parity): first block flush-left, last
block flush-right, and any middle block(s) packed left-to-right after
the first (v4 paints `system … ip … uptime` this way, `glances_curses.py`
`__display_top`). Returns the header height (0 when empty, else the
tallest block painted — normally 1).
"""Paint the header line (v4 parity): the blocks of `HEADER_SLOT_LEFT`
packed from the left edge, the blocks of `HEADER_SLOT_RIGHT`
right-aligned as one group (v4 paints `system … ip … uptime` this way,
`glances_curses.py` `__display_top`; v5 appends `now` at the far right).
Returns the header height (0 when empty, else the tallest block
painted — normally 1).
The middle-block packing is generic (not ip-specific): the header slot
order is owned by `HEADER_SLOT`; this painter just lays out whatever
blocks it is handed without overlapping them.
The layout is generic (not ip- or now-specific): slot membership and
order are owned by `HEADER_SLOT_*`; this painter just lays out whatever
blocks it is handed without overlapping them. With a single right-group
block the geometry reduces to the plain flush-right case.
"""
if not blocks:
return 0
left_blocks = [b for b in blocks if b.name not in HEADER_SLOT_RIGHT]
right_blocks = [b for b in blocks if b.name in HEADER_SLOT_RIGHT]
height = 0
first = blocks[0]
self._paint_block(stdscr, first, y0, 0, max_x, fit_to_term=False)
height = max(height, first.height)
# Middle blocks (e.g. ip): packed after the first block, each separated
# by `_HEADER_GAP`. Stop if we run past the right edge.
x = first.width
for block in blocks[1:-1]:
x += self._HEADER_GAP
# Left group: packed from x=0, each block separated by `_HEADER_GAP`.
# Stop if we run past the right edge.
x = 0
for i, block in enumerate(left_blocks):
if i:
x += self._HEADER_GAP
if x >= max_x:
break
self._paint_block(stdscr, block, y0, x, max(1, max_x - x), fit_to_term=False)
height = max(height, block.height)
x += block.width
if len(blocks) > 1:
last = blocks[-1]
# Flush-right, but never overlap the left-packed blocks.
right_x = max(x + 1, max_x - last.width)
if right_x < max_x:
self._paint_block(stdscr, last, y0, right_x, max(1, max_x - right_x), fit_to_term=False)
height = max(height, last.height)
if not right_blocks:
return height
# Right group: right-aligned as a whole, but never overlapping the
# left-packed blocks. `x` is 0 when the left group is empty, in which
# case the group is free to start at the natural right-aligned offset.
group_width = sum(b.width for b in right_blocks) + (len(right_blocks) - 1) * self._HEADER_GAP
right_x = max_x - group_width
if left_blocks:
right_x = max(x + 1, right_x)
for block in right_blocks:
if right_x >= max_x:
break
self._paint_block(stdscr, block, y0, right_x, max(1, max_x - right_x), fit_to_term=False)
height = max(height, block.height)
right_x += block.width + self._HEADER_GAP
return height
def _paint_top_row(self, stdscr, blocks: list[PluginBlock], y0: int, max_x: int) -> int:
+7 -6
View File
@@ -6,10 +6,13 @@
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Glances v5 — TUI renderer for the now plugin (left-sidebar one-liner).
"""Glances v5 — TUI renderer for the now plugin (header block, far right).
Mirrors v4 ``now.msg_curse``: the ``custom`` date string left-padded to 23
chars (the v4 process-list padding). The ISO field is REST-only.
The ``custom`` date string as a bare one-liner. Unlike v4 ``now.msg_curse``
there is no 23-char padding: that padding aligned the block with the process
list in v4's left sidebar, and trailing blanks would push the date away from
the right edge here (see ``curses_renderer_v5.HEADER_SLOT_RIGHT`` +
``glances_curses_v5._paint_header``). The ISO field is REST-only.
"""
from __future__ import annotations
@@ -18,11 +21,9 @@ from typing import Any
from glances.outputs.curses_renderer_v5 import Cell, Row
_NOW_PAD = 23
def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> list[Row]:
custom = payload.get("custom") if payload else None
if not custom:
return []
return [Row(cells=[Cell(text=f"{str(custom):{_NOW_PAD}}")])]
return [Row(cells=[Cell(text=str(custom))])]
+59 -1
View File
@@ -1135,7 +1135,7 @@ def test_slot_for_header_plugins():
def test_header_slot_orders_ip_between_system_and_uptime():
from glances.outputs.curses_renderer_v5 import HEADER_SLOT
assert HEADER_SLOT == ("system", "ip", "uptime")
assert HEADER_SLOT[:3] == ("system", "ip", "uptime")
assert slot_for("ip") == "header"
@@ -1248,6 +1248,64 @@ def test_build_frame_header_order_system_ip_uptime():
assert [b.name for b in frame.header] == ["system", "ip", "uptime"]
# ----------------------------------------------------- now = far-right header block
def _header_with_now_snapshot():
snapshot = {
"now": {"custom": "2026-07-25 11:30:00 CEST", "iso": "2026-07-25T11:30:00+02:00", "_levels": {}},
"uptime": {"seconds": 3600, "_levels": {}},
"ip": {"address": "192.168.1.10", "mask_cidr": 24, "_levels": {}},
"system": {"hostname": "h", "hr_name": "Ubuntu", "_levels": {}},
}
fields = {
"now": {"custom": {"unit": "string"}, "iso": {"unit": "string"}},
"uptime": {"seconds": {"unit": "seconds"}},
"ip": {"address": {"unit": "string"}, "mask_cidr": {"unit": "number"}},
"system": {"hostname": {"unit": "string"}, "hr_name": {"unit": "string"}},
}
# Deliberately out of order — HEADER_SLOT.index must enforce the order.
registry = [("now", False), ("uptime", False), ("ip", False), ("system", False)]
return snapshot, fields, registry
def test_now_is_routed_to_the_header_slot():
"""`now` moved out of the left sidebar into the header (v4 divergence:
v4 bottom-aligns it in the left column)."""
from glances.outputs.curses_renderer_v5 import LEFT_SLOT, slot_for
assert slot_for("now") == "header"
assert "now" not in LEFT_SLOT
def test_header_slot_is_the_concatenation_of_both_alignment_groups():
"""Guards against the two group tuples drifting from the flat one."""
from glances.outputs.curses_renderer_v5 import HEADER_SLOT, HEADER_SLOT_LEFT, HEADER_SLOT_RIGHT
assert HEADER_SLOT == HEADER_SLOT_LEFT + HEADER_SLOT_RIGHT
assert HEADER_SLOT_RIGHT[-1] == "now" # far right of the banner
def test_build_frame_header_order_puts_now_last():
from glances.outputs.curses_renderer_v5 import build_frame
snapshot, fields, registry = _header_with_now_snapshot()
frame = build_frame(snapshot, fields, registry, alerts_history=[], view={})
assert [b.name for b in frame.header] == ["system", "ip", "uptime", "now"]
# It must NOT leak back into the sidebar.
assert "now" not in [b.name for b in frame.top + frame.left + frame.right]
def test_build_frame_hide_now_flag_drops_now_block():
"""Progressive header degradation level 1: `hide_now` removes the now block
and brings the banner back to the v4 `system … ip … uptime` layout."""
from glances.outputs.curses_renderer_v5 import build_frame
snapshot, fields, registry = _header_with_now_snapshot()
frame = build_frame(snapshot, fields, registry, alerts_history=[], view={"hide_now": True})
assert [b.name for b in frame.header] == ["system", "ip", "uptime"]
# --------------------------------------------------------------- hide_* skip guards
+118
View File
@@ -1254,6 +1254,76 @@ def test_paint_header_packs_middle_block_between_first_and_last(fake_store, fake
assert any(y == 0 and x == uptime_x and "Uptime" in text for (y, x, text) in calls)
def test_paint_header_right_aligns_uptime_and_now_as_a_group(fake_store, fake_alerts, fake_config):
"""Header with 4 blocks: `system`/`ip` packed left, `uptime`/`now`
right-aligned as one group with `now` flush against the right edge.
"""
from glances.outputs import glances_curses_v5 as tui_mod
from glances.outputs.curses_renderer_v5 import Cell, ColorRole, PluginBlock, Row
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
system = PluginBlock(name="system", rows=[Row(cells=[Cell(text="myhost Ubuntu")])])
ip = PluginBlock(
name="ip",
rows=[Row(cells=[Cell(text="IP", color=ColorRole.HEADER), Cell(text="192.168.1.10/24")])],
)
uptime = PluginBlock(name="uptime", rows=[Row(cells=[Cell(text="Uptime: 3d04h")])])
now = PluginBlock(name="now", rows=[Row(cells=[Cell(text="2026-07-25 11:30:00")])])
fake_stdscr = MagicMock()
max_x = 120
height = tui._paint_header(fake_stdscr, [system, ip, uptime, now], y0=0, max_x=max_x)
assert height == 1
calls = [(c.args[0], c.args[1], c.args[2]) for c in fake_stdscr.addstr.call_args_list]
now_x = max_x - now.width
uptime_x = now_x - tui._HEADER_GAP - uptime.width
# Left group.
assert any(y == 0 and x == 0 and "myhost" in text for (y, x, text) in calls)
assert any(
y == 0 and system.width < x < uptime_x and ("IP" in text or "192.168.1.10" in text) for (y, x, text) in calls
)
# Right group: uptime then now, now's right edge exactly at max_x.
assert any(y == 0 and x == uptime_x and "Uptime" in text for (y, x, text) in calls)
assert any(y == 0 and x == now_x and "2026-07-25 11:30:00" in text for (y, x, text) in calls)
def test_paint_header_right_group_never_overlaps_the_left_group(fake_store, fake_alerts, fake_config):
"""On a terminal too narrow for both groups the right group is pushed past
the left-packed blocks rather than painted over them."""
from glances.outputs import glances_curses_v5 as tui_mod
from glances.outputs.curses_renderer_v5 import Cell, PluginBlock, Row
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[],
fields_by_plugin={},
refresh_interval=0.01,
)
system = PluginBlock(name="system", rows=[Row(cells=[Cell(text="myhost Ubuntu 24.04 LTS 64bit")])])
uptime = PluginBlock(name="uptime", rows=[Row(cells=[Cell(text="Uptime: 3d04h")])])
now = PluginBlock(name="now", rows=[Row(cells=[Cell(text="2026-07-25 11:30:00")])])
fake_stdscr = MagicMock()
max_x = 40 # smaller than system.width + uptime.width + gap + now.width
tui._paint_header(fake_stdscr, [system, uptime, now], y0=0, max_x=max_x)
calls = [(c.args[0], c.args[1], c.args[2]) for c in fake_stdscr.addstr.call_args_list]
painted_x = [x for (y, x, text) in calls if y == 0 and "Uptime" in text]
assert painted_x, "uptime must still be painted"
assert all(x > system.width for x in painted_x)
def test_paint_header_empty_returns_zero(fake_store, fake_alerts, fake_config):
from glances.outputs import glances_curses_v5 as tui_mod
@@ -1781,6 +1851,54 @@ def test_fit_header_progressively_degrades(fake_store, fake_alerts, fake_config)
assert [b.name for b in f3.header] == ["system"]
def test_now_is_the_first_header_block_dropped(fake_store, fake_alerts, fake_config):
"""`now` is the least prioritary header block: as soon as the terminal is
too narrow for the four blocks it goes first, leaving the v4
`system … ip … uptime` banner (OS-info included) intact."""
from glances.outputs import glances_curses_v5 as tui_mod
fake_store.as_dict.return_value = {
"system": {"hostname": "host", "hr_name": "Ubuntu 24.04 64bit / Linux 6.17", "_levels": {}},
"ip": {"address": "192.168.1.100", "mask_cidr": 24, "_levels": {}},
"uptime": {"seconds": 3600, "_levels": {}},
"now": {"custom": "2026-07-25 11:30:00 CEST", "iso": "2026-07-25T11:30:00+02:00", "_levels": {}},
}
tui = tui_mod.TuiV5(
store=fake_store,
alerts=fake_alerts,
config=fake_config,
registry=[("system", False), ("ip", False), ("uptime", False), ("now", False)],
fields_by_plugin={
"system": {"hostname": {"unit": "string"}, "hr_name": {"unit": "string"}},
"ip": {"address": {"unit": "string"}, "mask_cidr": {"unit": "number"}},
"uptime": {"seconds": {"unit": "seconds"}},
"now": {"custom": {"unit": "string"}, "iso": {"unit": "string"}},
},
refresh_interval=0.01,
)
gap = tui_mod.TuiV5._HEADER_GAP
# Wide terminal: the four blocks, now last.
wide = tui._build_fitted_frame(1000)
assert [b.name for b in wide.header] == ["system", "ip", "uptime", "now"]
w = {b.name: b.width for b in wide.header}
# One char short of the full banner → `now` is dropped, nothing else.
narrow = tui._build_fitted_frame(w["system"] + gap + w["ip"] + gap + w["uptime"] + gap + w["now"] - 1)
assert [b.name for b in narrow.header] == ["system", "ip", "uptime"]
system_text = " ".join(c.text for r in next(b for b in narrow.header if b.name == "system").rows for c in r.cells)
assert "Ubuntu" in system_text # OS-info still there: degraded one notch only
def test_hide_now_is_the_first_header_cascade_step():
from glances.outputs.glances_curses_v5 import _HEADER_DEGRADE_STEPS
keys = [k for k, _ in _HEADER_DEGRADE_STEPS]
assert keys[0] == "hide_now"
# Ordering contract: uptime stays the last resort.
assert keys[-1] == "hide_uptime"
def test_attr_for_prominent_badge_is_bold():
"""The prominent badge is always bold — on an 8-colour terminal that is
what promotes the light-gray foreground (colour 7) to true white."""
+11 -4
View File
@@ -1,4 +1,4 @@
"""Glances v5 — tests for the now curses renderer (left-sidebar one-liner)."""
"""Glances v5 — tests for the now curses renderer (header block, far right)."""
from __future__ import annotations
@@ -7,7 +7,7 @@ from glances.plugins.now.render_curses_v5 import render
NOW_FIELDS = {"custom": {"unit": "string"}, "iso": {"unit": "string"}}
def test_render_shows_custom_only_padded():
def test_render_shows_custom_only_unpadded():
# Use +0200 (numeric offset) so the custom string has no literal 'T',
# which guards against accidentally rendering the ISO field (e.g.
# "2026-06-06T12:00:00+02:00" has 'T' as the date/time separator).
@@ -15,10 +15,17 @@ def test_render_shows_custom_only_padded():
rows = render(payload, NOW_FIELDS)
assert len(rows) == 1
text = rows[0].cells[0].text
assert text.startswith("2026-06-06 12:00:00 +0200")
assert len(text) >= 23
# No padding: the block is right-aligned in the header, so trailing blanks
# would push the date away from the right edge.
assert text == "2026-06-06 12:00:00 +0200"
assert "T" not in text
def test_render_short_date_is_not_padded():
"""A date shorter than v4's 23-char process-list padding stays bare."""
rows = render({"custom": "12:00:00"}, NOW_FIELDS)
assert rows[0].cells[0].text == "12:00:00"
def test_render_empty_payload_yields_no_rows():
assert render({}, NOW_FIELDS) == []