Skip to content

Commit

Permalink
Merge pull request #1785 from davep/promote-disabled
Browse files Browse the repository at this point in the history
Promote disabled to `Widget` level
  • Loading branch information
willmcgugan authored Feb 21, 2023
2 parents 8925a0b + 121b0b8 commit 4530700
Show file tree
Hide file tree
Showing 20 changed files with 555 additions and 156 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/CSS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 3 additions & 4 deletions examples/five_by_five.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -192,8 +192,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.
Expand Down
5 changes: 5 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}
Expand Down
25 changes: 13 additions & 12 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand Down
14 changes: 5 additions & 9 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
50 changes: 46 additions & 4 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
Loading

0 comments on commit 4530700

Please sign in to comment.