mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' of github.com:Textualize/textual into dev-server
This commit is contained in:
@@ -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
73
docs/color_system.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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
193
sandbox/basic.css
Normal 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
89
sandbox/basic.py
Normal 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")
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
$background: #021720;
|
||||
|
||||
App > View {
|
||||
text: on $background;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
#info {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
239
src/textual/_color_constants.py
Normal file
239
src/textual/_color_constants.py
Normal 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),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
|
||||
49
src/textual/_segment_tools.py
Normal file
49
src/textual/_segment_tools.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
|
||||
@@ -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
445
src/textual/color.py
Normal 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"))
|
||||
@@ -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
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -11,6 +11,7 @@ VALID_VISIBILITY: Final = {"visible", "hidden"}
|
||||
VALID_DISPLAY: Final = {"block", "none"}
|
||||
VALID_BORDER: Final = {
|
||||
"none",
|
||||
"hidden",
|
||||
"round",
|
||||
"solid",
|
||||
"double",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "*"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
258
src/textual/design.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
185
tests/test_color.py
Normal 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"
|
||||
55
tests/test_segment_tools.py
Normal file
55
tests/test_segment_tools.py
Normal 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
|
||||
Reference in New Issue
Block a user