Merge branch 'main' into auto-focus-improv

This commit is contained in:
Rodrigo Girão Serrão
2023-05-17 10:28:54 +01:00
committed by GitHub
27 changed files with 1040 additions and 67 deletions

22
.github/workflows/black_format.yml vendored Normal file
View 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

View File

@@ -1,7 +1,8 @@
name: issues
name: Closed issue comment
on:
issues:
types: [closed]
jobs:
add-comment:
runs-on: ubuntu-latest

View File

@@ -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 ) }}

View File

@@ -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: |

View File

@@ -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

View File

@@ -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())

View 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()

View File

@@ -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.

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.
@@ -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(

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:
@@ -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

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,

View File

@@ -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:

View File

@@ -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

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

@@ -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()

View File

@@ -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"]
)