Merge pull request #376 from Textualize/custom-scrollbars

Custom scrollbars
This commit is contained in:
Will McGugan
2022-04-11 11:38:51 +01:00
committed by GitHub
15 changed files with 371 additions and 227 deletions

73
docs/color_system.md Normal file
View File

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

View File

@@ -1,11 +1,18 @@
/* CSS file for basic.py */
/*
* {
transition: color 500ms linear, background 500ms linear;
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;
@@ -31,7 +38,7 @@ App > Screen {
#sidebar .title {
height: 3;
background: $primary-darken-2;
color: $text-primary-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
}
@@ -59,63 +66,73 @@ App > Screen {
color: $text-background;
background: $background;
layout: vertical;
overflow-y:scroll;
}
Tweet {
height: 16;
height: 22;
max-width: 80;
margin: 1 3;
background: $surface;
color: $text-surface;
background: $panel;
color: $text-panel;
layout: vertical;
border: outer $accent2;
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll
}
TweetHeader {
height:1
background: $accent1
color: $text-accent1
background: $accent
color: $text-accent
}
TweetBody {
background: $surface
color: $text-surface-fade-1
height:6;
background: $panel;
color: $text-panel;
height:20;
padding: 0 1 0 0;
}
.button {
background: $accent2;
color: $text-accent2;
background: $accent;
color: $text-accent;
width:20;
height: 3
border: tall $text-background;
/* border-top: hidden $accent-darken-3; */
border: tall $accent-darken-2;
/* border-left: tall $accent-darken-1; */
margin: 1 1 1 1;
/* padding: 1 0 0 0 ; */
transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
}
.button:hover {
background: $accent1-darken-1;
color: $text-accent1-darken-1;
background: $accent-lighten-1;
color: $text-accent-lighten-1;
width: 20;
height: 3
border: tall $text-background;
border: tall $accent-darken-1;
/* border-left: tall $accent-darken-3; */
margin: 1 1 1 1;
}
#footer {
color: $text-accent2;
background: $accent2;
color: $text-accent;
background: $accent;
height: 1;
border-top: hkey $accent2-darken-2;
border-top: hkey $accent-darken-2;
}
@@ -127,26 +144,29 @@ OptionItem {
height: 3;
background: $primary;
transition: background 100ms linear;
border-right: outer $primary-darken-3;
border-right: outer $primary-darken-2;
border-left: hidden;
}
OptionItem:hover {
height: 3;
background: $accent1-darken-2;
color: $accent;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
border-right: outer $accent1-darken-3;
border-left: outer $accent-darken-2;
}
Error {
max-width: 78;
max-width: 80;
height:3;
background: $error;
color: $text-error;
border-top: hkey $error-darken-3;
border-bottom: hkey $error-darken-3;
border-top: hkey $error-darken-2;
border-bottom: hkey $error-darken-2;
margin: 1 3;
text-style: bold;
}
@@ -155,8 +175,8 @@ Warning {
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: hkey $warning-darken-3;
border-bottom: hkey $warning-darken-3;
border-top: hkey $warning-darken-2;
border-bottom: hkey $warning-darken-2;
margin: 1 2;
text-style: bold;
}
@@ -164,10 +184,10 @@ Warning {
Success {
max-width: 80;
height:3;
background: $success-lighten-2;
color: $text-success-lighten-2-fade-1;
border-top: hkey $success-darken-3;
border-bottom: hkey $success-darken-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;
}

View File

@@ -7,7 +7,8 @@ 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. """
"""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. """
)

View File

@@ -53,6 +53,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,15 +124,7 @@ class App(DOMNode):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
self.design = ColorSystem(
primary="#406e8e", # blueish
secondary="#6d9f71", # purplesis
warning="#ffa62b", # orange
error="#ba3c5b", # error
success="#6d9f71", # green
accent1="#ffa62b",
accent2="#5a4599",
)
self.design = DEFAULT_COLORS
self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_styles_update = False

View File

@@ -237,11 +237,12 @@ class Color(NamedTuple):
@classmethod
@lru_cache(maxsize=1024 * 4)
def parse(cls, color_text: str) -> Color:
def parse(cls, color_text: str | Color) -> Color:
"""Parse a string containing a CSS-style color.
Args:
color_text (str): Text with a valid color format.
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.
@@ -249,7 +250,8 @@ class Color(NamedTuple):
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)

View File

@@ -625,7 +625,7 @@ 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):
@@ -645,13 +645,15 @@ class NameListProperty:
class ColorProperty:
"""Descriptor for getting and setting color properties."""
def __init__(self, default_color: Color) -> None:
self._default_color = default_color
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: StylesBase, objtype: type[Styles] | None = None) -> Color:
def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> Color:
"""Get a ``Color``.
Args:
@@ -661,7 +663,7 @@ class ColorProperty:
Returns:
Color: The Color
"""
return obj.get_rule(self.name, self._default_color)
return cast(Color, obj.get_rule(self.name, self._default_color))
def __set__(self, obj: StylesBase, color: Color | str | None):
"""Set the Color

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from typing import cast, Iterable, NoReturn
import rich.repr
from rich.style import Style
from ._error_tools import friendly_list
from .constants import (
@@ -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,10 +289,10 @@ 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]:
@@ -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,11 +425,13 @@ class StylesBuilder:
f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})",
)
def process_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["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}"
@@ -473,29 +441,19 @@ class StylesBuilder:
name, token, f"unexpected token {token.value!r} in declaration"
)
def process_background(
self, name: str, tokens: list[Token], important: bool
) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["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(
@@ -505,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":
@@ -536,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":
@@ -549,9 +507,7 @@ class StylesBuilder:
layers.append(token.value)
self.styles._rules["layers"] = tuple(layers)
def process_transition(
self, name: str, tokens: list[Token], important: bool
) -> None:
def process_transition(self, name: str, tokens: list[Token]) -> None:
transitions: dict[str, Transition] = {}
def make_groups() -> Iterable[list[Token]]:

View File

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

View File

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

View File

@@ -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)
@@ -134,6 +142,12 @@ class StylesBase(ABC):
"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")
@@ -182,6 +196,14 @@ class StylesBase(ABC):
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):

View File

@@ -11,10 +11,13 @@ 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_BACKGROUND = "#f5f5f5"
DEFAULT_LIGHT_SURFACE = "#efefef"
DEFAULT_LIGHT_SURFACE = "#f5f5f5"
DEFAULT_LIGHT_BACKGROUND = "#efefef"
class ColorProperty:
@@ -53,11 +56,12 @@ class ColorSystem:
"secondary",
"background",
"surface",
"panel",
"warning",
"error",
"success",
"accent1",
"accent2",
"accent",
"system",
]
def __init__(
@@ -67,20 +71,26 @@ class ColorSystem:
warning: str | None = None,
error: str | None = None,
success: str | None = None,
accent1: str | None = None,
accent2: 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._accent1 = accent1
self._accent2 = accent2
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:
@@ -91,10 +101,13 @@ class ColorSystem:
warning = ColorProperty()
error = ColorProperty()
success = ColorProperty()
accent1 = ColorProperty()
accent2 = ColorProperty()
accent = ColorProperty()
system = ColorProperty()
surface = ColorProperty()
background = ColorProperty()
dark_surface = ColorProperty()
dark_background = ColorProperty()
panel = ColorProperty()
@property
def shades(self) -> Iterable[str]:
@@ -132,16 +145,25 @@ class ColorSystem:
warning = self.warning or primary
error = self.error or secondary
success = self.success or secondary
accent1 = self.accent1 or primary
accent2 = self.accent2 or secondary
accent = self.accent or primary
system = self.system or accent
background = self.background or Color.parse(
DEFAULT_DARK_BACKGROUND if dark else DEFAULT_LIGHT_BACKGROUND
)
light_background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND)
dark_background = self.dark_background or Color.parse(DEFAULT_DARK_BACKGROUND)
surface = self.surface or Color.parse(
DEFAULT_DARK_SURFACE if dark else DEFAULT_LIGHT_SURFACE
)
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] = {}
@@ -167,12 +189,13 @@ class ColorSystem:
("primary", primary),
("secondary", secondary),
("background", background),
("panel", panel),
("surface", surface),
("warning", warning),
("error", error),
("success", success),
("accent1", accent1),
("accent2", accent2),
("accent", accent),
("system", system),
]
# Colors names that have a dark variant
@@ -181,9 +204,11 @@ class ColorSystem:
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, 12 / 100)
dark_background = background.blend(color, 0.15)
shade_color = dark_background.blend(
WHITE, spread + luminosity_delta
).clamped
@@ -226,14 +251,8 @@ class ColorSystem:
if __name__ == "__main__":
color_system = ColorSystem(
primary="#005EA8",
secondary="#F59402",
warning="#ffa000",
error="#C62828",
success="#558B2F",
)
from .app import DEFAULT_COLORS
from rich import print
print(color_system)
print(DEFAULT_COLORS)

View File

@@ -311,6 +311,9 @@ class MessagePump:
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.
@@ -344,7 +347,6 @@ class MessagePump:
async def on_callback(self, event: events.Callback) -> None:
await invoke(event.callback)
# await event.callback()
def emit_no_wait(self, message: Message) -> bool:
if self._parent:

View File

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

View File

@@ -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
)
@@ -525,6 +537,9 @@ class Widget(DOMNode):
"""
if self._dirty_regions:
self._render_lines()
if self.is_container:
self.horizontal_scrollbar.refresh()
self.vertical_scrollbar.refresh()
lines = self._render_cache.lines[start:end]
return lines
@@ -639,30 +654,40 @@ class Widget(DOMNode):
def on_enter(self) -> None:
self.mouse_over = True
def on_mouse_scroll_down(self) -> None:
self.scroll_down(animate=False)
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) -> None:
self.scroll_up(animate=False)
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:

View File

@@ -108,6 +108,12 @@ 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)