Skip to content

Commit f2753f1

Browse files
committed
gh-138122: Integrate live profiler TUI with _colorize theming system
The Tachyon profiler's curses-based TUI now uses the centralized theming infrastructure in _colorize.py, enabling users to customize colors via the standard Python theming API. This adds a LiveProfiler theme section with two pre-configured themes: the default dark theme optimized for dark terminal backgrounds, and LiveProfilerLight for white/light backgrounds. Users can switch themes by calling _colorize.set_theme() in their PYTHONSTARTUP or sitecustomize.py. The table header rendering was also improved to draw a continuous background, eliminating visual gaps between columns when using reverse video styling.
1 parent eba449a commit f2753f1

File tree

4 files changed

+185
-77
lines changed

4 files changed

+185
-77
lines changed

Lib/_colorize.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,119 @@ class Unittest(ThemeSection):
223223
reset: str = ANSIColors.RESET
224224

225225

226+
@dataclass(frozen=True, kw_only=True)
227+
class LiveProfiler(ThemeSection):
228+
"""Theme section for the live profiling TUI (Tachyon profiler).
229+
230+
Colors are curses color constants: 0=black, 1=red, 2=green, 3=yellow,
231+
4=blue, 5=magenta, 6=cyan, 7=white, -1=default.
232+
"""
233+
# Header colors
234+
title_fg: int = 6 # cyan
235+
title_bg: int = -1 # default
236+
237+
# Status display colors
238+
pid_fg: int = 6 # cyan
239+
uptime_fg: int = 2 # green
240+
time_fg: int = 3 # yellow
241+
interval_fg: int = 5 # magenta
242+
243+
# Thread view colors
244+
thread_all_fg: int = 2 # green
245+
thread_single_fg: int = 5 # magenta
246+
247+
# Progress bar colors
248+
bar_good_fg: int = 2 # green
249+
bar_bad_fg: int = 1 # red
250+
251+
# Stats colors
252+
on_gil_fg: int = 2 # green
253+
off_gil_fg: int = 1 # red
254+
waiting_gil_fg: int = 3 # yellow
255+
gc_fg: int = 5 # magenta
256+
257+
# Function display colors
258+
func_total_fg: int = 6 # cyan
259+
func_exec_fg: int = 2 # green
260+
func_stack_fg: int = 3 # yellow
261+
func_shown_fg: int = 5 # magenta
262+
263+
# Table header colors
264+
sorted_header_fg: int = 0 # black
265+
sorted_header_bg: int = 3 # yellow
266+
267+
# Data row colors
268+
samples_fg: int = 6 # cyan
269+
file_fg: int = 2 # green
270+
func_fg: int = 3 # yellow
271+
272+
# Trend indicator colors
273+
trend_up_fg: int = 2 # green
274+
trend_down_fg: int = 1 # red
275+
276+
# Medal colors for top functions
277+
medal_gold_fg: int = 1 # red
278+
medal_silver_fg: int = 3 # yellow
279+
medal_bronze_fg: int = 2 # green
280+
281+
# Background style: 'dark' or 'light'
282+
background_style: str = "dark"
283+
284+
285+
LiveProfilerLight = LiveProfiler(
286+
# Header colors
287+
title_fg=4, # blue
288+
title_bg=-1, # default
289+
290+
# Status display colors
291+
pid_fg=4, # blue
292+
uptime_fg=2, # green
293+
time_fg=3, # yellow
294+
interval_fg=5, # magenta
295+
296+
# Thread view colors
297+
thread_all_fg=2, # green
298+
thread_single_fg=5, # magenta
299+
300+
# Progress bar colors
301+
bar_good_fg=2, # green
302+
bar_bad_fg=1, # red
303+
304+
# Stats colors
305+
on_gil_fg=2, # green
306+
off_gil_fg=1, # red
307+
waiting_gil_fg=3, # yellow
308+
gc_fg=5, # magenta
309+
310+
# Function display colors
311+
func_total_fg=4, # blue
312+
func_exec_fg=2, # green
313+
func_stack_fg=3, # yellow
314+
func_shown_fg=5, # magenta
315+
316+
# Table header colors
317+
sorted_header_fg=7, # white
318+
sorted_header_bg=4, # blue
319+
320+
# Data row colors
321+
samples_fg=4, # blue
322+
file_fg=2, # green
323+
func_fg=5, # magenta
324+
325+
# Trend indicator colors
326+
trend_up_fg=2, # green
327+
trend_down_fg=1, # red
328+
329+
# Medal colors for top functions
330+
medal_gold_fg=1, # red
331+
medal_silver_fg=4, # blue
332+
medal_bronze_fg=2, # green
333+
334+
# Background style
335+
background_style="light",
336+
)
337+
338+
226339
@dataclass(frozen=True, kw_only=True)
227340
class Theme:
228341
"""A suite of themes for all sections of Python.
@@ -235,6 +348,7 @@ class Theme:
235348
syntax: Syntax = field(default_factory=Syntax)
236349
traceback: Traceback = field(default_factory=Traceback)
237350
unittest: Unittest = field(default_factory=Unittest)
351+
live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
238352

239353
def copy_with(
240354
self,
@@ -244,6 +358,7 @@ def copy_with(
244358
syntax: Syntax | None = None,
245359
traceback: Traceback | None = None,
246360
unittest: Unittest | None = None,
361+
live_profiler: LiveProfiler | None = None,
247362
) -> Self:
248363
"""Return a new Theme based on this instance with some sections replaced.
249364
@@ -256,6 +371,7 @@ def copy_with(
256371
syntax=syntax or self.syntax,
257372
traceback=traceback or self.traceback,
258373
unittest=unittest or self.unittest,
374+
live_profiler=live_profiler or self.live_profiler,
259375
)
260376

261377
@classmethod
@@ -272,6 +388,7 @@ def no_colors(cls) -> Self:
272388
syntax=Syntax.no_colors(),
273389
traceback=Traceback.no_colors(),
274390
unittest=Unittest.no_colors(),
391+
live_profiler=LiveProfiler.no_colors(),
275392
)
276393

277394

@@ -338,6 +455,9 @@ def _safe_getenv(k: str, fallback: str | None = None) -> str | None:
338455
default_theme = Theme()
339456
theme_no_color = default_theme.no_colors()
340457

458+
# Convenience theme with light profiler colors (for white/light terminal backgrounds)
459+
light_profiler_theme = default_theme.copy_with(live_profiler=LiveProfilerLight)
460+
341461

342462
def get_theme(
343463
*,

Lib/profiling/sampling/live_collector/collector.py

Lines changed: 33 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -525,79 +525,57 @@ def _cycle_sort(self, reverse=False):
525525

526526
def _setup_colors(self):
527527
"""Set up color pairs and return color attributes."""
528-
529528
A_BOLD = self.display.get_attr("A_BOLD")
530529
A_REVERSE = self.display.get_attr("A_REVERSE")
531530
A_UNDERLINE = self.display.get_attr("A_UNDERLINE")
532531
A_NORMAL = self.display.get_attr("A_NORMAL")
533532

534-
# Check both curses color support and _colorize.can_colorize()
535533
if self.display.has_colors() and self._can_colorize:
536534
with contextlib.suppress(Exception):
537-
# Color constants (using curses values for compatibility)
538-
COLOR_CYAN = 6
539-
COLOR_GREEN = 2
540-
COLOR_YELLOW = 3
541-
COLOR_BLACK = 0
542-
COLOR_MAGENTA = 5
543-
COLOR_RED = 1
544-
545-
# Initialize all color pairs used throughout the UI
546-
self.display.init_color_pair(
547-
1, COLOR_CYAN, -1
548-
) # Data colors for stats rows
549-
self.display.init_color_pair(2, COLOR_GREEN, -1)
550-
self.display.init_color_pair(3, COLOR_YELLOW, -1)
551-
self.display.init_color_pair(
552-
COLOR_PAIR_HEADER_BG, COLOR_BLACK, COLOR_GREEN
553-
)
554-
self.display.init_color_pair(
555-
COLOR_PAIR_CYAN, COLOR_CYAN, COLOR_BLACK
556-
)
557-
self.display.init_color_pair(
558-
COLOR_PAIR_YELLOW, COLOR_YELLOW, COLOR_BLACK
559-
)
535+
theme = _colorize.get_theme(force_color=True)
536+
profiler_theme = theme.live_profiler
537+
default_bg = -1
538+
539+
self.display.init_color_pair(1, profiler_theme.samples_fg, default_bg)
540+
self.display.init_color_pair(2, profiler_theme.file_fg, default_bg)
541+
self.display.init_color_pair(3, profiler_theme.func_fg, default_bg)
542+
543+
header_bg = 2 if profiler_theme.background_style == "dark" else 4
544+
self.display.init_color_pair(COLOR_PAIR_HEADER_BG, 0, header_bg)
545+
546+
self.display.init_color_pair(COLOR_PAIR_CYAN, profiler_theme.pid_fg, default_bg)
547+
self.display.init_color_pair(COLOR_PAIR_YELLOW, profiler_theme.time_fg, default_bg)
548+
self.display.init_color_pair(COLOR_PAIR_GREEN, profiler_theme.uptime_fg, default_bg)
549+
self.display.init_color_pair(COLOR_PAIR_MAGENTA, profiler_theme.interval_fg, default_bg)
550+
self.display.init_color_pair(COLOR_PAIR_RED, profiler_theme.off_gil_fg, default_bg)
560551
self.display.init_color_pair(
561-
COLOR_PAIR_GREEN, COLOR_GREEN, COLOR_BLACK
562-
)
563-
self.display.init_color_pair(
564-
COLOR_PAIR_MAGENTA, COLOR_MAGENTA, COLOR_BLACK
565-
)
566-
self.display.init_color_pair(
567-
COLOR_PAIR_RED, COLOR_RED, COLOR_BLACK
568-
)
569-
self.display.init_color_pair(
570-
COLOR_PAIR_SORTED_HEADER, COLOR_BLACK, COLOR_YELLOW
552+
COLOR_PAIR_SORTED_HEADER,
553+
profiler_theme.sorted_header_fg,
554+
profiler_theme.sorted_header_bg,
571555
)
572556

557+
TREND_UP_PAIR = 11
558+
TREND_DOWN_PAIR = 12
559+
self.display.init_color_pair(TREND_UP_PAIR, profiler_theme.trend_up_fg, default_bg)
560+
self.display.init_color_pair(TREND_DOWN_PAIR, profiler_theme.trend_down_fg, default_bg)
561+
573562
return {
574-
"header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG)
575-
| A_BOLD,
576-
"cyan": self.display.get_color_pair(COLOR_PAIR_CYAN)
577-
| A_BOLD,
578-
"yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW)
579-
| A_BOLD,
580-
"green": self.display.get_color_pair(COLOR_PAIR_GREEN)
581-
| A_BOLD,
582-
"magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA)
583-
| A_BOLD,
584-
"red": self.display.get_color_pair(COLOR_PAIR_RED)
585-
| A_BOLD,
586-
"sorted_header": self.display.get_color_pair(
587-
COLOR_PAIR_SORTED_HEADER
588-
)
589-
| A_BOLD,
563+
"header": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) | A_BOLD,
564+
"cyan": self.display.get_color_pair(COLOR_PAIR_CYAN) | A_BOLD,
565+
"yellow": self.display.get_color_pair(COLOR_PAIR_YELLOW) | A_BOLD,
566+
"green": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD,
567+
"magenta": self.display.get_color_pair(COLOR_PAIR_MAGENTA) | A_BOLD,
568+
"red": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD,
569+
"sorted_header": self.display.get_color_pair(COLOR_PAIR_SORTED_HEADER) | A_BOLD,
590570
"normal_header": A_REVERSE | A_BOLD,
591571
"color_samples": self.display.get_color_pair(1),
592572
"color_file": self.display.get_color_pair(2),
593573
"color_func": self.display.get_color_pair(3),
594-
# Trend colors (stock-like indicators)
595-
"trend_up": self.display.get_color_pair(COLOR_PAIR_GREEN) | A_BOLD,
596-
"trend_down": self.display.get_color_pair(COLOR_PAIR_RED) | A_BOLD,
574+
"trend_up": self.display.get_color_pair(TREND_UP_PAIR) | A_BOLD,
575+
"trend_down": self.display.get_color_pair(TREND_DOWN_PAIR) | A_BOLD,
597576
"trend_stable": A_NORMAL,
598577
}
599578

600-
# Fallback to non-color attributes
601579
return {
602580
"header": A_REVERSE | A_BOLD,
603581
"cyan": A_BOLD,
@@ -610,7 +588,6 @@ def _setup_colors(self):
610588
"color_samples": A_NORMAL,
611589
"color_file": A_NORMAL,
612590
"color_func": A_NORMAL,
613-
# Trend colors (fallback to bold/normal for monochrome)
614591
"trend_up": A_BOLD,
615592
"trend_down": A_BOLD,
616593
"trend_stable": A_NORMAL,

Lib/profiling/sampling/live_collector/widgets.py

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -639,8 +639,6 @@ def render(self, line, width, **kwargs):
639639

640640
def draw_column_headers(self, line, width):
641641
"""Draw column headers with sort indicators."""
642-
col = 0
643-
644642
# Determine which columns to show based on width
645643
show_sample_pct = width >= WIDTH_THRESHOLD_SAMPLE_PCT
646644
show_tottime = width >= WIDTH_THRESHOLD_TOTTIME
@@ -659,38 +657,38 @@ def draw_column_headers(self, line, width):
659657
"cumtime": 4,
660658
}.get(self.collector.sort_by, -1)
661659

660+
# Build the full header line first, then draw it
661+
# This avoids gaps between columns when using reverse video
662+
header_parts = []
663+
col = 0
664+
662665
# Column 0: nsamples
663-
attr = sorted_header if sort_col == 0 else normal_header
664-
text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13}"
665-
self.add_str(line, col, text, attr)
666+
text = f"{'▼nsamples' if sort_col == 0 else 'nsamples':>13} "
667+
header_parts.append((col, text, sorted_header if sort_col == 0 else normal_header))
666668
col += 15
667669

668670
# Column 1: sample %
669671
if show_sample_pct:
670-
attr = sorted_header if sort_col == 1 else normal_header
671-
text = f"{'▼%' if sort_col == 1 else '%':>5}"
672-
self.add_str(line, col, text, attr)
672+
text = f"{'▼%' if sort_col == 1 else '%':>5} "
673+
header_parts.append((col, text, sorted_header if sort_col == 1 else normal_header))
673674
col += 7
674675

675676
# Column 2: tottime
676677
if show_tottime:
677-
attr = sorted_header if sort_col == 2 else normal_header
678-
text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10}"
679-
self.add_str(line, col, text, attr)
678+
text = f"{'▼tottime' if sort_col == 2 else 'tottime':>10} "
679+
header_parts.append((col, text, sorted_header if sort_col == 2 else normal_header))
680680
col += 12
681681

682682
# Column 3: cumul %
683683
if show_cumul_pct:
684-
attr = sorted_header if sort_col == 3 else normal_header
685-
text = f"{'▼%' if sort_col == 3 else '%':>5}"
686-
self.add_str(line, col, text, attr)
684+
text = f"{'▼%' if sort_col == 3 else '%':>5} "
685+
header_parts.append((col, text, sorted_header if sort_col == 3 else normal_header))
687686
col += 7
688687

689688
# Column 4: cumtime
690689
if show_cumtime:
691-
attr = sorted_header if sort_col == 4 else normal_header
692-
text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10}"
693-
self.add_str(line, col, text, attr)
690+
text = f"{'▼cumtime' if sort_col == 4 else 'cumtime':>10} "
691+
header_parts.append((col, text, sorted_header if sort_col == 4 else normal_header))
694692
col += 12
695693

696694
# Remaining headers
@@ -700,13 +698,22 @@ def draw_column_headers(self, line, width):
700698
MAX_FUNC_NAME_WIDTH,
701699
max(MIN_FUNC_NAME_WIDTH, remaining_space // 2),
702700
)
703-
self.add_str(
704-
line, col, f"{'function':<{func_width}}", normal_header
705-
)
701+
text = f"{'function':<{func_width}} "
702+
header_parts.append((col, text, normal_header))
706703
col += func_width + 2
707704

708705
if col < width - 10:
709-
self.add_str(line, col, "file:line", normal_header)
706+
file_text = "file:line"
707+
padding = width - col - len(file_text)
708+
text = file_text + " " * max(0, padding)
709+
header_parts.append((col, text, normal_header))
710+
711+
# Draw full-width background first
712+
self.add_str(line, 0, " " * (width - 1), normal_header)
713+
714+
# Draw each header part on top
715+
for col_pos, text, attr in header_parts:
716+
self.add_str(line, col_pos, text.rstrip(), attr)
710717

711718
return (
712719
line + 1,
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
The Tachyon profiler's live TUI now integrates with the :mod:`!_colorize`
2+
theming system, allowing users to customize colors via
3+
:func:`!_colorize.set_theme`. A :class:`!LiveProfilerLight` theme is provided
4+
for light terminal backgrounds. Patch by Pablo Galindo.

0 commit comments

Comments
 (0)