From 009c556ca9e62f87bae3be12784cd446fad55697 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 16:21:52 +0100 Subject: [PATCH 1/9] 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. From 9f0fc196b240e1b9256c9e8bfbe64fcbd22cfc68 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 16:25:07 +0100 Subject: [PATCH 2/9] docstring --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 62fee41c6..a79058e78 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -709,7 +709,7 @@ class DOMNode(MessagePump): """Add or remove class(es) based on a condition. Args: - add (bool): _description_ + add (bool): Add the classes if True, otherwise remove them. """ if add: self.add_class(*class_names) From 282a3df4c7b2e2420a786e6902dbebdc3b90caef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 17:43:28 +0100 Subject: [PATCH 3/9] calculator fixes --- examples/calculator.css | 8 ++++---- examples/calculator.py | 37 +++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/examples/calculator.css b/examples/calculator.css index 44e5dcde7..f4dddf0e2 100644 --- a/examples/calculator.css +++ b/examples/calculator.css @@ -23,13 +23,13 @@ Button { content-align: right middle; padding: 0 1; height: 100%; - background: $panel-darken-2; + background: $panel-darken-1; } -.special { - tint: $text-panel 20%; +#ac,#c,#plus_minus,#percent { + tint: $primary 20%; } -.zero { +#number-0 { column-span: 2; } diff --git a/examples/calculator.py b/examples/calculator.py index c98e16d36..2206108c5 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,6 +1,6 @@ from decimal import Decimal -from textual.app import App +from textual.app import App, ComposeResult from textual.layout import Container from textual.reactive import Reactive from textual.widgets import Button, Static @@ -9,8 +9,8 @@ from textual.widgets import Button, Static class CalculatorApp(App): """A working 'desktop' calculator.""" - numbers = Reactive.init("0") - show_ac = Reactive(True) + numbers = Reactive.var("0") + show_ac = Reactive.var(True) left = Reactive.var(Decimal("0")) right = Reactive.var(Decimal("0")) value = Reactive.var("") @@ -30,7 +30,7 @@ class CalculatorApp(App): self.query_one("#c").display = not show_ac self.query_one("#ac").display = show_ac - def compose(self): + def compose(self) -> ComposeResult: """Add our buttons.""" yield Container( Static(id="numbers"), @@ -38,20 +38,20 @@ class CalculatorApp(App): 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="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="4"), - Button("5", id="5"), - Button("6", id="6"), + Button("4", id="number-4"), + Button("5", id="number-5"), + Button("6", id="number-6"), Button("-", id="minus", variant="warning"), - Button("1", id="1"), - Button("2", id="2"), - Button("3", id="3"), + Button("1", id="number-1"), + Button("2", id="number-2"), + Button("3", id="number-3"), Button("+", id="plus", variant="warning"), - Button("0", id="0", classes="operator zero"), + Button("0", id="number-0"), Button(".", id="point"), Button("=", id="equals", variant="warning"), id="calculator", @@ -79,10 +79,11 @@ class CalculatorApp(App): self.numbers = str(self.left) self.value = "" except Exception: - self.display = "Error" + self.numbers = "Error" - if button_id.isdecimal(): - self.numbers = self.value = self.value.lstrip("0") + button_id + 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": From ac36e6814e4ad44d10b6dbf43b9b1809b311184f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 18:21:06 +0100 Subject: [PATCH 4/9] naming --- examples/calculator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/calculator.py b/examples/calculator.py index 2206108c5..90eba01af 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -36,7 +36,7 @@ class CalculatorApp(App): Static(id="numbers"), Button("AC", id="ac"), Button("C", id="c"), - Button("+/-", id="plus_minus"), + Button("+/-", id="plus-minus"), Button("%", id="percent"), Button("÷", id="divide", variant="warning"), Button("7", id="number-7"), @@ -84,7 +84,7 @@ class CalculatorApp(App): if button_id.startswith("number-"): number = button_id.partition("-")[-1] self.numbers = self.value = self.value.lstrip("0") + number - elif button_id == "plus_minus": + 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)) From a81b1945da95851d45aa9a815900c29f918d1f57 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 18:41:55 +0100 Subject: [PATCH 5/9] fix button --- examples/calculator.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/calculator.css b/examples/calculator.css index f4dddf0e2..eff034283 100644 --- a/examples/calculator.css +++ b/examples/calculator.css @@ -26,7 +26,7 @@ Button { background: $panel-darken-1; } -#ac,#c,#plus_minus,#percent { +#ac,#c,#plus-minus,#percent { tint: $primary 20%; } From 78fb85b725259a8ca53aec37be91d87888451c2d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Aug 2022 18:48:24 +0100 Subject: [PATCH 6/9] calculator tweak --- examples/calculator.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/calculator.css b/examples/calculator.css index eff034283..6e8e5da0f 100644 --- a/examples/calculator.css +++ b/examples/calculator.css @@ -10,7 +10,7 @@ Screen { table-rows: 2fr 1fr 1fr 1fr 1fr 1fr; margin: 1 2; min-height:25; - min-width: 50; + min-width: 26; } Button { @@ -23,11 +23,12 @@ Button { content-align: right middle; padding: 0 1; height: 100%; - background: $panel-darken-1; + background: $primary-lighten-2; + color: $text-primary-lighten-2; } #ac,#c,#plus-minus,#percent { - tint: $primary 20%; + tint: $primary 25%; } #number-0 { From 10ede76b9b85b0c5038eec5e696724d81ec462cb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Aug 2022 09:30:41 +0100 Subject: [PATCH 7/9] added keys to calculatorm, press methid to Button, primary button style --- examples/calculator.css | 4 ---- examples/calculator.py | 36 +++++++++++++++++++++++++---- src/textual/widgets/_button.py | 42 +++++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/examples/calculator.css b/examples/calculator.css index 6e8e5da0f..0d777edfa 100644 --- a/examples/calculator.css +++ b/examples/calculator.css @@ -27,10 +27,6 @@ Button { color: $text-primary-lighten-2; } -#ac,#c,#plus-minus,#percent { - tint: $primary 25%; -} - #number-0 { column-span: 2; } diff --git a/examples/calculator.py b/examples/calculator.py index 90eba01af..7c06c4d54 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,6 +1,7 @@ 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 @@ -16,6 +17,17 @@ class CalculatorApp(App): 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 @@ -34,10 +46,10 @@ class CalculatorApp(App): """Add our buttons.""" yield Container( Static(id="numbers"), - Button("AC", id="ac"), - Button("C", id="c"), - Button("+/-", id="plus-minus"), - Button("%", id="percent"), + 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"), @@ -57,6 +69,22 @@ class CalculatorApp(App): 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.""" diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 4a2b806b2..bb653bfd9 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): @@ -59,6 +59,32 @@ class Button(Widget, can_focus=True): border-top: tall $panel-darken-2; } + /* Primary variant */ + Button.-primary { + background: $primary; + color: $text-primary; + border-top: tall $primary-lighten-2; + border-bottom: tall $primary-darken-3; + + } + + Button.-primary:hover { + background: $primary-darken-2; + color: $text-primary-darken-2; + + } + + Button.-active { + tint: $background 30%; + } + + Button.-primary.-active { + background: $primary; + border-bottom: tall $primary-lighten-3; + border-top: tall $primary-darken-3; + + } + /* Success variant */ Button.-success { @@ -188,14 +214,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 +234,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)) From 2fa594f0cc4fc9a168bc5b792da5cff101cf6036 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Aug 2022 09:34:02 +0100 Subject: [PATCH 8/9] simplify css --- src/textual/widgets/_button.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index bb653bfd9..0d684aeea 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -57,6 +57,7 @@ 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 */ @@ -74,10 +75,6 @@ class Button(Widget, can_focus=True): } - Button.-active { - tint: $background 30%; - } - Button.-primary.-active { background: $primary; border-bottom: tall $primary-lighten-3; From 2627844796ae2751962281fcd5d6be1b9de41203 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Aug 2022 09:47:41 +0100 Subject: [PATCH 9/9] css tweak --- src/textual/widgets/_button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 0d684aeea..3121ef9c4 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -64,7 +64,7 @@ class Button(Widget, can_focus=True): Button.-primary { background: $primary; color: $text-primary; - border-top: tall $primary-lighten-2; + border-top: tall $primary-lighten-3; border-bottom: tall $primary-darken-3; }