color scheme, compositor fix

This commit is contained in:
Will McGugan
2022-04-04 17:00:29 +01:00
parent a6a2b0cdbf
commit ab5a2efd84
13 changed files with 181 additions and 104 deletions

View File

@@ -1,44 +1,62 @@
/* CSS file for basic.py */
$primary: #20639b;
App > Screen {
layout: dock;
docks: side=left/1;
background: $primary;
background: $background;
color: $on-background;
}
#sidebar {
color: #09312e;
background: #3caea3;
color: $on-primary;
background: $primary;
dock: side;
width: 30;
offset-x: -100%;
layout: dock;
transition: offset 500ms in_out_cubic;
border-right: outer #09312e;
}
#sidebar.-active {
offset-x: 0;
}
#sidebar .title {
height: 1;
background: $secondary-darken1;
color: $on-secondary-darken1;
border-right: vkey $secondary-darken2;
}
#sidebar .user {
height: 8;
background: $secondary;
color: $on-secondary;
border-right: vkey $secondary-darken2;
}
#sidebar .content {
background: $secondary-lighten1;
color: $on-secondary-lighten1;
border-right: vkey $secondary-darken2;
}
#header {
color: white;
background: #173f5f;
color: $on-primary;
background: $primary;
height: 3;
border: hkey white;
border: hkey $primary-darken2;
}
#content {
color: white;
background: $primary;
color: $on-background;
background: $background;
border-bottom: hkey #0f2b41;
}
#footer {
color: #3a3009;
background: #f6d55c;
color: $on-accent1;
background: $accent1;
height: 3;
height: 1;
}

View File

@@ -15,8 +15,21 @@ class BasicApp(App):
header=Widget(),
content=Widget(),
footer=Widget(),
sidebar=Widget(),
sidebar=Widget(
Widget(classes={"title"}),
Widget(classes={"user"}),
Widget(classes={"content"}),
),
)
async def on_key(self, event) -> None:
await self.dispatch_key(event)
def key_d(self):
self.dark = not self.dark
def key_x(self):
self.panic(self.tree)
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")

View File

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

View File

@@ -205,6 +205,8 @@ class Compositor:
else ORIGIN
)
# region += layout_offset
# Container region is minus border
container_region = region.shrink(widget.styles.gutter)
@@ -233,8 +235,13 @@ 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
+ child_region.origin
- scroll_offset
+ layout_offset
),
(z,) + sub_widget.z,
sub_clip,
)

View File

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

View File

@@ -12,10 +12,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,6 +28,7 @@ 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 .design import ColorSystem
from .dom import DOMNode
from .driver import Driver
from .file_monitor import FileMonitor
@@ -33,7 +36,6 @@ from .geometry import Offset, Region, Size
from .layouts.dock import Dock
from .message_pump import MessagePump
from .reactive import Reactive
from .renderables.gradient import VerticalGradient
from .screen import Screen
from .widget import Widget
@@ -110,7 +112,17 @@ class App(DOMNode):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
self.stylesheet = Stylesheet()
self.design = ColorSystem(
accent1="#ffa726",
secondary="#00695c",
warning="#ffa000",
error="#C62828",
success="#558B2F",
primary="#1976D2",
accent3="#512DA8",
)
self.stylesheet = Stylesheet(variables=self.get_css_variables())
self.css_file = css_file
self.css_monitor = (
@@ -128,6 +140,16 @@ class App(DOMNode):
title: Reactive[str] = Reactive("Textual")
sub_title: Reactive[str] = Reactive("")
background: Reactive[str] = Reactive("black")
dark = Reactive(True)
def get_css_variables(self) -> dict[str, str]:
variables = self.design.generate(self.dark)
return variables
def watch_dark(self, dark: bool) -> None:
self.log(dark=dark)
self.screen.dark = dark
self.refresh_css()
def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform.
@@ -137,6 +159,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
@@ -248,7 +271,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)
@@ -367,16 +390,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:
@@ -458,24 +483,30 @@ class App(DOMNode):
Args:
parent (Widget): Parent Widget
"""
self.log("app.register", parent, anon_widgets)
if not anon_widgets and not widgets:
raise AppError(
"Nothing to mount, did you forget parent as first positional arg?"
)
name_widgets: Iterable[tuple[str | None, Widget]]
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
self.log("name_widgets", name_widgets, bool(name_widgets))
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.children:
# for child in widget.children:
# self.register(child, *child.children)
for widget_id, widget in name_widgets:
self.log(widget_id=widget_id, widget=widget, _in=widget in self.registry)
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:
@@ -531,6 +562,17 @@ 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.
"""
# TODO: This doesn't update variables
self.app.stylesheet.set_variables(self.get_css_variables())
self.app.stylesheet.update(self.app, animate=animate)
self.refresh(layout=True)
def display(self, renderable: RenderableType) -> None:
if not self._running:
return

View File

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

View File

@@ -16,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]]] = {
@@ -212,13 +212,13 @@ def _unresolved(
def substitute_references(
tokens: Iterator[Token], css_variables: dict[str, list[Token]] | None = None
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:
@@ -230,8 +230,10 @@ def substitute_references(
"""
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":
@@ -239,7 +241,7 @@ def substitute_references(
yield token
while True:
token = next(tokens, None)
token = next(iter_tokens, None)
# TODO: Mypy error looks legit
if token.name == "whitespace":
yield token
@@ -281,7 +283,7 @@ def substitute_references(
else:
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:
@@ -302,7 +304,9 @@ def substitute_references(
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.
@@ -310,7 +314,8 @@ def parse(css: str, path: str) -> Iterable[RuleSet]:
css (str): The input CSS
path (str): Path to the CSS
"""
tokens = iter(substitute_references(tokenize(css, path)))
variable_tokens = tokenize_values(variables or {})
tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
while True:
token = next(tokens, None)
if token is None:

View File

@@ -36,10 +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, object] = {}
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:
@@ -54,7 +58,7 @@ class StylesheetErrors:
)
return Panel(syntax, border_style="red")
def add_variables(self, **variable_map: object) -> None:
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)
@@ -95,8 +99,9 @@ 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 {}
def __rich_repr__(self) -> rich.repr.Result:
yield self.rules
@@ -114,6 +119,9 @@ class Stylesheet:
def error_renderable(self) -> StylesheetErrors:
return StylesheetErrors(self)
def set_variables(self, variables: dict[str, str]) -> None:
self.variables = variables
def read(self, filename: str) -> None:
filename = os.path.expanduser(filename)
try:
@@ -123,14 +131,14 @@ 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))
rules = list(parse(css, path, variables=self.variables))
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")
self.rules.extend(rules)

View File

@@ -31,7 +31,13 @@ class ColorProperty:
class ColorSystem:
"""Defines a standard set of colors and variations for building a UI."""
"""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",
@@ -97,7 +103,7 @@ class ColorSystem:
def generate(
self,
dark: bool = False,
luminosity_spread: float = 0.2,
luminosity_spread: float = 0.15,
text_alpha: float = 0.9,
) -> dict[str, str]:
"""Generate a mapping of color name on to a CSS color.
@@ -129,6 +135,12 @@ class ColorSystem:
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(-2, +3):
if n < 0:
@@ -139,6 +151,7 @@ class ColorSystem:
label = ""
yield (f"{label}{abs(n) if n else ''}"), n * luminosity_step
# Color names and color
COLORS = [
("primary", primary),
("secondary", secondary),
@@ -152,6 +165,7 @@ class ColorSystem:
("accent3", accent3),
]
# Colors names that have a dark varient
DARK_SHADES = {"primary", "secondary"}
for name, color in COLORS:
@@ -203,8 +217,8 @@ class ColorSystem:
if __name__ == "__main__":
color_system = ColorSystem(
primary="#4caf50",
secondary="#ffa000",
primary="#1b5e20",
secondary="#263238",
warning="#ffa000",
error="#C62828",
success="#558B2F",

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ class Widget(DOMNode):
can_focus: bool = False
DEFAULT_STYLES = """
"""
def __init__(
@@ -421,7 +421,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: