diff --git a/examples/basic.css b/examples/basic.css index 614323785..5fa243240 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,4 +1,13 @@ -App > DockView { +App > View { + docks: main=top; +} + +#sidebar { + dock-group: main; +} + +/* +App > View { layout: dock; docks: side=left/1 header=top footer=bottom; layers: base panels; @@ -37,3 +46,4 @@ App > DockView { dock-group: header; text: on #20639b; } +*/ diff --git a/examples/basic.py b/examples/basic.py index 35ef90dab..476b4225d 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -6,7 +6,7 @@ class BasicApp(App): """A basic app demonstrating CSS""" def on_load(self): - self.bind("t", "toggle_class('#sidebar', '-active')") + self.bind("tab", "toggle_class('#sidebar', '-active')") def on_mount(self): """Build layout here.""" diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index e24dd1d01..cbe7290ef 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -15,7 +15,7 @@ ANSI_SEQUENCES: Dict[str, Tuple[Keys, ...]] = { "\x06": (Keys.ControlF,), # Control-F (cursor forward) "\x07": (Keys.ControlG,), # Control-G "\x08": (Keys.ControlH,), # Control-H (8) (Identical to '\b') - "\x09": (Keys.ControlI,), # Control-I (9) (Identical to '\t') + "\x09": (Keys.Tab,), # Control-I (9) (Identical to '\t') "\x0a": (Keys.ControlJ,), # Control-J (10) (Identical to '\n') "\x0b": (Keys.ControlK,), # Control-K (delete until end of line; vertical tab) "\x0c": (Keys.ControlL,), # Control-L (clear; form feed) diff --git a/src/textual/app.py b/src/textual/app.py index d738fe77e..eea165b14 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -498,7 +498,7 @@ class App(DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Mount): - view = DockView() + view = View() self.register(self, view) await self.push_view(view) await super().on_event(event) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index bc3817872..5c5a9711a 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -67,6 +67,7 @@ class ScalarProperty: if new_value is not None and new_value.is_percent: new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH) setattr(obj, self.internal_name, new_value) + obj.refresh() return value @@ -100,6 +101,7 @@ class BoxProperty: else: new_value = (_type, Style.from_color(Color.parse(color))) setattr(obj, self.internal_name, new_value) + obj.refresh() return border @@ -151,6 +153,7 @@ class BorderProperty: | None, ) -> None: top, right, bottom, left = self._properties + obj.refresh() if border is None: setattr(obj, top, None) setattr(obj, right, None) @@ -207,6 +210,7 @@ class StyleProperty: return style def __set__(self, obj: Styles, style: Style | str | None) -> Style | str | None: + obj.refresh() if style is None: setattr(obj, self._color_name, None) setattr(obj, self._bgcolor_name, None) @@ -232,6 +236,7 @@ class SpacingProperty: return getattr(obj, self._internal_name) or NULL_SPACING def __set__(self, obj: Styles, spacing: SpacingDimensions) -> Spacing: + obj.refresh(True) spacing = Spacing.unpack(spacing) setattr(obj, self._internal_name, spacing) return spacing @@ -246,6 +251,7 @@ class DocksProperty: def __set__( self, obj: Styles, docks: Iterable[DockGroup] | None ) -> Iterable[DockGroup] | None: + obj.refresh(True) if docks is None: obj._rule_docks = None else: @@ -258,6 +264,7 @@ class DockGroupProperty: return obj._rule_dock_group or "" def __set__(self, obj: Styles, spacing: str | None) -> str | None: + obj.refresh(True) obj._rule_dock_group = spacing return spacing @@ -274,6 +281,7 @@ class OffsetProperty: def __set__( self, obj: Styles, offset: tuple[int | str, int | str] ) -> tuple[int | str, int | str]: + obj.refresh(True) x, y = offset scalar_x = ( Scalar.parse(x, Unit.WIDTH) @@ -299,6 +307,7 @@ class IntegerProperty: return getattr(obj, self._internal_name, 0) def __set__(self, obj: Styles, value: int | None) -> int | None: + obj.refresh() if not isinstance(value, int): raise StyleTypeError(f"{self._name} must be a str") setattr(obj, self._internal_name, value) @@ -318,6 +327,7 @@ class StringProperty: return getattr(obj, self._internal_name, None) or self._default def __set__(self, obj: Styles, value: str | None = None) -> str | None: + obj.refresh() if value is not None: if value not in self._valid_values: raise StyleValueError( @@ -336,6 +346,7 @@ class NameProperty: return getattr(obj, self._internal_name) or "" def __set__(self, obj: Styles, name: str | None) -> str | None: + obj.refresh(True) if not isinstance(name, str): raise StyleTypeError(f"{self._name} must be a str") setattr(obj, self._internal_name, name) @@ -355,6 +366,7 @@ class NameListProperty: def __set__( self, obj: Styles, names: str | tuple[str] | None = None ) -> str | tuple[str] | None: + obj.refresh(True) names_value: tuple[str, ...] | None = None if isinstance(names, str): names_value = tuple(name.strip().lower() for name in names.split(" ")) @@ -375,6 +387,7 @@ class ColorProperty: return getattr(obj, self._internal_name, None) or Color.default() def __set__(self, obj: Styles, color: Color | str | None) -> Color | str | None: + obj.refresh() if color is None: setattr(self, self._internal_name, None) else: @@ -409,6 +422,7 @@ class StyleFlagsProperty: return getattr(obj, self._internal_name, None) or Style.null() def __set__(self, obj: Styles, style_flags: str | None) -> str | None: + obj.refresh() if style_flags is None: setattr(self, self._internal_name, None) else: diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index c6fd78b14..2e80311f8 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -5,7 +5,8 @@ from rich import print from functools import lru_cache from typing import Iterator, Iterable -from .tokenize import tokenize, Token +from .styles import Styles +from .tokenize import tokenize, tokenize_declarations, Token from .tokenizer import EOFError from .model import ( @@ -157,6 +158,44 @@ def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: yield rule_set +def parse_declarations(css: str, path: str) -> Styles: + + tokens = iter(tokenize_declarations(css, path)) + styles_builder = StylesBuilder() + + declaration: Declaration | None = None + errors: list[tuple[Token, str]] = [] + + while True: + token = next(tokens, None) + if token is None: + break + token_name = token.name + if token_name in ("whitespace", "declaration_end", "eof"): + continue + if token_name == "declaration_name": + if declaration and declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + declaration = Declaration(token, "") + declaration.name = token.value.rstrip(":") + elif token_name == "declaration_set_end": + break + else: + if declaration: + declaration.tokens.append(token) + + if declaration and declaration.tokens: + try: + styles_builder.add_declaration(declaration) + except DeclarationError as error: + errors.append((error.token, error.message)) + + return styles_builder.styles + + def parse(css: str, path: str) -> Iterable[RuleSet]: tokens = iter(tokenize(css, path)) @@ -206,3 +245,10 @@ def parse(css: str, path: str) -> Iterable[RuleSet]: if __name__ == "__main__": print(parse_selectors("Foo > Bar.baz { foo: bar")) + + CSS = """ +text: on red; +docks: main=top; + """ + + print(parse_declarations(CSS, "foo")) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 0e29f88be..eb2272927 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -91,6 +91,9 @@ class Styles: _rule_transitions: dict[str, Transition] | None = None + _layout_required: bool = False + _repaint_required: bool = False + important: set[str] = field(default_factory=set) display = StringProperty(VALID_DISPLAY, "block") @@ -130,6 +133,15 @@ class Styles: layers = NameListProperty() transitions = TransitionsProperty() + def refresh(self, layout: bool = False) -> None: + self._repaint_required = True + self._layout_required = layout + + def check_refresh(self) -> tuple[bool, bool]: + result = (self._repaint_required, self._layout_required) + self._repaint_required = self._layout_required = False + return result + @property def has_border(self) -> bool: """Check in a border is present.""" diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index b1ff95cd6..74833d024 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -40,6 +40,13 @@ expect_declaration = Expect( declaration_set_end=r"\}", ) +expect_declaration_solo = Expect( + whitespace=r"\s+", + comment_start=r"\/\*", + declaration_name=r"[a-zA-Z_\-]+\:", + declaration_set_end=r"\}", +).expect_eof(True) + expect_declaration_content = Expect( declaration_end=r"\n|;", whitespace=r"\s+", @@ -55,34 +62,65 @@ expect_declaration_content = Expect( ) -_STATES = { - "selector_start": expect_selector_continue, - "selector_start_id": expect_selector_continue, - "selector_start_class": expect_selector_continue, - "selector_start_universal": expect_selector_continue, - "selector_id": expect_selector_continue, - "selector_class": expect_selector_continue, - "selector_universal": expect_selector_continue, - "declaration_set_start": expect_declaration, - "declaration_name": expect_declaration_content, - "declaration_end": expect_declaration, - "declaration_set_end": expect_selector, -} +class StateTokenizer: + EXPECT = expect_selector + STATE_MAP = { + "selector_start": expect_selector_continue, + "selector_start_id": expect_selector_continue, + "selector_start_class": expect_selector_continue, + "selector_start_universal": expect_selector_continue, + "selector_id": expect_selector_continue, + "selector_class": expect_selector_continue, + "selector_universal": expect_selector_continue, + "declaration_set_start": expect_declaration, + "declaration_name": expect_declaration_content, + "declaration_end": expect_declaration, + "declaration_set_end": expect_selector, + } + + def __call__(self, code: str, path: str) -> Iterable[Token]: + tokenizer = Tokenizer(code, path=path) + expect = self.EXPECT + get_token = tokenizer.get_token + get_state = self.STATE_MAP.get + while True: + token = get_token(expect) + name = token.name + if name == "comment_start": + tokenizer.skip_to(expect_comment_end) + continue + elif name == "eof": + break + expect = get_state(name, expect) + yield token -def tokenize(code: str, path: str) -> Iterable[Token]: - tokenizer = Tokenizer(code, path=path) - expect = expect_selector - get_token = tokenizer.get_token - get_state = _STATES.get - while True: - token = get_token(expect) +class DeclarationStateTokenizer(StateTokenizer): + EXPECT = expect_declaration_solo + STATE_MAP = { + "declaration_name": expect_declaration_content, + "declaration_end": expect_declaration_solo, + } - name = token.name - if name == "comment_start": - tokenizer.skip_to(expect_comment_end) - continue - elif name == "eof": - break - expect = get_state(name, expect) - yield token + +tokenize = StateTokenizer() +tokenize_declarations = DeclarationStateTokenizer() + + +# def tokenize( +# code: str, path: str, *, expect: Expect = expect_selector +# ) -> Iterable[Token]: +# tokenizer = Tokenizer(code, path=path) +# # expect = expect_selector +# get_token = tokenizer.get_token +# get_state = _STATES.get +# while True: +# token = get_token(expect) +# name = token.name +# if name == "comment_start": +# tokenizer.skip_to(expect_comment_end) +# continue +# elif name == "eof": +# break +# expect = get_state(name, expect) +# yield token diff --git a/src/textual/view.py b/src/textual/view.py index eb1ca4ffa..7c6ff0a1c 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -39,12 +39,15 @@ class LayoutProperty: @rich.repr.auto class View(Widget): - layout_factory: ClassVar[Callable[[], Layout]] + CSS = """ + docks: main=top + """ - def __init__( - self, layout: Layout = None, name: str | None = None, id: str | None = None - ) -> None: - self._layout: Layout = layout or self.layout_factory() + def __init__(self, name: str | None = None, id: str | None = None) -> None: + + from .layouts.dock import DockLayout + + self._layout: Layout = DockLayout() self.mouse_over: Widget | None = None self.widgets: set[Widget] = set() @@ -56,7 +59,6 @@ class View(Widget): Offset(), [], ) - super().__init__(name=name, id=id) def __init_subclass__( diff --git a/src/textual/widget.py b/src/textual/widget.py index 70e1c691f..4ad4bdf93 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -48,7 +48,7 @@ class RenderCache(NamedTuple): @property def cursor_line(self) -> int | None: for index, line in enumerate(self.lines): - for text, style, control in line: + for _text, style, _control in line: if style and style._meta and style.meta.get("cursor", False): return index return None @@ -59,6 +59,10 @@ class Widget(DOMNode): _counts: ClassVar[dict[str, int]] = {} can_focus: bool = False + CSS = """ + dock-group: main; + """ + def __init__(self, name: str | None = None, id: str | None = None) -> None: if name is None: class_name = self.__class__.__name__ @@ -279,13 +283,14 @@ class Widget(DOMNode): self.refresh() async def on_idle(self, event: events.Idle) -> None: - if self.check_layout(): + repaint, layout = self.styles.check_refresh() + if layout or self.check_layout(): self.log("layout required") self.render_cache = None self.reset_check_repaint() self.reset_check_layout() await self.emit(Layout(self)) - elif self.check_repaint(): + elif repaint or self.check_repaint(): self.log("repaint required") self.render_cache = None self.reset_check_repaint()