Merge branch 'css' of github.com:Textualize/textual into dev-server

This commit is contained in:
Darren Burns
2022-04-11 15:44:57 +01:00
46 changed files with 3165 additions and 811 deletions

View File

@@ -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

73
docs/color_system.md Normal file
View File

@@ -0,0 +1,73 @@
_Note: This is kind of a living document, which might not form part of the user-facing documentation._
# Textual Color System
Textual's color system is a palette of colors for building TUIs, and a set of guidelines for how they should be used. Based loosely on Google's Material color system, the Textual color system ensures that elements in the TUI look aesthetically pleasing while maximizing legibility
## The colors
There are 10 base colors specified in the Textual Color System. Although it is unlikely that all will need to be specified, since some may be derived from others, and some defaults may not need to be changed.
A dark mode is automatically derived from the base colors. See Dark Mode below.
### Shades
Each color has 6 additional shades (3 darker and 3 lighter), giving a total of 7 shades per color. These are calculated automatically from the base colors.
### Primary and Secondary
The _primary_ and _secondary_ colors are used as a background for large areas of the interface, such as headers and sidebars. The secondary color is optional, and if not supplied will be set to be the same as primary. If supplied, the secondary color should be compliment the primary, and together can be considered the _branding colors_ as they have the greatest influence over the look of the TUI.
### Background and Surface
The _surface_ colors is the base color which goes behind text. The _background_ color is typically the negative space where there is no content.
These two colors tend to be very similar, with just enough difference in lightness to tell them apart. They should be chosen for good contrast with the text.
In light mode the default background is #efefef (a bright grey) and the surface is #f5f5f5 (off white). In dark mode the default background is 100% black, and the default surface is #121212 (very dark grey).
Note that, although both background and surface support the full range of shades, it may not be possible to darken or lighten them further. i.e. you can't get any lighter than 100% white or darken than 100% black.
### Panel
The _panel_ color is typically used as a background to emphasize text on the default surface, or as a UI element that is above the regular UI, such as a menu.
The default panel color is derived from the surface color by blending it towards either white or black text (depending on mode).
Unlike background and surface, the panel color is automatically selected so that it can always be lightened or darkened by the full degree.
### Accent
The _accent_ color should be a contrasting color use in UI elements that should stand out, such as selections, status bars, and underlines.
### Warning, Error, and Success
The _warning_, _error_, and _success_ colors have semantic meaning. While they could be any color, by convention warning should be amber / orange, error should be red, and success should be green.
### System
The system color is used for system controls such as scrollbars. The default is for the system color to be the same as accent, but it is recommended that a different color is chosen to differentiate app controls from those rendered by the Textual system.
## Text
For every color and shade there is an automatically calculated text color, which is either white or black, chosen to produce the greatest contrast.
The default text color as a slight alpha component, so that it not pure black or pure white, but a slight tint of the background showing through. Additionally, there are two text shades with increasingly greater alpha for reduced intensity text.
## Dark Mode
A dark mode is automatically generated from the theme. The dark variations of the primary and secondary colors are generated by blending with the background color. This ensures that the branding remains intact, while still providing dark backgrounds.
The dark variations of the background and surface color defaults are selected. The other colors remain the same as light mode. The overall effect is that the majority of the interface is dark, with small portions highlighted by color.
## Naming
The color system produces a number of constants which are exposed in the CSS via variables.
The name of the color will return one of the standard set of colors, for example `primary` or `panel`.
For one of the shade variations, you can append `-darken-1`, `-darken-2`, `-darken-3` for increasingly darker colors, and `-lighten-1`, `lighten-2`, `lighten-3` for increasingly light colors.
For the contrasting text color, prefix the name with `text-`, for instance `text-primary` or `text-panel`. Note that if the text is to be on top of a darkened or lightened color, it must also be included in the name. i.e. if the background is `primary-darken-2`, then the corresponding text color should be `text-primary-darken-2`.
The additional two levels of faded text may be requested by appending `-fade-1` or `-fade-2` for decreasing levels of text alpha.

View File

@@ -1,43 +0,0 @@
/* CSS file for basic.py */
$primary: #20639b;
App > Screen {
layout: dock;
docks: side=left/1;
text: on $primary;
}
Widget:hover {
outline: solid green;
}
#sidebar {
text: #09312e on #3caea3;
dock: side;
width: 30;
offset-x: -100%;
transition: offset 500ms in_out_cubic;
border-right: outer #09312e;
}
#sidebar.-active {
offset-x: 0;
}
#header {
text: white on #173f5f;
height: 3;
border: hkey;
}
#content {
text: white on $primary;
border-bottom: hkey #0f2b41;
}
#footer {
text: #3a3009 on #f6d55c;
height: 3;
}

View File

@@ -1,22 +0,0 @@
from textual.app import App
from textual.widget import Widget
class BasicApp(App):
"""A basic app demonstrating CSS"""
def on_load(self):
"""Bind keys here."""
self.bind("tab", "toggle_class('#sidebar', '-active')")
def on_mount(self):
"""Build layout here."""
self.mount(
header=Widget(),
content=Widget(),
footer=Widget(),
sidebar=Widget(),
)
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")

View File

@@ -4,14 +4,14 @@ Screen {
#borders {
layout: vertical;
text-background: #212121;
background: #212121;
overflow-y: scroll;
}
Lorem.border {
height: 12;
margin: 2 4;
text-background: #303f9f;
background: #303f9f;
}
Lorem.round {

193
sandbox/basic.css Normal file
View File

@@ -0,0 +1,193 @@
/* CSS file for basic.py */
* {
transition: color 300ms linear, background 300ms linear;
}
* {
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system;
scrollbar-color-active: $accent-darken-1;
}
App > Screen {
layout: dock;
docks: side=left/1;
background: $background;
color: $text-background;
}
#sidebar {
color: $text-primary;
background: $primary;
dock: side;
width: 30;
offset-x: -100%;
layout: dock;
transition: offset 500ms in_out_cubic;
}
#sidebar.-active {
offset-x: 0;
}
#sidebar .title {
height: 3;
background: $primary-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
}
#sidebar .user {
height: 8;
background: $primary-darken-1;
color: $text-primary-darken-1;
border-right: outer $primary-darken-3;
}
#sidebar .content {
background: $primary;
color: $text-primary;
border-right: outer $primary-darken-3;
}
#header {
color: $text-primary-darken-1;
background: $primary-darken-1;
height: 3;
}
#content {
color: $text-background;
background: $background;
layout: vertical;
overflow-y:scroll;
}
Tweet {
height: 22;
max-width: 80;
margin: 1 3;
background: $panel;
color: $text-panel;
layout: vertical;
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll
}
TweetHeader {
height:1
background: $accent
color: $text-accent
}
TweetBody {
background: $panel;
color: $text-panel;
height:20;
padding: 0 1 0 0;
}
.button {
background: $accent;
color: $text-accent;
width:20;
height: 3
/* border-top: hidden $accent-darken-3; */
border: tall $accent-darken-2;
/* border-left: tall $accent-darken-1; */
/* padding: 1 0 0 0 ; */
transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
}
.button:hover {
background: $accent-lighten-1;
color: $text-accent-lighten-1;
width: 20;
height: 3
border: tall $accent-darken-1;
/* border-left: tall $accent-darken-3; */
}
#footer {
color: $text-accent;
background: $accent;
height: 1;
border-top: hkey $accent-darken-2;
}
#sidebar .content {
layout: vertical
}
OptionItem {
height: 3;
background: $primary;
transition: background 100ms linear;
border-right: outer $primary-darken-2;
border-left: hidden;
}
OptionItem:hover {
height: 3;
color: $accent;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
border-left: outer $accent-darken-2;
}
Error {
max-width: 80;
height:3;
background: $error;
color: $text-error;
border-top: hkey $error-darken-2;
border-bottom: hkey $error-darken-2;
margin: 1 3;
text-style: bold;
}
Warning {
max-width: 80;
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: hkey $warning-darken-2;
border-bottom: hkey $warning-darken-2;
margin: 1 2;
text-style: bold;
}
Success {
max-width: 80;
height:3;
background: $success-lighten-3;
color: $text-success-lighten-3-fade-1;
border-top: hkey $success;
border-bottom: hkey $success;
margin: 1 2;
text-style: bold;
}

89
sandbox/basic.py Normal file
View File

@@ -0,0 +1,89 @@
from rich.align import Align
from rich.console import RenderableType
from rich.text import Text
from textual.app import App
from textual.widget import Widget
lorem = Text.from_markup(
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
)
class TweetHeader(Widget):
def render(self) -> RenderableType:
return Text("Lorem Impsum", justify="center")
class TweetBody(Widget):
def render(self) -> Text:
return lorem
class Tweet(Widget):
pass
class OptionItem(Widget):
def render(self) -> Text:
return Align.center(Text("Option", justify="center"), vertical="middle")
class Error(Widget):
def render(self) -> Text:
return Text("This is an error message", justify="center")
class Warning(Widget):
def render(self) -> Text:
return Text("This is a warning message", justify="center")
class Success(Widget):
def render(self) -> Text:
return Text("This is a success message", justify="center")
class BasicApp(App):
"""A basic app demonstrating CSS"""
def on_load(self):
"""Bind keys here."""
self.bind("tab", "toggle_class('#sidebar', '-active')")
def on_mount(self):
"""Build layout here."""
self.mount(
header=Widget(),
content=Widget(
Tweet(TweetBody(), Widget(classes={"button"})),
Error(),
Tweet(TweetBody()),
Warning(),
Tweet(TweetBody()),
Success(),
),
footer=Widget(),
sidebar=Widget(
Widget(classes={"title"}),
Widget(classes={"user"}),
OptionItem(),
OptionItem(),
OptionItem(),
Widget(classes={"content"}),
),
)
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def key_d(self):
self.dark = not self.dark
def key_x(self):
self.panic(self.tree)
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")

View File

@@ -5,22 +5,22 @@ $primary: #021720;
$secondary: #95d52a;
$background: #262626;
$primary-style: $text on $background;
$animation-speed: 500ms;
$animation: offset $animation-speed in_out_cubic;
$animatitext-speed: 500ms;
$animation: offset $animatitext-speed in_out_cubic;
App > View {
docks: side=left/1;
text: on $background;
background: $background;
}
Widget:hover {
outline: heavy;
text: bold !important;
text-style: bold !important;
}
#sidebar {
text: $primary-style;
color: $text;
background: $background;
dock: side;
width: 30;
offset-x: -100%;
@@ -33,7 +33,8 @@ Widget:hover {
}
#header {
text: $text on $primary;
color: $text;
background: $primary;
height: 3;
border-bottom: hkey $secondary;
}
@@ -43,7 +44,8 @@ Widget:hover {
}
#content {
text: $text on $background;
color: $text;
background: $background;
offset-y: -3;
}
@@ -53,7 +55,8 @@ Widget:hover {
#footer {
opacity: 1;
text: $text on $primary;
color: $text;
background: $background;
height: 3;
border-top: hkey $secondary;
}

View File

@@ -1,7 +1,7 @@
$background: #021720;
App > View {
text: on $background;
background: $background;
}
#info {

View File

@@ -1,6 +1,7 @@
#uber1 {
layout: vertical;
text: on dark_green;
background: dark_green;
overflow: hidden auto;
border: heavy white;
}
@@ -8,5 +9,5 @@
.list-item {
height: 8;
min-width: 80;
text-background: dark_blue;
background: dark_blue;
}

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
import sys
from time import time
from time import monotonic
from typing import Any, Callable, TypeVar
from dataclasses import dataclass
@@ -36,6 +36,9 @@ class Animation(ABC):
def __call__(self, time: float) -> bool: # pragma: no cover
raise NotImplementedError("")
def __eq__(self, other: object) -> bool:
return False
@dataclass
class SimpleAnimation(Animation):
@@ -85,6 +88,14 @@ class SimpleAnimation(Animation):
setattr(self.obj, self.attribute, value)
return factor >= 1
def __eq__(self, other: object) -> bool:
if isinstance(other, SimpleAnimation):
return (
self.final_value == other.final_value
and self.duration == other.duration
)
return False
class BoundAnimator:
def __init__(self, animator: Animator, obj: object) -> None:
@@ -130,7 +141,7 @@ class Animator:
def get_time(self) -> float:
"""Get the current wall clock time."""
return time()
return monotonic()
async def start(self) -> None:
"""Start the animator task."""
@@ -181,12 +192,10 @@ class Animator:
start_time = self.get_time()
animation_key = (id(obj), attribute)
if animation_key in self._animations:
self._animations[animation_key](start_time)
easing_function = EASING[easing] if isinstance(easing, str) else easing
animation: Animation
animation: Animation | None = None
if hasattr(obj, "__textual_animation__"):
animation = getattr(obj, "__textual_animation__")(
attribute,
@@ -196,7 +205,7 @@ class Animator:
speed=speed,
easing=easing_function,
)
else:
if animation is None:
start_value = getattr(obj, attribute)
if start_value == value:
@@ -219,6 +228,11 @@ class Animator:
easing=easing_function,
)
assert animation is not None, "animation expected to be non-None"
current_animation = self._animations.get(animation_key)
if current_animation is not None and current_animation == animation:
return
self._animations[animation_key] = animation
self._timer.resume()
@@ -236,4 +250,4 @@ class Animator:
def on_animation_frame(self) -> None:
# TODO: We should be able to do animation without refreshing everything
self.target.screen.refresh(layout=True)
self.target.screen.refresh_layout()

View File

@@ -7,6 +7,7 @@ import rich.repr
from rich.segment import Segment, SegmentLines
from rich.style import Style, StyleType
from .color import Color
from .css.types import EdgeStyle, EdgeType
@@ -16,6 +17,7 @@ OUTER = 2
BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
"": (" ", " ", " "),
"none": (" ", " ", " "),
"hidden": (" ", " ", " "),
"round": ("╭─╮", "│ │", "╰─╯"),
"solid": ("┌─┐", "│ │", "└─┘"),
"double": ("╔═╗", "║ ║", "╚═╝"),
@@ -36,6 +38,7 @@ BORDER_LOCATIONS: dict[
] = {
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"round": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"solid": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"double": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
@@ -117,9 +120,9 @@ class Border:
self,
renderable: RenderableType,
edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle],
inner_color: Color,
outer_color: Color,
outline: bool = False,
inner_color: Color | None = None,
outer_color: Color | None = None,
):
self.renderable = renderable
self.edge_styles = edge_styles
@@ -135,13 +138,17 @@ class Border:
from_color = Style.from_color
self._styles = (
from_color(top_color),
from_color(right_color),
from_color(bottom_color),
from_color(left_color),
from_color(top_color.rich_color),
from_color(right_color.rich_color),
from_color(bottom_color.rich_color),
from_color(left_color.rich_color),
)
self.inner_style = from_color(bgcolor=inner_color)
self.outer_style = from_color(bgcolor=outer_color)
self.inner_style = from_color(bgcolor=inner_color.rich_color)
self.outer_style = from_color(bgcolor=outer_color.rich_color)
def __rich_repr__(self) -> rich.repr.Result:
yield self.renderable
yield self.edge_styles
def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None:
"""Crops a renderable in place.
@@ -245,7 +252,7 @@ class Border:
yield new_line
if has_bottom:
box1, box2, box3 = get_box(top, style, outer_style, bottom_style)[2]
box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2]
if has_left:
yield box1 if bottom == left else _Segment(" ", box1.style)
yield _Segment(box2.text * width, box2.style)
@@ -256,10 +263,11 @@ class Border:
if __name__ == "__main__":
from rich import print
from rich.color import Color
from rich.text import Text
from rich.padding import Padding
from .color import Color
inner = Color.parse("#303F9F")
outer = Color.parse("#212121")
@@ -268,10 +276,10 @@ if __name__ == "__main__":
border = Border(
Padding(text, 1, style="on #303F9F"),
(
("none", Color.parse("#C5CAE9")),
("none", Color.parse("#C5CAE9")),
("wide", Color.parse("#C5CAE9")),
("wide", Color.parse("#C5CAE9")),
("wide", Color.parse("#C5CAE9")),
("wide", Color.parse("#C5CAE9")),
("none", Color.parse("#C5CAE9")),
),
inner_color=inner,
outer_color=outer,

View File

@@ -0,0 +1,239 @@
from __future__ import annotations
ANSI_COLOR_TO_RGB: dict[str, tuple[int, int, int]] = {
"black": (0, 0, 0),
"red": (128, 0, 0),
"green": (0, 128, 0),
"yellow": (128, 128, 0),
"blue": (0, 0, 128),
"magenta": (128, 0, 128),
"cyan": (0, 128, 128),
"white": (192, 192, 192),
"bright_black": (128, 128, 128),
"bright_red": (255, 0, 0),
"bright_green": (0, 255, 0),
"bright_yellow": (255, 255, 0),
"bright_blue": (0, 0, 255),
"bright_magenta": (255, 0, 255),
"bright_cyan": (0, 255, 255),
"bright_white": (255, 255, 255),
"grey0": (0, 0, 0),
"gray0": (0, 0, 0),
"navy_blue": (0, 0, 95),
"dark_blue": (0, 0, 135),
"blue3": (0, 0, 215),
"blue1": (0, 0, 255),
"dark_green": (0, 95, 0),
"deep_sky_blue4": (0, 95, 175),
"dodger_blue3": (0, 95, 215),
"dodger_blue2": (0, 95, 255),
"green4": (0, 135, 0),
"spring_green4": (0, 135, 95),
"turquoise4": (0, 135, 135),
"deep_sky_blue3": (0, 135, 215),
"dodger_blue1": (0, 135, 255),
"green3": (0, 215, 0),
"spring_green3": (0, 215, 95),
"dark_cyan": (0, 175, 135),
"light_sea_green": (0, 175, 175),
"deep_sky_blue2": (0, 175, 215),
"deep_sky_blue1": (0, 175, 255),
"spring_green2": (0, 255, 95),
"cyan3": (0, 215, 175),
"dark_turquoise": (0, 215, 215),
"turquoise2": (0, 215, 255),
"green1": (0, 255, 0),
"spring_green1": (0, 255, 135),
"medium_spring_green": (0, 255, 175),
"cyan2": (0, 255, 215),
"cyan1": (0, 255, 255),
"dark_red": (135, 0, 0),
"deep_pink4": (175, 0, 95),
"purple4": (95, 0, 175),
"purple3": (95, 0, 215),
"blue_violet": (95, 0, 255),
"orange4": (135, 95, 0),
"grey37": (95, 95, 95),
"gray37": (95, 95, 95),
"medium_purple4": (95, 95, 135),
"slate_blue3": (95, 95, 215),
"royal_blue1": (95, 95, 255),
"chartreuse4": (95, 135, 0),
"dark_sea_green4": (95, 175, 95),
"pale_turquoise4": (95, 135, 135),
"steel_blue": (95, 135, 175),
"steel_blue3": (95, 135, 215),
"cornflower_blue": (95, 135, 255),
"chartreuse3": (95, 215, 0),
"cadet_blue": (95, 175, 175),
"sky_blue3": (95, 175, 215),
"steel_blue1": (95, 215, 255),
"pale_green3": (135, 215, 135),
"sea_green3": (95, 215, 135),
"aquamarine3": (95, 215, 175),
"medium_turquoise": (95, 215, 215),
"chartreuse2": (135, 215, 0),
"sea_green2": (95, 255, 95),
"sea_green1": (95, 255, 175),
"aquamarine1": (135, 255, 215),
"dark_slate_gray2": (95, 255, 255),
"dark_magenta": (135, 0, 175),
"dark_violet": (175, 0, 215),
"purple": (175, 0, 255),
"light_pink4": (135, 95, 95),
"plum4": (135, 95, 135),
"medium_purple3": (135, 95, 215),
"slate_blue1": (135, 95, 255),
"yellow4": (135, 175, 0),
"wheat4": (135, 135, 95),
"grey53": (135, 135, 135),
"gray53": (135, 135, 135),
"light_slate_grey": (135, 135, 175),
"light_slate_gray": (135, 135, 175),
"medium_purple": (135, 135, 215),
"light_slate_blue": (135, 135, 255),
"dark_olive_green3": (175, 215, 95),
"dark_sea_green": (135, 175, 135),
"light_sky_blue3": (135, 175, 215),
"sky_blue2": (135, 175, 255),
"dark_sea_green3": (175, 215, 135),
"dark_slate_gray3": (135, 215, 215),
"sky_blue1": (135, 215, 255),
"chartreuse1": (135, 255, 0),
"light_green": (135, 255, 135),
"pale_green1": (175, 255, 135),
"dark_slate_gray1": (135, 255, 255),
"red3": (215, 0, 0),
"medium_violet_red": (175, 0, 135),
"magenta3": (215, 0, 215),
"dark_orange3": (215, 95, 0),
"indian_red": (215, 95, 95),
"hot_pink3": (215, 95, 135),
"medium_orchid3": (175, 95, 175),
"medium_orchid": (175, 95, 215),
"medium_purple2": (175, 135, 215),
"dark_goldenrod": (175, 135, 0),
"light_salmon3": (215, 135, 95),
"rosy_brown": (175, 135, 135),
"grey63": (175, 135, 175),
"gray63": (175, 135, 175),
"medium_purple1": (175, 135, 255),
"gold3": (215, 175, 0),
"dark_khaki": (175, 175, 95),
"navajo_white3": (175, 175, 135),
"grey69": (175, 175, 175),
"gray69": (175, 175, 175),
"light_steel_blue3": (175, 175, 215),
"light_steel_blue": (175, 175, 255),
"yellow3": (215, 215, 0),
"dark_sea_green2": (175, 255, 175),
"light_cyan3": (175, 215, 215),
"light_sky_blue1": (175, 215, 255),
"green_yellow": (175, 255, 0),
"dark_olive_green2": (175, 255, 95),
"dark_sea_green1": (215, 255, 175),
"pale_turquoise1": (175, 255, 255),
"deep_pink3": (215, 0, 135),
"magenta2": (255, 0, 215),
"hot_pink2": (215, 95, 175),
"orchid": (215, 95, 215),
"medium_orchid1": (255, 95, 255),
"orange3": (215, 135, 0),
"light_pink3": (215, 135, 135),
"pink3": (215, 135, 175),
"plum3": (215, 135, 215),
"violet": (215, 135, 255),
"light_goldenrod3": (215, 175, 95),
"tan": (215, 175, 135),
"misty_rose3": (215, 175, 175),
"thistle3": (215, 175, 215),
"plum2": (215, 175, 255),
"khaki3": (215, 215, 95),
"light_goldenrod2": (255, 215, 135),
"light_yellow3": (215, 215, 175),
"grey84": (215, 215, 215),
"gray84": (215, 215, 215),
"light_steel_blue1": (215, 215, 255),
"yellow2": (215, 255, 0),
"dark_olive_green1": (215, 255, 135),
"honeydew2": (215, 255, 215),
"light_cyan1": (215, 255, 255),
"red1": (255, 0, 0),
"deep_pink2": (255, 0, 95),
"deep_pink1": (255, 0, 175),
"magenta1": (255, 0, 255),
"orange_red1": (255, 95, 0),
"indian_red1": (255, 95, 135),
"hot_pink": (255, 95, 215),
"dark_orange": (255, 135, 0),
"salmon1": (255, 135, 95),
"light_coral": (255, 135, 135),
"pale_violet_red1": (255, 135, 175),
"orchid2": (255, 135, 215),
"orchid1": (255, 135, 255),
"orange1": (255, 175, 0),
"sandy_brown": (255, 175, 95),
"light_salmon1": (255, 175, 135),
"light_pink1": (255, 175, 175),
"pink1": (255, 175, 215),
"plum1": (255, 175, 255),
"gold1": (255, 215, 0),
"navajo_white1": (255, 215, 175),
"misty_rose1": (255, 215, 215),
"thistle1": (255, 215, 255),
"yellow1": (255, 255, 0),
"light_goldenrod1": (255, 255, 95),
"khaki1": (255, 255, 135),
"wheat1": (255, 255, 175),
"cornsilk1": (255, 255, 215),
"grey100": (255, 255, 255),
"gray100": (255, 255, 255),
"grey3": (8, 8, 8),
"gray3": (8, 8, 8),
"grey7": (18, 18, 18),
"gray7": (18, 18, 18),
"grey11": (28, 28, 28),
"gray11": (28, 28, 28),
"grey15": (38, 38, 38),
"gray15": (38, 38, 38),
"grey19": (48, 48, 48),
"gray19": (48, 48, 48),
"grey23": (58, 58, 58),
"gray23": (58, 58, 58),
"grey27": (68, 68, 68),
"gray27": (68, 68, 68),
"grey30": (78, 78, 78),
"gray30": (78, 78, 78),
"grey35": (88, 88, 88),
"gray35": (88, 88, 88),
"grey39": (98, 98, 98),
"gray39": (98, 98, 98),
"grey42": (108, 108, 108),
"gray42": (108, 108, 108),
"grey46": (118, 118, 118),
"gray46": (118, 118, 118),
"grey50": (128, 128, 128),
"gray50": (128, 128, 128),
"grey54": (138, 138, 138),
"gray54": (138, 138, 138),
"grey58": (148, 148, 148),
"gray58": (148, 148, 148),
"grey62": (158, 158, 158),
"gray62": (158, 158, 158),
"grey66": (168, 168, 168),
"gray66": (168, 168, 168),
"grey70": (178, 178, 178),
"gray70": (178, 178, 178),
"grey74": (188, 188, 188),
"gray74": (188, 188, 188),
"grey78": (198, 198, 198),
"gray78": (198, 198, 198),
"grey82": (208, 208, 208),
"gray82": (208, 208, 208),
"grey85": (218, 218, 218),
"gray85": (218, 218, 218),
"grey89": (228, 228, 228),
"gray89": (228, 228, 228),
"grey93": (238, 238, 238),
"gray93": (238, 238, 238),
}

View File

@@ -28,6 +28,8 @@ from .geometry import Region, Offset, Size
from ._loop import loop_last
from ._profile import timer
from ._segment_tools import line_crop
from ._types import Lines
from .widget import Widget
@@ -182,6 +184,7 @@ class Compositor:
size = root.size
map: RenderRegionMap = {}
widgets: set[Widget] = set()
get_order = attrgetter("order")
def add_widget(
widget: Widget,
@@ -207,24 +210,30 @@ class Compositor:
# Container region is minus border
container_region = region.shrink(widget.styles.gutter)
container_size = container_region.size
# Containers (widgets with layout) require adding children
if widget.layout is not None:
scroll_offset = widget.scroll_offset
# The region that contains the content (container region minus scrollbars)
child_region = widget._arrange_container(container_region)
# Adjust the clip region accordingly
sub_clip = clip.intersection(child_region)
# The region covered by children relative to parent widget
total_region = child_region.reset_origin
# Arrange the layout
placements, arranged_widgets = widget.layout.arrange(
widget, child_region.size, scroll_offset
widget, child_region.size, widget.scroll_offset
)
widgets.update(arranged_widgets)
placements = sorted(placements, key=attrgetter("order"))
placements = sorted(placements, key=get_order)
# An offset added to all placements
placement_offset = (
container_region.origin + layout_offset - widget.scroll_offset
)
# Add all the widgets
for sub_region, sub_widget, z in placements:
@@ -233,21 +242,21 @@ class Compositor:
if sub_widget is not None:
add_widget(
sub_widget,
sub_region + child_region.origin - scroll_offset,
sub_widget.z + (z,),
sub_region + placement_offset,
order + (z,),
sub_clip,
)
# Add any scrollbars
for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region.size
container_size
):
map[chrome_widget] = RenderRegion(
chrome_region + container_region.origin + layout_offset,
order,
clip,
container_region.size,
container_region.size,
container_size,
container_size,
)
# Add the container widget, which will render a background
@@ -256,21 +265,17 @@ class Compositor:
order,
clip,
total_region.size,
container_region.size,
container_size,
)
else:
# Add the widget to the map
map[widget] = RenderRegion(
region + layout_offset,
order,
clip,
region.size,
container_region.size,
region + layout_offset, order, clip, region.size, container_size
)
# Add top level (root) widget
add_widget(root, size.region, (), size.region)
add_widget(root, size.region, (0,), size.region)
return map, widgets
def __iter__(self) -> Iterator[tuple[Widget, Region, Region, Size, Size]]:
@@ -322,14 +327,14 @@ class Compositor:
return Style.null()
if widget not in self.regions:
return Style.null()
lines = widget.get_render_lines()
x -= region.x
y -= region.y
if y > len(lines):
lines = widget.get_render_lines(y, y + 1)
if not lines:
return Style.null()
line = lines[y]
end = 0
for segment in line:
for segment in lines[0]:
end += segment.cell_length
if x < end:
return segment.style or Style.null()
@@ -409,24 +414,21 @@ class Compositor:
else:
widget_regions = []
divide = Segment.divide
intersection = Region.intersection
overlaps = Region.overlaps
for widget, region, _order, clip in widget_regions:
if not region:
# log(widget, region)
continue
if region in clip:
yield region, clip, widget.get_render_lines()
elif overlaps(clip, region):
lines = widget.get_render_lines()
new_x, new_y, new_width, new_height = intersection(region, clip)
delta_x = new_x - region.x
delta_y = new_y - region.y
splits = [delta_x, delta_x + new_width]
lines = lines[delta_y : delta_y + new_height]
lines = [list(divide(line, splits))[1] for line in lines]
crop_x = delta_x + new_width
lines = widget.get_render_lines(delta_y, delta_y + new_height)
lines = [line_crop(line, delta_x, crop_x) for line in lines]
yield region, clip, lines
@classmethod
@@ -458,7 +460,6 @@ class Compositor:
Returns:
SegmentLines: A renderable
"""
width, height = self.size
screen_region = Region(0, 0, width, height)
@@ -494,6 +495,8 @@ class Compositor:
cut_segments = [line]
else:
# More than one cut, which means we need to divide the line
# if not final_cuts:
# continue
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts]
_, *cut_segments = divide(line, relative_cuts)
@@ -508,13 +511,11 @@ class Compositor:
crop_x, crop_y, crop_x2, crop_y2 = crop_region.corners
render_lines = self._assemble_chops(chops[crop_y:crop_y2])
def width_view(line: list[Segment]) -> list[Segment]:
div_lines = list(divide(line, [crop_x, crop_x2]))
line = div_lines[1] if len(div_lines) > 1 else div_lines[0]
return line
if crop is not None and (crop_x, crop_x2) != (0, width):
render_lines = [width_view(line) if line else line for line in render_lines]
render_lines = [
line_crop(line, crop_x, crop_x2) if line else line
for line in render_lines
]
return SegmentLines(render_lines, new_lines=True)

View File

@@ -1,17 +0,0 @@
from __future__ import annotations
from rich.segment import Segment
from .geometry import Region
from ._types import Lines
def crop_lines(lines: Lines, clip: Region) -> Lines:
lines = lines[clip.y : clip.y + clip.height]
def width_view(line: list[Segment]) -> list[Segment]:
_, line = Segment.divide(line, [clip.x, clip.x + clip.width])
return line
cropped_lines = [width_view(line) for line in lines]
return cropped_lines

View File

@@ -19,15 +19,13 @@ class NodeList:
"""
def __init__(self) -> None:
self._node_refs: list[ref[DOMNode]] = []
self.__nodes: list[DOMNode] | None = []
self._nodes: list[DOMNode] = []
def __bool__(self) -> bool:
self._prune()
return bool(self._node_refs)
return bool(self._nodes)
def __length_hint__(self) -> int:
return len(self._node_refs)
return len(self._nodes)
def __rich_repr__(self) -> rich.repr.Result:
yield self._nodes
@@ -38,32 +36,12 @@ class NodeList:
def __contains__(self, widget: DOMNode) -> bool:
return widget in self._nodes
@property
def _nodes(self) -> list[DOMNode]:
if self.__nodes is None:
self.__nodes = list(
filter(None, [widget_ref() for widget_ref in self._node_refs])
)
return self.__nodes
def _prune(self) -> None:
"""Remove expired references."""
self._node_refs[:] = filter(
None,
[
None if widget_ref() is None else widget_ref
for widget_ref in self._node_refs
],
)
def _append(self, widget: DOMNode) -> None:
if widget not in self._nodes:
self._node_refs.append(ref(widget))
self.__nodes = None
self._nodes.append(widget)
def _clear(self) -> None:
del self._node_refs[:]
self.__nodes = None
del self._nodes[:]
def __iter__(self) -> Iterator[DOMNode]:
return iter(self._nodes)
@@ -77,6 +55,5 @@ class NodeList:
...
def __getitem__(self, index: int | slice) -> DOMNode | list[DOMNode]:
self._prune()
assert self._nodes is not None
return self._nodes[index]

View File

@@ -0,0 +1,49 @@
"""
Tools for processing Segments, or lists of Segments.
"""
from __future__ import annotations
from rich.segment import Segment
def line_crop(segments: list[Segment], start: int, end: int) -> list[Segment]:
"""Crops a list of segments between two cell offsets.
Args:
segments (list[Segment]): A list of Segments for a line.
start (int): Start offset
end (int): End offset
Returns:
list[Segment]: A new shorter list of segments
"""
# This is essentially a specialized version of Segment.divide
# The following line has equivalent functionality (but a little slower)
# return list(Segment.divide(segments, [start, end]))[1]
pos = 0
output_segments: list[Segment] = []
add_segment = output_segments.append
iter_segments = iter(segments)
segment: Segment | None = None
for segment in iter_segments:
end_pos = pos + segment.cell_length
if end_pos > start:
segment = segment.split_cells(start - pos)[-1]
break
pos = end_pos
else:
return []
pos = start
while segment is not None:
end_pos = pos + segment.cell_length
if end_pos < end:
add_segment(segment)
else:
add_segment(segment.split_cells(end - pos)[0])
break
pos = end_pos
segment = next(iter_segments, None)
return output_segments

View File

@@ -8,12 +8,14 @@ from asyncio import (
sleep,
Task,
)
from functools import partial
from time import monotonic
from typing import Awaitable, Callable, Union
from rich.repr import Result, rich_repr
from . import events
from ._callback import invoke
from ._types import MessageTarget
TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]]
@@ -36,9 +38,21 @@ class Timer:
name: str | None = None,
callback: TimerCallback | None = None,
repeat: int | None = None,
skip: bool = False,
skip: bool = True,
pause: bool = False,
) -> None:
"""A class to send timer-based events.
Args:
event_target (MessageTarget): The object which will receive the timer events.
interval (float): The time between timer events.
sender (MessageTarget): The sender of the event.s
name (str | None, optional): A name to assign the event (for debugging). Defaults to None.
callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None.
repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None.
skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True.
pause (bool, optional): Start the timer paused. Defaults to False.
"""
self._target_repr = repr(event_target)
self._target = weakref.ref(event_target)
self._interval = interval
@@ -102,11 +116,19 @@ class Timer:
if wait_time:
await sleep(wait_time)
event = events.Timer(
self.sender, timer=self, count=count, callback=self._callback
self.sender,
timer=self,
time=next_timer,
count=count,
callback=self._callback,
)
count += 1
try:
await self.target.post_message(event)
if self._callback is not None:
await invoke(self._callback)
else:
await self.target.post_priority_message(event)
except EventTargetGone:
break
await self._active.wait()

View File

@@ -18,6 +18,9 @@ class MessageTarget(Protocol):
async def post_message(self, message: "Message") -> bool:
...
async def post_priority_message(self, message: "Message") -> bool:
...
def post_message_no_wait(self, message: "Message") -> bool:
...

View File

@@ -15,10 +15,12 @@ import rich.repr
from rich.console import Console, RenderableType
from rich.control import Control
from rich.measure import Measurement
from rich.segment import Segments
from rich.screen import Screen as ScreenRenderable
from rich.traceback import Traceback
from . import actions
from . import events
from . import log
from . import messages
@@ -26,16 +28,17 @@ from ._animator import Animator
from ._callback import invoke
from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler
from ._profile import timer
from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError
from .devtools.client import DevtoolsClient, DevtoolsConnectionError
from .design import ColorSystem
from .dom import DOMNode
from .driver import Driver
from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size
from .layouts.dock import Dock
from .message_pump import MessagePump
from ._profile import timer
from .reactive import Reactive
from .screen import Screen
from .widget import Widget
@@ -53,6 +56,17 @@ warnings.simplefilter("always", ResourceWarning)
LayoutDefinition = "dict[str, Any]"
DEFAULT_COLORS = ColorSystem(
primary="#406e8e",
secondary="#ffa62b",
warning="#ffa62b",
error="#ba3c5b",
success="#6d9f71",
accent="#ffa62b",
system="#5a4599",
)
class AppError(Exception):
pass
@@ -113,7 +127,10 @@ class App(DOMNode):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
self.stylesheet = Stylesheet()
self.design = DEFAULT_COLORS
self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_styles_update = False
self.css_file = css_file
self.css_monitor = (
@@ -133,6 +150,21 @@ class App(DOMNode):
title: Reactive[str] = Reactive("Textual")
sub_title: Reactive[str] = Reactive("")
background: Reactive[str] = Reactive("black")
dark = Reactive(False)
def get_css_variables(self) -> dict[str, str]:
"""Get a mapping of variables used to pre-populate CSS.
Returns:
dict[str, str]: A mapping of variable name to value.
"""
variables = self.design.generate(self.dark)
return variables
def watch_dark(self, dark: bool) -> None:
"""Watches the dark bool."""
self.screen.dark = dark
self.refresh_css()
def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform.
@@ -142,6 +174,7 @@ class App(DOMNode):
Returns:
Driver: A Driver class which manages input and display.
"""
driver_class: Type[Driver]
if WINDOWS:
from .drivers.windows_driver import WindowsDriver
@@ -272,7 +305,7 @@ class App(DOMNode):
async def _on_css_change(self) -> None:
if self.css_file is not None:
stylesheet = Stylesheet()
stylesheet = Stylesheet(variables=self.get_css_variables())
try:
self.log("loading", self.css_file)
stylesheet.read(self.css_file)
@@ -317,7 +350,8 @@ class App(DOMNode):
Should be called whenever CSS classes / pseudo classes change.
"""
self.post_message_no_wait(messages.StylesUpdated(self))
self._require_styles_update = True
self.check_idle()
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.screen, *anon_widgets, **widgets)
@@ -391,16 +425,18 @@ class App(DOMNode):
"""
if not renderables:
renderables = (
Traceback(
show_locals=True,
width=None,
locals_max_length=5,
suppress=[rich],
show_locals=True, width=None, locals_max_length=5, suppress=[rich]
),
)
self._exit_renderables.extend(renderables)
prerendered = [
Segments(self.console.render(renderable, self.console.options))
for renderable in renderables
]
self._exit_renderables.extend(prerendered)
self.close_messages_no_wait()
def _print_error_renderables(self) -> None:
@@ -478,6 +514,12 @@ class App(DOMNode):
if self.log_file is not None:
self.log_file.close()
async def on_idle(self) -> None:
"""Perform actions when there are no messages in the queue."""
if self._require_styles_update:
await self.post_message(messages.StylesUpdated(self))
self._require_styles_update = False
def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry:
parent.children._append(child)
@@ -503,16 +545,13 @@ class App(DOMNode):
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
apply_stylesheet = self.stylesheet.apply
# Register children
for widget_id, widget in name_widgets:
if widget.children:
self.register(widget, *widget.children)
for widget_id, widget in name_widgets:
if widget not in self.registry:
if widget_id is not None:
widget.id = widget_id
self._register_child(parent, child=widget)
self._register_child(parent, widget)
if widget.children:
self.register(widget, *widget.children)
apply_stylesheet(widget)
for _widget_id, widget in name_widgets:
@@ -572,6 +611,18 @@ class App(DOMNode):
except Exception:
self.panic()
def refresh_css(self, animate: bool = True) -> None:
"""Refresh CSS.
Args:
animate (bool, optional): Also execute CSS animations. Defaults to True.
"""
stylesheet = self.app.stylesheet
stylesheet.set_variables(self.get_css_variables())
stylesheet.reparse()
stylesheet.update(self.app, animate=animate)
self.refresh(layout=True)
def display(self, renderable: RenderableType) -> None:
if not self._running:
return
@@ -715,7 +766,7 @@ class App(DOMNode):
await self.action(
action, default_namespace=default_namespace, modifiers=modifiers
)
elif isinstance(action, Callable):
elif callable(action):
await action()
else:
return False
@@ -761,4 +812,4 @@ class App(DOMNode):
self.screen.query(selector).toggle_class(class_name)
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self)
self.stylesheet.update(self, animate=True)

445
src/textual/color.py Normal file
View File

@@ -0,0 +1,445 @@
"""
Manages Color in Textual.
All instances where the developer is presented with a color should use this class. The only
exception should be when passing things to a Rich renderable, which will need to use the
`rich_color` attribute to perform a conversion.
I'm not entirely happy with burdening the user with two similar color classes. In a future
update we might add a protocol to convert automatically so the dev could use them interchangably.
"""
from __future__ import annotations
from colorsys import rgb_to_hls, hls_to_rgb
from functools import lru_cache
import re
from operator import itemgetter
from typing import Callable, NamedTuple
import rich.repr
from rich.color import Color as RichColor
from rich.style import Style
from rich.text import Text
from ._color_constants import ANSI_COLOR_TO_RGB
from .geometry import clamp
class HLS(NamedTuple):
"""A color in HLS format."""
h: float
l: float
s: float
class HSV(NamedTuple):
"""A color in HSV format."""
h: float
s: float
v: float
class Lab(NamedTuple):
"""A color in CIE-L*ab format."""
L: float
a: float
b: float
RE_COLOR = re.compile(
r"""^
\#([0-9a-fA-F]{6})$|
\#([0-9a-fA-F]{8})$|
rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
""",
re.VERBOSE,
)
# Fast way to split a string of 8 characters in to 3 pairs of 2 characters
split_pairs3: Callable[[str], tuple[str, str, str]] = itemgetter(
slice(0, 2), slice(2, 4), slice(4, 6)
)
# Fast way to split a string of 8 characters in to 4 pairs of 2 characters
split_pairs4: Callable[[str], tuple[str, str, str, str]] = itemgetter(
slice(0, 2), slice(2, 4), slice(4, 6), slice(6, 8)
)
class ColorParseError(Exception):
"""A color failed to parse"""
@rich.repr.auto
class Color(NamedTuple):
"""A class to represent a single RGB color with alpha."""
r: int
g: int
b: int
a: float = 1.0
@classmethod
def from_rich_color(cls, rich_color: RichColor) -> Color:
"""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 = hls_to_rgb(h, l, s)
return cls(int(r * 255 + 0.5), int(g * 255 + 0.5), int(b * 255 + 0.5))
def __rich__(self) -> Text:
"""A Rich method to show the color."""
r, g, b, _ = self
return Text(
f" {self!r} ",
style=Style.from_color(
self.get_contrast_text().rich_color, RichColor.from_rgb(r, g, b)
),
)
@property
def clamped(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),
)
return color
@property
def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class."""
r, g, b, _a = self
return RichColor.from_rgb(r, g, b)
@property
def normalized(self) -> tuple[float, float, float]:
"""A tuple of the color components normalized to between 0 and 1."""
r, g, b, _a = self
return (r / 255, g / 255, b / 255)
@property
def hls(self) -> HLS:
"""Get the color as HLS."""
r, g, b = self.normalized
return HLS(*rgb_to_hls(r, g, b))
@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
@property
def hex(self) -> str:
"""The color in CSS hex form, with 6 digits for RGB, and 8 digits for RGBA.
Returns:
str: A CSS hex-style color, e.g. "#46b3de" or "#3342457f"
"""
r, g, b, a = self
return (
f"#{r:02X}{g:02X}{b:02X}"
if a == 1
else f"#{r:02X}{g:02X}{b:02X}{int(a*255):02X}"
)
@property
def css(self) -> str:
"""The color in CSS rgb or rgba form.
Returns:
str: A CSS style color, e.g. "rgb(10,20,30)" or "rgb(50,70,80,0.5)"
"""
r, g, b, a = self
return f"rgb({r},{g},{b})" if a == 1 else f"rgba({r},{g},{b},{a})"
def __rich_repr__(self) -> rich.repr.Result:
r, g, b, a = self
yield r
yield g
yield b
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, _ = 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.
Args:
destination (Color): Another color.
factor (float): A blend factor, 0 -> 1
Returns:
Color: A new color.
"""
if factor == 0:
return self
elif factor == 1:
return destination
r1, g1, b1, _ = self
r2, g2, b2, _ = destination
return Color(
int(r1 + (r2 - r1) * factor),
int(g1 + (g2 - g1) * factor),
int(b1 + (b2 - b1) * factor),
)
def __add__(self, other: object) -> Color:
if isinstance(other, Color):
new_color = self.blend(other, other.a)
return new_color
return NotImplemented
@classmethod
@lru_cache(maxsize=1024 * 4)
def parse(cls, color_text: str | Color) -> Color:
"""Parse a string containing a CSS-style color.
Args:
color_text (str | Color): Text with a valid color format. Color objects will
be returned unmodified.
Raises:
ColorParseError: If the color is not encoded correctly.
Returns:
Color: New color object.
"""
if isinstance(color_text, Color):
return color_text
ansi_color = ANSI_COLOR_TO_RGB.get(color_text)
if ansi_color is not None:
return cls(*ansi_color)
color_match = RE_COLOR.match(color_text)
if color_match is None:
raise ColorParseError(f"failed to parse {color_text!r} as a color")
rgb_hex, rgba_hex, rgb, rgba = color_match.groups()
if rgb_hex is not None:
r, g, b = [int(pair, 16) for pair in split_pairs3(rgb_hex)]
color = cls(r, g, b, 1.0)
elif rgba_hex is not None:
r, g, b, a = [int(pair, 16) for pair in split_pairs4(rgba_hex)]
color = cls(r, g, b, a / 255.0)
elif rgb is not None:
r, g, b = [clamp(int(float(value)), 0, 255) for value in rgb.split(",")]
color = cls(r, g, b, 1.0)
elif rgba is not None:
float_r, float_g, float_b, float_a = [
float(value) for value in rgba.split(",")
]
color = cls(
clamp(int(float_r), 0, 255),
clamp(int(float_g), 0, 255),
clamp(int(float_b), 0, 255),
clamp(float_a, 0.0, 1.0),
)
else:
raise AssertionError("Can't get here if RE_COLOR matches")
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.
"""
l, a, b = rgb_to_lab(self)
l -= amount * 100
return lab_to_rgb(Lab(l, a, b)).clamped
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).clamped
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(WHITE, alpha)
black = self.blend(BLACK, alpha)
brightness = self.brightness
white_contrast = abs(brightness - white.brightness)
black_contrast = abs(brightness - black.brightness)
return white if white_contrast > black_contrast else black
# Color constants
WHITE = Color(255, 255, 255)
BLACK = Color(0, 0, 0)
class ColorPair(NamedTuple):
"""A pair of colors for foreground and background."""
foreground: Color
background: Color
def __rich_repr__(self) -> rich.repr.Result:
yield "foreground", self.foreground
yield "background", self.background
@property
def style(self) -> Style:
"""A Rich style with foreground and background."""
return self._get_style()
@lru_cache(maxsize=1024 * 4)
def _get_style(self) -> Style:
"""Get a Rich style, foreground adjusted for transparency."""
r, g, b, a = self.foreground
if a == 0:
return Style.from_color(
self.background.rich_color, self.background.rich_color
)
elif a == 1:
return Style.from_color(
self.foreground.rich_color, self.background.rich_color
)
else:
r2, g2, b2, _ = self.background
return Style.from_color(
RichColor.from_rgb(
r + (r2 - r) * a, g + (g2 - g) * a, b + (b2 - b) * a
),
self.background.rich_color,
)
def rgb_to_lab(rgb: Color) -> Lab:
"""Convert an RGB color to the CIE-L*ab format.
Uses the standard RGB color space with a D65/2⁰ standard illuminant.
Conversion passes through the XYZ color space.
Cf. http://www.easyrgb.com/en/math.php.
"""
r, g, b = rgb.r / 255, rgb.g / 255, rgb.b / 255
r = pow((r + 0.055) / 1.055, 2.4) if r > 0.04045 else r / 12.92
g = pow((g + 0.055) / 1.055, 2.4) if g > 0.04045 else g / 12.92
b = pow((b + 0.055) / 1.055, 2.4) if b > 0.04045 else b / 12.92
x = (r * 41.24 + g * 35.76 + b * 18.05) / 95.047
y = (r * 21.26 + g * 71.52 + b * 7.22) / 100
z = (r * 1.93 + g * 11.92 + b * 95.05) / 108.883
off = 16 / 116
x = pow(x, 1 / 3) if x > 0.008856 else 7.787 * x + off
y = pow(y, 1 / 3) if y > 0.008856 else 7.787 * y + off
z = pow(z, 1 / 3) if z > 0.008856 else 7.787 * z + off
return Lab(116 * y - 16, 500 * (x - y), 200 * (y - z))
def lab_to_rgb(lab: Lab) -> Color:
"""Convert a CIE-L*ab color to RGB.
Uses the standard RGB color space with a D65/2⁰ standard illuminant.
Conversion passes through the XYZ color space.
Cf. http://www.easyrgb.com/en/math.php.
"""
y = (lab.L + 16) / 116
x = lab.a / 500 + y
z = y - lab.b / 200
off = 16 / 116
y = pow(y, 3) if y > 0.2068930344 else (y - off) / 7.787
x = 0.95047 * pow(x, 3) if x > 0.2068930344 else 0.122059 * (x - off)
z = 1.08883 * pow(z, 3) if z > 0.2068930344 else 0.139827 * (z - off)
r = x * 3.2406 + y * -1.5372 + z * -0.4986
g = x * -0.9689 + y * 1.8758 + z * 0.0415
b = x * 0.0557 + y * -0.2040 + z * 1.0570
r = 1.055 * pow(r, 1 / 2.4) - 0.055 if r > 0.0031308 else 12.92 * r
g = 1.055 * pow(g, 1 / 2.4) - 0.055 if g > 0.0031308 else 12.92 * g
b = 1.055 * pow(b, 1 / 2.4) - 0.055 if b > 0.0031308 else 12.92 * b
return Color(int(r * 255), int(g * 255), int(b * 255))
if __name__ == "__main__":
from rich import print
c1 = Color.parse("#112233")
print(c1, c1.hex, c1.css)
c2 = Color.parse("#11223344")
print(c2)
c3 = Color.parse("rgb(10,20,30)")
print(c3)
c4 = Color.parse("rgba(10,20,30,0.5)")
print(c4, c4.hex, c4.css)
p1 = ColorPair(c4, c1)
print(p1)
print(p1.style)
print(Color.parse("dark_blue"))

View File

@@ -12,10 +12,10 @@ from __future__ import annotations
from typing import Iterable, NamedTuple, TYPE_CHECKING, cast
import rich.repr
from rich.color import Color
from rich.style import Style
from .. import log
from ..color import Color, ColorPair
from ._error_tools import friendly_list
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
@@ -32,8 +32,8 @@ from ..geometry import Spacing, SpacingDimensions, clamp
if TYPE_CHECKING:
from ..layout import Layout
from .styles import Styles, StylesBase
from .styles import DockGroup
from .styles import DockGroup, Styles, StylesBase
from .types import EdgeType
@@ -116,7 +116,8 @@ class BoxProperty:
For example "border-right", "outline-bottom", etc.
"""
DEFAULT = ("", Color.default())
def __init__(self, default_color: Color) -> None:
self._default_color = default_color
def __set_name__(self, owner: StylesBase, name: str) -> None:
self.name = name
@@ -137,7 +138,7 @@ class BoxProperty:
A ``tuple[EdgeType, Style]`` containing the string type of the box and
it's style. Example types are "rounded", "solid", and "dashed".
"""
box_type, color = obj.get_rule(self.name) or self.DEFAULT
box_type, color = obj.get_rule(self.name) or ("", self._default_color)
return (box_type, color)
def __set__(self, obj: Styles, border: tuple[EdgeType, str | Color] | None):
@@ -315,46 +316,9 @@ class StyleProperty:
Returns:
A ``Style`` object.
"""
has_rule = obj.has_rule
style = Style.from_color(
obj.text_color if has_rule("text_color") else None,
obj.text_background if has_rule("text_background") else None,
)
if has_rule("text_style"):
style += obj.text_style
style = ColorPair(obj.color, obj.background).style + obj.text_style
return style
def __set__(self, obj: StylesBase, style: Style | str | None):
"""Set the Style
Args:
obj (Styles): The ``Styles`` object.
style (Style | str, optional): You can supply the ``Style`` directly, or a
string (e.g. ``"blue on #f0f0f0"``).
Raises:
StyleSyntaxError: When the supplied style string has invalid syntax.
"""
obj.refresh()
if style is None:
clear_rule = obj.clear_rule
clear_rule("text_color")
clear_rule("text_background")
clear_rule("text_style")
else:
if isinstance(style, str):
style = Style.parse(style)
if style.color is not None:
obj.text_color = style.color
if style.bgcolor is not None:
obj.text_background = style.bgcolor
if style.without_color:
obj.text_style = str(style.without_color)
class SpacingProperty:
"""Descriptor for getting and setting spacing properties (e.g. padding and margin)."""
@@ -414,7 +378,11 @@ class DocksProperty:
Returns:
tuple[DockGroup, ...]: A ``tuple`` containing the defined docks.
"""
return obj.get_rule("docks", ())
if obj.has_rule("docks"):
return obj.get_rule("docks")
from .styles import DockGroup
return (DockGroup("_default", "top", 1),)
def __set__(self, obj: StylesBase, docks: Iterable[DockGroup] | None):
"""Set the Docks property
@@ -657,11 +625,9 @@ class NameListProperty:
def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> tuple[str, ...]:
return obj.get_rule(self.name, ())
return cast(tuple[str, ...], obj.get_rule(self.name, ()))
def __set__(
self, obj: StylesBase, names: str | tuple[str] | None = None
) -> str | tuple[str] | None:
def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
if names is None:
if obj.clear_rule(self.name):
@@ -679,13 +645,16 @@ class NameListProperty:
class ColorProperty:
"""Descriptor for getting and setting color properties."""
def __init__(self, default_color: Color | str) -> None:
self._default_color = Color.parse(default_color)
def __set_name__(self, owner: StylesBase, name: str) -> None:
self.name = name
def __get__(
self, obj: StylesBaseStylesBase, objtype: type[Styles] | None = None
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> Color:
"""Get the ``Color``, or ``Color.default()`` if no color is set.
"""Get a ``Color``.
Args:
obj (Styles): The ``Styles`` object.
@@ -694,7 +663,7 @@ class ColorProperty:
Returns:
Color: The Color
"""
return obj.get_rule(self.name) or Color.default()
return cast(Color, obj.get_rule(self.name, self._default_color))
def __set__(self, obj: StylesBase, color: Color | str | None):
"""Set the Color

View File

@@ -3,8 +3,6 @@ from __future__ import annotations
from typing import cast, Iterable, NoReturn
import rich.repr
from rich.color import Color
from rich.style import Style
from ._error_tools import friendly_list
from .constants import (
@@ -22,6 +20,7 @@ from .styles import DockGroup, Styles
from .tokenize import Token
from .transition import Transition
from .types import BoxSizing, Edge, Display, Overflow, Visibility
from ..color import Color
from .._duration import _duration_as_seconds
from .._easing import EASING
from ..geometry import Spacing, SpacingDimensions, clamp
@@ -78,7 +77,7 @@ class StylesBuilder:
tokens = tokens[:-1]
self.styles.important.add(rule_name)
try:
process_method(declaration.name, tokens, important)
process_method(declaration.name, tokens)
except DeclarationError:
raise
except Exception as error:
@@ -90,7 +89,7 @@ class StylesBuilder:
"""Generic code to process a declaration with two enumerations, like overflow: auto auto"""
if len(tokens) > count or not tokens:
self.error(name, tokens[0], f"expected 1 to {count} tokens here")
results = []
results: list[str] = []
append = results.append
for token in tokens:
token_name, value, _, _, location, _ = token
@@ -126,7 +125,6 @@ class StylesBuilder:
if len(tokens) != 1:
self.error(name, tokens[0], "expected a single token here")
return False
token = tokens[0]
token_name, value, _, _, location, _ = token
@@ -144,7 +142,7 @@ class StylesBuilder:
)
return value
def process_display(self, name: str, tokens: list[Token], important: bool) -> None:
def process_display(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
name, value, _, _, location, _ = token
@@ -169,9 +167,7 @@ class StylesBuilder:
else:
self.error(name, tokens[0], "a single scalar is expected")
def process_box_sizing(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_box_sizing(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
name, value, _, _, location, _ = token
@@ -188,33 +184,25 @@ class StylesBuilder:
else:
self.error(name, token, f"invalid token {value!r} in this context")
def process_width(self, name: str, tokens: list[Token], important: bool) -> None:
def process_width(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_height(self, name: str, tokens: list[Token], important: bool) -> None:
def process_height(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_min_width(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_min_width(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_min_height(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_min_height(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_max_width(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_max_width(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_max_height(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_max_height(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_overflow(self, name: str, tokens: list[Token], important: bool) -> None:
def process_overflow(self, name: str, tokens: list[Token]) -> None:
rules = self.styles._rules
overflow_x, overflow_y = self._process_enum_multiple(
name, tokens, VALID_OVERFLOW, 2
@@ -222,23 +210,17 @@ class StylesBuilder:
rules["overflow_x"] = cast(Overflow, overflow_x)
rules["overflow_y"] = cast(Overflow, overflow_y)
def process_overflow_x(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_overflow_x(self, name: str, tokens: list[Token]) -> None:
self.styles._rules["overflow_x"] = cast(
Overflow, self._process_enum(name, tokens, VALID_OVERFLOW)
)
def process_overflow_y(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_overflow_y(self, name: str, tokens: list[Token]) -> None:
self.styles._rules["overflow_y"] = cast(
Overflow, self._process_enum(name, tokens, VALID_OVERFLOW)
)
def process_visibility(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_visibility(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
name, value, _, _, location, _ = token
if name == "token":
@@ -254,7 +236,7 @@ class StylesBuilder:
else:
self.error(name, token, f"invalid token {value!r} in this context")
def process_opacity(self, name: str, tokens: list[Token], important: bool) -> None:
def process_opacity(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
token = tokens[0]
@@ -307,15 +289,15 @@ class StylesBuilder:
)
self.styles._rules[name] = Spacing.unpack(cast(SpacingDimensions, tuple(space)))
def process_padding(self, name: str, tokens: list[Token], important: bool) -> None:
def process_padding(self, name: str, tokens: list[Token]) -> None:
self._process_space(name, tokens)
def process_margin(self, name: str, tokens: list[Token], important: bool) -> None:
def process_margin(self, name: str, tokens: list[Token]) -> None:
self._process_space(name, tokens)
def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]:
border_type = "solid"
border_color = Color.default()
border_color = Color(0, 255, 0)
for token in tokens:
token_name, value, _, _, _, _ = token
@@ -336,63 +318,47 @@ class StylesBuilder:
border = self._parse_border("border", tokens)
self.styles._rules[f"border_{edge}"] = border
def process_border(self, name: str, tokens: list[Token], important: bool) -> None:
def process_border(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border("border", tokens)
rules = self.styles._rules
rules["border_top"] = rules["border_right"] = border
rules["border_bottom"] = rules["border_left"] = border
def process_border_top(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_border_top(self, name: str, tokens: list[Token]) -> None:
self._process_border_edge("top", name, tokens)
def process_border_right(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_border_right(self, name: str, tokens: list[Token]) -> None:
self._process_border_edge("right", name, tokens)
def process_border_bottom(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_border_bottom(self, name: str, tokens: list[Token]) -> None:
self._process_border_edge("bottom", name, tokens)
def process_border_left(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_border_left(self, name: str, tokens: list[Token]) -> None:
self._process_border_edge("left", name, tokens)
def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border("outline", tokens)
self.styles._rules[f"outline_{edge}"] = border
def process_outline(self, name: str, tokens: list[Token], important: bool) -> None:
def process_outline(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border("outline", tokens)
rules = self.styles._rules
rules["outline_top"] = rules["outline_right"] = border
rules["outline_bottom"] = rules["outline_left"] = border
def process_outline_top(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_outline_top(self, name: str, tokens: list[Token]) -> None:
self._process_outline("top", name, tokens)
def process_parse_border_right(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_parse_border_right(self, name: str, tokens: list[Token]) -> None:
self._process_outline("right", name, tokens)
def process_outline_bottom(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_outline_bottom(self, name: str, tokens: list[Token]) -> None:
self._process_outline("bottom", name, tokens)
def process_outline_left(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_outline_left(self, name: str, tokens: list[Token]) -> None:
self._process_outline("left", name, tokens)
def process_offset(self, name: str, tokens: list[Token], important: bool) -> None:
def process_offset(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 2:
@@ -415,7 +381,7 @@ class StylesBuilder:
scalar_y = Scalar.parse(token2.value, Unit.HEIGHT)
self.styles._rules["offset"] = ScalarOffset(scalar_x, scalar_y)
def process_offset_x(self, name: str, tokens: list[Token], important: bool) -> None:
def process_offset_x(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 1:
@@ -428,7 +394,7 @@ class StylesBuilder:
y = self.styles.offset.y
self.styles._rules["offset"] = ScalarOffset(x, y)
def process_offset_y(self, name: str, tokens: list[Token], important: bool) -> None:
def process_offset_y(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 1:
@@ -441,7 +407,7 @@ class StylesBuilder:
x = self.styles.offset.x
self.styles._rules["offset"] = ScalarOffset(x, y)
def process_layout(self, name: str, tokens: list[Token], important: bool) -> None:
def process_layout(self, name: str, tokens: list[Token]) -> None:
from ..layouts.factory import get_layout, MissingLayout, LAYOUT_MAP
if tokens:
@@ -459,36 +425,13 @@ class StylesBuilder:
f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})",
)
def process_text(self, name: str, tokens: list[Token], important: bool) -> None:
style_definition = _join_tokens(tokens, joiner=" ")
# If every token in the value is a referenced by the same variable,
# we can display the variable name before the style definition.
# TODO: Factor this out to apply it to other properties too.
unique_references = {t.referenced_by for t in tokens if t.referenced_by}
if tokens and tokens[0].referenced_by and len(unique_references) == 1:
variable_prefix = f"${tokens[0].referenced_by.name}="
else:
variable_prefix = ""
try:
style = Style.parse(style_definition)
self.styles.text = style
except Exception as error:
message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}"
self.error(name, tokens[0], message)
if important:
self.styles.important.update(
{"text_style", "text_background", "text_color"}
)
def process_text_color(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_color(self, name: str, tokens: list[Token]) -> None:
"""Processes a simple color declaration."""
name = name.replace("-", "_")
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["text_color"] = Color.parse(token.value)
self.styles._rules[name] = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
@@ -498,29 +441,19 @@ class StylesBuilder:
name, token, f"unexpected token {token.value!r} in declaration"
)
def process_text_background(
self, name: str, tokens: list[Token], important: bool
) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["text_background"] = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
)
else:
self.error(
name, token, f"unexpected token {token.value!r} in declaration"
)
process_background = process_color
process_scrollbar_color = process_color
process_scrollbar_color_hover = process_color
process_scrollbar_color_active = process_color
process_scrollbar_background = process_color
process_scrollbar_background_hover = process_color
process_scrollbar_background_active = process_color
def process_text_style(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_text_style(self, name: str, tokens: list[Token]) -> None:
style_definition = " ".join(token.value for token in tokens)
self.styles.text_style = style_definition
def process_dock(self, name: str, tokens: list[Token], important: bool) -> None:
def process_dock(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(
@@ -530,7 +463,7 @@ class StylesBuilder:
)
self.styles._rules["dock"] = tokens[0].value if tokens else ""
def process_docks(self, name: str, tokens: list[Token], important: bool) -> None:
def process_docks(self, name: str, tokens: list[Token]) -> None:
docks: list[DockGroup] = []
for token in tokens:
if token.name == "key_value":
@@ -561,12 +494,12 @@ class StylesBuilder:
)
self.styles._rules["docks"] = tuple(docks + [DockGroup("_default", "top", 0)])
def process_layer(self, name: str, tokens: list[Token], important: bool) -> None:
def process_layer(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration")
self.styles._rules["layer"] = tokens[0].value
def process_layers(self, name: str, tokens: list[Token], important: bool) -> None:
def process_layers(self, name: str, tokens: list[Token]) -> None:
layers: list[str] = []
for token in tokens:
if token.name != "token":
@@ -574,9 +507,7 @@ class StylesBuilder:
layers.append(token.value)
self.styles._rules["layers"] = tuple(layers)
def process_transition(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_transition(self, name: str, tokens: list[Token]) -> None:
transitions: dict[str, Transition] = {}
def make_groups() -> Iterable[list[Token]]:

View File

@@ -11,6 +11,7 @@ VALID_VISIBILITY: Final = {"visible", "hidden"}
VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final = {
"none",
"hidden",
"round",
"solid",
"double",

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING
from .model import CombinatorType, Selector, SelectorSet, SelectorType
from .model import CombinatorType, Selector, SelectorSet
if TYPE_CHECKING:

View File

@@ -28,14 +28,17 @@ class CombinatorType(Enum):
CHILD = 3
@dataclass
class Location:
line: tuple[int, int]
column: tuple[int, int]
@dataclass
class Selector:
"""Represents a CSS selector.
Some examples of selectors:
*
Header.title
App > Content
"""
name: str
combinator: CombinatorType = CombinatorType.DESCENDENT
type: SelectorType = SelectorType.TYPE
@@ -46,6 +49,7 @@ class Selector:
@property
def css(self) -> str:
"""Rebuilds the selector as it would appear in CSS."""
pseudo_suffix = "".join(f":{name}" for name in self.pseudo_classes)
if self.type == SelectorType.UNIVERSAL:
return "*"

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
from collections import defaultdict
from functools import lru_cache
from typing import Iterator, Iterable, Optional
from typing import Iterator, Iterable
from rich import print
from rich.cells import cell_len
from textual.css.errors import UnresolvedVariableError
from ._styles_builder import StylesBuilder, DeclarationError
@@ -18,7 +16,7 @@ from .model import (
SelectorType,
)
from .styles import Styles
from .tokenize import tokenize, tokenize_declarations, Token
from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values
from .tokenizer import EOFError, ReferencedBy
SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = {
@@ -213,12 +211,14 @@ def _unresolved(
)
def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
def substitute_references(
tokens: Iterable[Token], css_variables: dict[str, list[Token]] | None = None
) -> Iterable[Token]:
"""Replace variable references with values by substituting variable reference
tokens with the tokens representing their values.
Args:
tokens (Iterator[Token]): Iterator of Tokens which may contain tokens
tokens (Iterable[Token]): Iterator of Tokens which may contain tokens
with the name "variable_ref".
Returns:
@@ -228,9 +228,12 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
but with variables resolved. Substituted tokens will have their referenced_by
attribute populated with information about where the tokens are being substituted to.
"""
variables: dict[str, list[Token]] = defaultdict(list)
variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {}
iter_tokens = iter(tokens)
while tokens:
token = next(tokens, None)
token = next(iter_tokens, None)
if token is None:
break
if token.name == "variable_name":
@@ -238,7 +241,8 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
yield token
while True:
token = next(tokens, None)
token = next(iter_tokens, None)
# TODO: Mypy error looks legit
if token.name == "whitespace":
yield token
else:
@@ -250,7 +254,7 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
if not token:
break
elif token.name == "whitespace":
variables[variable_name].append(token)
variables.setdefault(variable_name, []).append(token)
yield token
elif token.name == "variable_value_end":
yield token
@@ -259,7 +263,7 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
elif token.name == "variable_ref":
ref_name = token.value[1:]
if ref_name in variables:
variable_tokens = variables[variable_name]
variable_tokens = variables.setdefault(variable_name, [])
reference_tokens = variables[ref_name]
variable_tokens.extend(reference_tokens)
ref_location = token.location
@@ -277,9 +281,9 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
variable_name=ref_name, location=token.location
)
else:
variables[variable_name].append(token)
variables.setdefault(variable_name, []).append(token)
yield token
token = next(tokens, None)
token = next(iter_tokens, None)
elif token.name == "variable_ref":
variable_name = token.value[1:] # Trim the $, so $x -> x
if variable_name in variables:
@@ -300,7 +304,9 @@ def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]:
yield token
def parse(css: str, path: str) -> Iterable[RuleSet]:
def parse(
css: str, path: str, variables: dict[str, str] | None = None
) -> Iterable[RuleSet]:
"""Parse CSS by tokenizing it, performing variable substitution,
and generating rule sets from it.
@@ -308,7 +314,8 @@ def parse(css: str, path: str) -> Iterable[RuleSet]:
css (str): The input CSS
path (str): Path to the CSS
"""
tokens = iter(substitute_references(tokenize(css, path)))
variable_tokens = tokenize_values(variables or {})
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
while True:
token = next(tokens, None)
if token is None:

View File

@@ -65,3 +65,11 @@ class ScalarAnimation(Animation):
setattr(self.styles, f"{self.attribute}", offset)
return False
def __eq__(self, other: object) -> bool:
if isinstance(other, ScalarAnimation):
return (
self.final_value == other.final_value
and self.duration == other.duration
)
return False

View File

@@ -8,11 +8,11 @@ from operator import attrgetter
from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast
import rich.repr
from rich.color import Color
from rich.style import Style
from .. import log
from .._animator import Animation, EasingFunction
from ..color import Color
from ..geometry import Size, Spacing
from ._style_properties import (
BorderProperty,
@@ -61,16 +61,16 @@ class RulesMap(TypedDict, total=False):
Any key may be absent, indiciating that rule has not been set.
Does not define composite rules, that is a rule that is made of a combination of other rules. For instance,
the text style is made up of text_color, text_background, and text_style.
Does not define composite rules, that is a rule that is made of a combination of other rules.
"""
display: Display
visibility: Visibility
layout: "Layout"
text_color: Color
text_background: Color
color: Color
background: Color
text_style: Style
opacity: float
@@ -108,6 +108,14 @@ class RulesMap(TypedDict, total=False):
transitions: dict[str, Transition]
scrollbar_color: Color
scrollbar_color_hover: Color
scrollbar_color_active: Color
scrollbar_background: Color
scrollbar_background_hover: Color
scrollbar_background_active: Color
RULE_NAMES = list(RulesMap.__annotations__.keys())
_rule_getter = attrgetter(*RULE_NAMES)
@@ -132,15 +140,22 @@ class StylesBase(ABC):
"min_height",
"max_width",
"max_height",
"color",
"background",
"scrollbar_color",
"scrollbar_color_hover",
"scrollbar_color_active",
"scrollbar_background",
"scrollbar_background_hover",
"scrollbar_background_active",
}
display = StringEnumProperty(VALID_DISPLAY, "block")
visibility = StringEnumProperty(VALID_VISIBILITY, "visible")
layout = LayoutProperty()
text = StyleProperty()
text_color = ColorProperty()
text_background = ColorProperty()
color = ColorProperty(Color(255, 255, 255))
background = ColorProperty(Color(0, 0, 0))
text_style = StyleFlagsProperty()
opacity = FractionalProperty()
@@ -150,16 +165,16 @@ class StylesBase(ABC):
offset = OffsetProperty()
border = BorderProperty()
border_top = BoxProperty()
border_right = BoxProperty()
border_bottom = BoxProperty()
border_left = BoxProperty()
border_top = BoxProperty(Color(0, 255, 0))
border_right = BoxProperty(Color(0, 255, 0))
border_bottom = BoxProperty(Color(0, 255, 0))
border_left = BoxProperty(Color(0, 255, 0))
outline = BorderProperty()
outline_top = BoxProperty()
outline_right = BoxProperty()
outline_bottom = BoxProperty()
outline_left = BoxProperty()
outline_top = BoxProperty(Color(0, 255, 0))
outline_right = BoxProperty(Color(0, 255, 0))
outline_bottom = BoxProperty(Color(0, 255, 0))
outline_left = BoxProperty(Color(0, 255, 0))
box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box")
width = ScalarProperty(percent_unit=Unit.WIDTH)
@@ -179,6 +194,16 @@ class StylesBase(ABC):
layers = NameListProperty()
transitions = TransitionsProperty()
rich_style = StyleProperty()
scrollbar_color = ColorProperty("bright_magenta")
scrollbar_color_hover = ColorProperty("yellow")
scrollbar_color_active = ColorProperty("bright_yellow")
scrollbar_background = ColorProperty("#555555")
scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black")
def __eq__(self, styles: object) -> bool:
"""Check that Styles containts the same rules."""
if not isinstance(styles, StylesBase):
@@ -257,14 +282,6 @@ class StylesBase(ABC):
layout (bool, optional): Also require a layout. Defaults to False.
"""
@abstractmethod
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
@abstractmethod
def reset(self) -> None:
"""Reset the rules to initial state."""
@@ -377,7 +394,7 @@ class StylesBase(ABC):
if self.box_sizing == "content-box":
if has_rule("padding"):
size += self.padding
size += self.padding.totals
if has_rule("border"):
size += self.border.spacing.totals
if has_rule("margin"):
@@ -385,7 +402,7 @@ class StylesBase(ABC):
else: # border-box
if has_rule("padding"):
size -= self.padding
size -= self.padding.totals
if has_rule("border"):
size -= self.border.spacing.totals
if has_rule("margin"):
@@ -402,9 +419,6 @@ class Styles(StylesBase):
_rules: RulesMap = field(default_factory=dict)
_layout_required: bool = False
_repaint_required: bool = False
important: set[str] = field(default_factory=set)
def copy(self) -> Styles:
@@ -449,18 +463,8 @@ class Styles(StylesBase):
return self._rules.get(rule, default)
def refresh(self, *, layout: bool = False) -> None:
self._repaint_required = True
self._layout_required = self._layout_required or layout
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
result = (self._repaint_required, self._layout_required)
self._repaint_required = self._layout_required = False
return result
if self.node is not None:
self.node.refresh(layout=layout)
def reset(self) -> None:
"""Reset the rules to initial state."""
@@ -566,25 +570,25 @@ class Styles(StylesBase):
if top == right and right == bottom and bottom == left:
border_type, border_color = rules[f"{name}_top"]
yield name, f"{border_type} {border_color.name}"
yield name, f"{border_type} {border_color.hex}"
return
# Check for edges
if has_top:
border_type, border_color = rules[f"{name}_top"]
yield f"{name}-top", f"{border_type} {border_color.name}"
yield f"{name}-top", f"{border_type} {border_color.hex}"
if has_right:
border_type, border_color = rules[f"{name}_right"]
yield f"{name}-right", f"{border_type} {border_color.name}"
yield f"{name}-right", f"{border_type} {border_color.hex}"
if has_bottom:
border_type, border_color = rules[f"{name}_bottom"]
yield f"{name}-bottom", f"{border_type} {border_color.name}"
yield f"{name}-bottom", f"{border_type} {border_color.hex}"
if has_left:
border_type, border_color = rules[f"{name}_left"]
yield f"{name}-left", f"{border_type} {border_color.name}"
yield f"{name}-left", f"{border_type} {border_color.hex}"
@property
def css_lines(self) -> list[str]:
@@ -637,19 +641,12 @@ class Styles(StylesBase):
assert self.layout is not None
append_declaration("layout", self.layout.name)
if (
has_rule("text_color")
and has_rule("text_background")
and has_rule("text_style")
):
append_declaration("text", str(self.text))
else:
if has_rule("text_color"):
append_declaration("text-color", self.text_color.name)
if has_rule("text_background"):
append_declaration("text-background", self.text_background.name)
if has_rule("text_style"):
append_declaration("text-style", str(get_rule("text_style")))
if has_rule("color"):
append_declaration("color", self.color.hex)
if has_rule("background"):
append_declaration("background", self.background.hex)
if has_rule("text_style"):
append_declaration("text-style", str(get_rule("text_style")))
if has_rule("overflow-x"):
append_declaration("overflow-x", self.overflow_x)
@@ -725,17 +722,6 @@ class RenderStyles(StylesBase):
def merge_rules(self, rules: RulesMap) -> None:
self._inline_styles.merge_rules(rules)
def check_refresh(self) -> tuple[bool, bool]:
"""Check if the Styles must be refreshed.
Returns:
tuple[bool, bool]: (repaint required, layout_required)
"""
base_repaint, base_layout = self._base_styles.check_refresh()
inline_repaint, inline_layout = self._inline_styles.check_refresh()
result = (base_repaint or inline_repaint, base_layout or inline_layout)
return result
def reset(self) -> None:
"""Reset the rules to initial state."""
self._inline_styles.reset()

View File

@@ -16,12 +16,12 @@ from rich.syntax import Syntax
from rich.text import Text
from textual._loop import loop_last
from .._context import active_app
from .errors import StylesheetError
from .match import _check_selectors
from .model import RuleSet
from .parse import parse
from .styles import RULE_NAMES, Styles, RulesMap
from .styles import RulesMap
from .tokenize import tokenize_values, Token
from .types import Specificity3, Specificity4
from ..dom import DOMNode
from .. import log
@@ -36,8 +36,14 @@ class StylesheetParseError(Exception):
class StylesheetErrors:
def __init__(self, stylesheet: "Stylesheet") -> None:
def __init__(
self, stylesheet: "Stylesheet", variables: dict[str, str] | None = None
) -> None:
self.stylesheet = stylesheet
self.variables: dict[str, str] = {}
self._css_variables: dict[str, list[Token]] = {}
if variables:
self.set_variables(variables)
@classmethod
def _get_snippet(cls, code: str, line_no: int) -> Panel:
@@ -52,6 +58,11 @@ class StylesheetErrors:
)
return Panel(syntax, border_style="red")
def set_variables(self, variable_map: dict[str, str]) -> None:
"""Pre-populate CSS variables."""
self.variables.update(variable_map)
self._css_variables = tokenize_values(self.variables)
def __rich__(self) -> RenderableType:
highlighter = ReprHighlighter()
errors: list[RenderableType] = []
@@ -88,8 +99,10 @@ class StylesheetErrors:
@rich.repr.auto
class Stylesheet:
def __init__(self) -> None:
def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self.rules: list[RuleSet] = []
self.variables = variables or {}
self.source: list[tuple[str, str]] = []
def __rich_repr__(self) -> rich.repr.Result:
yield self.rules
@@ -107,7 +120,24 @@ class Stylesheet:
def error_renderable(self) -> StylesheetErrors:
return StylesheetErrors(self)
def set_variables(self, variables: dict[str, str]) -> None:
"""Set CSS variables.
Args:
variables (dict[str, str]): A mapping of name to variable.
"""
self.variables = variables
def read(self, filename: str) -> None:
"""Read Textual CSS file.
Args:
filename (str): filename of CSS.
Raises:
StylesheetError: If the CSS could not be read.
StylesheetParseError: If the CSS is invalid.
"""
filename = os.path.expanduser(filename)
try:
with open(filename, "rt") as css_file:
@@ -116,20 +146,59 @@ class Stylesheet:
except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {error}")
try:
rules = list(parse(css, path))
rules = list(parse(css, path, variables=self.variables))
except Exception as error:
raise StylesheetError(f"failed to parse {filename!r}; {error}")
self.rules.extend(rules)
def parse(self, css: str, *, path: str = "") -> None:
try:
rules = list(parse(css, path))
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")
else:
self.source.append((css, path))
self.rules.extend(rules)
if self.any_errors:
raise StylesheetParseError(self.error_renderable)
def parse(self, css: str, *, path: str = "") -> None:
"""Parse CSS from a string.
Args:
css (str): String with CSS source.
path (str, optional): The path of the source if a file, or some other identifier. Defaults to "".
Raises:
StylesheetError: If the CSS could not be read.
StylesheetParseError: If the CSS is invalid.
"""
try:
rules = list(parse(css, path, variables=self.variables))
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")
else:
self.source.append((css, path))
self.rules.extend(rules)
if self.any_errors:
raise StylesheetParseError(self.error_renderable)
def _clone(self, stylesheet: Stylesheet) -> None:
"""Replace this stylesheet contents with another.
Args:
stylesheet (Stylesheet): A Stylesheet.
"""
self.rules = stylesheet.rules.copy()
self.source = stylesheet.source.copy()
def reparse(self) -> None:
"""Re-parse source, applying new variables.
Raises:
StylesheetError: If the CSS could not be read.
StylesheetParseError: If the CSS is invalid.
"""
# Do this in a fresh Stylesheet so if there are errors we don't break self.
stylesheet = Stylesheet(variables=self.variables)
for css, path in self.source:
stylesheet.parse(css, path=path)
self._clone(stylesheet)
@classmethod
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
for selector_set in rule.selector_set:
@@ -231,7 +300,7 @@ class Stylesheet:
if is_animatable(key) and new_render_value != old_render_value:
transition = new_styles.get_transition(key)
if transition is not None:
duration, easing, delay = transition
duration, easing, _delay = transition
node.app.animator.animate(
node.styles.base,
key,
@@ -248,12 +317,6 @@ class Stylesheet:
for key in modified_rule_keys:
setattr(base_styles, key, rules.get(key))
# The styles object may have requested a refresh / layout
# It's the style properties that set these flags
repaint, layout = styles.check_refresh()
if repaint:
node.refresh(layout=layout)
def update(self, root: DOMNode, animate: bool = False) -> None:
"""Update a node and its children."""
apply = self.apply

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import pprint
import re
from typing import Iterable
@@ -10,7 +9,7 @@ COMMENT_START = r"\/\*"
SCALAR = r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)"
DURATION = r"\d+\.?\d*(?:ms|s)"
NUMBER = r"\-?\d+\.?\d*"
COLOR = r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)"
COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
TOKEN = "[a-zA-Z_-]+"
STRING = r"\".*?\""
@@ -97,6 +96,16 @@ expect_declaration_content = Expect(
declaration_set_end=r"\}",
)
expect_declaration_content_solo = Expect(
declaration_end=r"\n|;",
whitespace=r"\s+",
comment_start=COMMENT_START,
**DECLARATION_VALUES,
important=r"\!important",
comma=",",
declaration_set_end=r"\}",
).expect_eof(True)
class TokenizerState:
"""State machine for the tokenizer.
@@ -152,15 +161,41 @@ class DeclarationTokenizerState(TokenizerState):
}
class ValueTokenizerState(TokenizerState):
EXPECT = expect_declaration_content_solo
tokenize = TokenizerState()
tokenize_declarations = DeclarationTokenizerState()
tokenize_value = ValueTokenizerState()
def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]:
"""Tokens the values in a dict of strings.
Args:
values (dict[str, str]): A mapping of CSS variable name on to a value, to be
added to the CSS context.
Returns:
dict[str, list[Token]]: A mapping of name on to a list of tokens,
"""
value_tokens = {
name: list(tokenize_value(value, "__name__")) for name, value in values.items()
}
return value_tokens
if __name__ == "__main__":
from rich import print
css = """#something {
text: on red;
offset-x: 10;
color: rgb(10,12,23)
}
"""
# transition: offset 500 in_out_cubic;
tokens = tokenize(css, __name__)
pprint.pp(list(tokens))
print(list(tokens))
print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"}))

View File

@@ -110,9 +110,17 @@ class Tokenizer:
for name, value in zip(expect.names, iter_groups):
if value is not None:
break
else:
# For MyPy's benefit
raise AssertionError("can't reach here")
token = Token(
name, value, self.path, self.code, (line_no, col_no), referenced_by=None
name,
value,
self.path,
self.code,
(line_no, col_no),
referenced_by=None,
)
col_no += len(value)
if col_no >= len(line):

View File

@@ -3,8 +3,7 @@ from __future__ import annotations
import sys
from typing import Tuple
from rich.color import Color
from ..color import Color
if sys.version_info >= (3, 8):
from typing import Literal
@@ -16,6 +15,7 @@ Edge = Literal["top", "right", "bottom", "left"]
EdgeType = Literal[
"",
"none",
"hidden",
"round",
"solid",
"double",

258
src/textual/design.py Normal file
View File

@@ -0,0 +1,258 @@
from __future__ import annotations
from typing import Iterable
from rich.console import group
from rich.padding import Padding
from rich.table import Table
from rich.text import Text
from .color import Color, WHITE
NUMBER_OF_SHADES = 3
# Where no content exists
DEFAULT_DARK_BACKGROUND = "#000000"
# What text usually goes on top off
DEFAULT_DARK_SURFACE = "#121212"
DEFAULT_LIGHT_SURFACE = "#f5f5f5"
DEFAULT_LIGHT_BACKGROUND = "#efefef"
class ColorProperty:
"""Descriptor to parse colors."""
def __set_name__(self, owner: ColorSystem, name: str) -> None:
self._name = f"_{name}"
def __get__(
self, obj: ColorSystem, objtype: type[ColorSystem] | None = None
) -> Color | None:
color = getattr(obj, self._name)
if color is None:
return None
else:
return Color.parse(color)
def __set__(self, obj: ColorSystem, value: Color | str | None) -> None:
if isinstance(value, Color):
setattr(obj, self._name, value.css)
else:
setattr(obj, self._name, value)
class ColorSystem:
"""Defines a standard set of colors and variations for building a UI.
Primary is the main theme color
Secondary is a second theme color
"""
COLOR_NAMES = [
"primary",
"secondary",
"background",
"surface",
"panel",
"warning",
"error",
"success",
"accent",
"system",
]
def __init__(
self,
primary: str,
secondary: str | None = None,
warning: str | None = None,
error: str | None = None,
success: str | None = None,
accent: str | None = None,
system: str | None = None,
background: str | None = None,
surface: str | None = None,
dark_background: str | None = None,
dark_surface: str | None = None,
panel: str | None = None,
):
self._primary = primary
self._secondary = secondary
self._warning = warning
self._error = error
self._success = success
self._accent = accent
self._system = system
self._background = background
self._surface = surface
self._dark_background = dark_background
self._dark_surface = dark_surface
self._panel = panel
@property
def primary(self) -> Color:
"""Get the primary color."""
return Color.parse(self._primary)
secondary = ColorProperty()
warning = ColorProperty()
error = ColorProperty()
success = ColorProperty()
accent = ColorProperty()
system = ColorProperty()
surface = ColorProperty()
background = ColorProperty()
dark_surface = ColorProperty()
dark_background = ColorProperty()
panel = ColorProperty()
@property
def shades(self) -> Iterable[str]:
"""The names of the colors and derived shades."""
for color in self.COLOR_NAMES:
for shade_number in range(-NUMBER_OF_SHADES, NUMBER_OF_SHADES + 1):
if shade_number < 0:
yield f"{color}-darken-{abs(shade_number)}"
elif shade_number > 0:
yield f"{color}-lighten-{shade_number}"
else:
yield color
def generate(
self,
dark: bool = False,
luminosity_spread: float = 0.15,
text_alpha: float = 0.9,
) -> dict[str, str]:
"""Generate a mapping of color name on to a CSS color.
Args:
dark (bool, optional): Enable dark mode. Defaults to False.
luminosity_spread (float, optional): Amount of luminosity to subtract and add to generate
shades. Defaults to 0.2.
text_alpha (float, optional): Alpha value for text. Defaults to 0.9.
Returns:
dict[str, str]: A mapping of color name on to a CSS-style encoded color
"""
primary = self.primary
secondary = self.secondary or primary
warning = self.warning or primary
error = self.error or secondary
success = self.success or secondary
accent = self.accent or primary
system = self.system or accent
light_background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND)
dark_background = self.dark_background or Color.parse(DEFAULT_DARK_BACKGROUND)
light_surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE)
dark_surface = self.dark_surface or Color.parse(DEFAULT_DARK_SURFACE)
background = dark_background if dark else light_background
surface = dark_surface if dark else light_surface
text = background.get_contrast_text(1.0)
if self.panel is None:
panel = background.blend(
text, luminosity_spread if dark else luminosity_spread
)
else:
panel = self.panel
colors: dict[str, str] = {}
def luminosity_range(spread) -> Iterable[tuple[str, float]]:
"""Get the range of shades from darken2 to lighten2.
Returns:
Iterable of tuples (<SHADE SUFFIX, LUMINOSITY DELTA>)
"""
luminosity_step = spread / 2
for n in range(-NUMBER_OF_SHADES, +NUMBER_OF_SHADES + 1):
if n < 0:
label = "-darken"
elif n > 0:
label = "-lighten"
else:
label = ""
yield (f"{label}{'-' + str(abs(n)) if n else ''}"), n * luminosity_step
# Color names and color
COLORS = [
("primary", primary),
("secondary", secondary),
("background", background),
("panel", panel),
("surface", surface),
("warning", warning),
("error", error),
("success", success),
("accent", accent),
("system", system),
]
# Colors names that have a dark variant
DARK_SHADES = {"primary", "secondary"}
for name, color in COLORS:
is_dark_shade = dark and name in DARK_SHADES
spread = luminosity_spread / 1.5 if is_dark_shade else luminosity_spread
if name == "panel":
spread /= 2
for shade_name, luminosity_delta in luminosity_range(spread):
if is_dark_shade:
dark_background = background.blend(color, 0.15)
shade_color = dark_background.blend(
WHITE, spread + luminosity_delta
).clamped
colors[f"{name}{shade_name}"] = shade_color.hex
else:
shade_color = color.lighten(luminosity_delta)
colors[f"{name}{shade_name}"] = shade_color.hex
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.1 + 0.15)
on_name = f"text-{name}{shade_name}-fade-{fade}"
else:
on_name = f"text-{name}{shade_name}"
colors[on_name] = text_color.hex
return colors
def __rich__(self) -> Table:
@group()
def make_shades(dark: bool):
colors = self.generate(dark)
for name in self.shades:
background = colors[name]
foreground = colors[f"text-{name}"]
text = Text(f"{background} ", style=f"{foreground} on {background}")
for fade in range(3):
foreground = colors[
f"text-{name}-fade-{fade}" if fade else f"text-{name}"
]
text.append(f"{name} ", style=f"{foreground} on {background}")
yield Padding(text, 1, style=f"{foreground} on {background}")
table = Table(box=None, expand=True)
table.add_column("Light", justify="center")
table.add_column("Dark", justify="center")
table.add_row(make_shades(False), make_shades(True))
return table
if __name__ == "__main__":
from .app import DEFAULT_COLORS
from rich import print
print(DEFAULT_COLORS)

View File

@@ -223,38 +223,16 @@ class DOMNode(MessagePump):
)
@property
def z(self) -> tuple[int, ...]:
"""Get the z index tuple for this node.
Returns:
tuple[int, ...]: A tuple of ints to sort layers by.
"""
indexes: list[int] = []
append = indexes.append
node = self
layer: str = node.styles.layer
while node._parent:
parent_styles = node.parent.styles
layer = layer or node.styles.layer
if layer in parent_styles.layers:
append(parent_styles.layers.index(layer))
layer = ""
else:
append(0)
node = node.parent
return tuple(reversed(indexes))
@property
def text_style(self) -> Style:
def rich_text_style(self) -> Style:
"""Get the text style (added to parent style).
Returns:
Style: Rich Style object.
"""
return (
self.parent.text_style + self.styles.text
self.parent.rich_text_style + self.styles.rich_style
if self.has_parent
else self.styles.text
else self.styles.rich_style
)
@property
@@ -314,8 +292,7 @@ class DOMNode(MessagePump):
for node in self.walk_children():
node._css_styles.reset()
if isinstance(node, Widget):
# node.clear_render_cache()
node._repaint_required = True
node.set_dirty()
node._layout_required = True
def on_style_change(self) -> None:

View File

@@ -364,11 +364,13 @@ class Timer(Event, verbosity=3, bubble=False):
self,
sender: MessageTarget,
timer: "TimerClass",
time: float,
count: int = 0,
callback: TimerCallback | None = None,
) -> None:
super().__init__(sender)
self.timer = timer
self.time = time
self.count = count
self.callback = callback

View File

@@ -438,14 +438,14 @@ class Region(NamedTuple):
Returns:
Region: The new, smaller region.
"""
_clamp = clamp
top, right, bottom, left = margin
x, y, width, height = self
return Region(
x=_clamp(x + left, 0, width),
y=_clamp(y + top, 0, height),
width=_clamp(width - left - right, 0, width),
height=_clamp(height - top - bottom, 0, height),
x=x + left,
y=y + top,
width=max(0, width - left - right),
height=max(0, height - top - bottom),
)
def intersection(self, region: Region) -> Region:
@@ -495,11 +495,11 @@ class Region(NamedTuple):
cut_x ↓
┌────────┐┌───┐
│ ││ │
││
0 ││ 1
│ ││ │
cut_y → └────────┘└───┘
┌────────┐┌───┐
││
2 ││ 3
└────────┘└───┘
Args:
@@ -531,7 +531,7 @@ class Region(NamedTuple):
cut ↓
┌────────┐┌───┐
││
0 ││ 1
│ ││ │
└────────┘└───┘
@@ -556,10 +556,11 @@ class Region(NamedTuple):
"""Split a region in to two, from a given x offset.
┌─────────┐
0
│ │
cut → └─────────┘
┌─────────┐
│ 1 │
└─────────┘
Args:
@@ -612,7 +613,7 @@ class Spacing(NamedTuple):
@property
def totals(self) -> tuple[int, int]:
"""Total spacing for horizontal and vertical spacing."""
"""Returns a tuple of (<HORIZONTAL SPACE>, <VERTICAL SPACE>)."""
top, right, bottom, left = self
return (left + right, top + bottom)

View File

@@ -19,26 +19,6 @@ class WidgetPlacement(NamedTuple):
widget: Widget | None = None # A widget of None means empty space
order: int = 0
def apply_margin(self) -> "WidgetPlacement":
"""Apply any margin present in the styles of the widget by shrinking the
region appropriately.
Returns:
WidgetPlacement: Returns ``self`` if no ``margin`` styles are present in
the widget. Otherwise, returns a copy of self with a region shrunk to
account for margin.
"""
region, widget, order = self
if widget is not None:
styles = widget.styles
if styles.margin:
return WidgetPlacement(
region=region.shrink(styles.margin),
widget=widget,
order=order,
)
return self
class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them."""

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
import asyncio
from asyncio import CancelledError
from asyncio import Queue, QueueEmpty, Task
from functools import partial
from typing import TYPE_CHECKING, Awaitable, Iterable, Callable
from asyncio import PriorityQueue, QueueEmpty, Task
from functools import partial, total_ordering
from typing import TYPE_CHECKING, Awaitable, Iterable, Callable, NamedTuple
from weakref import WeakSet
from . import events
@@ -32,9 +32,28 @@ class MessagePumpClosed(Exception):
pass
@total_ordering
class MessagePriority:
"""Wraps a messages with a priority, and provides equality."""
__slots__ = ["message", "priority"]
def __init__(self, message: Message | None = None, priority: int = 0):
self.message = message
self.priority = priority
def __eq__(self, other: object) -> bool:
assert isinstance(other, MessagePriority)
return self.priority == other.priority
def __gt__(self, other: object) -> bool:
assert isinstance(other, MessagePriority)
return self.priority > other.priority
class MessagePump:
def __init__(self, parent: MessagePump | None = None) -> None:
self._message_queue: Queue[Message | None] = Queue()
self._message_queue: PriorityQueue[MessagePriority] = PriorityQueue()
self._parent = parent
self._running: bool = False
self._closing: bool = False
@@ -96,7 +115,7 @@ class MessagePump:
return self._pending_message
finally:
self._pending_message = None
message = await self._message_queue.get()
message = (await self._message_queue.get()).message
if message is None:
self._closed = True
raise MessagePumpClosed("The message pump is now closed")
@@ -111,7 +130,7 @@ class MessagePump:
"""
if self._pending_message is None:
try:
self._pending_message = self._message_queue.get_nowait()
self._pending_message = self._message_queue.get_nowait().message
except QueueEmpty:
pass
@@ -155,7 +174,7 @@ class MessagePump:
)
def close_messages_no_wait(self) -> None:
self._message_queue.put_nowait(None)
self._message_queue.put_nowait(MessagePriority(None))
async def close_messages(self) -> None:
"""Close message queue, and optionally wait for queue to finish processing."""
@@ -164,7 +183,7 @@ class MessagePump:
self._closing = True
await self._message_queue.put(None)
await self._message_queue.put(MessagePriority(None))
for task in self._child_tasks:
task.cancel()
@@ -284,7 +303,28 @@ class MessagePump:
return False
if not self.check_message_enabled(message):
return True
await self._message_queue.put(message)
await self._message_queue.put(MessagePriority(message))
return True
# TODO: This may not be needed, or may only be needed by the timer
# Consider removing or making private
async def post_priority_message(self, message: Message) -> bool:
"""Post a "priority" messages which will be processes prior to regular messages.
Note that you should rarely need this in a regular app. It exists primarily to allow
timer messages to skip the queue, so that they can be more regular.
Args:
message (Message): A message.
Returns:
bool: True if the messages was processed.
"""
if self._closing or self._closed:
return False
if not self.check_message_enabled(message):
return True
await self._message_queue.put(MessagePriority(message, -1))
return True
def post_message_no_wait(self, message: Message) -> bool:
@@ -292,7 +332,7 @@ class MessagePump:
return False
if not self.check_message_enabled(message):
return True
self._message_queue.put_nowait(message)
self._message_queue.put_nowait(MessagePriority(message))
return True
async def post_message_from_child(self, message: Message) -> bool:
@@ -307,7 +347,6 @@ class MessagePump:
async def on_callback(self, event: events.Callback) -> None:
await invoke(event.callback)
# await event.callback()
def emit_no_wait(self, message: Message) -> bool:
if self._parent:

View File

@@ -9,6 +9,7 @@ from . import events, messages, errors
from .geometry import Offset, Region
from ._compositor import Compositor
from .reactive import Reactive
from .widget import Widget
from .renderables.gradient import VerticalGradient
@@ -24,11 +25,16 @@ class Screen(Widget):
"""
dark = Reactive(False)
def __init__(self, name: str | None = None, id: str | None = None) -> None:
super().__init__(name=name, id=id)
self._compositor = Compositor()
self._dirty_widgets: list[Widget] = []
def watch_dark(self, dark: bool) -> None:
pass
@property
def is_transparent(self) -> bool:
return False
@@ -85,6 +91,7 @@ class Screen(Widget):
def on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
if self._dirty_widgets:
self.log(dirty=len(self._dirty_widgets))
for widget in self._dirty_widgets:
# Repaint widgets
# TODO: Combine these in to a single update.
@@ -94,7 +101,7 @@ class Screen(Widget):
# Reset dirty list
self._dirty_widgets.clear()
async def refresh_layout(self) -> None:
def refresh_layout(self) -> None:
"""Refresh the layout (can change size and positions of widgets)."""
if not self.size:
return
@@ -134,14 +141,15 @@ class Screen(Widget):
widget = message.widget
assert isinstance(widget, Widget)
self._dirty_widgets.append(widget)
self.check_idle()
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
await self.refresh_layout()
self.refresh_layout()
async def on_resize(self, event: events.Resize) -> None:
self.size_updated(event.size, event.virtual_size, event.container_size)
await self.refresh_layout()
self.refresh_layout()
event.stop()
async def _on_mouse_move(self, event: events.MouseMove) -> None:
@@ -204,10 +212,8 @@ class Screen(Widget):
widget, _region = self.get_widget_at(event.x, event.y)
except errors.NoWidget:
return
self.log("forward", widget, event)
scroll_widget = widget
if scroll_widget is not None:
await scroll_widget.forward_event(event)
else:
self.log("view.forwarded", event)
await self.post_message(event)

View File

@@ -206,9 +206,18 @@ class ScrollBar(Widget):
yield "position", self.position
def render(self) -> RenderableType:
styles = self.parent.styles
style = Style(
bgcolor=(Color.parse("#555555" if self.mouse_over else "#444444")),
color=Color.parse("bright_yellow" if self.grabbed else "bright_magenta"),
bgcolor=(
styles.scrollbar_background_hover.rich_color
if self.mouse_over
else styles.scrollbar_background.rich_color
),
color=(
styles.scrollbar_color_active.rich_color
if self.grabbed
else styles.scrollbar_color.rich_color
),
)
return ScrollBarRender(
virtual_size=self.window_virtual_size,

View File

@@ -26,6 +26,7 @@ from . import events
from ._animator import BoundAnimator
from ._border import Border
from ._callback import invoke
from .color import Color
from ._context import active_app
from ._types import Lines
from .dom import DOMNode
@@ -67,7 +68,7 @@ class Widget(DOMNode):
can_focus: bool = False
DEFAULT_STYLES = """
"""
def __init__(
@@ -84,7 +85,6 @@ class Widget(DOMNode):
self._layout_required = False
self._animate: BoundAnimator | None = None
self._reactive_watches: dict[str, Callable] = {}
self._mouse_over: bool = False
self.highlight_style: Style | None = None
self._vertical_scrollbar: ScrollBar | None = None
@@ -220,7 +220,7 @@ class Widget(DOMNode):
y: float | None = None,
*,
animate: bool = True,
):
) -> bool:
"""Scroll to a given (absolute) coordinate, optionally animating.
Args:
@@ -229,61 +229,73 @@ class Widget(DOMNode):
animate (bool, optional): Animate to new scroll position. Defaults to False.
"""
scrolled_x = False
scrolled_y = False
if animate:
# TODO: configure animation speed
if x is not None:
self.scroll_target_x = x
self.animate(
"scroll_x", self.scroll_target_x, speed=80, easing="out_cubic"
)
if x != self.scroll_x:
self.animate(
"scroll_x", self.scroll_target_x, speed=80, easing="out_cubic"
)
scrolled_x = True
if y is not None:
self.scroll_target_y = y
self.animate(
"scroll_y", self.scroll_target_y, speed=80, easing="out_cubic"
)
if y != self.scroll_y:
self.animate(
"scroll_y", self.scroll_target_y, speed=80, easing="out_cubic"
)
scrolled_y = True
else:
if x is not None:
self.scroll_target_x = self.scroll_x = x
if x != self.scroll_x:
scrolled_x = True
if y is not None:
self.scroll_target_y = self.scroll_y = y
self.refresh(layout=True)
if y != self.scroll_y:
scrolled_y = True
self.refresh(repaint=False, layout=True)
return scrolled_x or scrolled_y
def scroll_home(self, animate: bool = True) -> None:
self.scroll_to(0, 0, animate=animate)
def scroll_home(self, animate: bool = True) -> bool:
return self.scroll_to(0, 0, animate=animate)
def scroll_end(self, animate: bool = True) -> None:
self.scroll_to(0, self.max_scroll_y, animate=animate)
def scroll_end(self, animate: bool = True) -> bool:
return self.scroll_to(0, self.max_scroll_y, animate=animate)
def scroll_left(self, animate: bool = True) -> None:
self.scroll_to(x=self.scroll_target_x - 1.5, animate=animate)
def scroll_left(self, animate: bool = True) -> bool:
return self.scroll_to(x=self.scroll_target_x - 1, animate=animate)
def scroll_right(self, animate: bool = True) -> None:
self.scroll_to(x=self.scroll_target_x + 1.5, animate=animate)
def scroll_right(self, animate: bool = True) -> bool:
return self.scroll_to(x=self.scroll_target_x + 1, animate=animate)
def scroll_up(self, animate: bool = True) -> None:
self.scroll_to(y=self.scroll_target_y + 1.5, animate=animate)
def scroll_up(self, animate: bool = True) -> bool:
return self.scroll_to(y=self.scroll_target_y + 1, animate=animate)
def scroll_down(self, animate: bool = True) -> None:
self.scroll_to(y=self.scroll_target_y - 1.5, animate=animate)
def scroll_down(self, animate: bool = True) -> bool:
return self.scroll_to(y=self.scroll_target_y - 1, animate=animate)
def scroll_page_up(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_up(self, animate: bool = True) -> bool:
return self.scroll_to(
y=self.scroll_target_y - self.container_size.height, animate=animate
)
def scroll_page_down(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_down(self, animate: bool = True) -> bool:
return self.scroll_to(
y=self.scroll_target_y + self.container_size.height, animate=animate
)
def scroll_page_left(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_left(self, animate: bool = True) -> bool:
return self.scroll_to(
x=self.scroll_target_x - self.container_size.width, animate=animate
)
def scroll_page_right(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_right(self, animate: bool = True) -> bool:
return self.scroll_to(
x=self.scroll_target_x + self.container_size.width, animate=animate
)
@@ -356,7 +368,7 @@ class Widget(DOMNode):
def get_pseudo_classes(self) -> Iterable[str]:
"""Pseudo classes for a widget"""
if self._mouse_over:
if self.mouse_over:
yield "hover"
if self.has_focus:
yield "focus"
@@ -373,10 +385,11 @@ class Widget(DOMNode):
renderable = self.render()
styles = self.styles
parent_styles = self.parent.styles
parent_text_style = self.parent.text_style
parent_text_style = self.parent.rich_text_style
text_style = styles.rich_style
text_style = styles.text
renderable_text_style = parent_text_style + text_style
if renderable_text_style:
renderable = Styled(renderable, renderable_text_style)
@@ -390,20 +403,20 @@ class Widget(DOMNode):
renderable = Border(
renderable,
styles.border,
inner_color=renderable_text_style.bgcolor,
outer_color=parent_text_style.bgcolor,
inner_color=styles.background,
outer_color=Color.from_rich_color(parent_text_style.bgcolor),
)
if styles.outline:
renderable = Border(
renderable,
styles.outline,
inner_color=styles.background,
outer_color=parent_styles.background,
outline=True,
inner_color=renderable_text_style.bgcolor,
outer_color=parent_text_style.bgcolor,
)
if styles.opacity:
if styles.opacity != 1.0:
renderable = Opacity(renderable, opacity=styles.opacity)
return renderable
@@ -422,7 +435,10 @@ class Widget(DOMNode):
@property
def region(self) -> Region:
return self.screen._compositor.get_widget_region(self)
try:
return self.screen._compositor.get_widget_region(self)
except errors.NoWidget:
return Region()
@property
def scroll_offset(self) -> Offset:
@@ -435,7 +451,8 @@ class Widget(DOMNode):
Returns:
bool: ``True`` if there is background color, otherwise ``False``.
"""
return self.layout is not None and self.styles.text.bgcolor is None
return False
return self.layout is not None
@property
def console(self) -> Console:
@@ -472,6 +489,7 @@ class Widget(DOMNode):
def on_style_change(self) -> None:
self.set_dirty()
self.check_idle()
def size_updated(
self, size: Size, virtual_size: Size, container_size: Size
@@ -501,16 +519,28 @@ class Widget(DOMNode):
width, height = self.size
renderable = self.render_styled()
options = self.console.options.update_dimensions(width, height)
lines = self.console.render_lines(renderable, options)
self._render_cache = RenderCache(self.size, lines)
self._dirty_regions.clear()
def get_render_lines(self) -> Lines:
"""Get segment lines to render the widget."""
def get_render_lines(
self, start: int | None = None, end: int | None = None
) -> Lines:
"""Get segment lines to render the widget.
Args:
start (int | None, optional): line start index, or None for first line. Defaults to None.
end (int | None, optional): line end index, or None for last line. Defaults to None.
Returns:
Lines: A list of lists of segments.
"""
if self._dirty_regions:
self._render_lines()
lines = self._render_cache.lines
if self.is_container:
self.horizontal_scrollbar.refresh()
self.vertical_scrollbar.refresh()
lines = self._render_cache.lines[start:end]
return lines
def check_layout(self) -> bool:
@@ -575,15 +605,11 @@ class Widget(DOMNode):
Args:
event (events.Idle): Idle event.
"""
# Check if the styles have changed
repaint, layout = self.styles.check_refresh()
if self._dirty_regions:
repaint = True
if layout or self.check_layout():
if self.check_layout():
self._reset_check_layout()
self.screen.post_message_no_wait(messages.Layout(self))
elif repaint:
elif self._dirty_regions:
self.emit_no_wait(messages.Update(self, self))
async def focus(self) -> None:
@@ -622,30 +648,46 @@ class Widget(DOMNode):
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)
def on_mouse_scroll_down(self) -> None:
self.scroll_down(animate=True)
def on_leave(self) -> None:
self.mouse_over = False
def on_mouse_scroll_up(self) -> None:
self.scroll_up(animate=True)
def on_enter(self) -> None:
self.mouse_over = True
def on_mouse_scroll_down(self, event) -> None:
if self.is_container:
if not self.scroll_down(animate=False):
event.stop()
def on_mouse_scroll_up(self, event) -> None:
if self.is_container:
if not self.scroll_up(animate=False):
event.stop()
def handle_scroll_to(self, message: ScrollTo) -> None:
self.scroll_to(message.x, message.y, animate=message.animate)
if self.is_container:
self.scroll_to(message.x, message.y, animate=message.animate)
message.stop()
def handle_scroll_up(self, event: ScrollUp) -> None:
self.scroll_page_up()
event.stop()
if self.is_container:
self.scroll_page_up()
event.stop()
def handle_scroll_down(self, event: ScrollDown) -> None:
self.scroll_page_down()
event.stop()
if self.is_container:
self.scroll_page_down()
event.stop()
def handle_scroll_left(self, event: ScrollLeft) -> None:
self.scroll_page_left()
event.stop()
if self.is_container:
self.scroll_page_left()
event.stop()
def handle_scroll_right(self, event: ScrollRight) -> None:
self.scroll_page_right()
event.stop()
if self.is_container:
self.scroll_page_right()
event.stop()
def key_home(self) -> bool:
if self.is_container:

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
import pytest
from rich.color import Color
from rich.style import Style
from textual.color import Color
from textual.css.errors import StyleTypeError
from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode
@@ -83,33 +84,6 @@ def test_merge_rules():
}
def test_render_styles_text():
"""Test inline styles override base styles"""
base = Styles()
inline = Styles()
styles_view = RenderStyles(None, base, inline)
# Both styles are empty
assert styles_view.text == Style()
# Base is bold blue
base.text_color = "blue"
base.text_style = "bold"
assert styles_view.text == Style.parse("bold blue")
# Base is bold blue, inline is red
inline.text_color = "red"
assert styles_view.text == Style.parse("bold red")
# Base is bold yellow, inline is red
base.text_color = "yellow"
assert styles_view.text == Style.parse("bold red")
# Base is bold blue
inline.text_color = None
assert styles_view.text == Style.parse("bold yellow")
def test_render_styles_border():
base = Styles()
inline = Styles()
@@ -125,25 +99,28 @@ def test_render_styles_border():
assert styles_view.border_left == ("rounded", Color.parse("green"))
assert styles_view.border == (
("heavy", Color.parse("red")),
("", Color.default()),
("", Color.default()),
("", Color(0, 255, 0)),
("", Color(0, 255, 0)),
("rounded", Color.parse("green")),
)
def test_get_opacity_default():
styles = RenderStyles(DOMNode(), Styles(), Styles())
assert styles.opacity == 1.
assert styles.opacity == 1.0
@pytest.mark.parametrize("set_value, expected", [
[0.2, 0.2],
[-0.4, 0.0],
[5.8, 1.0],
["25%", 0.25],
["-10%", 0.0],
["120%", 1.0],
])
@pytest.mark.parametrize(
"set_value, expected",
[
[0.2, 0.2],
[-0.4, 0.0],
[5.8, 1.0],
["25%", 0.25],
["-10%", 0.0],
["120%", 1.0],
],
)
def test_opacity_set_then_get(set_value, expected):
styles = RenderStyles(DOMNode(), Styles(), Styles())
styles.opacity = set_value

185
tests/test_color.py Normal file
View File

@@ -0,0 +1,185 @@
import pytest
from rich.color import Color as RichColor
from rich.text import Text
from textual.color import Color, ColorPair, Lab, lab_to_rgb, rgb_to_lab
def test_rich_color():
"""Check conversion to Rich color."""
assert Color(10, 20, 30, 1.0).rich_color == RichColor.from_rgb(10, 20, 30)
assert Color.from_rich_color(RichColor.from_rgb(10, 20, 30)) == Color(
10, 20, 30, 1.0
)
def test_rich_color_rich_output():
assert isinstance(Color(10, 20, 30).__rich__(), Text)
def test_normalized():
assert Color(255, 128, 64).normalized == pytest.approx((1.0, 128 / 255, 64 / 255))
def test_clamped():
assert Color(300, 100, -20, 1.5).clamped == Color(255, 100, 0, 1.0)
def test_css():
"""Check conversion to CSS style"""
assert Color(10, 20, 30, 1.0).css == "rgb(10,20,30)"
assert Color(10, 20, 30, 0.5).css == "rgba(10,20,30,0.5)"
def test_colorpair_style():
"""Test conversion of colorpair to style."""
# Black on white
assert (
str(ColorPair(Color.parse("#000000"), Color.parse("#ffffff")).style)
== "#000000 on #ffffff"
)
# 50% black on white
assert (
str(ColorPair(Color.parse("rgba(0,0,0,0.5)"), Color.parse("#ffffff")).style)
== "#7f7f7f on #ffffff"
)
def test_hls():
red = Color(200, 20, 32)
print(red.hls)
assert red.hls == pytest.approx(
(0.9888888888888889, 0.43137254901960786, 0.818181818181818)
)
assert Color.from_hls(
0.9888888888888889, 0.43137254901960786, 0.818181818181818
).normalized == pytest.approx(red.normalized, rel=1e-5)
def test_color_brightness():
assert Color(255, 255, 255).brightness == 1
assert Color(0, 0, 0).brightness == 0
assert Color(127, 127, 127).brightness == pytest.approx(0.49803921568627446)
assert Color(255, 127, 64).brightness == pytest.approx(0.6199607843137255)
def test_color_hex():
assert Color(255, 0, 127).hex == "#FF007F"
assert Color(255, 0, 127, 0.5).hex == "#FF007F7F"
def test_color_css():
assert Color(255, 0, 127).css == "rgb(255,0,127)"
assert Color(255, 0, 127, 0.5).css == "rgba(255,0,127,0.5)"
def test_color_with_alpha():
assert Color(255, 50, 100).with_alpha(0.25) == Color(255, 50, 100, 0.25)
def test_color_blend():
assert Color(0, 0, 0).blend(Color(255, 255, 255), 0) == Color(0, 0, 0)
assert Color(0, 0, 0).blend(Color(255, 255, 255), 1.0) == Color(255, 255, 255)
assert Color(0, 0, 0).blend(Color(255, 255, 255), 0.5) == Color(127, 127, 127)
@pytest.mark.parametrize(
"text,expected",
[
("#000000", Color(0, 0, 0, 1.0)),
("#ffffff", Color(255, 255, 255, 1.0)),
("#FFFFFF", Color(255, 255, 255, 1.0)),
("#020304ff", Color(2, 3, 4, 1.0)),
("#02030400", Color(2, 3, 4, 0.0)),
("#0203040f", Color(2, 3, 4, 0.058823529411764705)),
("rgb(0,0,0)", Color(0, 0, 0, 1.0)),
("rgb(255,255,255)", Color(255, 255, 255, 1.0)),
("rgba(255,255,255,1)", Color(255, 255, 255, 1.0)),
("rgb(2,3,4)", Color(2, 3, 4, 1.0)),
("rgba(2,3,4,1.0)", Color(2, 3, 4, 1.0)),
("rgba(2,3,4,0.058823529411764705)", Color(2, 3, 4, 0.058823529411764705)),
],
)
def test_color_parse(text, expected):
assert Color.parse(text) == expected
def test_color_parse_color():
# as a convenience, if Color.parse is passed a color object, it will return it
color = Color(20, 30, 40, 0.5)
assert Color.parse(color) is color
def test_color_add():
assert Color(50, 100, 200) + Color(10, 20, 30, 0.9) == Color(14, 28, 47)
# Computed with http://www.easyrgb.com/en/convert.php,
# (r, g, b) values in sRGB to (L*, a*, b*) values in CIE-L*ab.
RGB_LAB_DATA = [
(10, 23, 73, 10.245, 15.913, -32.672),
(200, 34, 123, 45.438, 67.750, -8.008),
(0, 0, 0, 0, 0, 0),
(255, 255, 255, 100, 0, 0),
]
def test_color_darken():
assert Color(200, 210, 220).darken(1) == Color(0, 0, 0)
assert Color(200, 210, 220).darken(-1) == Color(255, 255, 255)
assert Color(200, 210, 220).darken(0.1) == Color(172, 182, 192)
assert Color(200, 210, 220).darken(0.5) == Color(71, 80, 88)
def test_color_lighten():
assert Color(200, 210, 220).lighten(1) == Color(255, 255, 255)
assert Color(200, 210, 220).lighten(-1) == Color(0, 0, 0)
assert Color(200, 210, 220).lighten(0.1) == Color(228, 238, 248)
@pytest.mark.parametrize(
"r, g, b, L_, a_, b_",
RGB_LAB_DATA,
)
def test_rgb_to_lab(r, g, b, L_, a_, b_):
"""Test conversion from the RGB color space to CIE-L*ab."""
rgb = Color(r, g, b)
lab = rgb_to_lab(rgb)
assert lab.L == pytest.approx(L_, abs=0.1)
assert lab.a == pytest.approx(a_, abs=0.1)
assert lab.b == pytest.approx(b_, abs=0.1)
@pytest.mark.parametrize(
"r, g, b, L_, a_, b_",
RGB_LAB_DATA,
)
def test_lab_to_rgb(r, g, b, L_, a_, b_):
"""Test conversion from the CIE-L*ab color space to RGB."""
lab = Lab(L_, a_, b_)
rgb = lab_to_rgb(lab)
assert rgb.r == pytest.approx(r, abs=1)
assert rgb.g == pytest.approx(g, abs=1)
assert rgb.b == pytest.approx(b, abs=1)
def test_rgb_lab_rgb_roundtrip():
"""Test RGB -> CIE-L*ab -> RGB color conversion roundtripping."""
for r in range(0, 256, 32):
for g in range(0, 256, 32):
for b in range(0, 256, 32):
c_ = lab_to_rgb(rgb_to_lab(Color(r, g, b)))
assert c_.r == pytest.approx(r, abs=1)
assert c_.g == pytest.approx(g, abs=1)
assert c_.b == pytest.approx(b, abs=1)
def test_color_pair_style():
pair = ColorPair(Color(220, 220, 220), Color(10, 20, 30))
assert str(pair.style) == "#dcdcdc on #0a141e"

View File

@@ -0,0 +1,55 @@
from rich.segment import Segment
from rich.style import Style
from textual._segment_tools import line_crop
def test_line_crop():
bold = Style(bold=True)
italic = Style(italic=True)
segments = [
Segment("Hello", bold),
Segment(" World!", italic),
]
assert line_crop(segments, 1, 2) == [Segment("e", bold)]
assert line_crop(segments, 4, 20) == [
Segment("o", bold),
Segment(" World!", italic),
]
def test_line_crop_emoji():
bold = Style(bold=True)
italic = Style(italic=True)
segments = [
Segment("Hello", bold),
Segment("💩💩💩", italic),
]
assert line_crop(segments, 8, 11) == [Segment(" 💩", italic)]
assert line_crop(segments, 9, 11) == [Segment("💩", italic)]
def test_line_crop_edge():
segments = [Segment("foo"), Segment("bar"), Segment("baz")]
assert line_crop(segments, 2, 9) == [Segment("o"), Segment("bar"), Segment("baz")]
assert line_crop(segments, 3, 9) == [Segment("bar"), Segment("baz")]
assert line_crop(segments, 4, 9) == [Segment("ar"), Segment("baz")]
assert line_crop(segments, 4, 8) == [Segment("ar"), Segment("ba")]
def test_line_crop_edge_2():
segments = [
Segment("╭─"),
Segment(
"────── Placeholder ───────",
),
Segment(
"─╮",
),
]
result = line_crop(segments, 30, 60)
expected = []
print(repr(result))
assert result == expected