improved color harmony

This commit is contained in:
Will McGugan
2022-07-19 21:41:34 +01:00
parent 1b6c4273c7
commit 49764a3ec7
13 changed files with 233 additions and 210 deletions

View File

@@ -29,8 +29,8 @@ DataTable {
}
#sidebar {
color: $text-primary;
background: $primary-background;
color: $text-panel;
background: $panel;
dock: side;
width: 30;
offset-x: -100%;
@@ -43,33 +43,35 @@ DataTable {
}
#sidebar .title {
height: 3;
background: $primary-background-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
height: 1;
background: $primary-background-darken-1;
color: $text-primary-background-darken-1;
border-right: wide $background;
content-align: center middle;
}
#sidebar .user {
height: 8;
background: $primary-background-darken-1;
color: $text-primary-darken-1;
border-right: outer $primary-background-darken-3;
background: $panel-darken-1;
color: $text-panel-darken-1;
border-right: wide $background;
content-align: center middle;
}
#sidebar .content {
background: $primary-background;
color: $text-primary-background;
border-right: outer $primary-background-darken-3;
background: $surface;
color: $text-surface;
border-right: wide $background;
content-align: center middle;
}
#header {
color: $text-secondary-background-darken-1;
background: $secondary-background-darken-1;
height: 3;
color: $text-secondary-background;
background: $secondary-background;
height: 1;
content-align: center middle;
}
#content {
@@ -90,7 +92,7 @@ Tweet {
layout: vertical;
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
border: wide $panel;
overflow: auto;
/* scrollbar-gutter: stable; */
align-horizontal: center;
@@ -175,16 +177,16 @@ Tweet.scroll-horizontal TweetBody {
OptionItem {
height: 3;
background: $primary-background;
border-right: outer $primary-background-darken-2;
background: $panel;
border-right: wide $background;
border-left: blank;
content-align: center middle;
}
OptionItem:hover {
height: 3;
color: $secondary;
background: $primary-background-darken-1;
color: $text-primary;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
@@ -196,8 +198,8 @@ Error {
height:3;
background: $error;
color: $text-error;
border-top: wide $error-darken-1;
border-bottom: wide $error-darken-1;
border-top: tall $error-darken-1;
border-bottom: tall $error-darken-1;
padding: 0;
text-style: bold;
@@ -209,8 +211,8 @@ Warning {
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: wide $warning-darken-1;
border-bottom: wide $warning-darken-1;
border-top: tall $warning-darken-1;
border-bottom: tall $warning-darken-1;
text-style: bold;
align-horizontal: center;
@@ -218,7 +220,7 @@ Warning {
Success {
width: 100%;
width:90%;
height:auto;
box-sizing: border-box;
background: $success;
@@ -227,7 +229,7 @@ Success {
border-top: hkey $success-darken-1;
border-bottom: hkey $success-darken-1;
text-style: bold underline;
text-style: bold ;
align-horizontal: center;
}

View File

@@ -179,21 +179,21 @@ app = BasicApp()
if __name__ == "__main__":
app.run()
from textual.geometry import Region
from textual.color import Color
# from textual.geometry import Region
# from textual.color import Color
print(Region.intersection.cache_info())
print(Region.overlaps.cache_info())
print(Region.union.cache_info())
print(Region.split_vertical.cache_info())
print(Region.__contains__.cache_info())
from textual.css.scalar import Scalar
# print(Region.intersection.cache_info())
# print(Region.overlaps.cache_info())
# print(Region.union.cache_info())
# print(Region.split_vertical.cache_info())
# print(Region.__contains__.cache_info())
# from textual.css.scalar import Scalar
print(Scalar.resolve_dimension.cache_info())
# print(Scalar.resolve_dimension.cache_info())
from rich.style import Style
from rich.cells import cached_cell_len
# from rich.style import Style
# from rich.cells import cached_cell_len
print(Style._add.cache_info())
# print(Style._add.cache_info())
print(cached_cell_len.cache_info())
# print(cached_cell_len.cache_info())

View File

@@ -80,16 +80,26 @@ _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
LayoutDefinition = "dict[str, Any]"
DEFAULT_COLORS = ColorSystem(
primary="#2A4E6E",
DEFAULT_COLORS = {
"dark": ColorSystem(
primary="#004578",
secondary="#ffa62b",
warning="#ffa62b",
error="#ba3c5b",
success="#4EBF71",
accent="#1A75B4",
system="#5a4599",
dark_surface="#292929",
)
accent="#0178D4",
dark=True,
),
"light": ColorSystem(
primary="#004578",
secondary="#ffa62b",
warning="#ffa62b",
error="#ba3c5b",
success="#4EBF71",
accent="#0178D4",
dark=False,
),
}
ComposeResult = Iterable[Widget]
@@ -338,7 +348,7 @@ class App(Generic[ReturnType], DOMNode):
Returns:
dict[str, str]: A mapping of variable name to value.
"""
variables = self.design.generate(self.dark)
variables = self.design["dark" if self.dark else "light"].generate()
return variables
def watch_dark(self, dark: bool) -> None:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from rich.console import ConsoleOptions, Console
from rich.console import ConsoleOptions, Console, RenderResult
from rich.traceback import Traceback
from ._help_renderables import HelpText
@@ -15,10 +15,6 @@ class DeclarationError(Exception):
super().__init__(message)
class UnresolvedVariableError(NameError):
pass
class StyleTypeError(TypeError):
pass
@@ -35,7 +31,9 @@ class StyleValueError(ValueError):
super().__init__(*args)
self.help_text = help_text
def __rich_console__(self, console: Console, options: ConsoleOptions):
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield Traceback.from_exception(type(self), self, self.__traceback__)
if self.help_text is not None:
yield ""

View File

@@ -2,11 +2,11 @@ from __future__ import annotations
from functools import lru_cache
from pathlib import PurePath
from typing import Iterator, Iterable
from typing import Iterator, Iterable, NoReturn, Sequence
from rich import print
from textual.css.errors import UnresolvedVariableError
from textual.css.tokenizer import TokenError
from textual.css.types import Specificity3
from ._styles_builder import StylesBuilder, DeclarationError
from .model import (
@@ -18,6 +18,7 @@ from .model import (
SelectorType,
)
from .styles import Styles
from ..suggestions import get_suggestion
from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values
from .tokenizer import EOFError, ReferencedBy
@@ -209,12 +210,20 @@ def parse_declarations(css: str, path: str) -> Styles:
return styles_builder.styles
def _unresolved(
variable_name: str, location: tuple[int, int]
) -> UnresolvedVariableError:
line_idx, col_idx = location
return UnresolvedVariableError(
f"reference to undefined variable '${variable_name}' at line {line_idx + 1}, column {col_idx + 1}."
def _unresolved(variable_name: str, variables: Sequence[str], token: Token) -> NoReturn:
message = f"reference to undefined variable '${variable_name}'"
suggested_variable = get_suggestion(variable_name, variables)
if suggested_variable:
message += f"; did you mean '{suggested_variable}'?"
raise TokenError(
token.path,
token.code,
token.start,
message,
end=token.end,
)
@@ -284,9 +293,7 @@ def substitute_references(
)
)
else:
raise _unresolved(
variable_name=ref_name, location=token.location
)
_unresolved(ref_name, variables.keys(), token)
else:
variables.setdefault(variable_name, []).append(token)
yield token
@@ -306,7 +313,7 @@ def substitute_references(
)
)
else:
raise _unresolved(variable_name=variable_name, location=token.location)
_unresolved(variable_name, variables.keys(), token)
else:
yield token

View File

@@ -22,7 +22,7 @@ from .model import RuleSet
from .parse import parse
from .styles import RulesMap, Styles
from .tokenize import tokenize_values, Token
from .tokenizer import TokenizeError
from .tokenizer import TokenError
from .types import Specificity3, Specificity4
from ..dom import DOMNode
from .. import messages
@@ -198,7 +198,7 @@ class Stylesheet:
is_default_rules=is_default_rules,
)
)
except TokenizeError:
except TokenError:
raise
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")

View File

@@ -15,28 +15,43 @@ from rich.text import Text
from .._loop import loop_last
class TokenizeError(Exception):
class TokenError(Exception):
"""Error raised when the CSS cannot be tokenized (syntax error)."""
def __init__(
self, path: str, code: str, line_no: int, col_no: int, message: str
self,
path: str,
code: str,
start: tuple[int, int],
message: str,
end: tuple[int, int] | None = None,
) -> None:
"""
Args:
path (str): Path to source or "<object>" if source is parsed from a literal.
code (str): The code being parsed.
line_no (int): Line number of the error.
col_no (int): Column number of the error.
start (tuple[int, int]): Line number of the error.
message (str): A message associated with the error.
end (tuple[int, int]): End location of .
"""
"""_summary_
Args:
path (str): Path to source or "<object>" if source is parsed from a literal.
code (str): The code being parsed.
start (tuple[int, int]): Location of the error.
message (str): A message associated with the error.
end (tuple[int, int] | None, optional): End location of the error, or None for
the same as start. Defaults to None.
"""
self.path = path
self.code = code
self.line_no = line_no
self.col_no = col_no
self.start = start
self.end = end or start
super().__init__(message)
@classmethod
def _get_snippet(cls, code: str, line_no: int) -> Panel:
def _get_snippet(self) -> Panel:
"""Get a short snippet of code around a given line number.
Args:
@@ -46,9 +61,10 @@ class TokenizeError(Exception):
Returns:
Panel: A renderable.
"""
line_no = self.start[0]
# TODO: Highlight column number
syntax = Syntax(
code,
self.code,
lexer="scss",
theme="ansi_light",
line_numbers=True,
@@ -56,6 +72,7 @@ class TokenizeError(Exception):
line_range=(max(0, line_no - 2), line_no + 2),
highlight_lines={line_no},
)
syntax.stylize_range("reverse bold", self.start, self.end)
return Panel(syntax, border_style="red")
def __rich__(self) -> RenderableType:
@@ -63,14 +80,12 @@ class TokenizeError(Exception):
errors: list[RenderableType] = []
message = str(self)
errors.append(Text(" Tokenizer error in stylesheet:", style="bold red"))
errors.append(Text(" Error in stylesheet:", style="bold red"))
errors.append(
highlighter(
f" {self.path or '<unknown>'}:{self.line_no + 1}:{self.col_no + 1}"
)
)
errors.append(self._get_snippet(self.code, self.line_no + 1))
line_no, col_no = self.start
errors.append(highlighter(f" {self.path or '<unknown>'}:{line_no}:{col_no}"))
errors.append(self._get_snippet())
final_message = ""
for is_last, message_part in loop_last(message.split(";")):
end = "" if is_last else "\n"
@@ -80,7 +95,7 @@ class TokenizeError(Exception):
return Group(*errors)
class EOFError(TokenizeError):
class EOFError(TokenError):
pass
@@ -120,6 +135,18 @@ class Token(NamedTuple):
location: tuple[int, int]
referenced_by: ReferencedBy | None = None
@property
def start(self) -> tuple[int, int]:
"""Start line and column (1 indexed)."""
line, offset = self.location
return (line + 1, offset)
@property
def end(self) -> tuple[int, int]:
"""End line and column (1 indexed)."""
line, offset = self.location
return (line + 1, offset + len(self.value))
def with_reference(self, by: ReferencedBy | None) -> "Token":
"""Return a copy of the Token, with reference information attached.
This is used for variable substitution, where a variable reference
@@ -161,19 +188,28 @@ class Tokenizer:
col_no = self.col_no
if line_no >= len(self.lines):
if expect._expect_eof:
return Token("eof", "", self.path, self.code, (line_no, col_no), None)
return Token(
"eof",
"",
self.path,
self.code,
(line_no + 1, col_no + 1),
None,
)
else:
raise EOFError(
self.path, self.code, line_no, col_no, "Unexpected end of file"
self.path,
self.code,
(line_no + 1, col_no + 1),
"Unexpected end of file",
)
line = self.lines[line_no]
match = expect.match(line, col_no)
if match is None:
raise TokenizeError(
raise TokenError(
self.path,
self.code,
line_no,
col_no,
(line_no, col_no),
"expected " + ", ".join(name.upper() for name in expect.names),
)
iter_groups = iter(match.groups())

View File

@@ -9,39 +9,18 @@ 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_DARK_SURFACE = "#292929"
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.
@@ -63,7 +42,6 @@ class ColorSystem:
"error",
"success",
"accent",
"system",
]
def __init__(
@@ -74,42 +52,30 @@ class ColorSystem:
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,
dark: bool = False,
luminosity_spread: float = 0.15,
text_alpha: float = 0.95,
):
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
def parse(color: str | None) -> Color | None:
if color is None:
return None
return Color.parse(color)
@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()
self.primary = Color.parse(primary)
self.secondary = parse(secondary)
self.warning = parse(warning)
self.error = parse(error)
self.success = parse(success)
self.accent = parse(accent)
self.background = parse(background)
self.surface = parse(surface)
self.panel = parse(panel)
self._dark = dark
self._luminosity_spread = luminosity_spread
self._text_alpha = text_alpha
@property
def shades(self) -> Iterable[str]:
@@ -123,12 +89,7 @@ class ColorSystem:
else:
yield color
def generate(
self,
dark: bool = False,
luminosity_spread: float = 0.15,
text_alpha: float = 0.9,
) -> dict[str, str]:
def generate(self) -> dict[str, str]:
"""Generate a mapping of color name on to a CSS color.
Args:
@@ -148,22 +109,20 @@ class ColorSystem:
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)
dark = self._dark
luminosity_spread = self._luminosity_spread
text_alpha = self._text_alpha
light_surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE)
dark_surface = self.dark_surface or Color.parse(DEFAULT_DARK_SURFACE)
if dark:
background = self.background or Color.parse(DEFAULT_DARK_BACKGROUND)
surface = self.surface or Color.parse(DEFAULT_DARK_SURFACE)
else:
background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND)
surface = self.surface or Color.parse(DEFAULT_LIGHT_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
)
panel = surface.blend(primary, luminosity_spread)
else:
panel = self.panel
@@ -199,7 +158,6 @@ class ColorSystem:
("error", error),
("success", success),
("accent", accent),
("system", system),
]
# Colors names that have a dark variant
@@ -207,7 +165,7 @@ 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
spread = luminosity_spread
if name == "panel":
spread /= 2
for shade_name, luminosity_delta in luminosity_range(spread):
@@ -231,11 +189,23 @@ class ColorSystem:
return colors
def __rich__(self) -> Table:
def show_design(light: ColorSystem, dark: ColorSystem) -> Table:
"""Generate a renderable to show color systems.
Args:
light (ColorSystem): Light ColorSystem.
dark (ColorSystem): Dark ColorSystem
Returns:
Table: Table showing all colors.
"""
@group()
def make_shades(dark: bool):
colors = self.generate(dark)
for name in self.shades:
def make_shades(system: ColorSystem):
colors = system.generate()
for name in system.shades:
background = colors[name]
foreground = colors[f"text-{name}"]
text = Text(f"{background} ", style=f"{foreground} on {background}")
@@ -250,7 +220,7 @@ class ColorSystem:
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))
table.add_row(make_shades(light), make_shades(dark))
return table
@@ -259,4 +229,4 @@ if __name__ == "__main__":
from rich import print
print(DEFAULT_COLORS)
print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"]))

View File

@@ -71,9 +71,9 @@ class Widget(DOMNode):
CSS = """
Widget{
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system;
scrollbar-background: $panel-darken-1;
scrollbar-background-hover: $panel-darken-2;
scrollbar-color: $primary-lighten-1;
scrollbar-color-active: $warning-darken-1;
scrollbar-size-vertical: 2;
scrollbar-size-horizontal: 1;

View File

@@ -33,11 +33,11 @@ class Button(Widget, can_focus=True):
Button {
width: auto;
height: 3;
background: $primary;
color: $text-primary;
background: $panel;
color: $text-panel;
border: none;
border-top: tall $primary-lighten-2;
border-bottom: tall $primary-darken-3;
border-top: tall $panel-lighten-2;
border-bottom: tall $panel-darken-3;
content-align: center middle;
text-style: bold;
}
@@ -47,15 +47,15 @@ class Button(Widget, can_focus=True):
}
Button:hover {
border-top: tall $primary-lighten-1;
background: $primary-darken-2;
color: $text-primary-darken-2;
border-top: tall $panel-lighten-1;
background: $panel-darken-2;
color: $text-panel-darken-2;
}
Button.-active {
background: $primary;
border-bottom: tall $primary-lighten-2;
border-top: tall $primary-darken-2;
background: $panel;
border-bottom: tall $panel-lighten-2;
border-top: tall $panel-darken-2;
}

View File

@@ -113,13 +113,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
}
DataTable > .datatable--header {
text-style: bold;
background: $primary-darken-1;
color: $text-primary-darken-1;
background: $primary;
color: $text-primary;
}
DataTable > .datatable--fixed {
text-style: bold;
background: $primary-darken-2;
color: $text-primary-darken-2;
background: $primary;
color: $text-primary;
}
DataTable > .datatable--odd-row {

View File

@@ -6,7 +6,7 @@ import pytest
from textual.color import Color
from textual.css._help_renderables import HelpText
from textual.css.stylesheet import Stylesheet, StylesheetParseError, CssSource
from textual.css.tokenizer import TokenizeError
from textual.css.tokenizer import TokenError
from textual.dom import DOMNode
from textual.geometry import Spacing
from textual.widget import Widget
@@ -145,7 +145,7 @@ def test_stylesheet_apply_user_css_over_widget_css():
["ansi_dark_cyan", pytest.raises(StylesheetParseError), None],
["red 4", pytest.raises(StylesheetParseError), None], # space in it
["1", pytest.raises(StylesheetParseError), None], # invalid value
["()", pytest.raises(TokenizeError), None], # invalid tokens
["()", pytest.raises(TokenError), None], # invalid tokens
],
)
def test_color_property_parsing(css_value, expectation, expected_color):

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import pytest
from textual.css.tokenize import tokenize
from textual.css.tokenizer import Token, TokenizeError
from textual.css.tokenizer import Token, TokenError
VALID_VARIABLE_NAMES = [
"warning-text",
@@ -331,7 +331,7 @@ def test_variable_declaration_no_semicolon():
def test_variable_declaration_invalid_value():
css = "$x:(@$12x)"
with pytest.raises(TokenizeError):
with pytest.raises(TokenError):
list(tokenize(css, ""))