Merge branch 'main' into key-refactor

This commit is contained in:
Will McGugan
2022-12-21 14:07:18 +00:00
committed by GitHub
27 changed files with 539 additions and 30 deletions

View File

@@ -7,10 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.8.0] - Unreleased
### Fixed
### Fixed
- Fixed issues with nested auto dimensions https://github.com/Textualize/textual/issues/1402
- Fixed watch method incorrectly running on first set when value hasn't changed and init=False https://github.com/Textualize/textual/pull/1367
- `App.dark` can now be set from `App.on_load` without an error being raised https://github.com/Textualize/textual/issues/1369
### Added
@@ -25,6 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Deprecated `PRIORITY_BINDINGS` class variable.
- Renamed `char` to `character` on Key event.
- Renamed `key_name` to `name` on Key event.
- Moved Ctrl+C, tab, and shift+tab to App BINDINGS
- Queries/`walk_children` no longer includes self in results by default https://github.com/Textualize/textual/pull/1416
## [0.7.0] - 2022-12-17

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,171 @@
---
draft: true
date: 2022-12-20
categories:
- DevLog
authors:
- darrenburns
---
# A year of building for the terminal
I joined Textualize back in January 2022, and since then have been hard at work with the team on both [Rich](https://github.com/Textualize/rich) and [Textual](https://github.com/Textualize/textual).
Over the course of the year, Ive been able to work on a lot of really cool things.
In this post, Ill review a subset of the more interesting and visual stuff Ive built. If youre into terminals and command line tooling, youll hopefully see at least one thing of interest!
<!-- more -->
## A file manager powered by Textual
Ive been slowly developing a file manager as a “dogfooding” project for Textual. It takes inspiration from tools such as Ranger and Midnight Commander.
![Untitled](../images/darren-year-in-review/Untitled.png)
As of December 2022, it lets you browse your file system, filtering, multi-selection, creating and deleting files/directories, opening files in your `$EDITOR` and more.
Im happy with how far this project has come — I think its a good example of the type of powerful application that can be built with Textual with relatively little code. Ive been able to focus on *features*, instead of worrying about terminal emulator implementation details.
![filemanager-trimmed.gif](../images/darren-year-in-review/filemanager-trimmed.gif)
The project is available [on GitHub](https://github.com/darrenburns/kupo).
## Better diffs in the terminal
Diffs in the terminal are often difficult to read at a glance. I wanted to see how close I could get to achieving a diff display of a quality similar to that found in the GitHub UI.
To attempt this, I built a tool called [Dunk](https://github.com/darrenburns/dunk). Its a command line program which you can pipe your `git diff` output into, and itll convert it into something which I find much more readable.
![Untitled](../images/darren-year-in-review/Untitled%201.png)
Although Im not particularly proud of the code - there are a lot of “hacks” going on, but Im proud of the result. If anything, it shows what can be achieved for tools like this.
For many diffs, the difference between running `git diff` and `git diff | dunk | less -R` is night and day.
![Untitled](../images/darren-year-in-review/Untitled%202.png)
Itd be interesting to revisit this at some point.
It has its issues, but Id love to see how it can be used alongside Textual to build a terminal-based diff/merge tool. Perhaps it could be combined with…
## Code editor floating gutter
This is a common feature in text editors and IDEs: when you scroll to the right, you should still be able to see what line youre on. Out of interest, I tried to recreate the effect in the terminal using Textual.
![floating-gutter.gif](../images/darren-year-in-review/floating-gutter.gif)
Textual CSS offers a `dock` property which allows you to attach a widget to an edge of its parent.
By creating a widget that contains a vertical list of numbers and setting the `dock` property to `left`, we can create a floating gutter effect.
Then, we just need to keep the `scroll_y` in sync between the gutter and the content to ensure the line numbers stay aligned.
## Dropdown autocompletion menu
While working on [Shira](https://github.com/darrenburns/shira) (a proof-of-concept, terminal-based Python object explorer), I wrote some autocompleting dropdown functionality.
![shira-demo.gif](../images/darren-year-in-review/shira-demo.gif)
Textual forgoes the z-index concept from browser CSS and instead uses a “named layer” system. Using the `layers` property you can defined an ordered list of named layers, and using the `layer` property, you can assign a descendant widget to one of those layers.
By creating a new layer above all others and assigning a widget to that layer, we can ensure that widget is painted above everything else.
In order to determine where to place the dropdown, we can track the current value in the dropdown by `watch`ing the reactive input “value” inside the Input widget. This method will be called every time the `value` of the Input changes, and we can use this hook to amend the position of our dropdown position to accommodate for the length of the input value.
![Untitled](../images/darren-year-in-review/Untitled%203.png)
Ive now extracted this into a separate library called [textual-autocomplete](https://github.com/darrenburns/textual-autocomplete).
## Tabs with animated underline
The aim here was to create a tab widget with underlines that animates smoothly as another tab is selected.
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/tabs-textual-video-demo.mp4" type="video/mp4"></video>
The difficulty with implementing something like this is that we dont have pixel-perfect resolution when animating - a terminal window is just a big grid of fixed-width character cells.
![Untitled](../images/darren-year-in-review/Untitled%204.png){ align=right width=250 }
However, when animating things in a terminal, we can often achieve better granularity using Unicode related tricks. In this case, instead of shifting the bar along one whole cell, we adjust the endings of the bar to be a character which takes up half of a cell.
The exact characters that form the bar are "╺", "━" and "╸". When the bar sits perfectly within cell boundaries, every character is “━”. As it travels over a cell boundary, the left and right ends of the bar are updated to "╺" and "╸" respectively.
## Snapshot testing for terminal apps
One of the great features we added to Rich this year was the ability to export console contents to an SVG. This feature was later exposed to Textual, allowing users to capture screenshots of their running Textual apps.
Ultimately, I ended up creating a tool for snapshot testing in the Textual codebase.
Snapshot testing is used to ensure that Textual output doesnt unexpectedly change. On disk, we store what we expect the output to look like. Then, when we run our unit tests, we get immediately alerted if the output has changed.
This essentially automates the process of manually spinning up several apps and inspecting them for unexpected visual changes. Its great for catching subtle regressions!
In Textual, each CSS property has its own canonical example and an associated snapshot test.
If we accidentally break a property in a way that affects the visual output, the chances of it sneaking into a release are greatly reduced, because the corresponding snapshot test will fail.
As part of this work, I built a web interface for comparing snapshots with test output.
Theres even a little toggle which highlights the differences, since theyre sometimes rather subtle.
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/Screen_Recording_2022-12-14_at_14.08.15.mov" type="video/mp4"></video>
Since the terminal output shown in the video above is just an SVG image, I was able to add the "Show difference" functionality
by overlaying the two images and applying a single CSS property: `mix-blend-mode: difference;`.
The snapshot testing functionality itself is implemented as a pytest plugin, and it builds on top of a snapshot testing framework called [syrupy](https://github.com/tophat/syrupy).
![Screenshot 2022-09-16 at 15.52.03.png](..%2Fimages%2Fdarren-year-in-review%2FScreenshot%202022-09-16%20at%2015.52.03.png)
It's quite likely that this will eventually be exposed to end-users of Textual.
## Demonstrating animation
I built an example app to demonstrate how to animate in Textual and the available easing functions.
<video style="position: relative; width: 100%;" controls loop><source src="../../../../images/darren-year-in-review/animation-easing-example.mov" type="video/mp4"></video>
The smoothness here is achieved using tricks similar to those used in the tabs I discussed earlier.
In fact, the bar that animates in the video above is the same Rich renderable that is used by Textual's scrollbars.
You can play with this app by running `textual easing`. Please use animation sparingly.
## Developer console
When developing terminal based applications, performing simple debugging using `print` can be difficult, since the terminal is in application mode.
A project I worked on earlier in the year to improve the situation was the Textual developer console, which you can launch with `textual console`.
<div>
<figure markdown>
<img src="../../../../images/darren-year-in-review/devtools.png">
<figcaption>On the right, <a href="https://twitter.com/davepdotorg">Dave's</a> 5x5 Textual app. On the left, the Textual console.</figcaption>
</figure>
</div>
Then, by running a Textual application with the `--dev` flag, all standard output will be redirected to it.
This means you can use the builtin `print` function and still immediately see the output.
Textual itself also writes information to this console, giving insight into the messages that are flowing through an application.
## Pixel art
Cells in the terminal are roughly two times taller than they are wide. This means, that two horizontally adjacent cells form an approximate square.
Using this fact, I wrote a simple library based on Rich and PIL which can convert an image file into terminal output.
You can find the library, `rich-pixels`, [on GitHub](https://github.com/darrenburns/rich-pixels).
Its particularly good for displaying simple pixel art images. The SVG image below is also a good example of the SVG export functionality I touched on earlier.
<div>
--8<-- "docs/blog/images/darren-year-in-review/bulbasaur.svg"
</div>
Since the library generates an object which is renderable using Rich, these can easily be embedded inside Textual applications.
Here's an example of that in a scrapped "Pokédex" app I threw together:
<video style="position: relative; width: 100%;" controls autoplay loop><source src="../../../../images/darren-year-in-review/pokedex-terminal.mov" type="video/mp4"></video>
This is a rather naive approach to the problem... but I did it for fun!
Other methods for displaying images in the terminal include:
- A more advanced library like [chafa](https://github.com/hpjansson/chafa), which uses a range of Unicode characters to achieve a more accurate representation of the image.
- One of the available terminal image protocols, such as Sixel, Kittys Terminal Graphics Protocol, and iTerm Inline Images Protocol.
<hr>
That was a whirlwind tour of just some of the projects I tackled in 2022.
If you found it interesting, be sure to [follow me on Twitter](https://twitter.com/_darrenburns).
I don't post often, but when I do, it's usually about things similar to those I've discussed here.

View File

@@ -1,4 +1,5 @@
import json
from pathlib import Path
from rich.text import Text
@@ -64,7 +65,8 @@ class TreeApp(App):
def on_mount(self) -> None:
"""Load some JSON when the app starts."""
with open("food.json") as data_file:
file_path = Path(__file__).parent / "food.json"
with open(file_path) as data_file:
self.json_data = json.load(data_file)
def action_add(self) -> None:

View File

@@ -0,0 +1,34 @@
---
title: "How do I center a widget in a screen?"
alt_titles:
- "centre a widget"
- "center a control"
- "centre a control"
---
To center a widget within a container use
[`align`](https://textual.textualize.io/styles/align/). But remember that
`align` works on the *children* of a container, it isn't something you use
on the child you want centered.
For example, here's an app that shows a `Button` in the middle of a
`Screen`:
```python
from textual.app import App, ComposeResult
from textual.widgets import Button
class ButtonApp(App):
CSS = """
Screen {
align: center middle;
}
"""
def compose(self) -> ComposeResult:
yield Button("PUSH ME!")
if __name__ == "__main__":
ButtonApp().run()
```

View File

@@ -0,0 +1,38 @@
---
title: "How do I pass arguments to an app?"
alt_titles:
- "pass arguments to an application"
- "pass parameters to an app"
- "pass parameters to an application"
---
When creating your `App` class, override `__init__` as you would when
inheriting normally. For example:
```python
from textual.app import App, ComposeResult
from textual.widgets import Static
class Greetings(App[None]):
def __init__(self, greeting: str="Hello", to_greet: str="World") -> None:
self.greeting = greeting
self.to_greet = to_greet
super().__init__()
def compose(self) -> ComposeResult:
yield Static(f"{self.greeting}, {self.to_greet}")
```
Then the app can be run, passing in various arguments; for example:
```python
# Running with default arguments.
Greetings().run()
# Running with a keyword arguyment.
Greetings(to_greet="davep").run()
# Running with both positional arguments.
Greetings("Well hello", "there").run()
```

View File

@@ -476,7 +476,14 @@ class App(Generic[ReturnType], DOMNode):
"""Watches the dark bool."""
self.set_class(dark, "-dark-mode")
self.set_class(not dark, "-light-mode")
self.refresh_css()
try:
self.refresh_css()
except ScreenStackError:
# It's possible that `dark` can be set before we have a default
# screen, in an app's `on_load`, for example. So let's eat the
# ScreenStackError -- the above styles will be handled once the
# screen is spun up anyway.
pass
def get_driver_class(self) -> Type[Driver]:
"""Get a driver class for this platform.
@@ -1487,7 +1494,7 @@ class App(Generic[ReturnType], DOMNode):
nodes: set[DOMNode] = {
child
for node in self._require_stylesheet_update
for child in node.walk_children()
for child in node.walk_children(with_self=True)
}
self._require_stylesheet_update.clear()
self.stylesheet.update_nodes(nodes, animate=True)

View File

@@ -25,14 +25,14 @@ def match(selector_sets: Iterable[SelectorSet], node: DOMNode) -> bool:
def _check_selectors(selectors: list[Selector], css_path_nodes: list[DOMNode]) -> bool:
"""Match a list of selectors against a node.
"""Match a list of selectors against DOM nodes.
Args:
selectors (list[Selector]): A list of selectors.
node (DOMNode): A DOM node.
css_path_nodes (list[DOMNode]): The DOM nodes to check the selectors against.
Returns:
bool: True if the node matches the selector.
bool: True if any node in css_path_nodes matches a selector.
"""
DESCENDENT = CombinatorType.DESCENDENT

View File

@@ -490,7 +490,7 @@ class Stylesheet:
animate (bool, optional): Enable CSS animation. Defaults to False.
"""
self.update_nodes(root.walk_children(), animate=animate)
self.update_nodes(root.walk_children(with_self=True), animate=animate)
def update_nodes(self, nodes: Iterable[DOMNode], animate: bool = False) -> None:
"""Update styles for nodes.

View File

@@ -612,7 +612,7 @@ class DOMNode(MessagePump):
"""Reset styles back to their initial state"""
from .widget import Widget
for node in self.walk_children():
for node in self.walk_children(with_self=True):
node._css_styles.reset()
if isinstance(node, Widget):
node._set_dirty()
@@ -645,7 +645,7 @@ class DOMNode(MessagePump):
self,
filter_type: type[WalkType],
*,
with_self: bool = True,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[WalkType]:
@@ -655,7 +655,7 @@ class DOMNode(MessagePump):
def walk_children(
self,
*,
with_self: bool = True,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[DOMNode]:
@@ -665,16 +665,16 @@ class DOMNode(MessagePump):
self,
filter_type: type[WalkType] | None = None,
*,
with_self: bool = True,
with_self: bool = False,
method: WalkMethod = "depth",
reverse: bool = False,
) -> list[DOMNode] | list[WalkType]:
"""Generate descendant nodes.
"""Walk the subtree rooted at this node, and return every descendant encountered in a list.
Args:
filter_type (type[WalkType] | None, optional): Filter only this type, or None for no filter.
Defaults to None.
with_self (bool, optional): Also yield self in addition to descendants. Defaults to True.
with_self (bool, optional): Also yield self in addition to descendants. Defaults to False.
method (Literal["breadth", "depth"], optional): One of "depth" or "breadth". Defaults to "depth".
reverse (bool, optional): Reverse the order (bottom up). Defaults to False.

44
tests/test_dark_toggle.py Normal file
View File

@@ -0,0 +1,44 @@
from textual.app import App
class OnLoadDarkSwitch(App[None]):
"""App for testing toggling dark mode in on_load."""
def on_load(self) -> None:
self.dark = not self.dark
async def test_toggle_dark_on_load() -> None:
"""It should be possible to toggle dark mode in on_load."""
async with OnLoadDarkSwitch().run_test() as pilot:
assert not pilot.app.dark
class OnMountDarkSwitch(App[None]):
"""App for testing toggling dark mode in on_mount."""
def on_mount(self) -> None:
self.dark = not self.dark
async def test_toggle_dark_on_mount() -> None:
"""It should be possible to toggle dark mode in on_mount."""
async with OnMountDarkSwitch().run_test() as pilot:
assert not pilot.app.dark
class ActionDarkSwitch(App[None]):
"""App for testing toggling dark mode from an action."""
BINDINGS = [("d", "toggle", "Toggle Dark Mode")]
def action_toggle(self) -> None:
self.dark = not self.dark
async def test_toggle_dark_in_action() -> None:
"""It should be possible to toggle dark mode with an action."""
async with OnMountDarkSwitch().run_test() as pilot:
await pilot.press("d")
await pilot.pause(2 / 100)
assert not pilot.app.dark

View File

@@ -1,5 +1,7 @@
import pytest
from textual.app import App, ComposeResult
from textual.containers import Container
from textual.widget import Widget
from textual.css.query import InvalidQueryFormat, WrongType, NoMatches, TooManyMatches
@@ -50,17 +52,16 @@ def test_query():
assert list(app.query(".frob")) == []
assert list(app.query("#frob")) == []
assert app.query("App")
assert not app.query("NotAnApp")
assert list(app.query("App")) == [app]
assert list(app.query("App")) == [] # Root is not included in queries
assert list(app.query("#main")) == [main_view]
assert list(app.query("View#main")) == [main_view]
assert list(app.query("View2#help")) == [help_view]
assert list(app.query("#widget1")) == [widget1]
assert list(app.query("#Widget1")) == [] # Note case.
assert list(app.query("#Widget1")) == [] # Note case.
assert list(app.query("#widget2")) == [widget2]
assert list(app.query("#Widget2")) == [] # Note case.
assert list(app.query("#Widget2")) == [] # Note case.
assert list(app.query("Widget.float")) == [sidebar, tooltip, helpbar]
assert list(app.query(Widget).filter(".float")) == [sidebar, tooltip, helpbar]
@@ -110,10 +111,10 @@ def test_query():
tooltip,
]
assert list(app.query("App,View")) == [app, main_view, sub_view, help_view]
assert list(app.query("View")) == [main_view, sub_view, help_view]
assert list(app.query("#widget1, #widget2")) == [widget1, widget2]
assert list(app.query("#widget1 , #widget2")) == [widget1, widget2]
assert list(app.query("#widget1, #widget2, App")) == [app, widget1, widget2]
assert list(app.query("#widget1, #widget2, App")) == [widget1, widget2]
assert app.query(".float").first() == sidebar
assert app.query(".float").last() == helpbar
@@ -130,14 +131,13 @@ def test_query():
def test_query_classes():
class App(Widget):
pass
class ClassTest(Widget):
pass
CHILD_COUNT=100
CHILD_COUNT = 100
# Create a fake app to hold everything else.
app = App()
@@ -147,34 +147,35 @@ def test_query_classes():
app._add_child(ClassTest(id=f"child{n}"))
# Let's just be 100% sure everything was created fine.
assert len(app.query(ClassTest))==CHILD_COUNT
assert len(app.query(ClassTest)) == CHILD_COUNT
# Now, let's check there are *no* children with the test class.
assert len(app.query(".test"))==0
assert len(app.query(".test")) == 0
# Add the test class to everything and then check again.
app.query(ClassTest).add_class("test")
assert len(app.query(".test"))==CHILD_COUNT
assert len(app.query(".test")) == CHILD_COUNT
# Remove the test class from everything then try again.
app.query(ClassTest).remove_class("test")
assert len(app.query(".test"))==0
assert len(app.query(".test")) == 0
# Add the test class to everything using set_class.
app.query(ClassTest).set_class(True, "test")
assert len(app.query(".test"))==CHILD_COUNT
assert len(app.query(".test")) == CHILD_COUNT
# Remove the test class from everything using set_class.
app.query(ClassTest).set_class(False, "test")
assert len(app.query(".test"))==0
assert len(app.query(".test")) == 0
# Add the test class to everything using toggle_class.
app.query(ClassTest).toggle_class("test")
assert len(app.query(".test"))==CHILD_COUNT
assert len(app.query(".test")) == CHILD_COUNT
# Remove the test class from everything using toggle_class.
app.query(ClassTest).toggle_class("test")
assert len(app.query(".test"))==0
assert len(app.query(".test")) == 0
def test_invalid_query():
class App(Widget):
@@ -187,3 +188,26 @@ def test_invalid_query():
with pytest.raises(InvalidQueryFormat):
app.query("#foo").exclude("#2")
async def test_universal_selector_doesnt_select_self():
class ExampleApp(App):
def compose(self) -> ComposeResult:
yield Container(
Widget(
Widget(),
Widget(
Widget(),
),
),
Widget(),
id="root-container",
)
app = ExampleApp()
async with app.run_test():
container = app.query_one("#root-container", Container)
query = container.query("*")
results = list(query.results())
assert len(results) == 5
assert not any(node.id == "root-container" for node in results)