diff --git a/examples/basic.css b/examples/basic.css index bbe4b534b..fa69c4f6a 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -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; } diff --git a/examples/basic.py b/examples/basic.py index 72743ab93..94968ef64 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -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") diff --git a/examples/borders.css b/examples/borders.css index de4761bab..f27374927 100644 --- a/examples/borders.css +++ b/examples/borders.css @@ -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 { diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 71e2e77a3..b4897f300 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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, ) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 863988519..61b00a2c5 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -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] diff --git a/src/textual/app.py b/src/textual/app.py index 6499c45fd..14f12538d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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 diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 3622f2a21..52405160b 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -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 diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 6450ff76d..d934a2109 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -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: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 0c45f440e..d3f00055c 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -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) diff --git a/src/textual/design.py b/src/textual/design.py index 25ffeb06b..5d56a4f9d 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -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 () + + """ 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", diff --git a/src/textual/layout.py b/src/textual/layout.py index 50ebedf76..9914ad78c 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -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.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index b69122fab..0d1043bb8 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index 3bc161c2e..fbc0bd676 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: