diff --git a/examples/basic.css b/examples/basic.css index bc40ac4f6..72d825b07 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,39 +1,39 @@ App > View { layout: dock; docks: side=left/1 header=top footer=bottom; - layers: base panels; } #sidebar { text: bold #09312e on #3caea3; - dock-group: side; + dock: side; width: 30; height: 1fr; - layer: panels; border-right: outer #09312e; offset-x: -100%; - transition: offset 1.2s in_cubic 200ms; + transition: offset 400ms in_out_cubic; } #sidebar.-active { offset-x: 0; + transition: offset 400ms in_out_cubic; + text: on red; } #header { text: on #173f5f; - dock-group: header; + dock: header; height: 3; border: hkey white; } #footer { - dock-group: header; + dock: header; height: 3; border-top: hkey #0f2b41; text: #3a3009 on #f6d55c; } #content { - dock-group: header; - text: on #20639b; + dock: header; + text: bold on #20639b; } diff --git a/examples/basic.py b/examples/basic.py index 476b4225d..cdd72fe6e 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -6,6 +6,7 @@ class BasicApp(App): """A basic app demonstrating CSS""" def on_load(self): + """Bind keys here.""" self.bind("tab", "toggle_class('#sidebar', '-active')") def on_mount(self): @@ -18,4 +19,4 @@ class BasicApp(App): ) -BasicApp.run(log="textual.log", css_file="basic.css", watch_css=True) +BasicApp.run(css_file="basic.css", watch_css=True) diff --git a/examples/example.css b/examples/example.css new file mode 100644 index 000000000..9f5dc0507 --- /dev/null +++ b/examples/example.css @@ -0,0 +1,38 @@ +App > View { + layout: dock; + docks: side=left/1 header=top footer=bottom; +} + +#sidebar { + text: bold #09312e on #3caea3; + dock: side; + width: 30; + height: 1fr; + border-right: outer #09312e; + offset-x: -100%; + transition: offset 400ms in_out_cubic; +} + +#sidebar.-active { + offset-x: 0; + transition: offset 400ms in_out_cubic; +} + +#header { + text: on #173f5f; + dock: header; + height: 3; + border: hkey white; +} + +#footer { + dock: header; + height: 3; + border-top: hkey #0f2b41; + text: #3a3009 on #f6d55c; +} + +#content { + dock: header; + text: bold on #20639b; +} diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 041ae0b0a..5ae39d4a6 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -4,8 +4,7 @@ from abc import ABC, abstractmethod import asyncio import sys from time import time -from tracemalloc import start -from typing import Callable, TypeVar +from typing import Any, Callable, TypeVar from dataclasses import dataclass @@ -62,7 +61,6 @@ class SimpleAnimation(Animation): factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) - log("ANIMATE", self.start_value, self.end_value) if isinstance(self.start_value, Animatable): assert isinstance( self.end_value, Animatable, "end_value must be animatable" @@ -117,6 +115,7 @@ class BoundAnimator: class Animator: def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: self._animations: dict[tuple[object, str], SimpleAnimation] = {} + self.target = target self._timer = Timer( target, 1 / frames_per_second, @@ -144,13 +143,13 @@ class Animator: self, obj: object, attribute: str, - value: float, + value: Any, *, duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, ) -> None: - log("animate", obj, attribute, value) + start_time = time() animation_key = (id(obj), attribute) @@ -167,7 +166,7 @@ class Animator: start_time, duration=duration, speed=speed, - easing=easing, + easing=easing_function, ) if animation is None: @@ -206,3 +205,4 @@ class Animator: animation = self._animations[animation_key] if animation(animation_time): del self._animations[animation_key] + self.target.view.refresh(True, True) diff --git a/src/textual/_linux_driver.py b/src/textual/_linux_driver.py index 7f4df92b2..4010a4c38 100644 --- a/src/textual/_linux_driver.py +++ b/src/textual/_linux_driver.py @@ -145,6 +145,7 @@ class LinuxDriver(Driver): if not self.exit_event.is_set(): signal.signal(signal.SIGWINCH, signal.SIG_DFL) self._disable_mouse_support() + termios.tcflush(self.fileno, termios.TCIFLUSH) self.exit_event.set() if self._key_thread is not None: self._key_thread.join() diff --git a/src/textual/_parser.py b/src/textual/_parser.py index 60ed2d3c7..a303cea4a 100644 --- a/src/textual/_parser.py +++ b/src/textual/_parser.py @@ -9,7 +9,6 @@ from typing import ( TypeVar, Generic, Union, - Iterator, Iterable, ) diff --git a/src/textual/app.py b/src/textual/app.py index bd3a46ff7..74f1c7455 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -158,7 +158,6 @@ class App(DOMNode): @property def size(self) -> Size: - width, height = self.console.size return Size(*self.console.size) def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None: @@ -240,7 +239,7 @@ class App(DOMNode): self.reset_styles() self.stylesheet = stylesheet self.stylesheet.update(self) - self.view.refresh(layout=True) + self.refresh(layout=True) def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.register(self.view, *anon_widgets, **widgets) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5c5a9711a..d56fcbe85 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -259,13 +259,13 @@ class DocksProperty: return docks -class DockGroupProperty: +class DockProperty: def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str: - return obj._rule_dock_group or "" + return obj._rule_dock or "" def __set__(self, obj: Styles, spacing: str | None) -> str | None: obj.refresh(True) - obj._rule_dock_group = spacing + obj._rule_dock = spacing return spacing @@ -279,9 +279,12 @@ class OffsetProperty: ) def __set__( - self, obj: Styles, offset: tuple[int | str, int | str] - ) -> tuple[int | str, int | str]: + self, obj: Styles, offset: tuple[int | str, int | str] | ScalarOffset + ) -> tuple[int | str, int | str] | ScalarOffset: obj.refresh(True) + if isinstance(offset, ScalarOffset): + setattr(obj, self._internal_name, offset) + return offset x, y = offset scalar_x = ( Scalar.parse(x, Unit.WIDTH) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 6ff8a0d6b..5f93c006e 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -294,15 +294,15 @@ class StylesBuilder: style_definition = " ".join(token.value for token in tokens) self.styles.text_style = style_definition - def process_dock_group(self, name: str, tokens: list[Token]) -> None: + def process_dock(self, name: str, tokens: list[Token]) -> None: if len(tokens) > 1: self.error( name, tokens[1], - f"unexpected tokens in dock-group declaration", + f"unexpected tokens in dock declaration", ) - self.styles._rule_dock_group = tokens[0].value if tokens else "" + self.styles._rule_dock = tokens[0].value if tokens else "" def process_docks(self, name: str, tokens: list[Token]) -> None: docks: list[DockGroup] = [] @@ -333,7 +333,7 @@ class StylesBuilder: token, f"unexpected token {token.value!r} in docks declaration", ) - self.styles._rule_docks = tuple(docks) + self.styles._rule_docks = tuple(docks + [DockGroup("_default", "top", 0)]) def process_layer(self, name: str, tokens: list[Token]) -> None: if len(tokens) > 1: diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py new file mode 100644 index 000000000..c39ec2234 --- /dev/null +++ b/src/textual/css/scalar_animation.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .. import events +from ..geometry import Offset +from .._animator import Animation +from .scalar import ScalarOffset +from .._animator import EasingFunction + + +if TYPE_CHECKING: + from ..widget import Widget + from .styles import Styles + + +class ScalarAnimation(Animation): + def __init__( + self, + widget: Widget, + styles: Styles, + start_time: float, + attribute: str, + value: ScalarOffset, + duration: float | None, + speed: float | None, + easing: EasingFunction, + ): + assert ( + speed is not None or duration is not None + ), "One of speed or duration required" + self.widget = widget + self.styles = styles + self.start_time = start_time + self.attribute = attribute + self.final_value = value + self.easing = easing + + size = widget.size + viewport = widget.app.size + + self.start: Offset = getattr(styles, attribute).resolve(size, viewport) + self.destination: Offset = value.resolve(size, viewport) + + if speed is not None: + distance = self.start.get_distance_to(self.destination) + self.duration = distance / speed + else: + assert duration is not None, "Duration expected to be non-None" + self.duration = duration + + def __call__(self, time: float) -> bool: + + factor = min(1.0, (time - self.start_time) / self.duration) + eased_factor = self.easing(factor) + + if eased_factor >= 1: + offset = self.final_value + setattr(self.styles, self.attribute, self.final_value) + return True + + offset = self.start + (self.destination - self.start) * eased_factor + current = getattr(self.styles, f"_rule_{self.attribute}") + if current != offset: + setattr(self.styles, f"{self.attribute}", offset) + + return False diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index c2e20ea2e..d70b89790 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -31,7 +31,7 @@ from ._style_properties import ( BoxProperty, ColorProperty, DocksProperty, - DockGroupProperty, + DockProperty, OffsetProperty, NameProperty, NameListProperty, @@ -94,7 +94,7 @@ class Styles: _rule_min_width: Scalar | None = None _rule_min_height: Scalar | None = None - _rule_dock_group: str | None = None + _rule_dock: str | None = None _rule_docks: tuple[DockGroup, ...] | None = None _rule_layers: tuple[str, ...] | None = None @@ -137,7 +137,7 @@ class Styles: min_width = ScalarProperty(percent_unit=Unit.WIDTH) min_height = ScalarProperty(percent_unit=Unit.HEIGHT) - dock_group = DockGroupProperty() + dock = DockProperty() docks = DocksProperty() layer = NameProperty() @@ -145,8 +145,6 @@ class Styles: transitions = TransitionsProperty() ANIMATABLE = { - "offset-x", - "offset-y", "offset", "padding", "margin", @@ -192,6 +190,8 @@ class Styles: def refresh(self, layout: bool = False) -> None: self._repaint_required = True self._layout_required = layout + # if self.node is not None: + # self.node.post_message_no_wait(events.Null(self.node)) def check_refresh(self) -> tuple[bool, bool]: result = (self._repaint_required, self._layout_required) @@ -252,25 +252,19 @@ class Styles: current = getattr(styles, f"_rule_{key}") if current == value: continue - log(key, "=", value) if is_animatable(key): - log("animatable", key) transition = styles.get_transition(key) - log("transition", transition) if transition is None: setattr(styles, f"_rule_{key}", value) else: duration, easing, delay = transition - log("ANIMATING") self.node.app.animator.animate( styles, key, value, duration=duration, easing=easing ) else: - log("not animatable") setattr(styles, f"_rule_{key}", value) - if self.node is not None: - self.node.post_message_no_wait(events.Null(self.node)) + self.node.on_style_change() def __rich_repr__(self) -> rich.repr.Result: for rule_name, internal_rule_name in zip(RULE_NAMES, INTERNAL_RULE_NAMES): @@ -362,8 +356,8 @@ class Styles: if self.offset: x, y = self.offset append_declaration("offset", f"{x} {y}") - if self._rule_dock_group: - append_declaration("dock-group", self._rule_dock_group) + if self._rule_dock: + append_declaration("dock-group", self._rule_dock) if self._rule_docks: append_declaration( "docks", @@ -416,7 +410,7 @@ if __name__ == "__main__": styles.outline_right = ("solid", "red") styles.docks = "foo bar" styles.text_style = "italic" - styles.dock_group = "bar" + styles.dock = "bar" styles.layers = "foo bar" from rich import inspect, print diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index d5db397f4..fb7dda99f 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -128,7 +128,6 @@ class Stylesheet: (name, max(specificity_rules, key=get_first_item)[1]) for name, specificity_rules in rule_attributes.items() ] - node.styles.apply_rules(node_rules) def update(self, root: DOMNode) -> None: diff --git a/src/textual/dom.py b/src/textual/dom.py index 2c461f7cc..68160d65e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -139,9 +139,14 @@ class DOMNode(MessagePump): from .widget import Widget for node in self.walk_children(): - # node.styles = Styles() + node.styles = Styles(node=node) if isinstance(node, Widget): node.clear_render_cache() + node._repaint_required = True + node._layout_required = True + + def on_style_change(self) -> None: + pass def add_child(self, node: DOMNode) -> None: self.children._append(node) diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index 97b3997e4..e5e1a6260 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -49,9 +49,7 @@ class LayoutMap: layout_offset = Offset(0, 0) if widget.styles.has_offset: - log("r", region, "c", clip.size) layout_offset = widget.styles.offset.resolve(region.size, clip.size) - log("layout_offset", layout_offset) self.widgets[widget] = RenderRegion(region + layout_offset, order, clip) @@ -70,7 +68,7 @@ class LayoutMap: self.add_widget( sub_widget, sub_region + region.origin - scroll, - sub_widget.z, + sub_widget.z + (z,), sub_clip, ) view.virtual_size = total_region.size diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 4edf0f684..2f49ec460 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -52,14 +52,14 @@ class DockLayout(Layout): for child in view.children: assert isinstance(child, Widget) if child.visible: - groups[child.styles.dock_group].append(child) + groups[child.styles.dock].append(child) docks: list[Dock] = [] append_dock = docks.append for name, edge, z in view.styles.docks: append_dock(Dock(edge, groups[name], z)) return docks - def get_widgets(self, view: View) -> Iterable[DOMNode]: + def get_widgets(self, view: View) -> Iterable[Widget]: for dock in self.get_docks(view): yield from dock.widgets diff --git a/src/textual/view.py b/src/textual/view.py index 935ab5210..2b32be09d 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -40,7 +40,8 @@ class LayoutProperty: class View(Widget): STYLES = """ - docks: main=top + docks: main=top; + """ def __init__(self, name: str | None = None, id: str | None = None) -> None: @@ -120,7 +121,6 @@ class View(Widget): if cached_size == size and cached_scroll == scroll: return arrangement arrangement = list(self._layout.arrange(self, size, scroll)) - self.log(arrangement) self._cached_arrangement = (size, scroll, arrangement) return arrangement diff --git a/src/textual/widget.py b/src/textual/widget.py index f7aa5dc11..f87a44f68 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -61,7 +61,7 @@ class Widget(DOMNode): can_focus: bool = False STYLES = """ - dock-group: main; + dock: _default; """ def __init__(self, name: str | None = None, id: str | None = None) -> None: @@ -194,6 +194,9 @@ class Widget(DOMNode): ) return gutter + def on_style_change(self) -> None: + self.clear_render_cache() + def _update_size(self, size: Size) -> None: self._size = size @@ -286,14 +289,12 @@ class Widget(DOMNode): async def on_idle(self, event: events.Idle) -> None: repaint, layout = self.styles.check_refresh() if layout or self.check_layout(): - self.log("layout required") - self.render_cache = None + # self.render_cache = None self.reset_check_repaint() self.reset_check_layout() await self.emit(Layout(self)) elif repaint or self.check_repaint(): - self.log("repaint required") - self.render_cache = None + # self.render_cache = None self.reset_check_repaint() await self.emit(Update(self, self))