Merge pull request #527 from Textualize/auto-dimensions

Auto dimensions
This commit is contained in:
Will McGugan
2022-05-20 12:04:48 +01:00
committed by GitHub
32 changed files with 540 additions and 209 deletions

View File

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

16
sandbox/auto_test.css Normal file
View File

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

20
sandbox/auto_test.py Normal file
View File

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

View File

@@ -69,12 +69,12 @@ App > Screen {
color: $text-background;
background: $background;
layout: vertical;
overflow-y: scroll;
overflow-y: scroll;
}
Tweet {
height: 12;
height: auto;
width: 80;
margin: 1 3;
@@ -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
}
}

View File

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

18
sandbox/horizontal.css Normal file
View File

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

26
sandbox/horizontal.py Normal file
View File

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

25
sandbox/nest.css Normal file
View File

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

38
sandbox/nest.py Normal file
View File

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

View File

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

22
sandbox/vertical.css Normal file
View File

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

29
sandbox/vertical.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -141,6 +141,16 @@ class Stylesheet:
def css(self) -> str:
return "\n\n".join(rule_set.css for rule_set in self.rules)
def copy(self) -> Stylesheet:
"""Create a copy of this stylesheet.
Returns:
Stylesheet: New stylesheet.
"""
stylesheet = Stylesheet(variables=self.variables.copy())
stylesheet.source = self.source.copy()
return stylesheet
def set_variables(self, variables: dict[str, str]) -> None:
"""Set CSS variables.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,10 +23,8 @@ from . import events
from ._animator import BoundAnimator
from ._border import Border
from .box_model import BoxModel, get_box_model
from .color import Color
from ._context import active_app
from ._types import Lines
from .css.styles import Styles
from .dom import DOMNode
from .geometry import clamp, Offset, Region, Size
from .layouts.vertical import VerticalLayout
@@ -66,12 +64,12 @@ class RenderCache(NamedTuple):
@rich.repr.auto
class Widget(DOMNode):
can_focus: bool = False
can_focus_children: bool = True
CSS = """
"""
can_focus: bool = False
can_focus_children: bool = True
def __init__(
self,
*children: Widget,
@@ -96,6 +94,11 @@ class Widget(DOMNode):
self._render_cache = RenderCache(Size(0, 0), [])
self._dirty_regions: list[Region] = []
# Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this
self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0)
super().__init__(name=name, id=id, classes=classes)
self.add_children(*children)
@@ -111,6 +114,26 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_scrollbar = Reactive(False, layout=True)
def watch_show_horizontal_scrollbar(self, value: bool) -> None:
"""Watch function for show_horizontal_scrollbar attribute.
Args:
value (bool): Show horizontal scrollbar flag.
"""
if not value:
# reset the scroll position if the scrollbar is hidden.
self.scroll_to(0, 0, animate=False)
def watch_show_vertical_scrollbar(self, value: bool) -> None:
"""Watch function for show_vertical_scrollbar attribute.
Args:
value (bool): Show vertical scrollbar flag.
"""
if not value:
# reset the scroll position if the scrollbar is hidden.
self.scroll_to(0, 0, animate=False)
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.app.register(self, *anon_widgets, **widgets)
self.screen.refresh()
@@ -150,39 +173,66 @@ class Widget(DOMNode):
)
return box_model
def get_content_width(self, container_size: Size, viewport_size: Size) -> int:
def get_content_width(self, container: Size, viewport: Size) -> int:
"""Gets the width of the content area.
Args:
container_size (Size): Size of the container (immediate parent) widget.
viewport_size (Size): Size of the viewport.
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
Returns:
int: The optimal width of the content.
"""
if self.is_container:
return self.layout.get_content_width(self, container, viewport)
cache_key = container.width
if self._content_width_cache[0] == cache_key:
return self._content_width_cache[1]
console = self.app.console
renderable = self.render(self.styles.rich_style)
measurement = Measurement.get(console, console.options, renderable)
return measurement.maximum
measurement = Measurement.get(
console,
console.options.update_width(container.width),
renderable,
)
width = measurement.maximum
self._content_width_cache = (cache_key, width)
return width
def get_content_height(
self, container_size: Size, viewport_size: Size, width: int
) -> int:
def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
"""Gets the height (number of lines) in the content area.
Args:
container_size (Size): Size of the container (immediate parent) widget.
viewport_size (Size): Size of the viewport.
container (Size): Size of the container (immediate parent) widget.
viewport (Size): Size of the viewport.
width (int): Width of renderable.
Returns:
int: The height of the content.
"""
renderable = self.render(self.styles.rich_style)
options = self.console.options.update_width(width)
segments = self.console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum(text.count("\n") for text, _, _ in segments)
if self.is_container:
assert self.layout is not None
height = self.layout.get_content_height(
self,
container,
viewport,
width,
)
else:
cache_key = width
if self._content_height_cache[0] == cache_key:
return self._content_height_cache[1]
renderable = self.render(self.styles.rich_style)
options = self.console.options.update_width(width).update(highlight=False)
segments = self.console.render(renderable, options)
# Cheaper than counting the lines returned from render_lines!
height = sum(text.count("\n") for text, _, _ in segments)
self._content_height_cache = (cache_key, height)
return height
async def watch_scroll_x(self, new_value: float) -> None:
@@ -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:

View File

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