Skip to content

Commit

Permalink
auto colors and component classes
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan committed Nov 10, 2024
1 parent c942d8d commit 1d30d6a
Show file tree
Hide file tree
Showing 6 changed files with 71 additions and 44 deletions.
44 changes: 27 additions & 17 deletions src/textual/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ class Color(NamedTuple):
"""Alpha (opacity) component in range 0 to 1."""
ansi: int | None = None
"""ANSI color index. `-1` means default color. `None` if not an ANSI color."""
auto: bool = False
"""Is the color automatic? (automatic colors may be white or black, to provide maximum contrast)"""

@classmethod
def automatic(cls, alpha: float = 1.0) -> Color:
"""Create an automatic color."""
return cls(0, 0, 0, alpha, auto=True)

@classmethod
def from_rich_color(cls, rich_color: RichColor | None) -> Color:
Expand Down Expand Up @@ -205,7 +212,7 @@ def inverse(self) -> Color:
Returns:
Inverse color.
"""
r, g, b, a, _ = self
r, g, b, a, _, _ = self
return Color(255 - r, 255 - g, 255 - b, a)

@property
Expand All @@ -216,14 +223,15 @@ def is_transparent(self) -> bool:
@property
def clamped(self) -> Color:
"""A clamped color (this color with all values in expected range)."""
r, g, b, a, _ = self
r, g, b, a, ansi, auto = self
_clamp = clamp
color = Color(
_clamp(r, 0, 255),
_clamp(g, 0, 255),
_clamp(b, 0, 255),
_clamp(a, 0.0, 1.0),
self.ansi,
ansi,
auto,
)
return color

Expand All @@ -235,7 +243,7 @@ def rich_color(self) -> RichColor:
Returns:
A color object as used by Rich.
"""
r, g, b, _a, ansi = self
r, g, b, _a, ansi, _ = self
if ansi is not None:
return RichColor.parse("default") if ansi < 0 else RichColor.from_ansi(ansi)
return RichColor(
Expand All @@ -249,13 +257,13 @@ def normalized(self) -> tuple[float, float, float]:
Returns:
Normalized components.
"""
r, g, b, _a, _ = self
r, g, b, _a, _, _ = self
return (r / 255, g / 255, b / 255)

@property
def rgb(self) -> tuple[int, int, int]:
"""The red, green, and blue color components as a tuple of ints."""
r, g, b, _, _ = self
r, g, b, _, _, _ = self
return (r, g, b)

@property
Expand Down Expand Up @@ -288,7 +296,7 @@ def hex(self) -> str:
For example, `"#46b3de"` for an RGB color, or `"#3342457f"` for a color with alpha.
"""
r, g, b, a, ansi = self.clamped
r, g, b, a, ansi, _ = self.clamped
if ansi is not None:
return "ansi_default" if ansi == -1 else f"ansi_{ANSI_COLORS[ansi]}"
return (
Expand All @@ -303,7 +311,7 @@ def hex6(self) -> str:
For example, `"#46b3de"`.
"""
r, g, b, _a, _ = self.clamped
r, g, b, _a, _, _ = self.clamped
return f"#{r:02X}{g:02X}{b:02X}"

@property
Expand All @@ -312,7 +320,7 @@ def css(self) -> str:
For example, `"rgb(10,20,30)"` for an RGB color, or `"rgb(50,70,80,0.5)"` for an RGBA color.
"""
r, g, b, a, ansi = self
r, g, b, a, ansi, _ = self
if ansi is not None:
return "ansi_default" if ansi == -1 else f"ansi_{ANSI_COLORS[ansi]}"
return f"rgb({r},{g},{b})" if a == 1 else f"rgba({r},{g},{b},{a})"
Expand All @@ -324,12 +332,12 @@ def monochrome(self) -> Color:
Returns:
The monochrome (black and white) version of this color.
"""
r, g, b, a, _ = self
r, g, b, a, _, _ = self
gray = round(r * 0.2126 + g * 0.7152 + b * 0.0722)
return Color(gray, gray, gray, a)

def __rich_repr__(self) -> rich.repr.Result:
r, g, b, a, ansi = self
r, g, b, a, ansi, _ = self
yield r
yield g
yield b
Expand All @@ -345,7 +353,7 @@ def with_alpha(self, alpha: float) -> Color:
Returns:
A new color.
"""
r, g, b, _, _ = self
r, g, b, _, _, _ = self
return Color(r, g, b, alpha)

def multiply_alpha(self, alpha: float) -> Color:
Expand All @@ -359,7 +367,7 @@ def multiply_alpha(self, alpha: float) -> Color:
"""
if self.ansi is not None:
return self
r, g, b, a, _ = self
r, g, b, a, _, _ = self
return Color(r, g, b, a * alpha)

@lru_cache(maxsize=1024)
Expand All @@ -380,14 +388,16 @@ def blend(
Returns:
A new color.
"""
if destination.auto:
destination = self.get_contrast_text(destination.a)
if destination.ansi is not None:
return destination
if factor <= 0:
return self
elif factor >= 1:
return destination
r1, g1, b1, a1, _ = self
r2, g2, b2, a2, _ = destination
r1, g1, b1, a1, _, _ = self
r2, g2, b2, a2, _, _ = destination

if alpha is None:
new_alpha = a1 + (a2 - a1) * factor
Expand All @@ -414,10 +424,10 @@ def tint(self, color: Color) -> Color:
New color
"""

r1, g1, b1, a1, ansi1 = self
r1, g1, b1, a1, ansi1, _ = self
if ansi1 is not None:
return self
r2, g2, b2, a2, ansi2 = color
r2, g2, b2, a2, ansi2, _ = color
if ansi2 is not None:
return self
return Color(
Expand Down
2 changes: 1 addition & 1 deletion src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,7 @@ class CommandList(OptionList, can_focus=False):
}
CommandList > .option-list--option {
padding-left: 2;
padding: 0 2;
}
"""

Expand Down
11 changes: 4 additions & 7 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ def _justify_lines(

if justify == "left":
lines = [line.truncate(width, overflow=overflow, pad=True) for line in lines]

elif justify == "center":
lines = [line.center(width) for line in lines]
elif justify == "right":
Expand Down Expand Up @@ -658,7 +657,7 @@ def get_style(style: str, /) -> Style:
]
spans.sort(key=itemgetter(0, 1))

stack: list[int] = [0]
stack: list[int] = []
stack_append = stack.append
stack_pop = stack.remove

Expand All @@ -672,7 +671,8 @@ def get_current_style() -> Style:
cached_style = style_cache_get(cache_key)
if cached_style is not None:
return cached_style
current_style = combine([style_map[_style_id] for _style_id in cache_key])
styles = [style_map[_style_id] for _style_id in cache_key]
current_style = combine(styles)
style_cache[cache_key] = current_style
return current_style

Expand Down Expand Up @@ -890,10 +890,7 @@ def wrap(
new_lines = line.divide(offsets)
new_lines = [line.rstrip_end(width) for line in new_lines]
new_lines = _justify_lines(
new_lines,
width,
justify=justify,
overflow=overflow,
new_lines, width, justify=justify, overflow=overflow
)
new_lines = [line.truncate(width, overflow=overflow) for line in new_lines]
lines.extend(new_lines)
Expand Down
10 changes: 7 additions & 3 deletions src/textual/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class Style:
strike: bool | None = None
link: str | None = None
_meta: bytes | None = None
auto_color: bool = False

def __rich_repr__(self) -> rich.repr.Result:
yield None, self.background
Expand Down Expand Up @@ -142,12 +143,17 @@ def from_styles(cls, styles: StylesBase) -> Style:
text_style = styles.text_style
return Style(
styles.background,
styles.color,
(
Color(0, 0, 0, styles.color.a, auto=True)
if styles.auto_color
else styles.color
),
bold=text_style.bold,
dim=text_style.italic,
italic=text_style.italic,
underline=text_style.underline,
strike=text_style.strike,
auto_color=styles.auto_color,
)

@cached_property
Expand Down Expand Up @@ -389,8 +395,6 @@ def render_strips(
None if height is None else height - padding.height,
style,
)
for strip in strips:
print(strip.cell_length, repr(strip.text))

if padding:
rich_style = style.rich_style
Expand Down
28 changes: 23 additions & 5 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,9 @@ def __init__(
self._odd: tuple[int, bool] = (-1, False)
"""Used to cache :odd pseudoclass state."""

self._layout_cache: dict[str, object] = {}
"""A dict that is refreshed when the widget is resized / refreshed."""

@property
def is_mounted(self) -> bool:
"""Check if this widget is mounted."""
Expand Down Expand Up @@ -1015,17 +1018,27 @@ def get_component_rich_style(self, *names: str, partial: bool = False) -> Style:

return partial_style if partial else style

def get_visual_style(self, *names: str) -> VisualStyle:
def get_visual_style(self, component_classes: Iterable[str]) -> VisualStyle:
"""Get the visual style for the widget, including any component styles.
Args:
component_classes: Optional component styles.
Returns:
A Visual style instance.
"""
background = Color(0, 0, 0, 0)
color = Color(255, 255, 255, 0)

style = Style()
opacity = 1.0

def iter_styles() -> Iterable[StylesBase]:
"""Iterate over the styles from the DOM and additional components styles."""
for node in reversed(self.ancestors_with_self):
yield node.styles
for name in names:
for name in component_classes:
yield node.get_component_styles(name)

for styles in iter_styles():
Expand Down Expand Up @@ -3627,7 +3640,7 @@ def _size_updated(
Returns:
True if anything changed, or False if nothing changed.
"""

self._layout_cache.clear()
if (
self._size != size
or self.virtual_size != virtual_size
Expand Down Expand Up @@ -3818,7 +3831,7 @@ def refresh(
Returns:
The `Widget` instance.
"""

self._layout_cache.clear()
if layout:
self._layout_required = True
for ancestor in self.ancestors:
Expand Down Expand Up @@ -3933,8 +3946,13 @@ def _render(self) -> Visual:
Returns:
A Visual.
"""

cache_key = "_render.visual"
cached_visual = self._layout_cache.get(cache_key, None)
if cached_visual is not None:
assert isinstance(cached_visual, Visual)
return cached_visual
visual = visualize(self, self.render())
self._layout_cache[cache_key] = visual
return visual

async def run_action(self, action: str) -> None:
Expand Down
20 changes: 9 additions & 11 deletions src/textual/widgets/_option_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ def __init__(
}
"""A dictionary of option IDs and the option indexes they relate to."""

self._content_render_cache: LRUCache[tuple[int, Style, int], list[Strip]]
self._content_render_cache: LRUCache[tuple[int, str, int], list[Strip]]
self._content_render_cache = LRUCache(256)

self._lines: list[tuple[int, int]] | None = None
Expand Down Expand Up @@ -405,7 +405,6 @@ def get_content_width(self, container: Size, viewport: Size) -> int:
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
# Get the content height without requiring a refresh
# TODO: Internal data structure could be simplified
style = self.rich_style
_render_option_content = self._render_option_content
heights = [
len(_render_option_content(index, option, "", width))
Expand Down Expand Up @@ -465,14 +464,13 @@ def _render_option_content(
Args:
option_index: Option index to render.
renderable: The Option renderable.
style: The Rich style to render with.
width: The width of the renderable.
content: Render result for prompt.
component class: Additional component class.
width: Desired width of render.
Returns:
A list of strips.
"""

cache_key = (option_index, component_class, width)
if (strips := self._content_render_cache.get(cache_key, None)) is not None:
return strips
Expand All @@ -482,11 +480,11 @@ def _render_option_content(
if padding:
visual = Padding(visual, padding)

visual_style = (
self.get_visual_style("option-list--option", component_class)
if component_class
else self.get_visual_style("option-list--option")
)
component_class_list = ["option-list--option"]
if component_class:
component_class_list.append(component_class)

visual_style = self.get_visual_style(component_class_list)

strips = Visual.to_strips(self, visual, width, None, visual_style, pad=True)
style_meta = Style.from_meta({"option": option_index})
Expand Down

0 comments on commit 1d30d6a

Please sign in to comment.