diff --git a/docs/examples/introduction/stopwatch01.py b/docs/examples/introduction/stopwatch01.py new file mode 100644 index 000000000..928372d93 --- /dev/null +++ b/docs/examples/introduction/stopwatch01.py @@ -0,0 +1,19 @@ +from textual.app import App +from textual.widgets import Header, Footer + + +class TimerApp(App): + def compose(self): + yield Header() + yield Footer() + + def on_load(self): + self.bind("d", "toggle_dark", description="Dark mode") + + def action_toggle_dark(self): + self.dark = not self.dark + + +app = TimerApp() +if __name__ == "__main__": + app.run() diff --git a/docs/examples/introduction/timers.css b/docs/examples/introduction/timers.css index a9fc25a31..458d9045b 100644 --- a/docs/examples/introduction/timers.css +++ b/docs/examples/introduction/timers.css @@ -1,11 +1,11 @@ TimerWidget { layout: horizontal; background: $panel-darken-1; - border: tall $panel-darken-2; + height: 5; min-width: 50; margin: 1; - padding: 0 1; + padding: 1 1; transition: background 300ms linear; } @@ -13,7 +13,6 @@ TimerWidget.started { text-style: bold; background: $success; color: $text-success; - border: tall $success-darken-2; } diff --git a/docs/examples/introduction/timers.py b/docs/examples/introduction/timers.py index 183e50a66..b7d667780 100644 --- a/docs/examples/introduction/timers.py +++ b/docs/examples/introduction/timers.py @@ -71,6 +71,7 @@ class TimerApp(App): """Called when the app first loads.""" self.bind("a", "add_timer", description="Add") self.bind("r", "remove_timer", description="Remove") + self.bind("d", "toggle_dark", description="Dark mode") def compose(self) -> ComposeResult: """Called to ad widgets to the app.""" @@ -90,6 +91,9 @@ class TimerApp(App): if timers: timers.last().remove() + def action_toggle_dark(self) -> None: + self.dark = not self.dark + app = TimerApp(css_path="timers.css") if __name__ == "__main__": diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 000000000..fa5d6b9fe --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,23 @@ +## Installation + +You can install Textual via PyPi. + +If you plan on developing Textual apps, then you can install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. + +```bash +pip install textual[dev] +``` + +If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: + +```bash +pip install textual +``` + +## Textual CLI app + +If you installed the dev dependencies, you have have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps. See the help for more details: + +```python +textual --help +``` diff --git a/docs/index.md b/docs/index.md index 2fb8bc20f..61444695c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,42 +6,31 @@ Welcome to the [Textual](https://github.com/Textualize/textual) framework docume Textual is a Python framework for building applications that run within your terminal. -Such text-based applications have a number of benefits: +Text User Interfaces (TUIs) have a number of benefits: -- **Quick to develop:** Textual is a modern Python API. -- **Low requirements:** Run Textual apps anywhere with a Python interpreter, even single-board computers. +- **Quick to develop:** Really rapid app development with a modern Python API. +- **Low requirements:** Textual apps anywhere with a Python interpreter, even single-board computers. - **Cross platform:** The same code will run on Linux, Windows, MacOS and more. - **Remote:** Fully featured UIs can run over SSH. - **CLI integration:** Textual apps integrate with your shell and other CLI tools. Textual TUIs are quick and easy to build with pure Python (not to mention _fun_). +
+ + + -```{.textual path="docs/examples/demo.py" columns=100 lines=48} +=== "Example 1" -``` + ```{.textual path="docs/examples/demo.py" columns=100 lines=48} -## Installation + ``` -You can install Textual via PyPi. +=== "Example 2" -If you plan on developing Textual apps, then you can install `textual[dev]`. The `[dev]` part installs a few extra dependencies for development. + ```{.textual path="docs/examples/introduction/timers.py"} -```bash -pip install textual[dev] -``` + ``` -If you only plan on _running_ Textual apps, then you can drop the `[dev]` part: - -```bash -pip install textual -``` - -## Textual CLI app - -If you installed the dev dependencies, you have have access to the `textual` CLI command. There are a number of sub-commands which will aid you in building Textual apps. See the help for more details: - -```python -textual --help -``` diff --git a/docs/introduction.md b/docs/introduction.md index 1e855bfed..ab2e434b0 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -4,6 +4,94 @@ Welcome to the Textual Introduction! By the end of this page you should have a good idea of the steps involved in creating an application with Textual. + +## Stopwatch Application + +We're going to build a stopwatch app. This app will display the elapsed time since the user hit a "Start" button. The user will be able to stop / resume / reset each stopwatch in addition to adding or removing them. + +This is a simple yet **fully featured** app — you could distribute this app if you wanted to! + +Here's what the finished app will look like: + + +```{.textual path="docs/examples/introduction/timers.py"} +``` + +## The App class + +The first step in building a Textual app is to import and extend the `App` class. Here's our basic app class with a few methods which we will cover below. + +```python title="stopwatch01.py" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + +If you run this code, you should see something like the following: + + +```{.textual path="docs/examples/introduction/stopwatch01.py"} +``` + +Hit the ++d++ key to toggle dark mode. + +```{.textual path="docs/examples/introduction/stopwatch01.py" press="d" title="TimerApp + dark"} +``` + +Hit ++ctrl+c++ to exit the app and return to the command prompt. + +### Looking at the code + +Let's example stopwatch01.py in more detail. + +```python title="stopwatch01.py" hl_lines="1 2" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + + +The first line imports the Textual `App` class. The second line imports two builtin widgets: `Footer` which shows available keys and `Header` which shows a title and the current time. + +Widgets are re-usable components responsible for managing a part of the screen. We will cover how to build such widgets in this introduction. + + +```python title="stopwatch01.py" hl_lines="5-15" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + +The App class is where most of the logic of Textual apps is written. It is responsible for loading configuration, setting up widgets, handling keys, and more. + +There are three methods in our stopwatch app currently. + +- **`compose()`** is where we construct a user interface with widgets. The `compose()` method may return a list of widgets, but it is generally easier to _yield_ them (making this method a generator). In the example code we yield instances of the widget classes we imported, i.e. the header and the footer. + +- **`on_load()`** is an _event handler_ method. Event handlers are called by Textual in response to external events like keys and mouse movements, and internal events needed to manage your application. Event handler methods begin with `on_` followed by the name of the event (in lower case). Hence, `on_load` it is called in response to the Load event which is sent just after the app starts. We're using this event to call `App.bind()` which connects a key to an _action_. + +- **`action_toggle_dark()`** defines an _action_ method. Actions are methods beginning with `action_` followed by the name of the action. The call to `bind()` in `on_load()` binds this action to the ++d++ key. The body of this method flips the state of the `dark` boolean to toggle dark mode. + +!!! note + + You may have noticed that the the `toggle_dark` doesn't do anything to explicitly change the _screen_, and yet hitting ++d++ refreshes and updates the whole terminal. This is an example of _reactivity_. Changing certain attributes will schedule an automatic update. + + +```python title="stopwatch01.py" hl_lines="17-19" +--8<-- "docs/examples/introduction/stopwatch01.py" +``` + +The last lines in "stopwatch01.py" may be familiar to you. We create an instance of our app class, and run it within a `__name__ == "__main__"` conditional block. This is so that we could import `app` if we want to. Or we could run it with `python stopwatch01.py`. + +## Timers + +=== "Timers Python" + + ```python title="timers.py" + --8<-- "docs/examples/introduction/timers.py" + ``` + +=== "Timers CSS" + + ```python title="timers.css" + --8<-- "docs/examples/introduction/timers.css" + ``` + + ## Pre-requisites - Python 3.7 or later. If you have a choice, pick the most recent version. @@ -37,7 +125,7 @@ The command prompt should disappear and you will see a blank screen: ``` -Hit ++ctrl+c++ to exit and return to the command prompt. +Hit ++Ctrl+c++ to exit and return to the command prompt. ### Application mode diff --git a/mkdocs.yml b/mkdocs.yml index e7ac4573e..fa0fdc5d8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,7 @@ site_url: https://www.textualize.io/ nav: - "index.md" + - "getting_started.md" - "introduction.md" - Guide: - "guide/guide.md" diff --git a/src/textual/_doc.py b/src/textual/_doc.py index 802dd2091..ce40361dd 100644 --- a/src/textual/_doc.py +++ b/src/textual/_doc.py @@ -6,11 +6,18 @@ import os def format_svg(source, language, css_class, options, md, attrs, **kwargs): """A superfences formatter to insert a SVG screenshot.""" + path = attrs.get("path") + press = attrs.get("press", "").split(",") + title = attrs.get("title") + os.environ["TEXTUAL"] = "headless" os.environ["TEXTUAL_SCREENSHOT"] = "0.15" + if title: + os.environ["TEXTUAL_SCREENSHOT_TITLE"] = title + else: + os.environ.pop("TEXTUAL_SCREENSHOT_TITLE", None) os.environ["COLUMNS"] = attrs.get("columns", "80") os.environ["LINES"] = attrs.get("lines", "24") - path = attrs.get("path") print(f"screenshotting {path!r}") if path: @@ -24,7 +31,7 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs): exec(source, app_vars) app = app_vars["app"] - app.run() + app.run(press=press or None) svg = app._screenshot finally: diff --git a/src/textual/app.py b/src/textual/app.py index c0c574bd6..fb9ef621d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -481,12 +481,13 @@ class App(Generic[ReturnType], DOMNode): """Action to save a screenshot.""" self.save_screenshot(path) - def export_screenshot(self) -> str: + def export_screenshot(self, *, title: str | None = None) -> str: """Export a SVG screenshot of the current screen. Args: - path (str | None, optional): Path of the SVG to save, or None to - generate a path automatically. Defaults to None. + title (str | None, optional): The title of the exported screenshot or None + to use app title. Defaults to None. + """ console = Console( @@ -499,7 +500,7 @@ class App(Generic[ReturnType], DOMNode): ) screen_render = self.screen._compositor.render(full=True) console.print(screen_render) - return console.export_svg(title=self.title) + return console.export_svg(title=title or self.title) def save_screenshot(self, path: str | None = None) -> str: """Save a screenshot of the current screen. @@ -545,7 +546,10 @@ class App(Generic[ReturnType], DOMNode): ) def run( - self, quit_after: float | None = None, headless: bool = False + self, + quit_after: float | None = None, + headless: bool = False, + press: Iterable[str] | None = None, ) -> ReturnType | None: """The main entry point for apps. @@ -566,6 +570,16 @@ class App(Generic[ReturnType], DOMNode): async def run_app() -> None: if quit_after is not None: self.set_timer(quit_after, self.shutdown) + if press is not None: + + async def press_keys(): + assert press + for key in press: + await asyncio.sleep(0.01) + await self.press(key) + + self.call_later(press_keys) + await self.process_messages() if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: @@ -1009,12 +1023,14 @@ class App(Generic[ReturnType], DOMNode): except ValueError: return + screenshot_title = os.environ.get("TEXTUAL_SCREENSHOT_TITLE") + if not screenshot_timer: return async def on_screenshot(): """Used by docs plugin.""" - svg = self.export_screenshot() + svg = self.export_screenshot(title=screenshot_title) self._screenshot = svg # type: ignore await self.shutdown() diff --git a/src/textual/screen.py b/src/textual/screen.py index b8f6af01f..a8463446a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -33,6 +33,8 @@ class Screen(Widget): CSS = """ Screen { + background: $background; + color: $text-background; layout: vertical; overflow-y: auto; }