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 */ /* CSS file for basic.py */
$primary: #20639b;
App > Screen { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
background: $primary; background: $background;
color: $on-background;
} }
#sidebar { #sidebar {
color: #09312e; color: $on-primary;
background: #3caea3; background: $primary;
dock: side; dock: side;
width: 30; width: 30;
offset-x: -100%; offset-x: -100%;
layout: dock;
transition: offset 500ms in_out_cubic; transition: offset 500ms in_out_cubic;
border-right: outer #09312e;
} }
#sidebar.-active { #sidebar.-active {
offset-x: 0; 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 { #header {
color: white; color: $on-primary;
background: #173f5f; background: $primary;
height: 3; height: 3;
border: hkey white; border: hkey $primary-darken2;
} }
#content { #content {
color: white; color: $on-background;
background: $primary; background: $background;
border-bottom: hkey #0f2b41; border-bottom: hkey #0f2b41;
} }
#footer { #footer {
color: #3a3009; color: $on-accent1;
background: #f6d55c; background: $accent1;
height: 3; height: 1;
} }

View File

@@ -15,8 +15,21 @@ class BasicApp(App):
header=Widget(), header=Widget(),
content=Widget(), content=Widget(),
footer=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") BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")

View File

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

View File

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

View File

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

View File

@@ -12,10 +12,12 @@ import rich.repr
from rich.console import Console, RenderableType from rich.console import Console, RenderableType
from rich.control import Control from rich.control import Control
from rich.measure import Measurement from rich.measure import Measurement
from rich.segment import Segments
from rich.screen import Screen as ScreenRenderable from rich.screen import Screen as ScreenRenderable
from rich.traceback import Traceback from rich.traceback import Traceback
from . import actions from . import actions
from . import events from . import events
from . import log from . import log
from . import messages from . import messages
@@ -26,6 +28,7 @@ from ._event_broker import extract_handler_actions, NoHandler
from ._profile import timer from ._profile import timer
from .binding import Bindings, NoBinding from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError
from .design import ColorSystem
from .dom import DOMNode from .dom import DOMNode
from .driver import Driver from .driver import Driver
from .file_monitor import FileMonitor from .file_monitor import FileMonitor
@@ -33,7 +36,6 @@ from .geometry import Offset, Region, Size
from .layouts.dock import Dock from .layouts.dock import Dock
from .message_pump import MessagePump from .message_pump import MessagePump
from .reactive import Reactive from .reactive import Reactive
from .renderables.gradient import VerticalGradient
from .screen import Screen from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -110,7 +112,17 @@ class App(DOMNode):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False) self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = 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_file = css_file
self.css_monitor = ( self.css_monitor = (
@@ -128,6 +140,16 @@ class App(DOMNode):
title: Reactive[str] = Reactive("Textual") title: Reactive[str] = Reactive("Textual")
sub_title: Reactive[str] = Reactive("") sub_title: Reactive[str] = Reactive("")
background: Reactive[str] = Reactive("black") 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]: def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform. """Get a driver class for this platform.
@@ -137,6 +159,7 @@ class App(DOMNode):
Returns: Returns:
Driver: A Driver class which manages input and display. Driver: A Driver class which manages input and display.
""" """
driver_class: Type[Driver]
if WINDOWS: if WINDOWS:
from .drivers.windows_driver import WindowsDriver from .drivers.windows_driver import WindowsDriver
@@ -248,7 +271,7 @@ class App(DOMNode):
async def _on_css_change(self) -> None: async def _on_css_change(self) -> None:
if self.css_file is not None: if self.css_file is not None:
stylesheet = Stylesheet() stylesheet = Stylesheet(variables=self.get_css_variables())
try: try:
self.log("loading", self.css_file) self.log("loading", self.css_file)
stylesheet.read(self.css_file) stylesheet.read(self.css_file)
@@ -367,16 +390,18 @@ class App(DOMNode):
""" """
if not renderables: if not renderables:
renderables = ( renderables = (
Traceback( Traceback(
show_locals=True, show_locals=True, width=None, locals_max_length=5, suppress=[rich]
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() self.close_messages_no_wait()
def _print_error_renderables(self) -> None: def _print_error_renderables(self) -> None:
@@ -458,24 +483,30 @@ class App(DOMNode):
Args: Args:
parent (Widget): Parent Widget parent (Widget): Parent Widget
""" """
self.log("app.register", parent, anon_widgets)
if not anon_widgets and not widgets: if not anon_widgets and not widgets:
raise AppError( raise AppError(
"Nothing to mount, did you forget parent as first positional arg?" "Nothing to mount, did you forget parent as first positional arg?"
) )
name_widgets: Iterable[tuple[str | None, Widget]] name_widgets: Iterable[tuple[str | None, Widget]]
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()] 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 apply_stylesheet = self.stylesheet.apply
# Register children # Register children
for widget_id, widget in name_widgets: # for widget_id, widget in name_widgets:
if widget.children: # if widget.children:
self.register(widget, *widget.children) # for child in widget.children:
# self.register(child, *child.children)
for widget_id, widget in name_widgets: 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 not in self.registry:
if widget_id is not None: if widget_id is not None:
widget.id = widget_id 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) apply_stylesheet(widget)
for _widget_id, widget in name_widgets: for _widget_id, widget in name_widgets:
@@ -531,6 +562,17 @@ class App(DOMNode):
except Exception: except Exception:
self.panic() 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: def display(self, renderable: RenderableType) -> None:
if not self._running: if not self._running:
return return

View File

@@ -32,8 +32,8 @@ from ..geometry import Spacing, SpacingDimensions, clamp
if TYPE_CHECKING: if TYPE_CHECKING:
from ..layout import Layout from ..layout import Layout
from .styles import Styles, StylesBase from .styles import DockGroup, Styles, StylesBase
from .styles import DockGroup
from .types import EdgeType from .types import EdgeType
@@ -378,7 +378,11 @@ class DocksProperty:
Returns: Returns:
tuple[DockGroup, ...]: A ``tuple`` containing the defined docks. 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): def __set__(self, obj: StylesBase, docks: Iterable[DockGroup] | None):
"""Set the Docks property """Set the Docks property

View File

@@ -16,7 +16,7 @@ from .model import (
SelectorType, SelectorType,
) )
from .styles import Styles 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 from .tokenizer import EOFError, ReferencedBy
SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = {
@@ -212,13 +212,13 @@ def _unresolved(
def substitute_references( 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]: ) -> Iterable[Token]:
"""Replace variable references with values by substituting variable reference """Replace variable references with values by substituting variable reference
tokens with the tokens representing their values. tokens with the tokens representing their values.
Args: 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". with the name "variable_ref".
Returns: Returns:
@@ -230,8 +230,10 @@ def substitute_references(
""" """
variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {} variables: dict[str, list[Token]] = css_variables.copy() if css_variables else {}
iter_tokens = iter(tokens)
while tokens: while tokens:
token = next(tokens, None) token = next(iter_tokens, None)
if token is None: if token is None:
break break
if token.name == "variable_name": if token.name == "variable_name":
@@ -239,7 +241,7 @@ def substitute_references(
yield token yield token
while True: while True:
token = next(tokens, None) token = next(iter_tokens, None)
# TODO: Mypy error looks legit # TODO: Mypy error looks legit
if token.name == "whitespace": if token.name == "whitespace":
yield token yield token
@@ -281,7 +283,7 @@ def substitute_references(
else: else:
variables.setdefault(variable_name, []).append(token) variables.setdefault(variable_name, []).append(token)
yield token yield token
token = next(tokens, None) token = next(iter_tokens, None)
elif token.name == "variable_ref": elif token.name == "variable_ref":
variable_name = token.value[1:] # Trim the $, so $x -> x variable_name = token.value[1:] # Trim the $, so $x -> x
if variable_name in variables: if variable_name in variables:
@@ -302,7 +304,9 @@ def substitute_references(
yield 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, """Parse CSS by tokenizing it, performing variable substitution,
and generating rule sets from it. and generating rule sets from it.
@@ -310,7 +314,8 @@ def parse(css: str, path: str) -> Iterable[RuleSet]:
css (str): The input CSS css (str): The input CSS
path (str): Path to the 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: while True:
token = next(tokens, None) token = next(tokens, None)
if token is None: if token is None:

View File

@@ -36,10 +36,14 @@ class StylesheetParseError(Exception):
class StylesheetErrors: class StylesheetErrors:
def __init__(self, stylesheet: "Stylesheet") -> None: def __init__(
self, stylesheet: "Stylesheet", variables: dict[str, str] | None = None
) -> None:
self.stylesheet = stylesheet self.stylesheet = stylesheet
self.variables: dict[str, object] = {} self.variables: dict[str, str] = {}
self._css_variables: dict[str, list[Token]] = {} self._css_variables: dict[str, list[Token]] = {}
if variables:
self.set_variables(variables)
@classmethod @classmethod
def _get_snippet(cls, code: str, line_no: int) -> Panel: def _get_snippet(cls, code: str, line_no: int) -> Panel:
@@ -54,7 +58,7 @@ class StylesheetErrors:
) )
return Panel(syntax, border_style="red") 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.""" """Pre-populate CSS variables."""
self.variables.update(variable_map) self.variables.update(variable_map)
self._css_variables = tokenize_values(self.variables) self._css_variables = tokenize_values(self.variables)
@@ -95,8 +99,9 @@ class StylesheetErrors:
@rich.repr.auto @rich.repr.auto
class Stylesheet: class Stylesheet:
def __init__(self) -> None: def __init__(self, *, variables: dict[str, str] | None = None) -> None:
self.rules: list[RuleSet] = [] self.rules: list[RuleSet] = []
self.variables = variables or {}
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:
yield self.rules yield self.rules
@@ -114,6 +119,9 @@ class Stylesheet:
def error_renderable(self) -> StylesheetErrors: def error_renderable(self) -> StylesheetErrors:
return StylesheetErrors(self) return StylesheetErrors(self)
def set_variables(self, variables: dict[str, str]) -> None:
self.variables = variables
def read(self, filename: str) -> None: def read(self, filename: str) -> None:
filename = os.path.expanduser(filename) filename = os.path.expanduser(filename)
try: try:
@@ -123,14 +131,14 @@ class Stylesheet:
except Exception as error: except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {error}") raise StylesheetError(f"unable to read {filename!r}; {error}")
try: try:
rules = list(parse(css, path)) rules = list(parse(css, path, variables=self.variables))
except Exception as error: except Exception as error:
raise StylesheetError(f"failed to parse {filename!r}; {error}") raise StylesheetError(f"failed to parse {filename!r}; {error}")
self.rules.extend(rules) self.rules.extend(rules)
def parse(self, css: str, *, path: str = "") -> None: def parse(self, css: str, *, path: str = "") -> None:
try: try:
rules = list(parse(css, path)) rules = list(parse(css, path, variables=self.variables))
except Exception as error: except Exception as error:
raise StylesheetError(f"failed to parse css; {error}") raise StylesheetError(f"failed to parse css; {error}")
self.rules.extend(rules) self.rules.extend(rules)

View File

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

View File

@@ -19,26 +19,6 @@ class WidgetPlacement(NamedTuple):
widget: Widget | None = None # A widget of None means empty space widget: Widget | None = None # A widget of None means empty space
order: int = 0 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): class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them.""" """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 .geometry import Offset, Region
from ._compositor import Compositor from ._compositor import Compositor
from .reactive import Reactive
from .widget import Widget from .widget import Widget
from .renderables.gradient import VerticalGradient 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: def __init__(self, name: str | None = None, id: str | None = None) -> None:
super().__init__(name=name, id=id) super().__init__(name=name, id=id)
self._compositor = Compositor() self._compositor = Compositor()
self._dirty_widgets: list[Widget] = [] self._dirty_widgets: list[Widget] = []
def watch_dark(self, dark: bool) -> None:
pass
@property @property
def is_transparent(self) -> bool: def is_transparent(self) -> bool:
return False return False

View File

@@ -67,7 +67,7 @@ class Widget(DOMNode):
can_focus: bool = False can_focus: bool = False
DEFAULT_STYLES = """ DEFAULT_STYLES = """
""" """
def __init__( def __init__(
@@ -421,7 +421,10 @@ class Widget(DOMNode):
@property @property
def region(self) -> Region: 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 @property
def scroll_offset(self) -> Offset: def scroll_offset(self) -> Offset: