Merge branch 'main' into directory-tree-work-in-worker

This commit is contained in:
Dave Pearson
2023-05-17 14:19:59 +01:00
committed by GitHub
17 changed files with 700 additions and 39 deletions

View File

@@ -12,8 +12,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521
- `DirectoryTree` now loads directory contents in a worker https://github.com/Textualize/textual/issues/2456
- Only a single error will be written by default, unless in dev mode ("debug" in App.features) https://github.com/Textualize/textual/issues/2480
- Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743
- Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575
- `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584
### Fixed
@@ -23,10 +25,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
- `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544
- Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563
- Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583
- Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525
### Added
- Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457
- Added `NULL_SPACING` and `NULL_REGION` to geometry.py
## [0.24.1] - 2023-05-08

View File

@@ -83,5 +83,5 @@ markers = [
]
[build-system]
requires = ["poetry-core>=1.0.0"]
requires = ["poetry-core>=1.2.0"]
build-backend = "poetry.core.masonry.api"

View File

@@ -33,7 +33,7 @@ from . import errors
from ._cells import cell_len
from ._context import visible_screen_stack
from ._loop import loop_last
from .geometry import NULL_OFFSET, Offset, Region, Size
from .geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing
from .strip import Strip, StripRenderable
if TYPE_CHECKING:
@@ -71,6 +71,8 @@ class MapGeometry(NamedTuple):
"""The container [size][textual.geometry.Size] (area not occupied by scrollbars)."""
virtual_region: Region
"""The [region][textual.geometry.Region] relative to the container (but not necessarily visible)."""
dock_gutter: Spacing
"""Space from the container reserved by docked widgets."""
@property
def visible_region(self) -> Region:
@@ -484,7 +486,7 @@ class Compositor:
# Widgets and regions in render order
visible_widgets = [
(order, widget, region, clip)
for widget, (region, order, clip, _, _, _) in map.items()
for widget, (region, order, clip, _, _, _, _) in map.items()
if in_screen(region) and overlaps(clip, region)
]
visible_widgets.sort(key=itemgetter(0), reverse=True)
@@ -522,6 +524,7 @@ class Compositor:
layer_order: int,
clip: Region,
visible: bool,
dock_gutter: Spacing,
_MapGeometry: type[MapGeometry] = MapGeometry,
) -> None:
"""Called recursively to place a widget and its children in the map.
@@ -591,10 +594,8 @@ class Compositor:
get_layer_index = layers_to_index.get
scroll_spacing = arrange_result.scroll_spacing
# Add all the widgets
for sub_region, margin, sub_widget, z, fixed, overlay in reversed(
for sub_region, _, sub_widget, z, fixed, overlay in reversed(
placements
):
layer_index = get_layer_index(sub_widget.layer, 0)
@@ -602,11 +603,6 @@ class Compositor:
if fixed:
widget_region = sub_region + placement_offset
else:
total_region = total_region.union(
sub_region.grow(
margin if layer_index else margin + scroll_spacing
)
)
widget_region = sub_region + placement_scroll_offset
widget_order = order + ((layer_index, z, layer_order),)
@@ -629,6 +625,7 @@ class Compositor:
layer_order,
no_clip if overlay else sub_clip,
visible,
arrange_result.scroll_spacing,
)
layer_order -= 1
@@ -646,6 +643,7 @@ class Compositor:
container_size,
container_size,
chrome_region,
dock_gutter,
)
map[widget] = _MapGeometry(
@@ -655,6 +653,7 @@ class Compositor:
total_region.size,
container_size,
virtual_region,
dock_gutter,
)
elif visible:
@@ -666,6 +665,7 @@ class Compositor:
region.size,
container_size,
virtual_region,
dock_gutter,
)
# Add top level (root) widget
@@ -677,6 +677,7 @@ class Compositor:
layer_order,
size.region,
True,
NULL_SPACING,
)
return map, widgets

View File

@@ -51,7 +51,8 @@ class DockArrangeResult:
Returns:
A Region.
"""
return self.spatial_map.total_region
_top, right, bottom, _left = self.scroll_spacing
return self.spatial_map.total_region.grow((0, right, bottom, 0))
def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
"""Get the placements visible within the given region.

View File

@@ -72,11 +72,11 @@ class SpatialMap(Generic[ValueType]):
_region_to_grid = self._region_to_grid_coordinates
total_region = self.total_region
for region, fixed, overlay, value in regions_and_values:
if not overlay:
total_region = total_region.union(region)
if fixed:
append_fixed(value)
else:
if not overlay:
total_region = total_region.union(region)
for grid in _region_to_grid(region):
get_grid_list(grid).append(value)
self.total_region = total_region

View File

@@ -604,14 +604,7 @@ class App(Generic[ReturnType], DOMNode):
"""
self.set_class(dark, "-dark-mode")
self.set_class(not dark, "-light-mode")
try:
self.refresh_css()
except ScreenStackError:
# It's possible that `dark` can be set before we have a default
# screen, in an app's `on_load`, for example. So let's eat the
# ScreenStackError -- the above styles will be handled once the
# screen is spun up anyway.
pass
self.call_later(self.refresh_css)
def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform.
@@ -1630,8 +1623,22 @@ class App(Generic[ReturnType], DOMNode):
def _print_error_renderables(self) -> None:
"""Print and clear exit renderables."""
error_count = len(self._exit_renderables)
if "debug" in self.features:
for renderable in self._exit_renderables:
self.error_console.print(renderable)
if error_count > 1:
self.error_console.print(
f"\n[b]NOTE:[/b] {error_count} errors show above.", markup=True
)
elif self._exit_renderables:
self.error_console.print(self._exit_renderables[0])
if error_count > 1:
self.error_console.print(
f"\n[b]NOTE:[/b] 1 of {error_count} errors show. Run with [b]--dev[/] to see all errors.",
markup=True,
)
self._exit_renderables.clear()
async def _process_messages(

View File

@@ -907,7 +907,7 @@ class Region(NamedTuple):
class Spacing(NamedTuple):
"""The spacing around a renderable, such as padding and border
"""The spacing around a renderable, such as padding and border.
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
@@ -940,7 +940,7 @@ class Spacing(NamedTuple):
top: int = 0
"""Space from the top of a region."""
right: int = 0
"""Space from the left of a region."""
"""Space from the right of a region."""
bottom: int = 0
"""Space from the bottom of a region."""
left: int = 0
@@ -1095,3 +1095,9 @@ class Spacing(NamedTuple):
NULL_OFFSET: Final = Offset(0, 0)
"""An [offset][textual.geometry.Offset] constant for (0, 0)."""
NULL_REGION: Final = Region(0, 0, 0, 0)
"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero)."""
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
"""A [Spacing][textual.geometry.Spacing] constant for no space."""

View File

@@ -349,20 +349,25 @@ class MessagePump(metaclass=_MessagePumpMeta):
self._timers.add(timer)
return timer
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
"""Schedule a callback to run after all messages are processed and the screen
has been refreshed. Positional and keyword arguments are passed to the callable.
Args:
callback: A callable.
Returns:
`True` if the callback was scheduled, or `False` if the callback could not be
scheduled (may occur if the message pump was closed or closing).
"""
# We send the InvokeLater message to ourselves first, to ensure we've cleared
# out anything already pending in our own queue.
message = messages.InvokeLater(partial(callback, *args, **kwargs))
self.post_message(message)
return self.post_message(message)
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
"""Schedule a callback to run after all messages are processed in this object.
Positional and keywords arguments are passed to the callable.
@@ -370,9 +375,14 @@ class MessagePump(metaclass=_MessagePumpMeta):
callback: Callable to call next.
*args: Positional arguments to pass to the callable.
**kwargs: Keyword arguments to pass to the callable.
Returns:
`True` if the callback was scheduled, or `False` if the callback could not be
scheduled (may occur if the message pump was closed or closing).
"""
message = events.Callback(callback=partial(callback, *args, **kwargs))
self.post_message(message)
return self.post_message(message)
def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run immediately after processing the current message.

View File

@@ -65,6 +65,7 @@ class Pilot(Generic[ReturnType]):
"""
if keys:
await self._app._press_keys(keys)
await self._wait_for_screen()
async def click(
self,
@@ -132,13 +133,49 @@ class Pilot(Generic[ReturnType]):
app.post_message(MouseMove(**message_arguments))
await self.pause()
async def _wait_for_screen(self, timeout: float = 30.0) -> bool:
"""Wait for the current screen to have processed all pending events.
Args:
timeout: A timeout in seconds to wait.
Returns:
`True` if all events were processed, or `False` if the wait timed out.
"""
children = [self.app, *self.app.screen.walk_children(with_self=True)]
count = 0
count_zero_event = asyncio.Event()
def decrement_counter() -> None:
"""Decrement internal counter, and set an event if it reaches zero."""
nonlocal count
count -= 1
if count == 0:
# When count is zero, all messages queued at the start of the method have been processed
count_zero_event.set()
# Increase the count for every successful call_later
for child in children:
if child.call_later(decrement_counter):
count += 1
if count:
# Wait for the count to return to zero, or a timeout
try:
await asyncio.wait_for(count_zero_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
return False
return True
async def pause(self, delay: float | None = None) -> None:
"""Insert a pause.
Args:
delay: Seconds to pause, or None to wait for cpu idle.
"""
# These sleep zeros, are to force asyncio to give up a time-slice,
# These sleep zeros, are to force asyncio to give up a time-slice.
await self._wait_for_screen()
if delay is None:
await wait_for_idle(0)
else:
@@ -152,7 +189,9 @@ class Pilot(Generic[ReturnType]):
async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._wait_for_screen()
await self._app.animator.wait_until_complete()
await self._wait_for_screen()
await wait_for_idle()
self.app.screen._on_timer_update()
@@ -162,5 +201,6 @@ class Pilot(Generic[ReturnType]):
Args:
result: The app result returned by `run` or `run_async`.
"""
await self._wait_for_screen()
await wait_for_idle()
self.app.exit(result)

View File

@@ -584,6 +584,7 @@ class Screen(Generic[ScreenResultType], Widget):
virtual_size,
container_size,
_,
_,
) in layers:
if widget in exposed_widgets:
if widget._size_updated(
@@ -614,6 +615,7 @@ class Screen(Generic[ScreenResultType], Widget):
virtual_size,
container_size,
_,
_,
) in layers:
widget._size_updated(region.size, virtual_size, container_size)
if widget in send_resize:

View File

@@ -57,7 +57,7 @@ from .box_model import BoxModel
from .css.query import NoMatches, WrongType
from .css.scalar import ScalarOffset
from .dom import DOMNode, NoScreen
from .geometry import Offset, Region, Size, Spacing, clamp
from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
@@ -1375,10 +1375,20 @@ class Widget(DOMNode):
"""
try:
return self.screen.find_widget(self).region
except NoScreen:
return Region()
except errors.NoWidget:
return Region()
except (NoScreen, errors.NoWidget):
return NULL_REGION
@property
def dock_gutter(self) -> Spacing:
"""Space allocated to docks in the parent.
Returns:
Space to be subtracted from scrollable area.
"""
try:
return self.screen.find_widget(self).dock_gutter
except (NoScreen, errors.NoWidget):
return NULL_SPACING
@property
def container_viewport(self) -> Region:
@@ -2263,7 +2273,7 @@ class Widget(DOMNode):
else:
scroll_offset = container.scroll_to_region(
region,
spacing=widget.parent.gutter,
spacing=widget.parent.gutter + widget.dock_gutter,
animate=animate,
speed=speed,
duration=duration,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
from textual.app import App
from textual.widgets import Header, Label, Footer
# Same as dock_scroll.py but with 2 labels
class TestApp(App):
BINDINGS = [("ctrl+q", "app.quit", "Quit")]
CSS = """
Label {
border: solid red;
}
Footer {
height: 4;
}
"""
def compose(self):
text = (
"this is a sample sentence and here are some words".replace(" ", "\n") * 2
)
yield Header()
yield Label(text)
yield Label(text)
yield Footer()
def on_mount(self):
self.dark = False
if __name__ == "__main__":
app = TestApp()
app.run()

View File

@@ -0,0 +1,17 @@
from textual.app import App, ComposeResult
from textual.widgets import Checkbox, Footer
class ScrollOffByOne(App):
def compose(self) -> ComposeResult:
for number in range(1, 100):
yield Checkbox(str(number))
yield Footer()
def on_mount(self) -> None:
self.query_one("Screen").scroll_end()
app = ScrollOffByOne()
if __name__ == "__main__":
app.run()

View File

@@ -19,6 +19,6 @@
#horizontal {
width: auto;
height: auto;
height: 4;
background: darkslateblue;
}

View File

@@ -0,0 +1,19 @@
from textual.app import App, ComposeResult
from textual.widgets import Checkbox, Footer
class ScrollOffByOne(App):
"""Scroll to item 50."""
def compose(self) -> ComposeResult:
for number in range(1, 100):
yield Checkbox(str(number), id=f"number-{number}")
yield Footer()
def on_ready(self) -> None:
self.query_one("#number-50").scroll_visible()
app = ScrollOffByOne()
if __name__ == "__main__":
app.run()

View File

@@ -203,9 +203,11 @@ def test_option_list(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py")
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
def test_option_list_build(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py")
def test_progress_bar_indeterminate(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])
@@ -457,6 +459,23 @@ def test_dock_scroll(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll.py", terminal_size=(80, 25))
def test_dock_scroll2(snap_compare):
# https://github.com/Textualize/textual/issues/2525
assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll2.py", terminal_size=(80, 25))
def test_dock_scroll_off_by_one(snap_compare):
# https://github.com/Textualize/textual/issues/2525
assert snap_compare(
SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25)
)
def test_scroll_to(snap_compare):
# https://github.com/Textualize/textual/issues/2525
assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25))
def test_auto_fr(snap_compare):
# https://github.com/Textualize/textual/issues/2220
assert snap_compare(SNAPSHOT_APPS_DIR / "auto_fr.py", terminal_size=(80, 25))