diff --git a/src/textual/color.py b/src/textual/color.py index a8361fea38..e13bd5a888 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -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: @@ -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 @@ -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 @@ -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( @@ -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 @@ -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 ( @@ -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 @@ -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})" @@ -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 @@ -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: @@ -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) @@ -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 @@ -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( diff --git a/src/textual/command.py b/src/textual/command.py index c371040f3a..b82282a816 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -380,7 +380,7 @@ class CommandList(OptionList, can_focus=False): } CommandList > .option-list--option { - padding-left: 2; + padding: 0 2; } """ diff --git a/src/textual/content.py b/src/textual/content.py index 55b5d6bba2..cb1f88c4f0 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -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": @@ -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 @@ -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 @@ -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) diff --git a/src/textual/visual.py b/src/textual/visual.py index f7442cded1..0e01646636 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -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 @@ -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 @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index 7ed002d02b..d352f92246 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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.""" @@ -1015,7 +1018,16 @@ 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) @@ -1023,9 +1035,10 @@ def get_visual_style(self, *names: str) -> VisualStyle: 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(): @@ -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 @@ -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: @@ -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: diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 19cd617a80..85c5d422c7 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -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 @@ -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)) @@ -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 @@ -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})