Skip to content
130 changes: 129 additions & 1 deletion Lib/_colorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

# types
if False:
from typing import IO, Self, ClassVar
from typing import IO, Literal, Self, ClassVar
_theme: Theme


Expand Down Expand Up @@ -74,6 +74,19 @@ class ANSIColors:
setattr(NoColors, attr, "")


class CursesColors:
Comment thread
pablogsal marked this conversation as resolved.
"""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


#
# Experimental theming support (see gh-133346)
#
Expand Down Expand Up @@ -187,6 +200,114 @@ class Difflib(ThemeSection):
reset: str = ANSIColors.RESET


@dataclass(frozen=True, kw_only=True)
class LiveProfiler(ThemeSection):
Comment thread
pablogsal marked this conversation as resolved.
"""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 (for sorted column highlight)
sorted_header_fg: int = CursesColors.BLACK
sorted_header_bg: int = CursesColors.CYAN

# Normal header colors (non-sorted columns) - use reverse video style
normal_header_fg: int = CursesColors.BLACK
normal_header_bg: int = CursesColors.WHITE

# 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'
Comment thread
pablogsal marked this conversation as resolved.
background_style: Literal["dark", "light"] = "dark"


LiveProfilerLight = LiveProfiler(
# Header colors
title_fg=CursesColors.BLUE, # Blue is more readable than cyan on light bg

# Status display colors - darker colors for light backgrounds
pid_fg=CursesColors.BLUE,
uptime_fg=CursesColors.BLACK,
time_fg=CursesColors.BLACK,
interval_fg=CursesColors.BLUE,

# Thread view colors
thread_all_fg=CursesColors.BLACK,
thread_single_fg=CursesColors.BLUE,

# Stats colors
waiting_gil_fg=CursesColors.RED,
gc_fg=CursesColors.BLUE,

# Function display colors
func_total_fg=CursesColors.BLUE,
func_exec_fg=CursesColors.BLACK,
func_stack_fg=CursesColors.BLACK,
func_shown_fg=CursesColors.BLUE,

# Table header colors (for sorted column highlight)
sorted_header_fg=CursesColors.WHITE,
sorted_header_bg=CursesColors.BLUE,

# Normal header colors (non-sorted columns)
normal_header_fg=CursesColors.WHITE,
normal_header_bg=CursesColors.BLACK,

# Data row colors - use dark colors readable on white
samples_fg=CursesColors.BLACK,
file_fg=CursesColors.BLACK,
func_fg=CursesColors.BLUE, # Blue is more readable than magenta on light bg

# Medal colors for top functions
medal_silver_fg=CursesColors.BLUE,

# Background style
background_style="light",
)


@dataclass(frozen=True, kw_only=True)
class Syntax(ThemeSection):
prompt: str = ANSIColors.BOLD_MAGENTA
Expand Down Expand Up @@ -232,6 +353,7 @@ class Theme:
"""
argparse: Argparse = field(default_factory=Argparse)
difflib: Difflib = field(default_factory=Difflib)
live_profiler: LiveProfiler = field(default_factory=LiveProfiler)
syntax: Syntax = field(default_factory=Syntax)
traceback: Traceback = field(default_factory=Traceback)
unittest: Unittest = field(default_factory=Unittest)
Expand All @@ -241,6 +363,7 @@ def copy_with(
*,
argparse: Argparse | None = None,
difflib: Difflib | None = None,
live_profiler: LiveProfiler | None = None,
syntax: Syntax | None = None,
traceback: Traceback | None = None,
unittest: Unittest | None = None,
Expand All @@ -253,6 +376,7 @@ def copy_with(
return type(self)(
argparse=argparse or self.argparse,
difflib=difflib or self.difflib,
live_profiler=live_profiler or self.live_profiler,
syntax=syntax or self.syntax,
traceback=traceback or self.traceback,
unittest=unittest or self.unittest,
Expand All @@ -269,6 +393,7 @@ def no_colors(cls) -> Self:
return cls(
argparse=Argparse.no_colors(),
difflib=Difflib.no_colors(),
live_profiler=LiveProfiler.no_colors(),
syntax=Syntax.no_colors(),
traceback=Traceback.no_colors(),
unittest=Unittest.no_colors(),
Expand Down Expand Up @@ -338,6 +463,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(
*,
Expand Down
100 changes: 42 additions & 58 deletions Lib/profiling/sampling/live_collector/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
FINISHED_BANNER_EXTRA_LINES,
DEFAULT_SORT_BY,
DEFAULT_DISPLAY_LIMIT,
COLOR_PAIR_SAMPLES,
COLOR_PAIR_FILE,
COLOR_PAIR_FUNC,
COLOR_PAIR_HEADER_BG,
COLOR_PAIR_CYAN,
COLOR_PAIR_YELLOW,
Expand Down Expand Up @@ -552,79 +555,61 @@ 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

# Initialize all color pairs used throughout the UI
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
)
theme = _colorize.get_theme(force_color=True).live_profiler
default_bg = -1

self.display.init_color_pair(COLOR_PAIR_SAMPLES, theme.samples_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_FILE, theme.file_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_FUNC, theme.func_fg, default_bg)

# Normal header background color pair
self.display.init_color_pair(
COLOR_PAIR_RED, COLOR_RED, COLOR_BLACK
COLOR_PAIR_HEADER_BG,
theme.normal_header_fg,
theme.normal_header_bg,
)

self.display.init_color_pair(COLOR_PAIR_CYAN, theme.pid_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_YELLOW, theme.time_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_GREEN, theme.uptime_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_MAGENTA, theme.interval_fg, default_bg)
self.display.init_color_pair(COLOR_PAIR_RED, theme.off_gil_fg, default_bg)
self.display.init_color_pair(
COLOR_PAIR_SORTED_HEADER, COLOR_BLACK, COLOR_YELLOW
COLOR_PAIR_SORTED_HEADER,
theme.sorted_header_fg,
theme.sorted_header_bg,
)

TREND_UP_PAIR = 11
TREND_DOWN_PAIR = 12
self.display.init_color_pair(TREND_UP_PAIR, theme.trend_up_fg, default_bg)
self.display.init_color_pair(TREND_DOWN_PAIR, 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,
"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,
"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": self.display.get_color_pair(COLOR_PAIR_HEADER_BG) | A_BOLD,
"color_samples": self.display.get_color_pair(COLOR_PAIR_SAMPLES),
"color_file": self.display.get_color_pair(COLOR_PAIR_FILE),
"color_func": self.display.get_color_pair(COLOR_PAIR_FUNC),
"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
# Fallback for no-color mode
return {
"header": A_REVERSE | A_BOLD,
"cyan": A_BOLD,
Expand All @@ -637,7 +622,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,
Expand Down
3 changes: 3 additions & 0 deletions Lib/profiling/sampling/live_collector/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
OPCODE_PANEL_HEIGHT = 12 # Height reserved for opcode statistics panel

# Color pair IDs
COLOR_PAIR_SAMPLES = 1
COLOR_PAIR_FILE = 2
COLOR_PAIR_FUNC = 3
COLOR_PAIR_HEADER_BG = 4
COLOR_PAIR_CYAN = 5
COLOR_PAIR_YELLOW = 6
Expand Down
8 changes: 6 additions & 2 deletions Lib/profiling/sampling/live_collector/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,17 @@ def get_dimensions(self):
return self.stdscr.getmaxyx()

def clear(self):
self.stdscr.clear()
# Use erase() instead of clear() to avoid flickering
# clear() forces a complete screen redraw, erase() just clears the buffer
self.stdscr.erase()

def refresh(self):
self.stdscr.refresh()

def redraw(self):
self.stdscr.redrawwin()
# Use noutrefresh + doupdate for smoother updates
self.stdscr.noutrefresh()
curses.doupdate()

def add_str(self, line, col, text, attr=0):
try:
Expand Down
Loading
Loading