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:
"""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):
"""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'
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