From 1b82fc9af7d7e68bc713af4284f829e2243a0efa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 9 Jan 2024 15:57:10 +0000 Subject: [PATCH 01/43] Initial dialog work in progress --- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_dialog.py | 131 +++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 src/textual/widgets/_dialog.py 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..ea28f8f96b --- /dev/null +++ b/src/textual/widgets/_dialog.py @@ -0,0 +1,131 @@ +"""Provides a dialog widget.""" + +from __future__ import annotations + +from textual.app import ComposeResult + +from ..containers import Horizontal, Vertical, VerticalScroll +from ..reactive import var +from ..widget import Widget + + +class _Body(VerticalScroll, can_focus=False): + """Internal dialog container class for the main body of the dialog.""" + + +class Dialog(Vertical): + """A dialog widget.""" + + DEFAULT_CSS = """ + Dialog { + border: panel $panel; + width: auto; + height: auto; + max-width: 90%; + max-height: 90%; + + ActionArea { + align: right middle; + height: auto; + width: 1fr; + + /* 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 { + height: auto; + width: 1fr; + align: left middle; + + /* 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; + } + } + } + """ + + title: var[str] = var("") + """The title of the dialog.""" + + def __init__( + self, + *children: Widget, + title: str = "", + 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. + 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__( + *children, 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.title = title + + def _watch_title(self) -> None: + """React to the title being changed.""" + self.border_title = self.title + + class ActionArea(Horizontal): + """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. + """ + + class GroupLeft(Horizontal): + """A container for grouping widgets to the left side of a `Dialog.ActionArea`.""" + + 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): + if action_area is not None: + raise self.TooManyActionAreas( + "Only one ActionArea can be defined for a Dialog." + ) + action_area = widget + else: + yield widget + if action_area is not None: + yield action_area From 492e262e3f288449e59940d908e60ec713109b07 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 09:10:49 +0000 Subject: [PATCH 02/43] Don't name the body widget like its an internal While the intention is that it won't be exported, I think it's sensible to give it a "public" name so we can refer to it in styling guides, so something can then style: Dialog > Body { ... } and so on, without needing to use leading underscores, making it look like they shouldn't do that. --- src/textual/widgets/_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index ea28f8f96b..21fc65c46e 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -9,7 +9,7 @@ from ..widget import Widget -class _Body(VerticalScroll, can_focus=False): +class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" @@ -117,7 +117,7 @@ def compose(self) -> ComposeResult: # inside a 'body' container that will scroll the content if # necessary. action_area: Dialog.ActionArea | None = None - with _Body(): + with Body(): for widget in self._dialog_children: if isinstance(widget, Dialog.ActionArea): if action_area is not None: From eb23537effc35a67f6146032717b780adae3c029 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 09:40:02 +0000 Subject: [PATCH 03/43] Flesh out a docstring Some of this text is just reminders for the moment, and proper linking will need to be done too; but that can come once everything has settled down. --- src/textual/widgets/_dialog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 21fc65c46e..6611112f48 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -90,6 +90,10 @@ class ActionArea(Horizontal): 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 `Dialog.ActionArea.GroupLeft`. """ class GroupLeft(Horizontal): From d6d65805f5577abeebe6e69e1732419781a46bce Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 10:56:08 +0000 Subject: [PATCH 04/43] Don't pass the children to the super class --- src/textual/widgets/_dialog.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 6611112f48..b717d13cab 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -73,9 +73,7 @@ def __init__( classes: The CSS classes of the dialog. disabled: Whether the dialog is disabled or not. """ - super().__init__( - *children, name=name, id=id, classes=classes, disabled=disabled - ) + 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.title = title From f8354d243bd4d22d65354814dd8f16074e25fcb0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 11:06:24 +0000 Subject: [PATCH 05/43] Add a little more styling Far from having the final styles yet; but this gets me started. --- src/textual/widgets/_dialog.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index b717d13cab..12218992be 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -17,10 +17,15 @@ class Dialog(Vertical): """A dialog widget.""" DEFAULT_CSS = """ + $--dialog-border-color: $primary; + Dialog { - border: panel $panel; + border: panel $--dialog-border-color; + border-title-color: $accent; + background: $surface; width: auto; height: auto; + padding: 1 1 0 1; max-width: 90%; max-height: 90%; @@ -28,6 +33,8 @@ class Dialog(Vertical): align: right middle; height: auto; width: 1fr; + padding: 1 1 0 1; + border-top: $--dialog-border-color; /* The developer may place widgets directly into the action area; they will likely do this half expecting that there will be From 9c9d538d58a5d371486ed6270c63cc7035dfceac Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 11:23:22 +0000 Subject: [PATCH 06/43] Style Labels in the ActionArea in a way the dev would generally expect --- src/textual/widgets/_dialog.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 12218992be..8eee6c129e 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -54,6 +54,16 @@ class Dialog(Vertical): 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; + } } } """ From 7a54458b7e74978d9f89302c827c90bab51c8d3d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 15:17:54 +0000 Subject: [PATCH 07/43] Import tidy --- src/textual/widgets/_dialog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 8eee6c129e..578dc4ef29 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -2,8 +2,7 @@ from __future__ import annotations -from textual.app import ComposeResult - +from ..app import ComposeResult from ..containers import Horizontal, Vertical, VerticalScroll from ..reactive import var from ..widget import Widget From 65ffbdf0e9370bc53106b49b5c29f3e22c8b1ec1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 15:21:47 +0000 Subject: [PATCH 08/43] Isolate the dialog parts from the usual containers I was inheriting from various containers to build up the key parts of the dialog; the problem with that is that it's pretty easy for a developer using this dialog to also have done some pretty simple app-level styling of a Vertical or a Horizontal, and then things would start to fall apart in ways they might not expect. So here I just inherit from Widget and style the width/height/layout as needed. It's not much extra work and it helps keep some isolation. --- src/textual/widgets/_dialog.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 578dc4ef29..1425794d3e 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -3,7 +3,7 @@ from __future__ import annotations from ..app import ComposeResult -from ..containers import Horizontal, Vertical, VerticalScroll +from ..containers import VerticalScroll from ..reactive import var from ..widget import Widget @@ -12,29 +12,39 @@ class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" -class Dialog(Vertical): +class Dialog(Widget): """A dialog widget.""" DEFAULT_CSS = """ $--dialog-border-color: $primary; Dialog { - border: panel $--dialog-border-color; - border-title-color: $accent; - background: $surface; + + layout: vertical; + width: auto; height: auto; - padding: 1 1 0 1; max-width: 90%; max-height: 90%; + border: panel $--dialog-border-color; + border-title-color: $accent; + background: $surface; + + padding: 1 1 0 1; + ActionArea { + + layout: horizontal; align: right middle; + height: auto; width: 1fr; - padding: 1 1 0 1; + border-top: $--dialog-border-color; + padding: 1 1 0 1; + /* 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 @@ -44,9 +54,12 @@ class Dialog(Vertical): } &> GroupLeft { + + layout: horizontal; + align: left middle; + height: auto; width: 1fr; - align: left middle; /* The rule above for all items in the ActionArea will give this grouping container a left margin too; but we don't want @@ -98,7 +111,7 @@ def _watch_title(self) -> None: """React to the title being changed.""" self.border_title = self.title - class ActionArea(Horizontal): + 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 @@ -110,7 +123,7 @@ class ActionArea(Horizontal): left of the area group them inside a `Dialog.ActionArea.GroupLeft`. """ - class GroupLeft(Horizontal): + class GroupLeft(Widget): """A container for grouping widgets to the left side of a `Dialog.ActionArea`.""" def compose_add_child(self, widget: Widget) -> None: From 0078d0d14287ad1759f0ef11ba4d684c4179c875 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 20:38:11 +0000 Subject: [PATCH 09/43] Add a bit of debug code While I'm trying to get to the bottom of how best to size all the various parts of the dialog, let's make it easier to see what part of the display is responsible for what space. --- src/textual/widgets/_dialog.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 1425794d3e..1cc65dadc8 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -11,6 +11,12 @@ class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" + DEFAULT_CSS = """ + Body { + width: auto; + } + """ + class Dialog(Widget): """A dialog widget.""" @@ -33,6 +39,14 @@ class Dialog(Widget): padding: 1 1 0 1; + /* DEBUG */ + &> * { + background: $boost 200%; + &> * { + background: $boost 200%; + } + } + ActionArea { layout: horizontal; @@ -160,3 +174,10 @@ def compose(self) -> ComposeResult: yield widget if action_area is not None: yield action_area + + def on_mount(self) -> None: + # DEBUG + for widget in [self, *self.query("*")]: + widget.tooltip = "\n".join( + f"{node!r}" for node in widget.ancestors_with_self + ) From 91a6207838311665a099fbff7de0916417f4374a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 20:47:49 +0000 Subject: [PATCH 10/43] Remove the width for the Body I want this, but this managed to sneak in too early; so reverting. There's currently a problem of wanting to width/height: auto this, but cap it at 1fr within the max-width/height of its container. Textual can't handle that right now; so we either need to find a better way of pulling this off, or CSS might need a bit of a tweak to support this. --- src/textual/widgets/_dialog.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 1cc65dadc8..28914ed15f 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -11,12 +11,6 @@ class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" - DEFAULT_CSS = """ - Body { - width: auto; - } - """ - class Dialog(Widget): """A dialog widget.""" From 99157f393249431472cda95464b7cbc84d8e85db Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 Jan 2024 21:24:19 +0000 Subject: [PATCH 11/43] Experiment with a get_content_height hack The plan isn't go actually go with this; but this sort of implements what I'm aiming for; so we'll riff on this for the moment. --- src/textual/widgets/_dialog.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 28914ed15f..bb5a5a5966 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -4,6 +4,8 @@ from ..app import ComposeResult from ..containers import VerticalScroll +from ..css.query import NoMatches +from ..geometry import Size from ..reactive import var from ..widget import Widget @@ -11,6 +13,28 @@ 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: + # Quick hack! + height = super().get_content_height(container, viewport, width) + if self.parent: + try: + action_area = self.parent.query_one(Dialog.ActionArea) + except NoMatches: + pass + else: + return min( + height, + int(self.screen.size.height * 0.8) - action_area.outer_size.height, + ) + return height + class Dialog(Widget): """A dialog widget.""" @@ -25,7 +49,7 @@ class Dialog(Widget): width: auto; height: auto; max-width: 90%; - max-height: 90%; + /*max-height: 90%; Using the get_content_height hack above instead. */ border: panel $--dialog-border-color; border-title-color: $accent; @@ -47,7 +71,7 @@ class Dialog(Widget): align: right middle; height: auto; - width: 1fr; + width: auto; border-top: $--dialog-border-color; From a8e4157cf2887735be65e5771de8c2bbfa28110b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 Jan 2024 09:48:15 +0000 Subject: [PATCH 12/43] Experiment some more with controlling the height of the body Tidying up the way I calculate the ideal height for the body. As mentioned before, this isn't the ideal way to do things; it can give a flicker as the dialog is first shown; but it's an approximation of what I want at least. --- src/textual/widgets/_dialog.py | 42 +++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index bb5a5a5966..735b6bf2e2 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing_extensions import Final + from ..app import ComposeResult from ..containers import VerticalScroll from ..css.query import NoMatches @@ -9,6 +11,9 @@ from ..reactive import var from ..widget import Widget +_MAX_DIALOG_DIMENSION: Final[float] = 0.9 +"""The idea maximum dimension for the dialog in respect to the sceen.""" + class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" @@ -21,17 +26,48 @@ class Body(VerticalScroll, can_focus=False): """ def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - # Quick hack! + """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` and `max-height: + 1fr`, with the constraint on the ultimate height being a + `max-height` on the container. As of the time of writing, there's no + (obvious?) way to do this in CSS (that particular CSS setup doesn't + do what you'd probably expect). + + 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 self.parent: + if isinstance(self.parent, Widget): try: + # See if the dialog has an ActionArea. action_area = self.parent.query_one(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: return min( + # Use the minimum of either the actual content height... height, - int(self.screen.size.height * 0.8) - action_area.outer_size.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 From 8f58de34c0f2dc9ddb1dba3c44e0de280c72a517 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 Jan 2024 11:33:15 +0000 Subject: [PATCH 13/43] Remove debug styling --- src/textual/widgets/_dialog.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 735b6bf2e2..a9b34466d1 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -93,14 +93,6 @@ class Dialog(Widget): padding: 1 1 0 1; - /* DEBUG */ - &> * { - background: $boost 200%; - &> * { - background: $boost 200%; - } - } - ActionArea { layout: horizontal; From 0f0010e36c8fd9d3ac9d5235a0b40e80fff98f61 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 Jan 2024 15:35:46 +0000 Subject: [PATCH 14/43] Add a workaround for getting the action area in sync with the body --- src/textual/widgets/_dialog.py | 61 ++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index a9b34466d1..5b021c2afb 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -36,11 +36,10 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int Returns: The height of the body portion of the dialog (in lines). - Ideally we'd want this widget to be `height: auto` and `max-height: - 1fr`, with the constraint on the ultimate height being a - `max-height` on the container. As of the time of writing, there's no - (obvious?) way to do this in CSS (that particular CSS setup doesn't - do what you'd probably expect). + 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`. 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 @@ -49,7 +48,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int further. """ height = super().get_content_height(container, viewport, width) - if isinstance(self.parent, Widget): + if isinstance(self.parent, Dialog): try: # See if the dialog has an ActionArea. action_area = self.parent.query_one(Dialog.ActionArea) @@ -98,6 +97,7 @@ class Dialog(Widget): layout: horizontal; align: right middle; + /* Action area should perfectly wrap its content. */ height: auto; width: auto; @@ -118,8 +118,10 @@ class Dialog(Widget): layout: horizontal; align: left middle; + /* The grouping container should perfectly wrap its content. */ height: auto; - width: 1fr; + 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 @@ -186,6 +188,51 @@ class ActionArea(Widget): class GroupLeft(Widget): """A container for grouping widgets to the left side of a `Dialog.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.query_one(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: + return max( + # Use our own content width... + width, + # ...or the width of the body, minus our horizontal + # padding and margins; whichever is greater. + body.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. From fd84c049cc303068d60b2563b7e25b82065aca19 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 09:58:42 +0000 Subject: [PATCH 15/43] Fix a typo --- src/textual/widgets/_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 5b021c2afb..e0edfeb68f 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -12,7 +12,7 @@ from ..widget import Widget _MAX_DIALOG_DIMENSION: Final[float] = 0.9 -"""The idea maximum dimension for the dialog in respect to the sceen.""" +"""The ideal maximum dimension for the dialog in respect to the sceen.""" class Body(VerticalScroll, can_focus=False): From 4b5560deebda663793b1edff7cbe8552ee74515c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 09:59:04 +0000 Subject: [PATCH 16/43] Fix a typo --- src/textual/widgets/_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index e0edfeb68f..f5a62004bc 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -12,7 +12,7 @@ from ..widget import Widget _MAX_DIALOG_DIMENSION: Final[float] = 0.9 -"""The ideal maximum dimension for the dialog in respect to the sceen.""" +"""The ideal maximum dimension for the dialog in respect to the screen.""" class Body(VerticalScroll, can_focus=False): From 8788aa67bcf0680bfd7eda434b83e4451e311f97 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 11:43:17 +0000 Subject: [PATCH 17/43] Add initial support for variants I think I'll want to dial in the styling some more; but this is the core framework for this. --- src/textual/widgets/_dialog.py | 161 ++++++++++++++++++++++++++++++++- src/textual/widgets/dialog.py | 3 + 2 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 src/textual/widgets/dialog.py diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index f5a62004bc..1663cf8bbb 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -2,10 +2,13 @@ from __future__ import annotations -from typing_extensions import Final +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 @@ -14,6 +17,12 @@ _MAX_DIALOG_DIMENSION: Final[float] = 0.9 """The ideal maximum dimension for the dialog in respect to the screen.""" +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]. +""" + class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" @@ -75,8 +84,6 @@ class Dialog(Widget): """A dialog widget.""" DEFAULT_CSS = """ - $--dialog-border-color: $primary; - Dialog { layout: vertical; @@ -86,7 +93,7 @@ class Dialog(Widget): max-width: 90%; /*max-height: 90%; Using the get_content_height hack above instead. */ - border: panel $--dialog-border-color; + border: panel $primary; border-title-color: $accent; background: $surface; @@ -101,7 +108,7 @@ class Dialog(Widget): height: auto; width: auto; - border-top: $--dialog-border-color; + border-top: $primary; padding: 1 1 0 1; @@ -139,9 +146,48 @@ class Dialog(Widget): content-align: center middle; } } + + /* + * Success variant styling. + */ + &.-success { + border: panel $success; + border-title-color: initial; + + ActionArea { + border-top: $success; + } + } + + /* + * Warning variant styling. + */ + &.-warning { + border: panel $warning; + border-title-color: initial; + + ActionArea { + border-top: $warning; + } + } + + /* + * Error variant styling. + */ + &.-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.""" @@ -149,6 +195,7 @@ def __init__( self, *children: Widget, title: str = "", + variant: DialogVariant = "default", name: str | None = None, id: str | None = None, classes: str | None = None, @@ -159,6 +206,7 @@ def __init__( 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. @@ -167,8 +215,24 @@ def __init__( 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.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 @@ -274,3 +338,90 @@ def on_mount(self) -> None: widget.tooltip = "\n".join( f"{node!r}" for node in widget.ancestors_with_self ) + + @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 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="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"] From 69ffc183f6c628681554474b11abb071d7922880 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 11:45:56 +0000 Subject: [PATCH 18/43] Tidy the commenting in the CSS around variants --- src/textual/widgets/_dialog.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 1663cf8bbb..f599b319a1 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -147,9 +147,10 @@ class Dialog(Widget): } } - /* - * Success variant styling. + /*** + * Styling exceptions for each of the variants. */ + &.-success { border: panel $success; border-title-color: initial; @@ -159,9 +160,6 @@ class Dialog(Widget): } } - /* - * Warning variant styling. - */ &.-warning { border: panel $warning; border-title-color: initial; @@ -171,9 +169,6 @@ class Dialog(Widget): } } - /* - * Error variant styling. - */ &.-error { border: panel $error; border-title-color: initial; From 06746845154f270bfc4fb1415056ff76f8436802 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 14:30:39 +0000 Subject: [PATCH 19/43] Tidy up the DOM info debug code --- src/textual/widgets/_dialog.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index f599b319a1..a7ed66f26e 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -23,6 +23,32 @@ These are the variants that can be used with a [`Dialog`][textual.widgets.Dialog]. """ +############################################################################## +from rich.console import Group +from rich.rule import Rule + + +class DOMInfo: + def __init__(self, widget: Widget) -> None: + self._widget = widget + + def __rich__(self) -> Group: + return Group( + Rule("DOM hierarchy"), + "\n".join(f"{node!r}" for node in self._widget.ancestors_with_self), + Rule("CSS"), + self._widget.styles.css, + Rule(), + ) + + @classmethod + def attach_to(cls, node: Widget) -> None: + for widget in [node, *node.query("*")]: + widget.tooltip = cls(widget) + + +############################################################################## + class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" @@ -328,11 +354,7 @@ def compose(self) -> ComposeResult: yield action_area def on_mount(self) -> None: - # DEBUG - for widget in [self, *self.query("*")]: - widget.tooltip = "\n".join( - f"{node!r}" for node in widget.ancestors_with_self - ) + DOMInfo.attach_to(self) @staticmethod def success( From c24c9892b83f87362886bbab4797011add544d4d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 14:53:03 +0000 Subject: [PATCH 20/43] Help debug some dimension information --- src/textual/widgets/_dialog.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index a7ed66f26e..cbc616ec3b 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -36,6 +36,9 @@ def __rich__(self) -> Group: return Group( Rule("DOM hierarchy"), "\n".join(f"{node!r}" for node in self._widget.ancestors_with_self), + Rule("Dimensions"), + f"Container: {self._widget.container_size}", + f"Content: {self._widget.content_size}", Rule("CSS"), self._widget.styles.css, Rule(), From c4fdbfbbaf4affc4b409977b1e3990dea76e800e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 Jan 2024 14:53:26 +0000 Subject: [PATCH 21/43] Simplify the get_content_* exit paths --- src/textual/widgets/_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index cbc616ec3b..e4db322015 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -95,7 +95,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int # take it into account for this calculation to take place. pass else: - return min( + height = min( # Use the minimum of either the actual content height... height, # ...or maximum fraction of the screen... @@ -306,7 +306,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int: # ahead and use our "normal" content width. pass else: - return max( + width = max( # Use our own content width... width, # ...or the width of the body, minus our horizontal From da312bc178ed404bc63f3b862aa06248365bb996 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 Jan 2024 10:06:54 +0000 Subject: [PATCH 22/43] Alert the developer if they misplace a GroupLeft --- src/textual/widgets/_dialog.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index e4db322015..423ed64b87 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -261,6 +261,9 @@ def _watch_title(self) -> None: """React to the title being changed.""" self.border_title = self.title + class MisplacedActionGroup(Exception): + """Error raised if an action group is misplaced.""" + class ActionArea(Widget): """A container that holds widgets that specify actions to perform on a dialog. @@ -276,6 +279,13 @@ class ActionArea(Widget): 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`. From 23084633534b84d2879b01b37c951582b5fe5193 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 Jan 2024 10:55:34 +0000 Subject: [PATCH 23/43] Add the scaffolding for Dialog documentation More to add to this, more to write, but this kicks off the basics for documenting the Dialog. --- docs/examples/widgets/dialog.py | 17 +++++++ docs/examples/widgets/dialog.tcss | 3 ++ docs/widgets/dialog.md | 73 +++++++++++++++++++++++++++++++ mkdocs-nav.yml | 1 + src/textual/widgets/_dialog.py | 46 ++++++++++++++++--- 5 files changed, 134 insertions(+), 6 deletions(-) create mode 100644 docs/examples/widgets/dialog.py create mode 100644 docs/examples/widgets/dialog.tcss create mode 100644 docs/widgets/dialog.md diff --git a/docs/examples/widgets/dialog.py b/docs/examples/widgets/dialog.py new file mode 100644 index 0000000000..5c6759b856 --- /dev/null +++ b/docs/examples/widgets/dialog.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.tcss" + + def compose(self) -> ComposeResult: + with Dialog(title="Greetings Professor Falken"): + yield Label("Shall we play a game?") + with Dialog.ActionArea(): + yield Button("Love to!") + yield Button("No thanks") + + +if __name__ == "__main__": + DialogApp().run() diff --git a/docs/examples/widgets/dialog.tcss b/docs/examples/widgets/dialog.tcss new file mode 100644 index 0000000000..87b9fc77f0 --- /dev/null +++ b/docs/examples/widgets/dialog.tcss @@ -0,0 +1,3 @@ +Screen { + align: center middle; +} diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md new file mode 100644 index 0000000000..2b54d49e68 --- /dev/null +++ b/docs/widgets/dialog.md @@ -0,0 +1,73 @@ +# Dialog + +!!! tip "Added in version x.y.z" + +Widget description. + +- [ ] Focusable +- [X] Container + + +## Example + +Example app showing the widget: + +=== "Output" + + ```{.textual path="docs/examples/widgets/dialog.py"} + ``` + +=== "checkbox.py" + + ```python + --8<-- "docs/examples/widgets/dialog.py" + ``` + +=== "checkbox.tcss" + + ```css + --8<-- "docs/examples/widgets/dialog.tcss" + ``` + + +## 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. + +## Additional notes + +- Did you know this? +- Another pro tip. + + +## 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 2296ac92fc..a9795d2a6e 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/_dialog.py b/src/textual/widgets/_dialog.py index 423ed64b87..3f9f783794 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -76,8 +76,9 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int 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`. At the moment there's no simple method - of doing this in with Textual's CSS. + 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 @@ -110,7 +111,20 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int class Dialog(Widget): - """A dialog widget.""" + """A dialog widget. + + `Dialog` is a container class that helps provide a classic dialog layout + for other widgets. An example use may be: + + ```python + def compose(self) -> ComposeResult: + with Dialog(title="Confirm"): + yield Label("Shall we play a game?") + with Dialog.ActionArea(): + yield Button("Yes", id="yes") + yield Button("No", id="no") + ``` + """ DEFAULT_CSS = """ Dialog { @@ -262,7 +276,15 @@ def _watch_title(self) -> None: self.border_title = self.title class MisplacedActionGroup(Exception): - """Error raised if an action group is misplaced.""" + """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. @@ -273,14 +295,26 @@ class ActionArea(Widget): 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 `Dialog.ActionArea.GroupLeft`. + 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") + ``` """ 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`.""" + # 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." From 871104aed8f7ad703d2f1c35f5192517d9f4c427 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 Jan 2024 11:31:55 +0000 Subject: [PATCH 24/43] Remove some debug code --- src/textual/widgets/_dialog.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 3f9f783794..1a2a67d303 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -23,35 +23,6 @@ These are the variants that can be used with a [`Dialog`][textual.widgets.Dialog]. """ -############################################################################## -from rich.console import Group -from rich.rule import Rule - - -class DOMInfo: - def __init__(self, widget: Widget) -> None: - self._widget = widget - - def __rich__(self) -> Group: - return Group( - Rule("DOM hierarchy"), - "\n".join(f"{node!r}" for node in self._widget.ancestors_with_self), - Rule("Dimensions"), - f"Container: {self._widget.container_size}", - f"Content: {self._widget.content_size}", - Rule("CSS"), - self._widget.styles.css, - Rule(), - ) - - @classmethod - def attach_to(cls, node: Widget) -> None: - for widget in [node, *node.query("*")]: - widget.tooltip = cls(widget) - - -############################################################################## - class Body(VerticalScroll, can_focus=False): """Internal dialog container class for the main body of the dialog.""" @@ -400,9 +371,6 @@ def compose(self) -> ComposeResult: if action_area is not None: yield action_area - def on_mount(self) -> None: - DOMInfo.attach_to(self) - @staticmethod def success( *children: Widget, From 5011b9eb96c8ead08a39def45da55f9e95a4db25 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 Jan 2024 11:39:43 +0000 Subject: [PATCH 25/43] Fix a copy/paste-o --- src/textual/widgets/_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 1a2a67d303..96ee8b2946 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -438,7 +438,7 @@ def error( classes: str | None = None, disabled: bool = False, ) -> Dialog: - """Create a warning variant dialog widget. + """Create a error variant dialog widget. Args: children: The child widgets for the dialog. From cee3962ddd8582f54916043a79c83b5db9e2e0fe Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 Jan 2024 16:41:17 +0000 Subject: [PATCH 26/43] Trying to dial in a more complex dialog example --- docs/examples/widgets/dialog_complex.py | 23 +++++++++++ docs/examples/widgets/dialog_complex.tcss | 15 +++++++ .../widgets/{dialog.py => dialog_simple.py} | 2 +- .../{dialog.tcss => dialog_simple.tcss} | 0 docs/widgets/dialog.md | 40 +++++++++++++++---- src/textual/widgets/_dialog.py | 14 ++++++- 6 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 docs/examples/widgets/dialog_complex.py create mode 100644 docs/examples/widgets/dialog_complex.tcss rename docs/examples/widgets/{dialog.py => dialog_simple.py} (92%) rename docs/examples/widgets/{dialog.tcss => dialog_simple.tcss} (100%) diff --git a/docs/examples/widgets/dialog_complex.py b/docs/examples/widgets/dialog_complex.py new file mode 100644 index 0000000000..638baf82b8 --- /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:") + yield Input(placeholder="The text to find") + yield Label("Replace with:") + yield Input(placeholder="The text to replace with") + with Dialog.ActionArea(): + with Dialog.ActionArea.GroupLeft(): + yield Checkbox("Regular Expression") + yield Button("Cancel", variant="error") + yield Button("First", variant="default") + yield Button("All", variant="primary") + + +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..ea15d986d3 --- /dev/null +++ b/docs/examples/widgets/dialog_complex.tcss @@ -0,0 +1,15 @@ +Screen { + align: center middle; + + Dialog { + + Input { + width: 1fr; + margin: 0 0 1 0; + } + + Label { + margin: 0 0 0 1; + } + } +} diff --git a/docs/examples/widgets/dialog.py b/docs/examples/widgets/dialog_simple.py similarity index 92% rename from docs/examples/widgets/dialog.py rename to docs/examples/widgets/dialog_simple.py index 5c6759b856..23348b4f98 100644 --- a/docs/examples/widgets/dialog.py +++ b/docs/examples/widgets/dialog_simple.py @@ -3,7 +3,7 @@ class DialogApp(App[None]): - CSS_PATH = "dialog.tcss" + CSS_PATH = "dialog_simple.tcss" def compose(self) -> ComposeResult: with Dialog(title="Greetings Professor Falken"): diff --git a/docs/examples/widgets/dialog.tcss b/docs/examples/widgets/dialog_simple.tcss similarity index 100% rename from docs/examples/widgets/dialog.tcss rename to docs/examples/widgets/dialog_simple.tcss diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index 2b54d49e68..b1051af725 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -2,33 +2,57 @@ !!! tip "Added in version x.y.z" -Widget description. +A container widget designed to help build classic dialog layouts. - [ ] Focusable - [X] Container - ## Example -Example app showing the widget: +### Simple example + +The example below shows a classic confirmation dialog, with a title, some +text to prompt the user, and then a pair of buttons to confirm or cancel the +following operation. === "Output" - ```{.textual path="docs/examples/widgets/dialog.py"} + ```{.textual path="docs/examples/widgets/dialog_simple.py"} ``` -=== "checkbox.py" +=== "dialog_simple.py" ```python - --8<-- "docs/examples/widgets/dialog.py" + --8<-- "docs/examples/widgets/dialog_simple.py" ``` -=== "checkbox.tcss" +=== "dialog_simple.tcss" ```css - --8<-- "docs/examples/widgets/dialog.tcss" + --8<-- "docs/examples/widgets/dialog_simple.tcss" + ``` + +### Longer example + +The dialog can contain any Textual widget, and the "action area" (where +buttons ordinarily live) can contain other widgets too. For example: + +=== "Output" + + ```{.textual path="docs/examples/widgets/dialog_complex.py"} ``` +=== "dialog_complex.py" + + ```python + --8<-- "docs/examples/widgets/dialog_complex.py" + ``` + +=== "dialog_complex.tcss" + + ```css + --8<-- "docs/examples/widgets/dialog_complex.tcss" + ``` ## Reactive Attributes diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 96ee8b2946..90c6bb95f4 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -15,12 +15,24 @@ from ..widget import Widget _MAX_DIALOG_DIMENSION: Final[float] = 0.9 -"""The ideal maximum dimension for the dialog in respect to the screen.""" +"""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] """ From 038e385cf628f36760eb07858b5999fb9bc11ab0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 10:10:01 +0000 Subject: [PATCH 27/43] Add Dialog to the widget gallery --- docs/widget_gallery.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index ca82b5d4e3..1b5b12fd38 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_simple.py"} +``` + ## Digits Display numbers in tall characters. From 573225f3f93e14d36d52f1839220fe4ce097339f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 10:23:05 +0000 Subject: [PATCH 28/43] Make the more complex dialog example fit --- docs/examples/widgets/dialog_complex.py | 4 ++-- docs/examples/widgets/dialog_complex.tcss | 4 ++++ docs/widget_gallery.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/examples/widgets/dialog_complex.py b/docs/examples/widgets/dialog_complex.py index 638baf82b8..61b19a33aa 100644 --- a/docs/examples/widgets/dialog_complex.py +++ b/docs/examples/widgets/dialog_complex.py @@ -6,14 +6,14 @@ class DialogApp(App[None]): CSS_PATH = "dialog_complex.tcss" def compose(self) -> ComposeResult: - with Dialog(title="Find and Replace"): + with Dialog(title="Find and replace"): yield Label("Find what:") yield Input(placeholder="The text to find") yield Label("Replace with:") yield Input(placeholder="The text to replace with") with Dialog.ActionArea(): with Dialog.ActionArea.GroupLeft(): - yield Checkbox("Regular Expression") + yield Checkbox("Ignore case") yield Button("Cancel", variant="error") yield Button("First", variant="default") yield Button("All", variant="primary") diff --git a/docs/examples/widgets/dialog_complex.tcss b/docs/examples/widgets/dialog_complex.tcss index ea15d986d3..4ecd9838dd 100644 --- a/docs/examples/widgets/dialog_complex.tcss +++ b/docs/examples/widgets/dialog_complex.tcss @@ -11,5 +11,9 @@ Screen { Label { margin: 0 0 0 1; } + + ActionArea Button { + min-width: 11; + } } } diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 1b5b12fd38..94889f29e4 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -68,7 +68,7 @@ A widget for laying out a classic dialog. [Dialog reference](./widgets/dialog.md){ .md-button .md-button--primary} -```{.textual path="docs/examples/widgets/dialog_simple.py"} +```{.textual path="docs/examples/widgets/dialog_complex.py"} ``` ## Digits From 031dbf398acceeff62d702e630815377ef5ea735 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 11:18:51 +0000 Subject: [PATCH 29/43] Start to flesh out the guide for the dialog a bit more --- docs/examples/widgets/dialog_complex.py | 20 ++++---- docs/examples/widgets/dialog_simple.py | 10 ++-- docs/widgets/dialog.md | 61 ++++++++++++++++--------- 3 files changed, 55 insertions(+), 36 deletions(-) diff --git a/docs/examples/widgets/dialog_complex.py b/docs/examples/widgets/dialog_complex.py index 61b19a33aa..d116350c1b 100644 --- a/docs/examples/widgets/dialog_complex.py +++ b/docs/examples/widgets/dialog_complex.py @@ -7,16 +7,16 @@ class DialogApp(App[None]): def compose(self) -> ComposeResult: with Dialog(title="Find and replace"): - yield Label("Find what:") - yield Input(placeholder="The text to find") - yield Label("Replace with:") - yield Input(placeholder="The text to replace with") - with Dialog.ActionArea(): - with Dialog.ActionArea.GroupLeft(): - yield Checkbox("Ignore case") - yield Button("Cancel", variant="error") - yield Button("First", variant="default") - yield Button("All", variant="primary") + 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__": diff --git a/docs/examples/widgets/dialog_simple.py b/docs/examples/widgets/dialog_simple.py index 23348b4f98..d6cc2a13e9 100644 --- a/docs/examples/widgets/dialog_simple.py +++ b/docs/examples/widgets/dialog_simple.py @@ -6,11 +6,11 @@ class DialogApp(App[None]): CSS_PATH = "dialog_simple.tcss" def compose(self) -> ComposeResult: - with Dialog(title="Greetings Professor Falken"): - yield Label("Shall we play a game?") - with Dialog.ActionArea(): - yield Button("Love to!") - yield Button("No thanks") + 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__": diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index b1051af725..290642365c 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -7,24 +7,25 @@ A container widget designed to help build classic dialog layouts. - [ ] Focusable - [X] Container -## Example +## Guide -### Simple example - -The example below shows a classic confirmation dialog, with a title, some -text to prompt the user, and then a pair of buttons to confirm or cancel the -following operation. - -=== "Output" - - ```{.textual path="docs/examples/widgets/dialog_simple.py"} - ``` +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 + ~~~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" @@ -32,21 +33,34 @@ following operation. --8<-- "docs/examples/widgets/dialog_simple.tcss" ``` -### Longer example +This is how the resulting dialog looks: -The dialog can contain any Textual widget, and the "action area" (where -buttons ordinarily live) can contain other widgets too. For example: +```{.textual path="docs/examples/widgets/dialog_simple.py"} +``` -=== "Output" +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: - ```{.textual path="docs/examples/widgets/dialog_complex.py"} - ``` +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 + ~~~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" @@ -54,6 +68,11 @@ buttons ordinarily live) can contain other widgets too. For example: --8<-- "docs/examples/widgets/dialog_complex.tcss" ``` +The resulting dialog looks like this: + +```{.textual path="docs/examples/widgets/dialog_complex.py"} +``` + ## Reactive Attributes | Name | Type | Default | Description | From 989bfaf3a7861f236b074bf64fbb6b58149ddc3f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 14:30:37 +0000 Subject: [PATCH 30/43] Remove the additional notes I think there might be some to add, still to be decided, but now's a good time to remove the placeholder in case that doesn't happen. --- docs/widgets/dialog.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index 290642365c..6ed182fc19 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -92,11 +92,6 @@ This widget has no bindings. This widget has no component classes. -## Additional notes - -- Did you know this? -- Another pro tip. - ## See also From 085f68fdccf28fa296e99de45ca814096b8c6509 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 15:20:49 +0000 Subject: [PATCH 31/43] Add an exception for a misplaced ActionArea widget There's little point in someone putting one of these anywhere other than inside a Dialog; so blow up if they do. --- src/textual/widgets/_dialog.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 90c6bb95f4..237202b623 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -258,6 +258,15 @@ 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. @@ -293,6 +302,13 @@ def compose(self) -> ComposeResult: ``` """ + 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`.""" From 0faf0055697f6c6c6f8595df1b3e5dfa79a9c7d0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 16:14:07 +0000 Subject: [PATCH 32/43] Move the checking for multiple ActionArea into on_mount This helps make testing for this easier; for one thing. --- src/textual/widgets/_dialog.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 237202b623..7ca7571e35 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -236,6 +236,8 @@ def __init__( 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 @@ -389,16 +391,27 @@ def compose(self) -> ComposeResult: with Body(): for widget in self._dialog_children: if isinstance(widget, Dialog.ActionArea): - if action_area is not None: - raise self.TooManyActionAreas( - "Only one ActionArea can be defined for a Dialog." - ) + # 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, From c4d0161de41ec651cfb2e68c29a5e60450052fb2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 Jan 2024 16:15:09 +0000 Subject: [PATCH 33/43] Add initial testing for Dialog Just some of the exception testing for now. --- tests/dialog/test_dialog_errors.py | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/dialog/test_dialog_errors.py 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 From 128200386a79ba0c9aa7097dda9c7d75754c4f61 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 09:11:29 +0000 Subject: [PATCH 34/43] Use get_child_by_type rather than query_one While I would never encourage it, ever, this does allow for a dialog within a dialog (and, really, just don't!). --- src/textual/widgets/_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 7ca7571e35..68150dcedc 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -73,7 +73,7 @@ def get_content_height(self, container: Size, viewport: Size, width: int) -> int if isinstance(self.parent, Dialog): try: # See if the dialog has an ActionArea. - action_area = self.parent.query_one(Dialog.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. @@ -345,7 +345,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int: if isinstance(self.parent, Dialog): try: # See if the dialog has a body yet. - body = self.parent.query_one(Body) + 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. From 15af7d1052570e919893f5388e9283dcceaf6b29 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 09:30:49 +0000 Subject: [PATCH 35/43] Place the instructions for composing a dialog in their own section --- docs/widgets/dialog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index 6ed182fc19..196c259b55 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -9,6 +9,8 @@ A container widget designed to help build classic dialog layouts. ## 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 From b40d3f9785d8a37e0d5c471f85d8cdaec415d90c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 10:20:47 +0000 Subject: [PATCH 36/43] Add documentation for dialog variants --- docs/examples/widgets/dialog_error.py | 27 +++++++++ docs/examples/widgets/dialog_success.py | 16 ++++++ docs/examples/widgets/dialog_variants.tcss | 7 +++ docs/examples/widgets/dialog_warning.py | 20 +++++++ docs/widgets/dialog.md | 67 ++++++++++++++++++++++ 5 files changed, 137 insertions(+) create mode 100644 docs/examples/widgets/dialog_error.py create mode 100644 docs/examples/widgets/dialog_success.py create mode 100644 docs/examples/widgets/dialog_variants.tcss create mode 100644 docs/examples/widgets/dialog_warning.py 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_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/widgets/dialog.md b/docs/widgets/dialog.md index 196c259b55..8108b3e37a 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -75,6 +75,73 @@ 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" + ~~~ + + ## Reactive Attributes | Name | Type | Default | Description | From b5303a6eceac142bdbe69d8fe4c4aac50c5153d5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 10:45:57 +0000 Subject: [PATCH 37/43] Add details on styling the body of the dialog --- docs/examples/widgets/dialog_styling.py | 27 +++++++++++++++++++++++ docs/examples/widgets/dialog_styling.tcss | 17 ++++++++++++++ docs/widgets/dialog.md | 23 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 docs/examples/widgets/dialog_styling.py create mode 100644 docs/examples/widgets/dialog_styling.tcss 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/widgets/dialog.md b/docs/widgets/dialog.md index 8108b3e37a..a7863a0278 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -141,6 +141,29 @@ constructor](#textual.widgets._dialog.Dialog.error). --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" + + ~~~python + --8<-- "docs/examples/widgets/dialog_styling.tcss" + ~~~ + +=== "dialog_styling.py" + + ~~~python + --8<-- "docs/examples/widgets/dialog_styling.py" + ~~~ + +=== "Styled dialog body" + + ```{.textual path="docs/examples/widgets/dialog_styling.py"} + ``` ## Reactive Attributes From 4f1f3e688bfc3c1f3bf533d5df6ce8f13faa1b43 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 12:59:38 +0000 Subject: [PATCH 38/43] Use outer-width for the width calculation --- src/textual/widgets/_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 68150dcedc..5dbcad99be 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -356,7 +356,7 @@ def get_content_width(self, container: Size, viewport: Size) -> int: width, # ...or the width of the body, minus our horizontal # padding and margins; whichever is greater. - body.size.width + body.outer_size.width - ( self.styles.padding.right + self.styles.padding.left From feebb98e03a9bfb5e4b4aeefa614ced7f5591d81 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 13:00:02 +0000 Subject: [PATCH 39/43] Swap the order of the styled dialog examples --- docs/widgets/dialog.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index a7863a0278..57755ad0d9 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -154,16 +154,17 @@ wish to style the main body you can target `Dialog Body` in your CSS: --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" ~~~ -=== "Styled dialog body" - - ```{.textual path="docs/examples/widgets/dialog_styling.py"} - ``` ## Reactive Attributes From e8e4edb64e6e6c701f96eda319ff19bbbbc3c062 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 13:00:23 +0000 Subject: [PATCH 40/43] Improve the padding in the action area --- src/textual/widgets/_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index 5dbcad99be..d79ad4aebd 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -136,7 +136,7 @@ def compose(self) -> ComposeResult: border-top: $primary; - padding: 1 1 0 1; + 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 From 251d536fded3d84c90bd38883d668c55d5430d93 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 14:51:00 +0000 Subject: [PATCH 41/43] Highlight the key lines in the TCSS --- docs/widgets/dialog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/dialog.md b/docs/widgets/dialog.md index 57755ad0d9..fd76625094 100644 --- a/docs/widgets/dialog.md +++ b/docs/widgets/dialog.md @@ -150,7 +150,7 @@ wish to style the main body you can target `Dialog Body` in your CSS: === "dialog_styling.tcss" - ~~~python + ~~~css hl_lines="7-12" --8<-- "docs/examples/widgets/dialog_styling.tcss" ~~~ From 0927938c38e8869b0d1081144491ecfce53aa6b7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 14:51:21 +0000 Subject: [PATCH 42/43] Remove example code from the docstring for Dialog There are more comprehensive examples in the main documentation; there's little point in having a cut-down version in the code too. --- src/textual/widgets/_dialog.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/textual/widgets/_dialog.py b/src/textual/widgets/_dialog.py index d79ad4aebd..953cf443e5 100644 --- a/src/textual/widgets/_dialog.py +++ b/src/textual/widgets/_dialog.py @@ -97,16 +97,7 @@ class Dialog(Widget): """A dialog widget. `Dialog` is a container class that helps provide a classic dialog layout - for other widgets. An example use may be: - - ```python - def compose(self) -> ComposeResult: - with Dialog(title="Confirm"): - yield Label("Shall we play a game?") - with Dialog.ActionArea(): - yield Button("Yes", id="yes") - yield Button("No", id="no") - ``` + for other widgets. """ DEFAULT_CSS = """ From 89de9394485f666b7d9eb7a1869657d24cf687bd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 Jan 2024 15:26:06 +0000 Subject: [PATCH 43/43] Add snapshot tests for the dialog docs examples --- .../__snapshots__/test_snapshots.ambr | 979 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 19 + 2 files changed, 998 insertions(+) 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")