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
|
||||
5
.github/workflows/comment.yml
vendored
5
.github/workflows/comment.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: Closed issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -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.
|
||||
|
||||
3
.github/workflows/new_issue.yml
vendored
3
.github/workflows/new_issue.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: issues
|
||||
name: FAQ issue comment
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
add-comment:
|
||||
if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }}
|
||||
|
||||
26
.github/workflows/pythonpackage.yml
vendored
26
.github/workflows/pythonpackage.yml
vendored
@@ -3,6 +3,7 @@ name: Test Textual module
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/pythonpackage.yml'
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- '**.css'
|
||||
@@ -21,27 +22,28 @@ jobs:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Install and configure Poetry
|
||||
- uses: actions/checkout@v3.5.2
|
||||
- name: Install and configure Poetry # This could be cached, too...
|
||||
uses: snok/install-poetry@v1.3.3
|
||||
with:
|
||||
version: 1.4.2
|
||||
virtualenvs-in-project: true
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4.6.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
|
||||
- name: Install dependencies
|
||||
run: poetry install --extras "dev"
|
||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||
- name: Format check with black
|
||||
run: |
|
||||
source $VENV
|
||||
make format-check
|
||||
# - name: Typecheck with mypy
|
||||
# run: |
|
||||
# source $VENV
|
||||
# make typecheck
|
||||
- name: Test with pytest
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class ModalApp(App):
|
||||
yield Footer()
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
"""Action to display the quit dialog."""
|
||||
self.push_screen(QuitScreen())
|
||||
|
||||
|
||||
|
||||
57
docs/examples/guide/screens/modal03.py
Normal file
57
docs/examples/guide/screens/modal03.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Grid
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Button, Footer, Header, Label
|
||||
|
||||
TEXT = """I must not fear.
|
||||
Fear is the mind-killer.
|
||||
Fear is the little-death that brings total obliteration.
|
||||
I will face my fear.
|
||||
I will permit it to pass over me and through me.
|
||||
And when it has gone past, I will turn the inner eye to see its path.
|
||||
Where the fear has gone there will be nothing. Only I will remain."""
|
||||
|
||||
|
||||
class QuitScreen(ModalScreen[bool]): # (1)!
|
||||
"""Screen with a dialog to quit."""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Grid(
|
||||
Label("Are you sure you want to quit?", id="question"),
|
||||
Button("Quit", variant="error", id="quit"),
|
||||
Button("Cancel", variant="primary", id="cancel"),
|
||||
id="dialog",
|
||||
)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
self.dismiss(True)
|
||||
else:
|
||||
self.dismiss(False)
|
||||
|
||||
|
||||
class ModalApp(App):
|
||||
"""An app with a modal dialog."""
|
||||
|
||||
CSS_PATH = "modal01.css"
|
||||
BINDINGS = [("q", "request_quit", "Quit")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Label(TEXT * 8)
|
||||
yield Footer()
|
||||
|
||||
def action_request_quit(self) -> None:
|
||||
"""Action to display the quit dialog."""
|
||||
|
||||
def check_quit(quit: bool) -> None:
|
||||
"""Called when QuitScreen is dismissed."""
|
||||
if quit:
|
||||
self.exit()
|
||||
|
||||
self.push_screen(QuitScreen(), check_quit)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = ModalApp()
|
||||
app.run()
|
||||
@@ -219,3 +219,40 @@ Let's see what happens when we use `ModalScreen`.
|
||||
|
||||
Now when we press ++q++, the dialog is displayed over the main screen.
|
||||
The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input.
|
||||
|
||||
## Returning data from screens
|
||||
|
||||
It is a common requirement for screens to be able to return data.
|
||||
For instance, you may want a screen to show a dialog and have the result of that dialog processed *after* the screen has been popped.
|
||||
|
||||
To return data from a screen, call [`dismiss()`][textual.screen.dismiss] on the screen with the data you wish to return.
|
||||
This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.push_screen]).
|
||||
|
||||
Let's modify the previous example to use `dismiss` rather than an explicit `pop_screen`.
|
||||
|
||||
=== "modal03.py"
|
||||
|
||||
```python title="modal03.py" hl_lines="15 27-30 47-50 52"
|
||||
--8<-- "docs/examples/guide/screens/modal03.py"
|
||||
```
|
||||
|
||||
1. See below for an explanation of the `[bool]`
|
||||
|
||||
=== "modal01.css"
|
||||
|
||||
```sass title="modal01.css"
|
||||
--8<-- "docs/examples/guide/screens/modal01.css"
|
||||
```
|
||||
|
||||
In the `on_button_pressed` message handler we call `dismiss` with a boolean that indicates if the user has chosen to quit the app.
|
||||
This boolean is passed to the `check_quit` function we provided when `QuitScreen` was pushed.
|
||||
|
||||
Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller.
|
||||
This makes it easier for the app to perform any cleanup actions prior to exiting, for example.
|
||||
|
||||
Returning data in this way can help keep your code manageable by making it easy to re-use your `Screen` classes in other contexts.
|
||||
|
||||
### Typing screen results
|
||||
|
||||
You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`.
|
||||
The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
|
||||
|
||||
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 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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -156,4 +156,4 @@ class GridLayout(Layout):
|
||||
add_placement(WidgetPlacement(region, margin, 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)
|
||||
|
||||
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
|
||||
|
||||
@@ -86,4 +86,4 @@ class VerticalLayout(Layout):
|
||||
if not overlay:
|
||||
y = next_y + margin
|
||||
|
||||
return placements, set(children)
|
||||
return placements
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -627,11 +627,17 @@ class OptionList(ScrollView, can_focus=True):
|
||||
"""
|
||||
self._contents.clear()
|
||||
self._options.clear()
|
||||
self._refresh_content_tracking(force=True)
|
||||
self.highlighted = None
|
||||
self._mouse_hovering_over = None
|
||||
self.virtual_size = Size(self.scrollable_content_region.width, 0)
|
||||
self.refresh()
|
||||
# TODO: See https://github.com/Textualize/textual/issues/2582 -- it
|
||||
# should not be necessary to do this like this here; ideally here in
|
||||
# clear_options it would be a forced refresh, and also in a
|
||||
# `on_show` it would be the same (which, I think, would actually
|
||||
# solve the problem we're seeing). But, until such a time as we get
|
||||
# to the bottom of 2582... this seems to delay the refresh enough
|
||||
# that things fall into place.
|
||||
self._request_content_tracking_refresh()
|
||||
return self
|
||||
|
||||
def _set_option_disabled(self, index: int, disabled: bool) -> Self:
|
||||
|
||||
@@ -185,10 +185,6 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
|
||||
border: tall $accent;
|
||||
}
|
||||
|
||||
Select {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
Select > SelectOverlay {
|
||||
width: 1fr;
|
||||
display: none;
|
||||
|
||||
File diff suppressed because one or more lines are too long
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):
|
||||
# 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"]
|
||||
)
|
||||
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user