Merge pull request #714 from Textualize/calculator

calculator example
This commit is contained in:
Will McGugan
2022-08-31 09:52:43 +01:00
committed by GitHub
6 changed files with 246 additions and 6 deletions

32
examples/calculator.css Normal file
View File

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

142
examples/calculator.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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