Merge branch 'main' of github.com:Textualize/textual into list-view

This commit is contained in:
Darren Burns
2022-11-09 09:44:57 +00:00
14 changed files with 125 additions and 38 deletions

View File

@@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.4.0] - Unreleased
## [0.4.0] - 2022-11-08
https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
### Changed
@@ -180,6 +182,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- New handler system for messages that doesn't require inheritance
- Improved traceback handling
[0.4.0]: https://github.com/Textualize/textual/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/Textualize/textual/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/Textualize/textual/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/Textualize/textual/compare/v0.1.18...v0.2.0

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -1,3 +1,2 @@
# Textual Blog
Welcome to the Textual blog, where we post about the latest releases and developments in the Textual world.

View File

@@ -0,0 +1,59 @@
---
draft: false
date: 2022-11-08
categories:
- Release
authors:
- willmcgugan
---
# Version 0.4.0
We've released version 0.4.0 of [Textual](https://pypi.org/search/?q=textual).
As this is the first post tagged with `release` let me first explain where the blog fits in with releases. We plan on doing a post for every note-worthy release. Which likely means all but the most trivial updates (typos just aren't that interesting). Blog posts will be supplementary to release notes which you will find on the [Textual repository](https://github.com/Textualize/textual).
Blog posts will give a little more background for the highlights in a release, and a rationale for changes and new additions. We embrace *building in public*, which means that we would like you to be as up-to-date with new developments as if you were sitting in our office. It's a small office, and you might not be a fan of the Scottish weather (it's [dreich](https://www.bbc.co.uk/news/uk-scotland-50476008)), but you can at least be here virtually.
<!-- more -->
Release 0.4.0 follows 0.3.0, released on October 31st. Here are the highlights of the update.
## Updated Mount Method
The [mount](/api/widget/#textual.widget.Widget.mount) method has seen some work. We've dropped the ability to assign an `id` via keyword attributes, which wasn't terribly useful. Now, an `id` must be assigned via the constructor.
The mount method has also grown `before` and `after` parameters which tell Textual where to add a new Widget (the default was to add it to the end). Here are a few examples:
```python
# Mount at the start
self.mount(Button(id="Buy Coffee"), before=0)
# Mount after a selector
self.mount(Static("Password is incorrect"), after="Dialog Input.-error")
# Mount after a specific widget
tweet = self.query_one("Tweet")
self.mount(Static("Consider switching to Mastodon"), after=tweet)
```
Textual needs much of the same kind of operations as the [JS API](https://developer.mozilla.org/en-US/docs/Web/API/Node/appendChild) exposed by the browser. But we are determined to make this way more intuitive. The new mount method is a step towards that.
## Faster Updates
Textual now writes to stdout in a thread. The upshot of this is that Textual can work on the next update before the terminal has displayed the previous frame.
This means smoother updates all round! You may notice this when scrolling and animating, but even if you don't, you will have more CPU cycles to play with in your Textual app.
<div class="excalidraw">
--8<-- "docs/blog/images/faster-updates.excalidraw.svg"
</div>
## Multiple CSS Paths
Up to version 0.3.0, Textual would only read a single CSS file set in the `CSS_PATH` class variable. You can now supply a list of paths if you have more than one CSS file.
This change was prompted by [tuilwindcss](https://github.com/koaning/tuilwindcss/) which brings a TailwindCSS like approach to building Textual Widgets. Also check out [calmcode.io](https://calmcode.io/) by the same author, which is an amazing resource.

View File

@@ -15,7 +15,6 @@
<meta property="og:url" content="{{ page.canonical_url | url }}">
<meta property="og:site_name" content="Textual Documentation">
<meta property="og:description" content="Textual is a TUI framework for Python, inspired by modern web development.">
<meta property="og:image" content="https://raw.githubusercontent.com/Textualize/textual/main/imgs/textual.png">
{% endblock %}

View File

@@ -70,7 +70,7 @@ Textual is a framework for building applications that run within your terminal.
```
```{.textual path="docs/examples/events/dictionary.py" columns="100" lines="30" press="tab,_,t,e,x,t,_,_,_,_,_,_,_,_,_,_,_,_,_"}
```{.textual path="docs/examples/events/dictionary.py" columns="100" lines="30" press="tab,_,t,e,x,t,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_,_"}
```

View File

@@ -13,11 +13,11 @@ h3 .doc-heading code {
monospace;
}
body[data-md-color-primary="indigo"] .excalidraw svg {
body[data-md-color-primary="black"] .excalidraw svg {
filter: invert(100%) hue-rotate(180deg);
}
body[data-md-color-primary="indigo"] .excalidraw svg rect {
body[data-md-color-primary="black"] .excalidraw svg rect {
fill: transparent;
}

View File

@@ -176,7 +176,7 @@ theme:
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: indigo
primary: black
toggle:
icon: material/weather-night
name: Switch to light mode
@@ -190,6 +190,7 @@ plugins:
as_creation: date
categories:
- categories
- release
- tags
- search:
- autorefs:

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.4.0a3"
version = "0.4.0"
homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"]

View File

@@ -32,7 +32,7 @@ class Pilot:
"""Simulate key-presses.
Args:
*key: Keys to press.
*keys: Keys to press.
"""
if keys:

View File

@@ -68,6 +68,7 @@ class Reactive(Generic[ReactiveType]):
layout (bool, optional): Perform a layout on change. Defaults to False.
repaint (bool, optional): Perform a repaint on change. Defaults to True.
always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False.
Returns:
Reactive: A Reactive instance which calls watchers or initialize.
"""

View File

@@ -147,6 +147,14 @@ class RenderCache(NamedTuple):
lines: Lines
class WidgetError(Exception):
"""Base widget error."""
class MountError(WidgetError):
"""Error raised when there was a problem with the mount request."""
@rich.repr.auto
class Widget(DOMNode):
"""
@@ -233,6 +241,10 @@ class Widget(DOMNode):
id=id,
classes=self.DEFAULT_CLASSES if classes is None else classes,
)
if self in children:
raise WidgetError("A widget can't be its own parent")
self._add_children(*children)
virtual_size = Reactive(Size(0, 0), layout=True)
@@ -374,9 +386,6 @@ class Widget(DOMNode):
if self._scrollbar_corner is not None:
yield self._scrollbar_corner
class MountError(Exception):
"""Error raised when there was a problem with the mount request."""
def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]:
"""Attempt to locate the point where the caller wants to mount something.
@@ -387,7 +396,7 @@ class Widget(DOMNode):
tuple[Widget, int]: The parent and the location in its child list.
Raises:
Widget.MountError: If there was an error finding where to mount a widget.
MountError: If there was an error finding where to mount a widget.
The rules of this method are:
@@ -414,7 +423,7 @@ class Widget(DOMNode):
# have a parent? There's no way we can use it as a sibling to make
# mounting decisions if it doesn't have a parent.
if spot.parent is None:
raise self.MountError(
raise MountError(
f"Unable to find relative location of {spot!r} because it has no parent"
)
@@ -424,7 +433,7 @@ class Widget(DOMNode):
try:
return spot.parent, spot.parent.children.index(spot)
except ValueError:
raise self.MountError(f"{spot!r} is not a child of {self!r}") from None
raise MountError(f"{spot!r} is not a child of {self!r}") from None
def mount(
self,
@@ -452,7 +461,7 @@ class Widget(DOMNode):
# Saying you want to mount before *and* after something is an error.
if before is not None and after is not None:
raise self.MountError(
raise MountError(
"Only one of `before` or `after` can be handled -- not both"
)

View File

@@ -1,6 +1,6 @@
import pytest
from textual.widget import Widget
from textual.widget import Widget, MountError
class Content(Widget):
@@ -36,5 +36,5 @@ def test_find_dom_spot():
# Finally, let's be sure that we get an error if, for some odd reason,
# we go looking for a widget that isn't actually part of the DOM we're
# looking in.
with pytest.raises(Widget.MountError):
with pytest.raises(MountError):
_ = screen._find_mount_point(Widget())

View File

@@ -1,7 +1,14 @@
import pytest
from textual.app import App
from textual.widget import Widget, WidgetError, MountError
from textual.widgets import Static
from textual.css.query import TooManyMatches
class SelfOwn(Widget):
"""Test a widget that tries to own itself."""
def __init__(self) -> None:
super().__init__(self)
async def test_mount_via_app() -> None:
"""Perform mount tests via the app."""
@@ -9,6 +16,10 @@ async def test_mount_via_app() -> None:
# Make a background set of widgets.
widgets = [Static(id=f"starter-{n}") for n in range( 10 )]
async with App().run_test() as pilot:
with pytest.raises(WidgetError):
await pilot.app.mount(SelfOwn())
async with App().run_test() as pilot:
# Mount the first one and make sure it's there.
await pilot.app.mount(widgets[0])
@@ -82,32 +93,21 @@ async def test_mount_via_app() -> None:
async with App().run_test() as pilot:
# Make sure we get told off for trying to before and after.
await pilot.app.mount_all(widgets)
with pytest.raises(Static.MountError):
with pytest.raises(MountError):
await pilot.app.mount(Static(), before=2, after=2)
async with App().run_test() as pilot:
# Make sure we get told off trying to mount relative to something
# that isn't actually in the DOM.
await pilot.app.mount_all(widgets)
with pytest.raises(Static.MountError):
with pytest.raises(MountError):
await pilot.app.mount(Static(), before=Static())
with pytest.raises(Static.MountError):
with pytest.raises(MountError):
await pilot.app.mount(Static(), after=Static())
# TODO: At the moment query_one() simply takes a query and returns the
# .first() item. As such doing a query_one() that gets more than one
# thing isn't an error, it just skims off the first thing. OTOH the
# intention of before= and after= with a selector is that an exception
# will be thrown -- the exception being the own that should be thrown
# from query_one(). So, this test here is a TODO test because we'll wait
# for a change to query_one() and then its exception will just bubble
# up.
#
# See https://github.com/Textualize/textual/issues/1096
#
# async with App().run_test() as pilot:
# # Make sure we get an error if we try and mount with a selector that
# # results in more than one hit.
# await pilot.app.mount_all(widgets)
# with pytest.raises( ?Something? ):
# await pilot.app.mount(Static(), before="Static")
async with App().run_test() as pilot:
# Make sure we get an error if we try and mount with a selector that
# results in more than one hit.
await pilot.app.mount_all(widgets)
with pytest.raises(TooManyMatches):
await pilot.app.mount(Static(), before="Static")