From c881a9657fe3c967139c07ce45673ece6a2e7221 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:03:24 +0000 Subject: [PATCH 01/43] Add a Label widget For the moment this does nothing more than inherit from a Static; but what it does do is make it easier for someone to add text to their application and to style it by styling all the Labels. Before now it would be common to use a Static but if you try and style (or query) all Statics, you'd also get things like Buttons, which inherit from Static. See #1190 --- CHANGELOG.md | 1 + examples/five_by_five.py | 16 ++++++++-------- src/textual/widgets/__init__.py | 2 ++ src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_label.py | 7 +++++++ 5 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 src/textual/widgets/_label.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fdb331..39bd22dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 +- Added a `Label` widget https://github.com/Textualize/textual/issues/1190 ### Changed diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 20bbea80f..f4574a8a4 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -13,7 +13,7 @@ from textual.containers import Horizontal from textual.app import App, ComposeResult from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Footer, Button, Static +from textual.widgets import Footer, Button, Label from textual.css.query import DOMQuery from textual.reactive import reactive from textual.binding import Binding @@ -33,10 +33,10 @@ class Help(Screen): Returns: ComposeResult: The result of composing the help screen. """ - yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) + yield Label(Markdown(Path(__file__).with_suffix(".md").read_text())) -class WinnerMessage(Static): +class WinnerMessage(Label): """Widget to tell the user they have won.""" MIN_MOVES: Final = 14 @@ -91,9 +91,9 @@ class GameHeader(Widget): ComposeResult: The result of composing the game header. """ yield Horizontal( - Static(self.app.title, id="app-title"), - Static(id="moves"), - Static(id="progress"), + Label(self.app.title, id="app-title"), + Label(id="moves"), + Label(id="progress"), ) def watch_moves(self, moves: int): @@ -102,7 +102,7 @@ class GameHeader(Widget): Args: moves (int): The number of moves made. """ - self.query_one("#moves", Static).update(f"Moves: {moves}") + self.query_one("#moves", Label).update(f"Moves: {moves}") def watch_filled(self, filled: int): """Watch the on-count reactive and update when it changes. @@ -110,7 +110,7 @@ class GameHeader(Widget): Args: filled (int): The number of cells that are currently on. """ - self.query_one("#progress", Static).update(f"Filled: {filled}") + self.query_one("#progress", Label).update(f"Filled: {filled}") class GameCell(Button): diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index c42b011a9..8cbcb9f3b 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -15,6 +15,7 @@ if typing.TYPE_CHECKING: from ._directory_tree import DirectoryTree from ._footer import Footer from ._header import Header + from ._label import Label from ._placeholder import Placeholder from ._pretty import Pretty from ._static import Static @@ -30,6 +31,7 @@ __all__ = [ "DirectoryTree", "Footer", "Header", + "Label", "Placeholder", "Pretty", "Static", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 5ceb01835..57f87df82 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -5,6 +5,7 @@ from ._checkbox import Checkbox as Checkbox from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header +from ._label import Label as Label from ._placeholder import Placeholder as Placeholder from ._pretty import Pretty as Pretty from ._static import Static as Static diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py new file mode 100644 index 000000000..a3adba462 --- /dev/null +++ b/src/textual/widgets/_label.py @@ -0,0 +1,7 @@ +"""Provides a simple Label widget.""" + +from ._static import Static + + +class Label(Static): + """A simple label widget for displaying text-oriented rendenrables.""" From 22863148ef628d9c3d9dbe1d8527f98b2e156e2f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:09:27 +0000 Subject: [PATCH 02/43] Update src/textual/widgets/_label.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py index a3adba462..df519ae4f 100644 --- a/src/textual/widgets/_label.py +++ b/src/textual/widgets/_label.py @@ -4,4 +4,4 @@ from ._static import Static class Label(Static): - """A simple label widget for displaying text-oriented rendenrables.""" + """A simple label widget for displaying text-oriented renderables.""" From e3899c0c106147e8e67845b8be1942bab579c141 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:41:48 +0000 Subject: [PATCH 03/43] Check button variant for validity during button construction See #1189 --- CHANGELOG.md | 1 + src/textual/widgets/_button.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fdb331..c217b6999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 - Widget.call_later has been renamed to Widget.call_after_refresh. +- Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189 ### Fixed diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index b394762d8..aa0f476fc 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -186,7 +186,7 @@ class Button(Static, can_focus=True): if disabled: self.add_class("-disabled") - self.variant = variant + self.variant = self.validate_variant(variant) label: Reactive[RenderableType] = Reactive("") variant = Reactive.init("default") From e32e094b9265d1690a6e4df3e1964161ed69d70f Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 15:47:48 +0000 Subject: [PATCH 04/43] Support callables in App.SCREENS (#1185) * Support Type[Screen] in App.SCREENS (lazy screens) * Update CHANGELOG * Remove redundant isinstance --- CHANGELOG.md | 1 + src/textual/app.py | 12 ++++++++---- tests/test_screens.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c300711d7..7148331ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 +- Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index b5526f73c..e74a90f8a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -25,6 +25,7 @@ from typing import ( TypeVar, Union, cast, + Callable, ) from weakref import WeakSet, WeakValueDictionary @@ -228,7 +229,7 @@ class App(Generic[ReturnType], DOMNode): } """ - SCREENS: dict[str, Screen] = {} + SCREENS: dict[str, Screen | Callable[[], Screen]] = {} _BASE_PATH: str | None = None CSS_PATH: CSSPathType = None TITLE: str | None = None @@ -330,7 +331,7 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() self._installed_screens: WeakValueDictionary[ - str, Screen + str, Screen | Callable[[], Screen] ] = WeakValueDictionary() self._installed_screens.update(**self.SCREENS) @@ -998,12 +999,15 @@ class App(Generic[ReturnType], DOMNode): next_screen = self._installed_screens[screen] except KeyError: raise KeyError(f"No screen called {screen!r} installed") from None + if callable(next_screen): + next_screen = next_screen() + self._installed_screens[screen] = next_screen else: next_screen = screen return next_screen def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: - """Get an installed screen and a await mount object. + """Get an installed screen and an AwaitMount object. If the screen isn't running, it will be registered before it is run. @@ -1558,7 +1562,7 @@ class App(Generic[ReturnType], DOMNode): # Close pre-defined screens for screen in self.SCREENS.values(): - if screen._running: + if isinstance(screen, Screen) and screen._running: await self._prune_node(screen) # Close any remaining nodes diff --git a/tests/test_screens.py b/tests/test_screens.py index 0841faf51..707bad5df 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -11,8 +11,37 @@ skip_py310 = pytest.mark.skipif( ) +async def test_installed_screens(): + class ScreensApp(App): + SCREENS = { + "home": Screen, # Screen type + "one": Screen(), # Screen instance + "two": lambda: Screen() # Callable[[], Screen] + } + + app = ScreensApp() + async with app.run_test() as pilot: + pilot.app.push_screen("home") # Instantiates and pushes the "home" screen + pilot.app.push_screen("one") # Pushes the pre-instantiated "one" screen + pilot.app.push_screen("home") # Pushes the single instance of "home" screen + pilot.app.push_screen("two") # Calls the callable, pushes returned Screen instance + + assert len(app.screen_stack) == 5 + assert app.screen_stack[1] is app.screen_stack[3] + assert app.screen is app.screen_stack[4] + assert isinstance(app.screen, Screen) + assert app.is_screen_installed(app.screen) + + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + with pytest.raises(ScreenStackError): + pilot.app.pop_screen() + + + @skip_py310 -@pytest.mark.asyncio async def test_screens(): app = App() From fd61ca69a4ed1c98b09482e5d162eee9a3251a5a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Nov 2022 17:02:50 +0000 Subject: [PATCH 05/43] Update comment.yml --- .github/workflows/comment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index 7191f1836..46cf1677f 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -13,6 +13,6 @@ jobs: with: issue-number: ${{ github.event.issue.number }} body: | - Did we solve your problem? - - Glad we could help! + Don't forget to [star](https://github.com/Textualize/textual) the repository! + + Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates. From e0ddea839de738c9cffc9ecc712a9cfd8e5c2543 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 21:48:30 +0000 Subject: [PATCH 06/43] Update changelog regarding horizontal width auto fix (#1192) Co-authored-by: Dave Pearson --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7394ea00..fb6eac1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 - Fixed issues with animation. Now objects of different types may be animated. - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 +- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 ## [0.4.0] - 2022-11-08 From 67386478effd65a52ffea7f1207eab86d477a0d1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:14:55 +0000 Subject: [PATCH 07/43] Trailing whitespace squishing --- mkdocs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index e7b4e5f9e..8bf9c7f34 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -185,13 +185,13 @@ plugins: - blog: - rss: - match_path: blog/posts/.* + match_path: blog/posts/.* date_from_meta: as_creation: date categories: - categories - release - - tags + - tags - search: - autorefs: - mkdocstrings: @@ -215,10 +215,10 @@ extra_css: extra: social: - - icon: fontawesome/brands/twitter + - icon: fontawesome/brands/twitter link: https://twitter.com/textualizeio name: textualizeio on Twitter - - icon: fontawesome/brands/github + - icon: fontawesome/brands/github link: https://github.com/textualize/textual/ name: Textual on Github - icon: fontawesome/brands/discord From 8a6d21da5eefe751ef8c6e057e323a28c2a1579a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:15:35 +0000 Subject: [PATCH 08/43] Add the basics of Label docs for the manual --- docs/api/label.md | 1 + docs/examples/widgets/label.py | 12 ++++++++++++ docs/widgets/label.md | 33 +++++++++++++++++++++++++++++++++ docs/widgets/static.md | 5 +++-- mkdocs.yml | 2 ++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/api/label.md create mode 100644 docs/examples/widgets/label.py create mode 100644 docs/widgets/label.md diff --git a/docs/api/label.md b/docs/api/label.md new file mode 100644 index 000000000..eee506a2c --- /dev/null +++ b/docs/api/label.md @@ -0,0 +1 @@ +::: textual.widgets.Label diff --git a/docs/examples/widgets/label.py b/docs/examples/widgets/label.py new file mode 100644 index 000000000..43a50bb10 --- /dev/null +++ b/docs/examples/widgets/label.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label + + +class LabelApp(App): + def compose(self) -> ComposeResult: + yield Label("Hello, world!") + + +if __name__ == "__main__": + app = LabelApp() + app.run() diff --git a/docs/widgets/label.md b/docs/widgets/label.md new file mode 100644 index 000000000..d4c22feac --- /dev/null +++ b/docs/widgets/label.md @@ -0,0 +1,33 @@ +# Label + +A widget which displays static text, but which can also contain more complex Rich renderables. + +- [ ] Focusable +- [x] Container + +## Example + +The example below shows how you can use a `Label` widget to display some text. + +=== "Output" + + ```{.textual path="docs/examples/widgets/label.py"} + ``` + +=== "label.py" + + ```python + --8<-- "docs/examples/widgets/label.py" + ``` + +## Reactive Attributes + +This widget has no reactive attributes. + +## Messages + +This widget sends no messages. + +## See Also + +* [Label](../api/label.md) code reference diff --git a/docs/widgets/static.md b/docs/widgets/static.md index 342e2daf7..4d9fc994c 100644 --- a/docs/widgets/static.md +++ b/docs/widgets/static.md @@ -1,14 +1,14 @@ # Static A widget which displays static content. -Can be used for simple text labels, but can also contain more complex Rich renderables. +Can be used for Rich renderables and can also for the base for other types of widgets. - [ ] Focusable - [x] Container ## Example -The example below shows how you can use a `Static` widget as a simple text label. +The example below shows how you can use a `Static` widget as a simple text label (but see [Label](./label.md) as a way of displaying text). === "Output" @@ -32,3 +32,4 @@ This widget sends no messages. ## See Also * [Static](../api/static.md) code reference +* [Label](./label.md) diff --git a/mkdocs.yml b/mkdocs.yml index 8bf9c7f34..332298e5d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -96,6 +96,7 @@ nav: - "widgets/footer.md" - "widgets/header.md" - "widgets/input.md" + - "widgets/label.md" - "widgets/static.md" - "widgets/tree_control.md" - API: @@ -109,6 +110,7 @@ nav: - "api/footer.md" - "api/geometry.md" - "api/header.md" + - "api/label.md" - "api/message_pump.md" - "api/message.md" - "api/pilot.md" From 29a891724be7975cdb06e0ff7fb7e55817edf13a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:37:30 +0000 Subject: [PATCH 09/43] Tidy up dead code from linux_driver.py --- src/textual/drivers/linux_driver.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index f0e75e71e..8f61c4fb2 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -236,17 +236,3 @@ class LinuxDriver(Driver): finally: with timer("selector.close"): selector.close() - - -if __name__ == "__main__": - from rich.console import Console - - console = Console() - - from ..app import App - - class MyApp(App): - async def on_mount(self, event: events.Mount) -> None: - self.set_timer(5, callback=self._close_messages) - - MyApp.run() From 5f4a44c6c64fa8521ca6224dec559561fdb997d3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:39:43 +0000 Subject: [PATCH 10/43] Fix the devtools example of how to run an app --- docs/guide/devtools.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index fa0d1e7e9..8643073ff 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -25,7 +25,7 @@ textual run my_app.py The `run` sub-command will first look for a `App` instance called `app` in the global scope of your Python file. If there is no `app`, it will create an instance of the first `App` class it finds and run that. -Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example: +Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example: ```bash textual run my_app.py:alternative_app @@ -119,6 +119,6 @@ class LogApp(App): self.log(self.tree) if __name__ == "__main__": - LogApp.run() + LogApp().run() ``` From 965cc7d19f8fd95d44b3c90f5607a8d0d6248773 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:44:08 +0000 Subject: [PATCH 11/43] Remove old test code from css/parse.py This is covered in unit tests these days. --- src/textual/css/parse.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index fdfca677a..1560a009f 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -366,26 +366,3 @@ def parse( is_default_rules=is_default_rules, tie_breaker=tie_breaker, ) - - -if __name__ == "__main__": - print(parse_selectors("Foo > Bar.baz { foo: bar")) - - css = """#something { - text: on red; - transition: offset 5.51s in_out_cubic; - offset-x: 100%; -} -""" - - from textual.css.stylesheet import Stylesheet, StylesheetParseError - from rich.console import Console - - console = Console() - stylesheet = Stylesheet() - try: - stylesheet.add_source(css) - except StylesheetParseError as e: - console.print(e.errors) - print(stylesheet) - print(stylesheet.css) From 93f952b74b165bb28bb4f6df188d75c374d04443 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:45:07 +0000 Subject: [PATCH 12/43] Remove old test code from css/scalar.py This is covered in unit tests these days. --- src/textual/css/scalar.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 8c5fa39b9..0fb4f874b 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -383,10 +383,3 @@ def percentage_string_to_float(string: str) -> float: else: float_percentage = float(string) return float_percentage - - -if __name__ == "__main__": - print(Scalar.parse("3.14fr")) - s = Scalar.parse("23") - print(repr(s)) - print(repr(s.cells)) From c8ae2455cf580de7456bccf69c671dec51bb5daf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:47:22 +0000 Subject: [PATCH 13/43] Remove old test code from css/styles.py This isn't fullt covered in unit tests yet, but the dead code can be removed and adding unit tests should likely be encouraged. --- src/textual/css/styles.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 17e5963a8..6b4dc1d7e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1037,25 +1037,3 @@ class RenderStyles(StylesBase): styles.merge(self._inline_styles) combined_css = styles.css return combined_css - - -if __name__ == "__main__": - styles = Styles() - - styles.display = "none" - styles.visibility = "hidden" - styles.border = ("solid", "rgb(10,20,30)") - styles.outline_right = ("solid", "red") - styles.text_style = "italic" - styles.dock = "bar" - styles.layers = "foo bar" - - from rich import print - - print(styles.text_style) - print(styles.text) - - print(styles) - print(styles.css) - - print(styles.extract_rules((0, 1, 0))) From f40c0bf3d0eaf1d8d920d4a65b86ad658c0ea8e5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:53:46 +0000 Subject: [PATCH 14/43] Remove old test code from css/tokenize.py This is covered in unit tests these days. --- src/textual/css/tokenize.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index dbec369df..e13820fd6 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -197,18 +197,3 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: name: list(tokenize_value(value, "__name__")) for name, value in values.items() } return value_tokens - - -if __name__ == "__main__": - from rich import print - - css = """#something { - - color: rgb(10,12,23) - } - """ - # transition: offset 500 in_out_cubic; - tokens = tokenize(css, __name__) - print(list(tokens)) - - print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"})) From 33eefd56cfdaec9beea254d45f4e0afe268a3a15 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:56:27 +0000 Subject: [PATCH 15/43] Remove old test code from renderables/blank.py This is covered in unit tests these days. --- src/textual/renderables/blank.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index b83dbbc34..05552ac2f 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -25,9 +25,3 @@ class Blank: for _ in range(height): yield segment yield line - - -if __name__ == "__main__": - from rich import print - - print(Blank("red")) From 37670578ff1a3e6ec7cb3d4362a77040ecebe1d1 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Thu, 17 Nov 2022 10:57:23 +0000 Subject: [PATCH 16/43] Ensure cursor visible when no placeholder in Input (#1202) * Ensure cursor visible when no placeholder in Input * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/textual/widgets/_input.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6eac1be..502d1fc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issues with animation. Now objects of different types may be animated. - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 - Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 +- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202 ## [0.4.0] - 2022-11-08 diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index c82e1074c..f01587120 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -176,6 +176,10 @@ class Input(Widget, can_focus=True): if self.has_focus: cursor_style = self.get_component_rich_style("input--cursor") if self._cursor_visible: + # If the placeholder is empty, there's no characters to stylise + # to make the cursor flash, so use a single space character + if len(placeholder) == 0: + placeholder = Text(" ") placeholder.stylize(cursor_style, 0, 1) return placeholder return _InputRenderable(self, self._cursor_visible) From 53b760eea2bb66f6525944aa041a954a86492e4c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:58:01 +0000 Subject: [PATCH 17/43] Remove old test code from renderables/gradient.py This isn't currently covered in unit tests but should be at some point? Either way, having a test in dead code in the library doesn't help much any more. --- src/textual/renderables/gradient.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py index 7d5fb3243..a0c5379fb 100644 --- a/src/textual/renderables/gradient.py +++ b/src/textual/renderables/gradient.py @@ -36,9 +36,3 @@ class VerticalGradient: ), ) yield Segment(f"{width * ' '}\n", line_color) - - -if __name__ == "__main__": - from rich import print - - print(VerticalGradient("red", "blue")) From 265f77097645d0b11aff102f256e625eca015b16 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:01:31 +0000 Subject: [PATCH 18/43] Remove old test code from case.py This is covered in unit tests these days. --- src/textual/case.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/textual/case.py b/src/textual/case.py index ba34e58a8..4ae4883f3 100644 --- a/src/textual/case.py +++ b/src/textual/case.py @@ -22,7 +22,3 @@ def camel_to_snake( return f"{lower}_{upper.lower()}" return _re_snake.sub(repl, name).lower() - - -if __name__ == "__main__": - print(camel_to_snake("HelloWorldEvent")) From 3b8b0ebeb2fa22b2899c01ff9e814be3abf1ec1d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:02:29 +0000 Subject: [PATCH 19/43] Remove old test code from design.py This is covered in unit tests these days. --- src/textual/design.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/textual/design.py b/src/textual/design.py index 2294de748..490aa9da2 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -222,11 +222,3 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table: table.add_column("Dark", justify="center") table.add_row(make_shades(light), make_shades(dark)) return table - - -if __name__ == "__main__": - from .app import DEFAULT_COLORS - - from rich import print - - print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])) From be0a268395ba533c4d571068f4bb17387a85f2f3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:04:01 +0000 Subject: [PATCH 20/43] Remove old test code from scrollbar.py This is covered in unit tests these days. --- src/textual/scrollbar.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 707cd67d2..f6d115f8a 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -320,18 +320,3 @@ class ScrollBarCorner(Widget): styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color) - - -if __name__ == "__main__": - from rich.console import Console - - console = Console() - - thickness = 2 - console.print(f"Bars thickness: {thickness}") - - console.print("Vertical bar:") - console.print(ScrollBarRender.render_bar(thickness=thickness)) - - console.print("Horizontal bar:") - console.print(ScrollBarRender.render_bar(vertical=False, thickness=thickness)) From e656284c57332e5a439f9a4d902d8866238fcd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 17 Nov 2022 11:21:39 +0000 Subject: [PATCH 21/43] Fix docstring for some widget optional arguments. (#1204) * Update _button.py * Update tabs.py --- src/textual/widgets/_button.py | 18 +++++++++--------- src/textual/widgets/tabs.py | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index aa0f476fc..719cbcb0d 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -168,9 +168,9 @@ class Button(Static, can_focus=True): """Create a Button widget. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. - variant (ButtonVariant): The variant of the button. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. + variant (ButtonVariant, optional): The variant of the button. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. @@ -267,8 +267,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating a success Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes(str | None, optional): The CSS classes of the button. @@ -298,8 +298,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating a warning Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. @@ -329,8 +329,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating an error Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 2b814d6a8..3a2af067d 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -177,16 +177,16 @@ class Tabs(Widget): Args: tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered. active_tab (str, optional): The name of the tab that should be active on first render. - active_tab_style (StyleType): Style to apply to the label of the active tab. - active_bar_style (StyleType): Style to apply to the underline of the active tab. - inactive_tab_style (StyleType): Style to apply to the label of inactive tabs. - inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs. - inactive_text_opacity (float): Opacity of the text labels of inactive tabs. - animation_duration (float): The duration of the tab change animation, in seconds. - animation_function (str): The easing function to use for the tab change animation. + active_tab_style (StyleType, optional): Style to apply to the label of the active tab. + active_bar_style (StyleType, optional): Style to apply to the underline of the active tab. + inactive_tab_style (StyleType, optional): Style to apply to the label of inactive tabs. + inactive_bar_style (StyleType, optional): Style to apply to the underline of inactive tabs. + inactive_text_opacity (float, optional): Opacity of the text labels of inactive tabs. + animation_duration (float, optional): The duration of the tab change animation, in seconds. + animation_function (str, optional): The easing function to use for the tab change animation. tab_padding (int, optional): The padding at the side of each tab. If None, tabs will automatically be padded such that they fit the available horizontal space. - search_by_first_character (bool): If True, entering a character on your keyboard + search_by_first_character (bool, optional): If True, entering a character on your keyboard will activate the next tab (in left-to-right order) with a label starting with that character. """ From f1be4e21aa147d6e975aa0f1017f7b214a07b297 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:34:04 +0000 Subject: [PATCH 22/43] Move the labels in the easing preview away from Static and into Label --- src/textual/cli/previews/easing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 6edcdb82a..b81290302 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal, Vertical from textual.reactive import Reactive from textual.scrollbar import ScrollBarRender from textual.widget import Widget -from textual.widgets import Button, Footer, Static, Input +from textual.widgets import Button, Footer, Label, Input VIRTUAL_SIZE = 100 WINDOW_SIZE = 10 @@ -27,7 +27,7 @@ class Bar(Widget): animation_running = Reactive(False) DEFAULT_CSS = """ - + Bar { background: $surface; color: $error; @@ -37,7 +37,7 @@ class Bar(Widget): background: $surface; color: $success; } - + """ def watch_animation_running(self, running: bool) -> None: @@ -67,14 +67,14 @@ class EasingApp(App): self.animated_bar.position = START_POSITION duration_input = Input("1.0", placeholder="Duration", id="duration-input") - self.opacity_widget = Static( + self.opacity_widget = Label( f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget" ) yield EasingButtons() yield Vertical( Horizontal( - Static("Animation Duration:", id="label"), duration_input, id="inputs" + Label("Animation Duration:", id="label"), duration_input, id="inputs" ), Horizontal( self.animated_bar, From 5a1b436e918809f7c94c1fa03f854d6c8245bd3c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:36:07 +0000 Subject: [PATCH 23/43] Move the label in the border preview away from Static and into Label --- src/textual/cli/previews/borders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py index 613343443..e5d453d86 100644 --- a/src/textual/cli/previews/borders.py +++ b/src/textual/cli/previews/borders.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult from textual.constants import BORDERS -from textual.widgets import Button, Static +from textual.widgets import Button, Label from textual.containers import Vertical @@ -48,7 +48,7 @@ class BorderApp(App): def compose(self): yield BorderButtons() - self.text = Static(TEXT, id="text") + self.text = Label(TEXT, id="text") yield self.text def on_button_pressed(self, event: Button.Pressed) -> None: From 5dcbd07f0878db2b7ebf02f833194ee15904b8df Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:42:04 +0000 Subject: [PATCH 24/43] Move the labels in the colour preview away from Static and into Label --- src/textual/cli/previews/colors.css | 2 +- src/textual/cli/previews/colors.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css index 3af8eabd7..657a8aabb 100644 --- a/src/textual/cli/previews/colors.css +++ b/src/textual/cli/previews/colors.css @@ -68,7 +68,7 @@ ColorGroup.-active { } -ColorLabel { +Label { padding: 0 0 1 0; content-align: center middle; color: $text; diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 56ac645c6..e25c70d7f 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -2,7 +2,7 @@ from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.design import ColorSystem from textual.widget import Widget -from textual.widgets import Button, Footer, Static +from textual.widgets import Button, Footer, Static, Label class ColorButtons(Vertical): @@ -28,10 +28,6 @@ class Content(Vertical): pass -class ColorLabel(Static): - pass - - class ColorsView(Vertical): def compose(self) -> ComposeResult: @@ -47,7 +43,7 @@ class ColorsView(Vertical): for color_name in ColorSystem.COLOR_NAMES: - items: list[Widget] = [ColorLabel(f'"{color_name}"')] + items: list[Widget] = [Label(f'"{color_name}"')] for level in LEVELS: color = f"{color_name}-{level}" if level else color_name item = ColorItem( From d30d3624bf5ebb765f098a49f92d916eba6ab895 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 15:51:38 +0000 Subject: [PATCH 25/43] Restore the content of docs.md --- docs.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs.md b/docs.md index ddc2bd439..63c47ed6a 100644 --- a/docs.md +++ b/docs.md @@ -1,14 +1,14 @@ # Documentation Workflow -* [Install Hatch](https://hatch.pypa.io/latest/install/) -* Run the live-reload server using `hatch run docs:serve` from the project root +* Ensure you're inside a *Python 3.10+* virtual environment +* Run the live-reload server using `mkdocs serve` from the project root * Create new pages by adding new directories and Markdown files inside `docs/*` ## Commands -- `hatch run docs:serve` - Start the live-reloading docs server. -- `hatch run docs:build` - Build the documentation site. -- `hatch run docs:help` - Print help message and exit. +- `mkdocs serve` - Start the live-reloading docs server. +- `mkdocs build` - Build the documentation site. +- `mkdocs -h` - Print help message and exit. ## Project layout From 5d7c98938f86e567ab1a72f2ea5e5ee4184994d5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 16:13:31 +0000 Subject: [PATCH 26/43] Correct a typo in a exception string --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8d993453c..8b8cc0fd6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -560,7 +560,7 @@ class Widget(DOMNode): if before is None and after is None: raise WidgetError("One of `before` or `after` is required.") elif before is not None and after is not None: - raise WidgetError("Only one of `before`or `after` can be handled.") + raise WidgetError("Only one of `before` or `after` can be handled.") def _to_widget(child: int | Widget, called: str) -> Widget: """Ensure a given child reference is a Widget.""" From 28bc889f7b0fbadfc9d194b40e84a537b7b57bfd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:24:39 +0000 Subject: [PATCH 27/43] Correct the container status of Static in the docs See https://github.com/Textualize/textual/pull/1193#discussion_r1025466567 --- docs/widgets/static.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/static.md b/docs/widgets/static.md index 4d9fc994c..c8e41606f 100644 --- a/docs/widgets/static.md +++ b/docs/widgets/static.md @@ -4,7 +4,7 @@ A widget which displays static content. Can be used for Rich renderables and can also for the base for other types of widgets. - [ ] Focusable -- [x] Container +- [ ] Container ## Example From c9484c64cacd294e297b98e185a99c0dad7c8193 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:25:02 +0000 Subject: [PATCH 28/43] Correct the container status of Label in the docs See https://github.com/Textualize/textual/pull/1193#discussion_r1025466567 --- docs/widgets/label.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/label.md b/docs/widgets/label.md index d4c22feac..96a31bcc6 100644 --- a/docs/widgets/label.md +++ b/docs/widgets/label.md @@ -3,7 +3,7 @@ A widget which displays static text, but which can also contain more complex Rich renderables. - [ ] Focusable -- [x] Container +- [ ] Container ## Example From 6dce7f1402311fbec5e15e5b697a20a5dd4337c6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:42:16 +0000 Subject: [PATCH 29/43] Fix a typo in the CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9092208f..4f95e4d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - It is now possible to `await` a `Widget.remove`. https://github.com/Textualize/textual/issues/1094 - It is now possible to `await` a `DOMQuery.remove`. Note that this changes - the return value of `DOMQuery.remove`, which uses to return `self`. + the return value of `DOMQuery.remove`, which used to return `self`. https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 From f07684438f1d4f9f18022b1e0d21b908ca299a23 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 11:34:52 +0000 Subject: [PATCH 30/43] fix remove freeze --- src/textual/app.py | 38 ++++++++++++++++++++++++++++++++++++++ src/textual/css/query.py | 11 ++--------- src/textual/events.py | 22 ---------------------- src/textual/screen.py | 23 ++++++++++++----------- src/textual/widget.py | 23 ++++++++++------------- 5 files changed, 62 insertions(+), 55 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index e74a90f8a..e69a82214 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -39,6 +39,7 @@ from rich.traceback import Traceback from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction +from .await_remove import AwaitRemove from ._ansi_sequences import SYNC_END, SYNC_START from ._callback import invoke from ._context import active_app @@ -353,6 +354,7 @@ class App(Generic[ReturnType], DOMNode): else None ) self._screenshot: str | None = None + self._dom_lock = asyncio.Lock() @property def return_value(self) -> ReturnType | None: @@ -1936,6 +1938,42 @@ class App(Generic[ReturnType], DOMNode): for child in widget.children: push(child) + def _remove_nodes(self, widgets: list[Widget]) -> AwaitRemove: + """Remove nodes from DOM, and return an awaitable that awaits cleanup. + + Args: + widgets (list[Widget]): List of nodes to remvoe. + + Returns: + AwaitRemove: Awaitable that returns when the nodes have been fully removed. + """ + + async def remove_task( + widgets: list[Widget], finished_event: asyncio.Event + ) -> None: + try: + await self._prune_nodes(widgets) + finally: + finished_event.set() + + removed_widgets = self._detach_from_dom(widgets) + self.refresh(layout=True) + + finished_event = asyncio.Event() + asyncio.create_task(remove_task(removed_widgets, finished_event)) + + return AwaitRemove(finished_event) + + async def _prune_nodes(self, widgets: list[Widget]) -> None: + """Remove nodes and children. + + Args: + widgets (Widget): _description_ + """ + async with self._dom_lock: + for widget in widgets: + await self._prune_node(widget) + async def _prune_node(self, root: Widget) -> None: """Remove a node and its children. Children are removed before parents. diff --git a/src/textual/css/query.py b/src/textual/css/query.py index ff1657a99..19f999e48 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -356,16 +356,9 @@ class DOMQuery(Generic[QueryType]): Returns: AwaitRemove: An awaitable object that waits for the widgets to be removed. """ - prune_finished_event = asyncio.Event() app = active_app.get() - app.post_message_no_wait( - events.Prune( - app, - widgets=app._detach_from_dom(list(self)), - finished_flag=prune_finished_event, - ) - ) - return AwaitRemove(prune_finished_event) + await_remove = app._remove_nodes(list(self)) + return await_remove def set_styles( self, css: str | None = None, **update_styles diff --git a/src/textual/events.py b/src/textual/events.py index 0eb5abac3..815f75f02 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -127,28 +127,6 @@ class Unmount(Mount, bubble=False, verbose=False): """Sent when a widget is unmounted and may not longer receive messages.""" -class Prune(Event, bubble=False): - """Sent to the app to ask it to prune one or more widgets from the DOM. - - Attributes: - widgets (list[Widgets]): The list of widgets to prune. - finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done. - """ - - def __init__( - self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event - ) -> None: - """Initialise the event. - - Args: - widgets (list[Widgets]): The list of widgets to prune. - finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done. - """ - super().__init__(sender) - self.finished_flag = finished_flag - self.widgets = widgets - - class Show(Event, bubble=False): """Sent when a widget has become visible.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 0069c5a79..2fd22308a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -310,18 +310,19 @@ class Screen(Widget): # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() - if self.is_current: - if self._layout_required: - self._refresh_layout() - self._layout_required = False - self._dirty_widgets.clear() - if self._repaint_required: - self._dirty_widgets.clear() - self._dirty_widgets.add(self) - self._repaint_required = False + async with self.app._dom_lock: + if self.is_current: + if self._layout_required: + self._refresh_layout() + self._layout_required = False + self._dirty_widgets.clear() + if self._repaint_required: + self._dirty_widgets.clear() + self._dirty_widgets.add(self) + self._repaint_required = False - if self._dirty_widgets: - self.update_timer.resume() + if self._dirty_widgets: + self.update_timer.resume() # The Screen is idle - a good opportunity to invoke the scheduled callbacks await self._invoke_and_clear_callbacks() diff --git a/src/textual/widget.py b/src/textual/widget.py index 8b8cc0fd6..fcc8aa328 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -523,15 +523,19 @@ class Widget(DOMNode): # Decide the final resting place depending on what we've been asked # to do. + insert_before: int | None = None + insert_after: int | None = None if before is not None: - parent, before = self._find_mount_point(before) + parent, insert_before = self._find_mount_point(before) elif after is not None: - parent, after = self._find_mount_point(after) + parent, insert_after = self._find_mount_point(after) else: parent = self return AwaitMount( - self.app._register(parent, *widgets, before=before, after=after) + self.app._register( + parent, *widgets, before=insert_before, after=insert_after + ) ) def move_child( @@ -697,7 +701,6 @@ class Widget(DOMNode): Returns: int: The height of the content. """ - if self.is_container: assert self._layout is not None height = ( @@ -2114,15 +2117,9 @@ class Widget(DOMNode): Returns: AwaitRemove: An awaitable object that waits for the widget to be removed. """ - prune_finished_event = AsyncEvent() - self.app.post_message_no_wait( - events.Prune( - self, - widgets=self.app._detach_from_dom([self]), - finished_flag=prune_finished_event, - ) - ) - return AwaitRemove(prune_finished_event) + + await_remove = self.app._remove_nodes([self]) + return await_remove def render(self) -> RenderableType: """Get renderable for widget. From ac1c6a2f3c2830d95e22c4fcf2c3b15106e69ebd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 11:39:35 +0000 Subject: [PATCH 31/43] rename --- src/textual/app.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index e69a82214..1961f1f47 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -18,6 +18,7 @@ from time import perf_counter from typing import ( TYPE_CHECKING, Any, + Callable, Generic, Iterable, List, @@ -25,7 +26,6 @@ from typing import ( TypeVar, Union, cast, - Callable, ) from weakref import WeakSet, WeakValueDictionary @@ -39,14 +39,14 @@ from rich.traceback import Traceback from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction -from .await_remove import AwaitRemove from ._ansi_sequences import SYNC_END, SYNC_START from ._callback import invoke from ._context import active_app from ._event_broker import NoHandler, extract_handler_actions from ._filter import LineFilter, Monochrome from ._path import _make_path_object_relative -from ._typing import TypeAlias, Final +from ._typing import Final, TypeAlias +from .await_remove import AwaitRemove from .binding import Binding, Bindings from .css.query import NoMatches from .css.stylesheet import Stylesheet @@ -62,7 +62,7 @@ from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen -from .widget import AwaitMount, Widget, MountError +from .widget import AwaitMount, MountError, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient @@ -1948,9 +1948,15 @@ class App(Generic[ReturnType], DOMNode): AwaitRemove: Awaitable that returns when the nodes have been fully removed. """ - async def remove_task( + async def prune_widgets_task( widgets: list[Widget], finished_event: asyncio.Event ) -> None: + """Prune widgets as a background task. + + Args: + widgets (list[Widget]): Widgets to prune. + finished_event (asyncio.Event): Event to set when complete. + """ try: await self._prune_nodes(widgets) finally: @@ -1960,7 +1966,7 @@ class App(Generic[ReturnType], DOMNode): self.refresh(layout=True) finished_event = asyncio.Event() - asyncio.create_task(remove_task(removed_widgets, finished_event)) + asyncio.create_task(prune_widgets_task(removed_widgets, finished_event)) return AwaitRemove(finished_event) From d4d517aa8f94fd07e633a0f0811dab9284dedea2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 11:40:58 +0000 Subject: [PATCH 32/43] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f95e4d10..312b85941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 - Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 - Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202 +- Fixed deadlock when remove widgets from the App https://github.com/Textualize/textual/pull/1219 ## [0.4.0] - 2022-11-08 From d90a1ea69101378b9604feed64acb4b6f3faee74 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 12:30:26 +0000 Subject: [PATCH 33/43] words --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312b85941..4f4579962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,7 +41,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 - Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 - Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202 -- Fixed deadlock when remove widgets from the App https://github.com/Textualize/textual/pull/1219 +- Fixed deadlock when removing widgets from the App https://github.com/Textualize/textual/pull/1219 ## [0.4.0] - 2022-11-08 From 0943d5a263bdb072b7937aa75e09eb2c11f7bccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+RodrigoGiraoSerrao@users.noreply.github.com> Date: Fri, 18 Nov 2022 13:14:40 +0000 Subject: [PATCH 34/43] Fix docstring signatures. --- src/textual/reactive.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 7cd88d630..c71070ba3 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -43,7 +43,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. init (bool, optional): Call watchers on initialize (post mount). Defaults to False. - always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. + always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( @@ -76,7 +76,7 @@ class Reactive(Generic[ReactiveType]): default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. 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. + 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. @@ -292,7 +292,7 @@ class reactive(Reactive[ReactiveType]): layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. init (bool, optional): Call watchers on initialize (post mount). Defaults to True. - always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. + always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( From deabb4859ba68d6d5b4567514214f39e8fd981eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+RodrigoGiraoSerrao@users.noreply.github.com> Date: Fri, 18 Nov 2022 13:24:33 +0000 Subject: [PATCH 35/43] Add httpx as dev dependency. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 937fd3590..acabc4cf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ syrupy = "^3.0.0" [tool.poetry.group.dev.dependencies] mkdocs-rss-plugin = "^1.5.0" +httpx = "^0.23.1" [tool.black] includes = "src" From 4cf69b05bb345ef88d437c3c78d2ede7c550af2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+RodrigoGiraoSerrao@users.noreply.github.com> Date: Fri, 18 Nov 2022 13:30:46 +0000 Subject: [PATCH 36/43] Update dev dependency listing to group notation. --- poetry.lock | 170 +++++++++++++++++++++++++++++++++++++++++-------- pyproject.toml | 4 +- 2 files changed, 143 insertions(+), 31 deletions(-) diff --git a/poetry.lock b/poetry.lock index b98b0925f..59f0e2065 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,6 +31,24 @@ python-versions = ">=3.7" [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "anyio" +version = "3.6.2" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] +trio = ["trio (>=0.16,<0.22)"] + [[package]] name = "async-timeout" version = "4.0.2" @@ -144,7 +162,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7 [[package]] name = "colored" -version = "1.4.3" +version = "1.4.4" description = "Simple library for color and formatting to terminal" category = "dev" optional = false @@ -182,7 +200,7 @@ python-versions = "*" [[package]] name = "exceptiongroup" -version = "1.0.1" +version = "1.0.4" description = "Backport of PEP 654 (exception groups)" category = "dev" optional = false @@ -250,7 +268,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\"" [[package]] name = "griffe" -version = "0.23.0" +version = "0.24.0" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." category = "dev" optional = false @@ -258,10 +276,60 @@ python-versions = ">=3.7" [package.dependencies] cached-property = {version = "*", markers = "python_version < \"3.8\""} +colorama = ">=0.4" [package.extras] async = ["aiofiles (>=0.7,<1.0)"] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[[package]] +name = "httpcore" +version = "0.16.1" +description = "A minimal low-level HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +anyio = ">=3.0,<5.0" +certifi = "*" +h11 = ">=0.13,<0.15" +sniffio = ">=1.0.0,<2.0.0" + +[package.extras] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + +[[package]] +name = "httpx" +version = "0.23.1" +description = "The next generation HTTP client." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +certifi = "*" +httpcore = ">=0.15.0,<0.17.0" +rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "identify" version = "2.5.8" @@ -390,7 +458,7 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "8.5.9" +version = "8.5.10" description = "Documentation that simply works" category = "dev" optional = false @@ -455,14 +523,14 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "0.7.1" +version = "0.8.0" description = "A Python handler for mkdocstrings." category = "dev" optional = false python-versions = ">=3.7" [package.dependencies] -griffe = ">=0.11.1" +griffe = ">=0.24" mkdocstrings = ">=0.19" [[package]] @@ -541,7 +609,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pathspec" -version = "0.10.1" +version = "0.10.2" description = "Utility library for gitignore style pattern matching of file paths." category = "dev" optional = false @@ -549,7 +617,7 @@ python-versions = ">=3.7" [[package]] name = "platformdirs" -version = "2.5.3" +version = "2.5.4" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false @@ -663,7 +731,7 @@ testing = ["coverage (==6.2)", "mypy (==0.931)"] [[package]] name = "pytest-asyncio" -version = "0.20.1" +version = "0.20.2" description = "Pytest support for asyncio" category = "dev" optional = false @@ -748,6 +816,20 @@ urllib3 = ">=1.21.1,<1.27" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rfc3986" +version = "1.5.0" +description = "Validating URI References per RFC 3986" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} + +[package.extras] +idna2008 = ["idna"] + [[package]] name = "rich" version = "12.6.0" @@ -793,6 +875,14 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "sniffio" +version = "1.3.0" +description = "Sniff out which async library your code is running under" +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "syrupy" version = "3.0.5" @@ -871,7 +961,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] [[package]] name = "virtualenv" -version = "20.16.6" +version = "20.16.7" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -929,7 +1019,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a" +content-hash = "578d7f611a797d406b8db7c61f28796e81af2e637d9671caab9b4ea2b1cf93c6" [metadata.files] aiohttp = [ @@ -1025,6 +1115,10 @@ aiosignal = [ {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, ] +anyio = [ + {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, + {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, +] async-timeout = [ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, {file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"}, @@ -1085,7 +1179,7 @@ colorama = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] colored = [ - {file = "colored-1.4.3.tar.gz", hash = "sha256:b7b48b9f40e8a65bbb54813d5d79dd008dc8b8c5638d5bbfd30fc5a82e6def7a"}, + {file = "colored-1.4.4.tar.gz", hash = "sha256:04ff4d4dd514274fe3b99a21bb52fb96f2688c01e93fba7bef37221e7cb56ce0"}, ] commonmark = [ {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, @@ -1148,8 +1242,8 @@ distlib = [ {file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"}, ] exceptiongroup = [ - {file = "exceptiongroup-1.0.1-py3-none-any.whl", hash = "sha256:4d6c0aa6dd825810941c792f53d7b8d71da26f5e5f84f20f9508e8f2d33b140a"}, - {file = "exceptiongroup-1.0.1.tar.gz", hash = "sha256:73866f7f842ede6cb1daa42c4af078e2035e5f7607f0e2c762cc51bb31bbe7b2"}, + {file = "exceptiongroup-1.0.4-py3-none-any.whl", hash = "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828"}, + {file = "exceptiongroup-1.0.4.tar.gz", hash = "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec"}, ] filelock = [ {file = "filelock-3.8.0-py3-none-any.whl", hash = "sha256:617eb4e5eedc82fc5f47b6d61e4d11cb837c56cb4544e39081099fa17ad109d4"}, @@ -1244,8 +1338,20 @@ gitpython = [ {file = "GitPython-3.1.29.tar.gz", hash = "sha256:cc36bfc4a3f913e66805a28e84703e419d9c264c1077e537b54f0e1af85dbefd"}, ] griffe = [ - {file = "griffe-0.23.0-py3-none-any.whl", hash = "sha256:cfca5f523808109da3f8cfaa46e325fa2e5bef51120d1146e908c121b56475f0"}, - {file = "griffe-0.23.0.tar.gz", hash = "sha256:a639e2968c8e27f56ebcc57f869a03cea7ac7e7f5684bd2429c665f761c4e7bd"}, + {file = "griffe-0.24.0-py3-none-any.whl", hash = "sha256:6c6b64716155f27ef63377e2b04749079c359f06d9a6e638bb2f885cbe463360"}, + {file = "griffe-0.24.0.tar.gz", hash = "sha256:afa92aeb8c5a4f2501693ffd607f820d7ade3ac2a36e34c43d39ee3486cec392"}, +] +h11 = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] +httpcore = [ + {file = "httpcore-0.16.1-py3-none-any.whl", hash = "sha256:8d393db683cc8e35cc6ecb02577c5e1abfedde52b38316d038932a84b4875ecb"}, + {file = "httpcore-0.16.1.tar.gz", hash = "sha256:3d3143ff5e1656a5740ea2f0c167e8e9d48c5a9bbd7f00ad1f8cff5711b08543"}, +] +httpx = [ + {file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"}, + {file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"}, ] identify = [ {file = "identify-2.5.8-py2.py3-none-any.whl", hash = "sha256:48b7925fe122720088aeb7a6c34f17b27e706b72c61070f27fe3789094233440"}, @@ -1326,8 +1432,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] mkdocs-material = [ - {file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"}, - {file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"}, + {file = "mkdocs_material-8.5.10-py3-none-any.whl", hash = "sha256:51760fa4c9ee3ca0b3a661ec9f9817ec312961bb84ff19e5b523fdc5256e1d6c"}, + {file = "mkdocs_material-8.5.10.tar.gz", hash = "sha256:7623608f746c6d9ff68a8ef01f13eddf32fa2cae5e15badb251f26d1196bc8f1"}, ] mkdocs-material-extensions = [ {file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"}, @@ -1342,8 +1448,8 @@ mkdocstrings = [ {file = "mkdocstrings-0.19.0.tar.gz", hash = "sha256:efa34a67bad11229d532d89f6836a8a215937548623b64f3698a1df62e01cc3e"}, ] mkdocstrings-python = [ - {file = "mkdocstrings-python-0.7.1.tar.gz", hash = "sha256:c334b382dca202dfa37071c182418a6df5818356a95d54362a2b24822ca3af71"}, - {file = "mkdocstrings_python-0.7.1-py3-none-any.whl", hash = "sha256:a22060bfa374697678e9af4e62b020d990dad2711c98f7a9fac5c0345bef93c7"}, + {file = "mkdocstrings-python-0.8.0.tar.gz", hash = "sha256:67f674a8b252fca0b9411c10fb923dd6aacc49ac55c59f738b78b06592ace43d"}, + {file = "mkdocstrings_python-0.8.0-py3-none-any.whl", hash = "sha256:cbee42e53aeaae340d79d72e9bcf42f2b6abe4d11696597c76e3e86a4d9f05a0"}, ] msgpack = [ {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, @@ -1509,12 +1615,12 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pathspec = [ - {file = "pathspec-0.10.1-py3-none-any.whl", hash = "sha256:46846318467efc4556ccfd27816e004270a9eeeeb4d062ce5e6fc7a87c573f93"}, - {file = "pathspec-0.10.1.tar.gz", hash = "sha256:7ace6161b621d31e7902eb6b5ae148d12cfd23f4a249b9ffb6b9fee12084323d"}, + {file = "pathspec-0.10.2-py3-none-any.whl", hash = "sha256:88c2606f2c1e818b978540f73ecc908e13999c6c3a383daf3705652ae79807a5"}, + {file = "pathspec-0.10.2.tar.gz", hash = "sha256:8f6bf73e5758fd365ef5d58ce09ac7c27d2833a8d7da51712eac6e27e35141b0"}, ] platformdirs = [ - {file = "platformdirs-2.5.3-py3-none-any.whl", hash = "sha256:0cb405749187a194f444c25c82ef7225232f11564721eabffc6ec70df83b11cb"}, - {file = "platformdirs-2.5.3.tar.gz", hash = "sha256:6e52c21afff35cb659c6e52d8b4d61b9bd544557180440538f255d9382c8cbe0"}, + {file = "platformdirs-2.5.4-py3-none-any.whl", hash = "sha256:af0276409f9a02373d540bf8480021a048711d572745aef4b7842dad245eba10"}, + {file = "platformdirs-2.5.4.tar.gz", hash = "sha256:1006647646d80f16130f052404c6b901e80ee4ed6bef6792e1f238a8969106f7"}, ] pluggy = [ {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, @@ -1545,8 +1651,8 @@ pytest-aiohttp = [ {file = "pytest_aiohttp-1.0.4-py3-none-any.whl", hash = "sha256:1d2dc3a304c2be1fd496c0c2fb6b31ab60cd9fc33984f761f951f8ea1eb4ca95"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.20.1.tar.gz", hash = "sha256:626699de2a747611f3eeb64168b3575f70439b06c3d0206e6ceaeeb956e65519"}, - {file = "pytest_asyncio-0.20.1-py3-none-any.whl", hash = "sha256:2c85a835df33fda40fe3973b451e0c194ca11bc2c007eabff90bb3d156fc172b"}, + {file = "pytest-asyncio-0.20.2.tar.gz", hash = "sha256:32a87a9836298a881c0ec637ebcc952cfe23a56436bdc0d09d1511941dd8a812"}, + {file = "pytest_asyncio-0.20.2-py3-none-any.whl", hash = "sha256:07e0abf9e6e6b95894a39f688a4a875d63c2128f76c02d03d16ccbc35bcc0f8a"}, ] pytest-cov = [ {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, @@ -1610,6 +1716,10 @@ requests = [ {file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"}, {file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"}, ] +rfc3986 = [ + {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, + {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, +] rich = [ {file = "rich-12.6.0-py3-none-any.whl", hash = "sha256:a4eb26484f2c82589bd9a17c73d32a010b1e29d89f1604cd9bf3a2097b81bb5e"}, {file = "rich-12.6.0.tar.gz", hash = "sha256:ba3a3775974105c221d31141f2c116f4fd65c5ceb0698657a11e9f295ec93fd0"}, @@ -1626,6 +1736,10 @@ smmap = [ {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] +sniffio = [ + {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, + {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, +] syrupy = [ {file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"}, {file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"}, @@ -1729,8 +1843,8 @@ urllib3 = [ {file = "urllib3-1.26.12.tar.gz", hash = "sha256:3fa96cf423e6987997fc326ae8df396db2a8b7c667747d47ddd8ecba91f4a74e"}, ] virtualenv = [ - {file = "virtualenv-20.16.6-py3-none-any.whl", hash = "sha256:186ca84254abcbde98180fd17092f9628c5fe742273c02724972a1d8a2035108"}, - {file = "virtualenv-20.16.6.tar.gz", hash = "sha256:530b850b523c6449406dfba859d6345e48ef19b8439606c5d74d7d3c9e14d76e"}, + {file = "virtualenv-20.16.7-py3-none-any.whl", hash = "sha256:efd66b00386fdb7dbe4822d172303f40cd05e50e01740b19ea42425cbe653e29"}, + {file = "virtualenv-20.16.7.tar.gz", hash = "sha256:8691e3ff9387f743e00f6bb20f70121f5e4f596cae754531f2b3b3a1b1ac696e"}, ] watchdog = [ {file = "watchdog-2.1.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a735a990a1095f75ca4f36ea2ef2752c99e6ee997c46b0de507ba40a09bf7330"}, diff --git a/pyproject.toml b/pyproject.toml index acabc4cf1..bf55b935b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ nanoid = ">=2.0.0" [tool.poetry.extras] dev = ["aiohttp", "click", "msgpack"] -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] pytest = "^7.1.3" black = "^22.3.0" mypy = "^0.990" @@ -57,8 +57,6 @@ pytest-aiohttp = "^1.0.4" time-machine = "^2.6.0" Jinja2 = "<3.1.0" syrupy = "^3.0.0" - -[tool.poetry.group.dev.dependencies] mkdocs-rss-plugin = "^1.5.0" httpx = "^0.23.1" From 50860b98b623b97d20688658cfbbcd41513b2f13 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 18 Nov 2022 13:43:27 +0000 Subject: [PATCH 37/43] Update the 5x5 example to make use of callable screens Now that #1054 has made it into main, make use of it. --- examples/five_by_five.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f4574a8a4..3e668f04d 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -311,7 +311,7 @@ class FiveByFive(App[None]): CSS_PATH = "five_by_five.css" """The name of the stylesheet for the app.""" - SCREENS = {"help": Help()} + SCREENS = {"help": Help} """The pre-loaded screens for the application.""" BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")] From fa5ac0dd6887fcda601d1f86e05f6eeedd612c86 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 13:59:58 +0000 Subject: [PATCH 38/43] simplified cache_key --- CHANGELOG.md | 1 + src/textual/css/styles.py | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f95e4d10..f6cb6dc7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 - Widget.call_later has been renamed to Widget.call_after_refresh. - Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189 +- Added caching of some properties in Styles object ### Fixed diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6b4dc1d7e..006f12910 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -557,6 +557,7 @@ class StylesBase(ABC): class Styles(StylesBase): node: DOMNode | None = None _rules: RulesMap = field(default_factory=dict) + _updates: int = 0 important: set[str] = field(default_factory=set) @@ -577,6 +578,7 @@ class Styles(StylesBase): Returns: bool: ``True`` if a rule was cleared, or ``False`` if it was already not set. """ + self._updates += 1 return self._rules.pop(rule, None) is not None def get_rules(self) -> RulesMap: @@ -592,6 +594,7 @@ class Styles(StylesBase): Returns: bool: ``True`` if the rule changed, otherwise ``False``. """ + self._updates += 1 if value is None: return self._rules.pop(rule, None) is not None current = self._rules.get(rule) @@ -610,6 +613,7 @@ class Styles(StylesBase): def reset(self) -> None: """Reset the rules to initial state.""" + self._updates += 1 self._rules.clear() def merge(self, other: Styles) -> None: @@ -618,10 +622,11 @@ class Styles(StylesBase): Args: other (Styles): A Styles object. """ - + self._updates += 1 self._rules.update(other._rules) def merge_rules(self, rules: RulesMap) -> None: + self._updates += 1 self._rules.update(rules) def extract_rules( @@ -929,6 +934,18 @@ class RenderStyles(StylesBase): self._base_styles = base self._inline_styles = inline_styles self._animate: BoundAnimator | None = None + self._updates: int = 0 + self._rich_style: tuple[int, Style] | None = None + self._gutter: tuple[int, Spacing] | None = None + + @property + def _cache_key(self) -> int: + """A key key, that changes when any style is changed. + + Returns: + int: An opaque integer. + """ + return self._updates + self._base_styles._updates + self._inline_styles._updates @property def base(self) -> Styles: @@ -946,6 +963,21 @@ class RenderStyles(StylesBase): assert self.node is not None return self.node.rich_style + @property + def gutter(self) -> Spacing: + """Get space around widget. + + Returns: + Spacing: Space around widget content. + """ + if self._gutter is not None: + cache_key, gutter = self._gutter + if cache_key == self._updates: + return gutter + gutter = self.padding + self.border.spacing + self._gutter = (self._cache_key, gutter) + return gutter + def animate( self, attribute: str, @@ -972,6 +1004,7 @@ class RenderStyles(StylesBase): """ if self._animate is None: + assert self.node is not None self._animate = self.node.app.animator.bind(self) assert self._animate is not None self._animate( @@ -1003,16 +1036,19 @@ class RenderStyles(StylesBase): def merge_rules(self, rules: RulesMap) -> None: self._inline_styles.merge_rules(rules) + self._updates += 1 def reset(self) -> None: """Reset the rules to initial state.""" self._inline_styles.reset() + self._updates += 1 def has_rule(self, rule: str) -> bool: """Check if a rule has been set.""" return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule) def set_rule(self, rule: str, value: object | None) -> bool: + self._updates += 1 return self._inline_styles.set_rule(rule, value) def get_rule(self, rule: str, default: object = None) -> object: @@ -1022,6 +1058,7 @@ class RenderStyles(StylesBase): def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" + self._updates += 1 return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: From 36664ef7ae88303f02c282c5f3cc9b080098969b Mon Sep 17 00:00:00 2001 From: darrenburns Date: Fri, 18 Nov 2022 14:05:45 +0000 Subject: [PATCH 39/43] Sensible default key displays + allow users to override key displays at the `App` level (#1213) * Get rid of string split key display * Include screen level bindings when no widget is focused * Add default key display mappings * Allow user to customise key display at app level * Better docstring * Update CHANGELOG.md --- CHANGELOG.md | 2 + src/textual/app.py | 23 ++- src/textual/keys.py | 28 ++++ src/textual/widgets/_footer.py | 12 +- .../__snapshots__/test_snapshots.ambr | 157 ++++++++++++++++++ .../snapshot_apps/key_display.py | 36 ++++ tests/snapshot_tests/test_snapshots.py | 6 + 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/key_display.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cb6dc7e..1323d53bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 - Added a `Label` widget https://github.com/Textualize/textual/issues/1190 - Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 +- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213 +- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index e74a90f8a..ba95fb6ef 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -56,12 +56,12 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size -from .keys import REPLACED_KEYS +from .keys import REPLACED_KEYS, _get_key_display from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen -from .widget import AwaitMount, Widget, MountError +from .widget import AwaitMount, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient @@ -102,7 +102,6 @@ DEFAULT_COLORS = { ComposeResult = Iterable[Widget] RenderResult = RenderableType - AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" @@ -668,6 +667,22 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) + def get_key_display(self, key: str) -> str: + """For a given key, return how it should be displayed in an app + (e.g. in the Footer widget). + By key, we refer to the string used in the "key" argument for + a Binding instance. By overriding this method, you can ensure that + keys are displayed consistently throughout your app, without + needing to add a key_display to every binding. + + Args: + key (str): The binding key string. + + Returns: + str: The display string for the input key. + """ + return _get_key_display(key) + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" app = self @@ -705,7 +720,7 @@ class App(Generic[ReturnType], DOMNode): # This conditional sleep can be removed after that issue is closed. if key == "tab": await asyncio.sleep(0.05) - await asyncio.sleep(0.02) + await asyncio.sleep(0.025) await app._animator.wait_for_idle() @asynccontextmanager diff --git a/src/textual/keys.py b/src/textual/keys.py index e6d386c68..aac14c138 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -1,5 +1,6 @@ from __future__ import annotations +import unicodedata from enum import Enum @@ -219,7 +220,34 @@ KEY_ALIASES = { "ctrl+j": ["newline"], } +KEY_DISPLAY_ALIASES = { + "up": "↑", + "down": "↓", + "left": "←", + "right": "→", + "backspace": "⌫", + "escape": "ESC", + "enter": "⏎", +} + def _get_key_aliases(key: str) -> list[str]: """Return all aliases for the given key, including the key itself""" return [key] + KEY_ALIASES.get(key, []) + + +def _get_key_display(key: str) -> str: + """Given a key (i.e. the `key` string argument to Binding __init__), + return the value that should be displayed in the app when referring + to this key (e.g. in the Footer widget).""" + display_alias = KEY_DISPLAY_ALIASES.get(key) + if display_alias: + return display_alias + + original_key = REPLACED_KEYS.get(key, key) + try: + unicode_character = unicodedata.lookup(original_key.upper().replace("_", " ")) + except KeyError: + return original_key.upper() + + return unicode_character diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index fe39d8b5a..fd8353ab6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -7,6 +7,7 @@ from rich.console import RenderableType from rich.text import Text from .. import events +from ..keys import _get_key_display from ..reactive import Reactive, watch from ..widget import Widget @@ -99,11 +100,12 @@ class Footer(Widget): for action, bindings in action_to_bindings.items(): binding = bindings[0] - key_display = ( - binding.key.upper() - if binding.key_display is None - else binding.key_display - ) + if binding.key_display is None: + key_display = self.app.get_key_display(binding.key) + if key_display is None: + key_display = binding.key.upper() + else: + key_display = binding.key_display hovered = self.highlight_key == binding.key key_text = Text.assemble( (f" {key_display} ", highlight_key_style if hovered else key_style), diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index cb6252b33..354a78d0f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -6166,6 +6166,163 @@ ''' # --- +# name: test_key_display + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyDisplayApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  + + + + + ''' +# --- # name: test_layers ''' diff --git a/tests/snapshot_tests/snapshot_apps/key_display.py b/tests/snapshot_tests/snapshot_apps/key_display.py new file mode 100644 index 000000000..9762bcff6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/key_display.py @@ -0,0 +1,36 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer + + +class KeyDisplayApp(App): + """Tests how keys are displayed in the Footer, and ensures + that overriding the key_displays works as expected. + Exercises both the built-in Textual key display replacements, + and user supplied replacements. + Will break when we update the Footer - but we should add a similar + test (or updated snapshot) for the updated Footer.""" + BINDINGS = [ + Binding("question_mark", "question", "Question"), + Binding("ctrl+q", "quit", "Quit app"), + Binding("escape", "escape", "Escape"), + Binding("a", "a", "Letter A"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + def get_key_display(self, key: str) -> str: + key_display_replacements = { + "escape": "Escape!", + "ctrl+q": "^q", + } + display = key_display_replacements.get(key) + if display: + return display + return super().get_key_display(key) + + +app = KeyDisplayApp() +if __name__ == '__main__': + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 95e83c278..f5116cc78 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -118,3 +118,9 @@ def test_css_property(file_name, snap_compare): def test_multiple_css(snap_compare): # Interaction between multiple CSS files and app-level/classvar CSS assert snap_compare("snapshot_apps/multiple_css/multiple_css.py") + + +# --- Other --- + +def test_key_display(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") From 5e2a5668a09a44135b38334ecc08046791f0d47a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 14:17:18 +0000 Subject: [PATCH 40/43] ws --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1323d53bf..56981a786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Change Log + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) From 69105946c90ec90116fdff6191dbe05244792b2b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 15:02:28 +0000 Subject: [PATCH 41/43] Added team --- docs/blog/.authors.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml index e233de92d..2cada49f7 100644 --- a/docs/blog/.authors.yml +++ b/docs/blog/.authors.yml @@ -2,3 +2,16 @@ willmcgugan: name: Will McGugan description: CEO / code-monkey avatar: https://github.com/willmcgugan.png +darrenburns: + name: Darren Burns + description: Code-monkey + avatar: https://github.com/darrenburns.png +davep: + name: Dave Pearson + description: Code-monkey + avatar: https://github.com/darrenburns.png +rodrigo: + name: Rodrigo Girão Serrão + description: Code-monkey + avatar: https://github.com/rodrigogiraoserrao.png + \ No newline at end of file From a1daf02537fea07951e5d63b8c33d1c2c8dcc159 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 15:30:34 +0000 Subject: [PATCH 42/43] fixed Daves avatar --- docs/blog/.authors.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml index 2cada49f7..9fda2fa39 100644 --- a/docs/blog/.authors.yml +++ b/docs/blog/.authors.yml @@ -9,7 +9,7 @@ darrenburns: davep: name: Dave Pearson description: Code-monkey - avatar: https://github.com/darrenburns.png + avatar: https://github.com/davep.png rodrigo: name: Rodrigo Girão Serrão description: Code-monkey From 805bf4d1758b9d88c97924550fda329384da4922 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 15:31:34 +0000 Subject: [PATCH 43/43] ws --- docs/blog/.authors.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/blog/.authors.yml b/docs/blog/.authors.yml index 9fda2fa39..5ed343e2f 100644 --- a/docs/blog/.authors.yml +++ b/docs/blog/.authors.yml @@ -14,4 +14,3 @@ rodrigo: name: Rodrigo Girão Serrão description: Code-monkey avatar: https://github.com/rodrigogiraoserrao.png - \ No newline at end of file