button widget

This commit is contained in:
Will McGugan
2022-04-27 14:02:28 +01:00
parent efafbdfcc1
commit 44c1f2373a
14 changed files with 142 additions and 78 deletions

View File

@@ -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

View File

@@ -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"),
),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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)

View File

@@ -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.

View File

@@ -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")

View File

@@ -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",

View File

@@ -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))

View File

@@ -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: