mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'css' of github.com:Textualize/textual into text-input-cursor-to-click
This commit is contained in:
@@ -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
16
sandbox/auto_test.css
Normal 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
20
sandbox/auto_test.py
Normal 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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
67
sandbox/borders.py
Normal 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()
|
||||
@@ -3,6 +3,10 @@
|
||||
background: rebeccapurple;
|
||||
}
|
||||
|
||||
*:focus {
|
||||
tint: yellow 50%;
|
||||
}
|
||||
|
||||
#foo:hover {
|
||||
background: greenyellow;
|
||||
}
|
||||
|
||||
18
sandbox/horizontal.css
Normal file
18
sandbox/horizontal.css
Normal 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
26
sandbox/horizontal.py
Normal 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
25
sandbox/nest.css
Normal 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
38
sandbox/nest.py
Normal 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()
|
||||
@@ -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
22
sandbox/vertical.css
Normal 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
29
sandbox/vertical.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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\\")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import inspect
|
||||
import json
|
||||
import msgpack
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user