Merge branch 'css' of github.com:Textualize/textual into text-input-cursor-to-click

This commit is contained in:
Darren Burns
2022-05-20 13:18:41 +01:00
41 changed files with 964 additions and 266 deletions

View File

@@ -27,6 +27,7 @@ Widget {
/* outline: heavy blue; */
height: 10;
padding: 1 2;
box-sizing: border-box;
max-height: 100vh;
@@ -41,5 +42,7 @@ Widget {
height: 10;
margin: 1;
background:blue;
color: white 50%;
border: white;
align-horizontal: center;
}

16
sandbox/auto_test.css Normal file
View File

@@ -0,0 +1,16 @@
Vertical {
background: red 50%;
}
.test {
width: auto;
height: auto;
background: white 50%;
border:solid green;
padding: 0;
margin:3;
align: center middle;
box-sizing: border-box;
}

20
sandbox/auto_test.py Normal file
View File

@@ -0,0 +1,20 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.layout import Vertical
from rich.text import Text
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(12)))
class AutoApp(App):
def compose(self) -> ComposeResult:
yield Vertical(
Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test")
)
app = AutoApp(css_path="auto_test.css")
if __name__ == "__main__":
app.run()

View File

@@ -69,12 +69,12 @@ App > Screen {
color: $text-background;
background: $background;
layout: vertical;
overflow-y: scroll;
overflow-y: scroll;
}
Tweet {
height: 12;
height: auto;
width: 80;
margin: 1 3;
@@ -84,9 +84,10 @@ Tweet {
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll;
overflow-y: auto;
/* scrollbar-gutter: stable; */
align-horizontal: center;
box-sizing: border-box;
}
.scrollable {
@@ -113,11 +114,11 @@ TweetHeader {
}
TweetBody {
width: 100%;
width: 100w;
background: $panel;
color: $text-panel;
height:20;
padding: 0 1 0 0;
height: auto;
padding: 0 1 0 0;
}
@@ -222,4 +223,4 @@ Success {
.horizontal {
layout: horizontal
}
}

View File

@@ -4,6 +4,7 @@ from rich.syntax import Syntax
from rich.text import Text
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Static
@@ -44,11 +45,15 @@ class Offset(NamedTuple):
'''
lorem = Text.from_markup(
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum."""
lorem = (
lorem_short
+ """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
)
lorem_short_text = Text.from_markup(lorem_short)
lorem_long_text = Text.from_markup(lorem * 2)
class TweetHeader(Widget):
def render(self, style: Style) -> RenderableType:
@@ -56,8 +61,10 @@ class TweetHeader(Widget):
class TweetBody(Widget):
short_lorem = Reactive[bool](False)
def render(self, style: Style) -> Text:
return lorem
return lorem_short_text if self.short_lorem else lorem_long_text
class Tweet(Widget):
@@ -135,9 +142,18 @@ class BasicApp(App):
def key_d(self):
self.dark = not self.dark
async def key_q(self):
await self.shutdown()
def key_x(self):
self.panic(self.tree)
def key_t(self):
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
tweet_body = self.screen.query("TweetBody").first()
tweet_body.short_lorem = not tweet_body.short_lorem
tweet_body.refresh(layout=True)
app = BasicApp(
css_path="basic.css",

67
sandbox/borders.py Normal file
View File

@@ -0,0 +1,67 @@
from rich.console import RenderableType
from rich.text import Text
from textual.app import App, ComposeResult
from textual.css.types import EdgeType
from textual.widget import Widget
from textual.widgets import Placeholder
class VerticalContainer(Widget):
CSS = """
VerticalContainer {
layout: vertical;
overflow: hidden auto;
background: darkblue;
}
VerticalContainer Placeholder {
margin: 1 0;
height: auto;
align: center top;
}
"""
class Introduction(Widget):
CSS = """
Introduction {
background: indigo;
color: white;
height: 3;
padding: 1 0;
}
"""
def render(self, styles) -> RenderableType:
return Text("Here are the color edge types we support.", justify="center")
class BorderDemo(Widget):
def __init__(self, name: str):
super().__init__(name=name)
def render(self, style) -> RenderableType:
return Text(self.name, style="black on yellow", justify="center")
class MyTestApp(App):
def compose(self) -> ComposeResult:
border_demo_widgets = []
for border_edge_type in EdgeType.__args__:
border_demo = BorderDemo(f'"border: {border_edge_type} white"')
border_demo.styles.height = "auto"
border_demo.styles.margin = (1, 0)
border_demo.styles.border = (border_edge_type, "white")
border_demo_widgets.append(border_demo)
yield VerticalContainer(Introduction(), *border_demo_widgets, id="root")
def on_mount(self):
self.bind("q", "quit")
app = MyTestApp()
if __name__ == "__main__":
app.run()

View File

@@ -3,6 +3,10 @@
background: rebeccapurple;
}
*:focus {
tint: yellow 50%;
}
#foo:hover {
background: greenyellow;
}

18
sandbox/horizontal.css Normal file
View File

@@ -0,0 +1,18 @@
Horizontal {
background: red 50%;
overflow-x: auto;
/* width: auto */
}
.test {
width: auto;
height: auto;
background: white 50%;
border:solid green;
padding: 0;
margin:3;
align: center middle;
box-sizing: content-box;
}

26
sandbox/horizontal.py Normal file
View File

@@ -0,0 +1,26 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual import layout
from rich.text import Text
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10)))
class AutoApp(App):
def on_mount(self) -> None:
self.bind("t", "tree")
def compose(self) -> ComposeResult:
yield layout.Horizontal(
Static(TEXT, classes="test"), Static(TEXT, id="test", classes="test")
)
def action_tree(self):
self.log(self.screen.tree)
app = AutoApp(css_path="horizontal.css")
if __name__ == "__main__":
app.run()

25
sandbox/nest.css Normal file
View File

@@ -0,0 +1,25 @@
Vertical {
background: blue;
}
#container {
width:50%;
height: auto;
align-horizontal: center;
padding: 1;
border: heavy white;
background: white 50%;
overflow-y: auto
}
TextWidget {
/* width: 50%; */
height: auto;
padding: 2;
background: green 30%;
border: yellow;
box-sizing: border-box;
}

38
sandbox/nest.py Normal file
View File

@@ -0,0 +1,38 @@
from textual.app import App, ComposeResult
from textual.widget import Widget
from textual import layout
from rich.text import Text
lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
TEXT = Text.from_markup(lorem)
class TextWidget(Widget):
def render(self, style):
return TEXT
class AutoApp(App):
def on_mount(self) -> None:
self.bind("t", "tree")
def compose(self) -> ComposeResult:
yield layout.Vertical(
Widget(
TextWidget(classes="test"),
id="container",
),
)
def action_tree(self):
self.log(self.screen.tree)
app = AutoApp(css_path="nest.css")
if __name__ == "__main__":
app.run()

View File

@@ -1,13 +1,14 @@
App.-show-focus *:focus {
tint: #8bc34a 50%;
tint: #8bc34a 20%;
}
#uber1 {
layout: vertical;
background: green;
overflow: hidden auto;
border: heavy white;
border: heavy white;
text-style: underline;
/* box-sizing: content-box; */
}
#uber1:focus-within {
@@ -16,11 +17,12 @@ App.-show-focus *:focus {
#child2 {
text-style: underline;
background: red;
background: red 10%;
}
.list-item {
height: 20;
height: 10;
/* display: none; */
color: #12a0;
background: #ffffff00;
}

22
sandbox/vertical.css Normal file
View File

@@ -0,0 +1,22 @@
Screen {
background:blue;
}
Vertical {
background: red 50%;
overflow: auto;
/* width: auto */
}
.test {
/* width: auto; */
/* height: 50vh; */
background: white 50%;
border:solid green;
padding: 0;
margin:3;
align: center middle;
box-sizing: border-box;
}

29
sandbox/vertical.py Normal file
View File

@@ -0,0 +1,29 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual import layout
from rich.text import Text
TEXT = Text.from_markup(" ".join(str(n) * 5 for n in range(10)))
class AutoApp(App):
def on_mount(self) -> None:
self.bind("t", "tree")
def compose(self) -> ComposeResult:
yield layout.Horizontal(
layout.Vertical(
Static(TEXT, classes="test"),
Static(TEXT, id="test", classes="test"),
)
)
def action_tree(self):
self.log(self.screen.tree)
app = AutoApp(css_path="vertical.css")
if __name__ == "__main__":
app.run()

View File

@@ -1,24 +1,34 @@
from __future__ import annotations
import sys
from functools import lru_cache
from typing import cast, Tuple, Union
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
import rich.repr
from rich.segment import Segment, SegmentLines
from rich.style import Style, StyleType
from rich.style import Style
from .color import Color
from .css.types import EdgeStyle, EdgeType
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
INNER = 1
OUTER = 2
BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
# TODO: in "browsers' CSS" `none` and `hidden` both set the border width to zero. Should we do the same?
# Each string of the tuple represents a sub-tuple itself:
# - 1st string represents `(top1, top2, top3)`
# - 2nd string represents (mid1, mid2, mid3)
# - 3rd string represents (bottom1, bottom2, bottom3)
"": (" ", " ", " "),
"none": (" ", " ", " "),
"hidden": (" ", " ", " "),
"blank": (" ", " ", " "),
"round": ("╭─╮", "│ │", "╰─╯"),
"solid": ("┌─┐", "│ │", "└─┘"),
"double": ("╔═╗", "║ ║", "╚═╝"),
@@ -40,6 +50,7 @@ BORDER_LOCATIONS: dict[
"": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"round": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"solid": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
"double": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
@@ -53,6 +64,10 @@ BORDER_LOCATIONS: dict[
"wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)),
}
INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]]
@lru_cache(maxsize=1024)
def get_box(
@@ -135,7 +150,12 @@ class Border:
(bottom, bottom_color),
(left, left_color),
) = edge_styles
self._sides = (top or "none", right or "none", bottom or "none", left or "none")
self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] = (
top or "none",
right or "none",
bottom or "none",
left or "none",
)
from_color = Style.from_color
self._styles = (
@@ -159,14 +179,15 @@ class Border:
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"
# the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string
has_left = bool(left)
has_right = bool(right)
has_top = bool(top)
has_bottom = bool(bottom)
if has_top:
lines.pop(0)
if has_bottom:
if has_bottom and lines:
lines.pop(-1)
divide = Segment.divide
@@ -188,10 +209,11 @@ class Border:
outer_style = console.get_style(self.outer_style)
top_style, right_style, bottom_style, left_style = self._styles
has_left = left != "none"
has_right = right != "none"
has_top = top != "none"
has_bottom = bottom != "none"
# ditto than in `_crop_renderable` ☝
has_left = bool(left)
has_right = bool(right)
has_top = bool(top)
has_bottom = bool(bottom)
width = options.max_width - has_left - has_right
@@ -210,8 +232,7 @@ class Border:
if new_height >= 1:
render_options = options.update_dimensions(width, new_height)
else:
render_options = options
has_top = has_bottom = False
render_options = options.update_width(width)
lines = console.render_lines(self.renderable, render_options)
@@ -262,6 +283,18 @@ class Border:
yield new_line
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
# i.e. we normalize "border: none;" to "border: ;".
# As a result our layout-related calculations that include borders are simpler (and have better performance)
"none": "",
"hidden": "",
}
def normalize_border_value(value: BorderValue) -> BorderValue:
return _edge_type_normalization_table.get(value[0], value[0]), value[1]
if __name__ == "__main__":
from rich import print
from rich.text import Text

View File

@@ -63,6 +63,7 @@ class MapGeometry(NamedTuple):
return self.clip.intersection(self.region)
# Maps a widget on to its geometry (information that describes its position in the composition)
CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"
@@ -98,23 +99,28 @@ class LayoutUpdate:
class SpansUpdate:
"""A renderable that applies updated spans to the screen."""
def __init__(self, spans: list[tuple[int, int, list[Segment]]]) -> None:
def __init__(
self, spans: list[tuple[int, int, list[Segment]]], crop_y: int
) -> None:
"""Apply spans, which consist of a tuple of (LINE, OFFSET, SEGMENTS)
Args:
spans (list[tuple[int, int, list[Segment]]]): A list of spans.
crop_y (int): The y extent of the crop region
"""
self.spans = spans
self.last_y = crop_y - 1
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
move_to = Control.move_to
new_line = Segment.line()
for last, (y, x, segments) in loop_last(self.spans):
last_y = self.last_y
for y, x, segments in self.spans:
yield move_to(x, y)
yield from segments
if not last:
if y != last_y:
yield new_line
def __rich_repr__(self) -> rich.repr.Result:
@@ -171,10 +177,11 @@ class Compositor:
Iterable[tuple[int, int, int]]: Yields tuples of (Y, X1, X2)
"""
inline_ranges: dict[int, list[tuple[int, int]]] = {}
setdefault = inline_ranges.setdefault
for region_x, region_y, width, height in regions:
span = (region_x, region_x + width)
for y in range(region_y, region_y + height):
inline_ranges.setdefault(y, []).append(span)
setdefault(y, []).append(span)
for y, ranges in sorted(inline_ranges.items()):
if len(ranges) == 1:
@@ -316,7 +323,7 @@ class Compositor:
# Arrange the layout
placements, arranged_widgets = widget.layout.arrange(
widget, child_region.size, widget.scroll_offset
widget, child_region.size
)
widgets.update(arranged_widgets)
placements = sorted(placements, key=get_order)
@@ -555,10 +562,10 @@ class Compositor:
screen_region = Region(0, 0, width, height)
update_regions = self._dirty_regions.copy()
self._dirty_regions.clear()
if screen_region in update_regions:
# If one of the updates is the entire screen, then we only need one update
update_regions.clear()
self._dirty_regions.clear()
if update_regions:
# Create a crop regions that surrounds all updates
@@ -616,7 +623,7 @@ class Compositor:
(y, x1, line_crop(render_lines[y - crop_y], x1, x2))
for y, x1, x2 in spans
]
return SpansUpdate(render_spans)
return SpansUpdate(render_spans, crop_y2)
else:
render_lines = self._assemble_chops(chops)
@@ -625,7 +632,8 @@ class Compositor:
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
yield self.render()
if self._dirty_regions:
yield self.render()
def update_widgets(self, widgets: set[Widget]) -> None:
"""Update a given widget in the composition.

View File

@@ -1,15 +1,23 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import sys
from typing import ClassVar, NamedTuple, TYPE_CHECKING
from .geometry import Region, Offset, Size
if sys.version_info >= (3, 10):
from typing import TypeAlias
else: # pragma: no cover
from typing_extensions import TypeAlias
if TYPE_CHECKING:
from .widget import Widget
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
class WidgetPlacement(NamedTuple):
"""The position, size, and relative order of a widget within its parent."""
@@ -28,16 +36,54 @@ class Layout(ABC):
return f"<{self.name}>"
@abstractmethod
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
def arrange(self, parent: Widget, size: Size) -> ArrangeResult:
"""Generate a layout map that defines where on the screen the widgets will be drawn.
Args:
parent (Widget): Parent widget.
size (Size): Size of container.
scroll (Offset): Offset to apply to the Widget placements.
Returns:
Iterable[WidgetPlacement]: An iterable of widget location
"""
def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int:
"""Get the width of the content.
Args:
widget (Widget): The container widget.
container (Size): The container size.
viewport (Size): The viewport size.
Returns:
int: Width of the content.
"""
width: int | None = None
for child in widget.displayed_children:
if not child.is_container:
child_width = child.get_content_width(container, viewport)
width = child_width if width is None else max(width, child_width)
if width is None:
width = container.width
return width
def get_content_height(
self, widget: Widget, container: Size, viewport: Size, width: int
) -> int:
"""Get the content height.
Args:
widget (Widget): The container widget.
container (Size): The container size.
viewport (Size): The viewport.
width (int): The content width.
Returns:
int: Content height (in lines).
"""
if not widget.displayed_children:
height = container.height
else:
placements, widgets = self.arrange(widget, Size(width, container.height))
height = max(placement.region.y_max for placement in placements)
return height

View File

@@ -114,7 +114,6 @@ class App(Generic[ReturnType], DOMNode):
driver_class: Type[Driver] | None = None,
log_path: str | PurePath = "",
log_verbosity: int = 1,
# TODO: make this Literal a proper type in Rich, so we re-use it?
log_color_system: Literal[
"auto", "standard", "256", "truecolor", "windows"
] = "auto",
@@ -399,7 +398,7 @@ class App(Generic[ReturnType], DOMNode):
output = " ".join(str(arg) for arg in objects)
if kwargs:
key_values = " ".join(
f"{key}={value}" for key, value in kwargs.items()
f"{key}={value!r}" for key, value in kwargs.items()
)
output = f"{output} {key_values}" if output else key_values
if self._log_console is not None:
@@ -408,8 +407,8 @@ class App(Generic[ReturnType], DOMNode):
self.devtools.log(
DevtoolsLog(output, caller=_textual_calling_frame)
)
except Exception:
pass
except Exception as error:
self.on_exception(error)
def action_screenshot(self, path: str | None = None) -> None:
"""Action to save a screenshot."""
@@ -497,14 +496,17 @@ class App(Generic[ReturnType], DOMNode):
try:
time = perf_counter()
self.stylesheet.read(self.css_path)
stylesheet = self.stylesheet.copy()
stylesheet.read(self.css_path)
stylesheet.parse()
elapsed = (perf_counter() - time) * 1000
self.log(f"loaded {self.css_path} in {elapsed:.0f}ms")
self.log(f"<stylesheet> loaded {self.css_path!r} in {elapsed:.0f} ms")
except Exception as error:
# TODO: Catch specific exceptions
self.console.bell()
self.bell()
self.log(error)
else:
self.stylesheet = stylesheet
self.reset_styles()
self.stylesheet.update(self)
self.screen.refresh(layout=True)
@@ -669,7 +671,7 @@ class App(Generic[ReturnType], DOMNode):
def fatal_error(self) -> None:
"""Exits the app after an unhandled exception."""
self.console.bell()
self.bell()
traceback = Traceback(
show_locals=True, width=None, locals_max_length=5, suppress=[rich]
)
@@ -741,8 +743,7 @@ class App(Generic[ReturnType], DOMNode):
self.on_exception(error)
finally:
self._running = False
if self._exit_renderables:
self._print_error_renderables()
self._print_error_renderables()
if self.devtools.is_connected:
await self._disconnect_devtools()
if self._log_console is not None:
@@ -751,6 +752,7 @@ class App(Generic[ReturnType], DOMNode):
)
if self._log_file is not None:
self._log_file.close()
self._log_console = None
def on_mount(self) -> None:
widgets = list(self.compose())
@@ -834,19 +836,7 @@ class App(Generic[ReturnType], DOMNode):
await self.close_messages()
def refresh(self, *, repaint: bool = True, layout: bool = False) -> None:
if not self._running:
return
if not self._closed:
console = self.console
try:
if self._sync_available:
console.file.write("\x1bP=1s\x1b\\")
console.print(self.screen._compositor)
if self._sync_available:
console.file.write("\x1bP=2s\x1b\\")
console.file.flush()
except Exception as error:
self.on_exception(error)
self._display(self.screen._compositor)
def refresh_css(self, animate: bool = True) -> None:
"""Refresh CSS.
@@ -860,10 +850,13 @@ class App(Generic[ReturnType], DOMNode):
stylesheet.update(self.app, animate=animate)
self.refresh(layout=True)
def display(self, renderable: RenderableType) -> None:
if not self._running:
return
if not self._closed:
def _display(self, renderable: RenderableType) -> None:
"""Display a renderable within a sync.
Args:
renderable (RenderableType): A Rich renderable.
"""
if self._running and not self._closed:
console = self.console
if self._sync_available:
console.file.write("\x1bP=1s\x1b\\")

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import Callable, NamedTuple
from .geometry import Size, Spacing
from .css.styles import StylesBase
from .geometry import Size, Spacing
class BoxModel(NamedTuple):
@@ -32,55 +32,72 @@ def get_box_model(
Returns:
BoxModel: A tuple with the size of the content area and margin.
"""
has_rule = styles.has_rule
width, height = container
is_content_box = styles.box_sizing == "content-box"
gutter = styles.padding + styles.border.spacing
if not has_rule("width"):
width = container.width
elif styles.width.is_auto:
# When width is auto, we want enough space to always fit the content
width = get_content_width(container, viewport)
if not is_content_box:
# If box sizing is border box we want to enlarge the width so that it
# can accommodate padding + border
width += gutter.width
else:
width = styles.width.resolve_dimension(container, viewport)
if not has_rule("height"):
height = container.height
elif styles.height.is_auto:
height = get_content_height(container, viewport, width)
if not is_content_box:
height += gutter.height
else:
height = styles.height.resolve_dimension(container, viewport)
if is_content_box:
gutter_width, gutter_height = gutter.totals
width += gutter_width
height += gutter_height
if has_rule("min_width"):
min_width = styles.min_width.resolve_dimension(container, viewport)
width = max(width, min_width)
if has_rule("max_width"):
max_width = styles.max_width.resolve_dimension(container, viewport)
width = min(width, max_width)
if has_rule("min_height"):
min_height = styles.min_height.resolve_dimension(container, viewport)
height = max(height, min_height)
if has_rule("max_height"):
max_height = styles.max_height.resolve_dimension(container, viewport)
height = min(height, max_height)
size = Size(width, height)
content_width, content_height = container
is_border_box = styles.box_sizing == "border-box"
gutter = styles.gutter
margin = styles.margin
return BoxModel(size, margin)
is_auto_width = styles.width and styles.width.is_auto
is_auto_height = styles.height and styles.height.is_auto
# Container minus padding and border
content_container = container - gutter.totals
# The container including the content
sizing_container = content_container if is_border_box else container
if styles.width is None:
# No width specified, fill available space
content_width = content_container.width - margin.width
elif is_auto_width:
# When width is auto, we want enough space to always fit the content
content_width = get_content_width(
content_container - styles.margin.totals, viewport
)
else:
# An explicit width
content_width = styles.width.resolve_dimension(sizing_container, viewport)
if is_border_box:
content_width -= gutter.width
if styles.min_width is not None:
# Restrict to minimum width, if set
min_width = styles.min_width.resolve_dimension(content_container, viewport)
content_width = max(content_width, min_width)
if styles.max_width is not None:
# Restrict to maximum width, if set
max_width = styles.max_width.resolve_dimension(content_container, viewport)
content_width = min(content_width, max_width)
content_width = max(1, content_width)
if styles.height is None:
# No height specified, fill the available space
content_height = content_container.height - margin.height
elif is_auto_height:
# Calculate dimensions based on content
content_height = get_content_height(content_container, viewport, content_width)
else:
# Explicit height set
content_height = styles.height.resolve_dimension(sizing_container, viewport)
if is_border_box:
content_height -= gutter.height
if styles.min_height is not None:
# Restrict to minimum height, if set
min_height = styles.min_height.resolve_dimension(content_container, viewport)
content_height = max(content_height, min_height)
if styles.max_height is not None:
# Restrict maximum height, if set
max_height = styles.max_height.resolve_dimension(content_container, viewport)
content_height = min(content_height, max_height)
content_height = max(1, content_height)
# Get box dimensions by adding gutter
width = content_width + gutter.width
height = content_height + gutter.height
model = BoxModel(Size(width, height), margin)
return model

View File

@@ -27,6 +27,7 @@ from ._help_text import (
string_enum_help_text,
color_property_help_text,
)
from .._border import INVISIBLE_EDGE_TYPES, normalize_border_value
from ..color import Color, ColorPair, ColorParseError
from ._error_tools import friendly_list
from .constants import NULL_SPACING, VALID_STYLE_FLAGS
@@ -71,7 +72,7 @@ class ScalarProperty:
self.name = name
def __get__(
self, obj: StylesBase, objtype: type[Styles] | None = None
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> Scalar | None:
"""Get the scalar property
@@ -321,28 +322,37 @@ class BorderProperty:
clear_rule(bottom)
clear_rule(left)
return
if isinstance(border, tuple):
setattr(obj, top, border)
setattr(obj, right, border)
setattr(obj, bottom, border)
setattr(obj, left, border)
if isinstance(border, tuple) and len(border) == 2:
_border = normalize_border_value(border)
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]
_border = normalize_border_value(border[0])
setattr(obj, top, _border)
setattr(obj, right, _border)
setattr(obj, bottom, _border)
setattr(obj, left, _border)
elif count == 2:
_border1, _border2 = border
_border1, _border2 = (
normalize_border_value(border[0]),
normalize_border_value(border[1]),
)
setattr(obj, top, _border1)
setattr(obj, bottom, _border1)
setattr(obj, right, _border2)
setattr(obj, left, _border2)
elif count == 4:
_border1, _border2, _border3, _border4 = border
_border1, _border2, _border3, _border4 = (
normalize_border_value(border[0]),
normalize_border_value(border[1]),
normalize_border_value(border[3]),
normalize_border_value(border[4]),
)
setattr(obj, top, _border1)
setattr(obj, right, _border2)
setattr(obj, bottom, _border3)

View File

@@ -34,6 +34,7 @@ from .constants import (
VALID_OVERFLOW,
VALID_VISIBILITY,
VALID_STYLE_FLAGS,
VALID_SCROLLBAR_GUTTER,
)
from .errors import DeclarationError, StyleValueError
from .model import Declaration
@@ -41,7 +42,8 @@ from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError
from .styles import DockGroup, Styles
from .tokenize import Token
from .transition import Transition
from .types import BoxSizing, Edge, Display, Overflow, Visibility
from .types import BoxSizing, Edge, Display, Overflow, Visibility, EdgeType
from .._border import normalize_border_value, BorderValue
from ..color import Color, ColorParseError
from .._duration import _duration_as_seconds
from .._easing import EASING
@@ -416,8 +418,8 @@ class StylesBuilder:
process_padding_bottom = _process_space_partial
process_padding_left = _process_space_partial
def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]:
border_type = "solid"
def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
border_type: EdgeType = "solid"
border_color = Color(0, 255, 0)
def border_value_error():
@@ -443,7 +445,7 @@ class StylesBuilder:
else:
border_value_error()
return (border_type, border_color)
return normalize_border_value((border_type, border_color))
def _process_border_edge(self, edge: str, name: str, tokens: list[Token]) -> None:
border = self._parse_border(name, tokens)
@@ -770,6 +772,18 @@ class StylesBuilder:
process_content_align_horizontal = process_align_horizontal
process_content_align_vertical = process_align_vertical
def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER)
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(name, VALID_SCROLLBAR_GUTTER, context="css"),
)
else:
self.styles._rules[name.replace("-", "_")] = value
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
"""
Returns a valid CSS property "Python" name, or None if no close matches could be found.

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import sys
import typing
if sys.version_info >= (3, 8):
from typing import Final
@@ -7,12 +9,16 @@ else:
from ..geometry import Spacing
if typing.TYPE_CHECKING:
from .types import EdgeType
VALID_VISIBILITY: Final = {"visible", "hidden"}
VALID_DISPLAY: Final = {"block", "none"}
VALID_BORDER: Final = {
VALID_BORDER: Final[set[EdgeType]] = {
"none",
"hidden",
"round",
"blank",
"solid",
"double",
"dashed",
@@ -31,6 +37,7 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"}
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
VALID_STYLE_FLAGS: Final = {
"none",
"not",

View File

@@ -82,7 +82,9 @@ class Scalar(NamedTuple):
percent_unit: Unit
def __str__(self) -> str:
value, _unit, _ = self
value, unit, _ = self
if unit == Unit.AUTO:
return "auto"
return f"{int(value) if value.is_integer() else value}{self.symbol}"
@property

View File

@@ -38,6 +38,7 @@ from .constants import (
VALID_DISPLAY,
VALID_VISIBILITY,
VALID_OVERFLOW,
VALID_SCROLLBAR_GUTTER,
)
from .scalar import Scalar, ScalarOffset, Unit
from .scalar_animation import ScalarAnimation
@@ -52,6 +53,7 @@ from .types import (
Specificity4,
AlignVertical,
Visibility,
ScrollbarGutter,
)
if sys.version_info >= (3, 8):
@@ -125,6 +127,8 @@ class RulesMap(TypedDict, total=False):
scrollbar_background_hover: Color
scrollbar_background_active: Color
scrollbar_gutter: ScrollbarGutter
align_horizontal: AlignHorizontal
align_vertical: AlignVertical
@@ -222,6 +226,8 @@ class StylesBase(ABC):
scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black")
scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto")
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
@@ -239,7 +245,7 @@ class StylesBase(ABC):
"""Get space around widget.
Returns:
Spacing: Space around widget.
Spacing: Space around widget content.
"""
spacing = self.padding + self.border.spacing
return spacing
@@ -656,6 +662,8 @@ class Styles(StylesBase):
append_declaration("overflow-x", self.overflow_x)
if has_rule("overflow-y"):
append_declaration("overflow-y", self.overflow_y)
if has_rule("scrollbar-gutter"):
append_declaration("scrollbar-gutter", self.scrollbar_gutter)
if has_rule("box-sizing"):
append_declaration("box-sizing", self.box_sizing)

View File

@@ -141,6 +141,16 @@ class Stylesheet:
def css(self) -> str:
return "\n\n".join(rule_set.css for rule_set in self.rules)
def copy(self) -> Stylesheet:
"""Create a copy of this stylesheet.
Returns:
Stylesheet: New stylesheet.
"""
stylesheet = Stylesheet(variables=self.variables.copy())
stylesheet.source = self.source.copy()
return stylesheet
def set_variables(self, variables: dict[str, str]) -> None:
"""Set CSS variables.
@@ -315,7 +325,7 @@ class Stylesheet:
styles = node.styles
base_styles = styles.base
# Styles currently used an new rules
# Styles currently used on new rules
modified_rule_keys = {*base_styles.get_rules().keys(), *rules.keys()}
# Current render rules (missing rules are filled with default)
current_render_rules = styles.get_render_rules()

View File

@@ -16,6 +16,7 @@ EdgeType = Literal[
"",
"none",
"hidden",
"blank",
"round",
"solid",
"double",
@@ -32,8 +33,9 @@ Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"]
AlignHorizontal = Literal["left", "center", "right"]
AlignVertical = Literal["top", "middle", "bottom"]
ScrollbarGutter = Literal["auto", "stable"]
BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[str, Color]
EdgeStyle = Tuple[EdgeType, Color]
Specificity3 = Tuple[int, int, int]
Specificity4 = Tuple[int, int, int, int]

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import datetime
import inspect
import json
import msgpack

View File

@@ -270,7 +270,7 @@ class DOMNode(MessagePump):
return tree
@property
def rich_text_style(self) -> Style:
def text_style(self) -> Style:
"""Get the text style object.
A widget's style is influenced by its parent. For instance if a widgets background has an alpha,
@@ -283,19 +283,30 @@ class DOMNode(MessagePump):
# TODO: Feels like there may be opportunity for caching here.
background = Color(0, 0, 0, 0)
color = Color(255, 255, 255, 0)
style = Style()
for node in reversed(self.ancestors):
style += node.styles.text_style
return style
@property
def colors(self) -> tuple[tuple[Color, Color], tuple[Color, Color]]:
"""Gets the Widgets foreground and background colors, and its parent's colors.
Returns:
tuple[tuple[Color, Color], tuple[Color, Color]]: Base colors and widget colors
"""
base_background = background = Color(0, 0, 0, 0)
base_color = color = Color(255, 255, 255, 0)
for node in reversed(self.ancestors):
styles = node.styles
if styles.has_rule("background"):
base_background = background
background += styles.background
if styles.has_rule("color"):
color = styles.color
style += styles.text_style
style = Style(bgcolor=background.rich_color, color=color.rich_color) + style
return style
base_color = color
color += styles.color
return (base_background, base_color), (background, color)
@property
def ancestors(self) -> list[DOMNode]:

View File

@@ -484,13 +484,13 @@ class Region(NamedTuple):
)
def intersection(self, region: Region) -> Region:
"""Get that covers both regions.
"""Get the overlapping portion of the two regions.
Args:
region (Region): A region that overlaps this region.
Returns:
Region: A new region that fits within ``region``.
Region: A new region that covers when the two regions overlap.
"""
# Unrolled because this method is used a lot
x1, y1, w1, h1 = self
@@ -511,10 +511,10 @@ class Region(NamedTuple):
"""Get a new region that contains both regions.
Args:
region (Region): [description]
region (Region): Another region.
Returns:
Region: [description]
Region: An optimally sized region to cover both regions.
"""
x1, y1, x2, y2 = self.corners
ox1, oy1, ox2, oy2 = region.corners

View File

@@ -9,7 +9,7 @@ from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence
from .._layout_resolve import layout_resolve
from ..css.types import Edge
from ..geometry import Offset, Region, Size
from .._layout import Layout, WidgetPlacement
from .._layout import ArrangeResult, Layout, WidgetPlacement
from ..widget import Widget
if sys.version_info >= (3, 8):
@@ -26,9 +26,13 @@ DockEdge = Literal["top", "right", "bottom", "left"]
@dataclass
class DockOptions:
size: int | None = None
fraction: int = 1
fraction: int | None = 1
min_size: int = 1
def __post_init__(self) -> None:
if self.size is None and self.fraction is None:
self.fraction = 1
class Dock(NamedTuple):
edge: Edge
@@ -59,9 +63,7 @@ class DockLayout(Layout):
append_dock(Dock(edge, groups[name], z))
return docks
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
def arrange(self, parent: Widget, size: Size) -> ArrangeResult:
width, height = size
layout_region = Region(0, 0, width, height)
@@ -73,6 +75,7 @@ class DockLayout(Layout):
styles = widget.styles
has_rule = styles.has_rule
# TODO: This was written pre resolve_dimension, we should update this to use available units
return (
DockOptions(
styles.width.cells if has_rule("width") else None,

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from typing import cast
from textual.geometry import Size, Offset, Region
from textual._layout import Layout, WidgetPlacement
from textual._layout import ArrangeResult, Layout, WidgetPlacement
from textual.widget import Widget
@@ -15,9 +15,7 @@ class HorizontalLayout(Layout):
name = "horizontal"
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
def arrange(self, parent: Widget, size: Size) -> ArrangeResult:
placements: list[WidgetPlacement] = []
add_placement = placements.append
@@ -50,8 +48,6 @@ class HorizontalLayout(Layout):
x += region.width + margin
max_width = x
max_width += margins[-1] if margins else 0
total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0))

View File

@@ -2,8 +2,8 @@ from __future__ import annotations
from typing import cast, TYPE_CHECKING
from ..geometry import Offset, Region, Size
from .._layout import Layout, WidgetPlacement
from ..geometry import Region, Size
from .._layout import ArrangeResult, Layout, WidgetPlacement
if TYPE_CHECKING:
from ..widget import Widget
@@ -14,14 +14,11 @@ class VerticalLayout(Layout):
name = "vertical"
def arrange(
self, parent: Widget, size: Size, scroll: Offset
) -> tuple[list[WidgetPlacement], set[Widget]]:
def arrange(self, parent: Widget, size: Size) -> ArrangeResult:
placements: list[WidgetPlacement] = []
add_placement = placements.append
max_width = max_height = 0
parent_size = parent.size
box_models = [
@@ -45,11 +42,8 @@ class VerticalLayout(Layout):
region = Region(offset_x, y, content_width, content_height)
add_placement(WidgetPlacement(region, widget, 0))
y += region.height + margin
max_height = y
max_height += margins[-1] if margins else 0
total_region = Region(0, 0, max_width, max_height)
total_region = Region(0, 0, size.width, y)
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(displayed_children)

View File

@@ -198,13 +198,14 @@ class MessagePump:
return
self._closing = True
await self._message_queue.put(MessagePriority(None))
for task in self._child_tasks:
task.cancel()
await task
self._child_tasks.clear()
if self._task is not None:
# Ensure everything is closed before returning
await self._task
def start_messages(self) -> None:
self._task = asyncio.create_task(self.process_messages())

View File

@@ -91,17 +91,20 @@ class Screen(Widget):
# Check for any widgets marked as 'dirty' (needs a repaint)
if self._dirty_widgets:
self._update_timer.resume()
if self._layout_required:
self._refresh_layout()
self._layout_required = False
def _on_update(self) -> None:
"""Called by the _update_timer."""
# Render widgets together
if self._dirty_widgets:
self._compositor.update_widgets(self._dirty_widgets)
self.app.display(self._compositor.render())
self.app._display(self._compositor.render())
self._dirty_widgets.clear()
self._update_timer.pause()
def refresh_layout(self) -> None:
def _refresh_layout(self) -> None:
"""Refresh the layout (can change size and positions of widgets)."""
if not self.size:
return
@@ -140,7 +143,7 @@ class Screen(Widget):
display_update = self._compositor.render()
if display_update is not None:
self.app.display(display_update)
self.app._display(display_update)
async def handle_update(self, message: messages.Update) -> None:
message.stop()
@@ -151,14 +154,15 @@ class Screen(Widget):
async def handle_layout(self, message: messages.Layout) -> None:
message.stop()
self.refresh_layout()
self._layout_required = True
self.check_idle()
def on_mount(self, event: events.Mount) -> None:
self._update_timer = self.set_interval(1 / 20, self._on_update, pause=True)
async def on_resize(self, event: events.Resize) -> None:
self.size_updated(event.size, event.virtual_size, event.container_size)
self.refresh_layout()
self._refresh_layout()
event.stop()
async def _on_mouse_move(self, event: events.MouseMove) -> None:

View File

@@ -42,7 +42,7 @@ class DockView(Screen):
await self.mount(widget)
else:
await self.mount(**{name: widget})
await self.refresh_layout()
await self._refresh_layout()
async def dock_grid(
self,
@@ -68,5 +68,5 @@ class DockView(Screen):
await self.mount(view)
else:
await self.mount(**{name: view})
await self.refresh_layout()
await self._refresh_layout()
return grid

View File

@@ -23,10 +23,8 @@ from . import events
from ._animator import BoundAnimator
from ._border import Border
from .box_model import BoxModel, get_box_model
from .color import Color
from ._context import active_app
from ._types import Lines
from .css.styles import Styles
from .dom import DOMNode
from .geometry import clamp, Offset, Region, Size
from .layouts.vertical import VerticalLayout
@@ -66,12 +64,12 @@ class RenderCache(NamedTuple):
@rich.repr.auto
class Widget(DOMNode):
can_focus: bool = False
can_focus_children: bool = True
CSS = """
"""
can_focus: bool = False
can_focus_children: bool = True
def __init__(
self,
*children: Widget,
@@ -96,6 +94,11 @@ class Widget(DOMNode):
self._render_cache = RenderCache(Size(0, 0), [])
self._dirty_regions: list[Region] = []
# Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this
self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0)
super().__init__(name=name, id=id, classes=classes)
self.add_children(*children)
@@ -111,6 +114,26 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_scrollbar = Reactive(False, layout=True)
def watch_show_horizontal_scrollbar(self, value: bool) -> None:
"""Watch function for show_horizontal_scrollbar attribute.
Args:
value (bool): Show horizontal scrollbar flag.
"""
if not value:
# reset the scroll position if the scrollbar is hidden.
self.scroll_to(0, 0, animate=False)
def watch_show_vertical_scrollbar(self, value: bool) -> None:
"""Watch function for show_vertical_scrollbar attribute.
Args:
value (bool): Show vertical scrollbar flag.
"""
if not value:
# reset the scroll position if the scrollbar is hidden.
self.scroll_to(0, 0, animate=False)
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.app.register(self, *anon_widgets, **widgets)
self.screen.refresh()
@@ -150,39 +173,66 @@ class Widget(DOMNode):
)
return box_model
def get_content_width(self, container_size: Size, viewport_size: Size) -> int:
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Gets the width of the content area.
Args:
container_size (Size): Size of the container (immediate parent) widget.
viewport_size (Size): Size of the viewport.
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
Returns:
int: The optimal width of the content.
"""
if self.is_container:
return self.layout.get_content_width(self, container, viewport)
cache_key = container.width
if self._content_width_cache[0] == cache_key:
return self._content_width_cache[1]
console = self.app.console
renderable = self.render(self.styles.rich_style)
measurement = Measurement.get(console, console.options, renderable)
return measurement.maximum
measurement = Measurement.get(
console,
console.options.update_width(container.width),
renderable,
)
width = measurement.maximum
self._content_width_cache = (cache_key, width)
return width
def get_content_height(
self, container_size: Size, viewport_size: Size, width: int
) -> int:
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
"""Gets the height (number of lines) in the content area.
Args:
container_size (Size): Size of the container (immediate parent) widget.
viewport_size (Size): Size of the viewport.
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
width (int): Width of renderable.
Returns:
int: The height of the content.
"""
renderable = self.render(self.styles.rich_style)
options = self.console.options.update_width(width)
segments = self.console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum(text.count("\n") for text, _, _ in segments)
if self.is_container:
assert self.layout is not None
height = self.layout.get_content_height(
self,
container,
viewport,
width,
)
else:
cache_key = width
if self._content_height_cache[0] == cache_key:
return self._content_height_cache[1]
renderable = self.render(self.styles.rich_style)
options = self.console.options.update_width(width).update(highlight=False)
segments = self.console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum(text.count("\n") for text, _, _ in segments)
self._content_height_cache = (cache_key, height)
return height
async def watch_scroll_x(self, new_value: float) -> None:
@@ -492,6 +542,9 @@ class Widget(DOMNode):
Region: The widget region minus scrollbars.
"""
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
if self.styles.scrollbar_gutter == "stable":
# Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
show_vertical_scrollbar = True
if show_horizontal_scrollbar and show_vertical_scrollbar:
(region, _, _, _) = region.split(-1, -1)
elif show_vertical_scrollbar:
@@ -553,32 +606,28 @@ class Widget(DOMNode):
Returns:
RenderableType: A new renderable.
"""
renderable = self.render(self.styles.rich_style)
renderable = self.render(self.text_style)
(base_background, base_color), (background, color) = self.colors
styles = self.styles
parent_styles = self.parent.styles
parent_text_style = self.parent.rich_text_style
text_style = styles.rich_style
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
if content_align != ("left", "top"):
horizontal, vertical = content_align
renderable = Align(renderable, horizontal, vertical=vertical)
renderable = Padding(renderable, styles.padding)
renderable_text_style = parent_text_style + text_style
if renderable_text_style:
style = Style.from_color(text_style.color, text_style.bgcolor)
renderable = Styled(renderable, style)
renderable = Padding(
renderable,
styles.padding,
style=Style.from_color(color.rich_color, background.rich_color),
)
if styles.border:
renderable = Border(
renderable,
styles.border,
inner_color=styles.background,
outer_color=Color.from_rich_color(parent_text_style.bgcolor),
inner_color=background,
outer_color=base_background,
)
if styles.outline:
@@ -586,7 +635,7 @@ class Widget(DOMNode):
renderable,
styles.outline,
inner_color=styles.background,
outer_color=parent_styles.background,
outer_color=base_background,
outline=True,
)
@@ -654,13 +703,9 @@ class Widget(DOMNode):
return self._animate
@property
def layout(self) -> Layout | None:
return self.styles.layout or (
# If we have children we _should_ return a layout, otherwise they won't be displayed:
self._default_layout
if self.children
else None
)
def layout(self) -> Layout:
"""Get the layout object if set in styles, or a default layout."""
return self.styles.layout or self._default_layout
@property
def is_container(self) -> bool:
@@ -701,6 +746,8 @@ class Widget(DOMNode):
self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_size = width
self.scroll_x = self.validate_scroll_x(self.scroll_x)
self.scroll_y = self.validate_scroll_y(self.scroll_y)
self.refresh(layout=True)
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
else:
@@ -743,9 +790,6 @@ class Widget(DOMNode):
"""Check if a layout has been requested."""
return self._layout_required
def _reset_check_layout(self) -> None:
self._layout_required = False
def get_style_at(self, x: int, y: int) -> Style:
offset_x, offset_y = self.screen.get_offset(self)
return self.screen.get_style_at(x + offset_x, y + offset_y)
@@ -803,7 +847,7 @@ class Widget(DOMNode):
"""
if self.check_layout():
self._reset_check_layout()
self._layout_required = False
self.screen.post_message_no_wait(messages.Layout(self))
elif self._repaint_required:
self.emit_no_wait(messages.Update(self, self))
@@ -849,7 +893,7 @@ class Widget(DOMNode):
widgets = list(self.compose())
if widgets:
self.mount(*widgets)
self.screen.refresh()
self.screen.refresh(repaint=False, layout=True)
def on_leave(self) -> None:
self.mouse_over = False
@@ -860,10 +904,12 @@ class Widget(DOMNode):
def on_focus(self, event: events.Focus) -> None:
self.emit_no_wait(events.DescendantFocus(self))
self.has_focus = True
self.refresh()
def on_blur(self, event: events.Blur) -> None:
self.emit_no_wait(events.DescendantBlur(self))
self.has_focus = False
self.refresh()
def on_descendant_focus(self, event: events.DescendantFocus) -> None:
self.descendant_has_focus = True
@@ -875,13 +921,13 @@ class Widget(DOMNode):
def on_mouse_scroll_down(self, event) -> None:
if self.is_container:
self.scroll_down(animate=False)
event.stop()
if self.scroll_down(animate=False):
event.stop()
def on_mouse_scroll_up(self, event) -> None:
if self.is_container:
self.scroll_up(animate=False)
event.stop()
if self.scroll_up(animate=False):
event.stop()
def handle_scroll_to(self, message: ScrollTo) -> None:
if self.is_container:

View File

@@ -19,6 +19,19 @@ class Placeholder(Widget, can_focus=True):
has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
def __init__(
# parent class constructor signature:
self,
*children: Widget,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
# ...and now for our own class specific params:
title: str | None = None,
) -> None:
super().__init__(*children, name=name, id=id, classes=classes)
self.title = title
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()
yield "has_focus", self.has_focus, False
@@ -32,7 +45,7 @@ class Placeholder(Widget, can_focus=True):
Pretty(self, no_wrap=True, overflow="ellipsis"),
vertical="middle",
),
title=self.__class__.__name__,
title=self.title or self.__class__.__name__,
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED,
)

View File

@@ -1,9 +1,16 @@
import sys
from decimal import Decimal
if sys.version_info >= (3, 10):
from typing import Literal
else: # pragma: no cover
from typing_extensions import Literal
import pytest
from rich.style import Style
from textual.app import ComposeResult
from textual.color import Color
from textual.css.errors import StyleValueError
from textual.css.scalar import Scalar, Unit
@@ -11,6 +18,8 @@ from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode
from textual.widget import Widget
from tests.utilities.test_app import AppTest
def test_styles_reset():
styles = Styles()
@@ -185,3 +194,77 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in
with pytest.raises(StyleValueError):
widget.styles.width = size_dimension_input
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overflow_y,scrollbar_gutter,text_length,expected_text_widget_width,expects_vertical_scrollbar",
(
# ------------------------------------------------
# ----- Let's start with `overflow-y: auto`:
# short text: full width, no scrollbar
["auto", "auto", "short_text", 80, False],
# long text: reduced width, scrollbar
["auto", "auto", "long_text", 79, True],
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["auto", "stable", "short_text", 79, False],
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
["auto", "stable", "long_text", 79, True],
# ------------------------------------------------
# ----- And now let's see the behaviour with `overflow-y: scroll`:
# short text: reduced width, scrollbar
["scroll", "auto", "short_text", 79, True],
# long text: reduced width, scrollbar
["scroll", "auto", "long_text", 79, True],
# short text, `scrollbar-gutter: stable`: reduced width, scrollbar
["scroll", "stable", "short_text", 79, True],
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
["scroll", "stable", "long_text", 79, True],
# ------------------------------------------------
# ----- Finally, let's check the behaviour with `overflow-y: hidden`:
# short text: full width, no scrollbar
["hidden", "auto", "short_text", 80, False],
# long text: full width, no scrollbar
["hidden", "auto", "long_text", 80, False],
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["hidden", "stable", "short_text", 79, False],
# long text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["hidden", "stable", "long_text", 79, False],
),
)
async def test_scrollbar_gutter(
overflow_y: str,
scrollbar_gutter: str,
text_length: Literal["short_text", "long_text"],
expected_text_widget_width: int,
expects_vertical_scrollbar: bool,
):
from rich.text import Text
from textual.geometry import Size
class TextWidget(Widget):
def render(self, styles) -> Text:
text_multiplier = 10 if text_length == "long_text" else 2
return Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a."
* text_multiplier
)
container = Widget()
container.styles.height = 3
container.styles.overflow_y = overflow_y
container.styles.scrollbar_gutter = scrollbar_gutter
text_widget = TextWidget()
text_widget.styles.height = "auto"
container.add_child(text_widget)
class MyTestApp(AppTest):
def compose(self) -> ComposeResult:
yield container
app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
await app.boot_and_shutdown()
assert text_widget.size.width == expected_text_widget_width
assert container.scrollbars_enabled[0] is expects_vertical_scrollbar

View File

@@ -59,7 +59,7 @@ def test_width():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(54, 16), Spacing(1, 2, 3, 4))
# Set width to auto-detect
styles.width = "auto"
@@ -68,7 +68,7 @@ def test_width():
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
# Setting width to auto should call get_auto_width
assert box_model == BoxModel(Size(10, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(10, 16), Spacing(1, 2, 3, 4))
# Set width to 100 vw which should make it the width of the parent
styles.width = "100vw"
@@ -76,7 +76,7 @@ def test_width():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(80, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(80, 16), Spacing(1, 2, 3, 4))
# Set the width to 100% should make it fill the container size
styles.width = "100%"
@@ -84,7 +84,7 @@ def test_width():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(60, 16), Spacing(1, 2, 3, 4))
styles.width = "100vw"
styles.max_width = "50%"
@@ -92,7 +92,7 @@ def test_width():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(30, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(30, 16), Spacing(1, 2, 3, 4))
def test_height():
@@ -116,7 +116,7 @@ def test_height():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(54, 16), Spacing(1, 2, 3, 4))
# Set width to 100 vw which should make it the width of the parent
styles.height = "100vh"
@@ -124,7 +124,7 @@ def test_height():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 24), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(54, 24), Spacing(1, 2, 3, 4))
# Set the width to 100% should make it fill the container size
styles.height = "100%"
@@ -132,7 +132,7 @@ def test_height():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(54, 20), Spacing(1, 2, 3, 4))
styles.height = "100vh"
styles.max_height = "50%"
@@ -140,7 +140,7 @@ def test_height():
box_model = get_box_model(
styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height
)
assert box_model == BoxModel(Size(60, 10), Spacing(1, 2, 3, 4))
assert box_model == BoxModel(Size(54, 10), Spacing(1, 2, 3, 4))
def test_max():

View File

@@ -3,9 +3,12 @@ import asyncio
from typing import cast, List
import pytest
from rich.console import RenderableType
from rich.text import Text
from tests.utilities.test_app import AppTest
from textual.app import ComposeResult
from textual.css.types import EdgeType
from textual.geometry import Size
from textual.widget import Widget
from textual.widgets import Placeholder
@@ -31,30 +34,20 @@ PLACEHOLDERS_DEFAULT_H = 3 # the default height for our Placeholder widgets
"expected_placeholders_offset_x",
),
(
[
SCREEN_SIZE,
1,
"border: ;", # #root has no border
"", # no specific placeholder style
# #root's virtual size=screen size
(SCREEN_W, SCREEN_H),
# placeholders width=same than screen :: height=default height
(SCREEN_W, PLACEHOLDERS_DEFAULT_H),
# placeholders should be at offset 0
0,
],
[
# "none" borders still allocate a space for the (invisible) border
SCREEN_SIZE,
1,
"border: none;", # #root has an invisible border
"", # no specific placeholder style
# #root's virtual size is smaller because of its borders
(SCREEN_W - 2, SCREEN_H - 2),
# placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders
(SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
# placeholders should be at offset 1 because of #root's border
1,
*[
[
SCREEN_SIZE,
1,
f"border: {invisible_border_edge};", # #root has no visible border
"", # no specific placeholder style
# #root's virtual size=screen size
(SCREEN_W, SCREEN_H),
# placeholders width=same than screen :: height=default height
(SCREEN_W, PLACEHOLDERS_DEFAULT_H),
# placeholders should be at offset 0
0,
]
for invisible_border_edge in ("", "none", "hidden")
],
[
SCREEN_SIZE,
@@ -169,3 +162,75 @@ async def test_composition_of_vertical_container_with_children(
assert placeholder.size == expected_placeholders_size
assert placeholder.styles.offset.x.value == 0.0
assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x
@pytest.mark.asyncio
@pytest.mark.integration_test
@pytest.mark.parametrize(
"edge_type,expected_box_inner_size,expected_box_size,expected_top_left_edge_color,expects_visible_char_at_top_left_edge",
(
# These first 3 types of border edge types are synonyms, and display no borders:
["", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
["none", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
["hidden", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
# Let's transition to "blank": we still see no visible border, but the size is increased
# as the gutter space is reserved the same way it would be with a border:
["blank", Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", False],
# And now for the "normally visible" border edge types:
# --> we see a visible border, and the size is increased:
*[
[edge_style, Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", True]
for edge_style in [
"round",
"solid",
"double",
"dashed",
"heavy",
"inner",
"outer",
"hkey",
"vkey",
"tall",
"wide",
]
],
),
)
async def test_border_edge_types_impact_on_widget_size(
edge_type: EdgeType,
expected_box_inner_size: Size,
expected_box_size: Size,
expected_top_left_edge_color: str,
expects_visible_char_at_top_left_edge: bool,
):
class BorderTarget(Widget):
def render(self, style) -> RenderableType:
return Text("border target", style="black on yellow", justify="center")
border_target = BorderTarget()
border_target.styles.height = "auto"
border_target.styles.border = (edge_type, "white")
class MyTestApp(AppTest):
def compose(self) -> ComposeResult:
yield border_target
app = MyTestApp(size=SCREEN_SIZE, test_name="border_edge_types")
await app.boot_and_shutdown()
box_inner_size = Size(
border_target.content_region.width,
border_target.content_region.height,
)
assert box_inner_size == expected_box_inner_size
assert border_target.size == expected_box_size
top_left_edge_style = app.screen.get_style_at(0, 0)
top_left_edge_color = top_left_edge_style.color.name
assert top_left_edge_color == expected_top_left_edge_color
top_left_edge_char = app.get_char_at(0, 0)
top_left_edge_char_is_a_visible_one = top_left_edge_char != " "
assert top_left_edge_char_is_a_visible_one == expects_visible_char_at_top_left_edge

View File

@@ -8,7 +8,7 @@ from typing import AsyncContextManager, cast
from rich.console import Console
from textual import events
from textual import events, errors
from textual.app import App, ReturnType, ComposeResult
from textual.driver import Driver
from textual.geometry import Size
@@ -38,6 +38,9 @@ class AppTest(App):
log_color_system="256",
)
# Let's disable all features by default
self.features = frozenset()
# We need this so the `CLEAR_SCREEN_SEQUENCE` is always sent for a screen refresh,
# whatever the environment:
self._sync_available = True
@@ -90,6 +93,19 @@ class AppTest(App):
return get_running_state_context_manager()
async def boot_and_shutdown(
self,
*,
waiting_duration_after_initialisation: float = 0.001,
waiting_duration_before_shutdown: float = 0,
):
"""Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases"""
async with self.in_running_state(
waiting_duration_after_initialisation=waiting_duration_after_initialisation,
waiting_duration_post_yield=waiting_duration_before_shutdown,
):
pass
def run(self):
raise NotImplementedError(
"Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"
@@ -107,6 +123,36 @@ class AppTest(App):
last_display_start_index = total_capture.rindex(CLEAR_SCREEN_SEQUENCE)
return total_capture[last_display_start_index:]
def get_char_at(self, x: int, y: int) -> str:
"""Get the character at the given cell or empty string
Args:
x (int): X position within the Layout
y (int): Y position within the Layout
Returns:
str: The character at the cell (x, y) within the Layout
"""
# N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()`
try:
widget, region = self.get_widget_at(x, y)
except errors.NoWidget:
return ""
if widget not in self.screen._compositor.regions:
return ""
x -= region.x
y -= region.y
lines = widget.get_render_lines(y, y + 1)
if not lines:
return ""
end = 0
for segment in lines[0]:
end += segment.cell_length
if x < end:
return segment.text[0]
return ""
@property
def console(self) -> ConsoleTest:
return self._console