diff --git a/conf/glances.conf b/conf/glances.conf index 116e9ea7..692e65db 100644 --- a/conf/glances.conf +++ b/conf/glances.conf @@ -36,6 +36,11 @@ history_size=1200 #-------------------------- # Disable background color #disable_bg=True +# Plugin title colour, tuned for your terminal background (Glances v5 TUI). +# dark (default) bold white, for the dark backgrounds most terminals use +# light dark grey, for a white/light background +# No single colour is readable on both, hence the switch. +#theme=light # # Specifics options for Glances server #------------------------------------- diff --git a/docs/config.rst b/docs/config.rst index 800c28c6..b0ca3d70 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -87,6 +87,11 @@ than a second one concerning the user interface: #left_menu=network,wifi,connections,ports,diskio,fs,irq,folders,raid,smart,sensors,now # Limit the number of processes to display (in the WebUI) max_processes_display=25 + # Plugin title colour in the Curses interface, tuned for your terminal + # background. "dark" (default) is bold white, suited to the dark + # backgrounds most terminals use; "light" is a dark grey for a white + # background. No single colour is legible on both, hence the switch. + #theme=light # Options for WebUI #------------------ # Set URL prefix for the WebUI and the API diff --git a/glances/outputs/curses_renderer_v5.py b/glances/outputs/curses_renderer_v5.py index 6873a617..f229b4ee 100644 --- a/glances/outputs/curses_renderer_v5.py +++ b/glances/outputs/curses_renderer_v5.py @@ -119,60 +119,10 @@ _LEVEL_TO_ROLE: dict[str, ColorRole] = { "critical": ColorRole.CRITICAL, } -# Severity ordering for picking the "worst" level across multiple -# prominent fields in a single plugin payload. -_LEVEL_PRIORITY: dict[str, int] = {"ok": 0, "careful": 1, "warning": 2, "critical": 3} - - -def _max_prominent_level(payload: dict[str, Any]) -> str: - """Return the worst level among prominent `_levels` entries. - - Supports both scalar payload (`_levels` is `{field: {level, prominent}}`) - and collection payload (`_levels` is `{pk: {field: {level, prominent}}}`). - Returns ``"ok"`` when no prominent field is escalated. - """ - levels = payload.get("_levels", {}) if isinstance(payload, dict) else {} - if not isinstance(levels, dict): - return "ok" - - def _scan(entries: dict) -> str: - worst = "ok" - worst_pri = 0 - for entry in entries.values(): - if not isinstance(entry, dict): - continue - # Detect shape: leaf entry has a `level` key. - if "level" in entry: - if not entry.get("prominent"): - continue - lvl = entry.get("level", "ok") - pri = _LEVEL_PRIORITY.get(lvl, 0) - if pri > worst_pri: - worst, worst_pri = lvl, pri - else: - # Nested (collection) — recurse one level. - nested = _scan(entry) - pri = _LEVEL_PRIORITY.get(nested, 0) - if pri > worst_pri: - worst, worst_pri = nested, pri - return worst - - return _scan(levels) - - -def title_role(payload: dict[str, Any]) -> ColorRole: - """Pick the renderer role for a plugin title given its payload. - - - No prominent field at warning or above → ``ColorRole.HEADER`` - (bold white, v4 TITLE decoration). ``ok`` and ``careful`` keep - the title neutral. - - Worst prominent level is warning/critical → the level's role - (rendered bold + that colour). - """ - level = _max_prominent_level(payload) - if level in ("ok", "careful"): - return ColorRole.HEADER - return _LEVEL_TO_ROLE.get(level, ColorRole.HEADER) +# Plugin titles and column headers are ALWAYS ``ColorRole.HEADER`` (v4's +# fixed "TITLE" decoration). An alert is signalled on the VALUE only — its +# colour plus, for a `prominent` field, the highlighted background. Do not +# escalate a header's colour from `_levels`. @dataclass diff --git a/glances/outputs/glances_curses_v5.py b/glances/outputs/glances_curses_v5.py index bb97ab93..bd551db4 100644 --- a/glances/outputs/glances_curses_v5.py +++ b/glances/outputs/glances_curses_v5.py @@ -196,6 +196,11 @@ class TuiV5(threading.Thread): # row blank instead, so the vertical rhythm of the layout is # preserved (v4 collapses the row; v5 keeps a blank line). self._separator_enabled = bool(self.config.get("outputs", "separator", True)) + # Plugin-title colour, see `_init_colors`. ``dark`` (default) is a + # light amber tuned for the dark backgrounds most terminals use; + # ``light`` swaps to a dark amber for white backgrounds. A single + # colour cannot clear 4.6:1 on both, hence the key. + self._theme = str(self.config.get("outputs", "theme", "dark")).strip().lower() self._stop_event = threading.Event() # User-toggled view options (percpu / short-name / programs), # driven by the hotkey dispatch table. The process sort key is @@ -336,7 +341,7 @@ class TuiV5(threading.Thread): logger.warning("TUI v5 crashed: %s", e) def _loop(self, stdscr) -> None: - _init_colors() + _init_colors(self._theme) cursor_was_hidden = False try: curses.curs_set(0) @@ -939,15 +944,26 @@ class TuiV5(threading.Thread): Cell(text="= stat severity (vs thresholds)"), ] ) + # Same four levels as `levels`, rendered as the highlighted badge so + # the user can tell the two apart side by side. The `Alert` sample + # lives here rather than in `decorations` — it IS this decoration. + prominent = Row( + cells=[ + Cell(text="OK", color=ColorRole.OK, prominent=True), + Cell(text="CAREFUL", color=ColorRole.CAREFUL, prominent=True), + Cell(text="WARNING", color=ColorRole.WARNING, prominent=True), + Cell(text="CRITICAL", color=ColorRole.CRITICAL, prominent=True), + Cell(text="= same, highlighted: an event is ongoing"), + ] + ) decorations = Row( cells=[ Cell(text="Title", color=ColorRole.HEADER), Cell(text="Sort", color=ColorRole.DEFAULT, bold=True, underline=True), - Cell(text="Alert", color=ColorRole.CRITICAL, prominent=True), - Cell(text="= section title / active sort column / ongoing alert"), + Cell(text="= section title / active sort column"), ] ) - return [levels, decorations] + return [levels, prominent, decorations] def _help_visual_rows(self, max_x: int) -> tuple[list[tuple[Row, Row | None]], int]: """Assemble the full scrollable help document as ``(left, right)`` rows. @@ -1060,7 +1076,7 @@ class TuiV5(threading.Thread): _COLOR_PAIRS_REVERSE: dict[ColorRole, int] = {} -def _init_colors() -> None: +def _init_colors(theme: str = "dark") -> None: try: if not curses.has_colors(): return @@ -1069,16 +1085,29 @@ def _init_colors() -> None: curses.use_default_colors() except curses.error: pass + colors = getattr(curses, "COLORS", 8) + # Plugin titles. A foreground readable on BOTH a black and a white + # background peaks at 4.58:1 (equal-contrast luminance L=0.179), so + # no single value serves both. The shade is therefore a user choice: + # dark (default) COLOR_WHITE, bold — v4's TITLE decoration, and + # what every existing deployment already renders. + # light grey 236 #303030 — ~13:1 on white, the mirror + # image of bold-white-on-dark. A cube index, so no + # terminal theme can redefine it. + if theme == "light": + header_color = 236 if colors >= 256 else curses.COLOR_BLACK + else: + header_color = curses.COLOR_WHITE # Foreground pairs (used by default for coloured text on the - # terminal's native background). + # terminal's native background). These four keep the terminal's own + # ANSI palette on purpose: they paint on the terminal's background, + # and a theme calibrates its palette to be readable on it. pairs = [ (ColorRole.OK, curses.COLOR_GREEN), (ColorRole.CAREFUL, curses.COLOR_BLUE), (ColorRole.WARNING, curses.COLOR_MAGENTA), (ColorRole.CRITICAL, curses.COLOR_RED), - # v4 fidelity: plugin titles use the TITLE decoration which is - # bold + default-text (white-ish on dark terminals). - (ColorRole.HEADER, curses.COLOR_WHITE), + (ColorRole.HEADER, header_color), ] for i, (role, color) in enumerate(pairs, start=1): try: @@ -1088,20 +1117,45 @@ def _init_colors() -> None: _COLOR_PAIRS[role] = curses.color_pair(i) _COLOR_PAIRS[ColorRole.DEFAULT] = curses.color_pair(0) - # Reverse pairs — explicit "white fg on coloured bg" (v4 parity). - # Using A_REVERSE alone would inherit the terminal's default - # foreground for the swapped fg, often a mid-gray that is hard to - # read on a magenta/red background. Defining a separate pair - # guarantees a white foreground. - reverse_pairs = [ - (ColorRole.OK, curses.COLOR_GREEN), - (ColorRole.CAREFUL, curses.COLOR_BLUE), - (ColorRole.WARNING, curses.COLOR_MAGENTA), - (ColorRole.CRITICAL, curses.COLOR_RED), - ] - for j, (role, bg) in enumerate(reverse_pairs, start=len(pairs) + 1): + # Reverse pairs — the filled "badge" used by prominent cells (v4 + # ``*_LOG`` decoration parity). A_REVERSE alone would inherit the + # terminal's default foreground, so each pair is defined explicitly. + # + # The badge paints its own background, so it must NOT rely on ANSI + # colours 0-15: themes (Catppuccin, Solarized, Gruvbox, Tokyo Night…) + # redefine those, and their luminance is unknowable without querying + # the terminal. Under Catppuccin Mocha, ANSI "red" is #f38ba8 — a + # light pink — so white-on-red collapses to ~2:1. + # + # Cube indices 16-255 are fixed by the spec and left alone by those + # themes. Painting the badge in absolute colours makes its contrast + # deterministic whatever the theme AND whatever the terminal + # background: black on a light cube colour, >= 11:1 on every level. + # Foreground 16 (not 0) is deliberate — A_BOLD brightens colours 0-7 + # on many terminals, which would turn black into mid-gray. + if colors >= 256: + reverse_pairs = [ + (ColorRole.OK, 16, 120), # #87ffaf ~16.8:1 + (ColorRole.CAREFUL, 16, 117), # #87d7ff ~13.6:1 + (ColorRole.WARNING, 16, 183), # #d7afff ~11.4:1 + (ColorRole.CRITICAL, 16, 210), # #ffafaf ~12.0:1 + ] + else: + # No absolute palette available. Best effort against the DEFAULT + # xterm luminance: green is its only light background. On a + # 16-colour terminal running a themed palette this stays + # imperfect — that case needs an OSC 4 query we deliberately + # avoid (fragile under tmux, and a startup risk). + white = 15 if colors >= 16 else curses.COLOR_WHITE + reverse_pairs = [ + (ColorRole.OK, curses.COLOR_BLACK, curses.COLOR_GREEN), + (ColorRole.CAREFUL, white, curses.COLOR_BLUE), + (ColorRole.WARNING, white, curses.COLOR_MAGENTA), + (ColorRole.CRITICAL, white, curses.COLOR_RED), + ] + for j, (role, fg, bg) in enumerate(reverse_pairs, start=len(pairs) + 1): try: - curses.init_pair(j, curses.COLOR_WHITE, bg) + curses.init_pair(j, fg, bg) except curses.error: # Terminal can't allocate the pair (very limited palettes). # Fall back to plain reverse so we don't crash. @@ -1128,6 +1182,10 @@ def _attr_for(cell: Cell) -> int: attr = _COLOR_PAIRS_REVERSE.get(cell.color) if attr is None: attr = _COLOR_PAIRS.get(cell.color, 0) | curses.A_REVERSE + # Bold the badge: on an 8-colour terminal this is what promotes the + # light-gray foreground to true white; elsewhere it just thickens the + # glyphs, which helps on a saturated background either way. + attr |= curses.A_BOLD else: attr = _COLOR_PAIRS.get(cell.color, 0) diff --git a/glances/plugins/containers/render_curses_v5.py b/glances/plugins/containers/render_curses_v5.py index e2d999de..7f6c23b4 100644 --- a/glances/plugins/containers/render_curses_v5.py +++ b/glances/plugins/containers/render_curses_v5.py @@ -19,7 +19,7 @@ from __future__ import annotations from typing import Any from glances.globals import auto_unit -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Header → the GLOBAL process sort key (view["sort_key"], dynamic/auto-resolved), # processlist-aligned. MEM maps to memory_percent because the process sort-key @@ -59,18 +59,18 @@ def _header_cell( def _build_header_row( - disable: set[str], *, show_engine: bool, show_pod: bool, name_w: int, sort_key: str | None, title_color: ColorRole + disable: set[str], *, show_engine: bool, show_pod: bool, name_w: int, sort_key: str | None ) -> Row: def hdr(label: str, width: int, *, ljust: bool = False, color: ColorRole = ColorRole.HEADER) -> Cell: return _header_cell(label, width, ljust=ljust, color=color, sort_key=sort_key) h: list[Cell] = [] if show_engine: - h.append(hdr("Engine", 6, ljust=True, color=title_color)) + h.append(hdr("Engine", 6, ljust=True)) if show_pod: h.append(hdr("Pod", 12, ljust=True)) if "name" not in disable: - h.append(hdr("CONTAINER", name_w, ljust=True, color=title_color if not show_engine else ColorRole.HEADER)) + h.append(hdr("CONTAINER", name_w, ljust=True)) if "status" not in disable: h.append(hdr("Status", 10)) if "uptime" not in disable: @@ -187,11 +187,7 @@ def render( show_engine = len({i.get("engine") for i in items}) > 1 show_pod = any(i.get("pod_name") for i in items) - title_color = title_role(payload) - - header = _build_header_row( - disable, show_engine=show_engine, show_pod=show_pod, name_w=name_w, sort_key=sort_key, title_color=title_color - ) + header = _build_header_row(disable, show_engine=show_engine, show_pod=show_pod, name_w=name_w, sort_key=sort_key) rows: list[Row] = [header] rows.extend( _build_data_row( diff --git a/glances/plugins/cpu/render_curses_v5.py b/glances/plugins/cpu/render_curses_v5.py index df53add0..5b5d32eb 100644 --- a/glances/plugins/cpu/render_curses_v5.py +++ b/glances/plugins/cpu/render_curses_v5.py @@ -33,7 +33,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label def _stat_cells(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]], key: str, label: str) -> list[Cell]: @@ -149,7 +149,7 @@ def render( # (HEADER/white when nothing is escalated, careful/warning/critical # colour otherwise — always bold). line1_cells: list[Cell] = [ - Cell(text="CPU", color=title_role(payload), bold=True), + Cell(text="CPU", color=ColorRole.HEADER, bold=True), _cell_for_field("total", payload.get("total"), fields_desc.get("total", {}), payload), ] # Line-1 col-2 pair (idle) — only when column 2 survives. diff --git a/glances/plugins/diskio/render_curses_v5.py b/glances/plugins/diskio/render_curses_v5.py index b0a68c0b..8a339569 100644 --- a/glances/plugins/diskio/render_curses_v5.py +++ b/glances/plugins/diskio/render_curses_v5.py @@ -32,7 +32,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Block width capped at the v5 left-sidebar maximum (34 chars). # name (_NAME_MAX_WIDTH) + 1 + rx (7) + 1 + wx (7) = name + 16 @@ -81,7 +81,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l """Render the diskio plugin's TUI block — mirrors v4 ``diskio.msg_curse``.""" header_row = Row( cells=[ - Cell(text="DISK I/O".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True), + Cell(text="DISK I/O".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="R/s".rjust(_RATE_COL_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="W/s".rjust(_RATE_COL_WIDTH), color=ColorRole.HEADER, bold=True), ] diff --git a/glances/plugins/fs/render_curses_v5.py b/glances/plugins/fs/render_curses_v5.py index 61cc0560..097bba61 100644 --- a/glances/plugins/fs/render_curses_v5.py +++ b/glances/plugins/fs/render_curses_v5.py @@ -32,7 +32,7 @@ from __future__ import annotations from typing import Any from glances.outputs.curses_formatters_v5 import format_value -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Block width capped at the v5 left-sidebar maximum (34 chars). # name (_NAME_MAX_WIDTH) + 1 + used (7) + 1 + total (7) = name + 16 @@ -71,7 +71,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l """Render the fs plugin's TUI block — mirrors v4 ``fs.msg_curse``.""" header_row = Row( cells=[ - Cell(text="FILE SYS".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True), + Cell(text="FILE SYS".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Used".rjust(_USED_COL_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Total".rjust(_TOTAL_COL_WIDTH), color=ColorRole.HEADER, bold=True), ] diff --git a/glances/plugins/gpu/render_curses_v5.py b/glances/plugins/gpu/render_curses_v5.py index ecc0f252..8d517344 100644 --- a/glances/plugins/gpu/render_curses_v5.py +++ b/glances/plugins/gpu/render_curses_v5.py @@ -23,7 +23,7 @@ from __future__ import annotations from typing import Any from glances.globals import to_fahrenheit -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row _HEADER_MAX = 17 @@ -109,7 +109,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | Non levels = payload.get("_levels") if isinstance(payload.get("_levels"), dict) else {} view = view or {} - header = Row(cells=[Cell(text=_build_header(cards), color=title_role(payload), bold=True)]) + header = Row(cells=[Cell(text=_build_header(cards), color=ColorRole.HEADER, bold=True)]) rows: list[Row] = [header] if len(cards) == 1 or view.get("meangpu"): diff --git a/glances/plugins/load/render_curses_v5.py b/glances/plugins/load/render_curses_v5.py index b97098c4..6f961c03 100644 --- a/glances/plugins/load/render_curses_v5.py +++ b/glances/plugins/load/render_curses_v5.py @@ -39,7 +39,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Fixed widths. _LOAD_LABEL_WIDTH = 6 # "15 min" = 6 chars @@ -80,7 +80,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l # and produced a visible 1-char overhang. # Title colour reflects the worst prominent alert level in the payload. header_cells: list[Cell] = [ - Cell(text="LOAD".ljust(_LOAD_LABEL_WIDTH), color=title_role(payload), bold=True), + Cell(text="LOAD".ljust(_LOAD_LABEL_WIDTH), color=ColorRole.HEADER, bold=True), ] cores = payload.get("cpucore") if isinstance(cores, (int, float)) and cores > 0: diff --git a/glances/plugins/mem/render_curses_v5.py b/glances/plugins/mem/render_curses_v5.py index 14ec88fa..2d596e7d 100644 --- a/glances/plugins/mem/render_curses_v5.py +++ b/glances/plugins/mem/render_curses_v5.py @@ -32,7 +32,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label # Value cells need a stable floor because their content can shrink # cycle-to-cycle (e.g. "5.0%" vs "100.0%"). Label cells don't — labels @@ -102,7 +102,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]], view # Line 1: title + percent (+ active pair only when col-2 survives). # Title colour reflects the worst prominent alert level in the payload. line1: list[Cell] = [ - Cell(text="MEM", color=title_role(payload), bold=True), + Cell(text="MEM", color=ColorRole.HEADER, bold=True), _cell_for_field("percent", payload.get("percent"), fields_desc.get("percent", {}), payload), ] if n_cols >= 2: diff --git a/glances/plugins/memswap/render_curses_v5.py b/glances/plugins/memswap/render_curses_v5.py index ee5bb377..dc455032 100644 --- a/glances/plugins/memswap/render_curses_v5.py +++ b/glances/plugins/memswap/render_curses_v5.py @@ -37,7 +37,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, _cell_for_field, field_label # Stable floor for the value column. ``mem`` and ``memswap`` both deal # with bytes / percent — same minimum width keeps the SWAP and MEM @@ -87,7 +87,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l # Line 1: title + percent. line1: list[Cell] = [ - Cell(text="SWAP", color=title_role(payload), bold=True), + Cell(text="SWAP", color=ColorRole.HEADER, bold=True), _cell_for_field("percent", payload.get("percent"), fields_desc.get("percent", {}), payload), ] diff --git a/glances/plugins/network/render_curses_v5.py b/glances/plugins/network/render_curses_v5.py index 4c5e2bc6..fbd3d10c 100644 --- a/glances/plugins/network/render_curses_v5.py +++ b/glances/plugins/network/render_curses_v5.py @@ -37,7 +37,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Hardcoded for G1 — must match the v5 left sidebar max width. # Painter caps the sidebar at 34 chars (`_left_sidebar_max_width=34`, @@ -91,7 +91,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l """Render the network plugin's TUI block — mirrors v4 ``network.msg_curse``.""" header_row = Row( cells=[ - Cell(text="NETWORK".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True), + Cell(text="NETWORK".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Rx/s".rjust(_RATE_COL_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Tx/s".rjust(_RATE_COL_WIDTH), color=ColorRole.HEADER, bold=True), ] diff --git a/glances/plugins/npu/render_curses_v5.py b/glances/plugins/npu/render_curses_v5.py index 97413ce5..643819f1 100644 --- a/glances/plugins/npu/render_curses_v5.py +++ b/glances/plugins/npu/render_curses_v5.py @@ -21,7 +21,7 @@ from __future__ import annotations from typing import Any from glances.globals import to_fahrenheit -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row _HEADER_MAX = 17 _RANGE_WIDTH = 14 @@ -64,7 +64,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | Non npu_id = npu.get("npu_id") rows: list[Row] = [ - Row(cells=[Cell(text=str(npu.get("name") or "NPU")[:_HEADER_MAX], color=title_role(payload), bold=True)]) + Row(cells=[Cell(text=str(npu.get("name") or "NPU")[:_HEADER_MAX], color=ColorRole.HEADER, bold=True)]) ] # Row 2: load% (or freq% fallback) + right-justified current/max freq range. diff --git a/glances/plugins/percpu/render_curses_v5.py b/glances/plugins/percpu/render_curses_v5.py index 22237e5b..91a9f187 100644 --- a/glances/plugins/percpu/render_curses_v5.py +++ b/glances/plugins/percpu/render_curses_v5.py @@ -43,7 +43,7 @@ from __future__ import annotations import sys from typing import Any -from glances.outputs.curses_renderer_v5 import Cell, Row, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row # v4 fidelity: top-N cores shown, the rest collapsed into a CPU* row. _DEFAULT_MAX_CPU_DISPLAY = 4 @@ -123,7 +123,7 @@ def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]]) -> l # Header row: "CPU" title + column labels. header_cells: list[Cell] = [ - Cell(text="CPU".ljust(_LABEL_WIDTH), color=title_role(payload), bold=True), + Cell(text="CPU".ljust(_LABEL_WIDTH), color=ColorRole.HEADER, bold=True), ] for stat in headers: header_cells.append(_header_cell(stat)) diff --git a/glances/plugins/processlist/render_curses_v5.py b/glances/plugins/processlist/render_curses_v5.py index 3b967265..9310c181 100644 --- a/glances/plugins/processlist/render_curses_v5.py +++ b/glances/plugins/processlist/render_curses_v5.py @@ -47,7 +47,7 @@ from __future__ import annotations import os from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row # Column widths (v4 parity — see ``processlist.layout_header``/``layout_stat``). _W_CPU = 5 @@ -94,10 +94,19 @@ _HEADER_SORT_KEY: dict[str, str] = { def _format_percent(value: Any, width: int) -> str: + """Percent with one decimal, dropped at >= 1000 so the column stays ``width``. + + A process spread over many cores reads e.g. ``1384.7`` — 6 characters in a + 5-wide column, which shifts every column to its right. Above 1000 the + decimal carries no useful signal, so it is dropped: ``1384``. Mirrors v4 + ``layout_stat['cpu_no_digit']`` (v4 switches at 100 for its 6-wide column). + """ try: - return f"{float(value):>{width}.1f}" + fvalue = float(value) except (TypeError, ValueError): return "?".rjust(width) + precision = 0 if abs(fvalue) >= 1000 else 1 + return f"{fvalue:>{width}.{precision}f}" def _format_int(value: Any, width: int, *, signed: bool = False) -> str: @@ -368,8 +377,6 @@ def render( raw_levels = payload.get("_levels") if isinstance(payload, dict) else None levels_index = raw_levels if isinstance(raw_levels, dict) else {} - title_color = title_role(payload) if items else ColorRole.HEADER - # Responsive columns: which of the 12 fixed columns are visible. Absent # ``proclist_width`` (export / tests / non-int) → keep all 12 (byte-identical # to the historical output, locked by ``test_no_width_keeps_all_columns``). @@ -381,7 +388,7 @@ def render( return [cell for cell, key in zip(cells, _FIXED_COL_KEYS) if key in active_set] header_fixed = [ - _header("CPU%", _W_CPU, color=title_color), + _header("CPU%", _W_CPU), _header("MEM%", _W_MEM), _header("VIRT", _W_VIRT), _header("RES", _W_RES), diff --git a/glances/plugins/programlist/render_curses_v5.py b/glances/plugins/programlist/render_curses_v5.py index ca15259a..695681e3 100644 --- a/glances/plugins/programlist/render_curses_v5.py +++ b/glances/plugins/programlist/render_curses_v5.py @@ -28,7 +28,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row from glances.plugins.processlist.render_curses_v5 import ( _HEADER_SORT_KEY, _W_CPU, @@ -89,10 +89,8 @@ def render( raw_levels = payload.get("_levels") if isinstance(payload, dict) else None levels_index = raw_levels if isinstance(raw_levels, dict) else {} - title_color = title_role(payload) if items else ColorRole.HEADER - header_cells = [ - _header("CPU%", _W_CPU, color=title_color), + _header("CPU%", _W_CPU), _header("MEM%", _W_MEM), _header("VIRT", _W_VIRT), _header("RES", _W_RES), diff --git a/glances/plugins/raid/render_curses_v5.py b/glances/plugins/raid/render_curses_v5.py index cab955ba..44997101 100644 --- a/glances/plugins/raid/render_curses_v5.py +++ b/glances/plugins/raid/render_curses_v5.py @@ -34,7 +34,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row _NAME_MAX_WIDTH = 18 _USED_COL_WIDTH = 7 @@ -65,7 +65,7 @@ def _status_role(levels: dict[str, Any], name: str) -> tuple[ColorRole, bool]: def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | None = None, view=None) -> list[Row]: header = Row( cells=[ - Cell(text="RAID disks".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True), + Cell(text="RAID disks".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Used".rjust(_USED_COL_WIDTH), color=ColorRole.HEADER, bold=True), Cell(text="Avail".rjust(_AVAIL_COL_WIDTH), color=ColorRole.HEADER, bold=True), ] diff --git a/glances/plugins/sensors/render_curses_v5.py b/glances/plugins/sensors/render_curses_v5.py index 05e4074f..35fb8b45 100644 --- a/glances/plugins/sensors/render_curses_v5.py +++ b/glances/plugins/sensors/render_curses_v5.py @@ -36,7 +36,7 @@ from __future__ import annotations from typing import Any from glances.globals import to_fahrenheit -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row from glances.outputs.glances_unicode import unicode_message _NAME_MAX_WIDTH = 19 @@ -94,7 +94,7 @@ def _value_text(row: dict[str, Any], fahrenheit: bool) -> str: def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | None = None, view=None) -> list[Row]: - header = Row(cells=[Cell(text="SENSORS".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True)]) + header = Row(cells=[Cell(text="SENSORS".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True)]) rows: list[Row] = [header] if not isinstance(payload, dict): diff --git a/glances/plugins/smart/render_curses_v5.py b/glances/plugins/smart/render_curses_v5.py index b538c2e3..7fa97510 100644 --- a/glances/plugins/smart/render_curses_v5.py +++ b/glances/plugins/smart/render_curses_v5.py @@ -28,7 +28,7 @@ from __future__ import annotations from typing import Any from glances.globals import auto_unit -from glances.outputs.curses_renderer_v5 import Cell, Row, title_role +from glances.outputs.curses_renderer_v5 import Cell, ColorRole, Row from glances.plugins.smart import LARGE_VALUE_KEYS _NAME_COL_WIDTH = 25 @@ -58,7 +58,7 @@ def _attr_value_text(attr: dict[str, Any]) -> str: def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | None = None, view=None) -> list[Row]: - header = Row(cells=[Cell(text="SMART disks".ljust(_NAME_COL_WIDTH), color=title_role(payload), bold=True)]) + header = Row(cells=[Cell(text="SMART disks".ljust(_NAME_COL_WIDTH), color=ColorRole.HEADER, bold=True)]) rows: list[Row] = [header] if not isinstance(payload, dict): diff --git a/glances/plugins/wifi/render_curses_v5.py b/glances/plugins/wifi/render_curses_v5.py index 2a48b4c9..d09c8f99 100644 --- a/glances/plugins/wifi/render_curses_v5.py +++ b/glances/plugins/wifi/render_curses_v5.py @@ -30,7 +30,7 @@ from __future__ import annotations from typing import Any -from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row, title_role +from glances.outputs.curses_renderer_v5 import _LEVEL_TO_ROLE, Cell, ColorRole, Row _NAME_MAX_WIDTH = 26 _VALUE_COL_WIDTH = 7 @@ -57,8 +57,8 @@ def _level_role(levels: dict[str, Any], ssid: str) -> tuple[ColorRole, bool]: def render(payload: dict[str, Any], fields_desc: dict[str, dict[str, Any]] | None = None, view=None) -> list[Row]: header = Row( cells=[ - Cell(text="WIFI".ljust(_NAME_MAX_WIDTH), color=title_role(payload), bold=True), - Cell(text="dBm".rjust(_VALUE_COL_WIDTH), color=title_role(payload), bold=True), + Cell(text="WIFI".ljust(_NAME_MAX_WIDTH), color=ColorRole.HEADER, bold=True), + Cell(text="dBm".rjust(_VALUE_COL_WIDTH), color=ColorRole.HEADER, bold=True), ] ) rows: list[Row] = [header] diff --git a/tests/test_curses_renderer_v5.py b/tests/test_curses_renderer_v5.py index 9f5f125f..ca8a4d87 100644 --- a/tests/test_curses_renderer_v5.py +++ b/tests/test_curses_renderer_v5.py @@ -979,82 +979,6 @@ def test_build_frame_returns_a_frame_instance(): assert isinstance(frame, Frame) -# --------------------------------------------------------------- dynamic title role - - -def test_title_role_returns_header_when_no_prominent_escalation(): - from glances.outputs.curses_renderer_v5 import title_role - - payload = { - "percent": 30.0, - "_levels": {"percent": {"level": "ok", "prominent": True}}, - } - assert title_role(payload) == ColorRole.HEADER - - -def test_title_role_stays_header_when_prominent_at_careful(): - """careful does NOT escalate the title — only warning/critical do.""" - from glances.outputs.curses_renderer_v5 import title_role - - payload = {"_levels": {"percent": {"level": "careful", "prominent": True}}} - assert title_role(payload) == ColorRole.HEADER - - -def test_title_role_returns_warning_when_prominent_at_warning(): - from glances.outputs.curses_renderer_v5 import title_role - - payload = {"_levels": {"percent": {"level": "warning", "prominent": True}}} - assert title_role(payload) == ColorRole.WARNING - - -def test_title_role_returns_critical_for_worst_level(): - """Multiple prominent fields — the worst level wins.""" - from glances.outputs.curses_renderer_v5 import title_role - - payload = { - "_levels": { - "percent": {"level": "warning", "prominent": True}, - "steal": {"level": "critical", "prominent": True}, - "iowait": {"level": "careful", "prominent": True}, - }, - } - assert title_role(payload) == ColorRole.CRITICAL - - -def test_title_role_ignores_non_prominent_escalations(): - """A non-prominent field at critical doesn't promote the title color.""" - from glances.outputs.curses_renderer_v5 import title_role - - payload = { - "_levels": { - "irq": {"level": "critical", "prominent": False}, - "percent": {"level": "ok", "prominent": True}, - }, - } - assert title_role(payload) == ColorRole.HEADER - - -def test_title_role_handles_collection_levels(): - """Collection plugins use a nested `_levels`: {pk: {field: {level, prominent}}}.""" - from glances.outputs.curses_renderer_v5 import title_role - - payload = { - "_levels": { - "eth0": { - "bytes_recv": {"level": "warning", "prominent": True}, - }, - "lo": {"bytes_recv": {"level": "ok", "prominent": True}}, - }, - } - assert title_role(payload) == ColorRole.WARNING - - -def test_title_role_handles_empty_payload(): - from glances.outputs.curses_renderer_v5 import title_role - - assert title_role({}) == ColorRole.HEADER - - def test_cell_supports_bold_flag(): """Cell carries an explicit `bold` field for non-HEADER colour cells that should still render bold (e.g. alert-coloured plugin titles).""" diff --git a/tests/test_curses_v5.py b/tests/test_curses_v5.py index 910a1312..f596724b 100644 --- a/tests/test_curses_v5.py +++ b/tests/test_curses_v5.py @@ -1045,8 +1045,11 @@ def test_tui_v5_help_shows_color_binding(fake_store, fake_alerts, fake_config): flat = " ".join(str(call) for call in fake_stdscr.addstr.call_args_list) assert "Color binding:" in flat - for sample in ("OK", "CAREFUL", "WARNING", "CRITICAL", "Title", "Sort", "Alert"): + for sample in ("OK", "CAREFUL", "WARNING", "CRITICAL", "Title", "Sort"): assert sample in flat + # The four severities appear twice: plain, then as the highlighted badge. + assert flat.count("'CRITICAL'") == 2 + assert "an event is ongoing" in flat def test_tui_v5_help_color_rows_use_real_attributes(fake_store, fake_alerts, fake_config): @@ -1056,13 +1059,29 @@ def test_tui_v5_help_color_rows_use_real_attributes(fake_store, fake_alerts, fak from glances.outputs.curses_renderer_v5 import ColorRole tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config) - levels, decorations = tui._help_color_rows() + levels, prominent, decorations = tui._help_color_rows() by_text = {c.text: c for c in levels.cells} assert by_text["OK"].color is ColorRole.OK assert by_text["CRITICAL"].color is ColorRole.CRITICAL + # The plain severity row must NOT be highlighted — it is the counterpart + # the `prominent` row is meant to contrast with. + assert all(c.prominent is False for c in levels.cells) deco = {c.text: c for c in decorations.cells} assert deco["Sort"].underline is True - assert deco["Alert"].prominent is True + + +def test_tui_v5_help_documents_the_prominent_badge(fake_store, fake_alerts, fake_config): + """The legend carries a row showing every severity as a highlighted badge, + so the `prominent` decoration is discoverable rather than folk knowledge.""" + from glances.outputs import glances_curses_v5 as tui_mod + from glances.outputs.curses_renderer_v5 import ColorRole + + tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config) + _, prominent, _ = tui._help_color_rows() + samples = {c.text: c for c in prominent.cells if c.prominent} + assert set(samples) == {"OK", "CAREFUL", "WARNING", "CRITICAL"} + assert samples["OK"].color is ColorRole.OK + assert samples["CRITICAL"].color is ColorRole.CRITICAL def test_tui_v5_paint_help_clamps_scroll_and_shows_footer(fake_store, fake_alerts, fake_config): @@ -1760,3 +1779,146 @@ def test_fit_header_progressively_degrades(fake_store, fake_alerts, fake_config) # Level 3 — uptime dropped too (only the hostname survives). f3 = tui._build_fitted_frame(w_sys_short) assert [b.name for b in f3.header] == ["system"] + + +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.""" + import curses + + from glances.outputs.curses_renderer_v5 import Cell, ColorRole + from glances.outputs.glances_curses_v5 import _attr_for + + cell = Cell(text="92.0", color=ColorRole.CRITICAL, prominent=True) + assert _attr_for(cell) & curses.A_BOLD + + +def _badge_pairs(monkeypatch, colors: int) -> dict: + """Run `_init_colors` against a fake curses and return {role: (fg, bg)}.""" + import curses + + calls: dict[int, tuple[int, int]] = {} + monkeypatch.setattr(curses, "has_colors", lambda: True) + monkeypatch.setattr(curses, "start_color", lambda: None) + monkeypatch.setattr(curses, "use_default_colors", lambda: None) + monkeypatch.setattr(curses, "COLORS", colors, raising=False) + monkeypatch.setattr(curses, "init_pair", lambda n, fg, bg: calls.__setitem__(n, (fg, bg))) + monkeypatch.setattr(curses, "color_pair", lambda n: n) + monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS", {}) + monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS_REVERSE", {}) + + from glances.outputs.glances_curses_v5 import _init_colors + + _init_colors() + + from glances.outputs.glances_curses_v5 import _COLOR_PAIRS_REVERSE as reverse + + return {role: calls[pair] for role, pair in reverse.items()} + + +def test_init_colors_badge_uses_theme_proof_cube_colours(monkeypatch): + """The badge must paint itself in ABSOLUTE cube colours (>= 16). + + Themes redefine ANSI 0-15, so a badge built on them has unknowable + contrast: under Catppuccin Mocha, ANSI red is the light pink #f38ba8 and + white-on-red collapses to ~2:1. Indices 16-255 are spec-fixed, keeping the + badge >= 11:1 whatever the theme. Foreground 16 rather than 0 because + A_BOLD brightens 0-7 into mid-gray on many terminals. + """ + from glances.outputs.curses_renderer_v5 import ColorRole + + fg_bg = _badge_pairs(monkeypatch, 256) + + assert fg_bg == { + ColorRole.OK: (16, 120), + ColorRole.CAREFUL: (16, 117), + ColorRole.WARNING: (16, 183), + ColorRole.CRITICAL: (16, 210), + } + assert all(bg >= 16 for _, bg in fg_bg.values()) # never a themed ANSI slot + + +def test_init_colors_badge_falls_back_on_16_colour_terminals(monkeypatch): + """Below 256 colours there is no absolute palette — fall back to ANSI + without crashing, picking the foreground by DEFAULT xterm luminance.""" + import curses + + from glances.outputs.curses_renderer_v5 import ColorRole + + fg_bg = _badge_pairs(monkeypatch, 16) + + # Green is the only light background in the default palette. + assert fg_bg[ColorRole.OK] == (curses.COLOR_BLACK, curses.COLOR_GREEN) + # The dark ones get true white (15), not the light-gray colour 7. + assert fg_bg[ColorRole.CRITICAL] == (15, curses.COLOR_RED) + + +def _header_color(monkeypatch, colors: int, theme: str = "dark") -> int: + """Run `_init_colors` against a fake curses and return the HEADER fg.""" + import curses + + from glances.outputs.curses_renderer_v5 import ColorRole + + calls: dict[int, tuple[int, int]] = {} + monkeypatch.setattr(curses, "has_colors", lambda: True) + monkeypatch.setattr(curses, "start_color", lambda: None) + monkeypatch.setattr(curses, "use_default_colors", lambda: None) + monkeypatch.setattr(curses, "COLORS", colors, raising=False) + monkeypatch.setattr(curses, "init_pair", lambda n, fg, bg: calls.__setitem__(n, (fg, bg))) + monkeypatch.setattr(curses, "color_pair", lambda n: n) + monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS", {}) + monkeypatch.setattr("glances.outputs.glances_curses_v5._COLOR_PAIRS_REVERSE", {}) + + from glances.outputs.glances_curses_v5 import _init_colors + + _init_colors(theme) + + from glances.outputs.glances_curses_v5 import _COLOR_PAIRS as fg_pairs + + return calls[fg_pairs[ColorRole.HEADER]][0] + + +def test_init_colors_header_default_theme_is_unchanged(monkeypatch): + """The default MUST stay v4's bold white — every existing deployment + renders it, and `theme` exists to add an option, not to move the default.""" + import curses + + assert _header_color(monkeypatch, 256) == curses.COLOR_WHITE + assert _header_color(monkeypatch, 16) == curses.COLOR_WHITE + + +def test_init_colors_header_light_theme_is_dark_grey(monkeypatch): + """`theme=light` mirrors the default for a white background: ~13:1 instead + of the ~1.2:1 bold white lands at. Cube index, so no theme redefines it.""" + import curses + + assert _header_color(monkeypatch, 256, "light") == 236 # #303030 + assert _header_color(monkeypatch, 256, "light") >= 16 # never a themable ANSI slot + assert _header_color(monkeypatch, 16, "light") == curses.COLOR_BLACK # fallback + + +def test_init_colors_header_unknown_theme_falls_back_to_dark(monkeypatch): + """An unrecognised `theme=` value must not break the TUI — treat it as dark.""" + import curses + + assert _header_color(monkeypatch, 256, "solarized-mocha") == curses.COLOR_WHITE + + +def test_tui_v5_reads_theme_from_config(fake_store, fake_alerts): + """`[outputs] theme` reaches `_init_colors`, normalised (case/whitespace).""" + from unittest.mock import MagicMock + + from glances.outputs import glances_curses_v5 as tui_mod + + cfg = MagicMock() + cfg.get.side_effect = lambda section, key, default=None: " LIGHT " if key == "theme" else default + tui = _make_tui(tui_mod, fake_store, fake_alerts, cfg) + assert tui._theme == "light" + + +def test_tui_v5_theme_defaults_to_dark(fake_store, fake_alerts, fake_config): + """No config key → dark, so an unconfigured user keeps the majority case.""" + from glances.outputs import glances_curses_v5 as tui_mod + + tui = _make_tui(tui_mod, fake_store, fake_alerts, fake_config) + assert tui._theme == "dark" diff --git a/tests/test_plugin_fs_render_curses_v5.py b/tests/test_plugin_fs_render_curses_v5.py index 498e7f37..9e34dcf0 100644 --- a/tests/test_plugin_fs_render_curses_v5.py +++ b/tests/test_plugin_fs_render_curses_v5.py @@ -160,11 +160,12 @@ def test_render_title_role_header_when_no_prominent_alert(fs_payload, fs_fields) assert title.bold is True -def test_render_title_role_escalates_on_critical(fs_fields): +def test_render_title_never_escalates_on_critical(fs_fields): + """v4 parity: the title is always TITLE/HEADER — only the VALUE carries the alert.""" payload = { "data": [{"mnt_point": "/", "size": 100, "used": 95, "free": 5, "percent": 95.0}], "_levels": {"/": {"percent": {"level": "critical", "prominent": True}}}, } rows = render(payload, fs_fields) - assert rows[0].cells[0].color == ColorRole.CRITICAL + assert rows[0].cells[0].color == ColorRole.HEADER assert rows[0].cells[0].bold is True diff --git a/tests/test_plugin_mem_render_curses_v5.py b/tests/test_plugin_mem_render_curses_v5.py index 98c6afb4..16fa00fe 100644 --- a/tests/test_plugin_mem_render_curses_v5.py +++ b/tests/test_plugin_mem_render_curses_v5.py @@ -190,29 +190,18 @@ def test_render_title_role_default_when_ok(mem_fields): assert rows[0].cells[0].bold is True -def test_render_title_role_warning_when_percent_warning(mem_fields): - """percent at WARNING (prominent) → title coloured WARNING + bold.""" - payload = { - "total": 1024, - "percent": 80.0, - "active": 256, - "_levels": {"percent": {"level": "warning", "prominent": True}}, - } - rows = render(payload, mem_fields) - assert rows[0].cells[0].color == ColorRole.WARNING - assert rows[0].cells[0].bold is True - - -def test_render_title_role_critical_when_percent_critical(mem_fields): - """percent at CRITICAL → title red + bold.""" +@pytest.mark.parametrize("level", ["warning", "critical"]) +def test_render_title_never_escalates(mem_fields, level): + """v4 parity: the MEM title is always HEADER — only the VALUE carries the alert.""" payload = { "total": 1024, "percent": 95.0, "active": 256, - "_levels": {"percent": {"level": "critical", "prominent": True}}, + "_levels": {"percent": {"level": level, "prominent": True}}, } rows = render(payload, mem_fields) - assert rows[0].cells[0].color == ColorRole.CRITICAL + assert rows[0].cells[0].color == ColorRole.HEADER + assert rows[0].cells[0].bold is True def test_render_pulls_short_name_from_schema(mem_payload_linux): diff --git a/tests/test_plugin_memswap_render_curses_v5.py b/tests/test_plugin_memswap_render_curses_v5.py index 10764792..06599ee0 100644 --- a/tests/test_plugin_memswap_render_curses_v5.py +++ b/tests/test_plugin_memswap_render_curses_v5.py @@ -190,7 +190,8 @@ def test_render_title_role_default_when_ok(memswap_payload, memswap_fields): assert rows[0].cells[0].bold is True -def test_render_title_role_warning_escalates(memswap_fields): +def test_render_title_never_escalates(memswap_fields): + """v4 parity: the SWAP title is always HEADER — only the VALUE carries the alert.""" payload = { "total": 1024, "used": 800, @@ -199,7 +200,7 @@ def test_render_title_role_warning_escalates(memswap_fields): "_levels": {"percent": {"level": "warning", "prominent": True}}, } rows = render(payload, memswap_fields) - assert rows[0].cells[0].color == ColorRole.WARNING + assert rows[0].cells[0].color == ColorRole.HEADER assert rows[0].cells[0].bold is True diff --git a/tests/test_plugin_network_render_curses_v5.py b/tests/test_plugin_network_render_curses_v5.py index 2178c45f..f43b7de6 100644 --- a/tests/test_plugin_network_render_curses_v5.py +++ b/tests/test_plugin_network_render_curses_v5.py @@ -253,8 +253,8 @@ def test_render_title_role_header_when_no_prominent_alert(network_payload, netwo assert title.bold is True -def test_render_title_role_escalates_on_critical(network_fields): - """A critical+prominent rate anywhere bumps the title color.""" +def test_render_title_never_escalates_on_critical(network_fields): + """v4 parity: the title is always TITLE/HEADER — only the VALUE carries the alert.""" payload = { "data": [ {"interface_name": "eth0", "bytes_recv": 9e6, "bytes_sent": 1e6, "is_up": True}, @@ -264,5 +264,5 @@ def test_render_title_role_escalates_on_critical(network_fields): }, } rows = render(payload, network_fields) - assert rows[0].cells[0].color == ColorRole.CRITICAL + assert rows[0].cells[0].color == ColorRole.HEADER assert rows[0].cells[0].bold is True diff --git a/tests/test_plugin_processlist_render_curses_v5.py b/tests/test_plugin_processlist_render_curses_v5.py index 1c094641..89ad5702 100644 --- a/tests/test_plugin_processlist_render_curses_v5.py +++ b/tests/test_plugin_processlist_render_curses_v5.py @@ -530,3 +530,20 @@ def test_header_and_rows_drop_consistently(fields): ncols = len(rows[0].cells) for r in rows[1:]: assert len(r.cells) == ncols # every data row matches the header column count + + +@pytest.mark.parametrize( + ("cpu_percent", "expected"), + [ + (12.3, " 12.3"), # < 1000 → one decimal, as before + (999.9, "999.9"), # last value that still fits with a decimal + (1384.7, " 1385"), # >= 1000 → integer form (rounded, as v4), column stays 5 wide + (12345.6, "12346"), + ], +) +def test_cpu_percent_drops_decimal_above_1000(fields, cpu_percent, expected): + """A process spread over many cores must not widen the CPU% column.""" + payload = {"data": [_proc(pid=1, cpu_percent=cpu_percent)], "_levels": {}} + cell = render(payload, fields)[1].cells[CPU_COL] + assert cell.text == expected + assert len(cell.text) == 5 # never overflows the 5-wide column