mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' into add-web-colors-management-and-prefix-ansi-ones
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [0.2.0] - Unreleased
|
||||||
|
|
||||||
## [0.1.15] - 2022-01-31
|
## [0.1.15] - 2022-01-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ App > Screen {
|
|||||||
background: $primary-darken-2;
|
background: $primary-darken-2;
|
||||||
color: $text-primary-darken-2 ;
|
color: $text-primary-darken-2 ;
|
||||||
border-right: outer $primary-darken-3;
|
border-right: outer $primary-darken-3;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .user {
|
#sidebar .user {
|
||||||
@@ -48,19 +49,21 @@ App > Screen {
|
|||||||
background: $primary-darken-1;
|
background: $primary-darken-1;
|
||||||
color: $text-primary-darken-1;
|
color: $text-primary-darken-1;
|
||||||
border-right: outer $primary-darken-3;
|
border-right: outer $primary-darken-3;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sidebar .content {
|
#sidebar .content {
|
||||||
background: $primary;
|
background: $primary;
|
||||||
color: $text-primary;
|
color: $text-primary;
|
||||||
border-right: outer $primary-darken-3;
|
border-right: outer $primary-darken-3;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
color: $text-primary-darken-1;
|
color: $text-primary-darken-1;
|
||||||
background: $primary-darken-1;
|
background: $primary-darken-1;
|
||||||
height: 3
|
height: 3;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content {
|
#content {
|
||||||
@@ -84,6 +87,7 @@ Tweet {
|
|||||||
border: wide $panel-darken-2;
|
border: wide $panel-darken-2;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
align-horizontal: center;
|
align-horizontal: center;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable {
|
.scrollable {
|
||||||
@@ -152,6 +156,7 @@ TweetBody {
|
|||||||
background: $accent;
|
background: $accent;
|
||||||
height: 1;
|
height: 1;
|
||||||
border-top: hkey $accent-darken-2;
|
border-top: hkey $accent-darken-2;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -165,6 +170,7 @@ OptionItem {
|
|||||||
transition: background 100ms linear;
|
transition: background 100ms linear;
|
||||||
border-right: outer $primary-darken-2;
|
border-right: outer $primary-darken-2;
|
||||||
border-left: hidden;
|
border-left: hidden;
|
||||||
|
content-align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionItem:hover {
|
OptionItem:hover {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ class Tweet(Widget):
|
|||||||
|
|
||||||
class OptionItem(Widget):
|
class OptionItem(Widget):
|
||||||
def render(self) -> Text:
|
def render(self) -> Text:
|
||||||
return Align.center(Text("Option", justify="center"), vertical="middle")
|
return Text("Option")
|
||||||
|
|
||||||
|
|
||||||
class Error(Widget):
|
class Error(Widget):
|
||||||
@@ -95,10 +95,9 @@ class BasicApp(App):
|
|||||||
"""Build layout here."""
|
"""Build layout here."""
|
||||||
self.mount(
|
self.mount(
|
||||||
header=Static(
|
header=Static(
|
||||||
Align.center(
|
Text.from_markup(
|
||||||
"[b]This is a [u]Textual[/u] app, running in the terminal",
|
"[b]This is a [u]Textual[/u] app, running in the terminal"
|
||||||
vertical="middle",
|
),
|
||||||
)
|
|
||||||
),
|
),
|
||||||
content=Widget(
|
content=Widget(
|
||||||
Tweet(
|
Tweet(
|
||||||
@@ -110,8 +109,8 @@ class BasicApp(App):
|
|||||||
# ),
|
# ),
|
||||||
),
|
),
|
||||||
Widget(
|
Widget(
|
||||||
Static(Syntax(CODE, "python"), classes={"code"}),
|
Static(Syntax(CODE, "python"), classes="code"),
|
||||||
classes={"scrollable"},
|
classes="scrollable",
|
||||||
),
|
),
|
||||||
Error(),
|
Error(),
|
||||||
Tweet(TweetBody()),
|
Tweet(TweetBody()),
|
||||||
@@ -121,12 +120,12 @@ class BasicApp(App):
|
|||||||
),
|
),
|
||||||
footer=Widget(),
|
footer=Widget(),
|
||||||
sidebar=Widget(
|
sidebar=Widget(
|
||||||
Widget(classes={"title"}),
|
Widget(classes="title"),
|
||||||
Widget(classes={"user"}),
|
Widget(classes="user"),
|
||||||
OptionItem(),
|
OptionItem(),
|
||||||
OptionItem(),
|
OptionItem(),
|
||||||
OptionItem(),
|
OptionItem(),
|
||||||
Widget(classes={"content"}),
|
Widget(classes="content"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,4 +139,7 @@ class BasicApp(App):
|
|||||||
self.panic(self.tree)
|
self.panic(self.tree)
|
||||||
|
|
||||||
|
|
||||||
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")
|
app = BasicApp(css_file="basic.css", watch_css=True, log="textual.log")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
|
|||||||
0
sandbox/buttons.css
Normal file
0
sandbox/buttons.css
Normal file
24
sandbox/buttons.py
Normal file
24
sandbox/buttons.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
|
||||||
|
from textual.widgets import Button
|
||||||
|
from textual import layout
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonsApp(App[str]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield layout.Vertical(
|
||||||
|
Button("foo", id="foo"),
|
||||||
|
Button("bar", id="bar"),
|
||||||
|
Button("baz", id="baz"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.app.bell()
|
||||||
|
self.exit(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
|
app = ButtonsApp(log="textual.log")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = app.run()
|
||||||
|
print(repr(result))
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#uber1 {
|
#uber1 {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
|
|
||||||
background: dark_green;
|
background: green;
|
||||||
overflow: hidden auto;
|
overflow: hidden auto;
|
||||||
border: heavy white;
|
border: heavy white;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ class BasicApp(App):
|
|||||||
Widget(id="uber2-child2"),
|
Widget(id="uber2-child2"),
|
||||||
)
|
)
|
||||||
uber1 = Widget(
|
uber1 = Widget(
|
||||||
Placeholder(id="child1", classes={"list-item"}),
|
Placeholder(id="child1", classes="list-item"),
|
||||||
Placeholder(id="child2", classes={"list-item"}),
|
Placeholder(id="child2", classes="list-item"),
|
||||||
Placeholder(id="child3", classes={"list-item"}),
|
Placeholder(id="child3", classes="list-item"),
|
||||||
Placeholder(classes={"list-item"}),
|
Placeholder(classes="list-item"),
|
||||||
Placeholder(classes={"list-item"}),
|
Placeholder(classes="list-item"),
|
||||||
Placeholder(classes={"list-item"}),
|
Placeholder(classes="list-item"),
|
||||||
)
|
)
|
||||||
self.mount(uber1=uber1)
|
self.mount(uber1=uber1)
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class BasicApp(App):
|
|||||||
await self.dispatch_key(event)
|
await self.dispatch_key(event)
|
||||||
|
|
||||||
def action_quit(self):
|
def action_quit(self):
|
||||||
self.panic(self.screen.tree)
|
self.panic(self.app.tree)
|
||||||
|
|
||||||
def action_dump(self):
|
def action_dump(self):
|
||||||
self.panic(str(self.app.registry))
|
self.panic(str(self.app.registry))
|
||||||
@@ -56,4 +56,7 @@ class BasicApp(App):
|
|||||||
sys.stdout.write("abcdef")
|
sys.stdout.write("abcdef")
|
||||||
|
|
||||||
|
|
||||||
BasicApp.run(css_file="uber.css", log="textual.log", log_verbosity=1)
|
app = BasicApp(css_file="uber.css", log="textual.log", log_verbosity=1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, int]] = {
|
COLOR_NAME_TO_RGB: dict[str, tuple[int, int, int] | tuple[int, int, int, float]] = {
|
||||||
# Let's start with a specific pseudo-color::
|
# Let's start with a specific pseudo-color::
|
||||||
"transparent": (0, 0, 0, 0),
|
"transparent": (0, 0, 0, 0),
|
||||||
# Then, the 16 common ANSI colors:
|
# Then, the 16 common ANSI colors:
|
||||||
|
|||||||
40
src/textual/_layout.py
Normal file
40
src/textual/_layout.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import ClassVar, NamedTuple, TYPE_CHECKING
|
||||||
|
|
||||||
|
|
||||||
|
from .geometry import Region, Offset, Size
|
||||||
|
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetPlacement(NamedTuple):
|
||||||
|
"""The position, size, and relative order of a widget within its parent."""
|
||||||
|
|
||||||
|
region: Region
|
||||||
|
widget: Widget | None = None # A widget of None means empty space
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Layout(ABC):
|
||||||
|
"""Responsible for arranging Widgets in a view and rendering them."""
|
||||||
|
|
||||||
|
name: ClassVar[str] = ""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def arrange(
|
||||||
|
self, parent: Widget, size: Size, scroll: Offset
|
||||||
|
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
||||||
|
"""Generate a layout map that defines where on the screen the widgets will be drawn.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
parent (Widget): Parent widget.
|
||||||
|
size (Size): Size of container.
|
||||||
|
scroll (Offset): Offset to apply to the Widget placements.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Iterable[WidgetPlacement]: An iterable of widget location
|
||||||
|
"""
|
||||||
@@ -9,7 +9,7 @@ import warnings
|
|||||||
from asyncio import AbstractEventLoop
|
from asyncio import AbstractEventLoop
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING
|
from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING
|
||||||
|
|
||||||
import rich
|
import rich
|
||||||
import rich.repr
|
import rich.repr
|
||||||
@@ -67,6 +67,8 @@ DEFAULT_COLORS = ColorSystem(
|
|||||||
dark_surface="#292929",
|
dark_surface="#292929",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ComposeResult = Iterable[Widget]
|
||||||
|
|
||||||
|
|
||||||
class AppError(Exception):
|
class AppError(Exception):
|
||||||
pass
|
pass
|
||||||
@@ -76,8 +78,11 @@ class ActionError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
ReturnType = TypeVar("ReturnType")
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class App(DOMNode):
|
class App(Generic[ReturnType], DOMNode):
|
||||||
"""The base class for Textual Applications"""
|
"""The base class for Textual Applications"""
|
||||||
|
|
||||||
css = ""
|
css = ""
|
||||||
@@ -159,6 +164,8 @@ class App(DOMNode):
|
|||||||
|
|
||||||
self.devtools = DevtoolsClient()
|
self.devtools = DevtoolsClient()
|
||||||
|
|
||||||
|
self._return_value: ReturnType | None = None
|
||||||
|
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
title: Reactive[str] = Reactive("Textual")
|
title: Reactive[str] = Reactive("Textual")
|
||||||
@@ -166,6 +173,20 @@ class App(DOMNode):
|
|||||||
background: Reactive[str] = Reactive("black")
|
background: Reactive[str] = Reactive("black")
|
||||||
dark = Reactive(False)
|
dark = Reactive(False)
|
||||||
|
|
||||||
|
def exit(self, result: ReturnType | None = None) -> None:
|
||||||
|
"""Exit the app, and return the supplied result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result (ReturnType | None, optional): Return value. Defaults to None.
|
||||||
|
"""
|
||||||
|
self._return_value = result
|
||||||
|
self.close_messages_no_wait()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Yield child widgets for a container."""
|
||||||
|
return
|
||||||
|
yield
|
||||||
|
|
||||||
def get_css_variables(self) -> dict[str, str]:
|
def get_css_variables(self) -> dict[str, str]:
|
||||||
"""Get a mapping of variables used to pre-populate CSS.
|
"""Get a mapping of variables used to pre-populate CSS.
|
||||||
|
|
||||||
@@ -284,27 +305,9 @@ class App(DOMNode):
|
|||||||
keys, action, description, show=show, key_display=key_display
|
keys, action, description, show=show, key_display=key_display
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
def run(self, loop: AbstractEventLoop | None = None) -> ReturnType | None:
|
||||||
def run(
|
|
||||||
cls,
|
|
||||||
console: Console | None = None,
|
|
||||||
screen: bool = True,
|
|
||||||
driver: Type[Driver] | None = None,
|
|
||||||
loop: AbstractEventLoop | None = None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""Run the app.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
console (Console, optional): Console object. Defaults to None.
|
|
||||||
screen (bool, optional): Enable application mode. Defaults to True.
|
|
||||||
driver (Type[Driver], optional): Driver class or None for default. Defaults to None.
|
|
||||||
loop (AbstractEventLoop): Event loop to run the application on. If not specified, uvloop will be used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def run_app() -> None:
|
async def run_app() -> None:
|
||||||
app = cls(screen=screen, driver_class=driver, **kwargs)
|
await self.process_messages()
|
||||||
await app.process_messages()
|
|
||||||
|
|
||||||
if loop:
|
if loop:
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
@@ -322,13 +325,15 @@ class App(DOMNode):
|
|||||||
finally:
|
finally:
|
||||||
event_loop.close()
|
event_loop.close()
|
||||||
|
|
||||||
|
return self._return_value
|
||||||
|
|
||||||
async def _on_css_change(self) -> None:
|
async def _on_css_change(self) -> None:
|
||||||
"""Called when the CSS changes (if watch_css is True)."""
|
"""Called when the CSS changes (if watch_css is True)."""
|
||||||
if self.css_file is not None:
|
if self.css_file is not None:
|
||||||
stylesheet = Stylesheet(variables=self.get_css_variables())
|
|
||||||
try:
|
try:
|
||||||
time = perf_counter()
|
time = perf_counter()
|
||||||
stylesheet.read(self.css_file)
|
self.stylesheet.read(self.css_file)
|
||||||
elapsed = (perf_counter() - time) * 1000
|
elapsed = (perf_counter() - time) * 1000
|
||||||
self.log(f"loaded {self.css_file} in {elapsed:.0f}ms")
|
self.log(f"loaded {self.css_file} in {elapsed:.0f}ms")
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
@@ -337,10 +342,12 @@ class App(DOMNode):
|
|||||||
self.log(error)
|
self.log(error)
|
||||||
else:
|
else:
|
||||||
self.reset_styles()
|
self.reset_styles()
|
||||||
self.stylesheet = stylesheet
|
|
||||||
self.stylesheet.update(self)
|
self.stylesheet.update(self)
|
||||||
self.screen.refresh(layout=True)
|
self.screen.refresh(layout=True)
|
||||||
|
|
||||||
|
def render(self) -> RenderableType:
|
||||||
|
return ""
|
||||||
|
|
||||||
def query(self, selector: str | None = None) -> DOMQuery:
|
def query(self, selector: str | None = None) -> DOMQuery:
|
||||||
"""Get a DOM query in the current screen.
|
"""Get a DOM query in the current screen.
|
||||||
|
|
||||||
@@ -498,7 +505,9 @@ class App(DOMNode):
|
|||||||
if self.css_file is not None:
|
if self.css_file is not None:
|
||||||
self.stylesheet.read(self.css_file)
|
self.stylesheet.read(self.css_file)
|
||||||
if self.css is not None:
|
if self.css is not None:
|
||||||
self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>")
|
self.stylesheet.add_source(
|
||||||
|
self.css, path=f"<{self.__class__.__name__}>"
|
||||||
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
self.on_exception(error)
|
self.on_exception(error)
|
||||||
self._print_error_renderables()
|
self._print_error_renderables()
|
||||||
@@ -521,6 +530,7 @@ class App(DOMNode):
|
|||||||
mount_event = events.Mount(sender=self)
|
mount_event = events.Mount(sender=self)
|
||||||
await self.dispatch_message(mount_event)
|
await self.dispatch_message(mount_event)
|
||||||
|
|
||||||
|
# TODO: don't override `self.console` here
|
||||||
self.console = Console(file=sys.__stdout__)
|
self.console = Console(file=sys.__stdout__)
|
||||||
self.title = self._title
|
self.title = self._title
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -547,6 +557,12 @@ class App(DOMNode):
|
|||||||
if self._log_file is not None:
|
if self._log_file is not None:
|
||||||
self._log_file.close()
|
self._log_file.close()
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
widgets = list(self.compose())
|
||||||
|
if widgets:
|
||||||
|
self.mount(*widgets)
|
||||||
|
self.screen.refresh()
|
||||||
|
|
||||||
async def on_idle(self) -> None:
|
async def on_idle(self) -> None:
|
||||||
"""Perform actions when there are no messages in the queue."""
|
"""Perform actions when there are no messages in the queue."""
|
||||||
if self._require_styles_update:
|
if self._require_styles_update:
|
||||||
@@ -558,6 +574,7 @@ class App(DOMNode):
|
|||||||
parent.children._append(child)
|
parent.children._append(child)
|
||||||
self.registry.add(child)
|
self.registry.add(child)
|
||||||
child.set_parent(parent)
|
child.set_parent(parent)
|
||||||
|
child.on_register(self)
|
||||||
child.start_messages()
|
child.start_messages()
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -123,6 +123,11 @@ class Color(NamedTuple):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_transparent(self) -> bool:
|
||||||
|
"""Check if the color is transparent, i.e. has 0 alpha."""
|
||||||
|
return self.a == 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def clamped(self) -> Color:
|
def clamped(self) -> Color:
|
||||||
"""Get a color with all components saturated to maximum and minimum values."""
|
"""Get a color with all components saturated to maximum and minimum values."""
|
||||||
@@ -329,6 +334,7 @@ class Color(NamedTuple):
|
|||||||
# Color constants
|
# Color constants
|
||||||
WHITE = Color(255, 255, 255)
|
WHITE = Color(255, 255, 255)
|
||||||
BLACK = Color(0, 0, 0)
|
BLACK = Color(0, 0, 0)
|
||||||
|
TRANSPARENT = Color(0, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
class ColorPair(NamedTuple):
|
class ColorPair(NamedTuple):
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from .transition import Transition
|
|||||||
from ..geometry import Spacing, SpacingDimensions, clamp
|
from ..geometry import Spacing, SpacingDimensions, clamp
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..layout import Layout
|
from .._layout import Layout
|
||||||
from .styles import DockGroup, Styles, StylesBase
|
from .styles import DockGroup, Styles, StylesBase
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -621,13 +621,18 @@ class StylesBuilder:
|
|||||||
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.styles._rules["align_horizontal"] = token_horizontal.value
|
name = name.replace("-", "_")
|
||||||
self.styles._rules["align_vertical"] = token_vertical.value
|
self.styles._rules[f"{name}_horizontal"] = token_horizontal.value
|
||||||
|
self.styles._rules[f"{name}_vertical"] = token_vertical.value
|
||||||
|
|
||||||
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
||||||
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
||||||
self.styles._rules["align_horizontal"] = value
|
self.styles._rules[name.replace("-", "_")] = value
|
||||||
|
|
||||||
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
||||||
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
||||||
self.styles._rules["align_vertical"] = value
|
self.styles._rules[name.replace("-", "_")] = value
|
||||||
|
|
||||||
|
process_content_align = process_align
|
||||||
|
process_content_align_horizontal = process_align_horizontal
|
||||||
|
process_content_align_vertical = process_align_vertical
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ if __name__ == "__main__":
|
|||||||
console = Console()
|
console = Console()
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
try:
|
try:
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
except StylesheetParseError as e:
|
except StylesheetParseError as e:
|
||||||
console.print(e.errors)
|
console.print(e.errors)
|
||||||
print(stylesheet)
|
print(stylesheet)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from rich.style import Style
|
|||||||
from .. import log
|
from .. import log
|
||||||
from .._animator import Animation, EasingFunction
|
from .._animator import Animation, EasingFunction
|
||||||
from ..color import Color
|
from ..color import Color
|
||||||
from ..geometry import Offset, Size, Spacing
|
from ..geometry import Spacing
|
||||||
from ._style_properties import (
|
from ._style_properties import (
|
||||||
BorderProperty,
|
BorderProperty,
|
||||||
BoxProperty,
|
BoxProperty,
|
||||||
@@ -64,13 +64,13 @@ else:
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..dom import DOMNode
|
from ..dom import DOMNode
|
||||||
from ..layout import Layout
|
from .._layout import Layout
|
||||||
|
|
||||||
|
|
||||||
class RulesMap(TypedDict, total=False):
|
class RulesMap(TypedDict, total=False):
|
||||||
"""A typed dict for CSS rules.
|
"""A typed dict for CSS rules.
|
||||||
|
|
||||||
Any key may be absent, indiciating that rule has not been set.
|
Any key may be absent, indicating that rule has not been set.
|
||||||
|
|
||||||
Does not define composite rules, that is a rule that is made of a combination of other rules.
|
Does not define composite rules, that is a rule that is made of a combination of other rules.
|
||||||
|
|
||||||
@@ -130,6 +130,9 @@ class RulesMap(TypedDict, total=False):
|
|||||||
align_horizontal: AlignHorizontal
|
align_horizontal: AlignHorizontal
|
||||||
align_vertical: AlignVertical
|
align_vertical: AlignVertical
|
||||||
|
|
||||||
|
content_align_horizontal: AlignHorizontal
|
||||||
|
content_align_vertical: AlignVertical
|
||||||
|
|
||||||
|
|
||||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||||
@@ -170,7 +173,7 @@ class StylesBase(ABC):
|
|||||||
layout = LayoutProperty()
|
layout = LayoutProperty()
|
||||||
|
|
||||||
color = ColorProperty(Color(255, 255, 255))
|
color = ColorProperty(Color(255, 255, 255))
|
||||||
background = ColorProperty(Color(0, 0, 0))
|
background = ColorProperty(Color(0, 0, 0, 0))
|
||||||
text_style = StyleFlagsProperty()
|
text_style = StyleFlagsProperty()
|
||||||
|
|
||||||
opacity = FractionalProperty()
|
opacity = FractionalProperty()
|
||||||
@@ -222,6 +225,9 @@ class StylesBase(ABC):
|
|||||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||||
|
|
||||||
|
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||||
|
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||||
|
|
||||||
def __eq__(self, styles: object) -> bool:
|
def __eq__(self, styles: object) -> bool:
|
||||||
"""Check that Styles containts the same rules."""
|
"""Check that Styles containts the same rules."""
|
||||||
if not isinstance(styles, StylesBase):
|
if not isinstance(styles, StylesBase):
|
||||||
@@ -677,6 +683,18 @@ class Styles(StylesBase):
|
|||||||
elif has_rule("align_horizontal"):
|
elif has_rule("align_horizontal"):
|
||||||
append_declaration("align-vertical", self.align_vertical)
|
append_declaration("align-vertical", self.align_vertical)
|
||||||
|
|
||||||
|
if has_rule("content_align_horizontal") and has_rule("content_align_vertical"):
|
||||||
|
append_declaration(
|
||||||
|
"content-align",
|
||||||
|
f"{self.content_align_horizontal} {self.content_align_vertical}",
|
||||||
|
)
|
||||||
|
elif has_rule("content_align_horizontal"):
|
||||||
|
append_declaration(
|
||||||
|
"content-align-horizontal", self.content_align_horizontal
|
||||||
|
)
|
||||||
|
elif has_rule("content_align_horizontal"):
|
||||||
|
append_declaration("content-align-vertical", self.content_align_vertical)
|
||||||
|
|
||||||
lines.sort()
|
lines.sort()
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|||||||
@@ -101,21 +101,25 @@ class StylesheetErrors:
|
|||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Stylesheet:
|
class Stylesheet:
|
||||||
def __init__(self, *, variables: dict[str, str] | None = None) -> None:
|
def __init__(self, *, variables: dict[str, str] | None = None) -> None:
|
||||||
self.rules: list[RuleSet] = []
|
self._rules: list[RuleSet] = []
|
||||||
self.variables = variables or {}
|
self.variables = variables or {}
|
||||||
self.source: list[tuple[str, str]] = []
|
self.source: dict[str, str] = {}
|
||||||
|
self._require_parse = False
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield self.rules
|
yield self.rules
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def css(self) -> str:
|
def rules(self) -> list[RuleSet]:
|
||||||
return "\n\n".join(rule_set.css for rule_set in self.rules)
|
if self._require_parse:
|
||||||
|
self.parse()
|
||||||
|
self._require_parse = False
|
||||||
|
assert self._rules is not None
|
||||||
|
return self._rules
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def any_errors(self) -> bool:
|
def css(self) -> str:
|
||||||
"""Check if there are any errors."""
|
return "\n\n".join(rule_set.css for rule_set in self.rules)
|
||||||
return any(rule.errors for rule in self.rules)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def error_renderable(self) -> StylesheetErrors:
|
def error_renderable(self) -> StylesheetErrors:
|
||||||
@@ -129,6 +133,28 @@ class Stylesheet:
|
|||||||
"""
|
"""
|
||||||
self.variables = variables
|
self.variables = variables
|
||||||
|
|
||||||
|
def _parse_rules(self, css: str, path: str) -> list[RuleSet]:
|
||||||
|
"""Parse CSS and return rules.
|
||||||
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
css (str): String containing Textual CSS.
|
||||||
|
path (str): Path to CSS or unique identifier
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StylesheetError: If the CSS is invalid.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list[RuleSet]: List of RuleSets.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rules = list(parse(css, path, variables=self.variables))
|
||||||
|
except TokenizeError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise StylesheetError(f"failed to parse css; {error}")
|
||||||
|
return rules
|
||||||
|
|
||||||
def read(self, filename: str) -> None:
|
def read(self, filename: str) -> None:
|
||||||
"""Read Textual CSS file.
|
"""Read Textual CSS file.
|
||||||
|
|
||||||
@@ -146,19 +172,10 @@ class Stylesheet:
|
|||||||
path = os.path.abspath(filename)
|
path = os.path.abspath(filename)
|
||||||
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:
|
self.source[path] = css
|
||||||
rules = list(parse(css, path, variables=self.variables))
|
self._require_parse = True
|
||||||
except TokenizeError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise StylesheetError(f"failed to parse {filename!r}; {error!r}")
|
|
||||||
else:
|
|
||||||
self.source.append((css, path))
|
|
||||||
self.rules.extend(rules)
|
|
||||||
if self.any_errors:
|
|
||||||
raise StylesheetParseError(self.error_renderable)
|
|
||||||
|
|
||||||
def parse(self, css: str, *, path: str = "") -> None:
|
def add_source(self, css: str, path: str | None = None) -> None:
|
||||||
"""Parse CSS from a string.
|
"""Parse CSS from a string.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -169,26 +186,31 @@ class Stylesheet:
|
|||||||
StylesheetError: If the CSS could not be read.
|
StylesheetError: If the CSS could not be read.
|
||||||
StylesheetParseError: If the CSS is invalid.
|
StylesheetParseError: If the CSS is invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
rules = list(parse(css, path, variables=self.variables))
|
|
||||||
except TokenizeError:
|
|
||||||
raise
|
|
||||||
except Exception as error:
|
|
||||||
raise StylesheetError(f"failed to parse css; {error}")
|
|
||||||
else:
|
|
||||||
self.source.append((css, path))
|
|
||||||
self.rules.extend(rules)
|
|
||||||
if self.any_errors:
|
|
||||||
raise StylesheetParseError(self.error_renderable)
|
|
||||||
|
|
||||||
def _clone(self, stylesheet: Stylesheet) -> None:
|
if path is None:
|
||||||
"""Replace this stylesheet contents with another.
|
path = str(hash(css))
|
||||||
|
if path in self.source and self.source[path] == css:
|
||||||
|
# Path already in source, and CSS is identical
|
||||||
|
return
|
||||||
|
|
||||||
Args:
|
self.source[path] = css
|
||||||
stylesheet (Stylesheet): A Stylesheet.
|
self._require_parse = True
|
||||||
|
|
||||||
|
def parse(self) -> None:
|
||||||
|
"""Parse the source in the stylesheet.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StylesheetParseError: If there are any CSS related errors.
|
||||||
"""
|
"""
|
||||||
self.rules = stylesheet.rules.copy()
|
rules: list[RuleSet] = []
|
||||||
self.source = stylesheet.source.copy()
|
add_rules = rules.extend
|
||||||
|
for path, css in self.source.items():
|
||||||
|
css_rules = self._parse_rules(css, path)
|
||||||
|
if any(rule.errors for rule in css_rules):
|
||||||
|
raise StylesheetParseError(self.error_renderable)
|
||||||
|
add_rules(css_rules)
|
||||||
|
self._rules = rules
|
||||||
|
self._require_parse = False
|
||||||
|
|
||||||
def reparse(self) -> None:
|
def reparse(self) -> None:
|
||||||
"""Re-parse source, applying new variables.
|
"""Re-parse source, applying new variables.
|
||||||
@@ -200,9 +222,11 @@ class Stylesheet:
|
|||||||
"""
|
"""
|
||||||
# Do this in a fresh Stylesheet so if there are errors we don't break self.
|
# Do this in a fresh Stylesheet so if there are errors we don't break self.
|
||||||
stylesheet = Stylesheet(variables=self.variables)
|
stylesheet = Stylesheet(variables=self.variables)
|
||||||
for css, path in self.source:
|
for path, css in self.source.items():
|
||||||
stylesheet.parse(css, path=path)
|
stylesheet.add_source(css, path)
|
||||||
self._clone(stylesheet)
|
stylesheet.parse()
|
||||||
|
self.rules = stylesheet.rules
|
||||||
|
self.source = stylesheet.source
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
|
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
|
||||||
@@ -401,7 +425,7 @@ if __name__ == "__main__":
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(CSS)
|
stylesheet.add_source(CSS)
|
||||||
|
|
||||||
print(stylesheet.css)
|
print(stylesheet.css)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from rich.text import Text
|
|||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from ._node_list import NodeList
|
from ._node_list import NodeList
|
||||||
|
from .color import Color
|
||||||
from .css._error_tools import friendly_list
|
from .css._error_tools import friendly_list
|
||||||
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
from .css.constants import VALID_DISPLAY, VALID_VISIBILITY
|
||||||
from .css.errors import StyleValueError
|
from .css.errors import StyleValueError
|
||||||
@@ -19,6 +20,7 @@ from .css.query import NoMatchingNodesError
|
|||||||
from .message_pump import MessagePump
|
from .message_pump import MessagePump
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from .app import App
|
||||||
from .css.query import DOMQuery
|
from .css.query import DOMQuery
|
||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
|
|
||||||
@@ -40,42 +42,44 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: set[str] | None = None,
|
classes: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self._id = id
|
self._id = id
|
||||||
self._classes: set[str] = set() if classes is None else classes
|
self._classes: set[str] = set() if classes is None else set(classes.split())
|
||||||
self.children = NodeList()
|
self.children = NodeList()
|
||||||
self._css_styles: Styles = Styles(self)
|
self._css_styles: Styles = Styles(self)
|
||||||
self._inline_styles: Styles = Styles.parse(
|
self._inline_styles: Styles = Styles.parse(
|
||||||
self.INLINE_STYLES, repr(self), node=self
|
self.INLINE_STYLES, repr(self), node=self
|
||||||
)
|
)
|
||||||
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
|
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
|
||||||
self._default_styles = Styles.parse(self.DEFAULT_STYLES, f"{self.__class__}")
|
self._default_styles = Styles()
|
||||||
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
|
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
def on_register(self, app: App) -> None:
|
||||||
|
"""Called when the widget is registered
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app (App): Parent application.
|
||||||
|
"""
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield "name", self._name, None
|
yield "name", self._name, None
|
||||||
yield "id", self._id, None
|
yield "id", self._id, None
|
||||||
if self._classes:
|
if self._classes:
|
||||||
yield "classes", self._classes
|
yield "classes", " ".join(self._classes)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def parent(self) -> DOMNode:
|
def parent(self) -> DOMNode | None:
|
||||||
"""Get the parent node.
|
"""Get the parent node.
|
||||||
|
|
||||||
Raises:
|
|
||||||
NoParent: If this is the root node.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DOMNode: The node which is the direct parent of this node.
|
DOMNode: The node which is the direct parent of this node.
|
||||||
"""
|
"""
|
||||||
if self._parent is None:
|
|
||||||
raise NoParent(f"{self} has no parent")
|
|
||||||
assert isinstance(self._parent, DOMNode)
|
|
||||||
return self._parent
|
return self._parent
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -222,19 +226,6 @@ class DOMNode(MessagePump):
|
|||||||
f"expected {friendly_list(VALID_VISIBILITY)})"
|
f"expected {friendly_list(VALID_VISIBILITY)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def rich_text_style(self) -> Style:
|
|
||||||
"""Get the text style (added to parent style).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Style: Rich Style object.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
self.parent.rich_text_style + self.styles.rich_style
|
|
||||||
if self.has_parent
|
|
||||||
else self.styles.rich_style
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tree(self) -> Tree:
|
def tree(self) -> Tree:
|
||||||
"""Get a Rich tree object which will recursively render the structure of the node tree.
|
"""Get a Rich tree object which will recursively render the structure of the node tree.
|
||||||
@@ -278,6 +269,52 @@ class DOMNode(MessagePump):
|
|||||||
add_children(tree, self)
|
add_children(tree, self)
|
||||||
return tree
|
return tree
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rich_text_style(self) -> Style:
|
||||||
|
"""Get the text style object.
|
||||||
|
|
||||||
|
A widget's style is influenced by its parent. For instance if a widgets background has an alpha,
|
||||||
|
then its parent's background color will show through. Additionally, widgets will inherit their
|
||||||
|
parent's text style (i.e. bold, italic etc).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Style: Rich Style object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO: Feels like there may be opportunity for caching here.
|
||||||
|
|
||||||
|
background = Color(0, 0, 0, 0)
|
||||||
|
color = Color(255, 255, 255, 0)
|
||||||
|
style = Style()
|
||||||
|
for node in reversed(self.ancestors):
|
||||||
|
styles = node.styles
|
||||||
|
if styles.has_rule("background"):
|
||||||
|
background += styles.background
|
||||||
|
if styles.has_rule("color"):
|
||||||
|
color = styles.color
|
||||||
|
style += styles.text_style
|
||||||
|
|
||||||
|
style = Style(bgcolor=background.rich_color, color=color.rich_color) + style
|
||||||
|
return style
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ancestors(self) -> list[DOMNode]:
|
||||||
|
"""Get a list of Nodes by tracing ancestors all the way back to App."""
|
||||||
|
|
||||||
|
nodes: list[DOMNode] = [self]
|
||||||
|
add_node = nodes.append
|
||||||
|
node = self
|
||||||
|
while True:
|
||||||
|
node = node.parent
|
||||||
|
if node is None:
|
||||||
|
break
|
||||||
|
add_node(node)
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
@property
|
||||||
|
def displayed_children(self) -> list[DOMNode]:
|
||||||
|
return [child for child in self.children if child.display]
|
||||||
|
|
||||||
def get_pseudo_classes(self) -> Iterable[str]:
|
def get_pseudo_classes(self) -> Iterable[str]:
|
||||||
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
|
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,21 @@
|
|||||||
from __future__ import annotations
|
from .widget import Widget
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import ClassVar, NamedTuple, TYPE_CHECKING
|
|
||||||
|
|
||||||
|
|
||||||
from .geometry import Region, Offset, Size
|
class Vertical(Widget):
|
||||||
|
"""A container widget to align children vertically."""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
Vertical {
|
||||||
|
layout: vertical;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
class Horizontal(Widget):
|
||||||
from .widget import Widget
|
"""A container widget to align children horizontally."""
|
||||||
from .screen import Screen
|
|
||||||
|
|
||||||
|
CSS = """
|
||||||
class WidgetPlacement(NamedTuple):
|
Horizontal {
|
||||||
"""The position, size, and relative order of a widget within its parent."""
|
layout: horizontal;
|
||||||
|
}
|
||||||
region: Region
|
"""
|
||||||
widget: Widget | None = None # A widget of None means empty space
|
|
||||||
order: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Layout(ABC):
|
|
||||||
"""Responsible for arranging Widgets in a view and rendering them."""
|
|
||||||
|
|
||||||
name: ClassVar[str] = ""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def arrange(
|
|
||||||
self, parent: Widget, size: Size, scroll: Offset
|
|
||||||
) -> tuple[list[WidgetPlacement], set[Widget]]:
|
|
||||||
"""Generate a layout map that defines where on the screen the widgets will be drawn.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
parent (Widget): Parent widget.
|
|
||||||
size (Size): Size of container.
|
|
||||||
scroll (Offset): Offset to apply to the Widget placements.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Iterable[WidgetPlacement]: An iterable of widget location
|
|
||||||
"""
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
|
|||||||
from .._layout_resolve import layout_resolve
|
from .._layout_resolve import layout_resolve
|
||||||
from ..css.types import Edge
|
from ..css.types import Edge
|
||||||
from ..geometry import Offset, Region, Size
|
from ..geometry import Offset, Region, Size
|
||||||
from ..layout import Layout, WidgetPlacement
|
from .._layout import Layout, WidgetPlacement
|
||||||
from ..widget import Widget
|
from ..widget import Widget
|
||||||
|
|
||||||
if sys.version_info >= (3, 8):
|
if sys.version_info >= (3, 8):
|
||||||
@@ -50,10 +50,9 @@ class DockLayout(Layout):
|
|||||||
|
|
||||||
def get_docks(self, parent: Widget) -> list[Dock]:
|
def get_docks(self, parent: Widget) -> list[Dock]:
|
||||||
groups: dict[str, list[Widget]] = defaultdict(list)
|
groups: dict[str, list[Widget]] = defaultdict(list)
|
||||||
for child in parent.children:
|
for child in parent.displayed_children:
|
||||||
assert isinstance(child, Widget)
|
assert isinstance(child, Widget)
|
||||||
if child.display:
|
groups[child.styles.dock].append(child)
|
||||||
groups[child.styles.dock].append(child)
|
|
||||||
docks: list[Dock] = []
|
docks: list[Dock] = []
|
||||||
append_dock = docks.append
|
append_dock = docks.append
|
||||||
for name, edge, z in parent.styles.docks:
|
for name, edge, z in parent.styles.docks:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from .horizontal import HorizontalLayout
|
from .horizontal import HorizontalLayout
|
||||||
from ..layout import Layout
|
from .._layout import Layout
|
||||||
from ..layouts.dock import DockLayout
|
from ..layouts.dock import DockLayout
|
||||||
from ..layouts.grid import GridLayout
|
from ..layouts.grid import GridLayout
|
||||||
from ..layouts.vertical import VerticalLayout
|
from ..layouts.vertical import VerticalLayout
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from typing import Iterable, NamedTuple, TYPE_CHECKING
|
|||||||
|
|
||||||
from .._layout_resolve import layout_resolve
|
from .._layout_resolve import layout_resolve
|
||||||
from ..geometry import Size, Offset, Region
|
from ..geometry import Size, Offset, Region
|
||||||
from ..layout import Layout, WidgetPlacement
|
from .._layout import Layout, WidgetPlacement
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..widget import Widget
|
from ..widget import Widget
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
from textual.geometry import Size, Offset, Region
|
from textual.geometry import Size, Offset, Region
|
||||||
from textual.layout import Layout, WidgetPlacement
|
from textual._layout import Layout, WidgetPlacement
|
||||||
|
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
|
|
||||||
@@ -39,7 +39,9 @@ class HorizontalLayout(Layout):
|
|||||||
|
|
||||||
x = box_models[0].margin.left if box_models else 0
|
x = box_models[0].margin.left if box_models else 0
|
||||||
|
|
||||||
for widget, box_model, margin in zip(parent.children, box_models, margins):
|
displayed_children = parent.displayed_children
|
||||||
|
|
||||||
|
for widget, box_model, margin in zip(displayed_children, box_models, margins):
|
||||||
content_width, content_height = box_model.size
|
content_width, content_height = box_model.size
|
||||||
offset_y = widget.styles.align_height(content_height, parent_size.height)
|
offset_y = widget.styles.align_height(content_height, parent_size.height)
|
||||||
region = Region(x, offset_y, content_width, content_height)
|
region = Region(x, offset_y, content_width, content_height)
|
||||||
@@ -53,4 +55,4 @@ class HorizontalLayout(Layout):
|
|||||||
total_region = Region(0, 0, max_width, max_height)
|
total_region = Region(0, 0, max_width, max_height)
|
||||||
add_placement(WidgetPlacement(total_region, None, 0))
|
add_placement(WidgetPlacement(total_region, None, 0))
|
||||||
|
|
||||||
return placements, set(parent.children)
|
return placements, set(displayed_children)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import cast, TYPE_CHECKING
|
|||||||
from .. import log
|
from .. import log
|
||||||
|
|
||||||
from ..geometry import Offset, Region, Size
|
from ..geometry import Offset, Region, Size
|
||||||
from ..layout import Layout, WidgetPlacement
|
from .._layout import Layout, WidgetPlacement
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..widget import Widget
|
from ..widget import Widget
|
||||||
@@ -40,10 +40,13 @@ class VerticalLayout(Layout):
|
|||||||
|
|
||||||
y = box_models[0].margin.top if box_models else 0
|
y = box_models[0].margin.top if box_models else 0
|
||||||
|
|
||||||
for widget, box_model, margin in zip(parent.children, box_models, margins):
|
displayed_children = parent.displayed_children
|
||||||
|
|
||||||
|
for widget, box_model, margin in zip(displayed_children, box_models, margins):
|
||||||
content_width, content_height = box_model.size
|
content_width, content_height = box_model.size
|
||||||
offset_x = widget.styles.align_width(content_width, parent_size.width)
|
offset_x = widget.styles.align_width(content_width, parent_size.width)
|
||||||
region = Region(offset_x, y, content_width, content_height)
|
region = Region(offset_x, y, content_width, content_height)
|
||||||
|
# TODO: it seems that `max_height` is not used?
|
||||||
max_height = max(max_height, content_height)
|
max_height = max(max_height, content_height)
|
||||||
add_placement(WidgetPlacement(region, widget, 0))
|
add_placement(WidgetPlacement(region, widget, 0))
|
||||||
y += region.height + margin
|
y += region.height + margin
|
||||||
@@ -54,4 +57,4 @@ class VerticalLayout(Layout):
|
|||||||
total_region = Region(0, 0, max_width, max_height)
|
total_region = Region(0, 0, max_width, max_height)
|
||||||
add_placement(WidgetPlacement(total_region, None, 0))
|
add_placement(WidgetPlacement(total_region, None, 0))
|
||||||
|
|
||||||
return placements, set(parent.children)
|
return placements, set(displayed_children)
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ class MessagePump:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def close_messages_no_wait(self) -> None:
|
def close_messages_no_wait(self) -> None:
|
||||||
|
"""Request the message queue to exit."""
|
||||||
self._message_queue.put_nowait(MessagePriority(None))
|
self._message_queue.put_nowait(MessagePriority(None))
|
||||||
|
|
||||||
async def close_messages(self) -> None:
|
async def close_messages(self) -> None:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
|||||||
Reactable = Union[Widget, App]
|
Reactable = Union[Widget, App]
|
||||||
|
|
||||||
|
|
||||||
ReactiveType = TypeVar("ReactiveType")
|
ReactiveType = TypeVar("ReactiveType", covariant=True)
|
||||||
|
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
@@ -36,7 +36,7 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
default: ReactiveType,
|
default: ReactiveType | Callable[[], ReactiveType],
|
||||||
*,
|
*,
|
||||||
layout: bool = False,
|
layout: bool = False,
|
||||||
repaint: bool = True,
|
repaint: bool = True,
|
||||||
@@ -58,7 +58,11 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.internal_name = f"_reactive_{name}"
|
self.internal_name = f"_reactive_{name}"
|
||||||
setattr(owner, self.internal_name, self._default)
|
setattr(
|
||||||
|
owner,
|
||||||
|
self.internal_name,
|
||||||
|
self._default() if callable(self._default) else self._default,
|
||||||
|
)
|
||||||
|
|
||||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||||
return getattr(obj, self.internal_name)
|
return getattr(obj, self.internal_name)
|
||||||
|
|||||||
@@ -11,18 +11,21 @@ from .geometry import Offset, Region
|
|||||||
from ._compositor import Compositor
|
from ._compositor import Compositor
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
from .renderables.gradient import VerticalGradient
|
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
class Screen(Widget):
|
class Screen(Widget):
|
||||||
"""A widget for the root of the app."""
|
"""A widget for the root of the app."""
|
||||||
|
|
||||||
DEFAULT_STYLES = """
|
CSS = """
|
||||||
|
|
||||||
layout: dock;
|
Screen {
|
||||||
docks: _default=top;
|
layout: dock;
|
||||||
|
docks: _default=top;
|
||||||
|
background: $surface;
|
||||||
|
color: $text-surface;
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dark = Reactive(False)
|
dark = Reactive(False)
|
||||||
@@ -35,12 +38,8 @@ class Screen(Widget):
|
|||||||
def watch_dark(self, dark: bool) -> None:
|
def watch_dark(self, dark: bool) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@property
|
|
||||||
def is_transparent(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return VerticalGradient("red", "blue")
|
return self.app.render()
|
||||||
|
|
||||||
def get_offset(self, widget: Widget) -> Offset:
|
def get_offset(self, widget: Widget) -> Offset:
|
||||||
"""Get the absolute offset of a given Widget.
|
"""Get the absolute offset of a given Widget.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
@@ -33,12 +33,13 @@ from .dom import DOMNode
|
|||||||
from .geometry import clamp, Offset, Region, Size, Spacing
|
from .geometry import clamp, Offset, Region, Size, Spacing
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from . import messages
|
from . import messages
|
||||||
from .layout import Layout
|
from ._layout import Layout
|
||||||
from .reactive import Reactive, watch
|
from .reactive import Reactive, watch
|
||||||
from .renderables.opacity import Opacity
|
from .renderables.opacity import Opacity
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from .app import App, ComposeResult
|
||||||
from .scrollbar import (
|
from .scrollbar import (
|
||||||
ScrollBar,
|
ScrollBar,
|
||||||
ScrollTo,
|
ScrollTo,
|
||||||
@@ -67,8 +68,7 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
can_focus: bool = False
|
can_focus: bool = False
|
||||||
|
|
||||||
DEFAULT_STYLES = """
|
CSS = """
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -76,7 +76,7 @@ class Widget(DOMNode):
|
|||||||
*children: Widget,
|
*children: Widget,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: set[str] | None = None,
|
classes: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
self._size = Size(0, 0)
|
self._size = Size(0, 0)
|
||||||
@@ -107,6 +107,26 @@ class Widget(DOMNode):
|
|||||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||||
show_horizontal_scrollbar = Reactive(False, layout=True)
|
show_horizontal_scrollbar = Reactive(False, layout=True)
|
||||||
|
|
||||||
|
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
||||||
|
self.app.register(self, *anon_widgets, **widgets)
|
||||||
|
self.screen.refresh()
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
"""Yield child widgets for a container."""
|
||||||
|
return
|
||||||
|
yield
|
||||||
|
|
||||||
|
def on_register(self, app: App) -> None:
|
||||||
|
"""Called when the instance is registered.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app (App): App instance.
|
||||||
|
"""
|
||||||
|
# Parser the Widget's CSS
|
||||||
|
self.app.stylesheet.add_source(
|
||||||
|
self.CSS, f"{__file__}:<{self.__class__.__name__}>"
|
||||||
|
)
|
||||||
|
|
||||||
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
||||||
"""Process the box model for this widget.
|
"""Process the box model for this widget.
|
||||||
|
|
||||||
@@ -414,12 +434,18 @@ class Widget(DOMNode):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
renderable = self.render()
|
renderable = self.render()
|
||||||
|
|
||||||
styles = self.styles
|
styles = self.styles
|
||||||
parent_styles = self.parent.styles
|
parent_styles = self.parent.styles
|
||||||
|
|
||||||
parent_text_style = self.parent.rich_text_style
|
parent_text_style = self.parent.rich_text_style
|
||||||
text_style = styles.rich_style
|
text_style = styles.rich_style
|
||||||
|
|
||||||
|
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
|
||||||
|
if content_align != ("left", "top"):
|
||||||
|
horizontal, vertical = content_align
|
||||||
|
renderable = Align(renderable, horizontal, vertical=vertical)
|
||||||
|
|
||||||
renderable_text_style = parent_text_style + text_style
|
renderable_text_style = parent_text_style + text_style
|
||||||
if renderable_text_style:
|
if renderable_text_style:
|
||||||
renderable = Styled(renderable, renderable_text_style)
|
renderable = Styled(renderable, renderable_text_style)
|
||||||
@@ -478,8 +504,7 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
bool: ``True`` if there is background color, otherwise ``False``.
|
bool: ``True`` if there is background color, otherwise ``False``.
|
||||||
"""
|
"""
|
||||||
return False
|
return self.is_container and self.styles.background.is_transparent
|
||||||
return self.layout is not None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def console(self) -> Console:
|
def console(self) -> Console:
|
||||||
@@ -615,8 +640,10 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
# Default displays a pretty repr in the center of the screen
|
# Default displays a pretty repr in the center of the screen
|
||||||
|
|
||||||
label = self.css_identifier_styled
|
if self.is_container:
|
||||||
return Align.center(label, vertical="middle")
|
return ""
|
||||||
|
|
||||||
|
return self.css_identifier_styled
|
||||||
|
|
||||||
async def action(self, action: str, *params) -> None:
|
async def action(self, action: str, *params) -> None:
|
||||||
await self.app.action(action, self)
|
await self.app.action(action, self)
|
||||||
@@ -677,6 +704,12 @@ class Widget(DOMNode):
|
|||||||
async def on_key(self, event: events.Key) -> None:
|
async def on_key(self, event: events.Key) -> None:
|
||||||
await self.dispatch_key(event)
|
await self.dispatch_key(event)
|
||||||
|
|
||||||
|
def on_mount(self, event: events.Mount) -> None:
|
||||||
|
widgets = list(self.compose())
|
||||||
|
if widgets:
|
||||||
|
self.mount(*widgets)
|
||||||
|
self.screen.refresh()
|
||||||
|
|
||||||
def on_leave(self) -> None:
|
def on_leave(self) -> None:
|
||||||
self.mouse_over = False
|
self.mouse_over = False
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from ._footer import Footer
|
from ._footer import Footer
|
||||||
from ._header import Header
|
from ._header import Header
|
||||||
from ._button import Button, ButtonPressed
|
from ._button import Button
|
||||||
from ._placeholder import Placeholder
|
from ._placeholder import Placeholder
|
||||||
from ._static import Static
|
from ._static import Static
|
||||||
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
|
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
|
||||||
@@ -8,7 +8,6 @@ from ._directory_tree import DirectoryTree, FileClick
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Button",
|
"Button",
|
||||||
"ButtonPressed",
|
|
||||||
"DirectoryTree",
|
"DirectoryTree",
|
||||||
"FileClick",
|
"FileClick",
|
||||||
"Footer",
|
"Footer",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from rich.align import Align
|
from typing import cast
|
||||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
|
||||||
from rich.style import StyleType
|
from rich.console import RenderableType
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
from .. import events
|
from .. import events
|
||||||
from ..message import Message
|
from ..message import Message
|
||||||
@@ -10,58 +11,65 @@ from ..reactive import Reactive
|
|||||||
from ..widget import Widget
|
from ..widget import Widget
|
||||||
|
|
||||||
|
|
||||||
class ButtonPressed(Message, bubble=True):
|
class Button(Widget, can_focus=True):
|
||||||
pass
|
"""A simple clickable button."""
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
|
||||||
|
Button {
|
||||||
|
width: auto;
|
||||||
|
height: 3;
|
||||||
|
padding: 0 2;
|
||||||
|
background: $primary;
|
||||||
|
color: $text-primary;
|
||||||
|
content-align: center middle;
|
||||||
|
border: tall $primary-lighten-3;
|
||||||
|
|
||||||
|
margin: 1 0;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
class Expand:
|
Button:hover {
|
||||||
def __init__(self, renderable: RenderableType) -> None:
|
background:$primary-darken-2;
|
||||||
self.renderable = renderable
|
color: $text-primary-darken-2;
|
||||||
|
border: tall $primary-lighten-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
def __rich_console__(
|
class Pressed(Message, bubble=True):
|
||||||
self, console: Console, options: ConsoleOptions
|
@property
|
||||||
) -> RenderResult:
|
def button(self) -> Button:
|
||||||
width = options.max_width
|
return cast(Button, self.sender)
|
||||||
height = options.height or 1
|
|
||||||
yield from console.render(
|
|
||||||
self.renderable, options.update_dimensions(width, height)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ButtonRenderable:
|
|
||||||
def __init__(self, label: RenderableType, style: StyleType = "") -> None:
|
|
||||||
self.label = label
|
|
||||||
self.style = style
|
|
||||||
|
|
||||||
def __rich_console__(
|
|
||||||
self, console: Console, options: ConsoleOptions
|
|
||||||
) -> RenderResult:
|
|
||||||
width = options.max_width
|
|
||||||
height = options.height or 1
|
|
||||||
|
|
||||||
yield Align.center(
|
|
||||||
self.label, vertical="middle", style=self.style, width=width, height=height
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Button(Widget):
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
label: RenderableType,
|
label: RenderableType | None = None,
|
||||||
|
disabled: bool = False,
|
||||||
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
style: StyleType = "white on dark_blue",
|
id: str | None = None,
|
||||||
|
classes: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(name=name)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
self.name = name or str(label)
|
|
||||||
self.button_style = style
|
|
||||||
|
|
||||||
self.label = label
|
self.label = self.css_identifier_styled if label is None else label
|
||||||
|
self.disabled = disabled
|
||||||
|
if disabled:
|
||||||
|
self.add_class("-disabled")
|
||||||
|
|
||||||
label: Reactive[RenderableType] = Reactive("")
|
label: Reactive[RenderableType] = Reactive("")
|
||||||
|
|
||||||
|
def validate_label(self, label: RenderableType) -> RenderableType:
|
||||||
|
"""Parse markup for self.label"""
|
||||||
|
if isinstance(label, str):
|
||||||
|
return Text.from_markup(label)
|
||||||
|
return label
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
def render(self) -> RenderableType:
|
||||||
return ButtonRenderable(self.label, style=self.button_style)
|
return self.label
|
||||||
|
|
||||||
async def on_click(self, event: events.Click) -> None:
|
async def on_click(self, event: events.Click) -> None:
|
||||||
event.prevent_default().stop()
|
event.stop()
|
||||||
await self.emit(ButtonPressed(self))
|
if not self.disabled:
|
||||||
|
await self.emit(Button.Pressed(self))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Static(Widget):
|
|||||||
*,
|
*,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
id: str | None = None,
|
id: str | None = None,
|
||||||
classes: set[str] | None = None,
|
classes: str | None = None,
|
||||||
style: StyleType = "",
|
style: StyleType = "",
|
||||||
padding: PaddingDimensions = 0,
|
padding: PaddingDimensions = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -864,7 +864,7 @@ class TestParseLayout:
|
|||||||
css = "#some-widget { layout: dock; }"
|
css = "#some-widget { layout: dock; }"
|
||||||
|
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
assert isinstance(styles.layout, DockLayout)
|
assert isinstance(styles.layout, DockLayout)
|
||||||
@@ -874,7 +874,8 @@ class TestParseLayout:
|
|||||||
|
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
with pytest.raises(StylesheetParseError) as ex:
|
with pytest.raises(StylesheetParseError) as ex:
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
stylesheet.parse()
|
||||||
|
|
||||||
assert ex.value.errors is not None
|
assert ex.value.errors is not None
|
||||||
|
|
||||||
@@ -886,7 +887,7 @@ class TestParseText:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
assert styles.color == Color.parse("green")
|
assert styles.color == Color.parse("green")
|
||||||
@@ -897,7 +898,7 @@ class TestParseText:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
assert styles.background == Color.parse("red")
|
assert styles.background == Color.parse("red")
|
||||||
@@ -933,7 +934,7 @@ class TestParseOffset:
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
|
|
||||||
@@ -972,7 +973,7 @@ class TestParseOffset:
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
|
|
||||||
@@ -1002,7 +1003,7 @@ class TestParseTransition:
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
|
|
||||||
@@ -1017,7 +1018,7 @@ class TestParseTransition:
|
|||||||
def test_no_delay_specified(self):
|
def test_no_delay_specified(self):
|
||||||
css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}"
|
css = f"#some-widget {{ transition: offset-x 1 in_out_cubic; }}"
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
styles = stylesheet.rules[0].styles
|
styles = stylesheet.rules[0].styles
|
||||||
|
|
||||||
@@ -1032,9 +1033,11 @@ class TestParseTransition:
|
|||||||
|
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
with pytest.raises(StylesheetParseError) as ex:
|
with pytest.raises(StylesheetParseError) as ex:
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
stylesheet.parse()
|
||||||
|
|
||||||
stylesheet_errors = stylesheet.rules[0].errors
|
rules = stylesheet._parse_rules(css, "foo")
|
||||||
|
stylesheet_errors = rules[0].errors
|
||||||
|
|
||||||
assert len(stylesheet_errors) == 1
|
assert len(stylesheet_errors) == 1
|
||||||
assert stylesheet_errors[0][0].value == invalid_func_name
|
assert stylesheet_errors[0][0].value == invalid_func_name
|
||||||
@@ -1056,7 +1059,7 @@ class TestParseOpacity:
|
|||||||
def test_opacity_to_styles(self, css_value, styles_value):
|
def test_opacity_to_styles(self, css_value, styles_value):
|
||||||
css = f"#some-widget {{ opacity: {css_value} }}"
|
css = f"#some-widget {{ opacity: {css_value} }}"
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
|
||||||
assert stylesheet.rules[0].styles.opacity == styles_value
|
assert stylesheet.rules[0].styles.opacity == styles_value
|
||||||
assert not stylesheet.rules[0].errors
|
assert not stylesheet.rules[0].errors
|
||||||
@@ -1066,15 +1069,17 @@ class TestParseOpacity:
|
|||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
|
|
||||||
with pytest.raises(StylesheetParseError):
|
with pytest.raises(StylesheetParseError):
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
assert stylesheet.rules[0].errors
|
stylesheet.parse()
|
||||||
|
rules = stylesheet._parse_rules(css, "foo")
|
||||||
|
assert rules[0].errors
|
||||||
|
|
||||||
|
|
||||||
class TestParseMargin:
|
class TestParseMargin:
|
||||||
def test_margin_partial(self):
|
def test_margin_partial(self):
|
||||||
css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}"
|
css = "#foo {margin: 1; margin-top: 2; margin-right: 3; margin-bottom: -1;}"
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1)
|
assert stylesheet.rules[0].styles.margin == Spacing(2, 3, -1, 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -1082,5 +1087,5 @@ class TestParsePadding:
|
|||||||
def test_padding_partial(self):
|
def test_padding_partial(self):
|
||||||
css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}"
|
css = "#foo {padding: 1; padding-top: 2; padding-right: 3; padding-bottom: -1;}"
|
||||||
stylesheet = Stylesheet()
|
stylesheet = Stylesheet()
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1)
|
assert stylesheet.rules[0].styles.padding == Spacing(2, 3, -1, 1)
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ def test_color_property_parsing(css_value, expectation, expected_color):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with expectation:
|
with expectation:
|
||||||
stylesheet.parse(css)
|
stylesheet.add_source(css)
|
||||||
|
stylesheet.parse()
|
||||||
|
|
||||||
if expected_color:
|
if expected_color:
|
||||||
css_rule = stylesheet.rules[0]
|
css_rule = stylesheet.rules[0]
|
||||||
|
|||||||
30
tests/layouts/test_common_layout_features.py
Normal file
30
tests/layouts/test_common_layout_features.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"layout,display,expected_in_displayed_children",
|
||||||
|
[
|
||||||
|
("dock", "block", True),
|
||||||
|
("horizontal", "block", True),
|
||||||
|
("vertical", "block", True),
|
||||||
|
("dock", "none", False),
|
||||||
|
("horizontal", "none", False),
|
||||||
|
("vertical", "none", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_nodes_take_display_property_into_account_when_they_display_their_children(
|
||||||
|
layout: str, display: str, expected_in_displayed_children: bool
|
||||||
|
):
|
||||||
|
widget = Widget(name="widget that might not be visible 🥷 ")
|
||||||
|
widget.styles.display = display
|
||||||
|
|
||||||
|
screen = Screen()
|
||||||
|
screen.styles.layout = layout
|
||||||
|
screen.add_child(widget)
|
||||||
|
|
||||||
|
displayed_children = screen.displayed_children
|
||||||
|
assert isinstance(displayed_children, list)
|
||||||
|
assert (widget in screen.displayed_children) is expected_in_displayed_children
|
||||||
Reference in New Issue
Block a user