Merge pull request #195 from willmcgugan/outline

Outline
This commit is contained in:
Will McGugan
2022-01-02 15:46:14 +00:00
committed by GitHub
60 changed files with 4338 additions and 370 deletions

34
examples/basic.css Normal file
View File

@@ -0,0 +1,34 @@
/* CSS file for basic.py */
App > View {
docks: side=left/1;
}
#sidebar {
text: #09312e on #3caea3;
dock: side;
width: 30;
offset-x: -100%;
transition: offset 500ms in_out_cubic;
border-right: outer #09312e;
}
#sidebar.-active {
offset-x: 0;
}
#header {
text: white on #173f5f;
height: 3;
border: hkey;
}
#content {
text: white on #20639b;
border-bottom: hkey #0f2b41;
}
#footer {
text: #3a3009 on #f6d55c;
height: 3;
}

22
examples/basic.py Normal file
View File

@@ -0,0 +1,22 @@
from textual.app import App
from textual.widget import Widget
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):
"""Build layout here."""
self.mount(
header=Widget(),
content=Widget(),
footer=Widget(),
sidebar=Widget(),
)
BasicApp.run(css_file="basic.css", watch_css=True, log="textual.log")

10
examples/colours.txt Normal file
View File

@@ -0,0 +1,10 @@
header blue white on #173f5f
sidebar #09312e on #3caea3
sidebar border #09312e
content blue white #20639b
content border #0f2b41
footer border #0f2b41
footer yellow #3a3009 on #f6d55c;

34
examples/example.css Normal file
View File

@@ -0,0 +1,34 @@
App > View {
layout: dock;
docks: side=left/1;
}
#header {
text: on #173f5f;
height: 3;
border: hkey white;
}
#content {
text: on #20639b;
}
#footer {
height: 3;
border-top: hkey #0f2b41;
text: #3a3009 on #f6d55c;
}
#sidebar {
text: #09312e on #3caea3;
dock: side;
width: 30;
border-right: outer #09312e;
offset-x: -100%;
transition: offset 400ms in_out_cubic;
}
#sidebar.-active {
offset-x: 0;
transition: offset 400ms in_out_cubic;
}

View File

@@ -1,6 +1,5 @@
from rich.markdown import Markdown
from textual import events
from textual.app import App
from textual.widgets import Header, Footer, Placeholder, ScrollView
@@ -8,24 +7,38 @@ from textual.widgets import Header, Footer, Placeholder, ScrollView
class MyApp(App):
"""An example of a very simple Textual App"""
async def on_load(self, event: events.Load) -> None:
stylesheet = """
App > View {
layout: dock
}
#body {
padding: 1
}
#sidebar {
edge left
size: 40
}
"""
async def on_load(self) -> None:
"""Bind keys with the app loads (but before entering application mode)"""
await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar")
await self.bind("q", "quit", "Quit")
async def on_mount(self, event: events.Mount) -> None:
async def on_mount(self) -> None:
"""Create and dock the widgets."""
# A scrollview to contain the markdown file
body = ScrollView(gutter=1)
# Header / footer / dock
await self.view.dock(Header(), edge="top")
await self.view.dock(Footer(), edge="bottom")
await self.view.dock(Placeholder(), edge="left", size=30, name="sidebar")
# Dock the body in the remaining space
await self.view.dock(body, edge="right")
body = ScrollView()
await self.view.mount(
Header(),
Footer(),
body=body,
sidebar=Placeholder(),
)
async def get_markdown(filename: str) -> None:
with open(filename, "rt") as fh:

7
examples/theme.css Normal file
View File

@@ -0,0 +1,7 @@
Header {
border: solid #122233;
}
App > View > Widget {
display: none;
}

3
poetry.lock generated
View File

@@ -645,7 +645,8 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "4e81046724f8d03d079e07d047f8bb6f14a0889716fac3938647934a0052d834"
content-hash = "2a8542d8f61658fe89457004c793928d3248fe4ede4ec2444b97b2188be11f60"
[metadata.files]
astunparse = [

View File

@@ -20,7 +20,7 @@ classifiers = [
[tool.poetry.dependencies]
python = "^3.7"
rich = "^10.7.0"
rich = "^10.12.0"
#rich = {git = "git@github.com:willmcgugan/rich", rev = "link-id"}
typing-extensions = { version = "^3.10.0", python = "<3.8" }

View File

@@ -1,16 +1,18 @@
from typing import Any
from rich.console import RenderableType
__all__ = ["log", "panic"]
def log(*args: Any, verbosity: int = 0, **kwargs) -> None:
def log(*args: object, verbosity: int = 0, **kwargs) -> None:
from ._context import active_app
app = active_app.get()
app.log(*args, verbosity=verbosity, **kwargs)
def panic(*args: Any) -> None:
def panic(*args: RenderableType) -> None:
from ._context import active_app
app = active_app.get()

View File

@@ -1,21 +1,23 @@
from __future__ import annotations
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
from . import log
from ._easing import DEFAULT_EASING, EASING
from ._profile import timer
from ._timer import Timer
from ._types import MessageTarget
if sys.version_info >= (3, 8):
from typing import Protocol
from typing import Protocol, runtime_checkable
else:
from typing_extensions import Protocol
from typing_extensions import Protocol, runtime_checkable
EasingFunction = Callable[[float], float]
@@ -23,20 +25,27 @@ EasingFunction = Callable[[float], float]
T = TypeVar("T")
@runtime_checkable
class Animatable(Protocol):
def blend(self: T, destination: T, factor: float) -> T:
...
class Animation(ABC):
@abstractmethod
def __call__(self, time: float) -> bool:
raise NotImplementedError("")
@dataclass
class Animation:
class SimpleAnimation(Animation):
obj: object
attribute: str
start_time: float
duration: float
start_value: float | Animatable
end_value: float | Animatable
easing_function: EasingFunction
easing: EasingFunction
def __call__(self, time: float) -> bool:
def blend_float(start: float, end: float, factor: float) -> float:
@@ -47,28 +56,34 @@ class Animation:
def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT:
return start.blend(end, factor)
blend_function = (
blend_float if isinstance(self.start_value, (int, float)) else blend
)
if self.duration == 0:
value = self.end_value
else:
factor = min(1.0, (time - self.start_time) / self.duration)
eased_factor = self.easing_function(factor)
# value = blend_function(self.start_value, self.end_value, eased_factor)
eased_factor = self.easing(factor)
if self.end_value > self.start_value:
eased_factor = self.easing_function(factor)
value = (
self.start_value
+ (self.end_value - self.start_value) * eased_factor
if isinstance(self.start_value, Animatable):
assert isinstance(
self.end_value, Animatable, "end_value must be animatable"
)
value = self.start_value.blend(self.end_value, eased_factor)
else:
eased_factor = 1 - self.easing_function(factor)
value = (
self.end_value + (self.start_value - self.end_value) * eased_factor
)
assert isinstance(
self.start_value, float
), "`start_value` must be float"
assert isinstance(self.end_value, float), "`end_value` must be float"
if self.end_value > self.start_value:
eased_factor = self.easing(factor)
value = (
self.start_value
+ (self.end_value - self.start_value) * eased_factor
)
else:
eased_factor = 1 - self.easing(factor)
value = (
self.end_value
+ (self.start_value - self.end_value) * eased_factor
)
setattr(self.obj, self.attribute, value)
return value == self.end_value
@@ -100,7 +115,8 @@ class BoundAnimator:
class Animator:
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
self._animations: dict[tuple[object, str], Animation] = {}
self._animations: dict[tuple[object, str], SimpleAnimation] = {}
self.target = target
self._timer = Timer(
target,
1 / frames_per_second,
@@ -109,7 +125,6 @@ class Animator:
callback=self,
pause=True,
)
self._timer_task: asyncio.Task | None = None
async def start(self) -> None:
if self._timer_task is None:
@@ -128,7 +143,7 @@ class Animator:
self,
obj: object,
attribute: str,
value: float,
value: Any,
*,
duration: float | None = None,
speed: float | None = None,
@@ -137,30 +152,46 @@ class Animator:
start_time = time()
animation_key = (obj, attribute)
animation_key = (id(obj), attribute)
if animation_key in self._animations:
self._animations[animation_key](start_time)
start_value = getattr(obj, attribute)
if start_value == value:
self._animations.pop(animation_key, None)
return
if duration is not None:
animation_duration = duration
else:
animation_duration = abs(value - start_value) / (speed or 50)
easing_function = EASING[easing] if isinstance(easing, str) else easing
animation = Animation(
obj,
attribute=attribute,
start_time=start_time,
duration=animation_duration,
start_value=start_value,
end_value=value,
easing_function=easing_function,
)
animation: Animation | None = None
if hasattr(obj, "__textual_animation__"):
animation = getattr(obj, "__textual_animation__")(
attribute,
value,
start_time,
duration=duration,
speed=speed,
easing=easing_function,
)
if animation is None:
start_value = getattr(obj, attribute)
if start_value == value:
self._animations.pop(animation_key, None)
return
if duration is not None:
animation_duration = duration
else:
animation_duration = abs(value - start_value) / (speed or 50)
animation = SimpleAnimation(
obj,
attribute=attribute,
start_time=start_time,
duration=animation_duration,
start_value=start_value,
end_value=value,
easing=easing_function,
)
assert animation is not None, "animation expected to be non-None"
self._animations[animation_key] = animation
self._timer.resume()
@@ -174,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)

View File

@@ -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)

185
src/textual/_border.py Normal file
View File

@@ -0,0 +1,185 @@
from __future__ import annotations
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment, SegmentLines
from rich.style import Style, StyleType
from .css.types import EdgeStyle
BORDER_STYLES: dict[str, tuple[str, str, str]] = {
"": (" ", " ", " "),
"none": (" ", " ", " "),
"round": ("╭─╮", "│ │", "╰─╯"),
"solid": ("┌─┐", "│ │", "└─┘"),
"double": ("╔═╗", "║ ║", "╚═╝"),
"dashed": ("┏╍┓", "╏ ╏", "┗╍┛"),
"heavy": ("┏━┓", "┃ ┃", "┗━┛"),
"inner": ("▗▄▖", "▐ ▌", "▝▀▘"),
"outer": ("▛▀▜", "▌ ▐", "▙▄▟"),
"hkey": ("▔▔▔", " ", "▁▁▁"),
"vkey": ("▏ ▕", "▏ ▕", "▏ ▕"),
}
class Border:
def __init__(
self,
renderable: RenderableType,
edge_styles: tuple[EdgeStyle, EdgeStyle, EdgeStyle, EdgeStyle],
outline: bool = False,
style: StyleType = "",
):
self.renderable = renderable
self.edge_styles = edge_styles
self.outline = outline
self.style = style
(
(top, top_style),
(right, right_style),
(bottom, bottom_style),
(left, left_style),
) = edge_styles
self._sides = (top or "none", right or "none", bottom or "none", left or "none")
self._styles = (top_style, right_style, bottom_style, left_style)
def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None:
"""Crops a renderable in place.
Args:
lines (list[list[Segment]]): Segment lines.
width (int): Desired width.
"""
top, right, bottom, left = self._sides
has_left = left != "none"
has_right = right != "none"
has_top = top != "none"
has_bottom = bottom != "none"
if has_top:
lines.pop(0)
if has_bottom:
lines.pop(-1)
divide = Segment.divide
if has_left and has_right:
for line in lines:
_, line[:] = divide(line, [1, width - 1])
elif has_left:
for line in lines:
_, line[:] = divide(line, [1, width])
elif has_right:
for line in lines:
line[:], _ = divide(line, [width - 1, width])
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
top, right, bottom, left = self._sides
style = console.get_style(self.style)
top_style, right_style, bottom_style, left_style = self._styles
if style:
top_style = style + top_style
right_style = style + right_style
bottom_style = style + bottom_style
left_style = style + left_style
BOX = BORDER_STYLES
has_left = left != "none"
has_right = right != "none"
has_top = top != "none"
has_bottom = bottom != "none"
width = options.max_width - has_left - has_right
if width <= 2:
lines = console.render_lines(self.renderable, options, new_lines=True)
yield SegmentLines(lines)
return
if self.outline:
render_options = options
else:
if options.height is None:
render_options = options.update_width(width)
else:
new_height = options.height - has_top - has_bottom
if new_height >= 1:
render_options = options.update_dimensions(width, new_height)
else:
render_options = options
has_top = has_bottom = False
lines = console.render_lines(self.renderable, render_options)
# if len(lines) <= 2:
# yield SegmentLines(lines, new_lines=True)
# return
if self.outline:
self._crop_renderable(lines, options.max_width)
_Segment = Segment
new_line = _Segment.line()
if has_top:
box1, box2, box3 = iter(BOX[top][0])
if has_left:
yield _Segment(box1 if top == left else " ", top_style)
yield _Segment(box2 * width, top_style)
if has_right:
yield _Segment(box3 if top == right else " ", top_style)
yield new_line
box_left = BOX[left][1][0]
box_right = BOX[right][1][2]
left_segment = _Segment(box_left, left_style)
right_segment = _Segment(box_right + "\n", right_style)
if has_left and has_right:
for line in lines:
yield left_segment
yield from line
yield right_segment
elif has_left:
for line in lines:
yield left_segment
yield from line
yield new_line
elif has_right:
for line in lines:
yield from line
yield right_segment
else:
for line in lines:
yield from line
yield new_line
if has_bottom:
box1, box2, box3 = iter(BOX[bottom][2])
if has_left:
yield _Segment(box1 if bottom == left else " ", bottom_style)
yield _Segment(box2 * width, bottom_style)
if has_right:
yield _Segment(box3 if bottom == right else " ", bottom_style)
yield new_line
if __name__ == "__main__":
from rich import print
from rich.text import Text
text = Text("Textual " * 40, style="dim")
border = Border(
text,
(
("outer", Style.parse("green")),
("outer", Style.parse("green")),
("outer", Style.parse("green")),
("outer", Style.parse("green")),
),
)
print(text)
print()
print(border)
print()
border.outline = True
print(border)

93
src/textual/_box.py Normal file
View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.segment import Segment
BOX_STYLES: dict[str, tuple[str, str, str]] = {
"": (" ", " ", " "),
"rounded": ("╭─╮", "│ │", "╰─╯"),
"solid": ("┌─┐", "│ │", "└─┘"),
"double": ("╔═╗", "║ ║", "╚═╝"),
"dashed": ("┏╍┓", "╏ ╏", "┗╍┛"),
"heavy": ("┏━┓", "┃ ┃", "┗━┛"),
"inner": ("▗▄▖", "▐ ▌", "▝▀▘"),
"outer": ("▛▀▜", "▌ ▐", "▙▄▟"),
}
class Box:
def __init__(
self,
renderable: RenderableType,
*,
sides: tuple[str, str, str, str],
styles: tuple[str, str, str, str],
):
self.renderable = renderable
self.sides = sides
self.styles = styles
def __rich_console__(
self, console: "Console", options: "ConsoleOptions"
) -> "RenderResult":
width = options.max_width
top, right, bottom, left = (
side if side != "none" else "" for side in self.sides
)
top_style, right_style, bottom_style, left_style = map(
console.get_style, self.styles
)
BOX = BOX_STYLES
renderable = self.renderable
render_width = width - bool(left) - bool(right)
lines = console.render_lines(renderable, options.update_width(render_width))
new_line = Segment.line()
if top != "none":
char_left, char_mid, char_right = iter(BOX[top][0])
row = f"{char_left if left else ''}{char_mid * render_width}{char_right if right else ''}"
yield Segment(row, top_style)
yield new_line
if not left and not right:
for line in lines:
yield from line
yield new_line
elif left and right:
left_segment = Segment(BOX[left][1][0], left_style)
right_segment = Segment(BOX[right][1][2] + "\n", right_style)
for line in lines:
yield left_segment
yield from line
yield right_segment
elif left:
left_segment = Segment(BOX[left][1][0], left_style)
for line in lines:
yield left_segment
yield from line
yield new_line
elif right:
right_segment = Segment(BOX[right][1][2] + "\n", right_style)
for line in lines:
yield from line
yield right_segment
if bottom:
char_left, char_mid, char_right = iter(BOX[bottom][2])
row = f"{char_left if left else ''}{char_mid * render_width}{char_right if right else ''}"
yield Segment(row, bottom_style)
yield new_line
if __name__ == "__main__":
from rich import print
box = Box(
"foo",
sides=("rounded", "rounded", "rounded", "rounded"),
styles=("green", "green", "green", "on green"),
)
print(box)

78
src/textual/_node_list.py Normal file
View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Iterator, overload, TYPE_CHECKING
from weakref import ref
import rich.repr
if TYPE_CHECKING:
from .dom import DOMNode
@rich.repr.auto
class NodeList:
"""
A container for widgets that forms one level of hierarchy.
Although named a list, widgets may appear only once, making them more like a set.
"""
def __init__(self) -> None:
self._node_refs: list[ref[DOMNode]] = []
self.__nodes: list[DOMNode] | None = []
def __rich_repr__(self) -> rich.repr.Result:
yield self._widgets
def __len__(self) -> int:
return len(self._widgets)
def __contains__(self, widget: DOMNode) -> bool:
return widget in self._widgets
@property
def _widgets(self) -> list[DOMNode]:
if self.__nodes is None:
self.__nodes = list(
filter(None, [widget_ref() for widget_ref in self._node_refs])
)
return self.__nodes
def _prune(self) -> None:
"""Remove expired references."""
self._node_refs[:] = filter(
None,
[
None if widget_ref() is None else widget_ref
for widget_ref in self._node_refs
],
)
def _append(self, widget: DOMNode) -> None:
if widget not in self._widgets:
self._node_refs.append(ref(widget))
self.__nodes = None
def _clear(self) -> None:
del self._node_refs[:]
self.__nodes = None
def __iter__(self) -> Iterator[DOMNode]:
for widget_ref in self._node_refs:
widget = widget_ref()
if widget is not None:
yield widget
@overload
def __getitem__(self, index: int) -> DOMNode:
...
@overload
def __getitem__(self, index: slice) -> list[DOMNode]:
...
def __getitem__(self, index: int | slice) -> DOMNode | list[DOMNode]:
self._prune()
assert self._widgets is not None
return self._widgets[index]

View File

@@ -9,7 +9,6 @@ from typing import (
TypeVar,
Generic,
Union,
Iterator,
Iterable,
)

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import os
import re
from typing import Callable, Generator
from typing import Any, Callable, Generator
from . import log
from . import events
from ._types import MessageTarget
from ._parser import Awaitable, Parser, TokenCallback
@@ -22,8 +24,17 @@ class XTermParser(Parser[events.Event]):
self.more_data = more_data
self.last_x = 0
self.last_y = 0
self._debug_log_file = (
open("keys.log", "wt") if "TEXTUAL_DEBUG" in os.environ else None
)
super().__init__()
def debug_log(self, *args: Any) -> None:
if self._debug_log_file is not None:
self._debug_log_file.write(" ".join(args) + "\n")
def parse_mouse_code(self, code: str, sender: MessageTarget) -> events.Event | None:
sgr_match = self._re_sgr_mouse.match(code)
if sgr_match:
@@ -71,12 +82,14 @@ class XTermParser(Parser[events.Event]):
while not self.is_eof:
character = yield read1()
# log.debug("character=%r", character)
self.debug_log(f"character={character!r}")
# The more_data is to allow the parse to distinguish between an escape sequence
# and the escape key pressed
if character == ESC and ((yield self.peek_buffer()) or more_data()):
sequence: str = character
while True:
sequence += yield read1()
# log.debug(f"sequence=%r", sequence)
self.debug_log(f"sequence={sequence!r}")
keys = get_ansi_sequence(sequence, None)
if keys is not None:
for key in keys:

View File

@@ -2,29 +2,38 @@ from __future__ import annotations
import os
import asyncio
from functools import partial
from typing import Any, Callable, ClassVar, Type, TypeVar
from typing import Any, Callable, ClassVar, Iterable, Type, TypeVar
import warnings
from rich.control import Control
import rich.repr
from rich.screen import Screen
from rich.console import Console, RenderableType
from rich.measure import Measurement
from rich.traceback import Traceback
from . import events
from . import actions
from .dom import DOMNode
from ._animator import Animator
from .binding import Bindings, NoBinding
from .geometry import Offset, Region
from .geometry import Offset, Region, Size
from . import log
from ._callback import invoke
from ._context import active_app
from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError
from ._event_broker import extract_handler_actions, NoHandler
from .driver import Driver
from .file_monitor import FileMonitor
from .layouts.dock import DockLayout, Dock
from ._linux_driver import LinuxDriver
from ._types import MessageTarget
from . import messages
from .message_pump import MessagePump
from ._profile import timer
from .view import View
@@ -48,15 +57,19 @@ else:
uvloop.install()
class AppError(Exception):
pass
class ActionError(Exception):
pass
@rich.repr.auto
class App(MessagePump):
class App(DOMNode):
"""The base class for Textual Applications"""
KEYS: ClassVar[dict[str, str]] = {}
css = ""
def __init__(
self,
@@ -66,6 +79,9 @@ class App(MessagePump):
log: str = "",
log_verbosity: int = 1,
title: str = "Textual Application",
css_file: str | None = None,
css: str | None = None,
watch_css: bool = True,
):
"""The Textual Application base class
@@ -81,8 +97,7 @@ class App(MessagePump):
self.driver_class = driver_class or LinuxDriver
self._title = title
self._layout = DockLayout()
self._view_stack: list[DockView] = []
self.children: set[MessagePump] = set()
self._view_stack: list[View] = []
self.focused: Widget | None = None
self.mouse_over: Widget | None = None
@@ -104,6 +119,19 @@ class App(MessagePump):
self.bindings.bind("ctrl+c", "quit", show=False, allow_forward=False)
self._refresh_required = False
self.stylesheet = Stylesheet()
self.css_file = css_file
self.css_monitor = (
FileMonitor(css_file, self._on_css_change)
if (watch_css and css_file)
else None
)
if css is not None:
self.css = css
self.registry: set[MessagePump] = set()
super().__init__()
title: Reactive[str] = Reactive("Textual")
@@ -121,9 +149,17 @@ class App(MessagePump):
return self._animator
@property
def view(self) -> DockView:
def view(self) -> View:
return self._view_stack[-1]
@property
def css_type(self) -> str:
return "app"
@property
def size(self) -> Size:
return Size(*self.console.size)
def log(self, *args: Any, verbosity: int = 1, **kwargs) -> None:
"""Write to logs.
@@ -144,7 +180,7 @@ class App(MessagePump):
except Exception:
pass
async def bind(
def bind(
self,
keys: str,
action: str,
@@ -187,8 +223,27 @@ class App(MessagePump):
asyncio.run(run_app())
async def _on_css_change(self) -> None:
if self.css_file is not None:
stylesheet = Stylesheet()
try:
self.log("loading", self.css_file)
stylesheet.read(self.css_file)
except StylesheetError as error:
self.log(error)
self.console.bell()
else:
self.reset_styles()
self.stylesheet = stylesheet
self.stylesheet.update(self)
self.view.refresh(layout=True)
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.view, *anon_widgets, **widgets)
self.view.refresh()
async def push_view(self, view: ViewType) -> ViewType:
self.register(view, self)
self._view_stack.append(view)
return view
@@ -263,61 +318,112 @@ class App(MessagePump):
self._exit_renderables.extend(renderables)
self.close_messages_no_wait()
def _print_error_renderables(self) -> None:
for renderable in self._exit_renderables:
self.error_console.print(renderable)
self._exit_renderables.clear()
async def process_messages(self) -> None:
active_app.set(self)
log("---")
log(f"driver={self.driver_class}")
load_event = events.Load(sender=self)
await self.dispatch_message(load_event)
await self.post_message(events.Mount(self))
await self.push_view(DockView())
# Wait for the load event to be processed, so we don't go in to application mode beforehand
await load_event.wait()
driver = self._driver = self.driver_class(self.console, self)
try:
if self.css_file is not None:
self.stylesheet.read(self.css_file)
if self.css is not None:
self.stylesheet.parse(self.css, path=f"<{self.__class__.__name__}>")
except StylesheetParseError as error:
self.panic(error)
self._print_error_renderables()
return
except Exception as error:
self.panic()
self._print_error_renderables()
return
if self.css_monitor:
self.set_interval(0.5, self.css_monitor)
self.log("started", self.css_monitor)
self._running = True
try:
load_event = events.Load(sender=self)
await self.dispatch_message(load_event)
# Wait for the load event to be processed, so we don't go in to application mode beforehand
# await load_event.wait()
driver = self._driver = self.driver_class(self.console, self)
driver.start_application_mode()
except Exception:
self.console.print_exception()
else:
try:
mount_event = events.Mount(sender=self)
await self.dispatch_message(mount_event)
self.title = self._title
self.refresh()
await self.animator.start()
await super().process_messages()
log("PROCESS END")
await self.animator.stop()
await self.close_all()
except Exception:
self.panic()
with timer("animator.stop()"):
await self.animator.stop()
with timer("self.close_all()"):
await self.close_all()
finally:
driver.stop_application_mode()
if self._exit_renderables:
for renderable in self._exit_renderables:
self.error_console.print(renderable)
if self.log_file is not None:
self.log_file.close()
except:
self.panic()
finally:
self._running = False
if self._exit_renderables:
self._print_error_renderables()
if self.log_file is not None:
self.log_file.close()
def register(self, child: MessagePump, parent: MessagePump) -> bool:
if child not in self.children:
self.children.add(child)
def _register(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry:
parent.children._append(child)
self.registry.add(child)
child.set_parent(parent)
child.start_messages()
child.post_message_no_wait(events.Mount(sender=parent))
return True
return False
def register(
self, parent: DOMNode, *anon_widgets: Widget, **widgets: Widget
) -> None:
"""Mount widget(s) so they may receive events.
Args:
parent (Widget): Parent Widget
"""
if not anon_widgets and not widgets:
raise AppError(
"Nothing to mount, did you forget parent as first positional arg?"
)
name_widgets: Iterable[tuple[str | None, Widget]]
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
apply_stylesheet = self.stylesheet.apply
for widget_id, widget in name_widgets:
if widget not in self.registry:
if widget_id is not None:
widget.id = widget_id
self._register(parent, widget)
apply_stylesheet(widget)
for _widget_id, widget in name_widgets:
widget.post_message_no_wait(events.Mount(sender=parent))
def is_mounted(self, widget: Widget) -> bool:
return widget in self.registry
async def close_all(self) -> None:
while self.children:
child = self.children.pop()
while self.registry:
child = self.registry.pop()
await child.close_messages()
async def remove(self, child: MessagePump) -> None:
self.children.remove(child)
self.registry.remove(child)
async def shutdown(self):
driver = self._driver
@@ -394,8 +500,14 @@ class App(MessagePump):
async def on_event(self, event: events.Event) -> None:
# Handle input events that haven't been forwarded
# If the event has been forwaded it may have bubbled up back to the App
if isinstance(event, events.InputEvent) and not event.is_forwarded:
# If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Mount):
view = View()
self.register(self, view)
await self.push_view(view)
await super().on_event(event)
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
if isinstance(event, events.MouseEvent):
# Record current mouse position on App
self.mouse_position = Offset(event.x, event.y)
@@ -467,6 +579,15 @@ class App(MessagePump):
return False
return True
async def handle_update(self, message: messages.Update) -> None:
message.stop()
self.app.refresh()
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
await self.view.refresh_layout()
self.app.refresh()
async def on_key(self, event: events.Key) -> None:
await self.press(event.key)
@@ -489,6 +610,18 @@ class App(MessagePump):
async def action_bell(self) -> None:
self.console.bell()
async def action_add_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).add_class(class_name)
self.view.refresh(layout=True)
async def action_remove_class_(self, selector: str, class_name: str) -> None:
self.view.query(selector).remove_class(class_name)
self.view.refresh(layout=True)
async def action_toggle_class(self, selector: str, class_name: str) -> None:
self.view.query(selector).toggle_class(class_name)
self.view.refresh(layout=True)
if __name__ == "__main__":
import asyncio

3
src/textual/css/Makefile Normal file
View File

@@ -0,0 +1,3 @@
parser.py:
python -m lark.tools.standalone css.g > parser.py
black parser.py

View File

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Iterable
def friendly_list(words: Iterable[str], joiner: str = "or") -> str:
"""Generate a list of words as readable prose.
>>> friendly_list(["foo", "bar", "baz"])
"'foo', 'bar', or 'baz'"
Args:
words (Iterable[str]): A list of words.
joiner (str, optional): The last joiner word. Defaults to "or".
Returns:
str: List as prose.
"""
words = [repr(word) for word in sorted(words, key=str.lower)]
if len(words) == 1:
return words[0]
elif len(words) == 2:
word1, word2 = words
return f"{word1} {joiner} {word2}"
else:
return f'{", ".join(words[:-1])}, {joiner} {words[-1]}'

View File

@@ -0,0 +1,473 @@
"""
Style properties are descriptors which allow the Styles object to accept different types when
setting attributes. This gives the developer more freedom in how to express style information.
Descriptors also play nicely with Mypy, which is aware that attributes can have different types
when setting and getting.
"""
from __future__ import annotations
from typing import Iterable, NamedTuple, Sequence, TYPE_CHECKING
import rich.repr
from rich.color import Color
from rich.style import Style
from .scalar import (
get_symbols,
UNIT_SYMBOL,
Unit,
Scalar,
ScalarOffset,
ScalarParseError,
)
from ..geometry import Spacing, SpacingDimensions
from .constants import NULL_SPACING
from .errors import StyleTypeError, StyleValueError
from .transition import Transition
from ._error_tools import friendly_list
if TYPE_CHECKING:
from .styles import Styles
from .styles import DockGroup
class ScalarProperty:
def __init__(
self, units: set[Unit] | None = None, percent_unit: Unit = Unit.WIDTH
) -> None:
self.units: set[Unit] = units or {*UNIT_SYMBOL}
self.percent_unit = percent_unit
super().__init__()
def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name
self.internal_name = f"_rule_{name}"
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> Scalar | None:
value = getattr(obj, self.internal_name)
return value
def __set__(
self, obj: Styles, value: float | Scalar | str | None
) -> float | Scalar | str | None:
new_value: Scalar | None = None
if value is None:
new_value = None
elif isinstance(value, float):
new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH)
elif isinstance(value, Scalar):
new_value = value
elif isinstance(value, str):
try:
new_value = Scalar.parse(value)
except ScalarParseError:
raise StyleValueError("unable to parse scalar from {value!r}")
else:
raise StyleValueError("expected float, Scalar, or None")
if new_value is not None and new_value.unit not in self.units:
raise StyleValueError(
f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}"
)
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
class BoxProperty:
DEFAULT = ("", Style())
def __set_name__(self, owner: Styles, name: str) -> None:
self.internal_name = f"_rule_{name}"
_type, edge = name.split("_")
self._type = _type
self.edge = edge
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> tuple[str, Style]:
value = getattr(obj, self.internal_name)
return value or self.DEFAULT
def __set__(
self, obj: Styles, border: tuple[str, str | Color | Style] | None
) -> tuple[str, str | Color | Style] | None:
if border is None:
new_value = None
else:
_type, color = border
if isinstance(color, str):
new_value = (_type, Style.parse(color))
elif isinstance(color, Color):
new_value = (_type, Style.from_color(color))
else:
new_value = (_type, Style.from_color(Color.parse(color)))
setattr(obj, self.internal_name, new_value)
obj.refresh()
return border
@rich.repr.auto
class Edges(NamedTuple):
"""Stores edges for border / outline."""
top: tuple[str, Style]
right: tuple[str, Style]
bottom: tuple[str, Style]
left: tuple[str, Style]
def __rich_repr__(self) -> rich.repr.Result:
top, right, bottom, left = self
if top[0]:
yield "top", top
if right[0]:
yield "right", right
if bottom[0]:
yield "bottom", bottom
if left[0]:
yield "left", left
def spacing(self) -> tuple[int, int, int, int]:
"""Get spacing created by borders.
Returns:
tuple[int, int, int, int]: Spacing for top, right, bottom, and left.
"""
top, right, bottom, left = self
return (
1 if top[0] else 0,
1 if right[0] else 0,
1 if bottom[0] else 0,
1 if left[0] else 0,
)
class BorderProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._properties = (
f"{name}_top",
f"{name}_right",
f"{name}_bottom",
f"{name}_left",
)
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Edges:
top, right, bottom, left = self._properties
border = Edges(
getattr(obj, top),
getattr(obj, right),
getattr(obj, bottom),
getattr(obj, left),
)
return border
def __set__(
self,
obj: Styles,
border: Sequence[tuple[str, str | Color | Style] | None]
| tuple[str, str | Color | Style]
| None,
) -> None:
top, right, bottom, left = self._properties
obj.refresh()
if border is None:
setattr(obj, top, None)
setattr(obj, right, None)
setattr(obj, bottom, None)
setattr(obj, left, None)
return
if isinstance(border, tuple):
setattr(obj, top, border)
setattr(obj, right, border)
setattr(obj, bottom, border)
setattr(obj, left, border)
return
count = len(border)
if count == 1:
_border = border[0]
setattr(obj, top, _border)
setattr(obj, right, _border)
setattr(obj, bottom, _border)
setattr(obj, left, _border)
elif count == 2:
_border1, _border2 = border
setattr(obj, top, _border1)
setattr(obj, right, _border1)
setattr(obj, bottom, _border2)
setattr(obj, left, _border2)
elif count == 4:
_border1, _border2, _border3, _border4 = border
setattr(obj, top, _border1)
setattr(obj, right, _border2)
setattr(obj, bottom, _border3)
setattr(obj, left, _border4)
else:
raise StyleValueError("expected 1, 2, or 4 values")
class StyleProperty:
DEFAULT_STYLE = Style()
def __set_name__(self, owner: Styles, name: str) -> None:
self._color_name = f"_rule_{name}_color"
self._bgcolor_name = f"_rule_{name}_background"
self._style_name = f"_rule_{name}_style"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Style:
color = getattr(obj, self._color_name)
bgcolor = getattr(obj, self._bgcolor_name)
style = Style.from_color(color, bgcolor)
style_flags = getattr(obj, self._style_name)
if style_flags is not None:
style += Style.parse(style_flags)
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)
setattr(obj, self._style_name, None)
elif isinstance(style, Style):
setattr(obj, self._color_name, style.color)
setattr(obj, self._bgcolor_name, style.bgcolor)
setattr(obj, self._style_name, str(style.without_color))
elif isinstance(style, str):
new_style = Style.parse(style)
setattr(obj, self._color_name, new_style.color)
setattr(obj, self._bgcolor_name, new_style.bgcolor)
style_str = str(new_style.without_color)
setattr(obj, self._style_name, style_str if style_str != "none" else "")
return style
class SpacingProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Spacing:
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
class DocksProperty:
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> tuple[DockGroup, ...]:
return obj._rule_docks or ()
def __set__(
self, obj: Styles, docks: Iterable[DockGroup] | None
) -> Iterable[DockGroup] | None:
obj.refresh(True)
if docks is None:
obj._rule_docks = None
else:
obj._rule_docks = tuple(docks)
return docks
class DockProperty:
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str:
return obj._rule_dock or ""
def __set__(self, obj: Styles, spacing: str | None) -> str | None:
obj.refresh(True)
obj._rule_dock = spacing
return spacing
class OffsetProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> ScalarOffset:
return getattr(obj, self._internal_name) or ScalarOffset(
Scalar.from_number(0), Scalar.from_number(0)
)
def __set__(
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)
if isinstance(x, str)
else Scalar(float(x), Unit.CELLS, Unit.WIDTH)
)
scalar_y = (
Scalar.parse(y, Unit.HEIGHT)
if isinstance(y, str)
else Scalar(float(y), Unit.CELLS, Unit.HEIGHT)
)
_offset = ScalarOffset(scalar_x, scalar_y)
setattr(obj, self._internal_name, _offset)
return offset
class IntegerProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> int:
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)
return value
class StringProperty:
def __init__(self, valid_values: set[str], default: str) -> None:
self._valid_values = valid_values
self._default = default
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str:
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(
f"{self._name} must be one of {friendly_list(self._valid_values)}"
)
setattr(obj, self._internal_name, value)
return value
class NameProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None) -> str:
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)
return name
class NameListProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> tuple[str, ...]:
return getattr(obj, self._internal_name, None) or ()
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(" "))
elif isinstance(names, tuple):
names_value = names
elif names is None:
names_value = None
setattr(obj, self._internal_name, names_value)
return names
class ColorProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Color:
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:
if isinstance(color, Color):
setattr(self, self._internal_name, color)
elif isinstance(color, str):
new_color = Color.parse(color)
setattr(self, self._internal_name, new_color)
return color
class StyleFlagsProperty:
_VALID_PROPERTIES = {
"not",
"bold",
"italic",
"underline",
"overline",
"strike",
"b",
"i",
"u",
"o",
}
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Style:
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:
words = [word.strip() for word in style_flags.split(" ")]
valid_word = self._VALID_PROPERTIES.__contains__
for word in words:
if not valid_word(word):
raise StyleValueError(f"unknown word {word!r} in style flags")
style = Style.parse(" ".join(words))
setattr(obj, self._internal_name, style)
return style_flags
class TransitionsProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self._name = name
self._internal_name = f"_rule_{name}"
def __get__(
self, obj: Styles, objtype: type[Styles] | None = None
) -> dict[str, Transition]:
return getattr(obj, self._internal_name, None) or {}

View File

@@ -0,0 +1,426 @@
"""
The StylesBuilder object takes tokens parsed from the CSS and converts
to the appropriate internal types.
"""
from __future__ import annotations
from typing import cast, Iterable, NoReturn
import rich.repr
from rich.color import Color
from rich.style import Style
from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY
from .errors import DeclarationError
from ._error_tools import friendly_list
from .._easing import EASING
from ..geometry import Spacing, SpacingDimensions
from .model import Declaration
from .scalar import Scalar, ScalarOffset, Unit, ScalarError
from .styles import DockGroup, Styles
from .types import Edge, Display, Visibility
from .tokenize import Token
from .transition import Transition
class StylesBuilder:
def __init__(self) -> None:
self.styles = Styles()
def __rich_repr__(self) -> rich.repr.Result:
yield "styles", self.styles
def __repr__(self) -> str:
return "StylesBuilder()"
def error(self, name: str, token: Token, message: str) -> NoReturn:
raise DeclarationError(name, token, message)
def add_declaration(self, declaration: Declaration) -> None:
if not declaration.tokens:
return
process_method = getattr(
self, f"process_{declaration.name.replace('-', '_')}", None
)
if process_method is None:
self.error(
declaration.name,
declaration.token,
f"unknown declaration {declaration.name!r}",
)
else:
tokens = declaration.tokens
if tokens[-1].name == "important":
tokens = tokens[:-1]
self.styles.important.add(declaration.name)
try:
process_method(declaration.name, tokens)
except DeclarationError as error:
raise
except Exception as error:
self.error(declaration.name, declaration.token, str(error))
def process_display(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
name, value, _, _, location = token
if name == "token":
value = value.lower()
if value in VALID_DISPLAY:
self.styles._rule_display = cast(Display, value)
else:
self.error(
name,
token,
f"invalid value for display (received {value!r}, expected {friendly_list(VALID_DISPLAY)})",
)
else:
self.error(name, token, f"invalid token {value!r} in this context")
def _process_scalar(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) == 1:
setattr(self.styles, name, Scalar.parse(tokens[0].value))
else:
self.error(name, tokens[0], "a single scalar is expected")
def process_width(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_height(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_min_width(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_min_height(self, name: str, tokens: list[Token]) -> None:
self._process_scalar(name, tokens)
def process_visibility(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
name, value, _, _, location = token
if name == "token":
value = value.lower()
if value in VALID_VISIBILITY:
self.styles._rule_visibility = cast(Visibility, value)
else:
self.error(
name,
token,
f"invalid value for visibility (received {value!r}, expected {friendly_list(VALID_VISIBILITY)})",
)
else:
self.error(name, token, f"invalid token {value!r} in this context")
def _process_space(self, name: str, tokens: list[Token]) -> None:
space: list[int] = []
append = space.append
for token in tokens:
(token_name, value, _, _, location) = token
if token_name == "scalar":
try:
append(int(value))
except ValueError:
self.error(name, token, f"expected a number here; found {value!r}")
else:
self.error(name, token, f"unexpected token {value!r} in declaration")
if len(space) not in (1, 2, 4):
self.error(
name, tokens[0], f"1, 2, or 4 values expected; received {len(space)}"
)
setattr(
self.styles,
f"_rule_{name}",
Spacing.unpack(cast(SpacingDimensions, tuple(space))),
)
def process_padding(self, name: str, tokens: list[Token]) -> None:
self._process_space(name, tokens)
def process_margin(self, name: str, tokens: list[Token]) -> None:
self._process_space(name, tokens)
def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Style]:
style = Style()
border_type = "solid"
style_tokens: list[str] = []
append = style_tokens.append
for token in tokens:
token_name, value, _, _, _ = token
if token_name == "token":
if value in VALID_BORDER:
border_type = value
else:
append(value)
elif token_name == "color":
append(value)
else:
self.error(name, token, f"unexpected token {value!r} in declaration")
style_definition = " ".join(style_tokens)
try:
style = Style.parse(style_definition)
except Exception as error:
self.error(name, tokens[0], f"error in {name} declaration; {error}")
return (border_type, style)
def _process_border(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border("border", tokens)
setattr(self.styles, f"_rule_border_{edge}", border)
def process_border(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border("border", tokens)
styles = self.styles
styles._rule_border_top = styles._rule_border_right = border
styles._rule_border_bottom = styles._rule_border_left = border
def process_border_top(self, name: str, tokens: list[Token]) -> None:
self._process_border("top", name, tokens)
def process_border_right(self, name: str, tokens: list[Token]) -> None:
self._process_border("right", name, tokens)
def process_border_bottom(self, name: str, tokens: list[Token]) -> None:
self._process_border("bottom", name, tokens)
def process_border_left(self, name: str, tokens: list[Token]) -> None:
self._process_border("left", name, tokens)
def _process_outline(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border("outline", tokens)
setattr(self.styles, f"_rule_outline_{edge}", border)
def process_outline(self, name: str, tokens: list[Token]) -> None:
border = self._parse_border("outline", tokens)
styles = self.styles
styles._rule_outline_top = styles._rule_outline_right = border
styles._rule_outline_bottom = styles._rule_outline_left = border
def process_outline_top(self, name: str, tokens: list[Token]) -> None:
self._process_outline("top", name, tokens)
def process_parse_border_right(self, name: str, tokens: list[Token]) -> None:
self._process_outline("right", name, tokens)
def process_outline_bottom(self, name: str, tokens: list[Token]) -> None:
self._process_outline("bottom", name, tokens)
def process_outline_left(self, name: str, tokens: list[Token]) -> None:
self._process_outline("left", name, tokens)
def process_offset(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 2:
self.error(name, tokens[0], "expected two numbers in declaration")
else:
token1, token2 = tokens
if token1.name != "scalar":
self.error(name, token1, f"expected a scalar; found {token1.value!r}")
if token2.name != "scalar":
self.error(name, token2, f"expected a scalar; found {token1.value!r}")
scalar_x = Scalar.parse(token1.value, Unit.WIDTH)
scalar_y = Scalar.parse(token2.value, Unit.HEIGHT)
self.styles._rule_offset = ScalarOffset(scalar_x, scalar_y)
def process_offset_x(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 1:
self.error(name, tokens[0], f"expected a single number")
else:
token = tokens[0]
if token.name != "scalar":
self.error(name, token, f"expected a scalar; found {token.value!r}")
x = Scalar.parse(token.value, Unit.WIDTH)
y = self.styles.offset.y
self.styles._rule_offset = ScalarOffset(x, y)
def process_offset_y(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 1:
self.error(name, tokens[0], f"expected a single number")
else:
token = tokens[0]
if token.name != "scalar":
self.error(name, token, f"expected a scalar; found {token.value!r}")
y = Scalar.parse(token.value, Unit.HEIGHT)
x = self.styles.offset.x
self.styles._rule_offset = ScalarOffset(x, y)
def process_layout(self, name: str, tokens: list[Token]) -> None:
if tokens:
if len(tokens) != 1:
self.error(name, tokens[0], "unexpected tokens in declaration")
else:
self.styles._rule_layout = tokens[0].value
def process_text(self, name: str, tokens: list[Token]) -> None:
style_definition = " ".join(token.value for token in tokens)
try:
style = Style.parse(style_definition)
except Exception as error:
self.error(name, tokens[0], f"failed to parse style; {error}")
self.styles.text = style
def process_text_color(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rule_text_color = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
)
else:
self.error(
name, token, f"unexpected token {token.value!r} in declaration"
)
def process_text_background(self, name: str, tokens: list[Token]) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rule_text_background = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
)
else:
self.error(
name, token, f"unexpected token {token.value!r} in declaration"
)
def process_text_style(self, name: str, tokens: list[Token]) -> None:
style_definition = " ".join(token.value for token in tokens)
self.styles.text_style = style_definition
def process_dock(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(
name,
tokens[1],
f"unexpected tokens in dock declaration",
)
self.styles._rule_dock = tokens[0].value if tokens else ""
def process_docks(self, name: str, tokens: list[Token]) -> None:
docks: list[DockGroup] = []
for token in tokens:
if token.name == "key_value":
key, edge_name = token.value.split("=")
edge_name = edge_name.strip().lower()
edge_name, _, number = edge_name.partition("/")
z = 0
if number:
if not number.isdigit():
self.error(
name, token, f"expected integer after /, found {number!r}"
)
z = int(number)
if edge_name not in VALID_EDGE:
self.error(
name,
token,
f"edge must be one of 'top', 'right', 'bottom', or 'left'; found {edge_name!r}",
)
docks.append(DockGroup(key.strip(), cast(Edge, edge_name), z))
elif token.name == "bar":
pass
else:
self.error(
name,
token,
f"unexpected token {token.value!r} in docks declaration",
)
self.styles._rule_docks = tuple(docks + [DockGroup("_default", "top", 0)])
def process_layer(self, name: str, tokens: list[Token]) -> None:
if len(tokens) > 1:
self.error(name, tokens[1], f"unexpected tokens in dock-edge declaration")
self.styles._rule_layer = tokens[0].value
def process_layers(self, name: str, tokens: list[Token]) -> None:
layers: list[str] = []
for token in tokens:
if token.name != "token":
self.error(name, token, "{token.name} not expected here")
layers.append(token.value)
self.styles._rule_layers = tuple(layers)
def process_transition(self, name: str, tokens: list[Token]) -> None:
transitions: dict[str, Transition] = {}
css_property = ""
duration = 1.0
easing = "linear"
delay = 0.0
iter_tokens = iter(tokens)
def make_groups() -> Iterable[list[Token]]:
"""Batch tokens in to comma-separated groups."""
group: list[Token] = []
for token in tokens:
if token.name == "comma":
if group:
yield group
group = []
else:
group.append(token)
if group:
yield group
for tokens in make_groups():
css_property = ""
duration = 1.0
easing = "linear"
delay = 0.0
try:
iter_tokens = iter(tokens)
token = next(iter_tokens)
if token.name != "token":
self.error(name, token, "expected property")
css_property = token.value
token = next(iter_tokens)
if token.name != "scalar":
self.error(name, token, "expected time")
try:
duration = Scalar.parse(token.value).resolve_time()
except ScalarError as error:
self.error(name, token, str(error))
token = next(iter_tokens)
if token.name != "token":
self.error(name, token, "easing function expected")
if token.value not in EASING:
self.error(
name,
token,
f"expected easing function; found {token.value!r}",
)
easing = token.value
token = next(iter_tokens)
if token.name != "scalar":
self.error(name, token, "expected time")
try:
delay = Scalar.parse(token.value).resolve_time()
except ScalarError as error:
self.error(name, token, str(error))
except StopIteration:
pass
transitions[css_property] = Transition(duration, easing, delay)
self.styles._rule_transitions = transitions

View File

@@ -0,0 +1,28 @@
import sys
if sys.version_info >= (3, 8):
from typing import Final
else:
from typing_extensions import Final # pragma: no cover
from ..geometry import Spacing
VALID_VISIBILITY: Final = {"visible", "hidden"}
VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final = {
"none",
"round",
"solid",
"double",
"dashed",
"heavy",
"inner",
"outer",
"hkey",
"vkey",
}
VALID_EDGE: Final = {"top", "right", "bottom", "left"}
VALID_LAYOUT: Final = {"dock", "vertical", "grid"}
NULL_SPACING: Final = Spacing(0, 0, 0, 0)

21
src/textual/css/errors.py Normal file
View File

@@ -0,0 +1,21 @@
from .tokenize import Token
class DeclarationError(Exception):
def __init__(self, name: str, token: Token, message: str) -> None:
self.name = name
self.token = token
self.message = message
super().__init__(message)
class StyleTypeError(TypeError):
pass
class StyleValueError(ValueError):
pass
class StylesheetError(Exception):
pass

73
src/textual/css/match.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING
from .model import CombinatorType, Selector, SelectorSet, SelectorType
if TYPE_CHECKING:
from ..dom import DOMNode
def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool:
"""Check if a given selector matches any of the given selector sets.
Args:
selector_sets (Iterable[SelectorSet]): Iterable of selector sets.
node (DOMNode): DOM node.
Returns:
bool: True if the node matches the selector, otherwise False.
"""
return any(
_check_selectors(selector_set.selectors, node) for selector_set in selector_sets
)
def _check_selectors(selectors: list[Selector], node: DOMNode) -> bool:
"""Match a list of selectors against a node.
Args:
selectors (list[Selector]): A list of selectors.
node (DOMNode): A DOM node.
Returns:
bool: True if the node matches the selector.
"""
DESCENDENT = CombinatorType.DESCENDENT
css_path = node.css_path
path_count = len(css_path)
selector_count = len(selectors)
stack: list[tuple[int, int]] = [(0, 0)]
push = stack.append
pop = stack.pop
selector_index = 0
while stack:
selector_index, node_index = stack[-1]
if selector_index == selector_count or node_index == path_count:
pop()
else:
path_node = css_path[node_index]
selector = selectors[selector_index]
if selector.combinator == DESCENDENT:
# Find a matching descendent
if selector.check(path_node):
if path_node is node and selector_index == selector_count - 1:
return True
stack[-1] = (selector_index + 1, node_index + selector.advance)
push((selector_index, node_index + 1))
else:
stack[-1] = (selector_index, node_index + 1)
else:
# Match the next node
if selector.check(path_node):
if path_node is node and selector_index == selector_count - 1:
return True
stack[-1] = (selector_index + 1, node_index + selector.advance)
else:
pop()
return False

176
src/textual/css/model.py Normal file
View File

@@ -0,0 +1,176 @@
from __future__ import annotations
import rich.repr
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable
from .. import log
from ..dom import DOMNode
from .styles import Styles
from .tokenize import Token
from .types import Specificity3
class SelectorType(Enum):
UNIVERSAL = 1
TYPE = 2
CLASS = 3
ID = 4
class CombinatorType(Enum):
SAME = 1
DESCENDENT = 2
CHILD = 3
@dataclass
class Location:
line: tuple[int, int]
column: tuple[int, int]
@dataclass
class Selector:
name: str
combinator: CombinatorType = CombinatorType.DESCENDENT
type: SelectorType = SelectorType.TYPE
pseudo_classes: list[str] = field(default_factory=list)
specificity: Specificity3 = field(default_factory=lambda: (0, 0, 0))
_name_lower: str = field(default="", repr=False)
advance: int = 1
@property
def css(self) -> str:
psuedo_suffix = "".join(f":{name}" for name in self.pseudo_classes)
if self.type == SelectorType.UNIVERSAL:
return "*"
elif self.type == SelectorType.TYPE:
return f"{self.name}{psuedo_suffix}"
elif self.type == SelectorType.CLASS:
return f".{self.name}{psuedo_suffix}"
else:
return f"#{self.name}{psuedo_suffix}"
def __post_init__(self) -> None:
self._name_lower = self.name.lower()
self._checks = {
SelectorType.UNIVERSAL: self._check_universal,
SelectorType.TYPE: self._check_type,
SelectorType.CLASS: self._check_class,
SelectorType.ID: self._check_id,
}
def check(self, node: DOMNode) -> bool:
return self._checks[self.type](node)
def _check_universal(self, node: DOMNode) -> bool:
return True
def _check_type(self, node: DOMNode) -> bool:
if node.css_type != self._name_lower:
return False
if self.pseudo_classes and not node.has_psuedo_class(*self.pseudo_classes):
return False
return True
def _check_class(self, node: DOMNode) -> bool:
if not node.has_class(self._name_lower):
return False
if self.pseudo_classes and not node.has_psuedo_class(*self.pseudo_classes):
return False
return True
def _check_id(self, node: DOMNode) -> bool:
if not node.id == self._name_lower:
return False
if self.pseudo_classes and not node.has_psuedo_class(*self.pseudo_classes):
return False
return True
@dataclass
class Declaration:
token: Token
name: str
tokens: list[Token] = field(default_factory=list)
@rich.repr.auto(angular=True)
@dataclass
class SelectorSet:
selectors: list[Selector] = field(default_factory=list)
specificity: Specificity3 = (0, 0, 0)
def __post_init__(self) -> None:
SAME = CombinatorType.SAME
for selector, next_selector in zip(self.selectors, self.selectors[1:]):
selector.advance = int(next_selector.combinator != SAME)
def __rich_repr__(self) -> rich.repr.Result:
selectors = RuleSet._selector_to_css(self.selectors)
yield selectors
yield None, self.specificity
@classmethod
def from_selectors(cls, selectors: list[list[Selector]]) -> Iterable[SelectorSet]:
for selector_list in selectors:
id_total = class_total = type_total = 0
for selector in selector_list:
_id, _class, _type = selector.specificity
id_total += _id
class_total += _class
type_total += _type
yield SelectorSet(selector_list, (id_total, class_total, type_total))
@dataclass
class RuleSet:
selector_set: list[SelectorSet] = field(default_factory=list)
styles: Styles = field(default_factory=Styles)
errors: list[tuple[Token, str]] = field(default_factory=list)
classes: set[str] = field(default_factory=set)
@classmethod
def _selector_to_css(cls, selectors: list[Selector]) -> str:
tokens: list[str] = []
for selector in selectors:
if selector.combinator == CombinatorType.DESCENDENT:
tokens.append(" ")
elif selector.combinator == CombinatorType.CHILD:
tokens.append(" > ")
tokens.append(selector.css)
return "".join(tokens).strip()
@property
def selectors(self):
return ", ".join(
self._selector_to_css(selector_set.selectors)
for selector_set in self.selector_set
)
@property
def css(self) -> str:
"""Generate the CSS this RuleSet
Returns:
str: A string containing CSS code.
"""
declarations = "\n".join(f" {line}" for line in self.styles.css_lines)
css = f"{self.selectors} {{\n{declarations}\n}}"
return css
def _post_parse(self) -> None:
"""Called after the RuleSet is parsed."""
# Build a set of the class names that have been updated
update = self.classes.update
class_type = SelectorType.CLASS
for selector_set in self.selector_set:
update(
selector.name
for selector in selector_set.selectors
if selector.type == class_type
)

265
src/textual/css/parse.py Normal file
View File

@@ -0,0 +1,265 @@
from __future__ import annotations
from rich import print
from functools import lru_cache
from typing import Iterator, Iterable
from .styles import Styles
from .tokenize import tokenize, tokenize_declarations, Token
from .tokenizer import EOFError
from .model import (
Declaration,
RuleSet,
Selector,
CombinatorType,
SelectorSet,
SelectorType,
)
from ._styles_builder import StylesBuilder, DeclarationError
SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = {
"selector": (SelectorType.TYPE, (0, 0, 1)),
"selector_start": (SelectorType.TYPE, (0, 0, 1)),
"selector_class": (SelectorType.CLASS, (0, 1, 0)),
"selector_start_class": (SelectorType.CLASS, (0, 1, 0)),
"selector_id": (SelectorType.ID, (1, 0, 0)),
"selector_start_id": (SelectorType.ID, (1, 0, 0)),
"selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
"selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
}
@lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
tokens = iter(tokenize(css_selectors, ""))
get_selector = SELECTOR_MAP.get
combinator: CombinatorType | None = CombinatorType.DESCENDENT
selectors: list[Selector] = []
rule_selectors: list[list[Selector]] = []
while True:
try:
token = next(tokens)
except EOFError:
break
if token.name == "pseudo_class":
selectors[-1].pseudo_classes.append(token.value.lstrip(":"))
elif token.name == "whitespace":
if combinator is None or combinator == CombinatorType.SAME:
combinator = CombinatorType.DESCENDENT
elif token.name == "new_selector":
rule_selectors.append(selectors[:])
selectors.clear()
combinator = None
elif token.name == "declaration_set_start":
break
elif token.name == "combinator_child":
combinator = CombinatorType.CHILD
else:
_selector, specificity = get_selector(
token.name, (SelectorType.TYPE, (0, 0, 0))
)
selectors.append(
Selector(
name=token.value.lstrip(".#"),
combinator=combinator or CombinatorType.DESCENDENT,
type=_selector,
specificity=specificity,
)
)
combinator = CombinatorType.SAME
if selectors:
rule_selectors.append(selectors[:])
selector_set = tuple(SelectorSet.from_selectors(rule_selectors))
return selector_set
def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]:
rule_set = RuleSet()
get_selector = SELECTOR_MAP.get
combinator: CombinatorType | None = CombinatorType.DESCENDENT
selectors: list[Selector] = []
rule_selectors: list[list[Selector]] = []
styles_builder = StylesBuilder()
while True:
if token.name == "pseudo_class":
selectors[-1].pseudo_classes.append(token.value.lstrip(":"))
elif token.name == "whitespace":
if combinator is None or combinator == CombinatorType.SAME:
combinator = CombinatorType.DESCENDENT
elif token.name == "new_selector":
rule_selectors.append(selectors[:])
selectors.clear()
combinator = None
elif token.name == "declaration_set_start":
break
elif token.name == "combinator_child":
combinator = CombinatorType.CHILD
else:
_selector, specificity = get_selector(
token.name, (SelectorType.TYPE, (0, 0, 0))
)
selectors.append(
Selector(
name=token.value.lstrip(".#"),
combinator=combinator or CombinatorType.DESCENDENT,
type=_selector,
specificity=specificity,
)
)
combinator = CombinatorType.SAME
token = next(tokens)
if selectors:
rule_selectors.append(selectors[:])
declaration = Declaration(token, "")
errors: list[tuple[Token, str]] = []
while True:
token = next(tokens)
token_name = token.name
if token_name in ("whitespace", "declaration_end"):
continue
if token_name == "declaration_name":
if 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:
declaration.tokens.append(token)
if declaration.tokens:
try:
styles_builder.add_declaration(declaration)
except DeclarationError as error:
errors.append((error.token, error.message))
rule_set = RuleSet(
list(SelectorSet.from_selectors(rule_selectors)), styles_builder.styles, errors
)
rule_set._post_parse()
yield rule_set
def parse_declarations(css: str, path: str) -> Styles:
"""Parse declarations and return a Styles object.
Args:
css (str): String containing CSS.
path (str): Path to the CSS, or something else to identify the location.
Returns:
Styles: A styles object.
"""
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:
raise
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:
raise
errors.append((error.token, error.message))
return styles_builder.styles
def parse(css: str, path: str) -> Iterable[RuleSet]:
tokens = iter(tokenize(css, path))
while True:
token = next(tokens, None)
if token is None:
break
if token.name.startswith("selector_start"):
yield from parse_rule_set(tokens, token)
# if __name__ == "__main__":
# test = """
# App View {
# text: red;
# }
# .foo.bar baz:focus, #egg .foo.baz {
# /* ignore me, I'm a comment */
# display: block;
# visibility: visible;
# border: solid green !important;
# outline: red;
# padding: 1 2;
# margin: 5;
# text: bold red on magenta
# text-color: green;
# text-background: white
# docks: foo bar bar
# dock-group: foo
# dock-edge: top
# offset-x: 4
# offset-y: 5
# }"""
# from .stylesheet import Stylesheet
# print(test)
# print()
# stylesheet = Stylesheet()
# stylesheet.parse(test)
# print(stylesheet)
# print()
# print(stylesheet.css)
if __name__ == "__main__":
print(parse_selectors("Foo > Bar.baz { foo: bar"))
CSS = """
text: on red;
docksX: main=top;
"""
print(parse_declarations(CSS, "foo"))

99
src/textual/css/query.py Normal file
View File

@@ -0,0 +1,99 @@
"""
A DOMQuery is a set of DOM nodes associated with a given CSS selector.
This set of nodes may be further filtered with the filter method. Additional methods apply
actions to the nodes in the query.
If this sounds like JQuery, a (once) popular JS library, it is no coincidence.
DOMQuery objects are typically created by Widget.filter method.
"""
from __future__ import annotations
import rich.repr
from typing import Iterable, Iterator, TYPE_CHECKING
from .match import match
from .parse import parse_selectors
if TYPE_CHECKING:
from ..dom import DOMNode
@rich.repr.auto(angular=True)
class DOMQuery:
def __init__(
self,
node: DOMNode | None = None,
selector: str | None = None,
nodes: Iterable[DOMNode] | None = None,
) -> None:
self._nodes: list[DOMNode] = []
if nodes is not None:
self._nodes = list(nodes)
elif node is not None:
self._nodes = list(node.walk_children())
else:
self._nodes = []
if selector is not None:
selector_set = parse_selectors(selector)
self._nodes = [_node for _node in self._nodes if match(selector_set, _node)]
def __len__(self) -> int:
return len(self._nodes)
def __bool__(self) -> bool:
"""True if non-empty, otherwise False."""
return bool(self._nodes)
def __iter__(self) -> Iterator[DOMNode]:
return iter(self._nodes)
def __rich_repr__(self) -> rich.repr.Result:
yield self._nodes
def filter(self, selector: str) -> DOMQuery:
"""Filter this set by the given CSS selector.
Args:
selector (str): A CSS selector.
Returns:
DOMQuery: New DOM Query.
"""
selector_set = parse_selectors(selector)
query = DOMQuery()
query._nodes = [_node for _node in self._nodes if match(selector_set, _node)]
return query
def first(self) -> DOMNode:
"""Get the first matched node.
Returns:
DOMNode: A DOM Node.
"""
# TODO: Better response to empty query than an IndexError
return self._nodes[0]
def add_class(self, *class_names: str) -> None:
"""Add the given class name(s) to nodes."""
for node in self._nodes:
node.add_class(*class_names)
def remove_class(self, *class_names: str) -> None:
"""Remove the given class names from the nodes."""
for node in self._nodes:
node.remove_class(*class_names)
def toggle_class(self, *class_names: str) -> None:
"""Toggle the given class names from matched nodes."""
for node in self._nodes:
node.toggle_class(*class_names)

176
src/textual/css/scalar.py Normal file
View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from enum import Enum, unique
import re
from typing import Iterable, NamedTuple, TYPE_CHECKING
import rich.repr
from ..geometry import Offset
class ScalarError(Exception):
pass
class ScalarResolveError(ScalarError):
pass
class ScalarParseError(ScalarError):
pass
@unique
class Unit(Enum):
CELLS = 1
FRACTION = 2
PERCENT = 3
WIDTH = 4
HEIGHT = 5
VIEW_WIDTH = 6
VIEW_HEIGHT = 7
MILLISECONDS = 8
SECONDS = 9
UNIT_SYMBOL = {
Unit.CELLS: "",
Unit.FRACTION: "fr",
Unit.PERCENT: "%",
Unit.WIDTH: "w",
Unit.HEIGHT: "h",
Unit.VIEW_WIDTH: "vw",
Unit.VIEW_HEIGHT: "vh",
Unit.MILLISECONDS: "ms",
Unit.SECONDS: "s",
}
SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()}
_MATCH_SCALAR = re.compile(r"^(\-?\d+\.?\d*)(fr|%|w|h|vw|vh|s|ms)?$").match
RESOLVE_MAP = {
Unit.CELLS: lambda value, size, viewport: value,
Unit.WIDTH: lambda value, size, viewport: size[0] * value / 100,
Unit.HEIGHT: lambda value, size, viewport: size[1] * value / 100,
Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0] * value / 100,
Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1] * value / 100,
}
if TYPE_CHECKING:
from ..widget import Widget
from .styles import Styles
from .._animator import EasingFunction
def get_symbols(units: Iterable[Unit]) -> list[str]:
"""Get symbols for an iterable of units.
Args:
units (Iterable[Unit]): A number of units.
Returns:
list[str]: List of symbols.
"""
return [UNIT_SYMBOL[unit] for unit in units]
class Scalar(NamedTuple):
"""A numeric value and a unit."""
value: float
unit: Unit
percent_unit: Unit
def __str__(self) -> str:
value, _unit, _ = self
return f"{int(value) if value.is_integer() else value}{self.symbol}"
@property
def is_percent(self) -> bool:
return self.unit == Unit.PERCENT
@property
def cells(self) -> int | None:
value, unit, _ = self
return int(value) if unit == Unit.CELLS else None
@property
def fraction(self) -> int | None:
value, unit, _ = self
return int(value) if unit == Unit.FRACTION else None
@property
def symbol(self) -> str:
return UNIT_SYMBOL[self.unit]
@classmethod
def from_number(cls, value: float) -> Scalar:
return cls(float(value), Unit.CELLS, Unit.WIDTH)
@classmethod
def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar:
"""Parse a string in to a Scalar
Args:
token (str): A string containing a scalar, e.g. "3.14fr"
Raises:
ScalarParseError: If the value is not a valid scalar
Returns:
Scalar: New scalar
"""
match = _MATCH_SCALAR(token)
if match is None:
raise ScalarParseError(f"{token!r} is not a valid scalar")
value, unit_name = match.groups()
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
return scalar
def resolve_dimension(
self, size: tuple[int, int], viewport: tuple[int, int]
) -> float:
value, unit, percent_unit = self
if unit == Unit.PERCENT:
unit = percent_unit
try:
return RESOLVE_MAP[unit](value, size, viewport)
except KeyError:
raise ScalarResolveError(f"expected dimensions; found {str(self)!r}")
def resolve_time(self) -> float:
value, unit, _ = self
if unit == Unit.MILLISECONDS:
return value / 1000.0
elif unit == Unit.SECONDS:
return value
raise ScalarResolveError(f"expected time; found {str(self)!r}")
@rich.repr.auto(angular=True)
class ScalarOffset(NamedTuple):
x: Scalar
y: Scalar
def __rich_repr__(self) -> rich.repr.Result:
yield None, str(self.x)
yield None, str(self.y)
def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset:
x, y = self
return Offset(
round(x.resolve_dimension(size, viewport)),
round(y.resolve_dimension(size, viewport)),
)
if __name__ == "__main__":
print(Scalar.parse("3.14fr"))
s = Scalar.parse("23")
print(repr(s))
print(repr(s.cells))

View File

@@ -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

434
src/textual/css/styles.py Normal file
View File

@@ -0,0 +1,434 @@
from __future__ import annotations
from dataclasses import dataclass, field
from functools import lru_cache
import sys
from typing import Any, Iterable, NamedTuple, TYPE_CHECKING
from rich import print
from rich.color import Color
import rich.repr
from rich.style import Style
from .. import log
from .._animator import SimpleAnimation, Animation, EasingFunction
from .._types import MessageTarget
from .errors import StyleValueError
from .. import events
from ._error_tools import friendly_list
from .constants import (
VALID_DISPLAY,
VALID_VISIBILITY,
VALID_LAYOUT,
NULL_SPACING,
)
from .scalar_animation import ScalarAnimation
from ..geometry import NULL_OFFSET, Offset, Spacing
from .scalar import Scalar, ScalarOffset, Unit
from .transition import Transition
from ._style_properties import (
BorderProperty,
BoxProperty,
ColorProperty,
DocksProperty,
DockProperty,
OffsetProperty,
NameProperty,
NameListProperty,
ScalarProperty,
SpacingProperty,
StringProperty,
StyleProperty,
StyleFlagsProperty,
TransitionsProperty,
)
from .types import Display, Edge, Visibility
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
if TYPE_CHECKING:
from ..dom import DOMNode
class DockGroup(NamedTuple):
name: str
edge: Edge
z: int
@rich.repr.auto
@dataclass
class Styles:
node: DOMNode | None = None
_rule_display: Display | None = None
_rule_visibility: Visibility | None = None
_rule_layout: str | None = None
_rule_text_color: Color | None = None
_rule_text_background: Color | None = None
_rule_text_style: Style | None = None
_rule_padding: Spacing | None = None
_rule_margin: Spacing | None = None
_rule_offset: ScalarOffset | None = None
_rule_border_top: tuple[str, Style] | None = None
_rule_border_right: tuple[str, Style] | None = None
_rule_border_bottom: tuple[str, Style] | None = None
_rule_border_left: tuple[str, Style] | None = None
_rule_outline_top: tuple[str, Style] | None = None
_rule_outline_right: tuple[str, Style] | None = None
_rule_outline_bottom: tuple[str, Style] | None = None
_rule_outline_left: tuple[str, Style] | None = None
_rule_width: Scalar | None = None
_rule_height: Scalar | None = None
_rule_min_width: Scalar | None = None
_rule_min_height: Scalar | None = None
_rule_dock: str | None = None
_rule_docks: tuple[DockGroup, ...] | None = None
_rule_layers: tuple[str, ...] | None = None
_rule_layer: str | None = None
_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")
visibility = StringProperty(VALID_VISIBILITY, "visible")
layout = StringProperty(VALID_LAYOUT, "dock")
text = StyleProperty()
text_color = ColorProperty()
text_background = ColorProperty()
text_style = StyleFlagsProperty()
padding = SpacingProperty()
margin = SpacingProperty()
offset = OffsetProperty()
border = BorderProperty()
border_top = BoxProperty()
border_right = BoxProperty()
border_bottom = BoxProperty()
border_left = BoxProperty()
outline = BorderProperty()
outline_top = BoxProperty()
outline_right = BoxProperty()
outline_bottom = BoxProperty()
outline_left = BoxProperty()
width = ScalarProperty(percent_unit=Unit.WIDTH)
height = ScalarProperty(percent_unit=Unit.HEIGHT)
min_width = ScalarProperty(percent_unit=Unit.WIDTH)
min_height = ScalarProperty(percent_unit=Unit.HEIGHT)
dock = DockProperty()
docks = DocksProperty()
layer = NameProperty()
layers = NameListProperty()
transitions = TransitionsProperty()
ANIMATABLE = {
"offset",
"padding",
"margin",
"width",
"height",
"min_width",
"min_height",
}
@property
def gutter(self) -> Spacing:
"""Get the gutter (additional space reserved for margin / padding / border).
Returns:
Spacing: [description]
"""
gutter = self.margin + self.padding + self.border.spacing
return gutter
@classmethod
@lru_cache(maxsize=1024)
def parse(cls, css: str, path: str) -> Styles:
from .parse import parse_declarations
styles = parse_declarations(css, path)
return styles
def __textual_animation__(
self,
attribute: str,
value: Any,
start_time: float,
duration: float | None,
speed: float | None,
easing: EasingFunction,
) -> Animation | None:
from ..widget import Widget
assert isinstance(self.node, Widget)
if isinstance(value, ScalarOffset):
return ScalarAnimation(
self.node,
self,
start_time,
attribute,
value,
duration=duration,
speed=speed,
easing=easing,
)
return None
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)
self._repaint_required = self._layout_required = False
return result
@property
def has_border(self) -> bool:
"""Check in a border is present."""
return any(edge for edge, _style in self.border)
@property
def has_padding(self) -> bool:
return self._rule_padding is not None
@property
def has_margin(self) -> bool:
return self._rule_margin is not None
@property
def has_outline(self) -> bool:
"""Check if an outline is present."""
return any(edge for edge, _style in self.outline)
@property
def has_offset(self) -> bool:
return self._rule_offset is not None
def get_transition(self, key: str) -> Transition | None:
if key in self.ANIMATABLE:
return self.transitions.get(key, None)
else:
return None
def extract_rules(
self, specificity: tuple[int, int, int]
) -> list[tuple[str, tuple[int, int, int, int], Any]]:
is_important = self.important.__contains__
rules = [
(
rule_name,
(int(is_important(rule_name)), *specificity),
getattr(self, f"_rule_{rule_name}"),
)
for rule_name in RULE_NAMES
if getattr(self, f"_rule_{rule_name}") is not None
]
return rules
def apply_rules(self, rules: Iterable[tuple[str, object]], animate: bool = False):
if animate or self.node is None:
for key, value in rules:
setattr(self, f"_rule_{key}", value)
else:
styles = self
is_animatable = styles.ANIMATABLE.__contains__
for key, value in rules:
current = getattr(styles, f"_rule_{key}")
if current == value:
continue
if is_animatable(key):
transition = styles.get_transition(key)
if transition is None:
setattr(styles, f"_rule_{key}", value)
else:
duration, easing, delay = transition
self.node.app.animator.animate(
styles, key, value, duration=duration, easing=easing
)
else:
setattr(styles, f"_rule_{key}", value)
if self.node is not None:
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):
if getattr(self, internal_rule_name) is not None:
yield rule_name, getattr(self, rule_name)
if self.important:
yield "important", self.important
@classmethod
def combine(cls, style1: Styles, style2: Styles) -> Styles:
"""Combine rule with another to produce a new rule.
Args:
style1 (Style): A style.
style2 (Style): Second style.
Returns:
Style: New rule with attributes of style2 overriding style1
"""
result = cls()
for name in INTERNAL_RULE_NAMES:
setattr(result, name, getattr(style1, name) or getattr(style2, name))
return result
@property
def css_lines(self) -> list[str]:
lines: list[str] = []
append = lines.append
def append_declaration(name: str, value: str) -> None:
if name in self.important:
append(f"{name}: {value} !important;")
else:
append(f"{name}: {value};")
if self._rule_display is not None:
append_declaration("display", self._rule_display)
if self._rule_visibility is not None:
append_declaration("visibility", self._rule_visibility)
if self._rule_padding is not None:
append_declaration("padding", self._rule_padding.packed)
if self._rule_margin is not None:
append_declaration("margin", self._rule_margin.packed)
if (
self._rule_border_top is not None
and self._rule_border_top == self._rule_border_right
and self._rule_border_right == self._rule_border_bottom
and self._rule_border_bottom == self._rule_border_left
):
_type, style = self._rule_border_top
append_declaration("border", f"{_type} {style}")
else:
if self._rule_border_top is not None:
_type, style = self._rule_border_top
append_declaration("border-top", f"{_type} {style}")
if self._rule_border_right is not None:
_type, style = self._rule_border_right
append_declaration("border-right", f"{_type} {style}")
if self._rule_border_bottom is not None:
_type, style = self._rule_border_bottom
append_declaration("border-bottom", f"{_type} {style}")
if self._rule_border_left is not None:
_type, style = self._rule_border_left
append_declaration("border-left", f"{_type} {style}")
if (
self._rule_outline_top is not None
and self._rule_outline_top == self._rule_outline_right
and self._rule_outline_right == self._rule_outline_bottom
and self._rule_outline_bottom == self._rule_outline_left
):
_type, style = self._rule_outline_top
append_declaration("outline", f"{_type} {style}")
else:
if self._rule_outline_top is not None:
_type, style = self._rule_outline_top
append_declaration("outline-top", f"{_type} {style}")
if self._rule_outline_right is not None:
_type, style = self._rule_outline_right
append_declaration("outline-right", f"{_type} {style}")
if self._rule_outline_bottom is not None:
_type, style = self._rule_outline_bottom
append_declaration("outline-bottom", f"{_type} {style}")
if self._rule_outline_left is not None:
_type, style = self._rule_outline_left
append_declaration("outline-left", f"{_type} {style}")
if self.offset:
x, y = self.offset
append_declaration("offset", f"{x} {y}")
if self._rule_dock:
append_declaration("dock-group", self._rule_dock)
if self._rule_docks:
append_declaration(
"docks",
" ".join(
(f"{name}={edge}/{z}" if z else f"{name}={edge}")
for name, edge, z in self._rule_docks
),
)
if self._rule_layers is not None:
append_declaration("layers", " ".join(self.layers))
if self._rule_layer is not None:
append_declaration("layer", self.layer)
if self._rule_text_color or self._rule_text_background or self._rule_text_style:
append_declaration("text", str(self.text))
if self._rule_width is not None:
append_declaration("width", str(self.width))
if self._rule_height is not None:
append_declaration("height", str(self.height))
if self._rule_min_width is not None:
append_declaration("min-width", str(self.min_width))
if self._rule_min_height is not None:
append_declaration("min-height", str(self.min_height))
if self._rule_transitions is not None:
append_declaration(
"transition",
", ".join(
f"{name} {transition}"
for name, transition in self.transitions.items()
),
)
lines.sort()
return lines
@property
def css(self) -> str:
return "\n".join(self.css_lines)
RULE_NAMES = [name[6:] for name in dir(Styles) if name.startswith("_rule_")]
INTERNAL_RULE_NAMES = [f"_rule_{name}" for name in RULE_NAMES]
if __name__ == "__main__":
styles = Styles()
styles.display = "none"
styles.visibility = "hidden"
styles.border = ("solid", "rgb(10,20,30)")
styles.outline_right = ("solid", "red")
styles.docks = "foo bar"
styles.text_style = "italic"
styles.dock = "bar"
styles.layers = "foo bar"
from rich import inspect, print
print(styles.text_style)
print(styles.text)
print(styles)
print(styles.css)
print(styles.extract_rules((0, 1, 0)))

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from collections import defaultdict
from operator import itemgetter
import os
from typing import Iterable, TYPE_CHECKING
from rich.console import RenderableType
import rich.repr
from rich.highlighter import ReprHighlighter
from rich.panel import Panel
from rich.text import Text
from rich.console import Group, RenderableType
from .errors import StylesheetError
from .match import _check_selectors
from .model import RuleSet
from .parse import parse
from .types import Specificity3, Specificity4
from ..dom import DOMNode
from .. import log
class StylesheetParseError(Exception):
def __init__(self, errors: StylesheetErrors) -> None:
self.errors = errors
def __rich__(self) -> RenderableType:
return self.errors
class StylesheetErrors:
def __init__(self, stylesheet: "Stylesheet") -> None:
self.stylesheet = stylesheet
@classmethod
def _get_snippet(cls, code: str, line_no: int, col_no: int, length: int) -> Panel:
lines = Text(code, style="dim").split()
lines[line_no].stylize("bold not dim", col_no, col_no + length - 1)
text = Text("\n").join(lines[max(0, line_no - 1) : line_no + 2])
return Panel(text, border_style="red")
def __rich__(self) -> RenderableType:
highlighter = ReprHighlighter()
errors: list[RenderableType] = []
append = errors.append
for rule in self.stylesheet.rules:
for token, message in rule.errors:
line_no, col_no = token.location
append(highlighter(f"{token.path or '<unknown>'}:{line_no}"))
append(
self._get_snippet(token.code, line_no, col_no, len(token.value) + 1)
)
append(highlighter(Text(message, "red")))
append("")
return Group(*errors)
@rich.repr.auto
class Stylesheet:
def __init__(self) -> None:
self.rules: list[RuleSet] = []
def __rich_repr__(self) -> rich.repr.Result:
yield self.rules
@property
def css(self) -> str:
return "\n\n".join(rule_set.css for rule_set in self.rules)
@property
def any_errors(self) -> bool:
"""Check if there are any errors."""
return any(rule.errors for rule in self.rules)
@property
def error_renderable(self) -> StylesheetErrors:
return StylesheetErrors(self)
def read(self, filename: str) -> None:
filename = os.path.expanduser(filename)
try:
with open(filename, "rt") as css_file:
css = css_file.read()
path = os.path.abspath(filename)
except Exception as error:
raise StylesheetError(f"unable to read {filename!r}; {error}") from None
try:
rules = list(parse(css, path))
except Exception as error:
raise StylesheetError(f"failed to parse {filename!r}; {error}") from None
self.rules.extend(rules)
def parse(self, css: str, *, path: str = "") -> None:
try:
rules = list(parse(css, path))
except Exception as error:
raise StylesheetError(f"failed to parse css; {error}")
self.rules.extend(rules)
if self.any_errors:
raise StylesheetParseError(self.error_renderable)
@classmethod
def _check_rule(cls, rule: RuleSet, node: DOMNode) -> Iterable[Specificity3]:
for selector_set in rule.selector_set:
if _check_selectors(selector_set.selectors, node):
yield selector_set.specificity
def apply(self, node: DOMNode) -> None:
rule_attributes: dict[str, list[tuple[Specificity4, object]]]
rule_attributes = defaultdict(list)
_check_rule = self._check_rule
for key, default_specificity, value in node._default_rules:
rule_attributes[key].append((default_specificity, value))
for rule in self.rules:
for specificity in _check_rule(rule, node):
for key, rule_specificity, value in rule.styles.extract_rules(
specificity
):
rule_attributes[key].append((rule_specificity, value))
get_first_item = itemgetter(0)
node_rules = [
(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:
"""Update a node and its children."""
apply = self.apply
for node in root.walk_children():
apply(node)
if __name__ == "__main__":
from rich.traceback import install
install(show_locals=True)
class Widget(DOMNode):
pass
class View(DOMNode):
pass
class App(DOMNode):
pass
app = App()
main_view = View(id="main")
help_view = View(id="help")
app.add_child(main_view)
app.add_child(help_view)
widget1 = Widget(id="widget1")
widget2 = Widget(id="widget2")
sidebar = Widget(id="sidebar")
sidebar.add_class("float")
helpbar = Widget(id="helpbar")
helpbar.add_class("float")
main_view.add_child(widget1)
main_view.add_child(widget2)
main_view.add_child(sidebar)
sub_view = View(id="sub")
sub_view.add_class("-subview")
main_view.add_child(sub_view)
tooltip = Widget(id="tooltip")
tooltip.add_class("float", "transient")
sub_view.add_child(tooltip)
help = Widget(id="markdown")
help_view.add_child(help)
help_view.add_child(helpbar)
from rich import print
print(app.tree)
print()
CSS = """
App > View {
layout: dock;
docks: sidebar=left | widgets=top;
}
#sidebar {
dock-group: sidebar;
}
#widget1 {
text: on blue;
dock-group: widgets;
}
#widget2 {
text: on red;
dock-group: widgets;
}
"""
stylesheet = Stylesheet()
stylesheet.parse(CSS)
print(stylesheet.css)
# print(stylesheet.error_renderable)
# print(widget1.styles)
# stylesheet.apply(widget1)
# print(widget1.styles)
# print(stylesheet.css)
# from .query import DOMQuery
# tests = ["View", "App > View", "Widget.float", ".float.transient", "*"]
# for test in tests:
# print("")
# print(f"[b]{test}")
# print(app.query(test))

126
src/textual/css/tokenize.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
import re
from typing import Iterable
from rich import print
from .tokenizer import Expect, Tokenizer, Token
expect_selector = Expect(
whitespace=r"\s+",
comment_start=r"\/\*",
selector_start_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*",
selector_start_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*",
selector_start_universal=r"\*",
selector_start=r"[a-zA-Z_\-]+",
).expect_eof(True)
expect_comment_end = Expect(
comment_end=re.escape("*/"),
)
expect_selector_continue = Expect(
whitespace=r"\s+",
comment_start=r"\/\*",
pseudo_class=r"\:[a-zA-Z_-]+",
selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*",
selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*",
selector_universal=r"\*",
selector=r"[a-zA-Z_\-]+",
combinator_child=">",
new_selector=r",",
declaration_set_start=r"\{",
)
expect_declaration = Expect(
whitespace=r"\s+",
comment_start=r"\/\*",
declaration_name=r"[a-zA-Z_\-]+\:",
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+",
comment_start=r"\/\*",
scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh|s|ms)?",
color=r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)",
key_value=r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+",
token="[a-zA-Z_-]+",
string=r"\".*?\"",
important=r"\!important",
comma=",",
declaration_set_end=r"\}",
)
class TokenizerState:
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
class DeclarationTokenizerState(TokenizerState):
EXPECT = expect_declaration_solo
STATE_MAP = {
"declaration_name": expect_declaration_content,
"declaration_end": expect_declaration_solo,
}
tokenize = TokenizerState()
tokenize_declarations = DeclarationTokenizerState()
# 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

View File

@@ -0,0 +1,116 @@
from __future__ import annotations
from typing import NamedTuple
import re
from rich import print
import rich.repr
class EOFError(Exception):
pass
class TokenizeError(Exception):
def __init__(self, col_no: int, row_no: int, message: str) -> None:
self.col_no = col_no
self.row_no = row_no
super().__init__(message)
class Expect:
def __init__(self, **tokens: str) -> None:
self.names = list(tokens.keys())
self.regexes = list(tokens.values())
self._regex = re.compile(
"("
+ "|".join(f"(?P<{name}>{regex})" for name, regex in tokens.items())
+ ")"
)
self.match = self._regex.match
self.search = self._regex.search
self._expect_eof = False
def expect_eof(self, eof: bool) -> Expect:
self._expect_eof = eof
return self
def __rich_repr__(self) -> rich.repr.Result:
yield from zip(self.names, self.regexes)
@rich.repr.auto
class Token(NamedTuple):
name: str
value: str
path: str
code: str
location: tuple[int, int]
def __str__(self) -> str:
return self.value
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self.name
yield "value", self.value
yield "path", self.path
yield "location", self.location
class Tokenizer:
def __init__(self, text: str, path: str = "") -> None:
self.path = path
self.code = text
self.lines = text.splitlines(keepends=True)
self.line_no = 0
self.col_no = 0
def get_token(self, expect: Expect) -> Token:
line_no = self.line_no
col_no = self.col_no
if line_no >= len(self.lines):
if expect._expect_eof:
return Token("eof", "", self.path, self.code, (line_no, col_no))
else:
raise EOFError()
line = self.lines[line_no]
match = expect.match(line, col_no)
if match is None:
raise TokenizeError(
line_no,
col_no,
"expected " + ", ".join(name.upper() for name in expect.names),
)
iter_groups = iter(match.groups())
next(iter_groups)
for name, value in zip(expect.names, iter_groups):
if value is not None:
break
token = Token(name, value, self.path, self.code, (line_no, col_no))
col_no += len(value)
if col_no >= len(line):
line_no += 1
col_no = 0
self.line_no = line_no
self.col_no = col_no
return token
def skip_to(self, expect: Expect) -> Token:
line_no = self.line_no
col_no = self.col_no
while True:
if line_no >= len(self.lines):
raise EOFError()
line = self.lines[line_no]
match = expect.search(line, col_no)
if match is None:
line_no += 1
col_no = 0
else:
self.line_no = line_no
self.col_no = match.span(0)[0]
return self.get_token(expect)

View File

@@ -0,0 +1,16 @@
from typing import NamedTuple
class Transition(NamedTuple):
duration: float = 1.0
easing: str = "linear"
delay: float = 0.0
def __str__(self) -> str:
duration, easing, delay = self
if delay:
return f"{duration:.1f}s {easing} {delay:.1f}"
elif easing != "linear":
return f"{duration:.1f}s {easing}"
else:
return f"{duration:.1f}s"

20
src/textual/css/types.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import sys
from typing import Tuple
from rich.style import Style
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
Edge = Literal["top", "right", "bottom", "left"]
Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"]
EdgeStyle = Tuple[str, Style]
Specificity3 = Tuple[int, int, int]
Specificity4 = Tuple[int, int, int, int]

253
src/textual/dom.py Normal file
View File

@@ -0,0 +1,253 @@
from __future__ import annotations
from typing import Any, cast, Iterable, Iterator, TYPE_CHECKING
from rich.highlighter import ReprHighlighter
import rich.repr
from rich.pretty import Pretty
from rich.style import Style
from rich.tree import Tree
from .css.styles import Styles
from .message_pump import MessagePump
from ._node_list import NodeList
if TYPE_CHECKING:
from .css.query import DOMQuery
from .widget import Widget
class NoParent(Exception):
pass
@rich.repr.auto
class DOMNode(MessagePump):
"""A node in a hierarchy of things forming the UI.
Nodes are mountable and may be styled with CSS.
"""
STYLES = ""
def __init__(self, name: str | None = None, id: str | None = None) -> None:
self._name = name
self._id = id
self._classes: set[str] = set()
self.children = NodeList()
self.styles: Styles = Styles(self)
super().__init__()
self.default_styles = Styles.parse(self.STYLES, repr(self))
self._default_rules = self.default_styles.extract_rules((0, 0, 0))
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self._name, None
yield "id", self._id, None
if self._classes:
yield "classes", self._classes
@property
def parent(self) -> DOMNode:
"""Get the parent node.
Raises:
NoParent: If this is the root node.
Returns:
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
@property
def id(self) -> str | None:
"""The ID of this node, or None if the node has no ID.
Returns:
(str | None): A Node ID or None.
"""
return self._id
@id.setter
def id(self, new_id: str) -> str:
"""Sets the ID (may only be done once).
Args:
new_id (str): ID for this node.
Raises:
ValueError: If the ID has already been set.
"""
if self._id is not None:
raise ValueError(
"Node 'id' attribute may not be changed once set (current id={self._id!r})"
)
self._id = new_id
return new_id
@property
def name(self) -> str | None:
return self._name
@property
def classes(self) -> frozenset[str]:
return frozenset(self._classes)
@property
def psuedo_classes(self) -> set[str]:
"""Get a set of all psuedo classes"""
return set()
@property
def css_type(self) -> str:
"""Gets the CSS type, used by the CSS.
Returns:
str: A type used in CSS (lower cased class name).
"""
return self.__class__.__name__.lower()
@property
def css_path(self) -> list[DOMNode]:
"""A list of nodes from the root to this node, forming a "path".
Returns:
list[DOMNode]: List of Nodes, starting with the root and ending with this node.
"""
result: list[DOMNode] = [self]
append = result.append
node: DOMNode = self
while isinstance(node._parent, DOMNode):
node = node._parent
append(node)
return result[::-1]
@property
def visible(self) -> bool:
return self.styles.display != "none"
@property
def z(self) -> tuple[int, ...]:
"""Get the z index tuple for this node.
Returns:
tuple[int, ...]: A tuple of ints to sort layers by.
"""
indexes: list[int] = []
append = indexes.append
node = self
layer: str = node.styles.layer
while node._parent:
parent_styles = node.parent.styles
layer = layer or node.styles.layer
if layer in parent_styles.layers:
append(parent_styles.layers.index(layer))
layer = ""
else:
append(0)
node = node.parent
return tuple(reversed(indexes))
@property
def text_style(self) -> Style:
"""Get the text style (added to parent style).
Returns:
Style: Rich Style object.
"""
return (
self.parent.text_style + self.styles.text
if self.has_parent
else self.styles.text
)
@property
def tree(self) -> Tree:
"""Get a Rich tree object which will recursively render the structure of the node tree.
Returns:
Tree: A Rich object which may be printed.
"""
highlighter = ReprHighlighter()
tree = Tree(highlighter(repr(self)))
def add_children(tree, node):
for child in node.children:
branch = tree.add(Pretty(child))
if tree.children:
add_children(branch, child)
add_children(tree, self)
return tree
def reset_styles(self) -> None:
from .widget import Widget
for node in self.walk_children():
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:
"""Add a new child node.
Args:
node (DOMNode): A DOM node.
"""
self.children._append(node)
node.set_parent(self)
def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]:
stack: list[Iterator[DOMNode]] = [iter(self.children)]
pop = stack.pop
push = stack.append
if with_self:
yield self
while stack:
node = next(stack[-1], None)
if node is None:
pop()
else:
yield node
if node.children:
push(iter(node.children))
def query(self, selector: str) -> DOMQuery:
from .css.query import DOMQuery
return DOMQuery(self, selector)
def has_class(self, *class_names: str) -> bool:
return self._classes.issuperset(class_names)
def add_class(self, *class_names: str) -> None:
"""Add class names."""
self._classes.update(class_names)
def remove_class(self, *class_names: str) -> None:
"""Remove class names"""
self._classes.difference_update(class_names)
def toggle_class(self, *class_names: str) -> None:
"""Toggle class names"""
self._classes.symmetric_difference_update(class_names)
self.app.stylesheet.update(self.app)
def has_psuedo_class(self, *class_names: str) -> bool:
"""Check for psuedo class (such as hover, focus etc)"""
has_psuedo_classes = self.psuedo_classes.issuperset(class_names)
return has_psuedo_classes

10
src/textual/draw.py Normal file
View File

@@ -0,0 +1,10 @@
from enum import Enum
class DrawStyle(Enum):
NONE = "none"
ASCII = "ascii"
SQUARE = "square"
HEAVY = "heavy"
ROUNDED = "rounded"
DOUBLE = "double"

2
src/textual/errors.py Normal file
View File

@@ -0,0 +1,2 @@
class MissingWidget(Exception):
pass

View File

@@ -0,0 +1,31 @@
import os
from typing import Callable
import rich.repr
from ._callback import invoke
@rich.repr.auto
class FileMonitor:
def __init__(self, path: str, callback: Callable) -> None:
self.path = path
self.callback = callback
self._modified = self._get_modified()
def _get_modified(self) -> float:
return os.stat(self.path).st_mtime
def check(self) -> bool:
modified = self._get_modified()
changed = modified != self._modified
self._modified = modified
return changed
async def __call__(self) -> None:
if self.check():
await self.on_change()
async def on_change(self) -> None:
"""Called when file changes."""
await invoke(self.callback)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from math import sqrt
from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar
@@ -41,6 +42,9 @@ class Offset(NamedTuple):
"""Check if the point is at the origin (0, 0)"""
return self == (0, 0)
def __bool__(self) -> bool:
return self != (0, 0)
def __add__(self, other: object) -> Offset:
if isinstance(other, tuple):
_x, _y = self
@@ -55,6 +59,12 @@ class Offset(NamedTuple):
return Offset(_x - x, _y - y)
return NotImplemented
def __mul__(self, other: object) -> Offset:
if isinstance(other, (float, int)):
x, y = self
return Offset(int(x * other), int(y * other))
return NotImplemented
def blend(self, destination: Offset, factor: float) -> Offset:
"""Blend (interpolate) to a new point.
@@ -69,6 +79,20 @@ class Offset(NamedTuple):
x2, y2 = destination
return Offset(int(x1 + (x2 - x1) * factor), int((y1 + (y2 - y1) * factor)))
def get_distance_to(self, other: Offset) -> float:
"""Get the distance to another offset.
Args:
other (Offset): An offset
Returns:
float: Distance to other offset
"""
x1, y1 = self
x2, y2 = other
distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1))
return distance
class Size(NamedTuple):
"""An area defined by its width and height."""
@@ -77,7 +101,7 @@ class Size(NamedTuple):
height: int = 0
def __bool__(self) -> bool:
"""A Size is Falsey if it has area 0."""
"""A Size is Falsy if it has area 0."""
return self.width * self.height != 0
@property
@@ -457,6 +481,16 @@ class Spacing(NamedTuple):
"""Bottom right space."""
return (self.right, self.bottom)
@property
def packed(self) -> str:
top, right, bottom, left = self
if top == right == bottom == left:
return f"{top}"
if (top, right) == (bottom, left):
return f"{top}, {right}"
else:
return f"{top}, {right}, {bottom}, {left}"
@classmethod
def unpack(cls, pad: SpacingDimensions) -> Spacing:
"""Unpack padding specified in CSS style."""
@@ -472,3 +506,15 @@ class Spacing(NamedTuple):
top, right, bottom, left = cast(Tuple[int, int, int, int], pad)
return cls(top, right, bottom, left)
raise ValueError(f"1, 2 or 4 integers required for spacing; {len(pad)} given")
def __add__(self, other: object) -> Spacing:
if isinstance(other, tuple):
top1, right1, bottom1, left1 = self
top2, right2, bottom2, left2 = other
return Spacing(
top1 + top2, right1 + right2, bottom1 + bottom2, left1 + left2
)
return NotImplemented
NULL_OFFSET = Offset(0, 0)

View File

@@ -6,8 +6,7 @@ from itertools import chain
from operator import itemgetter
import sys
from typing import Iterable, Iterator, NamedTuple, TYPE_CHECKING
from rich import segment
from typing import ClassVar, Iterable, Iterator, NamedTuple, TYPE_CHECKING
import rich.repr
from rich.control import Control
@@ -54,7 +53,7 @@ class WidgetPlacement(NamedTuple):
region: Region
widget: Widget | None = None
order: tuple[int, ...] = ()
order: int = 0
@rich.repr.auto
@@ -87,6 +86,8 @@ class LayoutUpdate:
class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them."""
name: ClassVar[str] = ""
def __init__(self) -> None:
self._layout_map: LayoutMap | None = None
self.width = 0
@@ -148,11 +149,13 @@ class Layout(ABC):
)
@abstractmethod
def get_widgets(self) -> Iterable[Widget]:
def get_widgets(self, view: View) -> Iterable[Widget]:
...
@abstractmethod
def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
"""Generate a layout map that defines where on the screen the widgets will be drawn.
Args:
@@ -165,7 +168,9 @@ class Layout(ABC):
"""
async def mount_all(self, view: "View") -> None:
await view.mount(*self.get_widgets())
widgets = list(self.get_widgets(view))
if widgets:
view.mount(*widgets)
@property
def map(self) -> LayoutMap | None:

View File

@@ -1,12 +1,11 @@
from __future__ import annotations
from rich.console import Console
from typing import ItemsView, KeysView, ValuesView, NamedTuple
from . import log
from .geometry import Region, Size
from .geometry import Offset, Region, Size
from operator import attrgetter
from .widget import Widget
@@ -48,7 +47,11 @@ class LayoutMap:
if widget in self.widgets:
return
self.widgets[widget] = RenderRegion(region + widget.layout_offset, order, clip)
layout_offset = Offset(0, 0)
if widget.styles.has_offset:
layout_offset = widget.styles.offset.resolve(region.size, clip.size)
self.widgets[widget] = RenderRegion(region + layout_offset, order, clip)
if isinstance(widget, View):
view: View = widget
@@ -56,14 +59,16 @@ class LayoutMap:
total_region = region.size.region
sub_clip = clip.intersection(region)
arrangement = view.get_arrangement(region.size, scroll)
for sub_region, sub_widget, sub_order in arrangement:
arrangement = sorted(
view.get_arrangement(region.size, scroll), key=attrgetter("order")
)
for sub_region, sub_widget, z in arrangement:
total_region = total_region.union(sub_region)
if sub_widget is not None:
self.add_widget(
sub_widget,
sub_region + region.origin - scroll,
sub_order,
sub_widget.z + (z,),
sub_clip,
)
view.virtual_size = total_region.size

View File

@@ -3,14 +3,17 @@ from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from typing import Iterable, TYPE_CHECKING, Sequence
from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
from rich.console import Console
from .. import log
from ..dom import DOMNode
from .._layout_resolve import layout_resolve
from ..geometry import Offset, Region, Size
from ..layout import Layout, WidgetPlacement
from ..layout_map import LayoutMap
from ..css.types import Edge
from ..widget import Widget
if sys.version_info >= (3, 8):
from typing import Literal
@@ -20,6 +23,7 @@ else:
if TYPE_CHECKING:
from ..widget import Widget
from ..view import View
DockEdge = Literal["top", "right", "bottom", "left"]
@@ -32,126 +36,136 @@ class DockOptions:
min_size: int = 1
@dataclass
class Dock:
edge: DockEdge
class Dock(NamedTuple):
edge: Edge
widgets: Sequence[Widget]
z: int = 0
class DockLayout(Layout):
def __init__(self, docks: list[Dock] = None) -> None:
self.docks: list[Dock] = docks or []
def __init__(self) -> None:
super().__init__()
self._docks: list[Dock] | None = None
def get_widgets(self) -> Iterable[Widget]:
for dock in self.docks:
def get_docks(self, view: View) -> list[Dock]:
groups: dict[str, list[Widget]] = defaultdict(list)
for child in view.children:
assert isinstance(child, Widget)
if child.visible:
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[Widget]:
for dock in self.get_docks(view):
yield from dock.widgets
def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
map: LayoutMap = LayoutMap(size)
width, height = size
layout_region = Region(0, 0, width, height)
layers: dict[int, Region] = defaultdict(lambda: layout_region)
for index, dock in enumerate(self.docks):
dock_options = [
docks = self.get_docks(view)
def make_dock_options(widget, edge: Edge) -> DockOptions:
styles = widget.styles
return (
DockOptions(
widget.layout_size, widget.layout_fraction, widget.layout_min_size
styles.width.cells if styles._rule_width is not None else None,
styles.width.fraction if styles._rule_width is not None else 1,
styles.min_width.cells if styles._rule_min_width is not None else 1,
)
for widget in dock.widgets
]
region = layers[dock.z]
if edge in ("left", "right")
else DockOptions(
styles.height.cells if styles._rule_height is not None else None,
styles.height.fraction if styles._rule_height is not None else 1,
styles.min_height.cells
if styles._rule_min_height is not None
else 1,
)
)
Placement = WidgetPlacement
for edge, widgets, z in docks:
dock_options = [make_dock_options(widget, edge) for widget in widgets]
region = layers[z]
if not region:
# No space left
continue
order = (dock.z, index)
x, y, width, height = region
if dock.edge == "top":
if edge == "top":
sizes = layout_resolve(height, dock_options)
render_y = y
remaining = region.height
total = 0
for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible:
continue
layout_size = min(remaining, layout_size)
if not layout_size:
for widget, new_size in zip(widgets, sizes):
new_size = min(remaining, new_size)
if not new_size:
break
total += layout_size
yield WidgetPlacement(
Region(x, render_y, width, layout_size), widget, order
)
render_y += layout_size
remaining = max(0, remaining - layout_size)
total += new_size
yield Placement(Region(x, render_y, width, new_size), widget, z)
render_y += new_size
remaining = max(0, remaining - new_size)
region = Region(x, y + total, width, height - total)
elif dock.edge == "bottom":
elif edge == "bottom":
sizes = layout_resolve(height, dock_options)
render_y = y + height
remaining = region.height
total = 0
for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible:
continue
layout_size = min(remaining, layout_size)
if not layout_size:
for widget, new_size in zip(widgets, sizes):
new_size = min(remaining, new_size)
if not new_size:
break
total += layout_size
yield WidgetPlacement(
Region(x, render_y - layout_size, width, layout_size),
widget,
order,
total += new_size
yield Placement(
Region(x, render_y - new_size, width, new_size), widget, z
)
render_y -= layout_size
remaining = max(0, remaining - layout_size)
render_y -= new_size
remaining = max(0, remaining - new_size)
region = Region(x, y, width, height - total)
elif dock.edge == "left":
elif edge == "left":
sizes = layout_resolve(width, dock_options)
render_x = x
remaining = region.width
total = 0
for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible:
continue
layout_size = min(remaining, layout_size)
if not layout_size:
for widget, new_size in zip(widgets, sizes):
new_size = min(remaining, new_size)
if not new_size:
break
total += layout_size
yield WidgetPlacement(
Region(render_x, y, layout_size, height),
widget,
order,
)
render_x += layout_size
remaining = max(0, remaining - layout_size)
total += new_size
yield Placement(Region(render_x, y, new_size, height), widget, z)
render_x += new_size
remaining = max(0, remaining - new_size)
region = Region(x + total, y, width - total, height)
elif dock.edge == "right":
elif edge == "right":
sizes = layout_resolve(width, dock_options)
render_x = x + width
remaining = region.width
total = 0
for widget, layout_size in zip(dock.widgets, sizes):
if not widget.visible:
continue
layout_size = min(remaining, layout_size)
if not layout_size:
for widget, new_size in zip(widgets, sizes):
new_size = min(remaining, new_size)
if not new_size:
break
total += layout_size
yield WidgetPlacement(
Region(render_x - layout_size, y, layout_size, height),
widget,
order,
total += new_size
yield Placement(
Region(render_x - new_size, y, new_size, height), widget, z
)
render_x -= layout_size
remaining = max(0, remaining - layout_size)
render_x -= new_size
remaining = max(0, remaining - new_size)
region = Region(x, y, width - total, height)
layers[dock.z] = region
return map
layers[z] = region

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from ..layout import Layout
from .dock import DockLayout
from .grid import GridLayout
from .vertical import VerticalLayout
class MissingLayout(Exception):
pass
LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout}
def get_layout(name: str) -> Layout:
"""Get a named layout object.
Args:
name (str): Name of the layout.
Raises:
MissingLayout: If the named layout doesn't exist.
Returns:
Layout: A layout object.
"""
layout_class = LAYOUT_MAP.get(name)
if layout_class is None:
raise MissingLayout("no layout called {name!r}")
return layout_class()

View File

@@ -6,21 +6,25 @@ from operator import itemgetter
from logging import getLogger
from itertools import cycle, product
import sys
from typing import Iterable, NamedTuple
from rich.console import Console
from typing import Iterable, NamedTuple, TYPE_CHECKING
from .._layout_resolve import layout_resolve
from ..geometry import Size, Offset, Region
from ..layout import Layout, WidgetPlacement
from ..layout_map import LayoutMap
from ..widget import Widget
if TYPE_CHECKING:
from ..widget import Widget
from ..view import View
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
log = getLogger("rich")
GridAlign = Literal["start", "end", "center", "stretch"]
@@ -263,7 +267,9 @@ class GridLayout(Layout):
def get_widgets(self) -> Iterable[Widget]:
return self.widgets.keys()
def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
"""Generate a map that associates widgets with their location on screen.
Args:

View File

@@ -1,12 +1,16 @@
from __future__ import annotations
from typing import Iterable
from typing import Iterable, TYPE_CHECKING
from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions
from ..layout import Layout, WidgetPlacement
from ..widget import Widget
from .._loop import loop_last
if TYPE_CHECKING:
from ..widget import Widget
from ..view import View
class VerticalLayout(Layout):
def __init__(
@@ -34,7 +38,9 @@ class VerticalLayout(Layout):
def get_widgets(self) -> Iterable[Widget]:
return self._widgets
def arrange(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
def arrange(
self, view: View, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
index = 0
width, _height = size
gutter = self.gutter

View File

@@ -41,7 +41,6 @@ class Message:
self._forwarded = False
self._no_default_action = False
self._stop_propagation = False
self.__done_event: Event | None = None
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:
@@ -52,12 +51,6 @@ class Message:
cls.bubble = bubble
cls.verbosity = verbosity
@property
def _done_event(self) -> Event:
if self.__done_event is None:
self.__done_event = Event()
return self.__done_event
@property
def is_forwarded(self) -> bool:
return self._forwarded
@@ -95,7 +88,3 @@ class Message:
"""
self._stop_propagation = stop
return self
async def wait(self) -> None:
"""Wait for the message to be processed."""
await self._done_event.wait()

View File

@@ -48,10 +48,8 @@ class MessagePump:
return self._task
@property
def parent(self) -> MessagePump:
if self._parent is None:
raise NoParent(f"{self._parent} has no parent")
return self._parent
def has_parent(self) -> bool:
return self._parent is not None
@property
def app(self) -> "App":
@@ -226,14 +224,11 @@ class MessagePump:
async def dispatch_message(self, message: Message) -> bool | None:
_rich_traceback_guard = True
try:
if isinstance(message, events.Event):
if not isinstance(message, events.Null):
await self.on_event(message)
else:
return await self.on_message(message)
finally:
message._done_event.set()
if isinstance(message, events.Event):
if not isinstance(message, events.Null):
await self.on_event(message)
else:
return await self.on_message(message)
return False
def _get_dispatch_methods(
@@ -248,7 +243,6 @@ class MessagePump:
async def on_event(self, event: events.Event) -> None:
_rich_traceback_guard = True
for method in self._get_dispatch_methods(f"on_{event.name}", event):
log(event, ">>>", self, verbosity=event.verbosity)
await invoke(method, event)
@@ -263,8 +257,8 @@ class MessagePump:
async def on_message(self, message: Message) -> None:
_rich_traceback_guard = True
method_name = f"handle_{message.name}"
method = getattr(self, method_name, None)
if method is not None:
log(message, ">>>", self, verbosity=message.verbosity)
await invoke(method, message)

View File

@@ -29,6 +29,9 @@ if TYPE_CHECKING:
ReactiveType = TypeVar("ReactiveType")
T = TypeVar("T")
class Reactive(Generic[ReactiveType]):
"""Reactive descriptor."""

View File

@@ -187,6 +187,7 @@ class ScrollBar(Widget):
grabbed: Reactive[Offset | None] = Reactive(None)
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "virtual_size", self.virtual_size
yield "window_size", self.window_size
yield "position", self.position

View File

@@ -3,14 +3,16 @@ from __future__ import annotations
from itertools import chain
from typing import Callable, Iterable, ClassVar, TYPE_CHECKING
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
from rich.console import RenderableType
import rich.repr
from rich.style import Style
from . import events
from . import errors
from . import log
from . import messages
from .layout import Layout, NoWidget, WidgetPlacement
from .layouts.factory import get_layout
from .geometry import Size, Offset, Region
from .reactive import Reactive, watch
@@ -21,16 +23,35 @@ if TYPE_CHECKING:
from .app import App
class LayoutProperty:
def __get__(self, obj: View, objtype: type[View] | None = None) -> str:
return obj._layout.name
def __set__(self, obj: View, layout: str | Layout) -> str:
if isinstance(layout, str):
new_layout = get_layout(layout)
else:
new_layout = layout
self._layout = new_layout
return self._layout.name
@rich.repr.auto
class View(Widget):
layout_factory: ClassVar[Callable[[], Layout]]
STYLES = """
docks: main=top;
"""
def __init__(self, name: str | None = None, id: str | None = None) -> None:
from .layouts.dock import DockLayout
self._layout: Layout = DockLayout()
def __init__(self, layout: Layout = None, name: str | None = None) -> None:
self.layout: Layout = layout or self.layout_factory()
self.mouse_over: Widget | None = None
self.widgets: set[Widget] = set()
self.named_widgets: dict[str, Widget] = {}
self._mouse_style: Style = Style()
self._mouse_widget: Widget | None = None
@@ -39,8 +60,7 @@ class View(Widget):
Offset(),
[],
)
super().__init__(name=name)
super().__init__(name=name, id=id)
def __init_subclass__(
cls, layout: Callable[[], Layout] | None = None, **kwargs
@@ -49,30 +69,29 @@ class View(Widget):
cls.layout_factory = layout
super().__init_subclass__(**kwargs)
layout = LayoutProperty()
background: Reactive[str] = Reactive("")
scroll_x: Reactive[int] = Reactive(0)
scroll_y: Reactive[int] = Reactive(0)
virtual_size = Reactive(Size(0, 0))
async def watch_background(self, value: str) -> None:
self.layout.background = value
self._layout.background = value
self.app.refresh()
@property
def scroll(self) -> Offset:
return Offset(self.scroll_x, self.scroll_y)
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
return
yield
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self.name
def __getitem__(self, widget_name: str) -> Widget:
return self.named_widgets[widget_name]
def __getitem__(self, widget_id: str) -> Widget:
try:
return self.get_child_by_id(widget_id)
except errors.MissingWidget as error:
raise KeyError(str(error))
@property
def is_visual(self) -> bool:
@@ -83,19 +102,19 @@ class View(Widget):
return bool(self._parent and self.parent is self.app)
def is_mounted(self, widget: Widget) -> bool:
return widget in self.widgets
return self.app.is_mounted(widget)
def render(self) -> RenderableType:
return self.layout
return self._layout
def get_offset(self, widget: Widget) -> Offset:
return self.layout.get_offset(widget)
return self._layout.get_offset(widget)
def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]:
cached_size, cached_scroll, arrangement = self._cached_arrangement
if cached_size == size and cached_scroll == scroll:
return arrangement
arrangement = list(self.layout.arrange(size, scroll))
arrangement = list(self._layout.arrange(self, size, scroll))
self._cached_arrangement = (size, scroll, arrangement)
return arrangement
@@ -105,8 +124,7 @@ class View(Widget):
widget = message.widget
assert isinstance(widget, Widget)
display_update = self.layout.update_widget(self.console, widget)
# self.log("UPDATE", widget, display_update)
display_update = self._layout.update_widget(self.console, widget)
if display_update is not None:
self.app.display(display_update)
@@ -116,25 +134,14 @@ class View(Widget):
message.stop()
self.app.refresh()
async def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
name_widgets: Iterable[tuple[str | None, Widget]]
name_widgets = chain(
((None, widget) for widget in anon_widgets), widgets.items()
)
for name, widget in name_widgets:
name = name or widget.name
if self.app.register(widget, self):
if name:
self.named_widgets[name] = widget
self.widgets.add(widget)
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.app.register(self, *anon_widgets, **widgets)
self.refresh()
async def refresh_layout(self) -> None:
self._cached_arrangement = (Size(), Offset(), [])
try:
await self.layout.mount_all(self)
await self._layout.mount_all(self)
if not self.is_root_view:
await self.app.view.refresh_layout()
return
@@ -142,8 +149,8 @@ class View(Widget):
if not self.size:
return
hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size))
assert self.layout.map is not None
hidden, shown, resized = self._layout.reflow(self, Size(*self.console.size))
assert self._layout.map is not None
for widget in hidden:
widget.post_message_no_wait(events.Hide(self))
@@ -153,13 +160,13 @@ class View(Widget):
send_resize = shown
send_resize.update(resized)
for widget, region, unclipped_region in self.layout:
for widget, region, unclipped_region in self._layout:
widget._update_size(unclipped_region.size)
if widget in send_resize:
widget.post_message_no_wait(
events.Resize(self, unclipped_region.size)
)
except:
except Exception:
self.app.panic()
async def on_resize(self, event: events.Resize) -> None:
@@ -170,13 +177,13 @@ class View(Widget):
event.stop()
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
return self.layout.get_widget_at(x, y)
return self._layout.get_widget_at(x, y)
def get_style_at(self, x: int, y: int) -> Style:
return self.layout.get_style_at(x, y)
return self._layout.get_style_at(x, y)
def get_widget_region(self, widget: Widget) -> Region:
return self.layout.get_widget_region(widget)
return self._layout.get_widget_region(widget)
async def on_mount(self, event: events.Mount) -> None:
async def watch_background(value: str) -> None:
@@ -185,8 +192,8 @@ class View(Widget):
watch(self.app, "background", watch_background)
async def on_idle(self, event: events.Idle) -> None:
if self.layout.check_update():
self.layout.reset_update()
if self._layout.check_update():
self._layout.reset_update()
await self.refresh_layout()
async def _on_mouse_move(self, event: events.MouseMove) -> None:
@@ -255,6 +262,6 @@ class View(Widget):
await self.post_message(event)
async def action_toggle(self, name: str) -> None:
widget = self.named_widgets[name]
widget = self[name]
widget.visible = not widget.visible
await self.post_message(messages.Layout(self))

View File

@@ -21,16 +21,21 @@ class DockView(View):
async def dock(
self,
*widgets: Widget,
name: str | None = None,
id: str | None = None,
edge: DockEdge = "top",
z: int = 0,
size: int | None | DoNotSet = do_not_set,
name: str | None = None,
) -> None:
dock = Dock(edge, widgets, z)
assert isinstance(self.layout, DockLayout)
self.layout.docks.append(dock)
assert isinstance(self._layout, DockLayout)
self._layout.docks.append(dock)
for widget in widgets:
if id is not None:
widget._id = id
if name is not None:
widget.name = name
if size is not do_not_set:
widget.layout_size = cast(Optional[int], size)
if name is None:
@@ -42,20 +47,21 @@ class DockView(View):
async def dock_grid(
self,
*,
name: str | None = None,
id: str | None = None,
edge: DockEdge = "top",
z: int = 0,
size: int | None | DoNotSet = do_not_set,
name: str | None = None,
gap: tuple[int, int] | int | None = None,
gutter: tuple[int, int] | int | None = None,
align: tuple[GridAlign, GridAlign] | None = None,
) -> GridLayout:
grid = GridLayout(gap=gap, gutter=gutter, align=align)
view = View(layout=grid, name=name)
view = View(layout=grid, id=id, name=name)
dock = Dock(edge, (view,), z)
assert isinstance(self.layout, DockLayout)
self.layout.docks.append(dock)
assert isinstance(self._layout, DockLayout)
self._layout.docks.append(dock)
if size is not do_not_set:
view.layout_size = cast(Optional[int], size)
if name is None:

View File

@@ -32,12 +32,12 @@ class WindowView(View, layout=VerticalLayout):
super().__init__(name=name, layout=layout)
async def update(self, widget: Widget | RenderableType) -> None:
layout = self.layout
layout = self._layout
assert isinstance(layout, VerticalLayout)
layout.clear()
self.widget = widget if isinstance(widget, Widget) else Static(widget)
layout.add(self.widget)
self.layout.require_update()
layout.require_update()
self.refresh(layout=True)
await self.emit(WindowChange(self))
@@ -46,8 +46,7 @@ class WindowView(View, layout=VerticalLayout):
await self.emit(WindowChange(self))
async def handle_layout(self, message: messages.Layout) -> None:
self.log("TRANSLATING layout")
self.layout.require_update()
self._layout.require_update()
message.stop()
self.refresh()
@@ -55,11 +54,11 @@ class WindowView(View, layout=VerticalLayout):
await self.emit(WindowChange(self))
async def watch_scroll_x(self, value: int) -> None:
self.layout.require_update()
self._layout.require_update()
self.refresh()
async def watch_scroll_y(self, value: int) -> None:
self.layout.require_update()
self._layout.require_update()
self.refresh()
async def on_resize(self, event: events.Resize) -> None:

View File

@@ -7,8 +7,8 @@ from typing import (
TYPE_CHECKING,
Callable,
ClassVar,
Iterable,
NamedTuple,
NewType,
cast,
)
import rich.repr
@@ -20,15 +20,17 @@ from rich.padding import Padding
from rich.pretty import Pretty
from rich.style import Style
from rich.styled import Styled
from rich.text import TextType
from rich.text import Text, TextType
from . import events
from . import errors
from ._animator import BoundAnimator
from ._border import Border, BORDER_STYLES
from ._callback import invoke
from .dom import DOMNode
from ._context import active_app
from .geometry import Size, Spacing, SpacingDimensions
from .message import Message
from .message_pump import MessagePump
from .messages import Layout, Update
from .reactive import Reactive, watch
from ._types import Lines
@@ -47,25 +49,28 @@ 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
@rich.repr.auto
class Widget(MessagePump):
_id: ClassVar[int] = 0
class Widget(DOMNode):
_counts: ClassVar[dict[str, int]] = {}
can_focus: bool = False
def __init__(self, name: str | None = None) -> None:
class_name = self.__class__.__name__
Widget._counts.setdefault(class_name, 0)
Widget._counts[class_name] += 1
_count = self._counts[class_name]
STYLES = """
dock: _default;
"""
self.name = name or f"{class_name}#{_count}"
def __init__(self, name: str | None = None, id: str | None = None) -> None:
if name is None:
class_name = self.__class__.__name__
Widget._counts.setdefault(class_name, 0)
Widget._counts[class_name] += 1
_count = self._counts[class_name]
name = f"{class_name}{_count}"
self._size = Size(0, 0)
self._repaint_required = False
@@ -75,47 +80,61 @@ class Widget(MessagePump):
self.render_cache: RenderCache | None = None
self.highlight_style: Style | None = None
super().__init__()
visible: Reactive[bool] = Reactive(True, layout=True)
layout_size: Reactive[int | None] = Reactive(None, layout=True)
layout_fraction: Reactive[int] = Reactive(1, layout=True)
layout_min_size: Reactive[int] = Reactive(1, layout=True)
layout_offset_x: Reactive[float] = Reactive(0.0, layout=True)
layout_offset_y: Reactive[float] = Reactive(0.0, layout=True)
style: Reactive[str | None] = Reactive(None)
padding: Reactive[Spacing | None] = Reactive(None, layout=True)
margin: Reactive[Spacing | None] = Reactive(None, layout=True)
border: Reactive[str] = Reactive("none", layout=True)
border_style: Reactive[str] = Reactive("")
border_title: Reactive[TextType] = Reactive("")
BOX_MAP = {"normal": box.SQUARE, "round": box.ROUNDED, "bold": box.HEAVY}
def validate_padding(self, padding: SpacingDimensions) -> Spacing:
return Spacing.unpack(padding)
def validate_margin(self, margin: SpacingDimensions) -> Spacing:
return Spacing.unpack(margin)
def validate_layout_offset_x(self, value) -> int:
return int(value)
def validate_layout_offset_y(self, value) -> int:
return int(value)
super().__init__(name=name, id=id)
def __init_subclass__(cls, can_focus: bool = True) -> None:
super().__init_subclass__()
cls.can_focus = can_focus
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self.name
yield "id", self.id, None
if self.name:
yield "name", self.name
if self.classes:
yield "classes", self.classes
def __rich__(self) -> RenderableType:
renderable = self.render_styled()
return renderable
def get_child_by_id(self, id: str) -> Widget:
"""Get a child with a given id.
Args:
id (str): A Widget id.
Raises:
errors.MissingWidget: If the widget was not found.
Returns:
Widget: A child widget.
"""
for widget in self.children:
if widget.id == id:
return cast(Widget, widget)
raise errors.MissingWidget(f"Widget with id=={id!r} was not found in {self}")
def get_child_by_name(self, name: str) -> Widget:
"""Get a child widget with a given name.
Args:
name (str): A name. Defaults to None.
Raises:
errors.MissingWidget: If no Widget is found.
Returns:
Widget: A Widget with the given name.
"""
for widget in self.children:
if widget.name == name:
return cast(Widget, widget)
raise errors.MissingWidget(
f"Widget with name=={name!r} was not found in {self}"
)
def watch(self, attribute_name, callback: Callable[[Any], Awaitable[None]]) -> None:
watch(self, attribute_name, callback)
@@ -125,21 +144,38 @@ class Widget(MessagePump):
Returns:
RenderableType: A new renderable.
"""
renderable = self.render()
if self.padding is not None:
renderable = Padding(renderable, self.padding)
if self.border in self.BOX_MAP:
renderable = Panel(
renderable,
box=self.BOX_MAP.get(self.border) or box.SQUARE,
style=self.border_style,
styles = self.styles
parent_text_style = self.parent.text_style
text_style = styles.text
renderable_text_style = parent_text_style + text_style
if renderable_text_style:
renderable = Styled(renderable, renderable_text_style)
if styles.has_padding:
renderable = Padding(
renderable, styles.padding, style=renderable_text_style
)
if self.margin is not None:
renderable = Padding(renderable, self.margin)
if self.style:
renderable = Styled(renderable, self.style)
if styles.has_border:
renderable = Border(renderable, styles.border, style=renderable_text_style)
if styles.has_margin:
renderable = Padding(renderable, styles.margin, style=parent_text_style)
if styles.has_outline:
renderable = Border(
renderable, styles.outline, outline=True, style=parent_text_style
)
return renderable
@property
def visible(self) -> bool:
return self.styles.display == "block"
@property
def size(self) -> Size:
return self._size
@@ -165,21 +201,19 @@ class Widget(MessagePump):
assert self._animate is not None
return self._animate
@property
def layout_offset(self) -> tuple[int, int]:
"""Get the layout offset as a tuple."""
return (round(self.layout_offset_x), round(self.layout_offset_y))
@property
def gutter(self) -> Spacing:
mt, mr, mb, bl = self.margin or (0, 0, 0, 0)
pt, pr, pb, pl = self.padding or (0, 0, 0, 0)
border = 1 if self.border else 0
gutter = Spacing(
mt + pt + border, mr + pr + border, mb + pb + border, bl + pl + border
)
"""Get additional space reserved by margin / padding / border.
Returns:
Spacing: [description]
"""
gutter = self.styles.gutter
return gutter
def on_style_change(self) -> None:
self.clear_render_cache()
def _update_size(self, size: Size) -> None:
self._size = size
@@ -254,9 +288,7 @@ class Widget(MessagePump):
Returns:
RenderableType: Any renderable
"""
return Panel(
Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__
)
return Align.center(Text(f"#{self.id}"), vertical="middle")
async def action(self, action: str, *params) -> None:
await self.app.action(action, self)
@@ -272,13 +304,14 @@ class Widget(MessagePump):
self.refresh()
async def on_idle(self, event: events.Idle) -> None:
if self.check_layout():
self.render_cache = None
repaint, layout = self.styles.check_refresh()
if layout or self.check_layout():
# self.render_cache = None
self.reset_check_repaint()
self.reset_check_layout()
await self.emit(Layout(self))
elif self.check_repaint():
self.render_cache = None
elif repaint or self.check_repaint():
# self.render_cache = None
self.reset_check_repaint()
await self.emit(Update(self, self))
@@ -286,9 +319,20 @@ class Widget(MessagePump):
await self.app.set_focus(self)
async def capture_mouse(self, capture: bool = True) -> None:
"""Capture (or release) the mouse.
When captured, all mouse coordinates will go to this widget even when the pointer is not directly over the widget.
Args:
capture (bool, optional): True to capture or False to release. Defaults to True.
"""
await self.app.capture_mouse(self if capture else None)
async def release_mouse(self) -> None:
"""Release the mouse.
Mouse events will only be sent when the mouse is over the widget.
"""
await self.app.capture_mouse(None)
async def broker_event(self, event_name: str, event: events.Event) -> bool:

View File

@@ -33,7 +33,7 @@ class Footer(Widget):
self.highlight_key = None
def __rich_repr__(self) -> rich.repr.Result:
yield "keys", self.keys
yield from super().__rich_repr__()
def make_key_text(self) -> Text:
"""Create text containing all the keys."""

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from datetime import datetime
from logging import getLogger
@@ -39,7 +41,8 @@ class Header(Widget):
return f"{self.title} - {self.sub_title}" if self.sub_title else self.title
def __rich_repr__(self) -> Result:
yield self.title
yield from super().__rich_repr__()
yield "title", self.title
async def watch_tall(self, tall: bool) -> None:
self.layout_size = 3 if tall else 1

View File

@@ -10,7 +10,6 @@ import rich.repr
from logging import getLogger
from .. import events
from ..geometry import Offset
from ..widget import Reactive, Widget
log = getLogger("rich")
@@ -29,7 +28,7 @@ class Placeholder(Widget, can_focus=True):
self.height = height
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self.name
yield from super().__rich_repr__()
yield "has_focus", self.has_focus, False
yield "mouse_over", self.mouse_over, False

View File

@@ -31,13 +31,18 @@ class ScrollView(View):
) -> None:
from ..views import WindowView
layout = GridLayout()
super().__init__(name=name, layout=layout)
self.fluid = fluid
self.vscroll = ScrollBar(vertical=True)
self.hscroll = ScrollBar(vertical=False)
self.window = WindowView(
"" if contents is None else contents, auto_width=auto_width, gutter=gutter
"" if contents is None else contents,
auto_width=auto_width,
gutter=gutter,
)
layout = GridLayout()
layout.add_column("main")
layout.add_column("vscroll", size=1)
layout.add_row("main")
@@ -47,7 +52,6 @@ class ScrollView(View):
)
layout.show_row("hscroll", False)
layout.show_column("vscroll", False)
super().__init__(name=name, layout=layout)
x: Reactive[float] = Reactive(0, repaint=False)
y: Reactive[float] = Reactive(0, repaint=False)
@@ -89,13 +93,13 @@ class ScrollView(View):
await self.window.update(renderable)
async def on_mount(self, event: events.Mount) -> None:
assert isinstance(self.layout, GridLayout)
self.layout.place(
assert isinstance(self._layout, GridLayout)
self._layout.place(
content=self.window,
vscroll=self.vscroll,
hscroll=self.hscroll,
)
await self.layout.mount_all(self)
await self._layout.mount_all(self)
def home(self) -> None:
self.x = self.y = 0
@@ -211,10 +215,10 @@ class ScrollView(View):
self.vscroll.virtual_size = virtual_height
self.vscroll.window_size = height
assert isinstance(self.layout, GridLayout)
assert isinstance(self._layout, GridLayout)
vscroll_change = self.layout.show_column("vscroll", virtual_height > height)
hscroll_change = self.layout.show_row("hscroll", virtual_width > width)
vscroll_change = self._layout.show_column("vscroll", virtual_height > height)
hscroll_change = self._layout.show_row("hscroll", virtual_width > width)
if hscroll_change or vscroll_change:
self.refresh(layout=True)

75
tests/test_query.py Normal file
View File

@@ -0,0 +1,75 @@
from textual.dom import DOMNode
def test_query():
class Widget(DOMNode):
pass
class View(DOMNode):
pass
class App(DOMNode):
pass
app = App()
main_view = View(id="main")
help_view = View(id="help")
app.add_child(main_view)
app.add_child(help_view)
widget1 = Widget(id="widget1")
widget2 = Widget(id="widget2")
sidebar = Widget(id="sidebar")
sidebar.add_class("float")
helpbar = Widget(id="helpbar")
helpbar.add_class("float")
main_view.add_child(widget1)
main_view.add_child(widget2)
main_view.add_child(sidebar)
sub_view = View(id="sub")
sub_view.add_class("-subview")
main_view.add_child(sub_view)
tooltip = Widget(id="tooltip")
tooltip.add_class("float", "transient")
sub_view.add_child(tooltip)
help = Widget(id="markdown")
help_view.add_child(help)
help_view.add_child(helpbar)
# repeat tests to account for caching
for repeat in range(3):
assert list(app.query("Frob")) == []
assert list(app.query(".frob")) == []
assert list(app.query("#frob")) == []
assert list(app.query("App")) == [app]
assert list(app.query("#main")) == [main_view]
assert list(app.query("View#main")) == [main_view]
assert list(app.query("#widget1")) == [widget1]
assert list(app.query("#widget2")) == [widget2]
assert list(app.query("Widget.float")) == [sidebar, tooltip, helpbar]
assert list(app.query("Widget.float.transient")) == [tooltip]
assert list(app.query("App > View")) == [main_view, help_view]
assert list(app.query("App > View#help")) == [help_view]
assert list(app.query("App > View#main .float ")) == [sidebar, tooltip]
assert list(app.query("View > View")) == [sub_view]
assert list(app.query("#help *")) == [help, helpbar]
assert list(app.query("#main *")) == [
widget1,
widget2,
sidebar,
sub_view,
tooltip,
]
assert list(app.query("App,View")) == [app, main_view, sub_view, help_view]
assert list(app.query("#widget1, #widget2")) == [widget1, widget2]
assert list(app.query("#widget1 , #widget2")) == [widget1, widget2]
assert list(app.query("#widget1, #widget2, App")) == [app, widget1, widget2]