mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into auto-focus-improv
This commit is contained in:
22
.github/workflows/black_format.yml
vendored
Normal file
22
.github/workflows/black_format.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
name: Black format check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/black_format.yml'
|
||||
- '**.py'
|
||||
|
||||
jobs:
|
||||
black-format-check:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: 3.11
|
||||
- name: Install black
|
||||
run: python -m pip install black
|
||||
- name: Run black
|
||||
run: black --check src
|
||||
3
.github/workflows/comment.yml
vendored
3
.github/workflows/comment.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: Closed issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
3
.github/workflows/new_issue.yml
vendored
3
.github/workflows/new_issue.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: FAQ issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }}
|
||||
|
||||
26
.github/workflows/pythonpackage.yml
vendored
26
.github/workflows/pythonpackage.yml
vendored
@@ -3,6 +3,7 @@ name: Test Textual module
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/pythonpackage.yml'
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- '**.css'
|
||||
@@ -21,27 +22,28 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install and configure Poetry
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- name: Install and configure Poetry # This could be cached, too...
|
||||
uses: snok/install-poetry@v1.3.3
|
||||
with:
|
||||
version: 1.4.2
|
||||
virtualenvs-in-project: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
- name: Install dependencies
|
||||
run: poetry install --extras "dev"
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Format check with black
|
||||
run: |
|
||||
source $VENV
|
||||
make format-check
|
||||
# - name: Typecheck with mypy
|
||||
# run: |
|
||||
# source $VENV
|
||||
# make typecheck
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
|
||||
@@ -11,8 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Changed
|
||||
|
||||
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521
|
||||
- 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,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- `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
|
||||
- `Screen.AUTO_FOCUS` now focuses the first _focusable_ widget that matches the selector https://github.com/Textualize/textual/issues/2578
|
||||
- `Screen.AUTO_FOCUS` now works on the default screen on startup https://github.com/Textualize/textual/pull/2581
|
||||
- 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
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class ModalApp(App):
|
||||
yield Footer()
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
"""Action to display the quit dialog."""
|
||||
self.push_screen(QuitScreen())
|
||||
|
||||
|
||||
|
||||
57
docs/examples/guide/screens/modal03.py
Normal file
57
docs/examples/guide/screens/modal03.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Grid
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Footer, Header, Label
|
||||
|
||||
TEXT = """I must not fear.
|
||||
Fear is the mind-killer.
|
||||
Fear is the little-death that brings total obliteration.
|
||||
I will face my fear.
|
||||
I will permit it to pass over me and through me.
|
||||
And when it has gone past, I will turn the inner eye to see its path.
|
||||
Where the fear has gone there will be nothing. Only I will remain."""
|
||||
|
||||
|
||||
class QuitScreen(ModalScreen[bool]): # (1)!
|
||||
"""Screen with a dialog to quit."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label("Are you sure you want to quit?", id="question"),
|
||||
Button("Quit", variant="error", id="quit"),
|
||||
Button("Cancel", variant="primary", id="cancel"),
|
||||
id="dialog",
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
self.dismiss(True)
|
||||
else:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class ModalApp(App):
|
||||
"""An app with a modal dialog."""
|
||||
|
||||
CSS_PATH = "modal01.css"
|
||||
BINDINGS = [("q", "request_quit", "Quit")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Label(TEXT * 8)
|
||||
yield Footer()
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
"""Action to display the quit dialog."""
|
||||
|
||||
def check_quit(quit: bool) -> None:
|
||||
"""Called when QuitScreen is dismissed."""
|
||||
if quit:
|
||||
self.exit()
|
||||
|
||||
self.push_screen(QuitScreen(), check_quit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ModalApp()
|
||||
app.run()
|
||||
@@ -219,3 +219,40 @@ Let's see what happens when we use `ModalScreen`.
|
||||
|
||||
Now when we press ++q++, the dialog is displayed over the main screen.
|
||||
The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input.
|
||||
|
||||
## Returning data from screens
|
||||
|
||||
It is a common requirement for screens to be able to return data.
|
||||
For instance, you may want a screen to show a dialog and have the result of that dialog processed *after* the screen has been popped.
|
||||
|
||||
To return data from a screen, call [`dismiss()`][textual.screen.dismiss] on the screen with the data you wish to return.
|
||||
This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.push_screen]).
|
||||
|
||||
Let's modify the previous example to use `dismiss` rather than an explicit `pop_screen`.
|
||||
|
||||
=== "modal03.py"
|
||||
|
||||
```python title="modal03.py" hl_lines="15 27-30 47-50 52"
|
||||
--8<-- "docs/examples/guide/screens/modal03.py"
|
||||
```
|
||||
|
||||
1. See below for an explanation of the `[bool]`
|
||||
|
||||
=== "modal01.css"
|
||||
|
||||
```sass title="modal01.css"
|
||||
--8<-- "docs/examples/guide/screens/modal01.css"
|
||||
```
|
||||
|
||||
In the `on_button_pressed` message handler we call `dismiss` with a boolean that indicates if the user has chosen to quit the app.
|
||||
This boolean is passed to the `check_quit` function we provided when `QuitScreen` was pushed.
|
||||
|
||||
Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller.
|
||||
This makes it easier for the app to perform any cleanup actions prior to exiting, for example.
|
||||
|
||||
Returning data in this way can help keep your code manageable by making it easy to re-use your `Screen` classes in other contexts.
|
||||
|
||||
### Typing screen results
|
||||
|
||||
You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`.
|
||||
The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -1416,7 +1409,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
Args:
|
||||
screen: A Screen instance or the name of an installed screen.
|
||||
callback: An optional callback function that is called if the screen is dismissed with a result.
|
||||
callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result.
|
||||
|
||||
Returns:
|
||||
An optional awaitable that awaits the mounting of the screen and its children.
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
@@ -766,6 +768,9 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
|
||||
"""Dismiss the screen, optionally with a result.
|
||||
|
||||
If `result` is provided and a callback was set when the screen was [pushed][textual.app.push_screen], then
|
||||
the callback will be invoked with `result`.
|
||||
|
||||
Args:
|
||||
result: The optional result to be passed to the result callback.
|
||||
|
||||
@@ -773,12 +778,6 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
ScreenStackError: If trying to dismiss a screen that is not at the top of
|
||||
the stack.
|
||||
|
||||
Note:
|
||||
If the screen was pushed with a callback, the callback will be
|
||||
called with the given result and then a call to
|
||||
[`App.pop_screen`][textual.app.App.pop_screen] is performed. If
|
||||
no callback was provided calling this method is the same as
|
||||
simply calling [`App.pop_screen`][textual.app.App.pop_screen].
|
||||
"""
|
||||
if self is not self.app.screen:
|
||||
from .app import ScreenStackError
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -627,11 +627,17 @@ class OptionList(ScrollView, can_focus=True):
|
||||
"""
|
||||
self._contents.clear()
|
||||
self._options.clear()
|
||||
self._refresh_content_tracking(force=True)
|
||||
self.highlighted = None
|
||||
self._mouse_hovering_over = None
|
||||
self.virtual_size = Size(self.scrollable_content_region.width, 0)
|
||||
self.refresh()
|
||||
# TODO: See https://github.com/Textualize/textual/issues/2582 -- it
|
||||
# should not be necessary to do this like this here; ideally here in
|
||||
# clear_options it would be a forced refresh, and also in a
|
||||
# `on_show` it would be the same (which, I think, would actually
|
||||
# solve the problem we're seeing). But, until such a time as we get
|
||||
# to the bottom of 2582... this seems to delay the refresh enough
|
||||
# that things fall into place.
|
||||
self._request_content_tracking_refresh()
|
||||
return self
|
||||
|
||||
def _set_option_disabled(self, index: int, disabled: bool) -> Self:
|
||||
|
||||
@@ -185,10 +185,6 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
Select {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Select > SelectOverlay {
|
||||
width: 1fr;
|
||||
display: none;
|
||||
|
||||
File diff suppressed because one or more lines are too long
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal file
33
tests/snapshot_tests/snapshot_apps/dock_scroll2.py
Normal 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()
|
||||
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal file
17
tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py
Normal 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()
|
||||
@@ -19,6 +19,6 @@
|
||||
|
||||
#horizontal {
|
||||
width: auto;
|
||||
height: auto;
|
||||
height: 4;
|
||||
background: darkslateblue;
|
||||
}
|
||||
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal file
19
tests/snapshot_tests/snapshot_apps/scroll_to.py
Normal 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()
|
||||
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
21
tests/snapshot_tests/snapshot_apps/select_rebuild.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Test https://github.com/Textualize/textual/issues/2557"""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Select, Button
|
||||
|
||||
|
||||
class SelectRebuildApp(App[None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Select[int]((("1", 1), ("2", 2)))
|
||||
yield Button("Rebuild")
|
||||
|
||||
def on_button_pressed(self):
|
||||
self.query_one(Select).set_options((
|
||||
("This", 0), ("Should", 1), ("Be", 2),
|
||||
("What", 3), ("Goes", 4), ("Into",5),
|
||||
("The", 6), ("Snapshit", 7)
|
||||
))
|
||||
|
||||
if __name__ == "__main__":
|
||||
SelectRebuildApp().run()
|
||||
@@ -455,6 +455,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))
|
||||
@@ -491,3 +508,12 @@ def test_quickly_change_tabs(snap_compare):
|
||||
def test_fr_unit_with_min(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2378
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "fr_with_min.py")
|
||||
|
||||
|
||||
def test_select_rebuild(snap_compare):
|
||||
# https://github.com/Textualize/textual/issues/2557
|
||||
assert snap_compare(
|
||||
SNAPSHOT_APPS_DIR / "select_rebuild.py",
|
||||
press=["tab", "space", "escape", "tab", "enter", "tab", "space"]
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user