mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
ws
This commit is contained in:
128
README.md
128
README.md
@@ -1,21 +1,121 @@
|
||||
# Textual
|
||||
|
||||
Textual is a TUI (Text User Interface) framework for Python using [Rich](https://github.com/willmcgugan/rich) as a renderer.
|
||||
Textual is a TUI (Text User Interface) framework for Python using [Rich](https://github.com/willmcgugan/rich) as a renderer. Currently a work in progress, but usable by brave souls who don't mind some API instability between updates.
|
||||
|
||||
The end goal is to be able to rapidly create *rich* terminal applications that look as good as possible (within the restrictions imposed by a terminal emulator).
|
||||
|
||||
Rich TUI will integrate tightly with its parent project, Rich. Any of the existing *renderables* can be used in a more dynamic application.
|
||||
The end goal is to be able to rapidly create _rich_ terminal applications that look as good as possible (within the restrictions imposed by a terminal emulator).
|
||||
|
||||
Textual will be eventually be cross platform, but for now it is MacOS / Linux only. Windows support is in the pipeline.
|
||||
|
||||
This project is currently a work in progress and may not be usable for a while. Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions.
|
||||
Follow [@willmcgugan](https://twitter.com/willmcgugan) for progress updates, or post in Discussions if you have any requests / suggestions.
|
||||
|
||||

|
||||
|
||||
## How it works
|
||||
|
||||
## Updates
|
||||
Textual has far more in common with web development than with curses. Every component has at its core a _message pump_ where it can receive and process events, a system modelled after JS in the browser. Web developers will recognize timers, intervals, propagation etc.
|
||||
|
||||
I'll be documenting progress in video form.
|
||||
Textual borrows other technologies from the web development world; layout is done with CSS grid and (soon) the theme may be customized with CSS. Textual is also influenced by modern JS frameworks such as Vue and React where modifying the state will automatically update the display.
|
||||
|
||||
## Installation
|
||||
|
||||
You can install Textual via pip (`pip install textual`), or by checking out the repo and installing with [poetry](https://python-poetry.org/docs/).
|
||||
|
||||
```
|
||||
poetry install
|
||||
```
|
||||
|
||||
## Building Textual applications
|
||||
|
||||
Let's look at the simplest Textual app which does _something_:
|
||||
|
||||
```python
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class Beeper(App):
|
||||
async def on_key(self, event):
|
||||
self.console.bell()
|
||||
|
||||
|
||||
Beeper.run()
|
||||
```
|
||||
|
||||
Here we can see a textual app with a single `on_key` method which will receive key events. Any key event will result in playing a beep noise. Hit ctrl+C to exit.
|
||||
|
||||
Event handlers in Textual are defined by convention, not by inheritance (so you won't find an `on_key` method in the base class). Each event has a `name` attribute which for the key event is simply `"key"`. Textual will call the method named `on_<event.name>` if it exists.
|
||||
|
||||
Lets look at a _slightly_ more interesting example:
|
||||
|
||||
```python
|
||||
from textual.app import App
|
||||
|
||||
|
||||
class ColorChanger(App):
|
||||
async def on_key(self, event):
|
||||
if event.key.isdigit():
|
||||
self.background = f"on color({event.key})"
|
||||
|
||||
|
||||
ColorChanger.run(log="textual.log")
|
||||
```
|
||||
|
||||
This example also handles key events, and will set `App.background` if the key is a digit. So pressing the keys 0 to 9 will change the background color to the corresponding [ansi colors](https://rich.readthedocs.io/en/latest/appendix/colors.html).
|
||||
|
||||
Note that we didn't need to explicitly refresh the screen or draw anything. Setting the `background` attribute is enough for Textual to update the visuals. This is an example of _reactivity_ in Textual.
|
||||
|
||||
### Widgets
|
||||
|
||||
To make more interesting apps you will need to make use of _widgets_, which are independent user interface elements. Textual comes with a (growing) library of widgets, but you can also develop your own.
|
||||
|
||||
Let's look at an app which contains widgets. We will be using the built in `Placeholder` widget which you can use to design application layouts before you implement the real content. They are also very useful for testing.
|
||||
|
||||
```python
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class SimpleApp(App):
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
await self.view.dock(Placeholder(), edge="left", size=40)
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
|
||||
|
||||
SimpleApp.run(log="textual.log")
|
||||
```
|
||||
|
||||
This app contains a single event handler `on_mount`. The mount event is sent when the app or widget is ready to start processing events. We can use it for initializing things. In this case we are going to call `self.view.dock` to add widgets to the interface. More about the `view` object later.
|
||||
|
||||
Here's the first line in the mount handler::
|
||||
|
||||
```python
|
||||
await self.view.dock(Placeholder(), edge="left", size=40)
|
||||
```
|
||||
|
||||
Note it is asynchronous like almost all API methods in Textual. We are awaiting `self.view.dock` which takes a newly constructed Placeholder widget, and docks it on to the `"left"` edge of the terminal with a size of 40 characters. In a real app you might use this to display a side-bar of sorts.
|
||||
|
||||
The following line is similar:
|
||||
|
||||
```python
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
```
|
||||
|
||||
You will notice that this time we are docking two Placeholder objects on the top edge. We haven't set an explicit size this time, so Textual will divide the remaining size amongst the two new widgets.
|
||||
|
||||
The last line calls the `run` class method in the usual way, but with an argument we haven't seen before: `log="textual.log"` tells Textual to write log information to the given file. You can tail textual.log to see the events that are being processed and other debug information.
|
||||
|
||||
If you run the above example, you will see something like the following:
|
||||
|
||||

|
||||
|
||||
If you move the mouse over the terminal you will notice that widgets receive mouse events. You can click any of the placeholders to give it input focus.
|
||||
|
||||
The dock layout feature is good enough for most purposes. For more sophisticated layouts we can use the grid API. See the [calculator.py](https://github.com/willmcgugan/textual/blob/main/examples/calculator.py) example which makes use of Grid.
|
||||
|
||||
## Developer VLog
|
||||
|
||||
Since Textual is a visual medium, I'll be documenting new features and milestones here.
|
||||
|
||||
### Update 1 - Basic scrolling
|
||||
|
||||
@@ -31,7 +131,7 @@ I'll be documenting progress in video form.
|
||||
|
||||
### Update 4 - Animation system with easing function
|
||||
|
||||
Now with a system to animate a value to another value. Here applied to the scroll position. The animation system supports CSS like *easing functions*. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down.
|
||||
Now with a system to animate a value to another value. Here applied to the scroll position. The animation system supports CSS like _easing functions_. You may be able to tell from the video that the page up / down keys cause the window to first speed up and then slow down.
|
||||
|
||||
[](http://www.youtube.com/watch?v=k2VwOp1YbSk)
|
||||
|
||||
@@ -47,10 +147,18 @@ New version (0.1.4) with API updates and the new layout system.
|
||||
|
||||
[](http://www.youtube.com/watch?v=jddccDuVd3E)
|
||||
|
||||
|
||||
### Update 7 - New Grid Layout
|
||||
|
||||
**11 July 2021**
|
||||
|
||||
Added a new layout system modelled on CSS grid. The example demostrates how once created a grid will adapt to the available space.
|
||||
Added a new layout system modelled on CSS grid. The example demonstrates how once created a grid will adapt to the available space.
|
||||
|
||||
[](http://www.youtube.com/watch?v=Zh9CEvu73jc)
|
||||
|
||||
## Update 8 - Tree control and scroll views
|
||||
|
||||
**6 Aug 2021**
|
||||
|
||||
Added a tree control and refactored the renderer to allow for widgets within a scrollable veiew
|
||||
|
||||
[](http://www.youtube.com/watch?v=J-dzzD6NQJ4)
|
||||
|
||||
@@ -7,4 +7,4 @@ class ColorChanger(App):
|
||||
self.background = f"on color({event.key})"
|
||||
|
||||
|
||||
ColorChanger.run()
|
||||
ColorChanger.run(log="textual.log")
|
||||
|
||||
16
docs/examples/widgets/placeholders.py
Normal file
16
docs/examples/widgets/placeholders.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from textual import events
|
||||
from textual.app import App
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
|
||||
class SimpleApp(App):
|
||||
"""Demonstrates smooth animation"""
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
"""Build layout here."""
|
||||
|
||||
await self.view.dock(Placeholder(), edge="left", size=40)
|
||||
await self.view.dock(Placeholder(), Placeholder(), edge="top")
|
||||
|
||||
|
||||
SimpleApp.run(log="textual.log")
|
||||
@@ -1,5 +1,9 @@
|
||||
# Examples
|
||||
|
||||
Run any of these examples to demonstrate a features.
|
||||
Run any of these examples to demonstrate a Textual features.
|
||||
|
||||
These examples may not be feature complete, but they should be somewhat useful and a good starting point for your own code.
|
||||
The example code will generate a log file called "textual.log". Tail this file to gain insight in to what Textual is doing.
|
||||
|
||||
```
|
||||
tail -f textual
|
||||
```
|
||||
|
||||
@@ -11,9 +11,6 @@ class GridTest(App):
|
||||
|
||||
grid = await self.view.dock_grid(edge="left", size=70, name="left")
|
||||
|
||||
# self.view["left"].scroll_y = 5
|
||||
# self.view["left"].scroll_x = 5
|
||||
|
||||
grid.add_column(fraction=1, name="left", min_size=20)
|
||||
grid.add_column(size=30, name="center")
|
||||
grid.add_column(fraction=1, name="right")
|
||||
|
||||
@@ -22,4 +22,4 @@ class GridTest(App):
|
||||
grid.place(*(Placeholder() for _ in range(20)), center=Placeholder())
|
||||
|
||||
|
||||
GridTest.run(title="Grid Test")
|
||||
GridTest.run(title="Grid Test", log="textual.log")
|
||||
|
||||
@@ -9,16 +9,22 @@ class MyApp(App):
|
||||
"""An example of a very simple Textual App"""
|
||||
|
||||
async def on_load(self, event: events.Load) -> None:
|
||||
"""Bind keys with the app loads (but before entering application mode)"""
|
||||
await self.bind("b", "view.toggle('sidebar')", "Toggle sidebar")
|
||||
await self.bind("q", "quit", "Quit")
|
||||
|
||||
async def on_mount(self, event: events.Mount) -> None:
|
||||
"""Create and dock the widgets."""
|
||||
|
||||
# A scrollview to contain the markdown file
|
||||
body = ScrollView()
|
||||
|
||||
# Header / footer / dock
|
||||
await self.view.dock(Header(), edge="top")
|
||||
await self.view.dock(Footer(), edge="bottom")
|
||||
await self.view.dock(Placeholder(), edge="left", size=30, name="sidebar")
|
||||
|
||||
# Dock the body in the remaining space
|
||||
await self.view.dock(body, edge="right")
|
||||
|
||||
async def get_markdown(filename: str) -> None:
|
||||
|
||||
BIN
imgs/widgets.png
Normal file
BIN
imgs/widgets.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 234 KiB |
@@ -112,12 +112,13 @@ class App(MessagePump):
|
||||
self.log_verbosity = log_verbosity
|
||||
|
||||
self.bindings.bind("ctrl+c", "quit", show=False)
|
||||
self._refresh_required = False
|
||||
|
||||
super().__init__()
|
||||
|
||||
title: Reactive[str] = Reactive("Textual")
|
||||
sub_title: Reactive[str] = Reactive("")
|
||||
background: Reactive[str] = Reactive("")
|
||||
background: Reactive[str] = Reactive("black")
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "title", self.title
|
||||
@@ -294,21 +295,11 @@ class App(MessagePump):
|
||||
if self.log_file is not None:
|
||||
self.log_file.close()
|
||||
|
||||
# def require_repaint(self) -> None:
|
||||
# self.refresh()
|
||||
|
||||
# def require_layout(self) -> None:
|
||||
# self.view.require_layout()
|
||||
|
||||
async def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
await self.post_message(events.Idle(self))
|
||||
await self.post_message(
|
||||
events.Callback(self, partial(callback, *args, **kwargs))
|
||||
)
|
||||
|
||||
# async def message_update(self, message: Message) -> None:
|
||||
# self.refresh()
|
||||
|
||||
def register(self, child: MessagePump, parent: MessagePump) -> bool:
|
||||
if child not in self.children:
|
||||
self.children.add(child)
|
||||
@@ -342,7 +333,7 @@ class App(MessagePump):
|
||||
console.print(Screen(Control.home(), self.view, Control.home()))
|
||||
if sync_available:
|
||||
console.file.write("\x1bP=2s\x1b\\")
|
||||
console.file.flush()
|
||||
console.file.flush()
|
||||
except Exception:
|
||||
self.panic()
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ class Null(Event, verbosity=3):
|
||||
@rich.repr.auto
|
||||
class Callback(Event, bubble=False, verbosity=3):
|
||||
def __init__(
|
||||
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]]
|
||||
self,
|
||||
sender: MessageTarget,
|
||||
callback: Callable[[], Awaitable[None]]
|
||||
) -> None:
|
||||
self.callback = callback
|
||||
super().__init__(sender)
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
)
|
||||
|
||||
from . import log
|
||||
from . import events
|
||||
|
||||
from ._types import MessageTarget
|
||||
@@ -66,16 +67,14 @@ class Reactive(Generic[ReactiveType]):
|
||||
def __set__(self, obj: Reactable, value: ReactiveType) -> None:
|
||||
|
||||
name = self.name
|
||||
internal_name = f"__{name}"
|
||||
current_value = getattr(obj, internal_name, None)
|
||||
current_value = getattr(obj, self.internal_name, None)
|
||||
validate_function = getattr(obj, f"validate_{name}", None)
|
||||
if callable(validate_function):
|
||||
value = validate_function(value)
|
||||
|
||||
if current_value != value or self._first:
|
||||
self._first = False
|
||||
setattr(obj, internal_name, value)
|
||||
|
||||
setattr(obj, self.internal_name, value)
|
||||
self.check_watchers(obj, name, current_value)
|
||||
|
||||
if self.layout:
|
||||
|
||||
@@ -47,6 +47,7 @@ class View(Widget):
|
||||
|
||||
async def watch_background(self, value: str) -> None:
|
||||
self.layout.background = value
|
||||
self.app.refresh()
|
||||
|
||||
scroll_x: Reactive[int] = Reactive(0)
|
||||
scroll_y: Reactive[int] = Reactive(0)
|
||||
|
||||
@@ -41,7 +41,6 @@ class WindowView(View, layout=VerticalLayout):
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
async def watch_virtual_size(self, size: Size) -> None:
|
||||
self.log("VIRTUAL SIZE CHANGE")
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
async def watch_scroll_x(self, value: int) -> None:
|
||||
@@ -53,7 +52,6 @@ class WindowView(View, layout=VerticalLayout):
|
||||
async def message_update(self, message: UpdateMessage) -> None:
|
||||
self.layout.require_update()
|
||||
await self.root_view.refresh_layout()
|
||||
# self.app.refresh()
|
||||
|
||||
async def on_resize(self, event: events.Resize) -> None:
|
||||
await self.emit(WindowChange(self))
|
||||
|
||||
@@ -51,7 +51,6 @@ class Placeholder(Widget, can_focus=True):
|
||||
self.has_focus = False
|
||||
|
||||
async def on_enter(self, event: events.Enter) -> None:
|
||||
self.log("ENTER", self)
|
||||
self.mouse_over = True
|
||||
|
||||
async def on_leave(self, event: events.Leave) -> None:
|
||||
|
||||
Reference in New Issue
Block a user