Merge pull request #608 from Textualize/scroll-to

Improvements to scroll_to_widget
This commit is contained in:
Will McGugan
2022-07-13 16:14:52 +01:00
committed by GitHub
14 changed files with 173 additions and 84 deletions

16
poetry.lock generated
View File

@@ -500,7 +500,7 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pre-commit" name = "pre-commit"
version = "2.19.0" version = "2.20.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks." description = "A framework for managing and maintaining multi-language pre-commit hooks."
category = "dev" category = "dev"
optional = false optional = false
@@ -654,7 +654,7 @@ pyyaml = "*"
[[package]] [[package]]
name = "rich" name = "rich"
version = "12.4.4" version = "12.5.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main" category = "main"
optional = false optional = false
@@ -780,7 +780,7 @@ dev = ["aiohttp", "click", "msgpack"]
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.7" python-versions = "^3.7"
content-hash = "8ce8d66466dad1b984673595ebd0cc7bc0d28c7a672269e9b5620c242d87d9ad" content-hash = "2d0f99d7fb563eb0b34cda9542ecf87c35cf5944a67510625969ec7b046b6d03"
[metadata.files] [metadata.files]
aiohttp = [ aiohttp = [
@@ -1295,10 +1295,7 @@ pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
] ]
pre-commit = [ pre-commit = []
{file = "pre_commit-2.19.0-py2.py3-none-any.whl", hash = "sha256:10c62741aa5704faea2ad69cb550ca78082efe5697d6f04e5710c3c229afdd10"},
{file = "pre_commit-2.19.0.tar.gz", hash = "sha256:4233a1e38621c87d9dda9808c6606d7e7ba0e087cd56d3fe03202a01d2919615"},
]
py = [ py = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
@@ -1375,10 +1372,7 @@ pyyaml-env-tag = [
{file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"},
{file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"},
] ]
rich = [ rich = []
{file = "rich-12.4.4-py3-none-any.whl", hash = "sha256:d2bbd99c320a2532ac71ff6a3164867884357da3e3301f0240090c5d2fdac7ec"},
{file = "rich-12.4.4.tar.gz", hash = "sha256:4c586de507202505346f3e32d1363eb9ed6932f0c2f63184dea88983ff4971e2"},
]
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},

View File

@@ -22,7 +22,7 @@ textual = "textual.cli.cli:run"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7" python = "^3.7"
rich = "^12.4.3" rich = "^12.5.0"
#rich = {path="../rich", develop=true} #rich = {path="../rich", develop=true}
importlib-metadata = "^4.11.3" importlib-metadata = "^4.11.3"
typing-extensions = { version = "^4.0.0", python = "<3.8" } typing-extensions = { version = "^4.0.0", python = "<3.8" }

View File

@@ -66,8 +66,8 @@ DataTable {
} }
#header { #header {
color: $text-primary-background-darken-1; color: $text-secondary-background-darken-1;
background: $primary-background-darken-1; background: $secondary-background-darken-1;
height: 3; height: 3;
content-align: center middle; content-align: center middle;
} }
@@ -109,6 +109,7 @@ Tweet {
.code { .code {
height: auto; height: auto;
} }

24
sandbox/will/buttons.css Normal file
View File

@@ -0,0 +1,24 @@
Button {
margin: 1;
width: 100%;
}
Vertical {
height: auto;
}
Horizontal {
height: auto;
}
Horizontal Button {
width: 20;
margin: 1 2 ;
}
#scroll {
height: 10;
}

46
sandbox/will/scroll.py Normal file
View File

@@ -0,0 +1,46 @@
from textual import layout, events
from textual.app import App, ComposeResult
from textual.widgets import Button
class ButtonsApp(App[str]):
def compose(self) -> ComposeResult:
yield layout.Vertical(
Button("default", id="foo"),
Button("Where there is a Will"),
Button("There is a Way"),
Button("There can be only one"),
Button.success("success", id="bar"),
layout.Horizontal(
Button("Where there is a Will"),
Button("There is a Way"),
Button("There can be only one"),
Button.warning("warning", id="baz"),
Button("Where there is a Will"),
Button("There is a Way"),
Button("There can be only one"),
id="scroll",
),
Button.error("error", id="baz"),
Button("Where there is a Will"),
Button("There is a Way"),
Button("There can be only one"),
)
def handle_pressed(self, event: Button.Pressed) -> None:
self.app.bell()
async def on_key(self, event: events.Key) -> None:
await self.dispatch_key(event)
def key_d(self):
self.dark = not self.dark
app = ButtonsApp(
log_path="textual.log", css_path="buttons.css", watch_css=True, log_verbosity=2
)
if __name__ == "__main__":
result = app.run()
print(repr(result))

View File

@@ -54,11 +54,12 @@ class ReflowResult(NamedTuple):
class MapGeometry(NamedTuple): class MapGeometry(NamedTuple):
"""Defines the absolute location of a Widget.""" """Defines the absolute location of a Widget."""
region: Region # The region occupied by the widget region: Region # The (screen) region occupied by the widget
order: tuple[int, ...] # A tuple of ints defining the painting order order: tuple[int, ...] # A tuple of ints defining the painting order
clip: Region # A region to clip the widget by (if a Widget is within a container) clip: Region # A region to clip the widget by (if a Widget is within a container)
virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container virtual_size: Size # The virtual size (scrollable region) of a widget if it is a container
container_size: Size # The container size (area not occupied by scrollbars) container_size: Size # The container size (area not occupied by scrollbars)
virtual_region: Region # The region relative to the container (but not necessarily visible)
@property @property
def visible_region(self) -> Region: def visible_region(self) -> Region:
@@ -271,8 +272,7 @@ class Compositor:
# Get a map of regions # Get a map of regions
self.regions = { self.regions = {
widget: (region, clip) widget: (region, clip) for widget, (region, _order, clip, *_) in map.items()
for widget, (region, _order, clip, _, _) in map.items()
} }
# Widgets with changed size # Widgets with changed size
@@ -326,6 +326,7 @@ class Compositor:
def add_widget( def add_widget(
widget: Widget, widget: Widget,
virtual_region: Region,
region: Region, region: Region,
order: tuple[int, ...], order: tuple[int, ...],
clip: Region, clip: Region,
@@ -379,6 +380,7 @@ class Compositor:
if sub_widget is not None: if sub_widget is not None:
add_widget( add_widget(
sub_widget, sub_widget,
sub_region,
sub_region + placement_offset, sub_region + placement_offset,
order + (z,), order + (z,),
sub_clip, sub_clip,
@@ -394,6 +396,7 @@ class Compositor:
clip, clip,
container_size, container_size,
container_size, container_size,
chrome_region,
) )
map[widget] = MapGeometry( map[widget] = MapGeometry(
@@ -402,16 +405,22 @@ class Compositor:
clip, clip,
total_region.size, total_region.size,
container_size, container_size,
virtual_region,
) )
else: else:
# Add the widget to the map # Add the widget to the map
map[widget] = MapGeometry( map[widget] = MapGeometry(
region + layout_offset, order, clip, region.size, container_size region + layout_offset,
order,
clip,
region.size,
container_size,
virtual_region,
) )
# Add top level (root) widget # Add top level (root) widget
add_widget(root, size.region, (0,), size.region) add_widget(root, size.region, size.region, (0,), size.region)
return map, widgets return map, widgets
def __iter__(self) -> Iterator[tuple[Widget, Region, Region, Size, Size]]: def __iter__(self) -> Iterator[tuple[Widget, Region, Region, Size, Size]]:
@@ -423,7 +432,7 @@ class Compositor:
""" """
layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True) layers = sorted(self.map.items(), key=lambda item: item[1].order, reverse=True)
intersection = Region.intersection intersection = Region.intersection
for widget, (region, _order, clip, virtual_size, container_size) in layers: for widget, (region, _order, clip, virtual_size, container_size, *_) in layers:
yield ( yield (
widget, widget,
intersection(region, clip), intersection(region, clip),
@@ -517,7 +526,7 @@ class Compositor:
intersection = Region.intersection intersection = Region.intersection
extend = list.extend extend = list.extend
for region, order, clip, _, _ in self.map.values(): for region, order, clip, *_ in self.map.values():
region = intersection(region, clip) region = intersection(region, clip)
if region and (region in screen_region): if region and (region in screen_region):
x, y, region_width, region_height = region x, y, region_width, region_height = region
@@ -547,13 +556,13 @@ class Compositor:
overlaps = crop.overlaps overlaps = crop.overlaps
mapped_regions = [ mapped_regions = [
(widget, region, order, clip) (widget, region, order, clip)
for widget, (region, order, clip, _, _) in self.map.items() for widget, (region, order, clip, *_) in self.map.items()
if widget.visible and not widget.is_transparent and overlaps(crop) if widget.visible and not widget.is_transparent and overlaps(crop)
] ]
else: else:
mapped_regions = [ mapped_regions = [
(widget, region, order, clip) (widget, region, order, clip)
for widget, (region, order, clip, _, _) in self.map.items() for widget, (region, order, clip, *_) in self.map.items()
if widget.visible and not widget.is_transparent if widget.visible and not widget.is_transparent
] ]
@@ -594,7 +603,6 @@ class Compositor:
] ]
return segment_lines return segment_lines
@timer("render")
def render(self, full: bool = False) -> RenderableType | None: def render(self, full: bool = False) -> RenderableType | None:
"""Render a layout. """Render a layout.

View File

@@ -81,7 +81,7 @@ LayoutDefinition = "dict[str, Any]"
DEFAULT_COLORS = ColorSystem( DEFAULT_COLORS = ColorSystem(
primary="#406e8e", primary="#2A4E6E",
secondary="#ffa62b", secondary="#ffa62b",
warning="#ffa62b", warning="#ffa62b",
error="#ba3c5b", error="#ba3c5b",
@@ -645,6 +645,7 @@ class App(Generic[ReturnType], DOMNode):
# Change focus # Change focus
self.focused = widget self.focused = widget
# Send focus event # Send focus event
self.screen.scroll_to_widget(widget)
widget.post_message_no_wait(events.Focus(self)) widget.post_message_no_wait(events.Focus(self))
widget.emit_no_wait(events.DescendantFocus(self)) widget.emit_no_wait(events.DescendantFocus(self))
@@ -926,7 +927,6 @@ class App(Generic[ReturnType], DOMNode):
stylesheet.update(self.app, animate=animate) stylesheet.update(self.app, animate=animate)
self.screen._refresh_layout(self.size, full=True) self.screen._refresh_layout(self.size, full=True)
@timer("_display")
def _display(self, renderable: RenderableType | None) -> None: def _display(self, renderable: RenderableType | None) -> None:
"""Display a renderable within a sync. """Display a renderable within a sync.

View File

@@ -449,8 +449,10 @@ class DOMNode(MessagePump):
""" """
_append = self.children._append _append = self.children._append
for node in nodes: for node in nodes:
node.set_parent(self)
_append(node) _append(node)
for node_id, node in named_nodes.items(): for node_id, node in named_nodes.items():
node.set_parent(self)
_append(node) _append(node)
node.id = node_id node.id = node_id

View File

@@ -569,8 +569,27 @@ class Region(NamedTuple):
) )
return new_region return new_region
def grow(self, margin: tuple[int, int, int, int]) -> Region:
"""Grow a region by adding spacing.
Args:
margin (Spacing): Defines how many cells to grow the Region by at each edge.
Returns:
Region: New region.
"""
top, right, bottom, left = margin
x, y, width, height = self
return Region(
x=x - left,
y=y - top,
width=max(0, width + left + right),
height=max(0, height + top + bottom),
)
def shrink(self, margin: tuple[int, int, int, int]) -> Region: def shrink(self, margin: tuple[int, int, int, int]) -> Region:
"""Shrink a region by pushing each edge inwards. """Shrink a region by subtracting spacing.
Args: Args:
margin (Spacing): Defines how many cells to shrink the Region by at each edge. margin (Spacing): Defines how many cells to shrink the Region by at each edge.

View File

@@ -57,7 +57,9 @@ class HorizontalLayout(Layout):
) )
next_x = x + content_width next_x = x + content_width
region = Region(int(x), offset_y, int(next_x - int(x)), int(content_height)) region = Region(int(x), offset_y, int(next_x - int(x)), int(content_height))
max_height = max(max_height, content_height) max_height = max(
max_height, content_height + offset_y + box_model.margin.bottom
)
add_placement(WidgetPlacement(region, widget, 0)) add_placement(WidgetPlacement(region, widget, 0))
x = next_x + margin x = next_x + margin
max_width = x max_width = x

View File

@@ -289,10 +289,12 @@ class Widget(DOMNode):
def watch_scroll_x(self, new_value: float) -> None: def watch_scroll_x(self, new_value: float) -> None:
self.horizontal_scrollbar.position = int(new_value) self.horizontal_scrollbar.position = int(new_value)
self.refresh(layout=True) self.refresh(layout=True)
self.horizontal_scrollbar.refresh()
def watch_scroll_y(self, new_value: float) -> None: def watch_scroll_y(self, new_value: float) -> None:
self.vertical_scrollbar.position = int(new_value) self.vertical_scrollbar.position = int(new_value)
self.refresh(layout=True) self.refresh(layout=True)
self.vertical_scrollbar.refresh()
def validate_scroll_x(self, value: float) -> float: def validate_scroll_x(self, value: float) -> float:
return clamp(value, 0, self.max_scroll_x) return clamp(value, 0, self.max_scroll_x)
@@ -307,7 +309,7 @@ class Widget(DOMNode):
return clamp(value, 0, self.max_scroll_y) return clamp(value, 0, self.max_scroll_y)
@property @property
def max_scroll_x(self) -> float: def max_scroll_x(self) -> int:
"""The maximum value of `scroll_x`.""" """The maximum value of `scroll_x`."""
return max( return max(
0, 0,
@@ -317,7 +319,7 @@ class Widget(DOMNode):
) )
@property @property
def max_scroll_y(self) -> float: def max_scroll_y(self) -> int:
"""The maximum value of `scroll_y`.""" """The maximum value of `scroll_y`."""
return max( return max(
0, 0,
@@ -469,6 +471,16 @@ class Widget(DOMNode):
except errors.NoWidget: except errors.NoWidget:
return Region() return Region()
@property
def virtual_region(self) -> Region:
"""The widget region relative to it's container. Which may not be visible,
depending on scroll offset.
"""
try:
return self.screen.find_widget(self).virtual_region
except errors.NoWidget:
return Region()
@property @property
def window_region(self) -> Region: def window_region(self) -> Region:
"""The region within the scrollable area that is currently visible. """The region within the scrollable area that is currently visible.
@@ -688,9 +700,7 @@ class Widget(DOMNode):
) )
def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool: def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool:
"""Starting from `widget`, travel up the DOM to this node, scrolling all containers such that """Scroll scrolling to bring a widget in to view.
every widget is visible within its parent container. This will, in the majority of cases,
bring the target widget into
Args: Args:
widget (Widget): A descendant widget. widget (Widget): A descendant widget.
@@ -700,54 +710,25 @@ class Widget(DOMNode):
bool: True if any scrolling has occurred in any descendant, otherwise False. bool: True if any scrolling has occurred in any descendant, otherwise False.
""" """
# TODO: Update this to use scroll_to_region # Grow the region by the margin so to keep the margin in view.
scrolls = set() region = widget.virtual_region.grow(widget.styles.margin)
scrolled = False
node = widget.parent while isinstance(widget.parent, Widget) and widget is not self:
child = widget container = widget.parent
while node: scroll_offset = container.scroll_to_region(region, animate=animate)
try: if scroll_offset:
widget_region = child.region scrolled = True
container_region = node.region
except (errors.NoWidget, AttributeError):
return False
if widget_region in container_region: # Adjust the region by the amount we just scrolled it, and convert to
# Widget is visible, nothing to do # it's parent's virtual coordinate system.
child = node region = (
node = node.parent region.translate(-scroll_offset)
continue .translate(-widget.scroll_offset)
.translate(container.virtual_region.offset)
# We can either scroll so the widget is at the top of the container, or so that ).intersection(container.virtual_region)
# it is at the bottom. We want to pick which has the shortest distance widget = container
top_delta = widget_region.offset - container_region.origin return scrolled
bottom_delta = widget_region.offset - (
container_region.origin
+ Offset(0, container_region.height - widget_region.height)
)
if widget_region.width > container_region.width:
delta_x = top_delta.x
else:
delta_x = min(top_delta.x, bottom_delta.x, key=abs)
if widget_region.height > container_region.height:
delta_y = top_delta.y
else:
delta_y = min(top_delta.y, bottom_delta.y, key=abs)
scrolled = node.scroll_relative(
delta_x or None, delta_y or None, animate=animate, duration=0.2
)
scrolls.add(scrolled)
if node == self:
break
child = node
node = node.parent
return any(scrolls)
def scroll_to_region( def scroll_to_region(
self, region: Region, *, spacing: Spacing | None = None, animate: bool = True self, region: Region, *, spacing: Spacing | None = None, animate: bool = True
@@ -763,13 +744,18 @@ class Widget(DOMNode):
spacing (Spacing): Space to subtract from the window region. spacing (Spacing): Space to subtract from the window region.
Returns: Returns:
bool: True if the window was scrolled. Offset: The distance that was scrolled.
""" """
window = self.content_region.at_offset(self.scroll_offset) window = self.content_region.at_offset(self.scroll_offset)
if spacing is not None: if spacing is not None:
window = window.shrink(spacing) window = window.shrink(spacing)
delta = Region.get_scroll_to_visible(window, region) delta_x, delta_y = Region.get_scroll_to_visible(window, region)
scroll_x, scroll_y = self.scroll_offset
delta = Offset(
clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x,
clamp(scroll_y + delta_y, 0, self.max_scroll_y) - scroll_y,
)
if delta: if delta:
self.scroll_relative( self.scroll_relative(
delta.x or None, delta.x or None,
@@ -781,7 +767,7 @@ class Widget(DOMNode):
def __init_subclass__( def __init_subclass__(
cls, cls,
can_focus: bool = True, can_focus: bool = False,
can_focus_children: bool = True, can_focus_children: bool = True,
inherit_css: bool = True, inherit_css: bool = True,
) -> None: ) -> None:

View File

@@ -43,7 +43,7 @@ class Button(Widget, can_focus=True):
} }
Button:focus { Button:focus {
text-style: bold underline; text-style: bold reverse;
} }
Button:hover { Button:hover {
@@ -183,6 +183,7 @@ class Button(Widget, can_focus=True):
def render(self) -> RenderableType: def render(self) -> RenderableType:
label = self.label.copy() label = self.label.copy()
label = Text.assemble(" ", label, " ")
label.stylize(self.text_style) label.stylize(self.text_style)
return label return label

View File

@@ -104,7 +104,7 @@ class Coord(NamedTuple):
return Coord(row + 1, column) return Coord(row + 1, column)
class DataTable(ScrollView, Generic[CellType]): class DataTable(ScrollView, Generic[CellType], can_focus=True):
CSS = """ CSS = """
DataTable { DataTable {

View File

@@ -251,6 +251,12 @@ def test_region_shrink():
assert region.shrink(margin) == Region(x=14, y=11, width=44, height=46) assert region.shrink(margin) == Region(x=14, y=11, width=44, height=46)
def test_region_grow():
margin = Spacing(top=1, right=2, bottom=3, left=4)
region = Region(x=10, y=10, width=50, height=50)
assert region.grow(margin) == Region(x=6, y=9, width=56, height=54)
def test_region_intersection(): def test_region_intersection():
assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region(
10, 10, 10, 10 10, 10, 10, 10