From 009c556ca9e62f87bae3be12784cd446fad55697 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 16:21:52 +0100 Subject: [PATCH] calculator example --- examples/calculator.css | 35 ++++++++++++ examples/calculator.py | 113 +++++++++++++++++++++++++++++++++++++++ src/textual/css/query.py | 13 +++++ src/textual/dom.py | 11 ++++ src/textual/reactive.py | 15 ++++++ 5 files changed, 187 insertions(+) create mode 100644 examples/calculator.css create mode 100644 examples/calculator.py diff --git a/examples/calculator.css b/examples/calculator.css new file mode 100644 index 000000000..44e5dcde7 --- /dev/null +++ b/examples/calculator.css @@ -0,0 +1,35 @@ +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: 50; +} + +Button { + width: 100%; + height: 100%; +} + +#numbers { + column-span: 4; + content-align: right middle; + padding: 0 1; + height: 100%; + background: $panel-darken-2; +} + +.special { + tint: $text-panel 20%; +} + +.zero { + column-span: 2; +} diff --git a/examples/calculator.py b/examples/calculator.py new file mode 100644 index 000000000..c98e16d36 --- /dev/null +++ b/examples/calculator.py @@ -0,0 +1,113 @@ +from decimal import Decimal + +from textual.app import App +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.init("0") + show_ac = Reactive(True) + left = Reactive.var(Decimal("0")) + right = Reactive.var(Decimal("0")) + value = Reactive.var("") + operator = Reactive.var("plus") + + 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): + """Add our buttons.""" + yield Container( + Static(id="numbers"), + Button("AC", id="ac"), + Button("C", id="c"), + Button("+/-", id="plus_minus"), + Button("%", id="percent"), + Button("÷", id="divide"), + Button("7", id="7"), + Button("8", id="8"), + Button("9", id="9"), + Button("×", id="multiply", variant="warning"), + Button("4", id="4"), + Button("5", id="5"), + Button("6", id="6"), + Button("-", id="minus", variant="warning"), + Button("1", id="1"), + Button("2", id="2"), + Button("3", id="3"), + Button("+", id="plus", variant="warning"), + Button("0", id="0", classes="operator zero"), + Button(".", id="point"), + Button("=", id="equals", variant="warning"), + id="calculator", + ) + + 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.display = "Error" + + if button_id.isdecimal(): + self.numbers = self.value = self.value.lstrip("0") + button_id + 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..62fee41c6 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): _description_ + """ + 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.