mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #527 from Textualize/auto-dimensions
Auto dimensions
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;
|
||||
@@ -85,9 +85,9 @@ Tweet {
|
||||
padding: 1;
|
||||
border: wide $panel-darken-2;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
/* scrollbar-gutter: stable; */
|
||||
align-horizontal: center;
|
||||
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
@@ -114,11 +114,11 @@ TweetHeader {
|
||||
}
|
||||
|
||||
TweetBody {
|
||||
width: 100%;
|
||||
width: 100w;
|
||||
background: $panel;
|
||||
color: $text-panel;
|
||||
height: auto;
|
||||
padding: 0 1 0 0;
|
||||
padding: 0 1 0 0;
|
||||
|
||||
}
|
||||
|
||||
@@ -223,4 +223,4 @@ Success {
|
||||
|
||||
.horizontal {
|
||||
layout: horizontal
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -7,8 +7,9 @@ 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, Color
|
||||
from rich.style import Style
|
||||
|
||||
from .color import Color
|
||||
from .css.types import EdgeStyle, EdgeType
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
@@ -186,7 +187,7 @@ class Border:
|
||||
|
||||
if has_top:
|
||||
lines.pop(0)
|
||||
if has_bottom:
|
||||
if has_bottom and lines:
|
||||
lines.pop(-1)
|
||||
|
||||
divide = Segment.divide
|
||||
@@ -231,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -398,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:
|
||||
@@ -407,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."""
|
||||
@@ -496,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)
|
||||
@@ -668,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]
|
||||
)
|
||||
@@ -740,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:
|
||||
@@ -750,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())
|
||||
@@ -833,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.
|
||||
@@ -859,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
|
||||
|
||||
@@ -72,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -245,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -556,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:
|
||||
@@ -589,7 +635,7 @@ class Widget(DOMNode):
|
||||
renderable,
|
||||
styles.outline,
|
||||
inner_color=styles.background,
|
||||
outer_color=parent_styles.background,
|
||||
outer_color=base_background,
|
||||
outline=True,
|
||||
)
|
||||
|
||||
@@ -657,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:
|
||||
@@ -704,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:
|
||||
@@ -746,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)
|
||||
@@ -806,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))
|
||||
@@ -852,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
|
||||
@@ -863,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
|
||||
@@ -878,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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user