Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `OptionList.set_options` https://github.com/Textualize/textual/pull/6048
- Added `TextArea.suggestion` https://github.com/Textualize/textual/pull/6048
- Added `TextArea.placeholder` https://github.com/Textualize/textual/pull/6048
- Added `Widget.get_line_filters` and `App.get_line_filters` https://github.com/Textualize/textual/pull/6057

### Changed

- Breaking change: The `renderable` property on the `Static` widget has been changed to `content`. https://github.com/Textualize/textual/pull/6041
- Breaking change: Renamed `Label` constructor argument `renderable` to `content` for consistency https://github.com/Textualize/textual/pull/6045
- Breaking change: Optimization to line API to avoid applying background styles to widget content. In practice this means that you can no longer rely on blank Segments automatically getting the background color.

# [5.3.0] - 2025-08-07

Expand Down
25 changes: 20 additions & 5 deletions src/textual/_segment_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

import re
from functools import lru_cache
from typing import Iterable

from rich.segment import Segment
Expand All @@ -15,6 +16,20 @@
from textual.geometry import Size


@lru_cache(1024 * 8)
def make_blank(width, style: Style) -> Segment:
"""Make a blank segment.

Args:
width: Width of blank.
style: Style of blank.

Returns:
A single segment
"""
return Segment(" " * width, style)


class NoCellPositionForIndex(Exception):
pass

Expand Down Expand Up @@ -162,19 +177,19 @@ def line_pad(
"""
if pad_left and pad_right:
return [
Segment(" " * pad_left, style),
make_blank(pad_left, style),
*segments,
Segment(" " * pad_right, style),
make_blank(pad_right, style),
]
elif pad_left:
return [
Segment(" " * pad_left, style),
make_blank(pad_left, style),
*segments,
]
elif pad_right:
return [
*segments,
Segment(" " * pad_right, style),
make_blank(pad_right, style),
]
return list(segments)

Expand Down Expand Up @@ -215,7 +230,7 @@ def blank_lines(count: int) -> list[list[Segment]]:
Returns:
A list of blank lines.
"""
return [[Segment(" " * width, style)]] * count
return [[make_blank(width, style)]] * count

top_blank_lines = bottom_blank_lines = 0
vertical_excess_space = max(0, height - shape_height)
Expand Down
61 changes: 24 additions & 37 deletions src/textual/_styles_cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

from functools import lru_cache
from sys import intern
from typing import TYPE_CHECKING, Callable, Iterable, Sequence

import rich.repr
Expand All @@ -14,7 +13,7 @@
from textual._border import get_box, render_border_label, render_row
from textual._context import active_app
from textual._opacity import _apply_opacity
from textual._segment_tools import apply_hatch, line_pad, line_trim
from textual._segment_tools import apply_hatch, line_pad, line_trim, make_blank
from textual.color import TRANSPARENT, Color
from textual.constants import DEBUG
from textual.content import Content
Expand All @@ -34,20 +33,6 @@
RenderLineCallback: TypeAlias = Callable[[int], Strip]


@lru_cache(1024 * 8)
def make_blank(width, style: RichStyle) -> Segment:
"""Make a blank segment.

Args:
width: Width of blank.
style: Style of blank.

Returns:
A single segment
"""
return Segment(intern(" " * width), style)


@rich.repr.auto(angular=True)
class StylesCache:
"""Responsible for rendering CSS Styles and keeping a cache of rendered lines.
Expand Down Expand Up @@ -105,6 +90,7 @@ def is_dirty(self, y: int) -> bool:

def clear(self) -> None:
"""Clear the styles cache (will cause the content to re-render)."""

self._cache.clear()
self._dirty_lines.clear()

Expand All @@ -122,14 +108,15 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
border_title = widget._border_title
border_subtitle = widget._border_subtitle

base_background, background = widget._opacity_background_colors
base_background, background = widget.background_colors
styles = widget.styles
strips = self.render(
styles,
widget.region.size,
base_background,
background,
widget.render_line,
widget.get_line_filters(),
(
None
if border_title is None
Expand All @@ -149,7 +136,6 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
content_size=widget.content_region.size,
padding=styles.padding,
crop=crop,
filters=widget.app._filters,
opacity=widget.opacity,
ansi_theme=widget.app.ansi_theme,
)
Expand Down Expand Up @@ -177,12 +163,12 @@ def render(
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
filters: Sequence[LineFilter],
border_title: tuple[Content, Color, Color, Style] | None,
border_subtitle: tuple[Content, Color, Color, Style] | None,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None,
filters: Sequence[LineFilter] | None = None,
opacity: float = 1.0,
ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME,
) -> list[Strip]:
Expand Down Expand Up @@ -223,9 +209,7 @@ def render(

is_dirty = self._dirty_lines.__contains__
render_line = self.render_line
apply_filters = (
[] if filters is None else [filter for filter in filters if filter.enabled]
)

for y in crop.line_range:
if is_dirty(y) or y not in self._cache:
strip = render_line(
Expand All @@ -246,7 +230,7 @@ def render(
else:
strip = self._cache[y]

for filter in apply_filters:
for filter in filters:
strip = strip.apply_filter(filter, background)

if DEBUG:
Expand All @@ -263,6 +247,16 @@ def render(

return strips

@lru_cache(1024)
def get_inner_outer(
cls, base_background: Color, background: Color
) -> tuple[Style, Style]:
"""Get inner and outer background colors."""
return (
Style(background=base_background + background),
Style(background=base_background),
)

def render_line(
self,
styles: StylesBase,
Expand Down Expand Up @@ -319,9 +313,7 @@ def render_line(
) = styles.outline

from_color = RichStyle.from_color

inner = Style(background=(base_background + background))
outer = Style(background=base_background)
inner, outer = self.get_inner_outer(base_background, background)

def line_post(segments: Iterable[Segment]) -> Iterable[Segment]:
"""Apply effects to segments inside the border."""
Expand All @@ -343,7 +335,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
Returns:
New list of segments
"""

try:
app = active_app.get()
ansi_theme = app.ansi_theme
Expand All @@ -361,7 +352,6 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
line: Iterable[Segment]
# Draw top or bottom borders (A)
if (border_top and y == 0) or (border_bottom and y == height - 1):

is_top = y == 0
border_color = base_background + (
border_top_color if is_top else border_bottom_color
Expand Down Expand Up @@ -427,7 +417,7 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
elif (pad_top and y < gutter.top) or (
pad_bottom and y >= height - gutter.bottom
):
background_rich_style = from_color(bgcolor=background.rich_color)
background_rich_style = inner.rich_style
left_style = Style(
foreground=base_background + border_left_color.multiply_alpha(opacity)
)
Expand All @@ -450,15 +440,12 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
content_y = y - gutter.top
if content_y < content_height:
line = render_content_line(y - gutter.top)
line = line.adjust_cell_length(content_width)
line = line.adjust_cell_length(content_width, inner.rich_style)
else:
line = [make_blank(content_width, inner.rich_style)]
if inner:
line = Segment.apply_style(line, inner.rich_style)
if styles.text_opacity != 1.0:
line = TextOpacity.process_segments(
line, styles.text_opacity, ansi_theme
)
line = Strip.blank(content_width, inner.rich_style)

if (text_opacity := styles.text_opacity) != 1.0:
line = TextOpacity.process_segments(line, text_opacity, ansi_theme)
line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style))

if border_left or border_right:
Expand Down
9 changes: 8 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,14 @@ def __init__(
)
)

def get_line_filters(self) -> Sequence[LineFilter]:
"""Get currently enabled line filters.

Returns:
A list of [LineFilter][textual.filters.LineFilter] instances.
"""
return [filter for filter in self._filters if filter.enabled]

@property
def _is_devtools_connected(self) -> bool:
"""Is the app connected to the devtools?"""
Expand Down Expand Up @@ -3160,7 +3168,6 @@ async def _process_messages(
terminal_size: tuple[int, int] | None = None,
message_hook: Callable[[Message], None] | None = None,
) -> None:

self._thread_init()

async def app_prelude() -> bool:
Expand Down
16 changes: 1 addition & 15 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -1146,26 +1146,12 @@ def _get_subtitle_style_information(

@property
def background_colors(self) -> tuple[Color, Color]:
"""The background color and the color of the parent's background.

Returns:
`(<background color>, <color>)`
"""
base_background = background = BLACK
for node in reversed(self.ancestors_with_self):
styles = node.styles
base_background = background
background += styles.background.tint(styles.background_tint)
return (base_background, background)

@property
def _opacity_background_colors(self) -> tuple[Color, Color]:
"""Background colors adjusted for opacity.

Returns:
`(<background color>, <color>)`
"""
base_background = background = BLACK
base_background = background = Color(0, 0, 0, 0)
opacity = 1.0
for node in reversed(self.ancestors_with_self):
styles = node.styles
Expand Down
34 changes: 18 additions & 16 deletions src/textual/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,22 +240,26 @@ def truecolor_style(self, style: Style, background: RichColor) -> Style:
New style.
"""
terminal_theme = self._terminal_theme
color = style.color
if color is not None and color.triplet is None:
color = RichColor.from_rgb(
*color.get_truecolor(terminal_theme, foreground=True)
)
bgcolor = style.bgcolor
if bgcolor is not None and bgcolor.triplet is None:
bgcolor = RichColor.from_rgb(
*bgcolor.get_truecolor(terminal_theme, foreground=False)

changed = False
if (color := style.color) is not None:
if color.triplet is None:
color = RichColor.from_triplet(
color.get_truecolor(terminal_theme, foreground=True)
)
changed = True
if style.dim:
color = dim_color(background, color)
style += NO_DIM
changed = True

if (bgcolor := style.bgcolor) is not None and bgcolor.triplet is None:
bgcolor = RichColor.from_triplet(
bgcolor.get_truecolor(terminal_theme, foreground=False)
)
# Convert dim style to RGB
if style.dim and color is not None:
color = dim_color(background, color)
style += NO_DIM
changed = True

return style + Style.from_color(color, bgcolor)
return style + Style.from_color(color, bgcolor) if changed else style

def apply(self, segments: list[Segment], background: Color) -> list[Segment]:
"""Transform a list of segments.
Expand All @@ -269,9 +273,7 @@ def apply(self, segments: list[Segment], background: Color) -> list[Segment]:
"""
_Segment = Segment
truecolor_style = self.truecolor_style

background_rich_color = background.rich_color

return [
_Segment(
text,
Expand Down
2 changes: 2 additions & 0 deletions src/textual/renderables/text_opacity.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def process_segments(
):
invisible_style = _from_color(bgcolor=style.bgcolor)
yield _Segment(cell_len(text) * " ", invisible_style)
elif opacity == 1:
yield from segments
else:
filter = ANSIToTruecolor(ansi_theme)
for segment in filter.apply(list(segments), TRANSPARENT):
Expand Down
4 changes: 2 additions & 2 deletions src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@ def watch_scroll_x(self, old_value: float, new_value: float) -> None:
if self.show_horizontal_scrollbar:
self.horizontal_scrollbar.position = new_value
if round(old_value) != round(new_value):
self.refresh()
self.refresh(self.size.region)

def watch_scroll_y(self, old_value: float, new_value: float) -> None:
if self.show_vertical_scrollbar:
self.vertical_scrollbar.position = new_value
if round(old_value) != round(new_value):
self.refresh()
self.refresh(self.size.region)

def on_mount(self):
self._refresh_scrollbars()
Expand Down
2 changes: 1 addition & 1 deletion src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ def render(self) -> RenderableType:
background = styles.scrollbar_background
color = styles.scrollbar_color
if background.a < 1:
base_background, _ = self.parent._opacity_background_colors
base_background, _ = self.parent.background_colors
background = base_background + background
color = background + color
scrollbar_style = Style.from_color(color.rich_color, background.rich_color)
Expand Down
Loading
Loading