mirror of
https://github.com/nicolargo/glances.git
synced 2026-10-05 08:25:25 -04:00
Move now plugin in the header in the TUI
This commit is contained in:
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.
|
||||
@@ -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":
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))])]
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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) == []
|
||||
Reference in new issue
Block a user