mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
return types and buttons
This commit is contained in:
18
docs/examples/app/question01.py
Normal file
18
docs/examples/app/question01.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Static, Button
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionApp(App[str]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("Do you love Textual?")
|
||||||
|
yield Button("Yes", id="yes", variant="primary")
|
||||||
|
yield Button("No", id="no", variant="error")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.exit(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
|
app = QuestionApp()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reply = app.run()
|
||||||
|
print(reply)
|
||||||
20
docs/examples/app/question02.py
Normal file
20
docs/examples/app/question02.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.widgets import Static, Button
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionApp(App[str]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("Do you love Textual?")
|
||||||
|
yield (yes := Button("Yes", id="yes"))
|
||||||
|
yes.variant = "primary"
|
||||||
|
yield (no := Button("No", id="no"))
|
||||||
|
no.variant = "error"
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
self.exit(event.button.id)
|
||||||
|
|
||||||
|
|
||||||
|
app = QuestionApp()
|
||||||
|
if __name__ == "__main__":
|
||||||
|
reply = app.run()
|
||||||
|
print(reply)
|
||||||
@@ -107,3 +107,34 @@ When you first run this you will get a blank screen. Press any key to add the we
|
|||||||
|
|
||||||
```{.textual path="docs/examples/app/widgets02.py" title="widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"}
|
```{.textual path="docs/examples/app/widgets02.py" title="widgets02.py" press="a,a,a,down,down,down,down,down,down,_,_,_,_,_,_"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Exiting
|
||||||
|
|
||||||
|
An app will run until you call [App.exit()](textual.app.App.exit) which will exit application mode and the [run](textual.app.App.run) method will return. If this is the last line in your code you will return to the command prompt.
|
||||||
|
|
||||||
|
The exit method will also accept an optional positional value to be returned by `run()`. The following example uses this to return the `id` (identifier) of a clicked button.
|
||||||
|
|
||||||
|
```python title="question01.py"
|
||||||
|
--8<-- "docs/examples/app/question01.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
Running this app will give you the following:
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/app/question01.py"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking either of those buttons will exit the app, and the `run()` method will return either `"yes"` or `"no"` depending on button clicked.
|
||||||
|
|
||||||
|
#### Typing
|
||||||
|
|
||||||
|
You may have noticed that we subclassed `App[str]` rather than the usual `App`.
|
||||||
|
|
||||||
|
```python title="question01.py" hl_lines="5"
|
||||||
|
--8<-- "docs/examples/app/question01.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
The addition of `[str]` tells Mypy that `run()` is expected to return a string. It may also return `None` if `sys.exit()` is called without a return value, so the return type of `run` will be `str | None`.
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Type annotations are entirely optional (but recommended) with Textual.
|
||||||
|
|||||||
44
src/textual/_compose.py
Normal file
44
src/textual/_compose.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import GeneratorType
|
||||||
|
from typing import TYPE_CHECKING, Iterable
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
|
||||||
|
from .app import ComposeResult
|
||||||
|
from .widget import Widget
|
||||||
|
|
||||||
|
|
||||||
|
def _compose(compose_result: ComposeResult) -> Iterable[Widget]:
|
||||||
|
"""Turns a compose result in to an iterable of Widgets.
|
||||||
|
|
||||||
|
If `compose_result` is a generator, this will run the generator and send
|
||||||
|
back the yielded widgets. This allows you to write code such as this:
|
||||||
|
|
||||||
|
```python
|
||||||
|
yes = yield Button("Yes")
|
||||||
|
yes.variant = "success"
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise `compose_result` is assumed to already be an iterable of Widgets
|
||||||
|
and will be returned unmodified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
compose_result (ComposeResult): Either an iterator of widgets,
|
||||||
|
or a generator.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Iterable[Widget]: In iterable if widgets.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not isinstance(compose_result, GeneratorType):
|
||||||
|
return compose_result
|
||||||
|
|
||||||
|
try:
|
||||||
|
widget = next(compose_result)
|
||||||
|
while True:
|
||||||
|
yield widget
|
||||||
|
widget = compose_result.send(widget)
|
||||||
|
except StopIteration:
|
||||||
|
pass
|
||||||
@@ -9,21 +9,13 @@ import sys
|
|||||||
import warnings
|
import warnings
|
||||||
from contextlib import redirect_stderr, redirect_stdout
|
from contextlib import redirect_stderr, redirect_stdout
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import PurePath, Path
|
from pathlib import Path, PurePath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from typing import (
|
from typing import Any, Generator, Generic, Iterable, Iterator, Type, TypeVar, cast
|
||||||
Any,
|
|
||||||
Generic,
|
|
||||||
Iterable,
|
|
||||||
Iterator,
|
|
||||||
TextIO,
|
|
||||||
Type,
|
|
||||||
TypeVar,
|
|
||||||
cast,
|
|
||||||
)
|
|
||||||
from weakref import WeakSet, WeakValueDictionary
|
from weakref import WeakSet, WeakValueDictionary
|
||||||
|
|
||||||
from ._ansi_sequences import SYNC_END, SYNC_START
|
from ._ansi_sequences import SYNC_END, SYNC_START
|
||||||
|
from ._compose import _compose
|
||||||
from ._path import _make_path_object_relative
|
from ._path import _make_path_object_relative
|
||||||
|
|
||||||
if sys.version_info >= (3, 8):
|
if sys.version_info >= (3, 8):
|
||||||
@@ -42,8 +34,8 @@ from rich.traceback import Traceback
|
|||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
Logger,
|
Logger,
|
||||||
LogSeverity,
|
|
||||||
LogGroup,
|
LogGroup,
|
||||||
|
LogSeverity,
|
||||||
LogVerbosity,
|
LogVerbosity,
|
||||||
actions,
|
actions,
|
||||||
events,
|
events,
|
||||||
@@ -72,7 +64,6 @@ from .renderables.blank import Blank
|
|||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
|
|
||||||
|
|
||||||
PLATFORM = platform.system()
|
PLATFORM = platform.system()
|
||||||
WINDOWS = PLATFORM == "Windows"
|
WINDOWS = PLATFORM == "Windows"
|
||||||
|
|
||||||
@@ -107,7 +98,7 @@ DEFAULT_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
ComposeResult = Iterable[Widget]
|
ComposeResult = Iterable[Widget] | Generator[Widget, Widget, None]
|
||||||
|
|
||||||
|
|
||||||
class AppError(Exception):
|
class AppError(Exception):
|
||||||
@@ -395,6 +386,9 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
return
|
return
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
def _compose(self) -> Iterable[Widget]:
|
||||||
|
return _compose(self.compose())
|
||||||
|
|
||||||
def get_css_variables(self) -> dict[str, str]:
|
def get_css_variables(self) -> dict[str, str]:
|
||||||
"""Get a mapping of variables used to pre-populate CSS.
|
"""Get a mapping of variables used to pre-populate CSS.
|
||||||
|
|
||||||
@@ -1155,7 +1149,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer")
|
self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer")
|
||||||
|
|
||||||
def _on_mount(self) -> None:
|
def _on_mount(self) -> None:
|
||||||
widgets = self.compose()
|
widgets = self._compose()
|
||||||
if widgets:
|
if widgets:
|
||||||
self.mount_all(widgets)
|
self.mount_all(widgets)
|
||||||
|
|
||||||
@@ -1189,9 +1183,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
parent (Widget): Parent Widget
|
parent (Widget): Parent Widget
|
||||||
"""
|
"""
|
||||||
if not anon_widgets and not widgets:
|
if not anon_widgets and not widgets:
|
||||||
raise AppError(
|
return
|
||||||
"Nothing to mount, did you forget parent as first positional arg?"
|
|
||||||
)
|
|
||||||
name_widgets: Iterable[tuple[str | None, Widget]]
|
name_widgets: Iterable[tuple[str | None, Widget]]
|
||||||
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
|
name_widgets = [*((None, widget) for widget in anon_widgets), *widgets.items()]
|
||||||
apply_stylesheet = self.stylesheet.apply
|
apply_stylesheet = self.stylesheet.apply
|
||||||
|
|||||||
@@ -4,16 +4,11 @@ from asyncio import Lock
|
|||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from operator import attrgetter
|
from operator import attrgetter
|
||||||
from typing import (
|
from types import GeneratorType
|
||||||
TYPE_CHECKING,
|
from typing import TYPE_CHECKING, ClassVar, Collection, Iterable, NamedTuple
|
||||||
ClassVar,
|
|
||||||
Collection,
|
|
||||||
Iterable,
|
|
||||||
NamedTuple,
|
|
||||||
)
|
|
||||||
|
|
||||||
import rich.repr
|
import rich.repr
|
||||||
from rich.console import Console, RenderableType, JustifyMethod
|
from rich.console import Console, JustifyMethod, RenderableType
|
||||||
from rich.measure import Measurement
|
from rich.measure import Measurement
|
||||||
from rich.segment import Segment
|
from rich.segment import Segment
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
@@ -22,7 +17,8 @@ from rich.text import Text
|
|||||||
|
|
||||||
from . import errors, events, messages
|
from . import errors, events, messages
|
||||||
from ._animator import BoundAnimator
|
from ._animator import BoundAnimator
|
||||||
from ._arrange import arrange, DockArrangeResult
|
from ._arrange import DockArrangeResult, arrange
|
||||||
|
from ._compose import _compose
|
||||||
from ._context import active_app
|
from ._context import active_app
|
||||||
from ._layout import Layout
|
from ._layout import Layout
|
||||||
from ._segment_tools import align_lines
|
from ._segment_tools import align_lines
|
||||||
@@ -30,8 +26,7 @@ from ._styles_cache import StylesCache
|
|||||||
from ._types import Lines
|
from ._types import Lines
|
||||||
from .box_model import BoxModel, get_box_model
|
from .box_model import BoxModel, get_box_model
|
||||||
from .css.constants import VALID_TEXT_ALIGN
|
from .css.constants import VALID_TEXT_ALIGN
|
||||||
from .dom import DOMNode
|
from .dom import DOMNode, NoScreen
|
||||||
from .dom import NoScreen
|
|
||||||
from .geometry import Offset, Region, Size, Spacing, clamp
|
from .geometry import Offset, Region, Size, Spacing, clamp
|
||||||
from .layouts.vertical import VerticalLayout
|
from .layouts.vertical import VerticalLayout
|
||||||
from .message import Message
|
from .message import Message
|
||||||
@@ -41,12 +36,12 @@ if TYPE_CHECKING:
|
|||||||
from .app import App, ComposeResult
|
from .app import App, ComposeResult
|
||||||
from .scrollbar import (
|
from .scrollbar import (
|
||||||
ScrollBar,
|
ScrollBar,
|
||||||
|
ScrollBarCorner,
|
||||||
ScrollDown,
|
ScrollDown,
|
||||||
ScrollLeft,
|
ScrollLeft,
|
||||||
ScrollRight,
|
ScrollRight,
|
||||||
ScrollTo,
|
ScrollTo,
|
||||||
ScrollUp,
|
ScrollUp,
|
||||||
ScrollBarCorner,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -239,7 +234,7 @@ class Widget(DOMNode):
|
|||||||
# reset the scroll position if the scrollbar is hidden.
|
# reset the scroll position if the scrollbar is hidden.
|
||||||
self.scroll_to(0, 0, animate=False)
|
self.scroll_to(0, 0, animate=False)
|
||||||
|
|
||||||
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
|
def mount(self, *anon_widgets: Widget, **widgets: Widget) -> int:
|
||||||
"""Mount child widgets (making this widget a container).
|
"""Mount child widgets (making this widget a container).
|
||||||
|
|
||||||
Widgets may be passed as positional arguments or keyword arguments. If keyword arguments,
|
Widgets may be passed as positional arguments or keyword arguments. If keyword arguments,
|
||||||
@@ -273,6 +268,9 @@ class Widget(DOMNode):
|
|||||||
return
|
return
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
def _compose(self) -> Iterable[Widget]:
|
||||||
|
return _compose(self.compose())
|
||||||
|
|
||||||
def _post_register(self, app: App) -> None:
|
def _post_register(self, app: App) -> None:
|
||||||
"""Called when the instance is registered.
|
"""Called when the instance is registered.
|
||||||
|
|
||||||
@@ -1458,10 +1456,9 @@ class Widget(DOMNode):
|
|||||||
await self.dispatch_key(event)
|
await self.dispatch_key(event)
|
||||||
|
|
||||||
def _on_mount(self, event: events.Mount) -> None:
|
def _on_mount(self, event: events.Mount) -> None:
|
||||||
widgets = list(self.compose())
|
widgets = self._compose()
|
||||||
if widgets:
|
self.mount(*widgets)
|
||||||
self.mount(*widgets)
|
self.screen.refresh(repaint=False, layout=True)
|
||||||
self.screen.refresh(repaint=False, layout=True)
|
|
||||||
|
|
||||||
def _on_leave(self, event: events.Leave) -> None:
|
def _on_leave(self, event: events.Leave) -> None:
|
||||||
self.mouse_over = False
|
self.mouse_over = False
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ class Button(Widget, can_focus=True):
|
|||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button.-disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
Button:focus {
|
Button:focus {
|
||||||
text-style: bold reverse;
|
text-style: bold reverse;
|
||||||
}
|
}
|
||||||
@@ -183,22 +187,32 @@ class Button(Widget, can_focus=True):
|
|||||||
if label is None:
|
if label is None:
|
||||||
label = self.css_identifier_styled
|
label = self.css_identifier_styled
|
||||||
|
|
||||||
self.label: Text = label
|
self.label = label
|
||||||
|
|
||||||
self.disabled = disabled
|
self.disabled = disabled
|
||||||
if disabled:
|
if disabled:
|
||||||
self.add_class("-disabled")
|
self.add_class("-disabled")
|
||||||
|
|
||||||
if variant in _VALID_BUTTON_VARIANTS:
|
self.variant = variant
|
||||||
if variant != "default":
|
|
||||||
self.add_class(f"-{variant}")
|
|
||||||
|
|
||||||
else:
|
label: Reactive[RenderableType] = Reactive("")
|
||||||
|
variant = Reactive.init("default")
|
||||||
|
disabled = Reactive(False)
|
||||||
|
|
||||||
|
def validate_variant(self, variant: str) -> str:
|
||||||
|
if variant not in _VALID_BUTTON_VARIANTS:
|
||||||
raise InvalidButtonVariant(
|
raise InvalidButtonVariant(
|
||||||
f"Valid button variants are {friendly_list(_VALID_BUTTON_VARIANTS)}"
|
f"Valid button variants are {friendly_list(_VALID_BUTTON_VARIANTS)}"
|
||||||
)
|
)
|
||||||
|
return variant
|
||||||
|
|
||||||
label: Reactive[RenderableType] = Reactive("")
|
def watch_variant(self, old_variant: str, variant: str):
|
||||||
|
self.remove_class(f"_{old_variant}")
|
||||||
|
self.add_class(f"-{variant}")
|
||||||
|
|
||||||
|
def watch_disabled(self, disabled: bool) -> None:
|
||||||
|
self.set_class(disabled, "-disabled")
|
||||||
|
self.can_focus = not disabled
|
||||||
|
|
||||||
def validate_label(self, label: RenderableType) -> RenderableType:
|
def validate_label(self, label: RenderableType) -> RenderableType:
|
||||||
"""Parse markup for self.label"""
|
"""Parse markup for self.label"""
|
||||||
|
|||||||
Reference in New Issue
Block a user