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

This commit is contained in:
Dave Pearson
2023-05-16 15:16:31 +01:00
29 changed files with 3343 additions and 2829 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
@@ -14,5 +15,5 @@ jobs:
issue-number: ${{ github.event.issue.number }}
body: |
Don't forget to [star](https://github.com/Textualize/textual) the repository!
Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

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

@@ -12,6 +12,8 @@ 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
- 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
### Fixed
@@ -20,6 +22,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed `TreeNode.collapse` and `TreeNode.collapse_all` not posting a `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535
- 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
### Added
- Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457
## [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.

BIN
reference/spacing.monopic Normal file

Binary file not shown.

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from fractions import Fraction
from operator import attrgetter
from typing import TYPE_CHECKING, Sequence
from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
from ._layout import DockArrangeResult, WidgetPlacement
from ._partition import partition
@@ -16,6 +16,21 @@ if TYPE_CHECKING:
TOP_Z = 2**31 - 1
def _build_dock_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]:
"""Organize widgets into layers.
Args:
widgets: The widgets.
Returns:
A mapping of layer name onto the widgets within the layer.
"""
layers: defaultdict[str, list[Widget]] = defaultdict(list)
for widget in widgets:
layers[widget.layer].append(widget)
return layers
def arrange(
widget: Widget, children: Sequence[Widget], size: Size, viewport: Size
) -> DockArrangeResult:
@@ -30,107 +45,120 @@ def arrange(
Widget arrangement information.
"""
arrange_widgets: set[Widget] = set()
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
for child in children:
if child.display:
dock_layers[child.layer].append(child)
width, height = size
placements: list[WidgetPlacement] = []
add_placement = placements.append
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
scroll_spacing = Spacing()
null_spacing = Spacing()
get_dock = attrgetter("styles.dock")
styles = widget.styles
# Widgets which will be displayed
display_widgets = [child for child in children if child.styles.display != "none"]
# Widgets organized into layers
dock_layers = _build_dock_layers(display_widgets)
layer_region = size.region
for widgets in dock_layers.values():
region = layer_region
# Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the
# document), and "dock" widgets which are positioned relative to an edge
layout_widgets, dock_widgets = partition(get_dock, widgets)
arrange_widgets.update(dock_widgets)
top = right = bottom = left = 0
for dock_widget in dock_widgets:
edge = dock_widget.styles.dock
box_model = dock_widget._get_box_model(
size, viewport, Fraction(size.width), Fraction(size.height)
)
widget_width_fraction, widget_height_fraction, margin = box_model
widget_width = int(widget_width_fraction) + margin.width
widget_height = int(widget_height_fraction) + margin.height
if edge == "bottom":
dock_region = Region(
0, height - widget_height, widget_width, widget_height
)
bottom = max(bottom, widget_height)
elif edge == "top":
dock_region = Region(0, 0, widget_width, widget_height)
top = max(top, widget_height)
elif edge == "left":
dock_region = Region(0, 0, widget_width, widget_height)
left = max(left, widget_width)
elif edge == "right":
dock_region = Region(
width - widget_width, 0, widget_width, widget_height
)
right = max(right, widget_width)
else:
# Should not occur, mainly to keep Mypy happy
raise AssertionError("invalid value for edge") # pragma: no-cover
align_offset = dock_widget.styles._align_size(
(widget_width, widget_height), size
)
dock_region = dock_region.shrink(margin).translate(align_offset)
add_placement(
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
)
dock_spacing = Spacing(top, right, bottom, left)
region = region.shrink(dock_spacing)
layout_placements, arranged_layout_widgets = widget._layout.arrange(
widget, layout_widgets, region.size
# Arrange docked widgets
_dock_placements, dock_spacing = _arrange_dock_widgets(
dock_widgets, size, viewport
)
if arranged_layout_widgets:
placements.extend(_dock_placements)
# Reduce the region to compensate for docked widgets
region = region.shrink(dock_spacing)
if layout_widgets:
# Arrange layout widgets (i.e. not docked)
layout_placements = widget._layout.arrange(
widget,
layout_widgets,
region.size,
)
scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)
arrange_widgets.update(arranged_layout_widgets)
placement_offset = region.offset
# Perform any alignment of the widgets.
if styles.align_horizontal != "left" or styles.align_vertical != "top":
placement_size = Region.from_union(
[
placement.region.grow(placement.margin)
for placement in layout_placements
]
).size
bounding_region = WidgetPlacement.get_bounds(layout_placements)
placement_offset += styles._align_size(
placement_size, region.size
bounding_region.size, region.size
).clamped
if placement_offset:
layout_placements = [
_WidgetPlacement(
_region + placement_offset,
margin,
layout_widget,
order,
fixed,
overlay,
)
for _region, margin, layout_widget, order, fixed, overlay in layout_placements
]
# Translate placements if required.
layout_placements = WidgetPlacement.translate(
layout_placements, placement_offset
)
placements.extend(layout_placements)
placements.extend(layout_placements)
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
return DockArrangeResult(placements, set(display_widgets), scroll_spacing)
def _arrange_dock_widgets(
dock_widgets: Sequence[Widget], size: Size, viewport: Size
) -> tuple[list[WidgetPlacement], Spacing]:
"""Arrange widgets which are *docked*.
Args:
dock_widgets: Widgets with a non-empty dock.
size: Size of the container.
viewport: Size of the viewport.
Returns:
A tuple of widget placements, and additional spacing around them
"""
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
width, height = size
null_spacing = Spacing()
top = right = bottom = left = 0
placements: list[WidgetPlacement] = []
append_placement = placements.append
for dock_widget in dock_widgets:
edge = dock_widget.styles.dock
box_model = dock_widget._get_box_model(
size, viewport, Fraction(size.width), Fraction(size.height)
)
widget_width_fraction, widget_height_fraction, margin = box_model
widget_width = int(widget_width_fraction) + margin.width
widget_height = int(widget_height_fraction) + margin.height
if edge == "bottom":
dock_region = Region(0, height - widget_height, widget_width, widget_height)
bottom = max(bottom, widget_height)
elif edge == "top":
dock_region = Region(0, 0, widget_width, widget_height)
top = max(top, widget_height)
elif edge == "left":
dock_region = Region(0, 0, widget_width, widget_height)
left = max(left, widget_width)
elif edge == "right":
dock_region = Region(width - widget_width, 0, widget_width, widget_height)
right = max(right, widget_width)
else:
# Should not occur, mainly to keep Mypy happy
raise AssertionError("invalid value for edge") # pragma: no-cover
align_offset = dock_widget.styles._align_size(
(widget_width, widget_height), size
)
dock_region = dock_region.shrink(margin).translate(align_offset)
append_placement(
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
)
dock_spacing = Spacing(top, right, bottom, left)
return (placements, dock_spacing)

View File

@@ -2,17 +2,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, ClassVar, NamedTuple
from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple
from ._spatial_map import SpatialMap
from .geometry import Region, Size, Spacing
from .geometry import Offset, Region, Size, Spacing
if TYPE_CHECKING:
from typing_extensions import TypeAlias
from .widget import Widget
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
ArrangeResult: TypeAlias = "list[WidgetPlacement]"
@dataclass
@@ -76,6 +76,41 @@ class WidgetPlacement(NamedTuple):
fixed: bool = False
overlay: bool = False
@classmethod
def translate(
cls, placements: list[WidgetPlacement], offset: Offset
) -> list[WidgetPlacement]:
"""Move all placements by a given offset.
Args:
placements: List of placements.
offset: Offset to add to placements.
Returns:
Placements with adjusted region, or same instance if offset is null.
"""
if offset:
return [
cls(region + offset, margin, layout_widget, order, fixed, overlay)
for region, margin, layout_widget, order, fixed, overlay in placements
]
return placements
@classmethod
def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region:
"""Get a bounding region around all placements.
Args:
placements: A number of placements.
Returns:
An optimal binding box around all placements.
"""
bounding_region = Region.from_union(
[placement.region.grow(placement.margin) for placement in placements]
)
return bounding_region
class Layout(ABC):
"""Responsible for arranging Widgets in a view and rendering them."""

View File

@@ -156,7 +156,7 @@ class ScreenError(Exception):
class ScreenStackError(ScreenError):
"""Raised when attempting to pop the last screen from the stack."""
"""Raised when trying to manipulate the screen stack incorrectly."""
class CssPathError(Exception):
@@ -1416,7 +1416,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.

View File

@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
Example:
Here's how you could detect when the app changes from dark to light mode (and visa versa).
Here's how you could detect when the app changes from dark to light mode (and vice versa).
```python
def on_dark_change(old_value:bool, new_value:bool):

View File

@@ -853,7 +853,7 @@ class Region(NamedTuple):
Args:
cut: An offset from self.y where the cut should be made. May be negative,
for the offset to start from the bottom edge.
for the offset to start from the lower edge.
Returns:
Two regions, which add up to the original (self).
@@ -909,7 +909,19 @@ class Region(NamedTuple):
class Spacing(NamedTuple):
"""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,
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
```
┌ ─ ─ ─ ─ ─ ─ ─▲─ ─ ─ ─ ─ ─ ─ ─ ┐
│ top
│ ┏━━━━━▼━━━━━━┓ │
◀──────▶┃ ┃◀───────▶
│ left ┃ ┃ right │
┃ ┃
│ ┗━━━━━▲━━━━━━┛ │
│ bottom
└ ─ ─ ─ ─ ─ ─ ─▼─ ─ ─ ─ ─ ─ ─ ─ ┘
```
Example:
```python

View File

@@ -156,4 +156,4 @@ class GridLayout(Layout):
add_placement(WidgetPlacement(region, margin, widget))
add_widget(widget)
return (placements, set(widgets))
return placements

View File

@@ -65,8 +65,6 @@ class HorizontalLayout(Layout):
x = Fraction(box_models[0].margin.left if box_models else 0)
displayed_children = [child for child in children if child.display]
_Region = Region
_WidgetPlacement = WidgetPlacement
for widget, box_model, margin in zip(children, box_models, margins):
@@ -86,4 +84,4 @@ class HorizontalLayout(Layout):
if not overlay:
x = next_x + margin
return placements, set(displayed_children)
return placements

View File

@@ -86,4 +86,4 @@ class VerticalLayout(Layout):
if not overlay:
y = next_y + margin
return placements, set(children)
return placements

View File

@@ -9,6 +9,7 @@ from typing import (
TYPE_CHECKING,
Awaitable,
Callable,
ClassVar,
Generic,
Iterable,
Iterator,
@@ -30,7 +31,7 @@ from ._types import CallbackType
from .binding import Binding
from .css.match import match
from .css.parse import parse_selectors
from .css.query import QueryType
from .css.query import NoMatches, QueryType
from .dom import DOMNode
from .geometry import Offset, Region, Size
from .reactive import Reactive
@@ -93,6 +94,13 @@ class ResultCallback(Generic[ScreenResultType]):
class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens."""
AUTO_FOCUS: ClassVar[str | None] = "*"
"""A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Set to `None` to disable auto focus.
"""
DEFAULT_CSS = """
Screen {
layout: vertical;
@@ -100,7 +108,6 @@ class Screen(Generic[ScreenResultType], Widget):
background: $surface;
}
"""
focused: Reactive[Widget | None] = Reactive(None)
"""The focused [widget][textual.widget.Widget] or `None` for no focus."""
stack_updates: Reactive[int] = Reactive(0, repaint=False)
@@ -659,6 +666,13 @@ class Screen(Generic[ScreenResultType], Widget):
"""Screen has resumed."""
self.stack_updates += 1
size = self.app.size
if self.AUTO_FOCUS is not None and self.focused is None:
try:
to_focus = self.query(self.AUTO_FOCUS).first()
except NoMatches:
pass
else:
self.set_focus(to_focus)
self._refresh_layout(size, full=True)
self.refresh()
@@ -754,16 +768,23 @@ 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.
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].
Raises:
ScreenStackError: If trying to dismiss a screen that is not at the top of
the stack.
"""
if self is not self.app.screen:
from .app import ScreenStackError
raise ScreenStackError(
f"Can't dismiss screen {self} that's not at the top of the stack."
)
if result is not self._NoResult and self._result_callbacks:
self._result_callbacks[-1](cast(ScreenResultType, result))
self.app.pop_screen()

View File

@@ -379,16 +379,22 @@ class Strip:
"""
pos = 0
cell_length = self.cell_length
cuts = [cut for cut in cuts if cut <= cell_length]
cache_key = tuple(cuts)
cached = self._divide_cache.get(cache_key)
if cached is not None:
return cached
strips: list[Strip] = []
add_strip = strips.append
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
add_strip(Strip(segments, cut - pos))
pos = cut
strips: list[Strip]
if cuts == [cell_length]:
strips = [self]
else:
strips = []
add_strip = strips.append
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
add_strip(Strip(segments, cut - pos))
pos = cut
self._divide_cache[cache_key] = strips
return strips

View File

@@ -795,19 +795,15 @@ class Widget(DOMNode):
Args:
child: The child widget to move.
before: Optional location to move before. An `int` is the index
of the child to move before, a `str` is a `query_one` query to
find the widget to move before.
after: Optional location to move after. An `int` is the index
of the child to move after, a `str` is a `query_one` query to
find the widget to move after.
before: Child widget or location index to move before.
after: Child widget or location index to move after.
Raises:
WidgetError: If there is a problem with the child or target.
Note:
Only one of ``before`` or ``after`` can be provided. If neither
or both are provided a ``WidgetError`` will be raised.
Only one of `before` or `after` can be provided. If neither
or both are provided a `WidgetError` will be raised.
"""
# One or the other of before or after are required. Can't do
@@ -817,6 +813,10 @@ class Widget(DOMNode):
elif before is not None and after is not None:
raise WidgetError("Only one of `before` or `after` can be handled.")
# We short-circuit the no-op, otherwise it will error later down the road.
if child is before or child is after:
return
def _to_widget(child: int | Widget, called: str) -> Widget:
"""Ensure a given child reference is a Widget."""
if isinstance(child, int):

View File

@@ -332,8 +332,9 @@ class Input(Widget, can_focus=True):
event.prevent_default()
def _on_paste(self, event: events.Paste) -> None:
line = event.text.splitlines()[0]
self.insert_text_at_cursor(line)
if event.text:
line = event.text.splitlines()[0]
self.insert_text_at_cursor(line)
event.stop()
async def _on_click(self, event: events.Click) -> None:

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

@@ -493,3 +493,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"]
)

View File

@@ -1,5 +1,6 @@
from textual import events
from textual.app import App
from textual.widgets import Input
async def test_paste_app():
@@ -16,3 +17,28 @@ async def test_paste_app():
assert len(paste_events) == 1
assert paste_events[0].text == "Hello"
async def test_empty_paste():
"""Regression test for https://github.com/Textualize/textual/issues/2563."""
paste_events = []
class MyInput(Input):
def on_paste(self, event):
super()._on_paste(event)
paste_events.append(event)
class PasteApp(App):
def compose(self):
yield MyInput()
def key_p(self):
self.query_one(MyInput).post_message(events.Paste(""))
app = PasteApp()
async with app.run_test() as pilot:
await pilot.press("p")
assert app.query_one(MyInput).value == ""
assert len(paste_events) == 1
assert paste_events[0].text == ""

View File

@@ -6,6 +6,7 @@ import pytest
from textual.app import App, ScreenStackError
from textual.screen import Screen
from textual.widgets import Button, Input
skip_py310 = pytest.mark.skipif(
sys.version_info.minor == 10 and sys.version_info.major == 3,
@@ -150,3 +151,58 @@ async def test_screens():
screen2.remove()
screen3.remove()
await app._shutdown()
async def test_auto_focus():
class MyScreen(Screen[None]):
def compose(self) -> None:
print("composing")
yield Button()
yield Input(id="one")
yield Input(id="two")
class MyApp(App[None]):
pass
app = MyApp()
async with app.run_test():
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
MyScreen.AUTO_FOCUS = "Input"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "one"
app.pop_screen()
MyScreen.AUTO_FOCUS = "#two"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "two"
# If we push and pop another screen, focus should be preserved for #two.
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
assert app.focused.id == "two"
async def test_dismiss_non_top_screen():
class MyApp(App[None]):
async def key_p(self) -> None:
self.bottom, top = Screen(), Screen()
await self.push_screen(self.bottom)
await self.push_screen(top)
app = MyApp()
async with app.run_test() as pilot:
await pilot.press("p")
with pytest.raises(ScreenStackError):
app.bottom.dismiss()

View File

@@ -42,22 +42,18 @@ async def test_move_child_to_outside() -> None:
pilot.app.screen.move_child(child, before=Widget())
@pytest.mark.xfail(
strict=True, reason="https://github.com/Textualize/textual/issues/1743"
)
async def test_move_child_before_itself() -> None:
"""Test moving a widget before itself."""
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)
pilot.app.screen.move_child(child, before=child)
@pytest.mark.xfail(
strict=True, reason="https://github.com/Textualize/textual/issues/1743"
)
async def test_move_child_after_itself() -> None:
"""Test moving a widget after itself."""
# Regression test for https://github.com/Textualize/textual/issues/1743
async with App().run_test() as pilot:
child = Widget(Widget())
await pilot.app.mount(child)