diff --git a/docs/examples/widgets/dialog_complex.py b/docs/examples/widgets/dialog_complex.py new file mode 100644 index 0000000000..d116350c1b --- /dev/null +++ b/docs/examples/widgets/dialog_complex.py @@ -0,0 +1,23 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Checkbox, Dialog, Input, Label + + +class DialogApp(App[None]): + CSS_PATH = "dialog_complex.tcss" + + def compose(self) -> ComposeResult: + with Dialog(title="Find and replace"): + yield Label("Find what:") # (1)! + yield Input(placeholder="The text to find") # (2)! + yield Label("Replace with:") # (3)! + yield Input(placeholder="The text to replace with") # (4)! + with Dialog.ActionArea(): # (5)! + with Dialog.ActionArea.GroupLeft(): # (6)! + yield Checkbox("Ignore case") # (7)! + yield Button("Cancel", variant="error") # (8)! + yield Button("First", variant="default") # (9)! + yield Button("All", variant="primary") # (10)! + + +if __name__ == "__main__": + DialogApp().run() diff --git a/docs/examples/widgets/dialog_complex.tcss b/docs/examples/widgets/dialog_complex.tcss new file mode 100644 index 0000000000..4ecd9838dd --- /dev/null +++ b/docs/examples/widgets/dialog_complex.tcss @@ -0,0 +1,19 @@ +Screen { + align: center middle; + + Dialog { + + Input { + width: 1fr; + margin: 0 0 1 0; + } + + Label { + margin: 0 0 0 1; + } + + ActionArea Button { + min-width: 11; + } + } +} diff --git a/docs/examples/widgets/dialog_error.py b/docs/examples/widgets/dialog_error.py new file mode 100644 index 0000000000..2d27d78f21 --- /dev/null +++ b/docs/examples/widgets/dialog_error.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Dialog, Label + + +class ErrorDialogApp(App[None]): + CSS_PATH = "dialog_variants.tcss" + + def compose(self) -> ComposeResult: + with Dialog.error(title="Emergency Transmission"): + yield Label( + "This is the President of the United Federation of " + "Planets. Do not approach the Earth. The transmissions of an " + "orbiting probe are causing critical damage to this planet. " + "It has almost totally ionized our atmosphere. All power " + "sources have failed. All Earth-orbiting starships are " + "powerless. The probe is vaporizing our oceans. We cannot " + "survive unless a way can be found to respond to the probe. " + "Further communications may not be possible. Save your " + "energy." + ) + with Dialog.ActionArea(): + yield Button("Obey") + yield Button("Ignore") + + +if __name__ == "__main__": + ErrorDialogApp().run() diff --git a/docs/examples/widgets/dialog_simple.py b/docs/examples/widgets/dialog_simple.py new file mode 100644 index 0000000000..d6cc2a13e9 --- /dev/null +++ b/docs/examples/widgets/dialog_simple.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Dialog, Label + + +class DialogApp(App[None]): + CSS_PATH = "dialog_simple.tcss" + + def compose(self) -> ComposeResult: + with Dialog(title="Greetings Professor Falken"): # (1)! + yield Label("Shall we play a game?") # (2)! + with Dialog.ActionArea(): # (3)! + yield Button("Love to!") # (4)! + yield Button("No thanks") # (5)! + + +if __name__ == "__main__": + DialogApp().run() diff --git a/docs/examples/widgets/dialog_simple.tcss b/docs/examples/widgets/dialog_simple.tcss new file mode 100644 index 0000000000..87b9fc77f0 --- /dev/null +++ b/docs/examples/widgets/dialog_simple.tcss @@ -0,0 +1,3 @@ +Screen { + align: center middle; +} diff --git a/docs/examples/widgets/dialog_styling.py b/docs/examples/widgets/dialog_styling.py new file mode 100644 index 0000000000..ef8125b204 --- /dev/null +++ b/docs/examples/widgets/dialog_styling.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Dialog, Label + + +class StyledDialogApp(App[None]): + CSS_PATH = "dialog_styling.tcss" + + def compose(self) -> ComposeResult: + with Dialog.error(title="Emergency Transmission"): + yield Label( + "This is the President of the United Federation of " + "Planets. Do not approach the Earth. The transmissions of an " + "orbiting probe are causing critical damage to this planet. " + "It has almost totally ionized our atmosphere. All power " + "sources have failed. All Earth-orbiting starships are " + "powerless. The probe is vaporizing our oceans. We cannot " + "survive unless a way can be found to respond to the probe. " + "Further communications may not be possible. Save your " + "energy." + ) + with Dialog.ActionArea(): + yield Button("Obey") + yield Button("Ignore") + + +if __name__ == "__main__": + StyledDialogApp().run() diff --git a/docs/examples/widgets/dialog_styling.tcss b/docs/examples/widgets/dialog_styling.tcss new file mode 100644 index 0000000000..9896b7c514 --- /dev/null +++ b/docs/examples/widgets/dialog_styling.tcss @@ -0,0 +1,17 @@ +Screen { + align: center middle; +} + +Dialog { + + Body { + border: thick red; + background: red 40%; + color: $text; + padding: 1 2; + } + + Label { + width: 1fr; + } +} diff --git a/docs/examples/widgets/dialog_success.py b/docs/examples/widgets/dialog_success.py new file mode 100644 index 0000000000..4c473030d2 --- /dev/null +++ b/docs/examples/widgets/dialog_success.py @@ -0,0 +1,16 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Dialog, Label + + +class SuccessDialogApp(App[None]): + CSS_PATH = "dialog_variants.tcss" + + def compose(self) -> ComposeResult: + with Dialog.success(title="Search Successful"): + yield Label("Admiral. We have found the nuclear wessel.") + with Dialog.ActionArea(): + yield Button("OK") + + +if __name__ == "__main__": + SuccessDialogApp().run() diff --git a/docs/examples/widgets/dialog_variants.tcss b/docs/examples/widgets/dialog_variants.tcss new file mode 100644 index 0000000000..7365f9983c --- /dev/null +++ b/docs/examples/widgets/dialog_variants.tcss @@ -0,0 +1,7 @@ +Screen { + align: center middle; +} + +Dialog Label { + width: 1fr; +} diff --git a/docs/examples/widgets/dialog_warning.py b/docs/examples/widgets/dialog_warning.py new file mode 100644 index 0000000000..56acd8bf62 --- /dev/null +++ b/docs/examples/widgets/dialog_warning.py @@ -0,0 +1,20 @@ +from textual.app import App, ComposeResult +from textual.widgets import Button, Dialog, Label + + +class WarningDialogApp(App[None]): + CSS_PATH = "dialog_variants.tcss" + + def compose(self) -> ComposeResult: + with Dialog.warning(title="Are you sure?"): + yield Label( + "Admiral, if we were to assume these whales were ours to do with as we pleased, " + "we would be as guilty as those who caused their extinction." + ) + with Dialog.ActionArea(): + yield Button("OK") + yield Button("Cancel") + + +if __name__ == "__main__": + WarningDialogApp().run() diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index ca82b5d4e3..94889f29e4 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -62,6 +62,15 @@ A powerful data table, with configurable cursors. ```{.textual path="docs/examples/widgets/data_table.py"} ``` +## Dialog + +A widget for laying out a classic dialog. + +[Dialog reference](./widgets/dialog.md){ .md-button .md-button--primary} + +```{.textual path="docs/examples/widgets/dialog_complex.py"} +``` + ## Digits Display numbers in tall characters. diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md new file mode 100644 index 0000000000..fd76625094 --- /dev/null +++ b/docs/widgets/dialog.md @@ -0,0 +1,204 @@ +# Dialog + +!!! tip "Added in version x.y.z" + +A container widget designed to help build classic dialog layouts. + +- [ ] Focusable +- [X] Container + +## Guide + +### Composing a `Dialog` + +The `Dialog` widget helps with laying out a classic dialog, one with +"content" widgets in the main body, and with a horizontal area of "action +items" (which will normally be buttons) at the bottom. This is ideally done +while [composing with a context +manager](../guide/layout/#composing-with-context-managers). For example: + +=== "dialog_simple.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_simple.py" + ~~~ + + 1. All widgets composed within here will be part of the dialog. + 2. The label is part of the body of the dialog. + 3. This introduces the area of horizontal-layout widgets at the bottom of the dialog. + 4. This button goes into the action area. + 5. This button goes into the action area. + +=== "dialog_simple.tcss" + + ```css + --8<-- "docs/examples/widgets/dialog_simple.tcss" + ``` + +This is how the resulting dialog looks: + +```{.textual path="docs/examples/widgets/dialog_simple.py"} +``` + +The items in the `ActionArea` of a dialog are aligned to the right; but it +is a common approach to include one or more widgets in the action area that +are aligned to the left. This can be done too: + +The dialog can contain any Textual widget, and the "action area" (where +buttons ordinarily live) can contain other widgets too. For example: + +=== "dialog_complex.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_complex.py" + ~~~ + + 1. This widget goes into the main body of the dialog. + 2. This widget goes into the main body of the dialog. + 3. This widget goes into the main body of the dialog. + 4. This widget goes into the main body of the dialog. + 5. This introduces the area of horizontal-layout widgets at the bottom of the dialog. + 6. Widgets within this group will be aligned to the left. + 7. The `Checkbox` will be on the left of the action area. + 8. The `Button` will be to the right of the action area. + 9. The `Button` will be to the right of the action area. + 10. The `Button` will be to the right of the action area. + +=== "dialog_complex.tcss" + + ```css + --8<-- "docs/examples/widgets/dialog_complex.tcss" + ``` + +The resulting dialog looks like this: + +```{.textual path="docs/examples/widgets/dialog_complex.py"} +``` + +### Dialog variants + +Much like with [`Button`](./button.md) the `Dialog` widget has variants; +these provide ready-made semantic styles for your dialogs. As well as +`default`, there are also: + +#### "success" + +A `success` variant `Dialog` can be created by either passing `"success"` as +the `variant` parameter when [creating the +`Dialog`](#textual.widgets.Dialog), by setting the [`variant` +reactive](#textual.widgets._dialog.Dialog.variant) to `"success"`, or by +calling the [`Dialog.success` +constructor](#textual.widgets._dialog.Dialog.success). + +=== "Success variant dialog" + + ```{.textual path="docs/examples/widgets/dialog_success.py"} + ``` + +=== "dialog_success.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_success.py" + ~~~ + +#### "warning" + +A `warning` variant `Dialog` can be created by either passing `"warning"` as +the `variant` parameter when [creating the +`Dialog`](#textual.widgets.Dialog), by setting the [`variant` +reactive](#textual.widgets._dialog.Dialog.variant) to `"warning"`, or by +calling the [`Dialog.warning` +constructor](#textual.widgets._dialog.Dialog.warning). + +=== "Warning variant dialog" + + ```{.textual path="docs/examples/widgets/dialog_warning.py"} + ``` + +=== "dialog_warning.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_warning.py" + ~~~ + +#### "error" + +An `error` variant `Dialog` can be created by either passing `"error"` as +the `variant` parameter when [creating the +`Dialog`](#textual.widgets.Dialog), by setting the [`variant` +reactive](#textual.widgets._dialog.Dialog.variant) to `"error"`, or by +calling the [`Dialog.error` +constructor](#textual.widgets._dialog.Dialog.error). + +=== "Error variant dialog" + + ```{.textual path="docs/examples/widgets/dialog_error.py"} + ``` + +=== "dialog_error.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_error.py" + ~~~ + +## Styling the Dialog + +The `Dialog` will always contain one sub-widget, called `Body`; this is +where the widgets that are not in the +[`ActionArea`](#textual.widgets._dialog.Dialog.ActionArea) are held. If you +wish to style the main body you can target `Dialog Body` in your CSS: + +=== "dialog_styling.tcss" + + ~~~css hl_lines="7-12" + --8<-- "docs/examples/widgets/dialog_styling.tcss" + ~~~ + +=== "Styled dialog body" + + ```{.textual path="docs/examples/widgets/dialog_styling.py"} + ``` + +=== "dialog_styling.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_styling.py" + ~~~ + + +## Reactive Attributes + +| Name | Type | Default | Description | +|-----------|-----------------|-------------|----------------------------------------------------------------------------| +| `title` | `str` | `""` | The title of the dialog. | +| `variant` | `DialogVariant` | `"default"` | Semantic styling variant. One of `default`, `success`, `warning`, `error`. | + +## Messages + +This widget posts no messages. + +## Bindings + +This widget has no bindings. + +## Component classes + +This widget has no component classes. + + +## See also + +- [ModalScreen](../guide/screens.md#modal-screens) + + +--- + + +::: textual.widgets.Dialog + options: + heading_level: 2 + +::: textual.widgets.dialog + options: + show_root_heading: true + show_root_toc_entry: true diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index a5ab15880a..7becb372c8 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -138,6 +138,7 @@ nav: - "widgets/collapsible.md" - "widgets/content_switcher.md" - "widgets/data_table.md" + - "widgets/dialog.md" - "widgets/digits.md" - "widgets/directory_tree.md" - "widgets/footer.md" diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index cd6e21f13b..e219535c20 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -15,6 +15,7 @@ from ._collapsible import Collapsible from ._content_switcher import ContentSwitcher from ._data_table import DataTable + from ._dialog import Dialog from ._digits import Digits from ._directory_tree import DirectoryTree from ._footer import Footer @@ -52,6 +53,7 @@ "Collapsible", "ContentSwitcher", "DataTable", + "Dialog", "Digits", "DirectoryTree", "Footer", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index d4db2f8f52..3686f0c738 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -4,6 +4,7 @@ from ._checkbox import Checkbox as Checkbox from ._collapsible import Collapsible as Collapsible from ._content_switcher import ContentSwitcher as ContentSwitcher from ._data_table import DataTable as DataTable +from ._dialog import Dialog as Dialog from ._digits import Digits as Digits from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py new file mode 100644 index 0000000000..953cf443e5 --- /dev/null +++ b/src/textual/widgets/_dialog.py @@ -0,0 +1,491 @@ +"""Provides a dialog widget.""" + +from __future__ import annotations + +from typing import get_args + +from typing_extensions import Final, Literal + +from ..app import ComposeResult +from ..containers import VerticalScroll +from ..css._error_tools import friendly_list +from ..css.query import NoMatches +from ..geometry import Size +from ..reactive import var +from ..widget import Widget + +_MAX_DIALOG_DIMENSION: Final[float] = 0.9 +"""The ideal maximum dimension for the dialog in respect to the screen. + +This is currently being used inside get_content_width/height in the code +below, as a stand-in for being able to max-width/height to 90% while also +doing a lot of `auto` sizing. Eventually, once we can support this via CSS, +this can go away. +""" + +DialogVariant = Literal["default", "success", "warning", "error"] +"""The names of the valid dialog variants. + +These are the variants that can be used with a [`Dialog`][textual.widgets.Dialog]. + +See also: + +- [`Dialog.success`][textual.widgets.Dialog.success] +- [`Dialog.warning`][textual.widgets.Dialog.warning] +- [`Dialog.error`][textual.widgets.Dialog.error] +""" + + +class Body(VerticalScroll, can_focus=False): + """Internal dialog container class for the main body of the dialog.""" + + DEFAULT_CSS = """ + Body { + width: auto; + height: auto; + } + """ + + def get_content_height(self, container: Size, viewport: Size, width: int) -> int: + """Get the ideal height for the body of the dialog. + + Args: + container: The container size. + viewport: The viewport. + width: The content width. + + Returns: + The height of the body portion of the dialog (in lines). + + Ideally we'd want this widget to be `height: auto` until it would be + too tall for the maximum height of the dialog, minus the height + needed for the [`ActionArea`][textual.widgets.Dialog.ActionArea]. At + the moment there's no simple method of doing this in with Textual's + CSS. + + So in this widget we simply set the `height` to `auto` and then + constrain the maximum height with this method; the idea being that + the content height will be capped at the maximum height that will + push the container to what we want its `max-height` to be, and no + further. + """ + height = super().get_content_height(container, viewport, width) + if isinstance(self.parent, Dialog): + try: + # See if the dialog has an ActionArea. + action_area = self.parent.get_child_by_type(Dialog.ActionArea) + except NoMatches: + # It's fine if it doesn't; that just means we don't need to + # take it into account for this calculation to take place. + pass + else: + height = min( + # Use the minimum of either the actual content height... + height, + # ...or maximum fraction of the screen... + int(self.screen.size.height * _MAX_DIALOG_DIMENSION) + # ...minus the full height of the ActionArea... + - action_area.outer_size.height + # ...and minus the size of the "non-content" parts of + # the container. + - (self.parent.outer_size.height - self.parent.size.height), + ) + return height + + +class Dialog(Widget): + """A dialog widget. + + `Dialog` is a container class that helps provide a classic dialog layout + for other widgets. + """ + + DEFAULT_CSS = """ + Dialog { + + layout: vertical; + + width: auto; + height: auto; + max-width: 90%; + /*max-height: 90%; Using the get_content_height hack above instead. */ + + border: panel $primary; + border-title-color: $accent; + background: $surface; + + padding: 1 1 0 1; + + ActionArea { + + layout: horizontal; + align: right middle; + + /* Action area should perfectly wrap its content. */ + height: auto; + width: auto; + + border-top: $primary; + + padding: 1 0 0 0; + + /* The developer may place widgets directly into the action + area; they will likely do this half expecting that there will be + a bit of space between each of the widgets. Let's help them with + that. */ + &> * { + margin-left: 1; + } + + &> GroupLeft { + + layout: horizontal; + align: left middle; + + /* The grouping container should perfectly wrap its content. */ + height: auto; + width: auto; + dock: left; + + /* The rule above for all items in the ActionArea will give + this grouping container a left margin too; but we don't want + that. */ + margin-left: 0; + } + + /* Devs will likely compose labels into the ActionArea, + expecting that they'll vertically line up with most other things + (most of those other) things going in often being 3 cells high. + So let's be super helpful here and have a default styling that + just does the right thing out of the box. */ + Label { + height: 100%; + content-align: center middle; + } + } + + /*** + * Styling exceptions for each of the variants. + */ + + &.-success { + border: panel $success; + border-title-color: initial; + + ActionArea { + border-top: $success; + } + } + + &.-warning { + border: panel $warning; + border-title-color: initial; + + ActionArea { + border-top: $warning; + } + } + + &.-error { + border: panel $error; + border-title-color: initial; + + ActionArea { + border-top: $error; + } + } + } + """ + + variant: var[DialogVariant] = var("default") + """The variant of dialog.""" + + title: var[str] = var("") + """The title of the dialog.""" + + def __init__( + self, + *children: Widget, + title: str = "", + variant: DialogVariant = "default", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + """Initialise the dialog widget. + + Args: + children: The child widgets for the dialog. + title: The title for the dialog. + variant: The variant of the dialog. + name: The name of the dialog. + id: The ID of the dialog in the DOM. + classes: The CSS classes of the dialog. + disabled: Whether the dialog is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self._dialog_children: list[Widget] = list(children) + """Holds the widgets that will go to making up the dialog.""" + self._action_area_count = 0 + """Keeps track of how many action areas were found.""" + self.variant = variant + self.title = title + + def _watch_variant( + self, old_variant: DialogVariant, new_variant: DialogVariant + ) -> None: + """React to the variant being changed.""" + self.remove_class(f"-{old_variant}") + self.add_class(f"-{new_variant}") + + def _validate_variant(self, variant: DialogVariant) -> DialogVariant: + """Ensure that the given variant is a supported value.""" + if variant not in get_args(DialogVariant): + raise ValueError( + f"Valid dialog variants are {friendly_list(get_args(DialogVariant))}" + ) + return variant + + def _watch_title(self) -> None: + """React to the title being changed.""" + self.border_title = self.title + + class MisplacedActionArea(Exception): + """Error raised if an action area is misplaced. + + [`ActionArea`][textual.widgets.Dialog.ActionArea] should only be + used inside a [`Dialog` widget][textual.widgets.Dialog]; this + exception will be raised if there is an attempt to mount an + [`ActionArea`][textual.widgets.Dialog.ActionArea] elsewhere. + """ + + class MisplacedActionGroup(Exception): + """Error raised if an action group is misplaced. + + [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft] should + only be used inside an + [`ActionArea`][textual.widgets.Dialog.ActionArea]; this exception + will be raised if there is an attempt to mount a + [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft] + elsewhere. + """ + + class ActionArea(Widget): + """A container that holds widgets that specify actions to perform on a dialog. + + This is the area in which buttons and other widgets should go that + dictate what actions should be performed on the contents of the + dialog. + + Widgets composed into this area will be grouped to the right, with a + 1-cell margin between them. If you wish to group some widgets to the + left of the area group them inside a + [`GroupLeft`][textual.widgets.Dialog.ActionArea.GroupLeft]; for example: + + ```python + def compose(self) -> ComposeResult: + with Dialog(title="Search"): + yield Input(placeholder="Search Text") + with Dialog.ActionArea(): + with Dialog.ActionArea.GroupLeft(): + yield Checkbox("RegExp") + yield Button("First", id="first") + yield Button("Next", id="next") + ``` + """ + + def on_mount(self) -> None: + # Check that we're only inside a `Dialog`. + if not isinstance(self.parent, Dialog): + raise Dialog.MisplacedActionArea( + "An ActionArea can only be used inside a Dialog." + ) + + class GroupLeft(Widget): + """A container for grouping widgets to the left side of a `Dialog.ActionArea`.""" + + def on_mount(self) -> None: + # Check that we're only inside an `ActionArea`. + if not isinstance(self.parent, Dialog.ActionArea): + raise Dialog.MisplacedActionGroup( + "A GroupLeft can only be used inside an ActionArea." + ) + + def get_content_width(self, container: Size, viewport: Size) -> int: + """Get the ideal width for the `ActionArea`. + + Args: + container: The container size. + viewport: The viewport. + + Returns: + The width of the action area for the dialog. + + Much like with `Body.get_content_height` this seeks to work + around a thing we need to figure out in CSS; where we want the + width of the dialog itself to be enough for the width of the + body or the width of the content of the action area; whichever + is greater. + + There's no easy way to say that in CSS right at the moment, so + this in concert with `Body.get_content_height` helps push the + layout in the right direction. + """ + width = super().get_content_width(container, viewport) + if isinstance(self.parent, Dialog): + try: + # See if the dialog has a body yet. + body = self.parent.get_child_by_type(Body) + except NoMatches: + # It's fine if it doesn't; that just means we'll go + # ahead and use our "normal" content width. + pass + else: + width = max( + # Use our own content width... + width, + # ...or the width of the body, minus our horizontal + # padding and margins; whichever is greater. + body.outer_size.width + - ( + self.styles.padding.right + + self.styles.padding.left + + self.styles.margin.right + + self.styles.margin.left + ), + ) + return width + + def compose_add_child(self, widget: Widget) -> None: + """Capture the widgets being added to the dialog for later processing. + + Args: + widget: The widget being added. + """ + self._dialog_children.append(widget) + + class TooManyActionAreas(Exception): + """Raised if there is an attempt to add more than one `ActionArea` to a dialog.""" + + def compose(self) -> ComposeResult: + """Compose the content of the dialog. + + Raises: + TooManyActionAreas: If more than one `ActionArea` is added to a dialog. + """ + # Loop over all of the children intended for the dialog, and extract + # any instance of an `ActionArea`; meanwhile place everything else + # inside a 'body' container that will scroll the content if + # necessary. + action_area: Dialog.ActionArea | None = None + with Body(): + for widget in self._dialog_children: + if isinstance(widget, Dialog.ActionArea): + # Note that we're always going to go with the last + # action area found; but we're also always keeping track + # of how many we saw. We should only ever see the one. + # In on_mount we'll raise an exception of more than one + # was found (that choice stems from exceptions from + # compose not always playing well with pytest). + action_area = widget + self._action_area_count += 1 + else: + yield widget + if action_area is not None: + yield action_area + + def on_mount(self) -> None: + # Now is a good idea to complain if there are too many ActionArea in + # the dialog. + if self._action_area_count > 1: + raise self.TooManyActionAreas( + "Only one ActionArea can be defined for a Dialog." + ) + + @staticmethod + def success( + *children: Widget, + title: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> Dialog: + """Create a success variant dialog widget. + + Args: + children: The child widgets for the dialog. + title: The title for the dialog. + name: The name of the dialog. + id: The ID of the dialog in the DOM. + classes: The CSS classes of the dialog. + disabled: Whether the dialog is disabled or not. + """ + return Dialog( + *children, + title=title, + variant="success", + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + + @staticmethod + def warning( + *children: Widget, + title: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> Dialog: + """Create a warning variant dialog widget. + + Args: + children: The child widgets for the dialog. + title: The title for the dialog. + name: The name of the dialog. + id: The ID of the dialog in the DOM. + classes: The CSS classes of the dialog. + disabled: Whether the dialog is disabled or not. + """ + return Dialog( + *children, + title=title, + variant="warning", + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + + @staticmethod + def error( + *children: Widget, + title: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> Dialog: + """Create a error variant dialog widget. + + Args: + children: The child widgets for the dialog. + title: The title for the dialog. + name: The name of the dialog. + id: The ID of the dialog in the DOM. + classes: The CSS classes of the dialog. + disabled: Whether the dialog is disabled or not. + """ + return Dialog( + *children, + title=title, + variant="error", + name=name, + id=id, + classes=classes, + disabled=disabled, + ) diff --git a/src/textual/widgets/dialog.py b/src/textual/widgets/dialog.py new file mode 100644 index 0000000000..b1d7ff389e --- /dev/null +++ b/src/textual/widgets/dialog.py @@ -0,0 +1,3 @@ +from ._dialog import DialogVariant + +__all__ = ["DialogVariant"] diff --git a/tests/dialog/test_dialog_errors.py b/tests/dialog/test_dialog_errors.py new file mode 100644 index 0000000000..00188a1af2 --- /dev/null +++ b/tests/dialog/test_dialog_errors.py @@ -0,0 +1,49 @@ +"""Tests for exceptions thrown by the Dialog widget.""" + +import pytest + +from textual.app import App, ComposeResult +from textual.widgets import Dialog, Label + + +class MultipleActionAreasApp(App[None]): + def compose(self) -> ComposeResult: + with Dialog(): + yield Label("Test") + with Dialog.ActionArea(): + yield Label("One") + with Dialog.ActionArea(): + yield Label("Two") + + +async def test_too_many_action_areas() -> None: + """More than one dialog action area should result in an error.""" + with pytest.raises(Dialog.TooManyActionAreas): + async with MultipleActionAreasApp().run_test(): + pass + + +class MisplacedActionAreaApp(App[None]): + def compose(self) -> ComposeResult: + with Dialog.ActionArea(): + yield Label("This should not be alone") + + +async def test_misplaced_action_area() -> None: + """Test that an exception is raised if an action area is misplaced.""" + with pytest.raises(Dialog.MisplacedActionArea): + async with MisplacedActionAreaApp().run_test(): + pass + + +class MisplacedActionAreaGroupApp(App[None]): + def compose(self) -> ComposeResult: + with Dialog.ActionArea.GroupLeft(): + yield Label("This should not be alone") + + +async def test_misplaced_action_area_group() -> None: + """Test that an exception is raised if an action area group is misplaced.""" + with pytest.raises(Dialog.MisplacedActionGroup): + async with MisplacedActionAreaGroupApp().run_test(): + pass diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 8ee39521f5..6fa60c4b95 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -16584,6 +16584,985 @@ ''' # --- +# name: test_dialog_complex + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DialogApp + + + + + + + + + + + + +  Find and replace ███████████████████████████████████████████████ + + Find what: + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The text to find + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Replace with: + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The text to replace with + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ──────────────────────────────────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Ignore caseCancelFirstAll + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + ''' +# --- +# name: test_dialog_error + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ErrorDialogApp + + + + + + + + + + + + + +  Emergency Transmission █████████████████████████████████████████ + + This is the President of the United Federation of Planets. Do  + not approach the Earth. The transmissions of an orbiting probe  + are causing critical damage to this planet. It has almost  + totally ionized our atmosphere. All power sources have failed.  + All Earth-orbiting starships are powerless. The probe is  + vaporizing our oceans. We cannot survive unless a way can be  + found to respond to the probe. Further communications may not be + possible. Save your energy. + ──────────────────────────────────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ObeyIgnore + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + ''' +# --- +# name: test_dialog_simple + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DialogApp + + + + + + + + + + + + + + + + +  Greetings Professor Falken ███████ + + Shall we play a game? + ────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Love to!No thanks + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + ''' +# --- +# name: test_dialog_styling + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + StyledDialogApp + + + + + + + + + + +  Emergency Transmission █████████████████████████████████████████ + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + + This is the President of the United Federation of Planets. + Do not approach the Earth. The transmissions of an  + orbiting probe are causing critical damage to this planet. + It has almost totally ionized our atmosphere. All power  + sources have failed. All Earth-orbiting starships are  + powerless. The probe is vaporizing our oceans. We cannot  + survive unless a way can be found to respond to the probe. + Further communications may not be possible. Save your  + energy. + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + ──────────────────────────────────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ObeyIgnore + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + ''' +# --- +# name: test_dialog_success + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SuccessDialogApp + + + + + + + + + + + + + + + + +  Search Successful ██████████████████████████████████████████████ + + Admiral. We have found the nuclear wessel. + ──────────────────────────────────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OK + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + ''' +# --- +# name: test_dialog_warning + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WarningDialogApp + + + + + + + + + + + + + + + +  Are you sure? ██████████████████████████████████████████████████ + + Admiral, if we were to assume these whales were ours to do with  + as we pleased, we would be as guilty as those who caused their  + extinction. + ──────────────────────────────────────────────────────────────── + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OKCancel + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + ''' +# --- # name: test_digits ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 63e5d93026..082a9c8f9a 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -984,3 +984,22 @@ def test_nested_specificity(snap_compare): def test_tab_rename(snap_compare): """Test setting a new label for a tab amongst a TabbedContent.""" assert snap_compare(SNAPSHOT_APPS_DIR / "tab_rename.py") + + +def test_dialog_complex(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_complex.py") + +def test_dialog_error(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_error.py") + +def test_dialog_simple(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_simple.py") + +def test_dialog_styling(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_styling.py") + +def test_dialog_success(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_success.py") + +def test_dialog_warning(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "dialog_warning.py")