From dabe2d61e3d3357face1764a5ce96bb288d8740e Mon Sep 17 00:00:00 2001 From: nicolargo Date: Sat, 25 Jul 2026 11:17:15 +0200 Subject: [PATCH] Move now plugin in the header in the TUI --- ...26-07-25-tui-v5-now-header-right-design.md | 212 ++++++++++++++++++ glances/outputs/curses_renderer_v5.py | 15 +- glances/outputs/glances_curses_v5.py | 95 ++++---- glances/plugins/now/render_curses_v5.py | 13 +- tests/test_curses_renderer_v5.py | 60 ++++- tests/test_curses_v5.py | 118 ++++++++++ tests/test_plugin_now_render_curses_v5.py | 15 +- 7 files changed, 473 insertions(+), 55 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-25-tui-v5-now-header-right-design.md diff --git a/docs/superpowers/specs/2026-07-25-tui-v5-now-header-right-design.md b/docs/superpowers/specs/2026-07-25-tui-v5-now-header-right-design.md new file mode 100644 index 00000000..5559efc6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-tui-v5-now-header-right-design.md @@ -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. diff --git a/glances/outputs/curses_renderer_v5.py b/glances/outputs/curses_renderer_v5.py index f229b4ee..eabb984a 100644 --- a/glances/outputs/curses_renderer_v5.py +++ b/glances/outputs/curses_renderer_v5.py @@ -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": diff --git a/glances/outputs/glances_curses_v5.py b/glances/outputs/glances_curses_v5.py index bd551db4..a7bbad38 100644 --- a/glances/outputs/glances_curses_v5.py +++ b/glances/outputs/glances_curses_v5.py @@ -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 top blocks (cpu | mem | load | ...) side-by-side @@ -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: diff --git a/glances/plugins/now/render_curses_v5.py b/glances/plugins/now/render_curses_v5.py index 5054eda9..6f0a7d30 100644 --- a/glances/plugins/now/render_curses_v5.py +++ b/glances/plugins/now/render_curses_v5.py @@ -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))])] diff --git a/tests/test_curses_renderer_v5.py b/tests/test_curses_renderer_v5.py index ca8a4d87..127ebf1c 100644 --- a/tests/test_curses_renderer_v5.py +++ b/tests/test_curses_renderer_v5.py @@ -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 diff --git a/tests/test_curses_v5.py b/tests/test_curses_v5.py index f596724b..9af1d491 100644 --- a/tests/test_curses_v5.py +++ b/tests/test_curses_v5.py @@ -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.""" diff --git a/tests/test_plugin_now_render_curses_v5.py b/tests/test_plugin_now_render_curses_v5.py index a9641b3a..78bda4cb 100644 --- a/tests/test_plugin_now_render_curses_v5.py +++ b/tests/test_plugin_now_render_curses_v5.py @@ -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) == []