mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
button widget
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.2.0] - Unreleased
|
||||
|
||||
## [0.1.15] - 2022-01-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -110,8 +110,8 @@ class BasicApp(App):
|
||||
# ),
|
||||
),
|
||||
Widget(
|
||||
Static(Syntax(CODE, "python"), classes={"code"}),
|
||||
classes={"scrollable"},
|
||||
Static(Syntax(CODE, "python"), classes="code"),
|
||||
classes="scrollable",
|
||||
),
|
||||
Error(),
|
||||
Tweet(TweetBody()),
|
||||
@@ -121,12 +121,12 @@ class BasicApp(App):
|
||||
),
|
||||
footer=Widget(),
|
||||
sidebar=Widget(
|
||||
Widget(classes={"title"}),
|
||||
Widget(classes={"user"}),
|
||||
Widget(classes="title"),
|
||||
Widget(classes="user"),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
OptionItem(),
|
||||
Widget(classes={"content"}),
|
||||
Widget(classes="content"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -24,12 +24,12 @@ class BasicApp(App):
|
||||
Widget(id="uber2-child2"),
|
||||
)
|
||||
uber1 = Widget(
|
||||
Placeholder(id="child1", classes={"list-item"}),
|
||||
Placeholder(id="child2", classes={"list-item"}),
|
||||
Placeholder(id="child3", classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(classes={"list-item"}),
|
||||
Placeholder(id="child1", classes="list-item"),
|
||||
Placeholder(id="child2", classes="list-item"),
|
||||
Placeholder(id="child3", classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
Placeholder(classes="list-item"),
|
||||
)
|
||||
self.mount(uber1=uber1)
|
||||
|
||||
|
||||
@@ -558,6 +558,7 @@ class App(DOMNode):
|
||||
parent.children._append(child)
|
||||
self.registry.add(child)
|
||||
child.set_parent(parent)
|
||||
child.on_register(self)
|
||||
child.start_messages()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -621,13 +621,18 @@ class StylesBuilder:
|
||||
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
||||
)
|
||||
|
||||
self.styles._rules["align_horizontal"] = token_horizontal.value
|
||||
self.styles._rules["align_vertical"] = token_vertical.value
|
||||
name = name.replace("-", "_")
|
||||
self.styles._rules[f"{name}_horizontal"] = token_horizontal.value
|
||||
self.styles._rules[f"{name}_vertical"] = token_vertical.value
|
||||
|
||||
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
||||
self.styles._rules["align_horizontal"] = value
|
||||
self.styles._rules[name.replace("-", "_")] = value
|
||||
|
||||
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
||||
value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
||||
self.styles._rules["align_vertical"] = value
|
||||
self.styles._rules[name.replace("-", "_")] = value
|
||||
|
||||
process_content_align = process_align
|
||||
process_content_align_horizontal = process_align_horizontal
|
||||
process_content_align_vertical = process_align_vertical
|
||||
|
||||
@@ -13,7 +13,7 @@ from rich.style import Style
|
||||
from .. import log
|
||||
from .._animator import Animation, EasingFunction
|
||||
from ..color import Color
|
||||
from ..geometry import Offset, Size, Spacing
|
||||
from ..geometry import Spacing
|
||||
from ._style_properties import (
|
||||
BorderProperty,
|
||||
BoxProperty,
|
||||
@@ -130,6 +130,9 @@ class RulesMap(TypedDict, total=False):
|
||||
align_horizontal: AlignHorizontal
|
||||
align_vertical: AlignVertical
|
||||
|
||||
content_align_horizontal: AlignHorizontal
|
||||
content_align_vertical: AlignVertical
|
||||
|
||||
|
||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||
@@ -222,6 +225,9 @@ class StylesBase(ABC):
|
||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
content_align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles containts the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -677,6 +683,18 @@ class Styles(StylesBase):
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-vertical", self.align_vertical)
|
||||
|
||||
if has_rule("content_align_horizontal") and has_rule("content_align_vertical"):
|
||||
append_declaration(
|
||||
"content-align",
|
||||
f"{self.content_align_horizontal} {self.content_align_vertical}",
|
||||
)
|
||||
elif has_rule("content_align_horizontal"):
|
||||
append_declaration(
|
||||
"content-align-horizontal", self.content_align_horizontal
|
||||
)
|
||||
elif has_rule("content_align_horizontal"):
|
||||
append_declaration("content-align-vertical", self.content_align_vertical)
|
||||
|
||||
lines.sort()
|
||||
return lines
|
||||
|
||||
|
||||
@@ -169,6 +169,8 @@ class Stylesheet:
|
||||
StylesheetError: If the CSS could not be read.
|
||||
StylesheetParseError: If the CSS is invalid.
|
||||
"""
|
||||
if (css, path) in self.source:
|
||||
return
|
||||
try:
|
||||
rules = list(parse(css, path, variables=self.variables))
|
||||
except TokenizeError:
|
||||
|
||||
@@ -19,6 +19,7 @@ from .css.query import NoMatchingNodesError
|
||||
from .message_pump import MessagePump
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .css.query import DOMQuery
|
||||
from .screen import Screen
|
||||
|
||||
@@ -40,28 +41,36 @@ class DOMNode(MessagePump):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
self._name = name
|
||||
self._id = id
|
||||
self._classes: set[str] = set() if classes is None else classes
|
||||
self._classes: set[str] = set() if classes is None else set(classes.split())
|
||||
self.children = NodeList()
|
||||
self._css_styles: Styles = Styles(self)
|
||||
self._inline_styles: Styles = Styles.parse(
|
||||
self.INLINE_STYLES, repr(self), node=self
|
||||
)
|
||||
self.styles = RenderStyles(self, self._css_styles, self._inline_styles)
|
||||
self._default_styles = Styles.parse(self.DEFAULT_STYLES, f"{self.__class__}")
|
||||
self._default_styles = Styles()
|
||||
self._default_rules = self._default_styles.extract_rules((0, 0, 0))
|
||||
super().__init__()
|
||||
|
||||
def on_register(self, app: App) -> None:
|
||||
"""Called when the widget is registered
|
||||
|
||||
Args:
|
||||
app (App): Parent application.
|
||||
"""
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "name", self._name, None
|
||||
yield "id", self._id, None
|
||||
if self._classes:
|
||||
yield "classes", self._classes
|
||||
yield "classes", " ".join(self._classes)
|
||||
|
||||
@property
|
||||
def parent(self) -> DOMNode:
|
||||
|
||||
@@ -25,7 +25,7 @@ if TYPE_CHECKING:
|
||||
Reactable = Union[Widget, App]
|
||||
|
||||
|
||||
ReactiveType = TypeVar("ReactiveType")
|
||||
ReactiveType = TypeVar("ReactiveType", covariant=True)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -36,7 +36,7 @@ class Reactive(Generic[ReactiveType]):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: ReactiveType,
|
||||
default: ReactiveType | Callable[[], ReactiveType],
|
||||
*,
|
||||
layout: bool = False,
|
||||
repaint: bool = True,
|
||||
@@ -58,7 +58,11 @@ class Reactive(Generic[ReactiveType]):
|
||||
|
||||
self.name = name
|
||||
self.internal_name = f"_reactive_{name}"
|
||||
setattr(owner, self.internal_name, self._default)
|
||||
setattr(
|
||||
owner,
|
||||
self.internal_name,
|
||||
self._default() if callable(self._default) else self._default,
|
||||
)
|
||||
|
||||
def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType:
|
||||
return getattr(obj, self.internal_name)
|
||||
|
||||
@@ -18,11 +18,14 @@ from .renderables.gradient import VerticalGradient
|
||||
class Screen(Widget):
|
||||
"""A widget for the root of the app."""
|
||||
|
||||
DEFAULT_STYLES = """
|
||||
|
||||
layout: dock;
|
||||
docks: _default=top;
|
||||
|
||||
CSS = """
|
||||
|
||||
Screen {
|
||||
layout: vertical;
|
||||
docks: _default=top;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
dark = Reactive(False)
|
||||
@@ -39,8 +42,8 @@ class Screen(Widget):
|
||||
def is_transparent(self) -> bool:
|
||||
return False
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return VerticalGradient("red", "blue")
|
||||
# def render(self) -> RenderableType:
|
||||
# return VerticalGradient("red", "blue")
|
||||
|
||||
def get_offset(self, widget: Widget) -> Offset:
|
||||
"""Get the absolute offset of a given Widget.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
@@ -39,6 +39,7 @@ from .renderables.opacity import Opacity
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .scrollbar import (
|
||||
ScrollBar,
|
||||
ScrollTo,
|
||||
@@ -71,12 +72,15 @@ class Widget(DOMNode):
|
||||
|
||||
"""
|
||||
|
||||
CSS = """
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*children: Widget,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
|
||||
self._size = Size(0, 0)
|
||||
@@ -107,6 +111,9 @@ class Widget(DOMNode):
|
||||
show_vertical_scrollbar = Reactive(False, layout=True)
|
||||
show_horizontal_scrollbar = Reactive(False, layout=True)
|
||||
|
||||
def on_register(self, app: App) -> None:
|
||||
self.app.stylesheet.parse(self.CSS, path=f"<{self.__class__.name}>")
|
||||
|
||||
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
|
||||
"""Process the box model for this widget.
|
||||
|
||||
@@ -414,12 +421,18 @@ class Widget(DOMNode):
|
||||
"""
|
||||
|
||||
renderable = self.render()
|
||||
|
||||
styles = self.styles
|
||||
parent_styles = self.parent.styles
|
||||
|
||||
parent_text_style = self.parent.rich_text_style
|
||||
text_style = styles.rich_style
|
||||
|
||||
content_align = (styles.content_align_horizontal, styles.content_align_vertical)
|
||||
if content_align != ("left", "top"):
|
||||
horizontal, vertical = content_align
|
||||
renderable = Align(renderable, horizontal, vertical=vertical)
|
||||
|
||||
renderable_text_style = parent_text_style + text_style
|
||||
if renderable_text_style:
|
||||
renderable = Styled(renderable, renderable_text_style)
|
||||
@@ -615,6 +628,9 @@ class Widget(DOMNode):
|
||||
|
||||
# Default displays a pretty repr in the center of the screen
|
||||
|
||||
if self.is_container:
|
||||
return ""
|
||||
|
||||
label = self.css_identifier_styled
|
||||
return Align.center(label, vertical="middle")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from ._footer import Footer
|
||||
from ._header import Header
|
||||
from ._button import Button, ButtonPressed
|
||||
from ._button import Button
|
||||
from ._placeholder import Placeholder
|
||||
from ._static import Static
|
||||
from ._tree_control import TreeControl, TreeClick, TreeNode, NodeID
|
||||
@@ -8,7 +8,6 @@ from ._directory_tree import DirectoryTree, FileClick
|
||||
|
||||
__all__ = [
|
||||
"Button",
|
||||
"ButtonPressed",
|
||||
"DirectoryTree",
|
||||
"FileClick",
|
||||
"Footer",
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.align import Align
|
||||
from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
|
||||
from rich.style import StyleType
|
||||
from rich.console import RenderableType
|
||||
from rich.text import Text
|
||||
|
||||
from .. import events
|
||||
from ..message import Message
|
||||
@@ -10,58 +9,64 @@ from ..reactive import Reactive
|
||||
from ..widget import Widget
|
||||
|
||||
|
||||
class ButtonPressed(Message, bubble=True):
|
||||
pass
|
||||
class Button(Widget, can_focus=True):
|
||||
"""A simple clickable button."""
|
||||
|
||||
CSS = """
|
||||
|
||||
Button {
|
||||
width: auto;
|
||||
height: 3;
|
||||
padding: 0 2;
|
||||
background: $primary;
|
||||
color: $text-primary;
|
||||
content-align: center middle;
|
||||
border: tall $primary-lighten-3;
|
||||
margin: 1;
|
||||
min-width:16;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
class Expand:
|
||||
def __init__(self, renderable: RenderableType) -> None:
|
||||
self.renderable = renderable
|
||||
Button:hover {
|
||||
background:$primary-darken-2;
|
||||
color: $text-primary-darken-2;
|
||||
border: tall $primary-lighten-1;
|
||||
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
width = options.max_width
|
||||
height = options.height or 1
|
||||
yield from console.render(
|
||||
self.renderable, options.update_dimensions(width, height)
|
||||
)
|
||||
class Pressed(Message, bubble=True):
|
||||
pass
|
||||
|
||||
|
||||
class ButtonRenderable:
|
||||
def __init__(self, label: RenderableType, style: StyleType = "") -> None:
|
||||
self.label = label
|
||||
self.style = style
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
) -> RenderResult:
|
||||
width = options.max_width
|
||||
height = options.height or 1
|
||||
|
||||
yield Align.center(
|
||||
self.label, vertical="middle", style=self.style, width=width, height=height
|
||||
)
|
||||
|
||||
|
||||
class Button(Widget):
|
||||
def __init__(
|
||||
self,
|
||||
label: RenderableType,
|
||||
label: RenderableType | None = None,
|
||||
disabled: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
style: StyleType = "white on dark_blue",
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
):
|
||||
super().__init__(name=name)
|
||||
self.name = name or str(label)
|
||||
self.button_style = style
|
||||
super().__init__(name=name, id=id, classes=classes)
|
||||
|
||||
self.label = label
|
||||
self.label = self.css_identifier_styled if label is None else label
|
||||
self.disabled = disabled
|
||||
if disabled:
|
||||
self.add_class("-disabled")
|
||||
|
||||
label: Reactive[RenderableType] = Reactive("")
|
||||
|
||||
def validate_label(self, label: RenderableType) -> RenderableType:
|
||||
"""Parse markup for self.label"""
|
||||
if isinstance(label, str):
|
||||
return Text.from_markup(label)
|
||||
return label
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
return ButtonRenderable(self.label, style=self.button_style)
|
||||
return self.label
|
||||
|
||||
async def on_click(self, event: events.Click) -> None:
|
||||
event.prevent_default().stop()
|
||||
await self.emit(ButtonPressed(self))
|
||||
event.stop()
|
||||
if not self.disabled:
|
||||
await self.emit(Button.Pressed(self))
|
||||
|
||||
@@ -14,7 +14,7 @@ class Static(Widget):
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: set[str] | None = None,
|
||||
classes: str | None = None,
|
||||
style: StyleType = "",
|
||||
padding: PaddingDimensions = 0,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user