Skip to content

Commit 182277f

Browse files
authored
Merge pull request #6546 from Textualize/ansi-opacity
text opacity change
2 parents 3b04f31 + 49cbec4 commit 182277f

7 files changed

Lines changed: 266 additions & 27 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,26 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8-
## Unreleased
8+
## [8.2.7] - 2026-05-19
99

1010
### Added
1111

12-
- Added support for Kitty key protocol "Report all keys as escape codes" which enabled alt+backspace on Warp
13-
- Added support for detecting separate modifier keys for terminals that support the Kitty key protocol
14-
- Added `TEXTUAL_DISABLE_KITTY_KEY` env var to disable Kitty key protocol support (debug aid).
12+
- Added support for Kitty key protocol "Report all keys as escape codes" which enabled alt+backspace on Warp https://github.com/Textualize/textual/pull/6544
13+
- Added support for detecting separate modifier keys for terminals that support the Kitty key protocol https://github.com/Textualize/textual/pull/6544
14+
- Added `TEXTUAL_DISABLE_KITTY_KEY` env var to disable Kitty key protocol support (debug aid). https://github.com/Textualize/textual/pull/6544
1515

1616
### Changed
1717

18-
- Undo/redo/copy/cut/paste in TextArea will now work with cmd+ on supported terminals
19-
- In TextArea, ctrl+u will now delete a newline if the cursor is at the start
20-
- in TextArea alt+delete is now bound to delete word right
18+
- Undo/redo/copy/cut/paste in TextArea will now work with cmd+ on supported terminals https://github.com/Textualize/textual/pull/6543
19+
- In TextArea, ctrl+u will now delete a newline if the cursor is at the start https://github.com/Textualize/textual/pull/6543
20+
- in TextArea alt+delete is now bound to delete word right https://github.com/Textualize/textual/pull/6543
21+
- Text opacity applied to an ansi theme will now set the dim attribute if the opacity is < 50% https://github.com/Textualize/textual/pull/6546
2122

22-
## [8.2.6] - 2026-04-13
23+
### Fixed
24+
25+
- Fixed text opacity with ANSI themes creating RGB colors. https://github.com/Textualize/textual/pull/6546
26+
27+
## [8.2.6] - 2026-05-13
2328

2429
### Fixed
2530

@@ -3459,6 +3464,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
34593464
- New handler system for messages that doesn't require inheritance
34603465
- Improved traceback handling
34613466

3467+
[8.2.7]: https://github.com/Textualize/textual/compare/v8.2.6...v8.2.7
34623468
[8.2.6]: https://github.com/Textualize/textual/compare/v8.2.5...v8.2.6
34633469
[8.2.5]: https://github.com/Textualize/textual/compare/v8.2.4...v8.2.5
34643470
[8.2.4]: https://github.com/Textualize/textual/compare/v8.2.3...v8.2.4

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "8.2.6"
3+
version = "8.2.7"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/_styles_cache.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
112112

113113
base_background, background = widget.background_colors
114114
styles = widget.styles
115+
app = widget.app
115116
strips = self.render(
116117
styles,
117118
widget.region.size,
@@ -139,7 +140,8 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]:
139140
padding=styles.padding,
140141
crop=crop,
141142
opacity=widget.opacity,
142-
ansi_theme=widget.app.ansi_theme,
143+
ansi_theme=app.ansi_theme,
144+
native_ansi=app.native_ansi_color,
143145
)
144146

145147
if widget.auto_links:
@@ -173,6 +175,7 @@ def render(
173175
crop: Region | None = None,
174176
opacity: float = 1.0,
175177
ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME,
178+
native_ansi: bool = False,
176179
) -> list[Strip]:
177180
"""Render a widget content plus CSS styles.
178181
@@ -191,6 +194,7 @@ def render(
191194
filters: Additional post-processing for the segments.
192195
opacity: Widget opacity.
193196
ansi_theme: Theme for ANSI colors.
197+
native_ansi: Use native ANSI colors?
194198
195199
Returns:
196200
Rendered lines.
@@ -227,6 +231,7 @@ def render(
227231
border_subtitle,
228232
opacity,
229233
ansi_theme,
234+
native_ansi,
230235
)
231236
self._cache[y] = strip
232237
else:
@@ -273,6 +278,7 @@ def render_line(
273278
border_subtitle: tuple[Content, Color, Color, Style] | None,
274279
opacity: float,
275280
ansi_theme: TerminalTheme,
281+
native_ansi: bool,
276282
) -> Strip:
277283
"""Render a styled line.
278284
@@ -289,6 +295,8 @@ def render_line(
289295
border_title: Optional tuple of (title, color, background, style).
290296
border_subtitle: Optional tuple of (subtitle, color, background, style).
291297
opacity: Opacity of line.
298+
ansi_theme: ANSI theme.
299+
native_ansi: Use native ANSI colors?
292300
293301
Returns:
294302
A line of segments.
@@ -450,7 +458,9 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]:
450458
line = Strip.blank(content_width, inner.rich_style)
451459

452460
if (text_opacity := styles.text_opacity) != 1.0:
453-
line = TextOpacity.process_segments(line, text_opacity, ansi_theme)
461+
line = TextOpacity.process_segments(
462+
line, text_opacity, ansi_theme, native_ansi
463+
)
454464
if pad_left or pad_right:
455465
line = line_post(line_pad(line, pad_left, pad_right, inner.rich_style))
456466
else:

src/textual/renderables/text_opacity.py

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def process_segments(
5656
segments: Iterable[Segment],
5757
opacity: float,
5858
ansi_theme: TerminalTheme,
59+
native_ansi: bool = False,
5960
) -> Iterable[Segment]:
6061
"""Apply opacity to segments.
6162
@@ -64,6 +65,7 @@ def process_segments(
6465
opacity: Opacity to apply.
6566
ansi_theme: Terminal theme.
6667
background: Color of background.
68+
native_ansi: Allow ANSI colors.
6769
6870
Returns:
6971
Segments with applied opacity.
@@ -82,21 +84,67 @@ def process_segments(
8284
elif opacity == 1:
8385
yield from segments
8486
else:
85-
filter = ANSIToTruecolor(ansi_theme)
86-
for segment in filter.apply(list(segments), TRANSPARENT):
87-
# use Tuple rather than tuple so Python 3.7 doesn't complain
88-
text, style, control = cast(Tuple[str, Style, object], segment)
89-
if not style:
90-
yield segment
91-
continue
92-
93-
color = style.color
94-
bgcolor = style.bgcolor
95-
if color and color.triplet and bgcolor and bgcolor.triplet:
96-
color_style = _get_blended_style_cached(bgcolor, color, opacity)
97-
yield _Segment(text, style + color_style)
98-
else:
99-
yield segment
87+
if native_ansi:
88+
# Special case for native ANSI
89+
# Without RGB we can't accurately calculate the foreground color
90+
DIM = Style(dim=True)
91+
filter = ANSIToTruecolor(ansi_theme)
92+
segments_ = list(segments)
93+
for original_segment, segment in zip(
94+
segments_, filter.apply(segments_, TRANSPARENT)
95+
):
96+
if original_segment.style is not None:
97+
text, style, control = original_segment
98+
if (
99+
style is not None
100+
and style.bgcolor is not None
101+
and style.bgcolor.is_default
102+
):
103+
# If the opacity is less than or equal to 50%, then set the dim attribute
104+
if style.color is not None and opacity <= 0.5:
105+
yield Segment(text, style + DIM, control)
106+
else:
107+
yield original_segment
108+
continue
109+
# use Tuple rather than tuple so Python 3.7 doesn't complain
110+
text, style, control = segment
111+
if not style:
112+
yield segment
113+
continue
114+
115+
color = style.color
116+
bgcolor = style.bgcolor
117+
if (
118+
color is not None
119+
and color.triplet
120+
and bgcolor
121+
and bgcolor.triplet
122+
):
123+
color_style = _get_blended_style_cached(bgcolor, color, opacity)
124+
yield _Segment(text, style + color_style)
125+
else:
126+
yield segment
127+
else:
128+
filter = ANSIToTruecolor(ansi_theme)
129+
for segment in filter.apply(list(segments), TRANSPARENT):
130+
# use Tuple rather than tuple so Python 3.7 doesn't complain
131+
text, style, control = segment
132+
if not style:
133+
yield segment
134+
continue
135+
136+
color = style.color
137+
bgcolor = style.bgcolor
138+
if (
139+
color is not None
140+
and color.triplet
141+
and bgcolor
142+
and bgcolor.triplet
143+
):
144+
color_style = _get_blended_style_cached(bgcolor, color, opacity)
145+
yield _Segment(text, style + color_style)
146+
else:
147+
yield segment
100148

101149
def __rich_console__(
102150
self, console: Console, options: ConsoleOptions
Lines changed: 150 additions & 0 deletions
Loading

tests/snapshot_tests/test_snapshots.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4913,3 +4913,26 @@ async def on_mount(self) -> None:
49134913
self.theme = "dracula"
49144914

49154915
assert snap_compare(MDApp())
4916+
4917+
4918+
def test_text_opacity_native_ansi(snap_compare) -> None:
4919+
"""Test that text-opacity with ansi themes doesn't produce RGB colors.
4920+
4921+
Textual can't calculate text opacity with ANSI colors. As a compromize a text-opacity of 50% or less
4922+
sets the dim attribute on the style.
4923+
4924+
You should see 10 labels for wach level of opacity. For opacity 50% and under, the labels
4925+
should be dimmed.
4926+
"""
4927+
4928+
class TApp(App):
4929+
4930+
def on_load(self) -> None:
4931+
self.theme = "ansi-dark"
4932+
4933+
def compose(self) -> ComposeResult:
4934+
for n in range(11):
4935+
yield (label := Label(f"Text Opacity {n*10}%"))
4936+
label.styles.text_opacity = f"{n * 10}%"
4937+
4938+
assert snap_compare(TApp())

0 commit comments

Comments
 (0)