Correct issues in TUI

This commit is contained in:
nicolargo
2026-07-18 18:26:20 +02:00
parent a243053181
commit 29d3217e60
28 changed files with 339 additions and 226 deletions

View File

@@ -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
#-------------------------------------

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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.

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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"):

View File

@@ -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:

View File

@@ -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:

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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.

View File

@@ -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))

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),
]

View File

@@ -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):

View File

@@ -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):

View File

@@ -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]

View File

@@ -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)."""

View File

@@ -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"

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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