diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da52b51e3..5322593e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added + +- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 + ### Fixed - DataTable scrollbars resize correctly when header is toggled https://github.com/Textualize/textual/pull/1803 diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 80753ddbd0..985c4b41f4 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -315,6 +315,8 @@ The `background: green` is only applied to the Button underneath the mouse curso Here are some other pseudo classes: +- `:disabled` Matches widgets which are in a disabled state. +- `:enabled` Matches widgets which are in an enabled state. - `:focus` Matches widgets which have input focus. - `:focus-within` Matches widgets with a focused a child widget. diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f3aada19f3..d986175a57 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -1,7 +1,7 @@ -from __future__ import annotations - """Simple version of 5x5, developed for/with Textual.""" +from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING, cast @@ -194,8 +194,7 @@ def game_playable(self, playable: bool) -> None: Args: playable (bool): Should the game currently be playable? """ - for cell in self.query(GameCell): - cell.disabled = not playable + self.query_one(GameGrid).disabled = not playable def cell(self, row: int, col: int) -> GameCell: """Get the cell at a given location. diff --git a/src/textual/app.py b/src/textual/app.py index bf33e07cd9..6bca628246 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -241,6 +241,11 @@ class App(Generic[ReturnType], DOMNode): background: $background; color: $text; } + + *:disabled { + opacity: 0.6; + text-opacity: 0.8; + } """ SCREENS: dict[str, Screen | Callable[[], Screen]] = {} diff --git a/src/textual/dom.py b/src/textual/dom.py index 77fc6c76d6..e68939b397 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -872,6 +872,16 @@ def set_class(self, add: bool, *class_names: str) -> None: else: self.remove_class(*class_names) + def _update_styles(self) -> None: + """Request an update of this node's styles. + + Should be called whenever CSS classes / pseudo classes change. + """ + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + def add_class(self, *class_names: str) -> None: """Add class names to this Node. @@ -884,10 +894,7 @@ def add_class(self, *class_names: str) -> None: self._classes.update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def remove_class(self, *class_names: str) -> None: """Remove class names from this Node. @@ -900,10 +907,7 @@ def remove_class(self, *class_names: str) -> None: self._classes.difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def toggle_class(self, *class_names: str) -> None: """Toggle class names on this Node. @@ -916,10 +920,7 @@ def toggle_class(self, *class_names: str) -> None: self._classes.symmetric_difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def has_pseudo_class(self, *class_names: str) -> bool: """Check for pseudo classes (such as hover, focus etc) diff --git a/src/textual/screen.py b/src/textual/screen.py index d1354a106f..ef73c8a63e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -159,11 +159,7 @@ def find_widget(self, widget: Widget) -> MapGeometry: @property def focus_chain(self) -> list[Widget]: - """Get widgets that may receive focus, in focus order. - - Returns: - List of Widgets in focus order. - """ + """A list of widgets that may receive focus, in focus order.""" widgets: list[Widget] = [] add_widget = widgets.append stack: list[Iterator[Widget]] = [iter(self.focusable_children)] @@ -177,7 +173,7 @@ def focus_chain(self) -> list[Widget]: else: if node.is_container and node.can_focus_children: push(iter(node.focusable_children)) - if node.can_focus: + if node.focusable: add_widget(node) return widgets @@ -314,7 +310,7 @@ def _reset_focus( # It may have been made invisible # Move to a sibling if possible for sibling in widget.visible_siblings: - if sibling not in avoiding and sibling.can_focus: + if sibling not in avoiding and sibling.focusable: self.set_focus(sibling) break else: @@ -351,7 +347,7 @@ def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None: self.focused.post_message_no_wait(events.Blur(self)) self.focused = None self.log.debug("focus was removed") - elif widget.can_focus: + elif widget.focusable: if self.focused != widget: if self.focused is not None: # Blur currently focused widget @@ -547,7 +543,7 @@ async def _forward_event(self, event: events.Event) -> None: except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseUp) and widget.can_focus: + if isinstance(event, events.MouseUp) and widget.focusable: if self.focused is not widget: self.set_focus(widget) event.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff932..a16a6bf60d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -225,6 +225,8 @@ class Widget(DOMNode): """Rich renderable may shrink.""" auto_links = Reactive(True) """Widget will highlight links automatically.""" + disabled = Reactive(False) + """The disabled state of the widget. `True` if disabled, `False` if not.""" hover_style: Reactive[Style] = Reactive(Style, repaint=False) highlight_link_id: Reactive[str] = Reactive("") @@ -235,6 +237,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: self._size = Size(0, 0) self._container_size = Size(0, 0) @@ -277,6 +280,7 @@ def __init__( raise WidgetError("A widget can't be its own parent") self._add_children(*children) + self.disabled = disabled virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) @@ -1173,6 +1177,20 @@ def virtual_region_with_margin(self) -> Region: """ return self.virtual_region.grow(self.styles.margin) + @property + def _self_or_ancestors_disabled(self) -> bool: + """Is this widget or any of its ancestors disabled?""" + return any( + node.disabled + for node in self.ancestors_with_self + if isinstance(node, Widget) + ) + + @property + def focusable(self) -> bool: + """Can this widget currently receive focus?""" + return self.can_focus and not self._self_or_ancestors_disabled + @property def focusable_children(self) -> list[Widget]: """Get the children which may be focused. @@ -2080,6 +2098,14 @@ def get_pseudo_classes(self) -> Iterable[str]: Names of the pseudo classes. """ + node = self + while isinstance(node, Widget): + if node.disabled: + yield "disabled" + break + node = node._parent + else: + yield "enabled" if self.mouse_over: yield "hover" if self.has_focus: @@ -2127,11 +2153,15 @@ def post_render(self, renderable: RenderableType) -> ConsoleRenderable: def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" if self._has_hover_style: - self.app.update_styles(self) + self._update_styles() def watch_has_focus(self, value: bool) -> None: """Update from CSS if has focus state changes.""" - self.app.update_styles(self) + self._update_styles() + + def watch_disabled(self) -> None: + """Update the styles of the widget and its children when disabled is toggled.""" + self._update_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size @@ -2421,6 +2451,18 @@ def release_mouse(self) -> None: """ self.app.capture_mouse(None) + def check_message_enabled(self, message: Message) -> bool: + # Do the normal checking and get out if that fails. + if not super().check_message_enabled(message): + return False + # Otherwise, if this is a mouse event, the widget receiving the + # event must not be disabled at this moment. + return ( + not self._self_or_ancestors_disabled + if isinstance(message, (events.MouseEvent, events.Enter, events.Leave)) + else True + ) + async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app._broker_event(event_name, event, default_namespace=self) @@ -2479,11 +2521,11 @@ def _on_blur(self, event: events.Blur) -> None: def _on_descendant_blur(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_descendant_focus(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if event.ctrl or event.shift: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fbeb60645a..ec143323ca 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -39,11 +39,6 @@ class Button(Static, can_focus=True): text-style: bold; } - Button.-disabled { - opacity: 0.4; - text-opacity: 0.7; - } - Button:focus { text-style: bold reverse; } @@ -156,9 +151,6 @@ class Button(Static, can_focus=True): variant = reactive("default") """The variant name for the button.""" - disabled = reactive(False) - """The disabled state of the button; `True` if disabled, `False` if not.""" - class Pressed(Message, bubble=True): """Event sent when a `Button` is pressed. @@ -176,45 +168,35 @@ def button(self) -> Button: def __init__( self, label: TextType | None = None, - disabled: bool = False, variant: ButtonVariant = "default", *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Create a Button widget. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. variant: The variant of the button. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. + disabled: Whether the button is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if label is None: label = self.css_identifier_styled self.label = self.validate_label(label) - self.disabled = disabled - if disabled: - self.add_class("-disabled") - self.variant = self.validate_variant(variant) def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() yield "variant", self.variant, "default" - yield "disabled", self.disabled, False - - def watch_mouse_over(self, value: bool) -> None: - """Update from CSS if mouse over state changes.""" - if self._has_hover_style and not self.disabled: - self.app.update_styles(self) def validate_variant(self, variant: str) -> str: if variant not in _VALID_BUTTON_VARIANTS: @@ -227,10 +209,6 @@ def watch_variant(self, old_variant: str, variant: str): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - def watch_disabled(self, disabled: bool) -> None: - self.set_class(disabled, "-disabled") - self.can_focus = not disabled - def validate_label(self, label: RenderableType) -> RenderableType: """Parse markup for self.label""" if isinstance(label, str): @@ -272,11 +250,11 @@ async def _on_key(self, event: events.Key) -> None: def success( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a success Button variant. @@ -292,22 +270,22 @@ def success( """ return Button( label=label, - disabled=disabled, variant="success", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def warning( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a warning Button variant. @@ -323,22 +301,22 @@ def warning( """ return Button( label=label, - disabled=disabled, variant="warning", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def error( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating an error Button variant. @@ -354,9 +332,9 @@ def error( """ return Button( label=label, - disabled=disabled, variant="error", name=name, id=id, classes=classes, + disabled=disabled, ) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 203936e718..a07fa5e441 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -473,8 +473,9 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._data: dict[RowKey, dict[ColumnKey, CellType]] = {} """Contains the cells of the table, indexed by row key and column key. The final positioning of a cell on screen cannot be determined solely by this diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9b3d33fc51..8b481a5522 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -29,6 +29,7 @@ class DirectoryTree(Tree[DirEntry]): name: The name of the widget, or None for no name. Defaults to None. id: The ID of the widget in the DOM, or None for no ID. Defaults to None. classes: A space-separated list of classes, or None for no classes. Defaults to None. + disabled: Whether the directory tree is disabled or not. """ COMPONENT_CLASSES: ClassVar[set[str]] = { @@ -87,6 +88,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: self.path = path super().__init__( @@ -95,6 +97,7 @@ def __init__( name=name, id=id, classes=classes, + disabled=disabled, ) def process_label(self, label: TextType): diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 8528e225ab..eb94718892 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -110,9 +110,6 @@ class Input(Widget, can_focus=True): height: 1; min-height: 1; } - Input.-disabled { - opacity: 0.6; - } Input:focus { border: tall $accent; } @@ -179,6 +176,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """Initialise the `Input` widget. @@ -190,8 +188,9 @@ def __init__( name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. + disabled: Whether the input is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value is not None: self.value = value self.placeholder = placeholder diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index efb552f0c0..af41589dbd 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -73,6 +73,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """ Args: @@ -81,8 +82,11 @@ def __init__( name: The name of the widget. id: The unique ID of the widget used in CSS/query selection. classes: The CSS classes of the widget. + disabled: Whether the ListView is disabled or not. """ - super().__init__(*children, name=name, id=id, classes=classes) + super().__init__( + *children, name=name, id=id, classes=classes, disabled=disabled + ) self._index = initial_index def on_mount(self) -> None: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index a9698a9544..5007f1b1d3 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -36,6 +36,7 @@ class Static(Widget, inherit_bindings=False): name: Name of widget. Defaults to None. id: ID of Widget. Defaults to None. classes: Space separated list of class names. Defaults to None. + disabled: Whether the static is disabled or not. """ DEFAULT_CSS = """ @@ -56,8 +57,9 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.expand = expand self.shrink = shrink self.markup = markup diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 45e856dd17..97e3e22233 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -100,6 +100,7 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Initialise the switch. @@ -109,8 +110,9 @@ def __init__( name: The name of the switch. id: The ID of the switch in the DOM. classes: The CSS classes of the switch. + disabled: Whether the switch is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 self._reactive_value = value diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index f78934e70a..c7efee07a2 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -43,8 +43,9 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.max_lines = max_lines self._start_line: int = 0 self.lines: list[Strip] = [] diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e93777248f..bc7fc18338 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -473,8 +473,9 @@ def __init__( name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) text_label = self.process_label(label) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f149ea09fe..088f6aba71 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -181,162 +181,162 @@ font-weight: 700; } - .terminal-3615181303-matrix { + .terminal-2059425018-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3615181303-title { + .terminal-2059425018-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3615181303-r1 { fill: #e1e1e1 } - .terminal-3615181303-r2 { fill: #c5c8c6 } - .terminal-3615181303-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-3615181303-r4 { fill: #454a50 } - .terminal-3615181303-r5 { fill: #292b2e } - .terminal-3615181303-r6 { fill: #24292f;font-weight: bold } - .terminal-3615181303-r7 { fill: #555657;font-weight: bold } - .terminal-3615181303-r8 { fill: #000000 } - .terminal-3615181303-r9 { fill: #161617 } - .terminal-3615181303-r10 { fill: #507bb3 } - .terminal-3615181303-r11 { fill: #283c52 } - .terminal-3615181303-r12 { fill: #dde6ed;font-weight: bold } - .terminal-3615181303-r13 { fill: #4f5a62;font-weight: bold } - .terminal-3615181303-r14 { fill: #001541 } - .terminal-3615181303-r15 { fill: #122032 } - .terminal-3615181303-r16 { fill: #7ae998 } - .terminal-3615181303-r17 { fill: #3d6a4a } - .terminal-3615181303-r18 { fill: #0a180e;font-weight: bold } - .terminal-3615181303-r19 { fill: #1e2f23;font-weight: bold } - .terminal-3615181303-r20 { fill: #008139 } - .terminal-3615181303-r21 { fill: #1b4c2f } - .terminal-3615181303-r22 { fill: #ffcf56 } - .terminal-3615181303-r23 { fill: #775f2f } - .terminal-3615181303-r24 { fill: #211505;font-weight: bold } - .terminal-3615181303-r25 { fill: #392b18;font-weight: bold } - .terminal-3615181303-r26 { fill: #b86b00 } - .terminal-3615181303-r27 { fill: #644316 } - .terminal-3615181303-r28 { fill: #e76580 } - .terminal-3615181303-r29 { fill: #683540 } - .terminal-3615181303-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-3615181303-r31 { fill: #6c595e;font-weight: bold } - .terminal-3615181303-r32 { fill: #780028 } - .terminal-3615181303-r33 { fill: #491928 } + .terminal-2059425018-r1 { fill: #e1e1e1 } + .terminal-2059425018-r2 { fill: #c5c8c6 } + .terminal-2059425018-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-2059425018-r4 { fill: #454a50 } + .terminal-2059425018-r5 { fill: #313437 } + .terminal-2059425018-r6 { fill: #24292f;font-weight: bold } + .terminal-2059425018-r7 { fill: #7c7d7e;font-weight: bold } + .terminal-2059425018-r8 { fill: #000000 } + .terminal-2059425018-r9 { fill: #101011 } + .terminal-2059425018-r10 { fill: #507bb3 } + .terminal-2059425018-r11 { fill: #324f70 } + .terminal-2059425018-r12 { fill: #dde6ed;font-weight: bold } + .terminal-2059425018-r13 { fill: #75828b;font-weight: bold } + .terminal-2059425018-r14 { fill: #001541 } + .terminal-2059425018-r15 { fill: #0c1e39 } + .terminal-2059425018-r16 { fill: #7ae998 } + .terminal-2059425018-r17 { fill: #4f9262 } + .terminal-2059425018-r18 { fill: #0a180e;font-weight: bold } + .terminal-2059425018-r19 { fill: #192e1f;font-weight: bold } + .terminal-2059425018-r20 { fill: #008139 } + .terminal-2059425018-r21 { fill: #156034 } + .terminal-2059425018-r22 { fill: #ffcf56 } + .terminal-2059425018-r23 { fill: #a4823a } + .terminal-2059425018-r24 { fill: #211505;font-weight: bold } + .terminal-2059425018-r25 { fill: #3a2a13;font-weight: bold } + .terminal-2059425018-r26 { fill: #b86b00 } + .terminal-2059425018-r27 { fill: #825210 } + .terminal-2059425018-r28 { fill: #e76580 } + .terminal-2059425018-r29 { fill: #904354 } + .terminal-2059425018-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-2059425018-r31 { fill: #978186;font-weight: bold } + .terminal-2059425018-r32 { fill: #780028 } + .terminal-2059425018-r33 { fill: #5b132a } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DefaultDefault - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Primary!Primary! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Success!Success! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Warning!Warning! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Error!Error! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -10813,6 +10813,193 @@ ''' # --- +# name: test_disabled_widgets + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WidgetDisableTestApp + + + + + + + + + + WidgetDisableTestApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Column 1  Column 2  Column 3  Column 4  +  0         0         0         0         + This is list item 0 + This is list item 1 + ▼ This is a test tree + ├── Leaf 0 + Hello, World! + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an empty input with a placeholder + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is some text in an input + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▇▇ + + + + + + ''' +# --- # name: test_dock_layout_sidebar ''' diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py new file mode 100644 index 0000000000..7a241e9146 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -0,0 +1,84 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical, Horizontal +from textual.widgets import ( + Header, + Footer, + Button, + DataTable, + Input, + ListView, + ListItem, + Label, + Markdown, + MarkdownViewer, + Tree, + TextLog, +) + + +class WidgetDisableTestApp(App[None]): + + CSS = """ + Horizontal { + height: auto; + } + DataTable, ListView, Tree, TextLog { + height: 2; + } + + Markdown, MarkdownViewer { + height: 1fr; + } + """ + + @property + def data_table(self) -> DataTable: + data_table = DataTable[str]() + data_table.add_columns("Column 1", "Column 2", "Column 3", "Column 4") + data_table.add_rows( + [(str(n), str(n * 10), str(n * 100), str(n * 1000)) for n in range(100)] + ) + return data_table + + @property + def list_view(self) -> ListView: + return ListView(*[ListItem(Label(f"This is list item {n}")) for n in range(20)]) + + @property + def test_tree(self) -> Tree: + tree = Tree[None](label="This is a test tree") + for n in range(10): + tree.root.add_leaf(f"Leaf {n}") + tree.root.expand() + return tree + + def compose(self) -> ComposeResult: + yield Header() + yield Vertical( + Horizontal( + Button(), + Button(variant="primary"), + Button(variant="success"), + Button(variant="warning"), + Button(variant="error"), + ), + self.data_table, + self.list_view, + self.test_tree, + TextLog(), + Input(), + Input(placeholder="This is an empty input with a placeholder"), + Input("This is some text in an input"), + Markdown("# Hello, World!"), + MarkdownViewer("# Hello, World!"), + id="test-container", + ) + yield Footer() + + def on_mount(self) -> None: + self.query_one(TextLog).write("Hello, World!") + self.query_one("#test-container", Vertical).disabled = True + + +if __name__ == "__main__": + WidgetDisableTestApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c23564c1b5..d19ac73281 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -231,3 +231,7 @@ def test_auto_width_input(snap_compare): def test_screen_switch(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"]) + + +def test_disabled_widgets(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "disable_widgets.py") diff --git a/tests/test_disabled.py b/tests/test_disabled.py new file mode 100644 index 0000000000..850fcf7c7b --- /dev/null +++ b/tests/test_disabled.py @@ -0,0 +1,84 @@ +"""Test Widget.disabled.""" + +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import ( + Button, + DataTable, + DirectoryTree, + Input, + ListView, + Markdown, + MarkdownViewer, + Switch, + TextLog, + Tree, +) + + +class DisableApp(App[None]): + """Application for testing Widget.disabled.""" + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Vertical( + Button(), + DataTable(), + DirectoryTree("."), + Input(), + ListView(), + Switch(), + TextLog(), + Tree("Test"), + Markdown(), + MarkdownViewer(), + id="test-container", + ) + + +async def test_all_initially_enabled() -> None: + """All widgets should start out enabled.""" + async with DisableApp().run_test() as pilot: + assert all( + not node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_enabled_widgets_have_enabled_pseudo_class() -> None: + """All enabled widgets should have the :enabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + assert all( + node.has_pseudo_class("enabled") and not node.has_pseudo_class("disabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_all_individually_disabled() -> None: + """Post-disable all widgets should report being disabled.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("Vertical > *"): + node.disabled = True + assert all( + node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disabled_widgets_have_disabled_pseudo_class() -> None: + """All disabled widgets should have the :disabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("#test-container > *"): + node.disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disable_via_container() -> None: + """All child widgets should appear (to CSS) as disabled by a container being disabled.""" + async with DisableApp().run_test() as pilot: + pilot.app.screen.query_one("#test-container", Vertical).disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + )