mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
34
examples/basic.css
Normal file
34
examples/basic.css
Normal 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
22
examples/basic.py
Normal 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
10
examples/colours.txt
Normal 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
34
examples/example.css
Normal 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;
|
||||
}
|
||||
@@ -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
7
examples/theme.css
Normal file
@@ -0,0 +1,7 @@
|
||||
Header {
|
||||
border: solid #122233;
|
||||
}
|
||||
|
||||
App > View > Widget {
|
||||
display: none;
|
||||
}
|
||||
3
poetry.lock
generated
3
poetry.lock
generated
@@ -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 = [
|
||||
|
||||
@@ -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" }
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
185
src/textual/_border.py
Normal 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
93
src/textual/_box.py
Normal 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
78
src/textual/_node_list.py
Normal 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]
|
||||
@@ -9,7 +9,6 @@ from typing import (
|
||||
TypeVar,
|
||||
Generic,
|
||||
Union,
|
||||
Iterator,
|
||||
Iterable,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
3
src/textual/css/Makefile
Normal file
@@ -0,0 +1,3 @@
|
||||
parser.py:
|
||||
python -m lark.tools.standalone css.g > parser.py
|
||||
black parser.py
|
||||
0
src/textual/css/__init__.py
Normal file
0
src/textual/css/__init__.py
Normal file
26
src/textual/css/_error_tools.py
Normal file
26
src/textual/css/_error_tools.py
Normal 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]}'
|
||||
473
src/textual/css/_style_properties.py
Normal file
473
src/textual/css/_style_properties.py
Normal 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 {}
|
||||
426
src/textual/css/_styles_builder.py
Normal file
426
src/textual/css/_styles_builder.py
Normal 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
|
||||
28
src/textual/css/constants.py
Normal file
28
src/textual/css/constants.py
Normal 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
21
src/textual/css/errors.py
Normal 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
73
src/textual/css/match.py
Normal 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
176
src/textual/css/model.py
Normal 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
265
src/textual/css/parse.py
Normal 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
99
src/textual/css/query.py
Normal 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
176
src/textual/css/scalar.py
Normal 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))
|
||||
67
src/textual/css/scalar_animation.py
Normal file
67
src/textual/css/scalar_animation.py
Normal 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
434
src/textual/css/styles.py
Normal 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)))
|
||||
234
src/textual/css/stylesheet.py
Normal file
234
src/textual/css/stylesheet.py
Normal 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
126
src/textual/css/tokenize.py
Normal 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
|
||||
116
src/textual/css/tokenizer.py
Normal file
116
src/textual/css/tokenizer.py
Normal 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)
|
||||
16
src/textual/css/transition.py
Normal file
16
src/textual/css/transition.py
Normal 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
20
src/textual/css/types.py
Normal 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
253
src/textual/dom.py
Normal 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
10
src/textual/draw.py
Normal 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
2
src/textual/errors.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class MissingWidget(Exception):
|
||||
pass
|
||||
31
src/textual/file_monitor.py
Normal file
31
src/textual/file_monitor.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
31
src/textual/layouts/factory.py
Normal file
31
src/textual/layouts/factory.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -29,6 +29,9 @@ if TYPE_CHECKING:
|
||||
ReactiveType = TypeVar("ReactiveType")
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class Reactive(Generic[ReactiveType]):
|
||||
"""Reactive descriptor."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
75
tests/test_query.py
Normal 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]
|
||||
Reference in New Issue
Block a user