diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416fd003..f66641495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [1.1.15] - 2022-01-31 +## [0.1.15] - 2022-01-31 ### Added - Added Windows Driver -## [1.1.14] - 2022-01-09 +## [0.1.14] - 2022-01-09 ### Changed diff --git a/src/textual/color.py b/src/textual/color.py index e2dbb2af5..738427e32 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -552,32 +552,67 @@ class Color(NamedTuple): @classmethod def from_rich_color(cls, rich_color: RichColor) -> Color: - """Create color from Rich's color class.""" + """Create a new color from Rich's Color class. + + Args: + rich_color (RichColor): An instance of rich.color.Color. + + Returns: + Color: A new Color. + """ r, g, b = rich_color.get_truecolor() return cls(r, g, b) @classmethod def from_hls(cls, h: float, l: float, s: float) -> Color: + """Create a color from HLS components. + + Args: + h (float): Hue. + l (float): Lightness. + s (float): Saturation. + + Returns: + Color: A new color. + """ r, g, b = colorsys.hls_to_rgb(h, l, s) return cls(int(r * 255), int(g * 255), int(b * 255)) @classmethod def from_hsv(cls, h: float, s: float, v: float) -> Color: + """Create a color from HSV components. + + Args: + h (float): Hue + s (float): Saturation + v (float): Value + + Returns: + Color: A new Color. + """ r, g, b = colorsys.hsv_to_rgb(h, s, v) return cls(int(r * 255), int(g * 255), int(b * 255)) def __rich__(self) -> Text: + """A Rich method to show the color.""" r, g, b, _ = self return Text( - " " * 10, - style=Style.from_color(RichColor.default(), RichColor.from_rgb(r, g, b)), + f" {self!r} ", + style=Style.from_color( + self.get_contrast_text().rich_color, RichColor.from_rgb(r, g, b) + ), ) @property def saturate(self) -> Color: + """Get a color with all components saturated to maximum and minimum values.""" r, g, b, a = self + _clamp = clamp color = Color( - clamp(r, 0, 255), clamp(g, 0, 255), clamp(b, 0, 255), clamp(a, 0.0, 1.0) + _clamp(r, 0, 255), + _clamp(g, 0, 255), + _clamp(b, 0, 255), + _clamp(a, 0.0, 1.0), ) return color @@ -595,19 +630,21 @@ class Color(NamedTuple): @property def hls(self) -> HLS: + """Get the color as HLS.""" r, g, b = self.normalized hls = colorsys.rgb_to_hls(r, g, b) return HLS(*hls) @property def hsv(self) -> HSV: + """Get the color as HSV.""" r, g, b = self.normalized hsv = colorsys.rgb_to_hsv(r, g, b) return HSV(*hsv) @property def is_transparent(self) -> bool: - """Check if the color is transparent.""" + """Check if the color is transparent (alpha == 0).""" return self.a == 0 @property @@ -630,7 +667,7 @@ class Color(NamedTuple): """The color in CSS rgb or rgba form. Returns: - str: A CSS color, e.g. "rgb(10,20,30)" or "(rgb(50,70,80,0.5)" + str: A CSS style color, e.g. "rgb(10,20,30)" or "rgb(50,70,80,0.5)" """ r, g, b, a = self @@ -644,9 +681,18 @@ class Color(NamedTuple): yield "a", a def with_alpha(self, alpha: float) -> Color: + """Create a new color with the given alpha. + + Args: + alpha (float): New value for alpha. + + Returns: + Color: A new color. + """ r, g, b, a = self return Color(r, g, b, alpha) + @lru_cache(maxsize=2048) def blend(self, destination: Color, factor: float) -> Color: """Generate a new color between two colors. @@ -727,39 +773,52 @@ class Color(NamedTuple): return color def darken(self, amount: float) -> Color: + """Darken the color by a given amount. + + Args: + amount (float): Value between 0-1 to reduce luminance by. + + Returns: + Color: New color. + """ h, l, s = self.hls color = Color.from_hls(h, l - amount, s) return color.saturate def lighten(self, amount: float) -> Color: + """Lighten the color by a given amount. + + Args: + amount (float): Value between 0-1 to increase luminance by. + + Returns: + Color: New color. + """ return self.darken(-amount).saturate @property def brightness(self) -> float: + """Get the human perceptual brightness.""" r, g, b = self.normalized brightness = (299 * r + 587 * g + 114 * b) / 1000 return brightness - def calculate_contrast(self, color: Color) -> float: - return abs(self.brightness - color.brightness) - # brightness = (299 * R + 587 * G + 114 * B) / 1000 - - # l1 = self.hls.l - # l2 = color.hls.l - # l1, l2 = sorted([l1, l2]) - # return (l1 + 0.05) / (l2 + 0.05) - def get_contrast_text(self, alpha=0.95) -> Color: + """Get a light or dark color that best contrasts this color, for use with text. + + Args: + alpha (float, optional): An alpha value to adjust the pure white / black by. + Defaults to 0.95. + + Returns: + Color: A new color, either an off-white or off-black + """ white = self.blend(Color(255, 255, 255), alpha) black = self.blend(Color(0, 0, 0), alpha) - - white_contrast = self.calculate_contrast(white) - black_contrast = self.calculate_contrast(black) - - if white_contrast > black_contrast: - return white - else: - return black + brightness = self.brightness + white_contrast = abs(brightness - white.brightness) + black_contrast = abs(brightness - black.brightness) + return white if white_contrast > black_contrast else black class ColorPair(NamedTuple): @@ -782,22 +841,20 @@ class ColorPair(NamedTuple): """Get a Rich style, foreground adjusted for transparency.""" r, g, b, a = self.foreground if a == 0: - return Style( - color=self.background.rich_color, - bgcolor=self.background.rich_color, + return Style.from_color( + self.background.rich_color, self.background.rich_color ) elif a == 1: - return Style( - color=self.foreground.rich_color, - bgcolor=self.background.rich_color, + return Style.from_color( + self.foreground.rich_color, self.background.rich_color ) else: r2, g2, b2, _ = self.background - return Style( - color=RichColor.from_rgb( + return Style.from_color( + RichColor.from_rgb( r + (r2 - r) * a, g + (g2 - g) * a, b + (b2 - b) * a ), - bgcolor=self.background.rich_color, + self.background.rich_color, ) diff --git a/src/textual/palette.py b/src/textual/palette.py index 12e1144b8..b1302e6de 100644 --- a/src/textual/palette.py +++ b/src/textual/palette.py @@ -26,6 +26,7 @@ def generate_light( secondary: Color | None = None, warning: Color | None = None, error: Color | None = None, + success: Color | None = None, accent1: Color | None = None, accent2: Color | None = None, accent3: Color | None = None, @@ -33,6 +34,7 @@ def generate_light( surface: Color | None = None, luminosity_spread: float = 0.2, text_alpha: float = 0.98, + dark: bool = False, ) -> tuple[dict[str, Color], dict[str, Color]]: if secondary is None: @@ -44,6 +46,9 @@ def generate_light( if error is None: error = secondary + if success is None: + success = secondary + if accent1 is None: accent1 = primary @@ -54,19 +59,16 @@ def generate_light( accent3 = accent2 if background is None: - background = Color(245, 245, 245) + background = Color(0, 0, 0) if dark else Color(245, 245, 245) if surface is None: - surface = Color(229, 229, 229) + surface = Color.parse("#121212") if dark else Color(229, 229, 229) backgrounds: dict[str, Color] = {"background": background, "surface": surface} - foregrounds: dict[str, Color] = { - "on-background": background.get_contrast_text(text_alpha), - "on-surface": surface.get_contrast_text(text_alpha), - } + foregrounds: dict[str, Color] = {} - def luminosity_range() -> Iterable[tuple[str, float]]: - luminosity_step = luminosity_spread / 2 + def luminosity_range(spread) -> Iterable[tuple[str, float]]: + luminosity_step = spread / 2 for n in range(-2, +3): if n < 0: label = "-darken" @@ -78,27 +80,37 @@ def generate_light( yield (f"{label}{abs(n) if n else ''}"), n * luminosity_step COLORS = [ - ("background", background), - ("surface", surface), ("primary", primary), ("secondary", secondary), + ("background", background), + ("surface", surface), ("warning", warning), ("error", error), + ("success", success), ("accent1", accent1), ("accent2", accent2), ("accent3", accent3), ] + DARK_SHADES = {"primary", "secondary"} + for name, color in COLORS: - for shade_name, luminosity_delta in luminosity_range(): - - shade_color = color.lighten(luminosity_delta) - backgrounds[f"{name}{shade_name}"] = shade_color - + is_dark_shade = name in DARK_SHADES + spread = luminosity_spread / 2 if is_dark_shade else luminosity_spread + for shade_name, luminosity_delta in luminosity_range(spread): + if dark and is_dark_shade: + dark_background = background.blend(color, 8 / 100) + shade_color = dark_background.blend( + Color(255, 255, 255), spread + luminosity_delta + ) + backgrounds[f"{name}{shade_name}"] = shade_color + else: + shade_color = color.lighten(luminosity_delta) + backgrounds[f"{name}{shade_name}"] = shade_color for fade in range(3): text_color = shade_color.get_contrast_text(text_alpha) if fade > 0: - text_color = text_color.blend(shade_color, fade * 0.2) + text_color = text_color.blend(shade_color, fade * 0.20 + 0.25) on_name = f"on-{name}{shade_name}-fade{fade}" else: on_name = f"on-{name}{shade_name}" @@ -119,22 +131,31 @@ if __name__ == "__main__": backgrounds, foregrounds = generate_light( primary=Color.parse("#4caf50"), secondary=Color.parse("#ffa000"), + warning=Color.parse("#FDD835"), error=Color.parse("#ff5722"), + success=Color.parse("#558B2F"), + accent1=Color.parse("#1976D2"), + dark=True, ) - print(foregrounds) - for name, background in backgrounds.items(): + foreground = foregrounds[f"on-{name}"] + text = Text(f"{background.hex} ", style=f"{foreground.hex} on {background.hex}") for fade in range(3): if fade: foreground = foregrounds[f"on-{name}-fade{fade}"] else: foreground = foregrounds[f"on-{name}"] - console.print( - Padding(f"{background.hex} - {name}", 0), - justify="left", + text.append( + f"{name} ", style=f"{foreground.hex} on {background.hex}", - highlight=False, ) + + console.print( + Padding(text, 1), + justify="left", + style=f"on {background.hex}", + highlight=False, + )