mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into directory-tree-work-in-worker
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:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [closed]
|
types: [closed]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
add-comment:
|
add-comment:
|
||||||
runs-on: ubuntu-latest
|
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:
|
on:
|
||||||
issues:
|
issues:
|
||||||
types: [opened]
|
types: [opened]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
add-comment:
|
add-comment:
|
||||||
if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }}
|
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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
- '.github/workflows/pythonpackage.yml'
|
||||||
- '**.py'
|
- '**.py'
|
||||||
- '**.pyi'
|
- '**.pyi'
|
||||||
- '**.css'
|
- '**.css'
|
||||||
@@ -21,27 +22,28 @@ jobs:
|
|||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v3.5.2
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Install and configure Poetry # This could be cached, too...
|
||||||
uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
architecture: x64
|
|
||||||
- name: Install and configure Poetry
|
|
||||||
uses: snok/install-poetry@v1.3.3
|
uses: snok/install-poetry@v1.3.3
|
||||||
with:
|
with:
|
||||||
version: 1.4.2
|
version: 1.4.2
|
||||||
virtualenvs-in-project: true
|
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
|
- name: Install dependencies
|
||||||
run: poetry install --extras "dev"
|
run: poetry install --extras "dev"
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||||
- name: Format check with black
|
|
||||||
run: |
|
|
||||||
source $VENV
|
|
||||||
make format-check
|
|
||||||
# - name: Typecheck with mypy
|
# - name: Typecheck with mypy
|
||||||
# run: |
|
# run: |
|
||||||
# source $VENV
|
|
||||||
# make typecheck
|
# make typecheck
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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
|
- 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
|
- `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
|
### 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.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
|
- 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
|
- `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
|
## [0.24.1] - 2023-05-08
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ModalApp(App):
|
|||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def action_request_quit(self) -> None:
|
def action_request_quit(self) -> None:
|
||||||
|
"""Action to display the quit dialog."""
|
||||||
self.push_screen(QuitScreen())
|
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.
|
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.
|
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
BIN
reference/spacing.monopic
Normal file
Binary file not shown.
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
from typing import TYPE_CHECKING, Sequence
|
from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
|
||||||
|
|
||||||
from ._layout import DockArrangeResult, WidgetPlacement
|
from ._layout import DockArrangeResult, WidgetPlacement
|
||||||
from ._partition import partition
|
from ._partition import partition
|
||||||
@@ -16,6 +16,21 @@ if TYPE_CHECKING:
|
|||||||
TOP_Z = 2**31 - 1
|
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(
|
def arrange(
|
||||||
widget: Widget, children: Sequence[Widget], size: Size, viewport: Size
|
widget: Widget, children: Sequence[Widget], size: Size, viewport: Size
|
||||||
) -> DockArrangeResult:
|
) -> DockArrangeResult:
|
||||||
@@ -30,34 +45,86 @@ def arrange(
|
|||||||
Widget arrangement information.
|
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] = []
|
placements: list[WidgetPlacement] = []
|
||||||
add_placement = placements.append
|
|
||||||
|
|
||||||
_WidgetPlacement = WidgetPlacement
|
|
||||||
top_z = TOP_Z
|
|
||||||
scroll_spacing = Spacing()
|
scroll_spacing = Spacing()
|
||||||
null_spacing = Spacing()
|
|
||||||
get_dock = attrgetter("styles.dock")
|
get_dock = attrgetter("styles.dock")
|
||||||
styles = widget.styles
|
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
|
layer_region = size.region
|
||||||
for widgets in dock_layers.values():
|
for widgets in dock_layers.values():
|
||||||
region = layer_region
|
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)
|
layout_widgets, dock_widgets = partition(get_dock, widgets)
|
||||||
|
|
||||||
arrange_widgets.update(dock_widgets)
|
# Arrange docked widgets
|
||||||
|
_dock_placements, dock_spacing = _arrange_dock_widgets(
|
||||||
|
dock_widgets, size, viewport
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
placement_offset = region.offset
|
||||||
|
# Perform any alignment of the widgets.
|
||||||
|
if styles.align_horizontal != "left" or styles.align_vertical != "top":
|
||||||
|
bounding_region = WidgetPlacement.get_bounds(layout_placements)
|
||||||
|
placement_offset += styles._align_size(
|
||||||
|
bounding_region.size, region.size
|
||||||
|
).clamped
|
||||||
|
|
||||||
|
if placement_offset:
|
||||||
|
# Translate placements if required.
|
||||||
|
layout_placements = WidgetPlacement.translate(
|
||||||
|
layout_placements, placement_offset
|
||||||
|
)
|
||||||
|
|
||||||
|
placements.extend(layout_placements)
|
||||||
|
|
||||||
|
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
|
top = right = bottom = left = 0
|
||||||
|
|
||||||
|
placements: list[WidgetPlacement] = []
|
||||||
|
append_placement = placements.append
|
||||||
|
|
||||||
for dock_widget in dock_widgets:
|
for dock_widget in dock_widgets:
|
||||||
edge = dock_widget.styles.dock
|
edge = dock_widget.styles.dock
|
||||||
|
|
||||||
@@ -70,9 +137,7 @@ def arrange(
|
|||||||
widget_height = int(widget_height_fraction) + margin.height
|
widget_height = int(widget_height_fraction) + margin.height
|
||||||
|
|
||||||
if edge == "bottom":
|
if edge == "bottom":
|
||||||
dock_region = Region(
|
dock_region = Region(0, height - widget_height, widget_width, widget_height)
|
||||||
0, height - widget_height, widget_width, widget_height
|
|
||||||
)
|
|
||||||
bottom = max(bottom, widget_height)
|
bottom = max(bottom, widget_height)
|
||||||
elif edge == "top":
|
elif edge == "top":
|
||||||
dock_region = Region(0, 0, widget_width, widget_height)
|
dock_region = Region(0, 0, widget_width, widget_height)
|
||||||
@@ -81,9 +146,7 @@ def arrange(
|
|||||||
dock_region = Region(0, 0, widget_width, widget_height)
|
dock_region = Region(0, 0, widget_width, widget_height)
|
||||||
left = max(left, widget_width)
|
left = max(left, widget_width)
|
||||||
elif edge == "right":
|
elif edge == "right":
|
||||||
dock_region = Region(
|
dock_region = Region(width - widget_width, 0, widget_width, widget_height)
|
||||||
width - widget_width, 0, widget_width, widget_height
|
|
||||||
)
|
|
||||||
right = max(right, widget_width)
|
right = max(right, widget_width)
|
||||||
else:
|
else:
|
||||||
# Should not occur, mainly to keep Mypy happy
|
# Should not occur, mainly to keep Mypy happy
|
||||||
@@ -93,44 +156,9 @@ def arrange(
|
|||||||
(widget_width, widget_height), size
|
(widget_width, widget_height), size
|
||||||
)
|
)
|
||||||
dock_region = dock_region.shrink(margin).translate(align_offset)
|
dock_region = dock_region.shrink(margin).translate(align_offset)
|
||||||
add_placement(
|
append_placement(
|
||||||
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
|
||||||
)
|
)
|
||||||
|
|
||||||
dock_spacing = Spacing(top, right, bottom, left)
|
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
|
|
||||||
)
|
|
||||||
if arranged_layout_widgets:
|
|
||||||
scroll_spacing = scroll_spacing.grow_maximum(dock_spacing)
|
|
||||||
arrange_widgets.update(arranged_layout_widgets)
|
|
||||||
|
|
||||||
placement_offset = region.offset
|
return (placements, dock_spacing)
|
||||||
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
|
|
||||||
placement_offset += styles._align_size(
|
|
||||||
placement_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
|
|
||||||
]
|
|
||||||
|
|
||||||
placements.extend(layout_placements)
|
|
||||||
|
|
||||||
return DockArrangeResult(placements, arrange_widgets, scroll_spacing)
|
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
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 ._spatial_map import SpatialMap
|
||||||
from .geometry import Region, Size, Spacing
|
from .geometry import Offset, Region, Size, Spacing
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import TypeAlias
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]"
|
ArrangeResult: TypeAlias = "list[WidgetPlacement]"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -76,6 +76,41 @@ class WidgetPlacement(NamedTuple):
|
|||||||
fixed: bool = False
|
fixed: bool = False
|
||||||
overlay: 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):
|
class Layout(ABC):
|
||||||
"""Responsible for arranging Widgets in a view and rendering them."""
|
"""Responsible for arranging Widgets in a view and rendering them."""
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ class ScreenError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class ScreenStackError(ScreenError):
|
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):
|
class CssPathError(Exception):
|
||||||
@@ -1416,7 +1416,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
screen: A Screen instance or the name of an installed screen.
|
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:
|
Returns:
|
||||||
An optional awaitable that awaits the mounting of the screen and its children.
|
An optional awaitable that awaits the mounting of the screen and its children.
|
||||||
|
|||||||
@@ -887,7 +887,7 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
Example:
|
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
|
```python
|
||||||
def on_dark_change(old_value:bool, new_value:bool):
|
def on_dark_change(old_value:bool, new_value:bool):
|
||||||
|
|||||||
@@ -853,7 +853,7 @@ class Region(NamedTuple):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
cut: An offset from self.y where the cut should be made. May be negative,
|
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:
|
Returns:
|
||||||
Two regions, which add up to the original (self).
|
Two regions, which add up to the original (self).
|
||||||
@@ -909,7 +909,19 @@ class Region(NamedTuple):
|
|||||||
class Spacing(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,
|
Spacing is defined by four integers for the space at the top, right, bottom, and left of a region.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ ─ ─ ─ ─ ─ ─ ─▲─ ─ ─ ─ ─ ─ ─ ─ ┐
|
||||||
|
│ top
|
||||||
|
│ ┏━━━━━▼━━━━━━┓ │
|
||||||
|
◀──────▶┃ ┃◀───────▶
|
||||||
|
│ left ┃ ┃ right │
|
||||||
|
┃ ┃
|
||||||
|
│ ┗━━━━━▲━━━━━━┛ │
|
||||||
|
│ bottom
|
||||||
|
└ ─ ─ ─ ─ ─ ─ ─▼─ ─ ─ ─ ─ ─ ─ ─ ┘
|
||||||
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -156,4 +156,4 @@ class GridLayout(Layout):
|
|||||||
add_placement(WidgetPlacement(region, margin, widget))
|
add_placement(WidgetPlacement(region, margin, widget))
|
||||||
add_widget(widget)
|
add_widget(widget)
|
||||||
|
|
||||||
return (placements, set(widgets))
|
return placements
|
||||||
|
|||||||
@@ -65,8 +65,6 @@ class HorizontalLayout(Layout):
|
|||||||
|
|
||||||
x = Fraction(box_models[0].margin.left if box_models else 0)
|
x = Fraction(box_models[0].margin.left if box_models else 0)
|
||||||
|
|
||||||
displayed_children = [child for child in children if child.display]
|
|
||||||
|
|
||||||
_Region = Region
|
_Region = Region
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
for widget, box_model, margin in zip(children, box_models, margins):
|
for widget, box_model, margin in zip(children, box_models, margins):
|
||||||
@@ -86,4 +84,4 @@ class HorizontalLayout(Layout):
|
|||||||
if not overlay:
|
if not overlay:
|
||||||
x = next_x + margin
|
x = next_x + margin
|
||||||
|
|
||||||
return placements, set(displayed_children)
|
return placements
|
||||||
|
|||||||
@@ -86,4 +86,4 @@ class VerticalLayout(Layout):
|
|||||||
if not overlay:
|
if not overlay:
|
||||||
y = next_y + margin
|
y = next_y + margin
|
||||||
|
|
||||||
return placements, set(children)
|
return placements
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import (
|
|||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
Awaitable,
|
Awaitable,
|
||||||
Callable,
|
Callable,
|
||||||
|
ClassVar,
|
||||||
Generic,
|
Generic,
|
||||||
Iterable,
|
Iterable,
|
||||||
Iterator,
|
Iterator,
|
||||||
@@ -30,7 +31,7 @@ from ._types import CallbackType
|
|||||||
from .binding import Binding
|
from .binding import Binding
|
||||||
from .css.match import match
|
from .css.match import match
|
||||||
from .css.parse import parse_selectors
|
from .css.parse import parse_selectors
|
||||||
from .css.query import QueryType
|
from .css.query import NoMatches, QueryType
|
||||||
from .dom import DOMNode
|
from .dom import DOMNode
|
||||||
from .geometry import Offset, Region, Size
|
from .geometry import Offset, Region, Size
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
@@ -93,6 +94,13 @@ class ResultCallback(Generic[ScreenResultType]):
|
|||||||
class Screen(Generic[ScreenResultType], Widget):
|
class Screen(Generic[ScreenResultType], Widget):
|
||||||
"""The base class for screens."""
|
"""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 = """
|
DEFAULT_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
layout: vertical;
|
layout: vertical;
|
||||||
@@ -100,7 +108,6 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
background: $surface;
|
background: $surface;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
focused: Reactive[Widget | None] = Reactive(None)
|
focused: Reactive[Widget | None] = Reactive(None)
|
||||||
"""The focused [widget][textual.widget.Widget] or `None` for no focus."""
|
"""The focused [widget][textual.widget.Widget] or `None` for no focus."""
|
||||||
stack_updates: Reactive[int] = Reactive(0, repaint=False)
|
stack_updates: Reactive[int] = Reactive(0, repaint=False)
|
||||||
@@ -659,6 +666,13 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
"""Screen has resumed."""
|
"""Screen has resumed."""
|
||||||
self.stack_updates += 1
|
self.stack_updates += 1
|
||||||
size = self.app.size
|
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_layout(size, full=True)
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -754,16 +768,23 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
|
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
|
||||||
"""Dismiss the screen, optionally with a result.
|
"""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:
|
Args:
|
||||||
result: The optional result to be passed to the result callback.
|
result: The optional result to be passed to the result callback.
|
||||||
|
|
||||||
Note:
|
Raises:
|
||||||
If the screen was pushed with a callback, the callback will be
|
ScreenStackError: If trying to dismiss a screen that is not at the top of
|
||||||
called with the given result and then a call to
|
the stack.
|
||||||
[`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
|
||||||
|
|
||||||
|
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:
|
if result is not self._NoResult and self._result_callbacks:
|
||||||
self._result_callbacks[-1](cast(ScreenResultType, result))
|
self._result_callbacks[-1](cast(ScreenResultType, result))
|
||||||
self.app.pop_screen()
|
self.app.pop_screen()
|
||||||
|
|||||||
@@ -379,12 +379,18 @@ class Strip:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
pos = 0
|
pos = 0
|
||||||
|
cell_length = self.cell_length
|
||||||
|
cuts = [cut for cut in cuts if cut <= cell_length]
|
||||||
cache_key = tuple(cuts)
|
cache_key = tuple(cuts)
|
||||||
cached = self._divide_cache.get(cache_key)
|
cached = self._divide_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
return cached
|
||||||
|
|
||||||
strips: list[Strip] = []
|
strips: list[Strip]
|
||||||
|
if cuts == [cell_length]:
|
||||||
|
strips = [self]
|
||||||
|
else:
|
||||||
|
strips = []
|
||||||
add_strip = strips.append
|
add_strip = strips.append
|
||||||
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
for segments, cut in zip(Segment.divide(self._segments, cuts), cuts):
|
||||||
add_strip(Strip(segments, cut - pos))
|
add_strip(Strip(segments, cut - pos))
|
||||||
|
|||||||
@@ -795,19 +795,15 @@ class Widget(DOMNode):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
child: The child widget to move.
|
child: The child widget to move.
|
||||||
before: Optional location to move before. An `int` is the index
|
before: Child widget or location index to move before.
|
||||||
of the child to move before, a `str` is a `query_one` query to
|
after: Child widget or location index to move after.
|
||||||
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.
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
WidgetError: If there is a problem with the child or target.
|
WidgetError: If there is a problem with the child or target.
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
Only one of ``before`` or ``after`` can be provided. If neither
|
Only one of `before` or `after` can be provided. If neither
|
||||||
or both are provided a ``WidgetError`` will be raised.
|
or both are provided a `WidgetError` will be raised.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# One or the other of before or after are required. Can't do
|
# 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:
|
elif before is not None and after is not None:
|
||||||
raise WidgetError("Only one of `before` or `after` can be handled.")
|
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:
|
def _to_widget(child: int | Widget, called: str) -> Widget:
|
||||||
"""Ensure a given child reference is a Widget."""
|
"""Ensure a given child reference is a Widget."""
|
||||||
if isinstance(child, int):
|
if isinstance(child, int):
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ class Input(Widget, can_focus=True):
|
|||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
|
|
||||||
def _on_paste(self, event: events.Paste) -> None:
|
def _on_paste(self, event: events.Paste) -> None:
|
||||||
|
if event.text:
|
||||||
line = event.text.splitlines()[0]
|
line = event.text.splitlines()[0]
|
||||||
self.insert_text_at_cursor(line)
|
self.insert_text_at_cursor(line)
|
||||||
event.stop()
|
event.stop()
|
||||||
|
|||||||
@@ -627,11 +627,17 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
"""
|
"""
|
||||||
self._contents.clear()
|
self._contents.clear()
|
||||||
self._options.clear()
|
self._options.clear()
|
||||||
self._refresh_content_tracking(force=True)
|
|
||||||
self.highlighted = None
|
self.highlighted = None
|
||||||
self._mouse_hovering_over = None
|
self._mouse_hovering_over = None
|
||||||
self.virtual_size = Size(self.scrollable_content_region.width, 0)
|
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
|
return self
|
||||||
|
|
||||||
def _set_option_disabled(self, index: int, disabled: bool) -> 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;
|
border: tall $accent;
|
||||||
}
|
}
|
||||||
|
|
||||||
Select {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
Select > SelectOverlay {
|
Select > SelectOverlay {
|
||||||
width: 1fr;
|
width: 1fr;
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
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()
|
||||||
@@ -493,3 +493,12 @@ def test_quickly_change_tabs(snap_compare):
|
|||||||
def test_fr_unit_with_min(snap_compare):
|
def test_fr_unit_with_min(snap_compare):
|
||||||
# https://github.com/Textualize/textual/issues/2378
|
# https://github.com/Textualize/textual/issues/2378
|
||||||
assert snap_compare(SNAPSHOT_APPS_DIR / "fr_with_min.py")
|
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"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from textual import events
|
from textual import events
|
||||||
from textual.app import App
|
from textual.app import App
|
||||||
|
from textual.widgets import Input
|
||||||
|
|
||||||
|
|
||||||
async def test_paste_app():
|
async def test_paste_app():
|
||||||
@@ -16,3 +17,28 @@ async def test_paste_app():
|
|||||||
|
|
||||||
assert len(paste_events) == 1
|
assert len(paste_events) == 1
|
||||||
assert paste_events[0].text == "Hello"
|
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 == ""
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import pytest
|
|||||||
|
|
||||||
from textual.app import App, ScreenStackError
|
from textual.app import App, ScreenStackError
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Button, Input
|
||||||
|
|
||||||
skip_py310 = pytest.mark.skipif(
|
skip_py310 = pytest.mark.skipif(
|
||||||
sys.version_info.minor == 10 and sys.version_info.major == 3,
|
sys.version_info.minor == 10 and sys.version_info.major == 3,
|
||||||
@@ -150,3 +151,58 @@ async def test_screens():
|
|||||||
screen2.remove()
|
screen2.remove()
|
||||||
screen3.remove()
|
screen3.remove()
|
||||||
await app._shutdown()
|
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()
|
||||||
|
|||||||
@@ -42,22 +42,18 @@ async def test_move_child_to_outside() -> None:
|
|||||||
pilot.app.screen.move_child(child, before=Widget())
|
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:
|
async def test_move_child_before_itself() -> None:
|
||||||
"""Test moving a widget before itself."""
|
"""Test moving a widget before itself."""
|
||||||
|
|
||||||
async with App().run_test() as pilot:
|
async with App().run_test() as pilot:
|
||||||
child = Widget(Widget())
|
child = Widget(Widget())
|
||||||
await pilot.app.mount(child)
|
await pilot.app.mount(child)
|
||||||
pilot.app.screen.move_child(child, before=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:
|
async def test_move_child_after_itself() -> None:
|
||||||
"""Test moving a widget after itself."""
|
"""Test moving a widget after itself."""
|
||||||
|
# Regression test for https://github.com/Textualize/textual/issues/1743
|
||||||
async with App().run_test() as pilot:
|
async with App().run_test() as pilot:
|
||||||
child = Widget(Widget())
|
child = Widget(Widget())
|
||||||
await pilot.app.mount(child)
|
await pilot.app.mount(child)
|
||||||
|
|||||||
Reference in New Issue
Block a user