Release Fenris 0.5.0 with Chalktone dashboard and dotted activity plots
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.4.0"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""A terminal volume plot shared by Fenris's activity views.
|
||||
|
||||
Braille provides two by four dots per terminal cell. Only adjacent, complete
|
||||
measurements are connected; gaps and partial evidence never imply continuity.
|
||||
The returned columns also place mouse inspection on the plotted time axis.
|
||||
"""
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VolumePoint:
|
||||
time: float
|
||||
volume: int | None
|
||||
label: str
|
||||
state: str = "measured"
|
||||
|
||||
|
||||
def volume_plot(
|
||||
points: Sequence[VolumePoint], width: int, height: int,
|
||||
selected: int, colors: Mapping[str, str],
|
||||
) -> tuple[Text, list[int], str]:
|
||||
"""Render bounded axes and a dotted trace, without resampling evidence."""
|
||||
width, height = max(12, width), max(4, height)
|
||||
axis_width = 8
|
||||
columns, rows = width - axis_width, height - 2
|
||||
pixel_width, pixel_height = columns * 2, rows * 4
|
||||
maximum = max((p.volume or 0 for p in points), default=0)
|
||||
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
|
||||
(1e9, "GB") if maximum >= 1e9 else
|
||||
(1e6, "MB") if maximum >= 1e6 else
|
||||
(1e3, "KB") if maximum >= 1e3 else (1, "B")
|
||||
)
|
||||
ceiling = maximum or scale
|
||||
start = points[0].time if points else 0
|
||||
span = (points[-1].time - start) if len(points) > 1 else 0
|
||||
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
|
||||
for p in points]
|
||||
cells = [[0] * columns for _ in range(rows)]
|
||||
# Unicode braille dot numbering, indexed by y within cell and then x.
|
||||
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
|
||||
|
||||
def dot(x: int, y: int) -> None:
|
||||
cells[y // 4][x // 2] |= bits[y % 4][x % 2]
|
||||
|
||||
previous = None
|
||||
markers = {}
|
||||
for index, (point, x) in enumerate(zip(points, xs)):
|
||||
if point.volume is None:
|
||||
previous = None
|
||||
if point.state != "future":
|
||||
markers[x // 2] = "?"
|
||||
continue
|
||||
y = round((1 - point.volume / ceiling) * (pixel_height - 1))
|
||||
y = min(pixel_height - 1, max(0, y))
|
||||
if previous is not None and point.state == "measured":
|
||||
px, py = previous
|
||||
steps = max(abs(x - px), abs(y - py), 1)
|
||||
for step in range(steps + 1):
|
||||
dot(round(px + (x - px) * step / steps),
|
||||
round(py + (y - py) * step / steps))
|
||||
dot(x, y)
|
||||
previous = (x, y) if point.state == "measured" else None
|
||||
if point.state != "measured":
|
||||
markers[x // 2] = "~" if point.state == "partial" else "u"
|
||||
elif point.volume == 0:
|
||||
markers.setdefault(x // 2, "·")
|
||||
|
||||
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
|
||||
result = Text(no_wrap=True, overflow="crop")
|
||||
ticks = {0, rows // 2, rows - 1}
|
||||
for row, values in enumerate(cells):
|
||||
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
|
||||
label = f"{value:6.2f}"[-6:] if row in ticks else " "
|
||||
result.append(label + " │", style=colors["muted"])
|
||||
for col, value in enumerate(values):
|
||||
char = chr(0x2800 + value) if value else " "
|
||||
style = colors["allocated"]
|
||||
if col == selected_column:
|
||||
style = "bold " + colors["selection"]
|
||||
if not value:
|
||||
char = "┊"
|
||||
style = colors["muted"]
|
||||
result.append(char, style=style)
|
||||
result.append("\n")
|
||||
result.append(" └", style=colors["muted"])
|
||||
for col in range(columns):
|
||||
result.append("▼" if col == selected_column else markers.get(col, "─"),
|
||||
style=colors["selection"] if col == selected_column else colors["muted"])
|
||||
result.append("\n" + " " * axis_width)
|
||||
labels = [" "] * columns
|
||||
end_of_label = -1
|
||||
for index in sorted({0, len(points) // 2, len(points) - 1}):
|
||||
if index < 0 or not points:
|
||||
continue
|
||||
label = points[index].label[:columns]
|
||||
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
|
||||
if column > end_of_label:
|
||||
labels[column:column + len(label)] = label
|
||||
end_of_label = column + len(label)
|
||||
result.append("".join(labels), style=colors["muted"])
|
||||
return result, [axis_width + x // 2 for x in xs], unit
|
||||
@@ -19,8 +19,8 @@ from typing import Any, Dict
|
||||
|
||||
|
||||
PREFERENCE_FILE_NAME = "preferences.json"
|
||||
VALID_THEMES = {"amber", "nord", "high_contrast"}
|
||||
DEFAULT_THEME = "amber"
|
||||
VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
|
||||
DEFAULT_THEME = "chalktone"
|
||||
DEFAULT_REDUCED_MOTION = False
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ def load_preferences() -> Dict[str, Any]:
|
||||
reduced_motion: bool
|
||||
|
||||
If the file is missing, corrupt, unreadable, or contains invalid
|
||||
values, returns safe defaults (Amber theme, normal motion).
|
||||
values, returns safe defaults (Chalktone theme, normal motion).
|
||||
"""
|
||||
path = get_preference_path()
|
||||
|
||||
|
||||
+20
-4
@@ -1,6 +1,6 @@
|
||||
"""Fenris theme presets (issue #80).
|
||||
|
||||
Three accessible colour presets: Amber (default), Nord, and High Contrast.
|
||||
Chalktone-inspired default plus Amber, Nord, and High Contrast.
|
||||
Themes style chrome, borders, accents, muted text, and graph roles;
|
||||
status semantic colours/glyphs/text always win.
|
||||
|
||||
@@ -126,7 +126,22 @@ _HIGH_CONTRAST = Theme(
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CHALKTONE = Theme(
|
||||
name="fenris-chalktone",
|
||||
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
|
||||
warning="#e9bc79", error="#e58d89", success="#acd29c",
|
||||
foreground="#e0d8c5", background="#202426", surface="#202426",
|
||||
panel="#252a2c", boost="#333b3d", dark=True,
|
||||
variables={
|
||||
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
|
||||
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
|
||||
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
|
||||
"border-default": "#606d6a", "muted-text": "#a7b1a9",
|
||||
},
|
||||
)
|
||||
|
||||
THEMES = {
|
||||
"chalktone": _CHALKTONE,
|
||||
"amber": _AMBER,
|
||||
"nord": _NORD,
|
||||
"high_contrast": _HIGH_CONTRAST,
|
||||
@@ -138,16 +153,16 @@ THEME_NAMES = set(THEMES.keys())
|
||||
def get_theme(name: str) -> Theme:
|
||||
"""Return a registered theme by preset name.
|
||||
|
||||
Unknown names fall back to Amber.
|
||||
Unknown names fall back to Chalktone.
|
||||
"""
|
||||
return THEMES.get(name, _AMBER)
|
||||
return THEMES.get(name, _CHALKTONE)
|
||||
|
||||
|
||||
def get_graph_colors(theme_name: str) -> Dict[str, str]:
|
||||
"""Return the graph colour roles for a theme preset.
|
||||
|
||||
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
|
||||
selection. Falls back to Amber for unknown names.
|
||||
selection and muted text. Falls back to Chalktone for unknown names.
|
||||
"""
|
||||
theme = get_theme(theme_name)
|
||||
variables = theme.variables or {}
|
||||
@@ -158,4 +173,5 @@ def get_graph_colors(theme_name: str) -> Dict[str, str]:
|
||||
"zero": variables.get("graph-zero", "#665533"),
|
||||
"partial": variables.get("graph-partial", "#aa8822"),
|
||||
"selection": variables.get("graph-selection", "#ffd54f"),
|
||||
"muted": variables.get("muted-text", "#a7b1a9"),
|
||||
}
|
||||
|
||||
+356
-514
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user