This commit is contained in:
Pablo Galindo Salgado 2025-12-08 09:35:16 +04:00 committed by GitHub
commit 473558efa6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 197 additions and 76 deletions

View file

@ -68,6 +68,19 @@ class ANSIColors:
ColorCodes = set()
NoColors = ANSIColors()
class CursesColors:
"""Curses color constants for terminal UI theming."""
BLACK = 0
RED = 1
GREEN = 2
YELLOW = 3
BLUE = 4
MAGENTA = 5
CYAN = 6
WHITE = 7
DEFAULT = -1
for attr, code in ANSIColors.__dict__.items():
if not attr.startswith("__"):
ColorCodes.add(code)
@ -223,6 +236,119 @@ class Unittest(ThemeSection):
reset: str = ANSIColors.RESET
@dataclass(frozen=True, kw_only=True)
class LiveProfiler(ThemeSection):
"""Theme section for the live profiling TUI (Tachyon profiler).
Colors use CursesColors constants (BLACK, RED, GREEN, YELLOW,
BLUE, MAGENTA, CYAN, WHITE, DEFAULT).
"""
# Header colors
title_fg: int = CursesColors.CYAN
title_bg: int = CursesColors.DEFAULT
# Status display colors
pid_fg: int = CursesColors.CYAN
uptime_fg: int = CursesColors.GREEN
time_fg: int = CursesColors.YELLOW
interval_fg: int = CursesColors.MAGENTA
# Thread view colors
thread_all_fg: int = CursesColors.GREEN
thread_single_fg: int = CursesColors.MAGENTA
# Progress bar colors
bar_good_fg: int = CursesColors.GREEN
bar_bad_fg: int = CursesColors.RED
# Stats colors
on_gil_fg: int = CursesColors.GREEN
off_gil_fg: int = CursesColors.RED
waiting_gil_fg: int = CursesColors.YELLOW
gc_fg: int = CursesColors.MAGENTA
# Function display colors
func_total_fg: int = CursesColors.CYAN
func_exec_fg: int = CursesColors.GREEN
func_stack_fg: int = CursesColors.YELLOW
func_shown_fg: int = CursesColors.MAGENTA
# Table header colors
sorted_header_fg: int = CursesColors.BLACK
sorted_header_bg: int = CursesColors.YELLOW
# Data row colors
samples_fg: int = CursesColors.CYAN
file_fg: int = CursesColors.GREEN
func_fg: int = CursesColors.YELLOW
# Trend indicator colors
trend_up_fg: int = CursesColors.GREEN
trend_down_fg: int = CursesColors.RED
# Medal colors for top functions
medal_gold_fg: int = CursesColors.RED
medal_silver_fg: int = CursesColors.YELLOW
medal_bronze_fg: int = CursesColors.GREEN
# Background style: 'dark' or 'light'
background_style: str = "dark"
LiveProfilerLight = LiveProfiler(
# Header colors
title_fg=CursesColors.BLUE,
title_bg=CursesColors.DEFAULT,
# Status display colors
pid_fg=CursesColors.BLUE,
uptime_fg=CursesColors.GREEN,
time_fg=CursesColors.YELLOW,
interval_fg=CursesColors.MAGENTA,
# Thread view colors
thread_all_fg=CursesColors.GREEN,
thread_single_fg=CursesColors.MAGENTA,
# Progress bar colors
bar_good_fg=CursesColors.GREEN,
bar_bad_fg=CursesColors.RED,
# Stats colors
on_gil_fg=CursesColors.GREEN,
off_gil_fg=CursesColors.RED,
waiting_gil_fg=CursesColors.YELLOW,
gc_fg=CursesColors.MAGENTA,
# Function display colors
func_total_fg=CursesColors.BLUE,
func_exec_fg=CursesColors.GREEN,
func_stack_fg=CursesColors.YELLOW,
func_shown_fg=CursesColors.MAGENTA,
# Table header colors
sorted_header_fg=CursesColors.WHITE,
sorted_header_bg=CursesColors.BLUE,
# Data row colors
samples_fg=CursesColors.BLUE,
file_fg=CursesColors.GREEN,
func_fg=CursesColors.MAGENTA,
# Trend indicator colors
trend_up_fg=CursesColors.GREEN,
trend_down_fg=CursesColors.RED,
# Medal colors for top functions
medal_gold_fg=CursesColors.RED,
medal_silver_fg=CursesColors.BLUE,
medal_bronze_fg=CursesColors.GREEN,
# Background style
background_style="light",
)
@dataclass(frozen=True, kw_only=True)
class Theme:
"""A suite of themes for all sections of Python.
@ -235,6 +361,7 @@ class Theme:
syntax: Syntax = field(default_factory=Syntax)
traceback: Traceback = field(default_factory=Traceback)
unittest: Unittest = field(default_factory=Unittest)
live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
def copy_with(
self,
@ -244,6 +371,7 @@ def copy_with(
syntax: Syntax | None = None,
traceback: Traceback | None = None,
unittest: Unittest | None = None,
live_profiler: LiveProfiler | None = None,
) -> Self:
"""Return a new Theme based on this instance with some sections replaced.
@ -256,6 +384,7 @@ def copy_with(
syntax=syntax or self.syntax,
traceback=traceback or self.traceback,
unittest=unittest or self.unittest,
live_profiler=live_profiler or self.live_profiler,
)
@classmethod
@ -272,6 +401,7 @@ def no_colors(cls) -> Self:
syntax=Syntax.no_colors(),
traceback=Traceback.no_colors(),
unittest=Unittest.no_colors(),
live_profiler=LiveProfiler.no_colors(),
)
@ -338,6 +468,9 @@ def _safe_getenv(k: str, fallback: str | None = None) -> str | None:
default_theme = Theme()
theme_no_color = default_theme.no_colors()
# Convenience theme with light profiler colors (for white/light terminal backgrounds)
light_profiler_theme = default_theme.copy_with(live_profiler=LiveProfilerLight)
def get_theme(
*,

View file

@ -501,79 +501,57 @@ def _cycle_sort(self, reverse=False):
def _setup_colors(self):
"""Set up color pairs and return color attributes."""
A_BOLD = self.display.get_attr("A_BOLD")
A_REVERSE = self.display.get_attr("A_REVERSE")
A_UNDERLINE = self.display.get_attr("A_UNDERLINE")
A_NORMAL = self.display.get_attr("A_NORMAL")
# Check both curses color support and _colorize.can_colorize()
if self.display.has_colors() and self._can_colorize:
with contextlib.suppress(Exception):
# Color constants (using curses values for compatibility)
COLOR_CYAN = 6
COLOR_GREEN = 2
COLOR_YELLOW = 3
COLOR_BLACK = 0
COLOR_MAGENTA = 5
COLOR_RED = 1
theme = _colorize.get_theme(force_color=True)
profiler_theme = theme.live_profiler
default_bg = -1
# Initialize all color pairs used throughout the UI
self.display.init_color_pair(1, profiler_theme.samples_fg, default_bg)
self.display.init_color_pair(2, profiler_theme.file_fg, default_bg)
self.display.init_color_pair(3, profiler_theme.func_fg, default_bg)
header_bg = 2 if profiler_theme.background_style == "dark" else 4
self.display.init_color_pair(COLOR_PAIR_HEADER_BG, 0, header_bg)
self.display.init_color_pair(COLOR_PAIR_CYAN, profiler_theme.pid_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_YELLOW, profiler_theme.time_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_GREEN, profiler_theme.uptime_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_MAGENTA, profiler_theme.interval_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_RED, profiler_theme.off_gil_fg, default_bg)
self.display.init_color_pair(
1, COLOR_CYAN, -1
) # Data colors for stats rows
self.display.init_color_pair(2, COLOR_GREEN, -1)
self.display.init_color_pair(3, COLOR_YELLOW, -1)
self.display.init_color_pair(
COLOR_PAIR_HEADER_BG, COLOR_BLACK, COLOR_GREEN
)
self.display.init_color_pair(
COLOR_PAIR_CYAN, COLOR_CYAN, COLOR_BLACK
)
self.display.init_color_pair(
COLOR_PAIR_YELLOW, COLOR_YELLOW, COLOR_BLACK
)
self.display.init_color_pair(
COLOR_PAIR_GREEN, COLOR_GREEN, COLOR_BLACK
)
self.display.init_color_pair(
COLOR_PAIR_MAGENTA, COLOR_MAGENTA, COLOR_BLACK
)
self.display.init_color_pair(
COLOR_PAIR_RED, COLOR_RED, COLOR_BLACK
)
self.display.init_color_pair(
COLOR_PAIR_SORTED_HEADER, COLOR_BLACK, COLOR_YELLOW
COLOR_PAIR_SORTED_HEADER,
profiler_theme.sorted_header_fg,
profiler_theme.sorted_header_bg,
)
TREND_UP_PAIR = 11
TREND_DOWN_PAIR = 12
self.display.init_color_pair(TREND_UP_PAIR, profiler_theme.trend_up_fg, default_bg)
self.display.init_color_pair(TREND_DOWN_PAIR, profiler_theme.trend_down_fg, default_bg)
return {
"header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG)
| A_BOLD,
"cyan": self.display.get_color_pair(COLOR_PAIR_CYAN)
| A_BOLD,
"yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW)
| A_BOLD,
"green": self.display.get_color_pair(COLOR_PAIR_GREEN)
| A_BOLD,
"magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA)
| A_BOLD,
"red": self.display.get_color_pair(COLOR_PAIR_RED)
| A_BOLD,
"sorted_header": self.display.get_color_pair(
COLOR_PAIR_SORTED_HEADER
)
| A_BOLD,
"header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) | A_BOLD,
"cyan": self.display.get_color_pair(COLOR_PAIR_CYAN) | A_BOLD,
"yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW) | A_BOLD,
"green": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD,
"magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA) | A_BOLD,
"red": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD,
"sorted_header": self.display.get_color_pair(COLOR_PAIR_SORTED_HEADER) | A_BOLD,
"normal_header": A_REVERSE | A_BOLD,
"color_samples": self.display.get_color_pair(1),
"color_file": self.display.get_color_pair(2),
"color_func": self.display.get_color_pair(3),
# Trend colors (stock-like indicators)
"trend_up": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD,
"trend_down": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD,
"trend_up": self.display.get_color_pair(TREND_UP_PAIR) | A_BOLD,
"trend_down": self.display.get_color_pair(TREND_DOWN_PAIR) | A_BOLD,
"trend_stable": A_NORMAL,
}
# Fallback to non-color attributes
return {
"header": A_REVERSE | A_BOLD,
"cyan": A_BOLD,
@ -586,7 +564,6 @@ def _setup_colors(self):
"color_samples": A_NORMAL,
"color_file": A_NORMAL,
"color_func": A_NORMAL,
# Trend colors (fallback to bold/normal for monochrome)
"trend_up": A_BOLD,
"trend_down": A_BOLD,
"trend_stable": A_NORMAL,

View file

@ -639,8 +639,6 @@ def render(self, line, width, **kwargs):
def draw_column_headers(self, line, width):
"""Draw column headers with sort indicators."""
col = 0
# Determine which columns to show based on width
show_sample_pct = width >= WIDTH_THRESHOLD_SAMPLE_PCT
show_tottime = width >= WIDTH_THRESHOLD_TOTTIME
@ -659,38 +657,38 @@ def draw_column_headers(self, line, width):
"cumtime": 4,
}.get(self.collector.sort_by, -1)
# Build the full header line first, then draw it
# This avoids gaps between columns when using reverse video
header_parts = []
col = 0
# Column 0: nsamples
attr = sorted_header if sort_col == 0 else normal_header
text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13}"
self.add_str(line, col, text, attr)
text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13} "
header_parts.append((col, text, sorted_header if sort_col == 0 else normal_header))
col += 15
# Column 1: sample %
if show_sample_pct:
attr = sorted_header if sort_col == 1 else normal_header
text = f"{'%' if sort_col == 1 else '%':>5}"
self.add_str(line, col, text, attr)
text = f"{'%' if sort_col == 1 else '%':>5} "
header_parts.append((col, text, sorted_header if sort_col == 1 else normal_header))
col += 7
# Column 2: tottime
if show_tottime:
attr = sorted_header if sort_col == 2 else normal_header
text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10}"
self.add_str(line, col, text, attr)
text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10} "
header_parts.append((col, text, sorted_header if sort_col == 2 else normal_header))
col += 12
# Column 3: cumul %
if show_cumul_pct:
attr = sorted_header if sort_col == 3 else normal_header
text = f"{'%' if sort_col == 3 else '%':>5}"
self.add_str(line, col, text, attr)
text = f"{'%' if sort_col == 3 else '%':>5} "
header_parts.append((col, text, sorted_header if sort_col == 3 else normal_header))
col += 7
# Column 4: cumtime
if show_cumtime:
attr = sorted_header if sort_col == 4 else normal_header
text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10}"
self.add_str(line, col, text, attr)
text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10} "
header_parts.append((col, text, sorted_header if sort_col == 4 else normal_header))
col += 12
# Remaining headers
@ -700,13 +698,22 @@ def draw_column_headers(self, line, width):
MAX_FUNC_NAME_WIDTH,
max(MIN_FUNC_NAME_WIDTH, remaining_space // 2),
)
self.add_str(
line, col, f"{'function':<{func_width}}", normal_header
)
text = f"{'function':<{func_width}} "
header_parts.append((col, text, normal_header))
col += func_width + 2
if col < width - 10:
self.add_str(line, col, "file:line", normal_header)
file_text = "file:line"
padding = width - col - len(file_text)
text = file_text + " " * max(0, padding)
header_parts.append((col, text, normal_header))
# Draw full-width background first
self.add_str(line, 0, " " * (width - 1), normal_header)
# Draw each header part on top
for col_pos, text, attr in header_parts:
self.add_str(line, col_pos, text.rstrip(), attr)
return (
line + 1,

View file

@ -0,0 +1,4 @@
The Tachyon profiler's live TUI now integrates with the :mod:`!_colorize`
theming system, allowing users to customize colors via
:func:`!_colorize.set_theme`. A :class:`!LiveProfilerLight` theme is provided
for light terminal backgrounds. Patch by Pablo Galindo.