mirror of
https://github.com/nicolargo/glances.git
synced 2026-08-02 08:16:55 -04:00
Correct issues in TUI
This commit is contained in:
@@ -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
|
||||
#-------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user