From 9838d3b34efcd497f31cd02457da1f858edf37ae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 18 Sep 2022 11:25:16 +0100 Subject: [PATCH] fluid rendering --- docs/README.md | 18 ----- docs/examples/light_dark.py | 19 ------ docs/guide/styles.md | 2 +- examples/code_browser.css | 4 -- src/textual/app.py | 4 +- src/textual/cli/previews/borders.py | 1 + src/textual/cli/previews/easing.css | 2 +- src/textual/widget.py | 102 ++++++++++++++++++++++------ src/textual/widgets/_static.py | 33 +++++++-- 9 files changed, 114 insertions(+), 71 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/examples/light_dark.py diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 63c47ed6a..000000000 --- a/docs/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# Documentation Workflow - -* Ensure you're inside a *Python 3.10+* virtual environment -* Run the live-reload server using `mkdocs serve` from the project root -* Create new pages by adding new directories and Markdown files inside `docs/*` - -## Commands - -- `mkdocs serve` - Start the live-reloading docs server. -- `mkdocs build` - Build the documentation site. -- `mkdocs -h` - Print help message and exit. - -## Project layout - - mkdocs.yml # The configuration file. - docs/ - index.md # The documentation homepage. - ... # Other markdown pages, images and other files. diff --git a/docs/examples/light_dark.py b/docs/examples/light_dark.py deleted file mode 100644 index be4351258..000000000 --- a/docs/examples/light_dark.py +++ /dev/null @@ -1,19 +0,0 @@ -from textual.app import App -from textual.widgets import Button - - -class ButtonApp(App): - - DEFAULT_CSS = """ - Button { - width: 100%; - } - """ - - def compose(self): - yield Button("Lights off") - - def on_button_pressed(self, event): - self.dark = not self.dark - self.bell() - event.button.label = "Lights ON" if self.dark else "Lights OFF" diff --git a/docs/guide/styles.md b/docs/guide/styles.md index ecf337bbe..f2b12e39a 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -206,7 +206,7 @@ The same units may also be used to set limits on a dimension. The following styl - [min-width](../styles/min_width.md) sets a minimum width. - [max-width](../styles/max_width.md) sets a maximum width. - [min-height](../styles/min_height.md) sets a minimum height. -- [max-height](../styles/max_hright.md) sets a maximum height. +- [max-height](../styles/max_height.md) sets a maximum height. ### Padding diff --git a/examples/code_browser.css b/examples/code_browser.css index aca48c127..e342b7a00 100644 --- a/examples/code_browser.css +++ b/examples/code_browser.css @@ -16,10 +16,6 @@ CodeBrowser.-show-tree #tree-view { background: $surface; } -CodeBrowser{ - background: $background; -} - DirectoryTree { padding-right: 1; padding-right: 1; diff --git a/src/textual/app.py b/src/textual/app.py index f3fcd68a2..d63389c55 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -583,7 +583,7 @@ class App(Generic[ReturnType], DOMNode): filename (str | None, optional): Filename of SVG screenshot, or None to auto-generate a filename with the date and time. Defaults to None. path (str, optional): Path to directory for output. Defaults to current working directory. - time_format(str, optional): Time format to use if filename is None. Defaults to "%Y-%m-%d %X %f". + time_format (str, optional): Time format to use if filename is None. Defaults to "%Y-%m-%d %X %f". Returns: str: Filename of screenshot. @@ -1213,6 +1213,8 @@ class App(Generic[ReturnType], DOMNode): apply_stylesheet = self.stylesheet.apply for widget_id, widget in name_widgets: + if not isinstance(widget, Widget): + raise AppError(f"Can't register {widget!r}; expected a Widget instance") if widget not in self._registry: if widget_id is not None: widget.id = widget_id diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py index d2d4123fe..68b4d3a10 100644 --- a/src/textual/cli/previews/borders.py +++ b/src/textual/cli/previews/borders.py @@ -18,6 +18,7 @@ class BorderButtons(layout.Vertical): BorderButtons { dock: left; width: 24; + overflow-y: scroll; } BorderButtons > Button { diff --git a/src/textual/cli/previews/easing.css b/src/textual/cli/previews/easing.css index 3f602b656..83277d566 100644 --- a/src/textual/cli/previews/easing.css +++ b/src/textual/cli/previews/easing.css @@ -3,7 +3,7 @@ EasingButtons > Button { } EasingButtons { dock: left; - overflow: auto auto; + overflow-y: scroll; width: 20; } diff --git a/src/textual/widget.py b/src/textual/widget.py index 646098af5..fa4ba0d19 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -30,6 +30,7 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive +from .render import measure if TYPE_CHECKING: from .app import App, ComposeResult @@ -61,7 +62,9 @@ class RenderCache(NamedTuple): @rich.repr.auto class Widget(DOMNode): """ - A Widget is the base class for Textual widgets. Extent this class (or a sub-class) when defining your own widgets. + A Widget is the base class for Textual widgets. + + See also [static][textual.widgets._static.Static] for starting point for your own widgets. """ @@ -80,6 +83,7 @@ class Widget(DOMNode): can_focus: bool = False can_focus_children: bool = True + fluid = Reactive(True) def __init__( self, @@ -317,6 +321,8 @@ class Widget(DOMNode): self.get_content_width, self.get_content_height, ) + self.log(self) + self.log(box_model) return box_model def get_content_width(self, container: Size, viewport: Size) -> int: @@ -342,12 +348,10 @@ class Widget(DOMNode): console = self.app.console renderable = self.post_render(self.render()) - measurement = Measurement.get( - console, - console.options.update_width(container.width), - renderable, - ) - width = measurement.maximum + width = measure(console, renderable, container.width) + if self.fluid: + width = min(width, container.width) + self._content_width_cache = (cache_key, width) return width @@ -493,6 +497,8 @@ class Widget(DOMNode): overflow_y = styles.overflow_y width, height = self.container_size + previous_show_vertical = self.show_vertical_scrollbar + show_horizontal = self.show_horizontal_scrollbar if overflow_x == "hidden": show_horizontal = False @@ -509,10 +515,15 @@ class Widget(DOMNode): elif overflow_y == "auto": show_vertical = self.virtual_size.height > height - if show_vertical and not show_horizontal and overflow_x == "auto": - show_horizontal = ( - self.virtual_size.width + styles.scrollbar_size_vertical > width - ) + # if ( + # not previous_show_vertical + # and show_vertical + # and show_horizontal + # and overflow_x == "auto" + # ): + # show_horizontal = ( + # self.virtual_size.width - styles.scrollbar_size_vertical > width + # ) self.show_horizontal_scrollbar = show_horizontal self.show_vertical_scrollbar = show_vertical @@ -1168,12 +1179,21 @@ class Widget(DOMNode): duration=duration, ) - def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool: + def scroll_to_widget( + self, + widget: Widget, + *, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> bool: """Scroll scrolling to bring a widget in to view. Args: widget (Widget): A descendant widget. animate (bool, optional): True to animate, or False to jump. Defaults to True. + speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration. + duration (float | None, optional): Duration of animation, if animate is True and speed is None. Returns: bool: True if any scrolling has occurred in any descendant, otherwise False. @@ -1186,7 +1206,11 @@ class Widget(DOMNode): while isinstance(widget.parent, Widget) and widget is not self: container = widget.parent scroll_offset = container.scroll_to_region( - region, spacing=widget.parent.gutter, animate=animate + region, + spacing=widget.parent.gutter, + animate=animate, + speed=speed, + duration=duration, ) if scroll_offset: scrolled = True @@ -1206,7 +1230,13 @@ class Widget(DOMNode): return scrolled def scroll_to_region( - self, region: Region, *, spacing: Spacing | None = None, animate: bool = True + self, + region: Region, + *, + spacing: Spacing | None = None, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, ) -> Offset: """Scrolls a given region in to view, if required. @@ -1216,8 +1246,9 @@ class Widget(DOMNode): Args: region (Region): A region that should be visible. spacing (Spacing | None, optional): Optional spacing around the region. Defaults to None. - animate (bool, optional): Enable animation. Defaults to True. - spacing (Spacing): Space to subtract from the window region. + animate (bool, optional): True to animate, or False to jump. Defaults to True. + speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration. + duration (float | None, optional): Duration of animation, if animate is True and speed is None. Returns: Offset: The distance that was scrolled. @@ -1236,19 +1267,39 @@ class Widget(DOMNode): clamp(scroll_y + delta_y, 0, self.max_scroll_y) - scroll_y, ) if delta: + if speed is None and duration is None: + duration = 0.2 self.scroll_relative( delta.x or None, delta.y or None, animate=animate if (abs(delta_y) > 1 or delta_x) else False, - duration=0.2, + speed=speed, + duration=duration, ) return delta - def scroll_visible(self) -> None: - """Scroll the container to make this widget visible.""" + def scroll_visible( + self, + animate: bool = True, + speed: float | None = None, + duration: float | None = None, + ) -> None: + """Scroll the container to make this widget visible. + + Args: + animate (bool, optional): _description_. Defaults to True. + speed (float | None, optional): _description_. Defaults to None. + duration (float | None, optional): _description_. Defaults to None. + """ parent = self.parent if isinstance(parent, Widget): - self.call_later(parent.scroll_to_widget, self) + self.call_later( + parent.scroll_to_widget, + self, + animate=animate, + speed=speed, + duration=duration, + ) def __init_subclass__( cls, @@ -1476,7 +1527,10 @@ class Widget(DOMNode): """ if self._dirty_regions: self._render_content() - line = self._render_cache.lines[y] + try: + line = self._render_cache.lines[y] + except IndexError: + line = [Segment(" " * self.size.width, self.rich_style)] return line def render_lines(self, crop: Region) -> Lines: @@ -1657,7 +1711,11 @@ class Widget(DOMNode): def _on_mount(self, event: events.Mount) -> None: widgets = self.compose() self.mount(*widgets) - self.screen.refresh(repaint=False, layout=True) + # Preset scrollbars if not automatic + if self.styles.overflow_y == "scroll": + self.show_vertical_scrollbar = True + if self.styles.overflow_x == "scroll": + self.show_horizontal_scrollbar = True def _on_leave(self, event: events.Leave) -> None: self.mouse_over = False diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 5e5c74a64..132770c31 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -3,7 +3,6 @@ from __future__ import annotations from rich.console import RenderableType from rich.protocol import is_renderable -from ..reactive import Reactive from ..errors import RenderError from ..widget import Widget @@ -20,11 +19,22 @@ def _check_renderable(renderable: object): """ if not is_renderable(renderable): raise RenderError( - f"unable to render {renderable!r}; A string, Text, or other Rich renderable is required" + f"unable to render {renderable!r}; a string, Text, or other Rich renderable is required" ) class Static(Widget): + """A widget to display simple static content, or use as a base- lass for more complex widgets. + + Args: + renderable (RenderableType, optional): A Rich renderable, or string containing console markup. + Defaults to "". + fluid (bool, optional): Enable fluid content (adapts to size of window). Defaults to True. + name (str | None, optional): Name of widget. Defaults to None. + id (str | None, optional): ID of Widget. Defaults to None. + classes (str | None, optional): Space separated list of class names. Defaults to None. + """ + DEFAULT_CSS = """ Static { height: auto; @@ -35,18 +45,31 @@ class Static(Widget): self, renderable: RenderableType = "", *, + fluid: bool = True, name: str | None = None, id: str | None = None, classes: str | None = None, ) -> None: + super().__init__(name=name, id=id, classes=classes) - self.renderable = renderable + self._renderable = renderable + self.fluid = fluid _check_renderable(renderable) def render(self) -> RenderableType: - return self.renderable + """Get a rich renderable for the widget's content. + + Returns: + RenderableType: A rich renderable. + """ + return self._renderable def update(self, renderable: RenderableType) -> None: + """Update the widget contents. + + Args: + renderable (RenderableType): A new rich renderable. + """ _check_renderable(renderable) - self.renderable = renderable + self._renderable = renderable self.refresh(layout=True)