commit 7b7dd9d811196a4a91f06b5a759cab986abe291c Author: Waylon S. Walker Date: Tue Oct 25 07:47:13 2022 -0500 init diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1 @@ + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e5400e --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# textual-tutorial + +[![PyPI - Version](https://img.shields.io/pypi/v/textual-tutorial.svg)](https://pypi.org/project/textual-tutorial) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/textual-tutorial.svg)](https://pypi.org/project/textual-tutorial) + +----- + +**Table of Contents** + +- [Installation](#installation) +- [License](#license) + +## Installation + +```console +pip install textual-tutorial +``` + +## License + +`textual-tutorial` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97b0147 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "textual-tutorial" +description = '' +readme = "README.md" +requires-python = ">=3.7" +license = "MIT" +keywords = [] +authors = [ + { name = "Waylon S. Walker", email = "waylon@waylonwalker.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = ['textual'] +dynamic = ["version"] + +[project.scripts] +tutorial = 'textual_tutorial.tui:tui' + +[project.urls] +Documentation = "https://github.com/unknown/textual-tutorial#readme" +Issues = "https://github.com/unknown/textual-tutorial/issues" +Source = "https://github.com/unknown/textual-tutorial" + +[tool.hatch.version] +path = "textual_tutorial/__about__.py" + + +[project.optional-dependencies] +dev = [ + 'textual[dev]', + "pytest", + "pytest-cov", +] + +[tool.hatch.envs.default] +dependencies = [ + 'textual[dev]', + "pytest", + "pytest-cov", +] +[tool.hatch.envs.default.scripts] +cov = "pytest --cov-report=term-missing --cov-config=pyproject.toml --cov=textual_tutorial --cov=tests {args}" +no-cov = "cov --no-cov {args}" + +[[tool.hatch.envs.test.matrix]] +python = ["37", "38", "39", "310", "311"] + +[tool.coverage.run] +branch = true +parallel = true +omit = [ + "textual_tutorial/__about__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8ecc75f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2022-present Waylon S. Walker +# +# SPDX-License-Identifier: MIT diff --git a/tests/tui.py b/tests/tui.py new file mode 100644 index 0000000..d4b1732 --- /dev/null +++ b/tests/tui.py @@ -0,0 +1,2 @@ +def tui(): + print("running") diff --git a/textual_tutorial/__about__.py b/textual_tutorial/__about__.py new file mode 100644 index 0000000..fe60eb5 --- /dev/null +++ b/textual_tutorial/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2022-present Waylon S. Walker +# +# SPDX-License-Identifier: MIT +__version__ = '0.0.1' diff --git a/textual_tutorial/__init__.py b/textual_tutorial/__init__.py new file mode 100644 index 0000000..8ecc75f --- /dev/null +++ b/textual_tutorial/__init__.py @@ -0,0 +1,3 @@ +# SPDX-FileCopyrightText: 2022-present Waylon S. Walker +# +# SPDX-License-Identifier: MIT diff --git a/textual_tutorial/__pycache__/__init__.cpython-310.pyc b/textual_tutorial/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..f17428c Binary files /dev/null and b/textual_tutorial/__pycache__/__init__.cpython-310.pyc differ diff --git a/textual_tutorial/__pycache__/tui.cpython-310.pyc b/textual_tutorial/__pycache__/tui.cpython-310.pyc new file mode 100644 index 0000000..de8659b Binary files /dev/null and b/textual_tutorial/__pycache__/tui.cpython-310.pyc differ diff --git a/textual_tutorial/tui.css b/textual_tutorial/tui.css new file mode 100644 index 0000000..7832c0d --- /dev/null +++ b/textual_tutorial/tui.css @@ -0,0 +1,56 @@ +Stopwatch { + layout: horizontal; + background: $boost; + height: 5; + padding: 1; + margin: 1; +} + +Stopwatch.active { + background: red; + background: $background-lighten-3; +} + +TimeDisplay { + content-align: center middle; + text-opacity: 60%; + height: 3; +} + +Button { + width: 16; +} + +#start { + dock: left; +} + +#stop { + dock: left; + display: none; +} + +#reset { + dock: right; +} +.started { + text-style: bold; + background: $success; + color: $text; +} + +.started TimeDisplay { + text-opacity: 100%; +} + +.started #start { + display: none +} + +.started #stop { + display: block +} + +.started #reset { + visibility: hidden +} diff --git a/textual_tutorial/tui.py b/textual_tutorial/tui.py new file mode 100644 index 0000000..5fcb40d --- /dev/null +++ b/textual_tutorial/tui.py @@ -0,0 +1,189 @@ +from pathlib import Path +from textual.app import App, ComposeResult +from textual.css.query import NoMatches +from textual.widgets import Header, Footer + +from textual.containers import Container +from textual.widgets import Button, Header, Footer, Static + +from textual.reactive import reactive +from time import monotonic + + +class TimeDisplay(Static): + """A widget to display elapsed time.""" + + start_time = reactive(monotonic) + time = reactive(0.0) + total = reactive(0.0) + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True) + + def update_time(self) -> None: + """Method to update the time to the current time.""" + self.time = self.total + (monotonic() - self.start_time) + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + minutes, seconds = divmod(time, 60) + hours, minutes = divmod(minutes, 60) + self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}") + + def start(self) -> None: + """Method to start (or resume) time updating.""" + self.start_time = monotonic() + self.update_timer.resume() + + def stop(self): + """Method to stop the time display updating.""" + self.update_timer.pause() + self.total += monotonic() - self.start_time + self.time = self.total + + def reset(self): + """Method to reset the time display to zero.""" + self.total = 0 + self.time = 0 + + +class Stopwatch(Static): + """A stopwatch widget.""" + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Event handler called when a button is pressed.""" + button_id = event.button.id + time_display = self.query_one(TimeDisplay) + if button_id == "start": + time_display.start() + self.add_class("started") + elif button_id == "stop": + time_display.stop() + self.remove_class("started") + elif button_id == "reset": + time_display.reset() + + def compose(self) -> ComposeResult: + """Create child widgets of a stopwatch.""" + yield Button("Start", id="start", variant="success") + yield Button("Stop", id="stop", variant="error") + yield Button("Reset", id="reset") + yield TimeDisplay("00:00:00.00") + + +class StopwatchApp(App): + """A Textual app to manage stopwatches.""" + + CSS_PATH = Path("__file__").parent / "tui.css" + BINDINGS = [ + ("q", "quit", "Quit"), + ("d", "toggle_dark", "Toggle dark mode"), + ("a", "add_stopwatch", "Add"), + ("r", "remove_stopwatch", "Remove"), + ("R", "reset", "Reset"), + ("j", "next", "Next"), + ("j", "next", "Next"), + ("k", "prev", "Prev"), + ("space", "toggle", "Toggle"), + ] + + def on_mount(self): + + try: + self.query_one("Stopwatch").add_class("active") + except NoMatches: + ... + + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Container( + # Stopwatch(), + # Stopwatch(), + # Stopwatch(), + id="timers", + ) + yield Footer() + + def action_next(self): + self.activate(1) + + def action_prev(self): + self.activate(-1) + + def action_toggle(self): + try: + active = self.query_one("Stopwatch.active") + except NoMatches: + return + if "started" in active.classes: + active.query_one("#stop").press() + else: + active.query_one("#start").press() + + def action_reset(self): + try: + active = self.query_one("Stopwatch.active") + active.query_one("#reset").press() + except NoMatches: + ... + + def activate(self, n=1): + try: + active = self.query_one("Stopwatch.active") + except NoMatches: + return + stopwatches = self.query("Stopwatch").nodes + active_index = stopwatches.index(active) + next_index = active_index + n + if next_index > len(stopwatches) - 1: + next_index = 0 + if next_index < 0: + next_index = len(stopwatches) - 1 + active.remove_class("active") + stopwatches[next_index].add_class("active") + + def action_add_stopwatch(self) -> None: + """An action to add a timer.""" + new_stopwatch = Stopwatch() + try: + active = self.query_one("Stopwatch.active") + active.remove_class("active") + except NoMatches: + ... + new_stopwatch.add_class("active") + self.query_one("#timers").mount(new_stopwatch) + new_stopwatch.scroll_visible() + + def action_remove_stopwatch(self) -> None: + """Called to remove a timer.""" + timers = self.query("Stopwatch") + if timers: + timers.last().remove() + + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + self.log("going dark") + + +def tui(): + + from textual.features import parse_features + import os + import sys + + dev = "--dev" in sys.argv + features = set(parse_features(os.environ.get("TEXTUAL", ""))) + if dev: + features.add("debug") + features.add("devtools") + + os.environ["TEXTUAL"] = ",".join(sorted(features)) + app = StopwatchApp() + app.run() + + +if __name__ == "__main__": + tui()