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; */ /* outline: heavy blue; */
height: 10; height: 10;
padding: 1 2; padding: 1 2;
box-sizing: border-box; box-sizing: border-box;
max-height: 100vh; max-height: 100vh;
@@ -41,5 +42,7 @@ Widget {
height: 10; height: 10;
margin: 1; margin: 1;
background:blue; background:blue;
color: white 50%;
border: white;
align-horizontal: center; 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 { Tweet {
height: 12; height: auto;
width: 80; width: 80;
margin: 1 3; margin: 1 3;
@@ -85,7 +85,7 @@ Tweet {
/* border: outer $primary; */ /* border: outer $primary; */
padding: 1; padding: 1;
border: wide $panel-darken-2; border: wide $panel-darken-2;
overflow-y: scroll; overflow-y: auto;
align-horizontal: center; align-horizontal: center;
} }
@@ -223,4 +223,4 @@ Success {
.horizontal { .horizontal {
layout: 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 { App.-show-focus *:focus {
tint: #8bc34a 50%; tint: #8bc34a 20%;
} }
#uber1 { #uber1 {
layout: vertical; layout: vertical;
background: green; background: green;
overflow: hidden auto; overflow: hidden auto;
border: heavy white; border: heavy white;
text-style: underline; text-style: underline;
/* box-sizing: content-box; */
} }
#uber1:focus-within { #uber1:focus-within {
@@ -16,11 +17,12 @@ App.-show-focus *:focus {
#child2 { #child2 {
text-style: underline; text-style: underline;
background: red; background: red 10%;
} }
.list-item { .list-item {
height: 20; height: 10;
/* display: none; */
color: #12a0; color: #12a0;
background: #ffffff00; background: #ffffff00;
} }

View File

@@ -5,7 +5,7 @@ from functools import lru_cache
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
import rich.repr import rich.repr
from rich.segment import Segment, SegmentLines from rich.segment import Segment, SegmentLines
from rich.style import Style, StyleType from rich.style import Style
from .color import Color from .color import Color
from .css.types import EdgeStyle, EdgeType from .css.types import EdgeStyle, EdgeType
@@ -210,8 +210,8 @@ class Border:
if new_height >= 1: if new_height >= 1:
render_options = options.update_dimensions(width, new_height) render_options = options.update_dimensions(width, new_height)
else: else:
render_options = options render_options = options.update_width(width)
has_top = has_bottom = False # has_top = has_bottom = False
lines = console.render_lines(self.renderable, render_options) lines = console.render_lines(self.renderable, render_options)

View File

@@ -41,3 +41,27 @@ class Layout(ABC):
Returns: Returns:
Iterable[WidgetPlacement]: An iterable of widget location 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) output = " ".join(str(arg) for arg in objects)
if kwargs: if kwargs:
key_values = " ".join( 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 output = f"{output} {key_values}" if output else key_values
if self._log_console is not None: if self._log_console is not None:
@@ -425,7 +425,7 @@ class App(Generic[ReturnType], DOMNode):
DevtoolsLog(output, caller=_textual_calling_frame) DevtoolsLog(output, caller=_textual_calling_frame)
) )
except Exception: except Exception:
pass self.console.bell()
def bind( def bind(
self, self,
@@ -470,14 +470,17 @@ class App(Generic[ReturnType], DOMNode):
try: try:
time = perf_counter() 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 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: except Exception as error:
# TODO: Catch specific exceptions # TODO: Catch specific exceptions
self.console.bell() self.console.bell()
self.log(error) self.log(error)
else: else:
self.stylesheet = stylesheet
self.reset_styles() self.reset_styles()
self.stylesheet.update(self) self.stylesheet.update(self)
self.screen.refresh(layout=True) self.screen.refresh(layout=True)

View File

@@ -17,8 +17,8 @@ def get_box_model(
styles: StylesBase, styles: StylesBase,
container: Size, container: Size,
viewport: Size, viewport: Size,
get_content_width: Callable[[Size, Size], int], get_content_width: Callable[[Size, Size], int | None],
get_content_height: Callable[[Size, Size, int], int], get_content_height: Callable[[Size, Size, int], int | None],
) -> BoxModel: ) -> BoxModel:
"""Resolve the box model for this Styles. """Resolve the box model for this Styles.
@@ -36,34 +36,26 @@ def get_box_model(
has_rule = styles.has_rule has_rule = styles.has_rule
width, height = container width, height = container
is_content_box = styles.box_sizing == "content-box" is_content_box = styles.box_sizing == "content-box"
is_border_box = styles.box_sizing == "border-box"
gutter = styles.padding + styles.border.spacing 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"): if not has_rule("width"):
width = container.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 # When width is auto, we want enough space to always fit the content
width = get_content_width(container, viewport) width = get_content_width(
if not is_content_box: (container - gutter.totals if is_border_box else container)
# If box sizing is border box we want to enlarge the width so that it - styles.margin.totals,
# can accommodate padding + border viewport,
width += gutter.width )
# width = min(container.width, width)
else: else:
width = styles.width.resolve_dimension(container, viewport) 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"): if has_rule("min_width"):
min_width = styles.min_width.resolve_dimension(container, viewport) min_width = styles.min_width.resolve_dimension(container, viewport)
width = max(width, min_width) width = max(width, min_width)
@@ -72,6 +64,17 @@ def get_box_model(
max_width = styles.max_width.resolve_dimension(container, viewport) max_width = styles.max_width.resolve_dimension(container, viewport)
width = min(width, max_width) 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"): if has_rule("min_height"):
min_height = styles.min_height.resolve_dimension(container, viewport) min_height = styles.min_height.resolve_dimension(container, viewport)
height = max(height, min_height) height = max(height, min_height)
@@ -80,7 +83,16 @@ def get_box_model(
max_height = styles.max_height.resolve_dimension(container, viewport) max_height = styles.max_height.resolve_dimension(container, viewport)
height = min(height, max_height) 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) size = Size(width, height)
margin = styles.margin 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 percent_unit: Unit
def __str__(self) -> str: 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}" return f"{int(value) if value.is_integer() else value}{self.symbol}"
@property @property

View File

@@ -141,6 +141,11 @@ class Stylesheet:
def css(self) -> str: def css(self) -> str:
return "\n\n".join(rule_set.css for rule_set in self.rules) 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: def set_variables(self, variables: dict[str, str]) -> None:
"""Set CSS variables. """Set CSS variables.

View File

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

View File

@@ -72,19 +72,13 @@ class DevConsoleLog:
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
) -> RenderResult: ) -> RenderResult:
local_time = ( local_time = datetime.fromtimestamp(self.unix_timestamp)
datetime.fromtimestamp(self.unix_timestamp)
.replace(tzinfo=timezone.utc)
.astimezone(tz=datetime.now().astimezone().tzinfo)
)
timezone_name = local_time.tzname()
table = Table.grid(expand=True) table = Table.grid(expand=True)
table.add_column()
table.add_column()
file_link = escape(f"file://{Path(self.path).absolute()}") file_link = escape(f"file://{Path(self.path).absolute()}")
file_and_line = escape(f"{Path(self.path).name}:{self.line_number}") file_and_line = escape(f"{Path(self.path).name}:{self.line_number}")
table.add_row( table.add_row(
f"[dim]{local_time.time()} {timezone_name}", f"[dim]{local_time.time()}",
Align.right( Align.right(
Text(f"{file_and_line}", style=Style(dim=True, link=file_link)) 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 and message_time - last_message_time > 1
): ):
# Print a rule if it has been longer than a second since the last message # 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( self.service.console.print(
DevConsoleLog( DevConsoleLog(
segments=segments, segments=segments,

View File

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

View File

@@ -50,8 +50,6 @@ class HorizontalLayout(Layout):
x += region.width + margin x += region.width + margin
max_width = x max_width = x
max_width += margins[-1] if margins else 0
total_region = Region(0, 0, max_width, max_height) total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0)) add_placement(WidgetPlacement(total_region, None, 0))

View File

@@ -47,9 +47,17 @@ class VerticalLayout(Layout):
y += region.height + margin y += region.height + margin
max_height = y 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) total_region = Region(0, 0, max_width, max_height)
add_placement(WidgetPlacement(total_region, None, 0)) add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(displayed_children) 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 can_focus_children: bool = True
CSS = """ CSS = """
""" """
def __init__( def __init__(
@@ -111,6 +112,14 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True) show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_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: def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.app.register(self, *anon_widgets, **widgets) self.app.register(self, *anon_widgets, **widgets)
self.screen.refresh() self.screen.refresh()
@@ -160,10 +169,15 @@ class Widget(DOMNode):
Returns: Returns:
int: The optimal width of the content. 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 console = self.app.console
renderable = self.render(self.styles.rich_style) renderable = self.render(self.styles.rich_style)
measurement = Measurement.get(console, console.options, renderable) measurement = Measurement.get(
return measurement.maximum console, console.options.update_width(container_size.width), renderable
)
width = measurement.maximum
return width
def get_content_height( def get_content_height(
self, container_size: Size, viewport_size: Size, width: int self, container_size: Size, viewport_size: Size, width: int
@@ -178,11 +192,20 @@ class Widget(DOMNode):
Returns: Returns:
int: The height of the content. int: The height of the content.
""" """
renderable = self.render(self.styles.rich_style)
options = self.console.options.update_width(width) if self.is_container:
segments = self.console.render(renderable, options) assert self.layout is not None
# Cheaper than counting the lines returned from render_lines! return self.layout.get_content_height(
height = sum(text.count("\n") for text, _, _ in segments) 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 return height
async def watch_scroll_x(self, new_value: float) -> None: async def watch_scroll_x(self, new_value: float) -> None:
@@ -556,32 +579,28 @@ class Widget(DOMNode):
Returns: Returns:
RenderableType: A new renderable. 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 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) content_align = (styles.content_align_horizontal, styles.content_align_vertical)
if content_align != ("left", "top"): if content_align != ("left", "top"):
horizontal, vertical = content_align horizontal, vertical = content_align
renderable = Align(renderable, horizontal, vertical=vertical) renderable = Align(renderable, horizontal, vertical=vertical)
renderable = Padding(renderable, styles.padding) renderable = Padding(
renderable,
renderable_text_style = parent_text_style + text_style styles.padding,
if renderable_text_style: style=Style.from_color(color.rich_color, background.rich_color),
style = Style.from_color(text_style.color, text_style.bgcolor) )
renderable = Styled(renderable, style)
if styles.border: if styles.border:
renderable = Border( renderable = Border(
renderable, renderable,
styles.border, styles.border,
inner_color=styles.background, inner_color=background,
outer_color=Color.from_rich_color(parent_text_style.bgcolor), outer_color=base_background,
) )
if styles.outline: if styles.outline:
@@ -589,7 +608,7 @@ class Widget(DOMNode):
renderable, renderable,
styles.outline, styles.outline,
inner_color=styles.background, inner_color=styles.background,
outer_color=parent_styles.background, outer_color=base_background,
outline=True, outline=True,
) )
@@ -706,6 +725,8 @@ class Widget(DOMNode):
self.horizontal_scrollbar.window_virtual_size = virtual_size.width self.horizontal_scrollbar.window_virtual_size = virtual_size.width
self.horizontal_scrollbar.window_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.refresh(layout=True)
self.call_later(self.scroll_to, self.scroll_x, self.scroll_y) self.call_later(self.scroll_to, self.scroll_x, self.scroll_y)
else: else:
@@ -865,10 +886,12 @@ class Widget(DOMNode):
def on_focus(self, event: events.Focus) -> None: def on_focus(self, event: events.Focus) -> None:
self.emit_no_wait(events.DescendantFocus(self)) self.emit_no_wait(events.DescendantFocus(self))
self.has_focus = True self.has_focus = True
self.refresh()
def on_blur(self, event: events.Blur) -> None: def on_blur(self, event: events.Blur) -> None:
self.emit_no_wait(events.DescendantBlur(self)) self.emit_no_wait(events.DescendantBlur(self))
self.has_focus = False self.has_focus = False
self.refresh()
def on_descendant_focus(self, event: events.DescendantFocus) -> None: def on_descendant_focus(self, event: events.DescendantFocus) -> None:
self.descendant_has_focus = True self.descendant_has_focus = True
@@ -880,13 +903,13 @@ class Widget(DOMNode):
def on_mouse_scroll_down(self, event) -> None: def on_mouse_scroll_down(self, event) -> None:
if self.is_container: if self.is_container:
self.scroll_down(animate=False) if self.scroll_down(animate=False):
event.stop() event.stop()
def on_mouse_scroll_up(self, event) -> None: def on_mouse_scroll_up(self, event) -> None:
if self.is_container: if self.is_container:
self.scroll_up(animate=False) if self.scroll_up(animate=False):
event.stop() event.stop()
def handle_scroll_to(self, message: ScrollTo) -> None: def handle_scroll_to(self, message: ScrollTo) -> None:
if self.is_container: if self.is_container: