diff --git a/examples/calculator.css b/examples/calculator.css new file mode 100644 index 000000000..0d777edfa --- /dev/null +++ b/examples/calculator.css @@ -0,0 +1,32 @@ +Screen { + overflow: auto; +} + +#calculator { + layout: table; + table-size: 4; + table-gutter: 1 2; + table-columns: 1fr; + table-rows: 2fr 1fr 1fr 1fr 1fr 1fr; + margin: 1 2; + min-height:25; + min-width: 26; +} + +Button { + width: 100%; + height: 100%; +} + +#numbers { + column-span: 4; + content-align: right middle; + padding: 0 1; + height: 100%; + background: $primary-lighten-2; + color: $text-primary-lighten-2; +} + +#number-0 { + column-span: 2; +} diff --git a/examples/calculator.py b/examples/calculator.py new file mode 100644 index 000000000..7c06c4d54 --- /dev/null +++ b/examples/calculator.py @@ -0,0 +1,142 @@ +from decimal import Decimal + +from textual.app import App, ComposeResult +from textual import events +from textual.layout import Container +from textual.reactive import Reactive +from textual.widgets import Button, Static + + +class CalculatorApp(App): + """A working 'desktop' calculator.""" + + numbers = Reactive.var("0") + show_ac = Reactive.var(True) + left = Reactive.var(Decimal("0")) + right = Reactive.var(Decimal("0")) + value = Reactive.var("") + operator = Reactive.var("plus") + + KEY_MAP = { + "+": "plus", + "-": "minus", + ".": "point", + "*": "multiply", + "/": "divide", + "_": "plus-minus", + "%": "percent", + "=": "equals", + } + + def watch_numbers(self, value: str) -> None: + """Called when numbers is updated.""" + # Update the Numbers widget + self.query_one("#numbers", Static).update(value) + + def compute_show_ac(self) -> bool: + """Compute switch to show AC or C button""" + return self.value in ("", "0") and self.numbers == "0" + + def watch_show_ac(self, show_ac: bool) -> None: + """Called when show_ac changes.""" + self.query_one("#c").display = not show_ac + self.query_one("#ac").display = show_ac + + def compose(self) -> ComposeResult: + """Add our buttons.""" + yield Container( + Static(id="numbers"), + Button("AC", id="ac", variant="primary"), + Button("C", id="c", variant="primary"), + Button("+/-", id="plus-minus", variant="primary"), + Button("%", id="percent", variant="primary"), + Button("÷", id="divide", variant="warning"), + Button("7", id="number-7"), + Button("8", id="number-8"), + Button("9", id="number-9"), + Button("×", id="multiply", variant="warning"), + Button("4", id="number-4"), + Button("5", id="number-5"), + Button("6", id="number-6"), + Button("-", id="minus", variant="warning"), + Button("1", id="number-1"), + Button("2", id="number-2"), + Button("3", id="number-3"), + Button("+", id="plus", variant="warning"), + Button("0", id="number-0"), + Button(".", id="point"), + Button("=", id="equals", variant="warning"), + id="calculator", + ) + + def on_key(self, event: events.Key) -> None: + """Called when the user presses a key.""" + + def press(button_id: str) -> None: + self.query_one(f"#{button_id}", Button).press() + self.set_focus(None) + + key = event.key + if key.isdecimal(): + press(f"number-{key}") + elif key == "c": + press("c") + press("ac") + elif key in self.KEY_MAP: + press(self.KEY_MAP[key]) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Called when a button is pressed.""" + + button_id = event.button.id + assert button_id is not None + + self.bell() # Terminal bell + + def do_math() -> None: + """Does the math: LEFT OPERATOR RIGHT""" + try: + if self.operator == "plus": + self.left += self.right + elif self.operator == "minus": + self.left -= self.right + elif self.operator == "divide": + self.left /= self.right + elif self.operator == "multiply": + self.left *= self.right + self.numbers = str(self.left) + self.value = "" + except Exception: + self.numbers = "Error" + + if button_id.startswith("number-"): + number = button_id.partition("-")[-1] + self.numbers = self.value = self.value.lstrip("0") + number + elif button_id == "plus-minus": + self.numbers = self.value = str(Decimal(self.value or "0") * -1) + elif button_id == "percent": + self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) + elif button_id == "point": + if "." not in self.value: + self.numbers = self.value = (self.value or "0") + "." + elif button_id == "ac": + self.value = "" + self.left = self.right = Decimal(0) + self.operator = "plus" + self.numbers = "0" + elif button_id == "c": + self.value = "" + self.numbers = "0" + elif button_id in ("plus", "minus", "divide", "multiply"): + self.right = Decimal(self.value or "0") + do_math() + self.operator = button_id + elif button_id == "equals": + if self.value: + self.right = Decimal(self.value) + do_math() + + +app = CalculatorApp(css_path="calculator.css") +if __name__ == "__main__": + app.run() diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 9b8c6b96a..202a0767a 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -251,6 +251,19 @@ class DOMQuery: if isinstance(node, filter_type): yield node + def set_class(self, add: bool, *class_names: str) -> DOMQuery: + """Set the given class name(s) according to a condition. + + Args: + add (bool): Add the classes if True, otherwise remove them. + + Returns: + DOMQuery: Self. + """ + for node in self: + node.set_class(add, *class_names) + return self + def add_class(self, *class_names: str) -> DOMQuery: """Add the given class name(s) to nodes.""" for node in self: diff --git a/src/textual/dom.py b/src/textual/dom.py index 984ac8f16..a79058e78 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -705,6 +705,17 @@ class DOMNode(MessagePump): """ return self._classes.issuperset(class_names) + def set_class(self, add: bool, *class_names: str) -> None: + """Add or remove class(es) based on a condition. + + Args: + add (bool): Add the classes if True, otherwise remove them. + """ + if add: + self.add_class(*class_names) + else: + self.remove_class(*class_names) + def add_class(self, *class_names: str) -> None: """Add class names to this Node. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index dcc9757c3..99dea0ea0 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -74,6 +74,21 @@ class Reactive(Generic[ReactiveType]): """ return cls(default, layout=layout, repaint=repaint, init=True) + @classmethod + def var( + cls, + default: ReactiveType | Callable[[], ReactiveType], + ) -> Reactive: + """A reactive variable that doesn't update or layout. + + Args: + default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + + Returns: + Reactive: A Reactive descriptor. + """ + return cls(default, layout=False, repaint=False, init=True) + @classmethod async def initialize_object(cls, obj: object) -> None: """Call any watchers / computes for the first time. diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 4a2b806b2..3121ef9c4 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -18,8 +18,8 @@ from ..message import Message from ..reactive import Reactive from ..widget import Widget -ButtonVariant = Literal["default", "success", "warning", "error"] -_VALID_BUTTON_VARIANTS = {"default", "success", "warning", "error"} +ButtonVariant = Literal["default", "primary", "success", "warning", "error"] +_VALID_BUTTON_VARIANTS = {"default", "primary", "success", "warning", "error"} class InvalidButtonVariant(Exception): @@ -57,6 +57,29 @@ class Button(Widget, can_focus=True): background: $panel; border-bottom: tall $panel-lighten-2; border-top: tall $panel-darken-2; + tint: $background 30%; + } + + /* Primary variant */ + Button.-primary { + background: $primary; + color: $text-primary; + border-top: tall $primary-lighten-3; + border-bottom: tall $primary-darken-3; + + } + + Button.-primary:hover { + background: $primary-darken-2; + color: $text-primary-darken-2; + + } + + Button.-primary.-active { + background: $primary; + border-bottom: tall $primary-lighten-3; + border-top: tall $primary-darken-3; + } @@ -188,14 +211,18 @@ class Button(Widget, can_focus=True): label.stylize(self.text_style) return label - async def on_click(self, event: events.Click) -> None: + async def _on_click(self, event: events.Click) -> None: event.stop() - if self.disabled: + self.press() + + def press(self) -> None: + """Respond to a button press.""" + if self.disabled or not self.display: return # Manage the "active" effect: self._start_active_affect() # ...and let other components know that we've just been clicked: - await self.emit(Button.Pressed(self)) + self.emit_no_wait(Button.Pressed(self)) def _start_active_affect(self) -> None: """Start a small animation to show the button was clicked.""" @@ -204,7 +231,7 @@ class Button(Widget, can_focus=True): self.ACTIVE_EFFECT_DURATION, partial(self.remove_class, "-active") ) - async def on_key(self, event: events.Key) -> None: + async def _on_key(self, event: events.Key) -> None: if event.key == "enter" and not self.disabled: self._start_active_affect() await self.emit(Button.Pressed(self))