Skip to content

Optimize gradient rendering with precomputed lookups#21

Open
maxludden wants to merge 1 commit intomainfrom
codex/write-v2-of-rich-gradient-for-performance
Open

Optimize gradient rendering with precomputed lookups#21
maxludden wants to merge 1 commit intomainfrom
codex/write-v2-of-rich-gradient-for-performance

Conversation

@maxludden
Copy link
Owner

@maxludden maxludden commented Feb 3, 2026

Motivation

  • Reduce per-character / per-cluster CPU overhead by avoiding repeated gamma/linear conversions and pow() calls during rendering.
  • Speed up common cases (single- and double-width clusters) by reusing precomputed styles for each column.
  • Keep behavior identical for uncommon cases by keeping safe fallback paths to the previous interpolation logic.

Description

  • Precompute linear-light color stop representations and refresh them when colors / bg_colors are set via _refresh_linear_stops and _to_linear_stops.
  • Add a per-render style lookup builder _build_style_lookup which returns foreground/background style LUTs limited by _MAX_PRECOMPUTED_WIDTH to bound memory use.
  • Populate per-column style LUTs with _build_color_lut and interpolate using precomputed linear stops through _interpolate_linear_color to avoid repeated conversion cost.
  • Use the lookup during rendering (__rich_console__) via _get_style_from_lookup for width 1 and 2 clusters, while falling back to _get_style_at_position for other sizes or out-of-range spans.
  • Ensure LUTs are refreshed on color setter paths so changes to colors / bg_colors remain consistent with lookups.

Testing

  • Ran ruff check src tests, which failed to run because the environment lacked the expected ruff binary / pyenv pointed to a missing Python version and reported ruff: command not found.
  • Ran python3 -m pytest, which did not run because pytest is not installed in the execution environment (No module named pytest).
  • No automated tests were executed successfully in this environment; code changes include conservative fallbacks to preserve correctness where precomputation is disabled.

Codex Task

Summary by Sourcery

Optimize gradient style computation by precomputing linear-light color data and per-column style lookups for common cluster widths while preserving existing behavior via fallbacks.

Enhancements:

  • Precompute linear-light foreground and background color stops and keep them in sync with color/bg_color setters.
  • Introduce bounded per-span style lookup tables to reuse interpolated foreground/background styles across columns for single- and double-width clusters.
  • Add optimized style retrieval paths that use precomputed lookups when available and fall back to the existing per-position interpolation logic otherwise.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 3, 2026

Reviewer's Guide

This PR optimizes gradient rendering by precomputing linear-light color stops and building bounded per-span style lookup tables used for fast style retrieval in the common single- and double-width cluster paths, while preserving the existing interpolation logic as a fallback for edge cases.

Sequence diagram for gradient rendering with precomputed style lookup

sequenceDiagram
    participant Console
    participant Gradient
    participant RenderLoop

    Console->>Gradient: __rich_console__(console, options)
    activate Gradient
    Gradient->>Console: render_lines(content, options, pad, new_lines)
    Console-->>Gradient: lines
    Gradient->>Gradient: _build_style_lookup(width)
    Gradient-->>Gradient: foreground_lut, background_lut
    loop per line
        Gradient->>RenderLoop: iterate segments
        loop per cluster
            RenderLoop->>Gradient: _get_style_from_lookup(position, cluster_width, width, foreground_lut, background_lut)
            alt width is 1 or 2 and index in range and LUT available
                Gradient-->>RenderLoop: precomputed Style
            else fallback path
                Gradient->>Gradient: _get_style_at_position(position, cluster_width, width)
                Gradient-->>RenderLoop: computed Style
            end
            RenderLoop->>Gradient: _merge_styles(base_style, style)
            RenderLoop->>Gradient: _apply_highlight_style(merged_style, cluster)
        end
    end
    Gradient-->>Console: rendered segments
    deactivate Gradient
Loading

Class diagram for updated Gradient gradient lookup and interpolation

classDiagram
    class Gradient {
        <<class>>
        _GAMMA_CORRECTION : float
        _MAX_PRECOMPUTED_WIDTH : int
        _active_stops
        _linear_foreground : list~tuple~float,float,float~~
        _linear_background : list~tuple~float,float,float~~
        _highlight_rules : list~_HighlightRule~
        __init__(self, text, colors, bg_colors, style, justify, end, highlight_words, vertical)
        colors(self) List~ColorTriplet~
        colors(self, colors List~ColorType~) void
        bg_colors(self) List~ColorTriplet~
        bg_colors(self, colors Optional~List~ColorType~~) void
        __rich_console__(self, console, options)
        _get_style_at_position(self, position int, width int, span int) Style
        _get_style_from_lookup(self, position int, width int, span int, foreground_lut list~Style~|None, background_lut list~Style~|None) Style
        _compute_fraction(self, position int, width int, span float) float
        _refresh_linear_stops(self) void
        _to_linear_stops(self, stops list~ColorTriplet~) list~tuple~float,float,float~~
        _build_style_lookup(self, span int) tuple~list~Style~|None, list~Style~|None~
        _build_color_lut(self, span int, stops list~ColorTriplet~, linear_stops list~tuple~float,float,float~, is_background bool) list~Style~
        _interpolate_linear_color(self, frac float, stops list~ColorTriplet~, linear_stops list~tuple~float,float,float~) tuple~float,float,float~~
        _merge_styles(original Style, gradient_style Style) Style
    }
Loading

File-Level Changes

Change Details Files
Precompute and cache linear-light color stops for foreground and background gradients and keep them in sync with color setters.
  • Introduce _linear_foreground and _linear_background attributes initialized in init.
  • Add _refresh_linear_stops to derive linear-light stops from current colors/bg_colors.
  • Call _refresh_linear_stops from init and whenever colors or bg_colors setters recompute _active_stops.
  • Implement _to_linear_stops to convert sRGB triplet stops to gamma-corrected linear-light tuples.
src/rich_gradient/gradient.py
Add bounded per-span style lookup tables and use them for fast style selection in the main render loop when possible.
  • Define _MAX_PRECOMPUTED_WIDTH to cap lookup table size.
  • Add rich_console integration that builds foreground/background LUTs via _build_style_lookup before iterating lines.
  • Implement _build_style_lookup to construct optional foreground/background LUTs, short-circuiting when span is non-positive or exceeds the configured maximum.
  • Implement _build_color_lut to populate a Style per column by computing the gradient color for that column and assigning it as foreground or background.
  • Introduce _get_style_from_lookup, which uses LUT entries for width-1 and width-2 clusters, falling back to _get_style_at_position for other widths or invalid spans and when LUTs are absent.
  • Replace direct calls to _get_style_at_position in the cluster rendering paths with _get_style_from_lookup.
src/rich_gradient/gradient.py
Factor gradient color interpolation into a reusable helper that operates on precomputed linear-light stops, reapplying gamma to return sRGB values.
  • Add _interpolate_linear_color to compute an interpolated sRGB color from a fractional position using precomputed linear-light stops.
  • Use _compute_fraction within _build_color_lut to compute the per-column fraction and then delegate color computation to _interpolate_linear_color.
  • Ensure interpolation clamps to the first/last stops for frac outside [0,1] and correctly interpolates within the appropriate stop segment using the configured gamma exponent.
src/rich_gradient/gradient.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The new _to_linear_stops / _interpolate_linear_color paths duplicate and slightly diverge from the existing _to_linear/to_srgb behavior (different gamma modeling and no reuse of existing helpers), which risks subtle visual differences; consider refactoring to share the same conversion utilities so behavior stays identical while still benefiting from precomputation.
  • In _get_style_from_lookup, the choice of index = position + (width // 2) for 2-cell clusters changes how the gradient is sampled compared to _get_style_at_position (which uses width in _compute_fraction); it may be worth confirming this matches the previous visual behavior or adjusting the LUT construction to explicitly handle double-width alignment.
  • When only one of foreground_lut/background_lut is present, _get_style_from_lookup allocates a new Style() on every call; you could avoid this per-cluster allocation by using a cached empty style or by merging directly into the non-empty style.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `_to_linear_stops` / `_interpolate_linear_color` paths duplicate and slightly diverge from the existing `_to_linear`/`to_srgb` behavior (different gamma modeling and no reuse of existing helpers), which risks subtle visual differences; consider refactoring to share the same conversion utilities so behavior stays identical while still benefiting from precomputation.
- In `_get_style_from_lookup`, the choice of `index = position + (width // 2)` for 2-cell clusters changes how the gradient is sampled compared to `_get_style_at_position` (which uses width in `_compute_fraction`); it may be worth confirming this matches the previous visual behavior or adjusting the LUT construction to explicitly handle double-width alignment.
- When only one of `foreground_lut`/`background_lut` is present, `_get_style_from_lookup` allocates a new `Style()` on every call; you could avoid this per-cluster allocation by using a cached empty style or by merging directly into the non-empty style.

## Individual Comments

### Comment 1
<location> `src/rich_gradient/gradient.py:503-505` </location>
<code_context>
+        Returns:
+            Style with appropriate foreground and/or background colors.
+        """
+        if span <= 0 or position < 0:
+            return self._get_style_at_position(position, width, span)
+        if width in {1, 2}:
+            if background_lut is None and foreground_lut is None:
+                return self._get_style_at_position(position, width, span)
</code_context>

<issue_to_address>
**issue (bug_risk):** Precomputed lookup changes how width is used in the gradient calculation for 2-cell clusters.

The LUT path samples a single precomputed column using `index = position + (width // 2)` and a LUT built with `width=1` in `_compute_fraction`, while `_get_style_at_position(position, width, span)` incorporates `width` into the gradient fraction. For `width == 2`, this changes behavior from approximating over the cluster width to sampling only at the center, which can subtly change the gradient, especially with phase/justify logic or near edges. If you need to preserve the old behavior for `width == 2`, consider either continuing to use `_get_style_at_position` in that case or updating the LUT generation to account for `width` in the fraction computation.
</issue_to_address>

### Comment 2
<location> `src/rich_gradient/gradient.py:482` </location>
<code_context>

         return Style(color=fg_style or None, bgcolor=bg_style or None)

+    def _get_style_from_lookup(
+        self,
+        position: int,
</code_context>

<issue_to_address>
**issue (complexity):** Consider simplifying the LUT optimization by unifying the style-selection helpers and eliminating unnecessary cached linear-stop state.

The new LUT path adds a second style pipeline and extra mutable state that you don’t really need. You can keep the optimization while simplifying in two main places:

---

### 1. Unify `_get_style_from_lookup` and `_get_style_at_position`

Right now, you have two parallel helpers and the LUT one has width-specific branching and falls back in several places. You can collapse this into a single helper that always computes the style and *internally* uses LUTs when possible:

```python
def _style_at(
    self,
    position: int,
    width: int,
    span: int,
    fg_lut: list[Style] | None,
    bg_lut: list[Style] | None,
) -> Style:
    # preserve all existing guards
    if span <= 0 or position < 0 or (fg_lut is None and bg_lut is None):
        return self._get_style_at_position(position, width, span)

    # width‑agnostic cluster indexing; clamp into span
    index = position + width // 2
    if index < 0 or index >= span:
        return self._get_style_at_position(position, width, span)

    fg = fg_lut[index] if fg_lut is not None else Style()
    bg = bg_lut[index] if bg_lut is not None else Style()
    return fg + bg
```

And update the rendering loop to only use this helper:

```python
style = self._style_at(
    column - cluster_width,
    cluster_width,
    width,
    foreground_lut,
    background_lut,
)
```

You can then drop `_get_style_from_lookup` entirely and keep `_get_style_at_position` as the single “slow path” implementation. This removes the width `{1, 2}` special case and the “which helper do I call?” mental branch, while preserving behavior where LUT cannot or should not be used.

---

### 2. Remove `_linear_foreground` / `_linear_background` state and simplify LUT building

The extra `_linear_*` attributes plus `_refresh_linear_stops` increase mutable state and sync points. Since LUT building is already O(span), you can derive linear stops on demand inside the LUT builder instead of maintaining them on the object.

You can replace `_refresh_linear_stops` and `_linear_*` with a small helper:

```python
def _to_linear_stops(
    self, stops: list[ColorTriplet]
) -> list[tuple[float, float, float]]:
    if not stops:
        return []

    def to_linear(c: float) -> float:
        return (c / 255.0) ** self._GAMMA_CORRECTION

    return [(to_linear(r), to_linear(g), to_linear(b)) for r, g, b in stops]
```

Then simplify your LUT API:

```python
def _build_fg_lut(self, span: int) -> list[Style] | None:
    if not self.colors:
        return None
    linear = self._to_linear_stops(self.colors)
    return self._build_color_lut(span, self.colors, linear, is_background=False)

def _build_bg_lut(self, span: int) -> list[Style] | None:
    if not self.bg_colors:
        return None
    linear = self._to_linear_stops(self.bg_colors)
    return self._build_color_lut(span, self.bg_colors, linear, is_background=True)

def _build_style_lookup(
    self, span: int
) -> tuple[list[Style] | None, list[Style] | None]:
    if span <= 0 or span > self._MAX_PRECOMPUTED_WIDTH:
        return None, None
    return self._build_fg_lut(span), self._build_bg_lut(span)
```

With this, you can remove:

- `self._linear_foreground`
- `self._linear_background`
- `_refresh_linear_stops`
- calls to `_refresh_linear_stops()` in `__init__`, `colors` setter, and `bg_colors` setter

This keeps all LUT benefits but reduces class-level state and makes LUT construction easier to follow.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +503 to +505
if span <= 0 or position < 0:
return self._get_style_at_position(position, width, span)
if width in {1, 2}:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Precomputed lookup changes how width is used in the gradient calculation for 2-cell clusters.

The LUT path samples a single precomputed column using index = position + (width // 2) and a LUT built with width=1 in _compute_fraction, while _get_style_at_position(position, width, span) incorporates width into the gradient fraction. For width == 2, this changes behavior from approximating over the cluster width to sampling only at the center, which can subtly change the gradient, especially with phase/justify logic or near edges. If you need to preserve the old behavior for width == 2, consider either continuing to use _get_style_at_position in that case or updating the LUT generation to account for width in the fraction computation.


return Style(color=fg_style or None, bgcolor=bg_style or None)

def _get_style_from_lookup(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider simplifying the LUT optimization by unifying the style-selection helpers and eliminating unnecessary cached linear-stop state.

The new LUT path adds a second style pipeline and extra mutable state that you don’t really need. You can keep the optimization while simplifying in two main places:


1. Unify _get_style_from_lookup and _get_style_at_position

Right now, you have two parallel helpers and the LUT one has width-specific branching and falls back in several places. You can collapse this into a single helper that always computes the style and internally uses LUTs when possible:

def _style_at(
    self,
    position: int,
    width: int,
    span: int,
    fg_lut: list[Style] | None,
    bg_lut: list[Style] | None,
) -> Style:
    # preserve all existing guards
    if span <= 0 or position < 0 or (fg_lut is None and bg_lut is None):
        return self._get_style_at_position(position, width, span)

    # width‑agnostic cluster indexing; clamp into span
    index = position + width // 2
    if index < 0 or index >= span:
        return self._get_style_at_position(position, width, span)

    fg = fg_lut[index] if fg_lut is not None else Style()
    bg = bg_lut[index] if bg_lut is not None else Style()
    return fg + bg

And update the rendering loop to only use this helper:

style = self._style_at(
    column - cluster_width,
    cluster_width,
    width,
    foreground_lut,
    background_lut,
)

You can then drop _get_style_from_lookup entirely and keep _get_style_at_position as the single “slow path” implementation. This removes the width {1, 2} special case and the “which helper do I call?” mental branch, while preserving behavior where LUT cannot or should not be used.


2. Remove _linear_foreground / _linear_background state and simplify LUT building

The extra _linear_* attributes plus _refresh_linear_stops increase mutable state and sync points. Since LUT building is already O(span), you can derive linear stops on demand inside the LUT builder instead of maintaining them on the object.

You can replace _refresh_linear_stops and _linear_* with a small helper:

def _to_linear_stops(
    self, stops: list[ColorTriplet]
) -> list[tuple[float, float, float]]:
    if not stops:
        return []

    def to_linear(c: float) -> float:
        return (c / 255.0) ** self._GAMMA_CORRECTION

    return [(to_linear(r), to_linear(g), to_linear(b)) for r, g, b in stops]

Then simplify your LUT API:

def _build_fg_lut(self, span: int) -> list[Style] | None:
    if not self.colors:
        return None
    linear = self._to_linear_stops(self.colors)
    return self._build_color_lut(span, self.colors, linear, is_background=False)

def _build_bg_lut(self, span: int) -> list[Style] | None:
    if not self.bg_colors:
        return None
    linear = self._to_linear_stops(self.bg_colors)
    return self._build_color_lut(span, self.bg_colors, linear, is_background=True)

def _build_style_lookup(
    self, span: int
) -> tuple[list[Style] | None, list[Style] | None]:
    if span <= 0 or span > self._MAX_PRECOMPUTED_WIDTH:
        return None, None
    return self._build_fg_lut(span), self._build_bg_lut(span)

With this, you can remove:

  • self._linear_foreground
  • self._linear_background
  • _refresh_linear_stops
  • calls to _refresh_linear_stops() in __init__, colors setter, and bg_colors setter

This keeps all LUT benefits but reduces class-level state and makes LUT construction easier to follow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant