491 lines
15 KiB
Python
491 lines
15 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from functools import partial
|
||
|
|
from typing import TYPE_CHECKING, cast
|
||
|
|
|
||
|
|
import rich.repr
|
||
|
|
from rich.cells import cell_len
|
||
|
|
from rich.console import ConsoleRenderable, RenderableType
|
||
|
|
from typing_extensions import Literal, Self
|
||
|
|
|
||
|
|
from textual import events
|
||
|
|
|
||
|
|
if TYPE_CHECKING:
|
||
|
|
from textual.app import RenderResult
|
||
|
|
|
||
|
|
from rich.style import Style
|
||
|
|
|
||
|
|
from textual.binding import Binding
|
||
|
|
from textual.content import Content, ContentText
|
||
|
|
from textual.css._error_tools import friendly_list
|
||
|
|
from textual.geometry import Size
|
||
|
|
from textual.message import Message
|
||
|
|
from textual.reactive import reactive
|
||
|
|
from textual.widget import Widget
|
||
|
|
|
||
|
|
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
|
||
|
|
"""The names of the valid button variants.
|
||
|
|
|
||
|
|
These are the variants that can be used with a [`Button`][textual.widgets.Button].
|
||
|
|
"""
|
||
|
|
|
||
|
|
_VALID_BUTTON_VARIANTS = {"default", "primary", "success", "warning", "error"}
|
||
|
|
|
||
|
|
|
||
|
|
class InvalidButtonVariant(Exception):
|
||
|
|
"""Exception raised if an invalid button variant is used."""
|
||
|
|
|
||
|
|
|
||
|
|
class Button(Widget, can_focus=True):
|
||
|
|
"""A simple clickable button.
|
||
|
|
|
||
|
|
Clicking the button will send a [Button.Pressed][textual.widgets.Button.Pressed] message,
|
||
|
|
unless the `action` parameter is provided.
|
||
|
|
|
||
|
|
"""
|
||
|
|
|
||
|
|
ALLOW_SELECT = False
|
||
|
|
|
||
|
|
DEFAULT_CSS = """
|
||
|
|
Button {
|
||
|
|
width: auto;
|
||
|
|
min-width: 16;
|
||
|
|
height:auto;
|
||
|
|
line-pad: 1;
|
||
|
|
text-align: center;
|
||
|
|
content-align: center middle;
|
||
|
|
|
||
|
|
|
||
|
|
&.-style-flat {
|
||
|
|
text-style: bold;
|
||
|
|
color: auto 90%;
|
||
|
|
background: $surface;
|
||
|
|
border: block $surface;
|
||
|
|
&:hover {
|
||
|
|
background: $primary;
|
||
|
|
border: block $primary;
|
||
|
|
}
|
||
|
|
&:focus {
|
||
|
|
text-style: $button-focus-text-style;
|
||
|
|
}
|
||
|
|
&.-active {
|
||
|
|
background: $surface;
|
||
|
|
border: block $surface;
|
||
|
|
tint: $background 30%;
|
||
|
|
}
|
||
|
|
&:disabled {
|
||
|
|
color: auto 50%;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-primary {
|
||
|
|
background: $primary-muted;
|
||
|
|
border: block $primary-muted;
|
||
|
|
color: $text-primary;
|
||
|
|
&:hover {
|
||
|
|
color: $text;
|
||
|
|
background: $primary;
|
||
|
|
border: block $primary;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
&.-success {
|
||
|
|
background: $success-muted;
|
||
|
|
border: block $success-muted;
|
||
|
|
color: $text-success;
|
||
|
|
&:hover {
|
||
|
|
color: $text;
|
||
|
|
background: $success;
|
||
|
|
border: block $success;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
&.-warning {
|
||
|
|
background: $warning-muted;
|
||
|
|
border: block $warning-muted;
|
||
|
|
color: $text-warning;
|
||
|
|
&:hover {
|
||
|
|
color: $text;
|
||
|
|
background: $warning;
|
||
|
|
border: block $warning;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
&.-error {
|
||
|
|
background: $error-muted;
|
||
|
|
border: block $error-muted;
|
||
|
|
color: $text-error;
|
||
|
|
&:hover {
|
||
|
|
color: $text;
|
||
|
|
background: $error;
|
||
|
|
border: block $error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
&.-style-default {
|
||
|
|
text-style: bold;
|
||
|
|
color: $button-foreground;
|
||
|
|
background: $surface;
|
||
|
|
border: none;
|
||
|
|
border-top: tall $surface-lighten-1;
|
||
|
|
border-bottom: tall $surface-darken-1;
|
||
|
|
|
||
|
|
|
||
|
|
&.-textual-compact {
|
||
|
|
border: none !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
&:disabled {
|
||
|
|
text-opacity: 0.6;
|
||
|
|
}
|
||
|
|
|
||
|
|
&:focus {
|
||
|
|
text-style: $button-focus-text-style;
|
||
|
|
background-tint: $foreground 5%;
|
||
|
|
}
|
||
|
|
&:hover {
|
||
|
|
border-top: tall $surface;
|
||
|
|
background: $surface-darken-1;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-active {
|
||
|
|
background: $surface;
|
||
|
|
border-bottom: tall $surface-lighten-1;
|
||
|
|
border-top: tall $surface-darken-1;
|
||
|
|
tint: $background 30%;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-primary {
|
||
|
|
color: $button-color-foreground;
|
||
|
|
background: $primary;
|
||
|
|
border-top: tall $primary-lighten-3;
|
||
|
|
border-bottom: tall $primary-darken-3;
|
||
|
|
|
||
|
|
&:hover {
|
||
|
|
background: $primary-darken-2;
|
||
|
|
border-top: tall $primary;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-active {
|
||
|
|
background: $primary;
|
||
|
|
border-bottom: tall $primary-lighten-3;
|
||
|
|
border-top: tall $primary-darken-3;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-success {
|
||
|
|
color: $button-color-foreground;
|
||
|
|
background: $success;
|
||
|
|
border-top: tall $success-lighten-2;
|
||
|
|
border-bottom: tall $success-darken-3;
|
||
|
|
|
||
|
|
&:hover {
|
||
|
|
background: $success-darken-2;
|
||
|
|
border-top: tall $success;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-active {
|
||
|
|
background: $success;
|
||
|
|
border-bottom: tall $success-lighten-2;
|
||
|
|
border-top: tall $success-darken-2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-warning{
|
||
|
|
color: $button-color-foreground;
|
||
|
|
background: $warning;
|
||
|
|
border-top: tall $warning-lighten-2;
|
||
|
|
border-bottom: tall $warning-darken-3;
|
||
|
|
|
||
|
|
&:hover {
|
||
|
|
background: $warning-darken-2;
|
||
|
|
border-top: tall $warning;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-active {
|
||
|
|
background: $warning;
|
||
|
|
border-bottom: tall $warning-lighten-2;
|
||
|
|
border-top: tall $warning-darken-2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-error {
|
||
|
|
color: $button-color-foreground;
|
||
|
|
background: $error;
|
||
|
|
border-top: tall $error-lighten-2;
|
||
|
|
border-bottom: tall $error-darken-3;
|
||
|
|
|
||
|
|
&:hover {
|
||
|
|
background: $error-darken-1;
|
||
|
|
border-top: tall $error;
|
||
|
|
}
|
||
|
|
|
||
|
|
&.-active {
|
||
|
|
background: $error;
|
||
|
|
border-bottom: tall $error-lighten-2;
|
||
|
|
border-top: tall $error-darken-2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
"""
|
||
|
|
|
||
|
|
BINDINGS = [Binding("enter", "press", "Press button", show=False)]
|
||
|
|
|
||
|
|
label: reactive[ContentText] = reactive[ContentText](Content.empty())
|
||
|
|
"""The text label that appears within the button."""
|
||
|
|
|
||
|
|
variant = reactive("default", init=False)
|
||
|
|
"""The variant name for the button."""
|
||
|
|
|
||
|
|
compact = reactive(False, toggle_class="-textual-compact")
|
||
|
|
"""Make the button compact (without borders)."""
|
||
|
|
|
||
|
|
flat = reactive(False)
|
||
|
|
"""Enable alternative flat button style."""
|
||
|
|
|
||
|
|
class Pressed(Message):
|
||
|
|
"""Event sent when a `Button` is pressed and there is no Button action.
|
||
|
|
|
||
|
|
Can be handled using `on_button_pressed` in a subclass of
|
||
|
|
[`Button`][textual.widgets.Button] or in a parent widget in the DOM.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, button: Button) -> None:
|
||
|
|
self.button: Button = button
|
||
|
|
"""The button that was pressed."""
|
||
|
|
super().__init__()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def control(self) -> Button:
|
||
|
|
"""An alias for [Pressed.button][textual.widgets.Button.Pressed.button].
|
||
|
|
|
||
|
|
This will be the same value as [Pressed.button][textual.widgets.Button.Pressed.button].
|
||
|
|
"""
|
||
|
|
return self.button
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
label: ContentText | None = None,
|
||
|
|
variant: ButtonVariant = "default",
|
||
|
|
*,
|
||
|
|
name: str | None = None,
|
||
|
|
id: str | None = None,
|
||
|
|
classes: str | None = None,
|
||
|
|
disabled: bool = False,
|
||
|
|
tooltip: RenderableType | None = None,
|
||
|
|
action: str | None = None,
|
||
|
|
compact: bool = False,
|
||
|
|
flat: bool = False,
|
||
|
|
):
|
||
|
|
"""Create a Button widget.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
label: The text that appears within the button.
|
||
|
|
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.
|
||
|
|
tooltip: Optional tooltip.
|
||
|
|
action: Optional action to run when clicked.
|
||
|
|
compact: Enable compact button style.
|
||
|
|
flat: Enable alternative flat look buttons.
|
||
|
|
"""
|
||
|
|
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||
|
|
|
||
|
|
if label is None:
|
||
|
|
label = self.css_identifier_styled
|
||
|
|
|
||
|
|
self.variant = variant
|
||
|
|
self.flat = flat
|
||
|
|
self.compact = compact
|
||
|
|
self.set_reactive(Button.label, Content.from_text(label))
|
||
|
|
|
||
|
|
self.action = action
|
||
|
|
self.active_effect_duration = 0.2
|
||
|
|
"""Amount of time in seconds the button 'press' animation lasts."""
|
||
|
|
|
||
|
|
if tooltip is not None:
|
||
|
|
self.tooltip = tooltip
|
||
|
|
|
||
|
|
def get_content_width(self, container: Size, viewport: Size) -> int:
|
||
|
|
assert isinstance(self.label, Content)
|
||
|
|
try:
|
||
|
|
return max([cell_len(line) for line in self.label.plain.splitlines()]) + 2
|
||
|
|
except ValueError:
|
||
|
|
# Empty string label
|
||
|
|
return 2
|
||
|
|
|
||
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
||
|
|
yield from super().__rich_repr__()
|
||
|
|
yield "variant", self.variant, "default"
|
||
|
|
|
||
|
|
def validate_variant(self, variant: str) -> str:
|
||
|
|
if variant not in _VALID_BUTTON_VARIANTS:
|
||
|
|
raise InvalidButtonVariant(
|
||
|
|
f"Valid button variants are {friendly_list(_VALID_BUTTON_VARIANTS)}"
|
||
|
|
)
|
||
|
|
return variant
|
||
|
|
|
||
|
|
def watch_variant(self, old_variant: str, variant: str):
|
||
|
|
self.remove_class(f"-{old_variant}")
|
||
|
|
self.add_class(f"-{variant}")
|
||
|
|
|
||
|
|
def watch_flat(self, flat: bool) -> None:
|
||
|
|
self.set_class(flat, "-style-flat")
|
||
|
|
self.set_class(not flat, "-style-default")
|
||
|
|
|
||
|
|
def validate_label(self, label: ContentText) -> Content:
|
||
|
|
"""Parse markup for self.label"""
|
||
|
|
return Content.from_text(label)
|
||
|
|
|
||
|
|
def render(self) -> RenderResult:
|
||
|
|
assert isinstance(self.label, Content)
|
||
|
|
return self.label
|
||
|
|
|
||
|
|
def post_render(
|
||
|
|
self, renderable: RenderableType, base_style: Style
|
||
|
|
) -> ConsoleRenderable:
|
||
|
|
return cast(ConsoleRenderable, renderable)
|
||
|
|
|
||
|
|
async def _on_click(self, event: events.Click) -> None:
|
||
|
|
event.stop()
|
||
|
|
if not self.has_class("-active"):
|
||
|
|
self.press()
|
||
|
|
|
||
|
|
def press(self) -> Self:
|
||
|
|
"""Animate the button and send the [Pressed][textual.widgets.Button.Pressed] message.
|
||
|
|
|
||
|
|
Can be used to simulate the button being pressed by a user.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The button instance.
|
||
|
|
"""
|
||
|
|
if self.disabled or not self.display:
|
||
|
|
return self
|
||
|
|
# Manage the "active" effect:
|
||
|
|
self._start_active_affect()
|
||
|
|
# ...and let other components know that we've just been clicked:
|
||
|
|
if self.action is None:
|
||
|
|
self.post_message(Button.Pressed(self))
|
||
|
|
else:
|
||
|
|
self.call_later(
|
||
|
|
self.app.run_action, self.action, default_namespace=self._parent
|
||
|
|
)
|
||
|
|
return self
|
||
|
|
|
||
|
|
def _start_active_affect(self) -> None:
|
||
|
|
"""Start a small animation to show the button was clicked."""
|
||
|
|
if self.active_effect_duration > 0:
|
||
|
|
self.add_class("-active")
|
||
|
|
self.set_timer(
|
||
|
|
self.active_effect_duration, partial(self.remove_class, "-active")
|
||
|
|
)
|
||
|
|
|
||
|
|
def action_press(self) -> None:
|
||
|
|
"""Activate a press of the button."""
|
||
|
|
if not self.has_class("-active"):
|
||
|
|
self.press()
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def success(
|
||
|
|
cls,
|
||
|
|
label: ContentText | None = None,
|
||
|
|
*,
|
||
|
|
name: str | None = None,
|
||
|
|
id: str | None = None,
|
||
|
|
classes: str | None = None,
|
||
|
|
disabled: bool = False,
|
||
|
|
flat: bool = False,
|
||
|
|
) -> Button:
|
||
|
|
"""Utility constructor for creating a success Button variant.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
label: The text that appears within 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.
|
||
|
|
flat: Enable alternative flat look buttons.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A [`Button`][textual.widgets.Button] widget of the 'success'
|
||
|
|
[variant][textual.widgets.button.ButtonVariant].
|
||
|
|
"""
|
||
|
|
return Button(
|
||
|
|
label=label,
|
||
|
|
variant="success",
|
||
|
|
name=name,
|
||
|
|
id=id,
|
||
|
|
classes=classes,
|
||
|
|
disabled=disabled,
|
||
|
|
flat=flat,
|
||
|
|
)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def warning(
|
||
|
|
cls,
|
||
|
|
label: ContentText | None = None,
|
||
|
|
*,
|
||
|
|
name: str | None = None,
|
||
|
|
id: str | None = None,
|
||
|
|
classes: str | None = None,
|
||
|
|
disabled: bool = False,
|
||
|
|
flat: bool = False,
|
||
|
|
) -> Button:
|
||
|
|
"""Utility constructor for creating a warning Button variant.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
label: The text that appears within 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.
|
||
|
|
flat: Enable alternative flat look buttons.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A [`Button`][textual.widgets.Button] widget of the 'warning'
|
||
|
|
[variant][textual.widgets.button.ButtonVariant].
|
||
|
|
"""
|
||
|
|
return Button(
|
||
|
|
label=label,
|
||
|
|
variant="warning",
|
||
|
|
name=name,
|
||
|
|
id=id,
|
||
|
|
classes=classes,
|
||
|
|
disabled=disabled,
|
||
|
|
flat=flat,
|
||
|
|
)
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def error(
|
||
|
|
cls,
|
||
|
|
label: ContentText | None = None,
|
||
|
|
*,
|
||
|
|
name: str | None = None,
|
||
|
|
id: str | None = None,
|
||
|
|
classes: str | None = None,
|
||
|
|
disabled: bool = False,
|
||
|
|
flat: bool = False,
|
||
|
|
) -> Button:
|
||
|
|
"""Utility constructor for creating an error Button variant.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
label: The text that appears within 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.
|
||
|
|
flat: Enable alternative flat look buttons.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
A [`Button`][textual.widgets.Button] widget of the 'error'
|
||
|
|
[variant][textual.widgets.button.ButtonVariant].
|
||
|
|
"""
|
||
|
|
return Button(
|
||
|
|
label=label,
|
||
|
|
variant="error",
|
||
|
|
name=name,
|
||
|
|
id=id,
|
||
|
|
classes=classes,
|
||
|
|
disabled=disabled,
|
||
|
|
flat=flat,
|
||
|
|
)
|