auto sizing

This commit is contained in:
Will McGugan
2022-05-13 15:27:02 +01:00
parent 96ce4202a5
commit efd4273a4c
20 changed files with 247 additions and 87 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(10)))
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

@@ -75,7 +75,7 @@ App > Screen {
Tweet {
height: 12;
height: auto;
width: 80;
margin: 1 3;
@@ -85,7 +85,7 @@ Tweet {
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll;
overflow-y: auto;
align-horizontal: center;
}
@@ -223,4 +223,4 @@ Success {
.horizontal {
layout: horizontal
}
}

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: border-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()

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;
}

View File

@@ -5,7 +5,7 @@ from functools import lru_cache
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
@@ -210,8 +210,8 @@ 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)
# has_top = has_bottom = False
lines = console.render_lines(self.renderable, render_options)

View File

@@ -41,3 +41,27 @@ class Layout(ABC):
Returns:
Iterable[WidgetPlacement]: An iterable of widget location
"""
def get_content_width(
self, parent: Widget, container_size: Size, viewport_size: Size
) -> int:
width: int | None = None
for child in parent.displayed_children:
if not child.is_container:
child_width = child.get_content_width(container_size, viewport_size)
width = child_width if width is None else max(width, child_width)
if width is None:
width = container_size.width
return width
def get_content_height(
self, parent: Widget, container_size: Size, viewport_size: Size, width: int
) -> int:
if not parent.displayed_children:
height = container_size.height
else:
placements, widgets = self.arrange(
parent, Size(width, container_size.height), Offset(0, 0)
)
height = max(placement.region.y_max for placement in placements)
return height

View File

@@ -415,7 +415,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:
@@ -425,7 +425,7 @@ class App(Generic[ReturnType], DOMNode):
DevtoolsLog(output, caller=_textual_calling_frame)
)
except Exception:
pass
self.console.bell()
def bind(
self,
@@ -470,14 +470,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.log(error)
else:
self.stylesheet = stylesheet
self.reset_styles()
self.stylesheet.update(self)
self.screen.refresh(layout=True)

View File

@@ -17,8 +17,8 @@ def get_box_model(
styles: StylesBase,
container: Size,
viewport: Size,
get_content_width: Callable[[Size, Size], int],
get_content_height: Callable[[Size, Size, int], int],
get_content_width: Callable[[Size, Size], int | None],
get_content_height: Callable[[Size, Size, int], int | None],
) -> BoxModel:
"""Resolve the box model for this Styles.
@@ -36,34 +36,26 @@ def get_box_model(
has_rule = styles.has_rule
width, height = container
is_content_box = styles.box_sizing == "content-box"
is_border_box = styles.box_sizing == "border-box"
gutter = styles.padding + styles.border.spacing
is_auto_width = styles.width and styles.width.is_auto
is_auto_height = styles.height and styles.height.is_auto
if not has_rule("width"):
width = container.width
elif styles.width.is_auto:
elif is_auto_width:
# 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
width = get_content_width(
(container - gutter.totals if is_border_box else container)
- styles.margin.totals,
viewport,
)
# width = min(container.width, 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)
@@ -72,6 +64,17 @@ def get_box_model(
max_width = styles.max_width.resolve_dimension(container, viewport)
width = min(width, max_width)
if not has_rule("height"):
height = container.height
elif styles.height.is_auto:
height = get_content_height(
container - gutter.totals if is_border_box else container, viewport, width
)
if is_border_box:
height += gutter.height
else:
height = styles.height.resolve_dimension(container, viewport)
if has_rule("min_height"):
min_height = styles.min_height.resolve_dimension(container, viewport)
height = max(height, min_height)
@@ -80,7 +83,16 @@ def get_box_model(
max_height = styles.max_height.resolve_dimension(container, viewport)
height = min(height, max_height)
if is_border_box and is_auto_width:
width += gutter.width
if is_content_box:
width += gutter.width
height += gutter.height
size = Size(width, height)
margin = styles.margin
return BoxModel(size, margin)
model = BoxModel(size, margin)
return model

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

@@ -141,6 +141,11 @@ class Stylesheet:
def css(self) -> str:
return "\n\n".join(rule_set.css for rule_set in self.rules)
def copy(self) -> 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

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
import base64
import datetime
from time import time
import inspect
import json
import pickle
@@ -207,7 +207,7 @@ class DevtoolsClient:
{
"type": "client_log",
"payload": {
"timestamp": int(datetime.datetime.utcnow().timestamp()),
"timestamp": int(time()),
"path": getattr(log.caller, "filename", ""),
"line_number": getattr(log.caller, "lineno", 0),
"encoded_segments": encoded_segments,
@@ -242,6 +242,6 @@ class DevtoolsClient:
Returns:
str: The Segment list pickled with pickle protocol v3, then base64 encoded
"""
pickled = pickle.dumps(segments, protocol=3)
pickled = pickle.dumps(segments, protocol=pickle.HIGHEST_PROTOCOL)
encoded = base64.b64encode(pickled)
return str(encoded, encoding="utf-8")

View File

@@ -72,19 +72,13 @@ class DevConsoleLog:
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
local_time = (
datetime.fromtimestamp(self.unix_timestamp)
.replace(tzinfo=timezone.utc)
.astimezone(tz=datetime.now().astimezone().tzinfo)
)
timezone_name = local_time.tzname()
local_time = datetime.fromtimestamp(self.unix_timestamp)
table = Table.grid(expand=True)
table.add_column()
table.add_column()
file_link = escape(f"file://{Path(self.path).absolute()}")
file_and_line = escape(f"{Path(self.path).name}:{self.line_number}")
table.add_row(
f"[dim]{local_time.time()} {timezone_name}",
f"[dim]{local_time.time()}",
Align.right(
Text(f"{file_and_line}", style=Style(dim=True, link=file_link))
),

View File

@@ -179,7 +179,7 @@ class ClientHandler:
and message_time - last_message_time > 1
):
# Print a rule if it has been longer than a second since the last message
self.service.console.rule("")
self.service.console.rule()
self.service.console.print(
DevConsoleLog(
segments=segments,

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,25 @@ 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]]:
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

@@ -50,8 +50,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

@@ -47,9 +47,17 @@ class VerticalLayout(Layout):
y += region.height + margin
max_height = y
max_height += margins[-1] if margins else 0
# max_height += margins[-1] if margins else 0
total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(displayed_children)
# def get_content_width(
# self, parent: Widget, container_size: Size, viewport_size: Size
# ) -> int:
# width = super().get_content_width(parent, container_size, viewport_size)
# width = min(width, container_size.width)
# print("get_content_width", parent, container_size, width)
# return width

View File

@@ -70,6 +70,7 @@ class Widget(DOMNode):
can_focus_children: bool = True
CSS = """
"""
def __init__(
@@ -111,6 +112,14 @@ 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:
if not value:
self.scroll_to(0, 0, animate=False)
def watch_show_vertical_scrollbar(self, value: bool) -> None:
if not value:
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()
@@ -160,10 +169,15 @@ class Widget(DOMNode):
Returns:
int: The optimal width of the content.
"""
if self.is_container:
return self.layout.get_content_width(self, container_size, viewport_size)
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_size.width), renderable
)
width = measurement.maximum
return width
def get_content_height(
self, container_size: Size, viewport_size: Size, width: int
@@ -178,11 +192,20 @@ class Widget(DOMNode):
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
return self.layout.get_content_height(
self, container_size, viewport_size, width
)
else:
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!
# print(list(segments))
height = sum(text.count("\n") for text, _, _ in segments)
return height
async def watch_scroll_x(self, new_value: float) -> None:
@@ -556,32 +579,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 +608,7 @@ class Widget(DOMNode):
renderable,
styles.outline,
inner_color=styles.background,
outer_color=parent_styles.background,
outer_color=base_background,
outline=True,
)
@@ -706,6 +725,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:
@@ -865,10 +886,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
@@ -880,13 +903,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: