From ed4d811451226cac847af0d7302f71876df39595 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: Tue, 9 May 2023 14:55:18 +0100 Subject: [PATCH 01/85] Add tests for Screen auto focus. Related issues: #2457. --- tests/test_screens.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/tests/test_screens.py b/tests/test_screens.py index 6825c101c..7e20348a9 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -6,6 +6,7 @@ import pytest from textual.app import App, ScreenStackError from textual.screen import Screen +from textual.widgets import Button, Input skip_py310 = pytest.mark.skipif( sys.version_info.minor == 10 and sys.version_info.major == 3, @@ -150,3 +151,44 @@ async def test_screens(): screen2.remove() screen3.remove() await app._shutdown() + + +async def test_auto_focus(): + class MyScreen(Screen[None]): + def compose(self) -> None: + print("composing") + yield Button() + yield Input(id="one") + yield Input(id="two") + + class MyApp(App[None]): + pass + + app = MyApp() + async with app.run_test(): + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Button) + app.pop_screen() + + MyScreen.auto_focus = None + await app.push_screen(MyScreen()) + assert app.focused is None + app.pop_screen() + + MyScreen.auto_focus = "Input" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Input) + assert app.focused.id == "one" + app.pop_screen() + + MyScreen.auto_focus = "#two" + await app.push_screen(MyScreen()) + assert isinstance(app.focused, Input) + assert app.focused.id == "two" + + # If we push and pop another screen, focus should be preserved for #two. + MyScreen.auto_focus = None + await app.push_screen(MyScreen()) + assert app.focused is None + app.pop_screen() + assert app.focused.id == "two" From 8d3f69a04d49d1e4e83db1c573d1232f7201aede 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: Tue, 9 May 2023 14:57:50 +0100 Subject: [PATCH 02/85] Add auto_focus attribute to screens. --- CHANGELOG.md | 7 +++++++ src/textual/screen.py | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2923fac..d5f95b549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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/). + +## Unreleased + +### Added + +- Attribute `auto_focus` to screens https://github.com/Textualize/textual/issues/2457 + ## [0.24.1] - 2023-05-08 ### Fixed diff --git a/src/textual/screen.py b/src/textual/screen.py index 34db473ef..8bc9c0532 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -30,7 +30,7 @@ from ._types import CallbackType from .binding import Binding from .css.match import match from .css.parse import parse_selectors -from .css.query import QueryType +from .css.query import NoMatches, QueryType from .dom import DOMNode from .geometry import Offset, Region, Size from .reactive import Reactive @@ -101,6 +101,12 @@ class Screen(Generic[ScreenResultType], Widget): } """ + auto_focus: str | None = "*" + """A selector to determine what to focus automatically when the screen is activated. + + The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors). + Set to `None` to disable auto focus. + """ focused: Reactive[Widget | None] = Reactive(None) """The focused [widget][textual.widget.Widget] or `None` for no focus.""" stack_updates: Reactive[int] = Reactive(0, repaint=False) @@ -659,6 +665,13 @@ class Screen(Generic[ScreenResultType], Widget): """Screen has resumed.""" self.stack_updates += 1 size = self.app.size + if self.auto_focus is not None and self.focused is None: + try: + to_focus = self.query(self.auto_focus).first() + except NoMatches: + pass + else: + self.set_focus(to_focus) self._refresh_layout(size, full=True) self.refresh() From eafe6b1786490679b4acb85dfbf3978e54a2cc4a 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: Tue, 9 May 2023 15:56:28 +0100 Subject: [PATCH 03/85] Moving child before/after self is a no-op. Related issues: #1743. --- CHANGELOG.md | 7 +++++++ src/textual/widget.py | 15 +++++++-------- tests/test_widget_child_moving.py | 8 ++------ 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa2923fac..df66182ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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/). + +## Unreleased + +### Changed + +- Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 + ## [0.24.1] - 2023-05-08 ### Fixed diff --git a/src/textual/widget.py b/src/textual/widget.py index 4fc364ebf..7103c630c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -795,19 +795,15 @@ class Widget(DOMNode): Args: child: The child widget to move. - before: Optional location to move before. An `int` is the index - of the child to move before, a `str` is a `query_one` query to - find the widget to move before. - after: Optional location to move after. An `int` is the index - of the child to move after, a `str` is a `query_one` query to - find the widget to move after. + before: Child widget or location index to move before. + after: Child widget or location index to move after. Raises: WidgetError: If there is a problem with the child or target. Note: - Only one of ``before`` or ``after`` can be provided. If neither - or both are provided a ``WidgetError`` will be raised. + Only one of `before` or `after` can be provided. If neither + or both are provided a `WidgetError` will be raised. """ # One or the other of before or after are required. Can't do @@ -817,6 +813,9 @@ class Widget(DOMNode): elif before is not None and after is not None: raise WidgetError("Only one of `before` or `after` can be handled.") + if child is before or child is after: + return + def _to_widget(child: int | Widget, called: str) -> Widget: """Ensure a given child reference is a Widget.""" if isinstance(child, int): diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py index 520ef7810..f2d40a4aa 100644 --- a/tests/test_widget_child_moving.py +++ b/tests/test_widget_child_moving.py @@ -42,22 +42,18 @@ async def test_move_child_to_outside() -> None: pilot.app.screen.move_child(child, before=Widget()) -@pytest.mark.xfail( - strict=True, reason="https://github.com/Textualize/textual/issues/1743" -) async def test_move_child_before_itself() -> None: """Test moving a widget before itself.""" + async with App().run_test() as pilot: child = Widget(Widget()) await pilot.app.mount(child) pilot.app.screen.move_child(child, before=child) -@pytest.mark.xfail( - strict=True, reason="https://github.com/Textualize/textual/issues/1743" -) async def test_move_child_after_itself() -> None: """Test moving a widget after itself.""" + # Regression test for https://github.com/Textualize/textual/issues/1743 async with App().run_test() as pilot: child = Widget(Widget()) await pilot.app.mount(child) From 30a20ac8daeb8a95800cdcf1ffbbadb890f18d50 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 9 May 2023 16:41:17 +0100 Subject: [PATCH 04/85] Break iterdir out into a method of its own for easy testing As I work on what's to come (loading DirectoryTree with a worker), I'm going to want to try and construct slow loads so I can test the effectiveness of the changes. This means a desire to fake a very slow source of directory information. So let's drop this into its own method so we can then do silly things like add a sleep to really show stuff down. --- src/textual/widgets/_directory_tree.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index fb6d7c5c5..7c0dc6ba0 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import ClassVar, Iterable +from typing import ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType @@ -229,6 +229,19 @@ class DirectoryTree(Tree[DirEntry]): """ return paths + def _directory_content(self, directory: Path) -> Iterator[Path]: + """Get the entries within a given directory. + + Args: + directory: The directory to get the content of. + + Returns: + An iterator of `Path` objects. + """ + # TODO: Place this in a loop with a sleep to slow things down for + # testing. + return directory.iterdir() + def _load_directory(self, node: TreeNode[DirEntry]) -> None: """Load the directory contents for a given node. @@ -238,7 +251,7 @@ class DirectoryTree(Tree[DirEntry]): assert node.data is not None node.data.loaded = True directory = sorted( - self.filter_paths(node.data.path.iterdir()), + self.filter_paths(self._directory_content(node.data.path)), key=lambda path: (not path.is_dir(), path.name.lower()), ) for path in directory: From 3245eb38bb5f6a4a95fbe282f30b88288c49505f 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: Tue, 9 May 2023 16:44:37 +0100 Subject: [PATCH 05/85] Make auto-focus a class var. Related comments: https://github.com/Textualize/textual/pull/2527\#discussion_r1188776849 --- src/textual/screen.py | 19 ++++++++++--------- tests/test_screens.py | 8 ++++---- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 8bc9c0532..af0b006be 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from typing import ( TYPE_CHECKING, Awaitable, Callable, + ClassVar, Generic, Iterable, Iterator, @@ -93,6 +94,13 @@ class ResultCallback(Generic[ScreenResultType]): class Screen(Generic[ScreenResultType], Widget): """The base class for screens.""" + AUTO_FOCUS: ClassVar[str | None] = "*" + """A selector to determine what to focus automatically when the screen is activated. + + The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors). + Set to `None` to disable auto focus. + """ + DEFAULT_CSS = """ Screen { layout: vertical; @@ -100,13 +108,6 @@ class Screen(Generic[ScreenResultType], Widget): background: $surface; } """ - - auto_focus: str | None = "*" - """A selector to determine what to focus automatically when the screen is activated. - - The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors). - Set to `None` to disable auto focus. - """ focused: Reactive[Widget | None] = Reactive(None) """The focused [widget][textual.widget.Widget] or `None` for no focus.""" stack_updates: Reactive[int] = Reactive(0, repaint=False) @@ -665,9 +666,9 @@ class Screen(Generic[ScreenResultType], Widget): """Screen has resumed.""" self.stack_updates += 1 size = self.app.size - if self.auto_focus is not None and self.focused is None: + if self.AUTO_FOCUS is not None and self.focused is None: try: - to_focus = self.query(self.auto_focus).first() + to_focus = self.query(self.AUTO_FOCUS).first() except NoMatches: pass else: diff --git a/tests/test_screens.py b/tests/test_screens.py index 7e20348a9..2e3dbfcbe 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -170,24 +170,24 @@ async def test_auto_focus(): assert isinstance(app.focused, Button) app.pop_screen() - MyScreen.auto_focus = None + MyScreen.AUTO_FOCUS = None await app.push_screen(MyScreen()) assert app.focused is None app.pop_screen() - MyScreen.auto_focus = "Input" + MyScreen.AUTO_FOCUS = "Input" await app.push_screen(MyScreen()) assert isinstance(app.focused, Input) assert app.focused.id == "one" app.pop_screen() - MyScreen.auto_focus = "#two" + MyScreen.AUTO_FOCUS = "#two" await app.push_screen(MyScreen()) assert isinstance(app.focused, Input) assert app.focused.id == "two" # If we push and pop another screen, focus should be preserved for #two. - MyScreen.auto_focus = None + MyScreen.AUTO_FOCUS = None await app.push_screen(MyScreen()) assert app.focused is None app.pop_screen() From 17af4735588a657f465cea01cfdd136b980ba677 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: Tue, 9 May 2023 16:58:56 +0100 Subject: [PATCH 06/85] Annotate no-op. --- 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 7103c630c..f2f99ba42 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -813,7 +813,7 @@ class Widget(DOMNode): elif before is not None and after is not None: raise WidgetError("Only one of `before` or `after` can be handled.") - if child is before or child is after: + if child is before or child is after: # no-op return def _to_widget(child: int | Widget, called: str) -> Widget: From fcf9806f6b79b549633fa49e263415e794febcde 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: Tue, 9 May 2023 17:00:07 +0100 Subject: [PATCH 07/85] Add clarifying comment. --- src/textual/widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index f2f99ba42..b116f5393 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -813,7 +813,8 @@ class Widget(DOMNode): elif before is not None and after is not None: raise WidgetError("Only one of `before` or `after` can be handled.") - if child is before or child is after: # no-op + # We short-circuit the no-op, otherwise it will error later down the road. + if child is before or child is after: return def _to_widget(child: int | Widget, called: str) -> Widget: From d673175e621f2c289207fac0ee79bfa51e5a99f9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 May 2023 11:17:02 +0100 Subject: [PATCH 08/85] Experimenting with placing _load_directory in a worker This isn't the final form, not even close, this is more to help test out the idea and how well it will work. Note the very deliberate sleep in the code that's there to emulate loading from a slow blocking source. This will be removed and tidied up before a final PR, of course. The main aim here is to emulate a worst-case scenario so that the use of a worker can be tried out with some confidence. See #2456. --- src/textual/widgets/_directory_tree.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 7c0dc6ba0..3cab29220 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -7,6 +7,7 @@ from typing import ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType +from .. import work from ..events import Mount from ..message import Message from ..reactive import var @@ -238,10 +239,18 @@ class DirectoryTree(Tree[DirEntry]): Returns: An iterator of `Path` objects. """ - # TODO: Place this in a loop with a sleep to slow things down for - # testing. - return directory.iterdir() + # TODO: Not like this. Oh so very not like this. This is here to + # slow things down on purpose, to emulate loading directory + # information from a slow source. + # + # REMOVE BEFORE FLIGHT! + import time + for entry in directory.iterdir(): + yield entry + time.sleep(0.05) + + @work def _load_directory(self, node: TreeNode[DirEntry]) -> None: """Load the directory contents for a given node. @@ -255,12 +264,13 @@ class DirectoryTree(Tree[DirEntry]): key=lambda path: (not path.is_dir(), path.name.lower()), ) for path in directory: - node.add( + self.app.call_from_thread( + node.add, path.name, data=DirEntry(path), allow_expand=path.is_dir(), ) - node.expand() + self.app.call_from_thread(node.expand) def _on_mount(self, _: Mount) -> None: self._load_directory(self.root) From 8b9a8e417415860cd4726dc50122a6c210ac177b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 May 2023 12:06:11 +0100 Subject: [PATCH 09/85] Simplify _load_directory Move the node population code into its own method, the idea here being that the update happens in one call to call_from_thread rather than spawning lots of calls to it. --- src/textual/widgets/_directory_tree.py | 35 ++++++++++++++++++-------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3cab29220..97294f8bc 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -250,6 +250,23 @@ class DirectoryTree(Tree[DirEntry]): yield entry time.sleep(0.05) + def _populate_node( + self, node: TreeNode[DirEntry], directory: Iterable[Path] + ) -> None: + """Populate the given node with the contents of a directory. + + Args: + node: The node to populate. + directory: The directory contents to populate it with. + """ + for path in directory: + node.add( + path.name, + data=DirEntry(path), + allow_expand=path.is_dir(), + ) + node.expand() + @work def _load_directory(self, node: TreeNode[DirEntry]) -> None: """Load the directory contents for a given node. @@ -259,18 +276,14 @@ class DirectoryTree(Tree[DirEntry]): """ assert node.data is not None node.data.loaded = True - directory = sorted( - self.filter_paths(self._directory_content(node.data.path)), - key=lambda path: (not path.is_dir(), path.name.lower()), + self.app.call_from_thread( + self._populate_node, + node, + sorted( + self.filter_paths(self._directory_content(node.data.path)), + key=lambda path: (not path.is_dir(), path.name.lower()), + ), ) - for path in directory: - self.app.call_from_thread( - node.add, - path.name, - data=DirEntry(path), - allow_expand=path.is_dir(), - ) - self.app.call_from_thread(node.expand) def _on_mount(self, _: Mount) -> None: self._load_directory(self.root) From 39971876d06c30fd5c5db5ee6fb2fe47a9fe5b13 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 10 May 2023 16:26:36 +0100 Subject: [PATCH 10/85] WiP: Throttle back the number of concurrent loads of a DirectoryTree Having got the initial version of background loading of nodes in the directory tree working, this moves to a gentler approach where only so many loads run at once, and a queue of jobs that need to be completed is kept. This is an end-of-coding-session WiP commit; there's more to come on this. But at the moment I'm happy with the way it's going. --- src/textual/widgets/_directory_tree.py | 67 +++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 97294f8bc..0f7629bc7 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -2,10 +2,12 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path +from queue import Empty, Queue from typing import ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType +from typing_extensions import Final from .. import work from ..events import Mount @@ -126,6 +128,8 @@ class DirectoryTree(Tree[DirEntry]): disabled=disabled, ) self.path = path + self._waiting_load_jobs: Queue[TreeNode[DirEntry]] = Queue() + self._running_load_jobs: set[int] = set() def reload(self) -> None: """Reload the `DirectoryTree` contents.""" @@ -267,6 +271,13 @@ class DirectoryTree(Tree[DirEntry]): ) node.expand() + @dataclass + class _LoadFinished(Message): + """Internal message to mark when a load of a node is finished.""" + + node: TreeNode[DirEntry] + """The node that has finished loading.""" + @work def _load_directory(self, node: TreeNode[DirEntry]) -> None: """Load the directory contents for a given node. @@ -276,6 +287,7 @@ class DirectoryTree(Tree[DirEntry]): """ assert node.data is not None node.data.loaded = True + # TODO: Perhaps move this out of here and... self.app.call_from_thread( self._populate_node, node, @@ -284,9 +296,60 @@ class DirectoryTree(Tree[DirEntry]): key=lambda path: (not path.is_dir(), path.name.lower()), ), ) + # TODO: ...attach it to this and have the receiver update the tree? + self.post_message(self._LoadFinished(node)) + + _MAX_CONCURRENT_JOBS: Final[int] = 5 + """The maximum number of load jobs to run at the same time.""" + + # TODO: Remove debug logging before going to an actual PR. + + def _process_load_jobs(self) -> None: + """Process the incoming load request queue.""" + # While we still have spare capacity... + self.log.debug( + f"LOAD QUEUE: Running job count is {len(self._running_load_jobs)}" + ) + while len(self._running_load_jobs) <= self._MAX_CONCURRENT_JOBS: + try: + # ...pull a load job off the queue. + new_job = self._waiting_load_jobs.get(block=False) + except Empty: + # Queue is empty; our work here is done. + self.log.debug("LOAD QUEUE: New job queue is empty") + return + # We've got a new directory load job, add it to the collection + # of running jobs and kick off the load. + self.log.debug(f"LOAD QUEUE: Staring a new job running {new_job.id}") + self._running_load_jobs.add(new_job.id) + self._load_directory(new_job) + self.log.debug(f"LOAD QUEUE: Running job queue is full") + + def _on_directory_tree__load_finished( + self, event: DirectoryTree._LoadFinished + ) -> None: + """Act on a signal that a node has finished loading. + + Args: + event: The event to process. + """ + event.stop() + self.log.debug(f"LOAD QUEUE: Done {event.node.id} - REMOVING") + self._running_load_jobs.remove(event.node.id) + self._process_load_jobs() + + def _add_load_job(self, node: TreeNode[DirEntry]) -> None: + """Add a directory loading job to the queue. + + Args: + node: The node that needs loading. + """ + self.log.debug(f"LOAD QUEUE: New {node.id} - ADDING") + self._waiting_load_jobs.put(node) + self._process_load_jobs() def _on_mount(self, _: Mount) -> None: - self._load_directory(self.root) + self._add_load_job(self.root) def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() @@ -295,7 +358,7 @@ class DirectoryTree(Tree[DirEntry]): return if dir_entry.path.is_dir(): if not dir_entry.loaded: - self._load_directory(event.node) + self._add_load_job(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From 5a02f7335e65b97ce71914b57be9b1f94f3c8048 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 10 May 2023 22:21:30 +0200 Subject: [PATCH 11/85] fix docstrings --- src/textual/geometry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 706e264f5..d228391bb 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -852,8 +852,8 @@ class Region(NamedTuple): ``` Args: - cut: An offset from self.x where the cut should be made. May be negative, - for the offset to start from the right edge. + cut: An offset from self.y where the cut should be made. May be negative, + for the offset to start from the bottom edge. Returns: Two regions, which add up to the original (self). From 0dc89dc2a807c0a8ca81a8f2632e308dcbaff7ec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 10 May 2023 22:25:04 +0200 Subject: [PATCH 12/85] wrong axis --- src/textual/geometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index d228391bb..32dbd0a7d 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -839,7 +839,7 @@ class Region(NamedTuple): @lru_cache(maxsize=1024) def split_horizontal(self, cut: int) -> tuple[Region, Region]: - """Split a region in to two, from a given x offset. + """Split a region in to two, from a given y offset. ``` ┌─────────┐ From 05dc877a24e95dc7a82a32be97fc255b16a74ccc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 08:31:01 +0100 Subject: [PATCH 13/85] Check for the worker being cancelled So far this is working fine, but there was an issue where, if the load of a very large directory was started, and then the application was cancelled right away, the application would close down but there would be a long pause until the shell prompt came back, the cause presumably being that we were waiting for that particular thread to end. So here I make sure I check the cancelled state of the worker. This would also suggest that, while I turned the use of iterdir into a loop so I could throw the sleep in to emulate a slow directory load, I *actually* want to do this in a loop so I can test the cancelled state as we stream in the directory content. --- src/textual/widgets/_directory_tree.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0f7629bc7..fc60ab500 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -13,6 +13,7 @@ from .. import work from ..events import Mount from ..message import Message from ..reactive import var +from ..worker import get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode @@ -250,7 +251,10 @@ class DirectoryTree(Tree[DirEntry]): # REMOVE BEFORE FLIGHT! import time + worker = get_current_worker() for entry in directory.iterdir(): + if worker.is_cancelled: + return yield entry time.sleep(0.05) From df0f73ba3bec55f89f48a126c82132b2509663c7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 08:35:36 +0100 Subject: [PATCH 14/85] Remove debug logging --- src/textual/widgets/_directory_tree.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index fc60ab500..8ae59581b 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -306,28 +306,20 @@ class DirectoryTree(Tree[DirEntry]): _MAX_CONCURRENT_JOBS: Final[int] = 5 """The maximum number of load jobs to run at the same time.""" - # TODO: Remove debug logging before going to an actual PR. - def _process_load_jobs(self) -> None: """Process the incoming load request queue.""" # While we still have spare capacity... - self.log.debug( - f"LOAD QUEUE: Running job count is {len(self._running_load_jobs)}" - ) while len(self._running_load_jobs) <= self._MAX_CONCURRENT_JOBS: try: # ...pull a load job off the queue. new_job = self._waiting_load_jobs.get(block=False) except Empty: # Queue is empty; our work here is done. - self.log.debug("LOAD QUEUE: New job queue is empty") return # We've got a new directory load job, add it to the collection # of running jobs and kick off the load. - self.log.debug(f"LOAD QUEUE: Staring a new job running {new_job.id}") self._running_load_jobs.add(new_job.id) self._load_directory(new_job) - self.log.debug(f"LOAD QUEUE: Running job queue is full") def _on_directory_tree__load_finished( self, event: DirectoryTree._LoadFinished @@ -338,7 +330,6 @@ class DirectoryTree(Tree[DirEntry]): event: The event to process. """ event.stop() - self.log.debug(f"LOAD QUEUE: Done {event.node.id} - REMOVING") self._running_load_jobs.remove(event.node.id) self._process_load_jobs() @@ -348,7 +339,6 @@ class DirectoryTree(Tree[DirEntry]): Args: node: The node that needs loading. """ - self.log.debug(f"LOAD QUEUE: New {node.id} - ADDING") self._waiting_load_jobs.put(node) self._process_load_jobs() From 4ead43c149fae6027c2217261a45d028177dca59 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:10:55 +0100 Subject: [PATCH 15/85] Set up the job tracking before setting the path Setting the path to anything other than "." is going to result in a reset happening, so we need the tracking support in place first. --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 8ae59581b..86478063c 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -128,9 +128,9 @@ class DirectoryTree(Tree[DirEntry]): classes=classes, disabled=disabled, ) - self.path = path self._waiting_load_jobs: Queue[TreeNode[DirEntry]] = Queue() self._running_load_jobs: set[int] = set() + self.path = path def reload(self) -> None: """Reload the `DirectoryTree` contents.""" From ce7a78db69fc0366463006386fcb8cd8d36bba03 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:12:10 +0100 Subject: [PATCH 16/85] Have the reset method take part in background loading One instance of a call to _load_directory that I missed. --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 86478063c..030ed8857 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -135,7 +135,7 @@ class DirectoryTree(Tree[DirEntry]): def reload(self) -> None: """Reload the `DirectoryTree` contents.""" self.reset(str(self.path), DirEntry(Path(self.path))) - self._load_directory(self.root) + self._add_load_job(self.root) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. From 791f2ea1890dc494ce2fee577ba38ac74c7a2ff8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:16:54 +0100 Subject: [PATCH 17/85] Ensure we don't create a job for a node that's already being loaded --- src/textual/widgets/_directory_tree.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 030ed8857..50b03dd2f 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -316,10 +316,12 @@ class DirectoryTree(Tree[DirEntry]): except Empty: # Queue is empty; our work here is done. return - # We've got a new directory load job, add it to the collection - # of running jobs and kick off the load. - self._running_load_jobs.add(new_job.id) - self._load_directory(new_job) + # At this point we've got a new directory load job; add it to + # the collection of running jobs and kick off the load, but only + # if there isn't already a job for it. + if not new_job.id in self._running_load_jobs: + self._running_load_jobs.add(new_job.id) + self._load_directory(new_job) def _on_directory_tree__load_finished( self, event: DirectoryTree._LoadFinished From 08246d84ac8e9928dcd949e2b77a115991a15137 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:57:30 +0100 Subject: [PATCH 18/85] Don't post the finished message if we've been cancelled --- src/textual/widgets/_directory_tree.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 50b03dd2f..9c60aff94 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -301,7 +301,8 @@ class DirectoryTree(Tree[DirEntry]): ), ) # TODO: ...attach it to this and have the receiver update the tree? - self.post_message(self._LoadFinished(node)) + if not get_current_worker().is_cancelled: + self.post_message(self._LoadFinished(node)) _MAX_CONCURRENT_JOBS: Final[int] = 5 """The maximum number of load jobs to run at the same time.""" From 9ae8e47c6ce7bf97df977c87a6974811ef57229a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:59:18 +0100 Subject: [PATCH 19/85] Add a method for cancelling all of the load jobs This marks all current jobs as cancelled and also removes all pending jobs. --- src/textual/widgets/_directory_tree.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9c60aff94..b1fea1a0e 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -307,6 +307,16 @@ class DirectoryTree(Tree[DirEntry]): _MAX_CONCURRENT_JOBS: Final[int] = 5 """The maximum number of load jobs to run at the same time.""" + def _cancel_all_jobs(self) -> None: + """Cancel all running load jobs.""" + self._waiting_load_jobs = Queue() + self._running_load_jobs = set() + # TODO: Check if there's an Textual-API-way to say "get all workers + # in this DOM node", or "cancel all of the works I made", or + # something. This seems fine, but I want to be 100% sure. + for job in (worker for worker in self.app.workers if worker.node == self): + job.cancel() + def _process_load_jobs(self) -> None: """Process the incoming load request queue.""" # While we still have spare capacity... From 82a08177af299c12c92f6c5d313e119902562ed4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 09:59:57 +0100 Subject: [PATCH 20/85] Cancel any loads when resetting the tree --- src/textual/widgets/_directory_tree.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index b1fea1a0e..7dbbf789f 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -134,6 +134,13 @@ class DirectoryTree(Tree[DirEntry]): def reload(self) -> None: """Reload the `DirectoryTree` contents.""" + # We're about to nuke the whole tree and start over, so we don't + # want any dangling load jobs. Before we do anything else, ensure + # they're all marked as cancelled and that the queue of pending jobs + # has been emptied. + self._cancel_all_jobs() + # That out of the way, we can reset the tree and start loading the + # root's content. self.reset(str(self.path), DirEntry(Path(self.path))) self._add_load_job(self.root) From 05eeaa767984a15ce4af23d079dc505b5d5e0068 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 10:53:07 +0100 Subject: [PATCH 21/85] Tidy up _load_directory Explain some about the decisions made, and also throw in a bit of over-cautious worker cancellation checking. --- src/textual/widgets/_directory_tree.py | 44 ++++++++++++++++++++------ 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 7dbbf789f..e3a9eb33a 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -296,19 +296,43 @@ class DirectoryTree(Tree[DirEntry]): Args: node: The node to load the directory contents for. """ + + # We should not ever be asked to load a directory for a node that + # has no directory information. assert node.data is not None + + # Mark the node as loaded; we do this as soon as possible. node.data.loaded = True - # TODO: Perhaps move this out of here and... - self.app.call_from_thread( - self._populate_node, - node, - sorted( - self.filter_paths(self._directory_content(node.data.path)), - key=lambda path: (not path.is_dir(), path.name.lower()), - ), + + # From now on we get the directory content and populate the node in + # simple steps, checking that the worker hasn't been cancelled at + # every step of the way. We /could/ just run to the end, but we + # might as well drop out of here as soon as we can tell we've been + # asked to stop. + worker = get_current_worker() + + # Load up the content of the directly. + content = self.filter_paths(self._directory_content(node.data.path)) + if worker.is_cancelled: + return + + # We're still going, sort the content, case-insensitive, placing + # directory entries up front. + content = sorted( + content, + key=lambda path: (not path.is_dir(), path.name.lower()), ) - # TODO: ...attach it to this and have the receiver update the tree? - if not get_current_worker().is_cancelled: + if worker.is_cancelled: + return + + # We have directory content, it's filtered, it's sorted, we're still + # working, so now let's update the actual node in the tree. + self.app.call_from_thread(self._populate_node, node, content) + + # Finally, if we're 100% sure we've not been cancelled, post a + # message to say the load has finished. Our caller should not be + # told we finished fine if they've cancelled us. + if not worker.is_cancelled: self.post_message(self._LoadFinished(node)) _MAX_CONCURRENT_JOBS: Final[int] = 5 From 9b41b743fee554fd614af29b569802e0f4b99680 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 11:13:19 +0100 Subject: [PATCH 22/85] Remove the artificial slowdown --- src/textual/widgets/_directory_tree.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index e3a9eb33a..0c8f390e1 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -251,19 +251,11 @@ class DirectoryTree(Tree[DirEntry]): Returns: An iterator of `Path` objects. """ - # TODO: Not like this. Oh so very not like this. This is here to - # slow things down on purpose, to emulate loading directory - # information from a slow source. - # - # REMOVE BEFORE FLIGHT! - import time - worker = get_current_worker() for entry in directory.iterdir(): if worker.is_cancelled: return yield entry - time.sleep(0.05) def _populate_node( self, node: TreeNode[DirEntry], directory: Iterable[Path] From c45126b21cbed4e2c007d22093bfe2ade4ff9f68 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 11:19:30 +0100 Subject: [PATCH 23/85] Update the ChangeLog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b136f51..5e37712ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521 +- `DirectoryTree` now loads directory contents in a worker https://github.com/Textualize/textual/issues/2456 ### Fixed From 58cf825eb4f105e4ad42171c183bab2b1c559848 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, 11 May 2023 12:19:29 +0100 Subject: [PATCH 24/85] Use footer--description component class. Related issues: #2544. --- src/textual/widgets/_footer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index c9b74ddc0..a52e05785 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -98,6 +98,7 @@ class Footer(Widget): highlight_style = self.get_component_rich_style("footer--highlight") highlight_key_style = self.get_component_rich_style("footer--highlight-key") key_style = self.get_component_rich_style("footer--key") + description_style = self.get_component_rich_style("footer--description") bindings = [ binding @@ -122,7 +123,7 @@ class Footer(Widget): (f" {key_display} ", highlight_key_style if hovered else key_style), ( f" {binding.description} ", - highlight_style if hovered else base_style, + highlight_style if hovered else base_style + description_style, ), meta={ "@click": f"app.check_bindings('{binding.key}')", From 8dc801fec76eb25474524a36c06d1bd025933213 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, 11 May 2023 15:03:21 +0100 Subject: [PATCH 25/85] Changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b136f51..8d6821f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TreeNode.expand` and `TreeNode.expand_all` not posting a `Tree.NodeExpanded` message https://github.com/Textualize/textual/issues/2535 - Fixed `TreeNode.collapse` and `TreeNode.collapse_all` not posting a `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 +- `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 ## [0.24.1] - 2023-05-08 From 4d225b8ebb5648f6bdb65214cbae49799ad8932d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 15:22:19 +0100 Subject: [PATCH 26/85] Correct a comment typo --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0c8f390e1..9ba82d6df 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -303,7 +303,7 @@ class DirectoryTree(Tree[DirEntry]): # asked to stop. worker = get_current_worker() - # Load up the content of the directly. + # Load up the content of the directory. content = self.filter_paths(self._directory_content(node.data.path)) if worker.is_cancelled: return From a065ff572e752c9e3290c422339ae977a98edfb2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 12 May 2023 08:34:27 +0100 Subject: [PATCH 27/85] Correct spelling in a docstring (#2552) --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index e136a5588..c6efd1c4f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -887,7 +887,7 @@ class DOMNode(MessagePump): Example: - Here's how you could detect when the app changes from dark to light mode (and visa versa). + Here's how you could detect when the app changes from dark to light mode (and vice versa). ```python def on_dark_change(old_value:bool, new_value:bool): From 4434b599825a20e26d85fbebede3b1847a411dae Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 10 May 2023 22:40:15 +0200 Subject: [PATCH 28/85] spacing diagram --- reference/spacing.monopic | Bin 0 -> 1716 bytes src/textual/geometry.py | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 reference/spacing.monopic diff --git a/reference/spacing.monopic b/reference/spacing.monopic new file mode 100644 index 0000000000000000000000000000000000000000..474d7d3ad7d7bcbd883f1f744b0de9dec5dd82ba GIT binary patch literal 1716 zcmV;l221(>O;1iwP)S1pABzY8000000u$|9-E*5b5dT*?JkuHU4SDVBJ>6?3!(dys zxnjrxX`9RVe?LiRF%Xj)h~hNqOG{+5+FkAb_Cx5=D^~x=bzONkkNWYmK>F#fsM0zu z^0<^+@22@7@O*DmtUE(UkECTKYwsrTy}e8}as6;7y&L3v`XNp?>Nz<2=JgP3(x>t8 z9KAaIDKo=OEq~VWy2xvpEBaq={buB@NOR5Z*V`k|4fN~JvQ{b)V-QL$9d-G`{z=|# zmh2R9WJ!x?GHHG(X)&2JzL2!hv(;R@%4EKgYDQh&OW!*b8=1vglXlIJs6`KVRlF+7 zjVzVMv!e8FC>9}#f*@3URQqI8{D{rgs9Ea$q^^_oK2fw=x|+iJ+wR@Fe}#Iw0PSjtK>P0+NYb1%-a{% z^}a0bcl&l=l_<8=P!7h6dt)-!Vz&CLlbzX{b|dgfLB)1!w$2ND?JSLaRr;Tz=?P!+ zQr)lAyK1Ilv1*Lk&TW?gHtqIxXc}pHIw-3%imB~ju2xr1ncUVbGwY(vwf50z0dTm+ z81xcFneI}hFz+)7JAw?PgZXFnyT5ss9V=(5XDPFNFecE&8Lf|#0azEY524rkW9##` z?LwQR+Mg7t(97)5z4^CL{~m`L%6e+k(_6ha+tU~V)f-lBleN@dIJAVmlt8j$Oec5I zTM^4mT6Z?s>f;ycYc~cz*=Fk$Pn~OEOBNKk)=ZY>a-c5D&cKb@7f19taHEe%V-oT&77qx?*MLKg=o%(}rTxJGEg01_Bn z69Y(K0uS^RLKAvBp$YRjp{a`u_?MaRDeXmiXHs1eco_MJc|e3tPN&(W=`?bdsUz;J zbRmH|g%D>|5y8$BqTs_x`H4a|bg?NAs~=J}ppoWE1F=cx^`9pV1m_lP-mZ&g?ip83 zvUHap3=@G4z~5#2M>AIe?Y{jx-N-gbHW)LE45!c|fWoS%>*CNja1+|WTPbp*GeRBh zMdSW8G`bLL&{C{Hi?C);5k3bs4M}LWO{qO-^u{`36#wanyE58-ra9zs4+FBXT>o3R4=ECTv00uWGKsTq6XWO6fByh1ZpOlrpR$PF-vdAYHe zo0p4DZY*9R!4BVFNHENUc#Z;sa2F87%z(g=k0OxUn8pBBuK}B=51af+KYM_ZUy}TL zQkfq45GQY65b_3CW&Xe6$pvAD`%7kRslu#YNdx=LK_axyWl^2mK$<>8dL8h$MM3#Qs10c#@w8w7BJ zMAxyu6HDP#`=e;K{n1P9j|P(t+0m3n7kX=rF7*31y0DLjjV?kAP%r3ov89LX_~LdK z5?4QDN053T$UP8*Ed;>={i4~043we?&8}vT-{=`T3YVU-yE|pVGwUv*>Mqe9-@7|c z!q{1Df*c*k5ado9)ZGC)cXT)v%xWs2==V53pHRw!x8U#vnxquJKw(YTyX%)|cEE(R za7lKj(j8fMs`pC%8|U|jl`M@VBa=9A^8z(VvQjNRcGlCMDvNXpJ*{M(oi=a+AcfTpOW_Sw|3)!y~`U9eEIqAtgP31^rZcr_%)Fq^ky4*Jh46pq;KY%O-kR@h7a7k|6U>HHD|x8Ddj$_T_*X(JYtHpk{i4Wp0VcU4y&kN*Qj KFnbEGKL7whc2Il( literal 0 HcmV?d00001 diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 32dbd0a7d..4b337a4d9 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -853,7 +853,7 @@ class Region(NamedTuple): Args: cut: An offset from self.y where the cut should be made. May be negative, - for the offset to start from the bottom edge. + for the offset to start from the lower edge. Returns: Two regions, which add up to the original (self). @@ -909,7 +909,19 @@ class Region(NamedTuple): class Spacing(NamedTuple): """The spacing around a renderable, such as padding and border - Spacing is defined by four integers for the space at the top, right, bottom, and left of a region, + Spacing is defined by four integers for the space at the top, right, bottom, and left of a region. + + ``` + ┌ ─ ─ ─ ─ ─ ─ ─▲─ ─ ─ ─ ─ ─ ─ ─ ┐ + │ top + │ ┏━━━━━▼━━━━━━┓ │ + ◀──────▶┃ ┃◀───────▶ + │ left ┃ ┃ right │ + ┃ ┃ + │ ┗━━━━━▲━━━━━━┛ │ + │ bottom + └ ─ ─ ─ ─ ─ ─ ─▼─ ─ ─ ─ ─ ─ ─ ─ ┘ + ``` Example: ```python From 9a134b49203f306167306ebc61b4cd9e8d3f19c0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 15:36:49 +0200 Subject: [PATCH 29/85] virtual size and docks --- src/textual/_arrange.py | 4 +++- src/textual/_compositor.py | 11 ++++++++++- src/textual/_layout.py | 6 +++--- src/textual/_partition.py | 5 +++-- src/textual/layouts/grid.py | 2 +- src/textual/screen.py | 5 ++++- tests/test_arrange.py | 24 ++++++++---------------- 7 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index b415d8983..1484b120f 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -94,7 +94,9 @@ def arrange( ) dock_region = dock_region.shrink(margin).translate(align_offset) add_placement( - _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) + _WidgetPlacement( + dock_region, null_spacing, dock_widget, top_z, True, False + ) ) dock_spacing = Spacing(top, right, bottom, left) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 61e4f3de8..115dafa9f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -592,6 +592,7 @@ class Compositor: get_layer_index = layers_to_index.get scroll_spacing = arrange_result.scroll_spacing + total_region = total_region.shrink(scroll_spacing) # Add all the widgets for sub_region, margin, sub_widget, z, fixed, overlay in reversed( @@ -621,13 +622,21 @@ class Compositor: constrain in ("y", "both"), ) + if overlay: + clip_region = no_clip + else: + if fixed: + clip_region = sub_clip + else: + clip_region = sub_clip.shrink(scroll_spacing) + add_widget( sub_widget, sub_region, widget_region, ((1, 0, 0),) if overlay else widget_order, layer_order, - no_clip if overlay else sub_clip, + clip_region, visible, ) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index e0061c148..40716b7f7 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -72,9 +72,9 @@ class WidgetPlacement(NamedTuple): region: Region margin: Spacing widget: Widget - order: int = 0 - fixed: bool = False - overlay: bool = False + order: int + fixed: bool + overlay: bool class Layout(ABC): diff --git a/src/textual/_partition.py b/src/textual/_partition.py index 858846f54..7bb46794d 100644 --- a/src/textual/_partition.py +++ b/src/textual/_partition.py @@ -21,8 +21,9 @@ def partition( """ result: tuple[list[T], list[T]] = ([], []) - appends = (result[0].append, result[1].append) + append0 = result[0].append + append1 = result[1].append for value in iterable: - appends[1 if pred(value) else 0](value) + (append1 if pred(value) else append0)(value) return result diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 3ade70ab2..d26752f75 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -153,7 +153,7 @@ class GridLayout(Layout): .shrink(margin) .clip_size(cell_size) ) - add_placement(WidgetPlacement(region, margin, widget)) + add_placement(WidgetPlacement(region, margin, widget, 0, False, False)) add_widget(widget) return (placements, set(widgets)) diff --git a/src/textual/screen.py b/src/textual/screen.py index 34db473ef..d58a69031 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -580,7 +580,10 @@ class Screen(Generic[ScreenResultType], Widget): ) in layers: if widget in exposed_widgets: if widget._size_updated( - region.size, virtual_size, container_size, layout=False + region.size, + virtual_size, + container_size, + layout=False, ): widget.post_message( ResizeEvent( diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 38f5a191d..8014d0d31 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -24,10 +24,8 @@ def test_arrange_dock_top(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement( - Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True - ), - WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), + WidgetPlacement(Region(0, 0, 80, 1), Spacing(), header, TOP_Z, True, False), + WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, 0, False, False), ] assert result.widgets == {child, header} @@ -41,10 +39,8 @@ def test_arrange_dock_left(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement( - Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True - ), - WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), + WidgetPlacement(Region(0, 0, 10, 24), Spacing(), header, TOP_Z, True, False), + WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, 0, False, False), ] assert result.widgets == {child, header} @@ -58,10 +54,8 @@ def test_arrange_dock_right(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement( - Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True - ), - WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), + WidgetPlacement(Region(70, 0, 10, 24), Spacing(), header, TOP_Z, True, False), + WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, 0, False, False), ] assert result.widgets == {child, header} @@ -75,10 +69,8 @@ def test_arrange_dock_bottom(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement( - Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True - ), - WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), + WidgetPlacement(Region(0, 23, 80, 1), Spacing(), header, TOP_Z, True, False), + WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, 0, False, False), ] assert result.widgets == {child, header} From 47970dd6229f252e744d4226c19f887a6f8bf6e6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 15:45:25 +0200 Subject: [PATCH 30/85] remove total region update --- src/textual/_compositor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 115dafa9f..fb1a642d5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -603,11 +603,11 @@ class Compositor: if fixed: widget_region = sub_region + placement_offset else: - total_region = total_region.union( - sub_region.grow( - margin if layer_index else margin + scroll_spacing - ) - ) + # total_region = total_region.union( + # sub_region.grow( + # margin if layer_index else margin + scroll_spacing + # ) + # ) widget_region = sub_region + placement_scroll_offset widget_order = order + ((layer_index, z, layer_order),) From 4e069abd2e79f8c7caa7683f85af98a27a07a38d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 15:49:39 +0200 Subject: [PATCH 31/85] Revert "remove total region update" This reverts commit 47970dd6229f252e744d4226c19f887a6f8bf6e6. --- src/textual/_compositor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index fb1a642d5..115dafa9f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -603,11 +603,11 @@ class Compositor: if fixed: widget_region = sub_region + placement_offset else: - # total_region = total_region.union( - # sub_region.grow( - # margin if layer_index else margin + scroll_spacing - # ) - # ) + total_region = total_region.union( + sub_region.grow( + margin if layer_index else margin + scroll_spacing + ) + ) widget_region = sub_region + placement_scroll_offset widget_order = order + ((layer_index, z, layer_order),) From c2e7b619f19de3423c33e7caf4bb209a1f25417a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 15:53:47 +0200 Subject: [PATCH 32/85] Revert "virtual size and docks" This reverts commit 9a134b49203f306167306ebc61b4cd9e8d3f19c0. --- src/textual/_arrange.py | 4 +--- src/textual/_compositor.py | 11 +---------- src/textual/_layout.py | 6 +++--- src/textual/_partition.py | 5 ++--- src/textual/layouts/grid.py | 2 +- src/textual/screen.py | 5 +---- tests/test_arrange.py | 24 ++++++++++++++++-------- 7 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 1484b120f..b415d8983 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -94,9 +94,7 @@ def arrange( ) dock_region = dock_region.shrink(margin).translate(align_offset) add_placement( - _WidgetPlacement( - dock_region, null_spacing, dock_widget, top_z, True, False - ) + _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) ) dock_spacing = Spacing(top, right, bottom, left) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 115dafa9f..61e4f3de8 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -592,7 +592,6 @@ class Compositor: get_layer_index = layers_to_index.get scroll_spacing = arrange_result.scroll_spacing - total_region = total_region.shrink(scroll_spacing) # Add all the widgets for sub_region, margin, sub_widget, z, fixed, overlay in reversed( @@ -622,21 +621,13 @@ class Compositor: constrain in ("y", "both"), ) - if overlay: - clip_region = no_clip - else: - if fixed: - clip_region = sub_clip - else: - clip_region = sub_clip.shrink(scroll_spacing) - add_widget( sub_widget, sub_region, widget_region, ((1, 0, 0),) if overlay else widget_order, layer_order, - clip_region, + no_clip if overlay else sub_clip, visible, ) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 40716b7f7..e0061c148 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -72,9 +72,9 @@ class WidgetPlacement(NamedTuple): region: Region margin: Spacing widget: Widget - order: int - fixed: bool - overlay: bool + order: int = 0 + fixed: bool = False + overlay: bool = False class Layout(ABC): diff --git a/src/textual/_partition.py b/src/textual/_partition.py index 7bb46794d..858846f54 100644 --- a/src/textual/_partition.py +++ b/src/textual/_partition.py @@ -21,9 +21,8 @@ def partition( """ result: tuple[list[T], list[T]] = ([], []) - append0 = result[0].append - append1 = result[1].append + appends = (result[0].append, result[1].append) for value in iterable: - (append1 if pred(value) else append0)(value) + appends[1 if pred(value) else 0](value) return result diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index d26752f75..3ade70ab2 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -153,7 +153,7 @@ class GridLayout(Layout): .shrink(margin) .clip_size(cell_size) ) - add_placement(WidgetPlacement(region, margin, widget, 0, False, False)) + add_placement(WidgetPlacement(region, margin, widget)) add_widget(widget) return (placements, set(widgets)) diff --git a/src/textual/screen.py b/src/textual/screen.py index d58a69031..34db473ef 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -580,10 +580,7 @@ class Screen(Generic[ScreenResultType], Widget): ) in layers: if widget in exposed_widgets: if widget._size_updated( - region.size, - virtual_size, - container_size, - layout=False, + region.size, virtual_size, container_size, layout=False ): widget.post_message( ResizeEvent( diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 8014d0d31..38f5a191d 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -24,8 +24,10 @@ def test_arrange_dock_top(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement(Region(0, 0, 80, 1), Spacing(), header, TOP_Z, True, False), - WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, 0, False, False), + WidgetPlacement( + Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] assert result.widgets == {child, header} @@ -39,8 +41,10 @@ def test_arrange_dock_left(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement(Region(0, 0, 10, 24), Spacing(), header, TOP_Z, True, False), - WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, 0, False, False), + WidgetPlacement( + Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] assert result.widgets == {child, header} @@ -54,8 +58,10 @@ def test_arrange_dock_right(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement(Region(70, 0, 10, 24), Spacing(), header, TOP_Z, True, False), - WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, 0, False, False), + WidgetPlacement( + Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] assert result.widgets == {child, header} @@ -69,8 +75,10 @@ def test_arrange_dock_bottom(): result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) assert result.placements == [ - WidgetPlacement(Region(0, 23, 80, 1), Spacing(), header, TOP_Z, True, False), - WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, 0, False, False), + WidgetPlacement( + Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True + ), + WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] assert result.widgets == {child, header} From d061065dcce4f523a5a74bd3919c833aeafbc25b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 18:29:51 +0200 Subject: [PATCH 33/85] optimization for divide --- src/textual/strip.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/textual/strip.py b/src/textual/strip.py index 554acdecf..c1f9e2587 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -379,16 +379,22 @@ class Strip: """ pos = 0 + cell_length = self.cell_length + cuts = [cut for cut in cuts if cut <= cell_length] cache_key = tuple(cuts) cached = self._divide_cache.get(cache_key) if cached is not None: return cached - strips: list[Strip] = [] - add_strip = strips.append - for segments, cut in zip(Segment.divide(self._segments, cuts), cuts): - add_strip(Strip(segments, cut - pos)) - pos = cut + strips: list[Strip] + if cuts == [cell_length]: + strips = [self] + else: + strips = [] + add_strip = strips.append + for segments, cut in zip(Segment.divide(self._segments, cuts), cuts): + add_strip(Strip(segments, cut - pos)) + pos = cut self._divide_cache[cache_key] = strips return strips From d266e3685f37353eb3e201a6538d28e4cc5d7025 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 May 2023 18:57:53 +0200 Subject: [PATCH 34/85] snapshot update (#2555) --- .../__snapshots__/test_snapshots.ambr | 5378 +++++++++-------- 1 file changed, 2695 insertions(+), 2683 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index cbee7ba35..8b03449d9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,137 +21,138 @@ font-weight: 700; } - .terminal-369237853-matrix { + .terminal-1593336641-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-369237853-title { + .terminal-1593336641-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-369237853-r1 { fill: #c5c8c6 } - .terminal-369237853-r2 { fill: #7ae998 } - .terminal-369237853-r3 { fill: #0a180e;font-weight: bold } - .terminal-369237853-r4 { fill: #008139 } - .terminal-369237853-r5 { fill: #e1e1e1 } - .terminal-369237853-r6 { fill: #e76580 } - .terminal-369237853-r7 { fill: #f5e5e9;font-weight: bold } - .terminal-369237853-r8 { fill: #780028 } + .terminal-1593336641-r1 { fill: #c5c8c6 } + .terminal-1593336641-r2 { fill: #7ae998 } + .terminal-1593336641-r3 { fill: #0a180e;font-weight: bold } + .terminal-1593336641-r4 { fill: #008139 } + .terminal-1593336641-r5 { fill: #e3dbce } + .terminal-1593336641-r6 { fill: #e1e1e1 } + .terminal-1593336641-r7 { fill: #e76580 } + .terminal-1593336641-r8 { fill: #f5e5e9;font-weight: bold } + .terminal-1593336641-r9 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignContainersApp + AlignContainersApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - center - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - middle - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + center + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + middle + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -346,202 +347,202 @@ font-weight: 700; } - .terminal-2978213952-matrix { + .terminal-3056812568-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2978213952-title { + .terminal-3056812568-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2978213952-r1 { fill: #c5c8c6 } - .terminal-2978213952-r2 { fill: #e3e3e3 } - .terminal-2978213952-r3 { fill: #004578 } - .terminal-2978213952-r4 { fill: #e1e1e1 } - .terminal-2978213952-r5 { fill: #632ca6 } - .terminal-2978213952-r6 { fill: #dde6ed;font-weight: bold } - .terminal-2978213952-r7 { fill: #14191f } - .terminal-2978213952-r8 { fill: #23568b } + .terminal-3056812568-r1 { fill: #c5c8c6 } + .terminal-3056812568-r2 { fill: #e3e3e3 } + .terminal-3056812568-r3 { fill: #004578 } + .terminal-3056812568-r4 { fill: #e1e1e1 } + .terminal-3056812568-r5 { fill: #632ca6 } + .terminal-3056812568-r6 { fill: #dde6ed;font-weight: bold } + .terminal-3056812568-r7 { fill: #14191f } + .terminal-3056812568-r8 { fill: #23568b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - - - - MyApp - ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - oktest - ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ -  0 ────────────────────────────────────── 1 ────────────────────────────────────── 2 ───── - -  Foo       Bar         Baz               Foo       Bar         Baz               Foo      -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH -  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH - ───────────────────────────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + + + MyApp + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + oktest + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ +  0 ────────────────────────────────────── 1 ────────────────────────────────────── 2 ───── + +  Foo       Bar         Baz               Foo       Bar         Baz               Foo      +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY▁▁ ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH +  ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH  0123456789  IJKLMNOPQRSTUVWXY ABCDEFGH + ───────────────────────────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── @@ -571,136 +572,136 @@ font-weight: 700; } - .terminal-3956291897-matrix { + .terminal-1625062503-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3956291897-title { + .terminal-1625062503-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3956291897-r1 { fill: #c5c8c6 } - .terminal-3956291897-r2 { fill: #e3e3e3 } - .terminal-3956291897-r3 { fill: #1e1e1e } - .terminal-3956291897-r4 { fill: #0178d4 } - .terminal-3956291897-r5 { fill: #e1e1e1 } - .terminal-3956291897-r6 { fill: #e2e2e2 } - .terminal-3956291897-r7 { fill: #ddedf9 } + .terminal-1625062503-r1 { fill: #c5c8c6 } + .terminal-1625062503-r2 { fill: #e3e3e3 } + .terminal-1625062503-r3 { fill: #1e1e1e } + .terminal-1625062503-r4 { fill: #0178d4 } + .terminal-1625062503-r5 { fill: #e1e1e1 } + .terminal-1625062503-r6 { fill: #e2e2e2 } + .terminal-1625062503-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputWidthAutoApp + InputWidthAutoApp - - - - InputWidthAutoApp - ▔▔▔▔▔▔▔▔▔▔ - Hello - ▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - - + + + + InputWidthAutoApp + ▔▔▔▔▔▔▔▔▔▔ + Hello + ▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + @@ -731,136 +732,137 @@ font-weight: 700; } - .terminal-2059832628-matrix { + .terminal-2470781732-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2059832628-title { + .terminal-2470781732-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2059832628-r1 { fill: #1e1e1e } - .terminal-2059832628-r2 { fill: #c5c8c6 } - .terminal-2059832628-r3 { fill: #183118 } - .terminal-2059832628-r4 { fill: #124512 } - .terminal-2059832628-r5 { fill: #0c580c } - .terminal-2059832628-r6 { fill: #066c06 } - .terminal-2059832628-r7 { fill: #008000 } + .terminal-2470781732-r1 { fill: #1e1e1e } + .terminal-2470781732-r2 { fill: #c5c8c6 } + .terminal-2470781732-r3 { fill: #e1e1e1 } + .terminal-2470781732-r4 { fill: #183118 } + .terminal-2470781732-r5 { fill: #124512 } + .terminal-2470781732-r6 { fill: #0c580c } + .terminal-2470781732-r7 { fill: #066c06 } + .terminal-2470781732-r8 { fill: #008000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderAlphaApp + BorderAlphaApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + @@ -1055,161 +1057,162 @@ font-weight: 700; } - .terminal-3643133712-matrix { + .terminal-619468389-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3643133712-title { + .terminal-619468389-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3643133712-r1 { fill: #c5c8c6 } - .terminal-3643133712-r2 { fill: #e1e1e1;font-weight: bold } - .terminal-3643133712-r3 { fill: #454a50 } - .terminal-3643133712-r4 { fill: #35383c } - .terminal-3643133712-r5 { fill: #24292f;font-weight: bold } - .terminal-3643133712-r6 { fill: #7c7d7e;font-weight: bold } - .terminal-3643133712-r7 { fill: #000000 } - .terminal-3643133712-r8 { fill: #0c0c0c } - .terminal-3643133712-r9 { fill: #507bb3 } - .terminal-3643133712-r10 { fill: #3c5577 } - .terminal-3643133712-r11 { fill: #dde6ed;font-weight: bold } - .terminal-3643133712-r12 { fill: #75828b;font-weight: bold } - .terminal-3643133712-r13 { fill: #001541 } - .terminal-3643133712-r14 { fill: #0c1833 } - .terminal-3643133712-r15 { fill: #7ae998 } - .terminal-3643133712-r16 { fill: #559767 } - .terminal-3643133712-r17 { fill: #0a180e;font-weight: bold } - .terminal-3643133712-r18 { fill: #192e1f;font-weight: bold } - .terminal-3643133712-r19 { fill: #008139 } - .terminal-3643133712-r20 { fill: #0c592e } - .terminal-3643133712-r21 { fill: #ffcf56 } - .terminal-3643133712-r22 { fill: #a5883f } - .terminal-3643133712-r23 { fill: #211505;font-weight: bold } - .terminal-3643133712-r24 { fill: #3a2a13;font-weight: bold } - .terminal-3643133712-r25 { fill: #b86b00 } - .terminal-3643133712-r26 { fill: #7a4c0c } - .terminal-3643133712-r27 { fill: #e76580 } - .terminal-3643133712-r28 { fill: #964858 } - .terminal-3643133712-r29 { fill: #f5e5e9;font-weight: bold } - .terminal-3643133712-r30 { fill: #978186;font-weight: bold } - .terminal-3643133712-r31 { fill: #780028 } - .terminal-3643133712-r32 { fill: #540c24 } + .terminal-619468389-r1 { fill: #e1e1e1 } + .terminal-619468389-r2 { fill: #c5c8c6 } + .terminal-619468389-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-619468389-r4 { fill: #454a50 } + .terminal-619468389-r5 { fill: #35383c } + .terminal-619468389-r6 { fill: #24292f;font-weight: bold } + .terminal-619468389-r7 { fill: #7c7d7e;font-weight: bold } + .terminal-619468389-r8 { fill: #000000 } + .terminal-619468389-r9 { fill: #0c0c0c } + .terminal-619468389-r10 { fill: #507bb3 } + .terminal-619468389-r11 { fill: #3c5577 } + .terminal-619468389-r12 { fill: #dde6ed;font-weight: bold } + .terminal-619468389-r13 { fill: #75828b;font-weight: bold } + .terminal-619468389-r14 { fill: #001541 } + .terminal-619468389-r15 { fill: #0c1833 } + .terminal-619468389-r16 { fill: #7ae998 } + .terminal-619468389-r17 { fill: #559767 } + .terminal-619468389-r18 { fill: #0a180e;font-weight: bold } + .terminal-619468389-r19 { fill: #192e1f;font-weight: bold } + .terminal-619468389-r20 { fill: #008139 } + .terminal-619468389-r21 { fill: #0c592e } + .terminal-619468389-r22 { fill: #ffcf56 } + .terminal-619468389-r23 { fill: #a5883f } + .terminal-619468389-r24 { fill: #211505;font-weight: bold } + .terminal-619468389-r25 { fill: #3a2a13;font-weight: bold } + .terminal-619468389-r26 { fill: #b86b00 } + .terminal-619468389-r27 { fill: #7a4c0c } + .terminal-619468389-r28 { fill: #e76580 } + .terminal-619468389-r29 { fill: #964858 } + .terminal-619468389-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-619468389-r31 { fill: #978186;font-weight: bold } + .terminal-619468389-r32 { fill: #780028 } + .terminal-619468389-r33 { fill: #540c24 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DefaultDefault - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Primary!Primary! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Success!Success! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Warning!Warning! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Error!Error! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -2482,134 +2485,135 @@ font-weight: 700; } - .terminal-2323733830-matrix { + .terminal-1331556511-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2323733830-title { + .terminal-1331556511-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2323733830-r1 { fill: #808080 } - .terminal-2323733830-r2 { fill: #e1e1e1 } - .terminal-2323733830-r3 { fill: #c5c8c6 } - .terminal-2323733830-r4 { fill: #ddedf9 } + .terminal-1331556511-r1 { fill: #808080 } + .terminal-1331556511-r2 { fill: #e1e1e1 } + .terminal-1331556511-r3 { fill: #c5c8c6 } + .terminal-1331556511-r4 { fill: #ddedf9 } + .terminal-1331556511-r5 { fill: #e2e2e2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignAllApp + AlignAllApp - - - - ──────────────────────────────────────────────────────────────────────── - left topcenter topright top - - - - - ──────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────── - - - left middlecenter middleright middle - - - ──────────────────────────────────────────────────────────────────────── - - ──────────────────────────────────────────────────────────────────────── - - - - - - left bottomcenter bottomright bottom - ──────────────────────────────────────────────────────────────────────── + + + + ──────────────────────────────────────────────────────────────────────── + left topcenter topright top + + + + + ──────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────── + + + left middlecenter middleright middle + + + ──────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────── + + + + + + left bottomcenter bottomright bottom + ──────────────────────────────────────────────────────────────────────── @@ -3273,141 +3277,141 @@ font-weight: 700; } - .terminal-1536397390-matrix { + .terminal-1997861159-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1536397390-title { + .terminal-1997861159-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1536397390-r1 { fill: #c5c8c6 } - .terminal-1536397390-r2 { fill: #fea62b } - .terminal-1536397390-r3 { fill: #fea62b;font-weight: bold } - .terminal-1536397390-r4 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-1536397390-r5 { fill: #cc555a;font-weight: bold } - .terminal-1536397390-r6 { fill: #e1e1e1 } - .terminal-1536397390-r7 { fill: #1e1e1e } - .terminal-1536397390-r8 { fill: #1e1e1e;text-decoration: underline; } - .terminal-1536397390-r9 { fill: #fea62b;text-decoration: underline; } - .terminal-1536397390-r10 { fill: #4b4e55;text-decoration: underline; } - .terminal-1536397390-r11 { fill: #4ebf71 } - .terminal-1536397390-r12 { fill: #b93c5b } + .terminal-1997861159-r1 { fill: #e1e1e1 } + .terminal-1997861159-r2 { fill: #c5c8c6 } + .terminal-1997861159-r3 { fill: #fea62b } + .terminal-1997861159-r4 { fill: #fea62b;font-weight: bold } + .terminal-1997861159-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-1997861159-r6 { fill: #cc555a;font-weight: bold } + .terminal-1997861159-r7 { fill: #1e1e1e } + .terminal-1997861159-r8 { fill: #1e1e1e;text-decoration: underline; } + .terminal-1997861159-r9 { fill: #fea62b;text-decoration: underline; } + .terminal-1997861159-r10 { fill: #4b4e55;text-decoration: underline; } + .terminal-1997861159-r11 { fill: #4ebf71 } + .terminal-1997861159-r12 { fill: #b93c5b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderSubTitleAlignAll + BorderSubTitleAlignAll - - - - - - Border titleLef…▁▁▁▁Left▁▁▁▁ - This is the story ofa Pythondeveloper that - Border subtitleCen…▔▔▔▔@@@▔▔▔▔▔ - - - - - - +--------------+Title───────────────── - |had to fill up|nine labelsand ended up redoing it - +-Left-------+──────────────Subtitle - - - - - Title, but really looo… - Title, but r…Title, but reall… - because the first tryhad some labelsthat were too long. - Subtitle, bu…Subtitle, but re… - Subtitle, but really l… - + + + + + + Border titleLef…▁▁▁▁Left▁▁▁▁ + This is the story ofa Pythondeveloper that + Border subtitleCen…▔▔▔▔@@@▔▔▔▔▔ + + + + + + +--------------+Title───────────────── + |had to fill up|nine labelsand ended up redoing it + +-Left-------+──────────────Subtitle + + + + + Title, but really looo… + Title, but r…Title, but reall… + because the first tryhad some labelsthat were too long. + Subtitle, bu…Subtitle, but re… + Subtitle, but really l… + @@ -5014,132 +5018,132 @@ font-weight: 700; } - .terminal-1564714526-matrix { + .terminal-1840966081-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1564714526-title { + .terminal-1840966081-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1564714526-r1 { fill: #e1e1e1 } - .terminal-1564714526-r2 { fill: #c5c8c6 } - .terminal-1564714526-r3 { fill: #ffffff } + .terminal-1840966081-r1 { fill: #e1e1e1 } + .terminal-1840966081-r2 { fill: #c5c8c6 } + .terminal-1840966081-r3 { fill: #ffffff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DockAllApp + DockAllApp - - - - - - - ────────────────────────────────────────────────────────── - top - - - - - - - leftright - - - - - - - - bottom - ────────────────────────────────────────────────────────── - - + + + + + + + ────────────────────────────────────────────────────────── + top + + + + + + + leftright + + + + + + + + bottom + ────────────────────────────────────────────────────────── + + @@ -6427,132 +6431,134 @@ font-weight: 700; } - .terminal-2726481143-matrix { + .terminal-2838975926-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2726481143-title { + .terminal-2838975926-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2726481143-r1 { fill: #c5c8c6 } - .terminal-2726481143-r2 { fill: #000000 } - .terminal-2726481143-r3 { fill: #e1e1e1 } + .terminal-2838975926-r1 { fill: #efddef } + .terminal-2838975926-r2 { fill: #c5c8c6 } + .terminal-2838975926-r3 { fill: #000000 } + .terminal-2838975926-r4 { fill: #ddefef } + .terminal-2838975926-r5 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LayoutApp + LayoutApp - - - - - Layout - - Is - - Vertical - - - LayoutIsHorizontal - - - - - - - - - - - - - - + + + + + Layout + + Is + + Vertical + + + LayoutIsHorizontal + + + + + + + + + + + + + + @@ -7839,140 +7845,141 @@ font-weight: 700; } - .terminal-4172255139-matrix { + .terminal-2245771963-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4172255139-title { + .terminal-2245771963-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4172255139-r1 { fill: #ffffff } - .terminal-4172255139-r2 { fill: #c5c8c6 } - .terminal-4172255139-r3 { fill: #ece5e5 } - .terminal-4172255139-r4 { fill: #eee8e3 } - .terminal-4172255139-r5 { fill: #e7e0e6 } - .terminal-4172255139-r6 { fill: #eae2e4 } - .terminal-4172255139-r7 { fill: #e3ede7 } - .terminal-4172255139-r8 { fill: #e8ede4 } - .terminal-4172255139-r9 { fill: #e1eceb } - .terminal-4172255139-r10 { fill: #eeeddf } + .terminal-2245771963-r1 { fill: #ffffff } + .terminal-2245771963-r2 { fill: #c5c8c6 } + .terminal-2245771963-r3 { fill: #e0e0e0 } + .terminal-2245771963-r4 { fill: #ece5e5 } + .terminal-2245771963-r5 { fill: #eee8e3 } + .terminal-2245771963-r6 { fill: #e7e0e6 } + .terminal-2245771963-r7 { fill: #eae2e4 } + .terminal-2245771963-r8 { fill: #e3ede7 } + .terminal-2245771963-r9 { fill: #e8ede4 } + .terminal-2245771963-r10 { fill: #e1eceb } + .terminal-2245771963-r11 { fill: #eeeddf } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarginAllApp + MarginAllApp - - - - ────────────────────────────────────────────────────────────────── - - - - marginmargin: 1  - no marginmargin: 1: 1 51 2 6 - - - - - ────────────────────────────────────────────────────────────────── - - ────────────────────────────────────────────────────────────────── - - - margin-bottom: 4 - - margin-right: margin-left: 3 - 3 - margin-top: 4 - - - - ────────────────────────────────────────────────────────────────── + + + + ────────────────────────────────────────────────────────────────── + + + + marginmargin: 1  + no marginmargin: 1: 1 51 2 6 + + + + + ────────────────────────────────────────────────────────────────── + + ────────────────────────────────────────────────────────────────── + + + margin-bottom: 4 + + margin-right: margin-left: 3 + 3 + margin-top: 4 + + + + ────────────────────────────────────────────────────────────────── @@ -8160,134 +8167,134 @@ font-weight: 700; } - .terminal-987506037-matrix { + .terminal-1398959741-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-987506037-title { + .terminal-1398959741-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-987506037-r1 { fill: #c5c8c6 } - .terminal-987506037-r2 { fill: #e8e0e7 } - .terminal-987506037-r3 { fill: #eae3e5 } - .terminal-987506037-r4 { fill: #ede6e6 } - .terminal-987506037-r5 { fill: #efe9e4 } + .terminal-1398959741-r1 { fill: #c5c8c6 } + .terminal-1398959741-r2 { fill: #e8e0e7 } + .terminal-1398959741-r3 { fill: #eae3e5 } + .terminal-1398959741-r4 { fill: #ede6e6 } + .terminal-1398959741-r5 { fill: #efe9e4 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MaxWidthApp + MaxWidthApp - - - - - - max-width:  - 50h - - - - - max-width: 999 - - - - - - max-width: 50% - - - - - - max-width: 30 - - + + + + + + max-width:  + 50h + + + + + max-width: 999 + + + + + + max-width: 50% + + + + + + max-width: 30 + + @@ -8637,134 +8644,134 @@ font-weight: 700; } - .terminal-3520697079-matrix { + .terminal-292160688-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3520697079-title { + .terminal-292160688-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3520697079-r1 { fill: #000000 } - .terminal-3520697079-r2 { fill: #0000ff } - .terminal-3520697079-r3 { fill: #c5c8c6 } - .terminal-3520697079-r4 { fill: #ff0000 } - .terminal-3520697079-r5 { fill: #008000 } + .terminal-292160688-r1 { fill: #000000 } + .terminal-292160688-r2 { fill: #0000ff } + .terminal-292160688-r3 { fill: #c5c8c6 } + .terminal-292160688-r4 { fill: #ff0000 } + .terminal-292160688-r5 { fill: #008000 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OffsetApp + OffsetApp - - - - - Chani (offset 0  - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀-3) - - - - Paul (offset 8 2)▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - - ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - Duncan (offset 4  - 10) - - - - ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - - - + + + + + Chani (offset 0  + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀-3) + + + + Paul (offset 8 2)▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + Duncan (offset 4  + 10) + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + @@ -9429,136 +9436,136 @@ font-weight: 700; } - .terminal-3720200886-matrix { + .terminal-2990670852-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3720200886-title { + .terminal-2990670852-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3720200886-r1 { fill: #c5c8c6 } - .terminal-3720200886-r2 { fill: #000000 } - .terminal-3720200886-r3 { fill: #008000 } - .terminal-3720200886-r4 { fill: #e5f0e5 } - .terminal-3720200886-r5 { fill: #036a03 } - .terminal-3720200886-r6 { fill: #14191f } + .terminal-2990670852-r1 { fill: #c5c8c6 } + .terminal-2990670852-r2 { fill: #000000 } + .terminal-2990670852-r3 { fill: #008000 } + .terminal-2990670852-r4 { fill: #e5f0e5 } + .terminal-2990670852-r5 { fill: #036a03 } + .terminal-2990670852-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OverflowApp + OverflowApp - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over meI will permit it to pass over me  - and through me.and through me. - And when it has gone past, I And when it has gone past, I will  - will turn the inner eye to see turn the inner eye to see its  - its path.▁▁path. - Where the fear has gone there Where the fear has gone there will - will be nothing. Only I will be nothing. Only I will remain. - remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. - I must not fear.Fear is the mind-killer. - Fear is the mind-killer.Fear is the little-death that  - Fear is the little-death that brings total obliteration. - brings total obliteration.I will face my fear. - I will face my fear.I will permit it to pass over me  - I will permit it to pass over meand through me. + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over meI will permit it to pass over me  + and through me.and through me. + And when it has gone past, I And when it has gone past, I will  + will turn the inner eye to see turn the inner eye to see its  + its path.▁▁path. + Where the fear has gone there Where the fear has gone there will + will be nothing. Only I will be nothing. Only I will remain. + remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. + I must not fear.Fear is the mind-killer. + Fear is the mind-killer.Fear is the little-death that  + Fear is the little-death that brings total obliteration. + brings total obliteration.I will face my fear. + I will face my fear.I will permit it to pass over me  + I will permit it to pass over meand through me. @@ -9743,138 +9750,138 @@ font-weight: 700; } - .terminal-2103878337-matrix { + .terminal-1642992271-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2103878337-title { + .terminal-1642992271-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2103878337-r1 { fill: #e7e0e6 } - .terminal-2103878337-r2 { fill: #c5c8c6 } - .terminal-2103878337-r3 { fill: #eae2e4 } - .terminal-2103878337-r4 { fill: #ece5e5 } - .terminal-2103878337-r5 { fill: #eee8e3 } - .terminal-2103878337-r6 { fill: #e8ede4 } - .terminal-2103878337-r7 { fill: #e3ede7 } - .terminal-2103878337-r8 { fill: #e1eceb } - .terminal-2103878337-r9 { fill: #eeeddf } + .terminal-1642992271-r1 { fill: #c5c8c6 } + .terminal-1642992271-r2 { fill: #e7e0e6 } + .terminal-1642992271-r3 { fill: #eae2e4 } + .terminal-1642992271-r4 { fill: #ece5e5 } + .terminal-1642992271-r5 { fill: #eee8e3 } + .terminal-1642992271-r6 { fill: #e8ede4 } + .terminal-1642992271-r7 { fill: #e3ede7 } + .terminal-1642992271-r8 { fill: #e1eceb } + .terminal-1642992271-r9 { fill: #eeeddf } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PaddingAllApp + PaddingAllApp - - - - no padding - padding: 1padding:padding: 1 1 - 1 52 6 - - - - - - - - - - padding-right: 3padding-bottom: 4padding-left: 3 - - - - padding-top: 4 - - - - - - + + + + no padding + padding: 1padding:padding: 1 1 + 1 52 6 + + + + + + + + + + padding-right: 3padding-bottom: 4padding-left: 3 + + + + padding-top: 4 + + + + + + @@ -12284,141 +12291,141 @@ font-weight: 700; } - .terminal-1052270191-matrix { + .terminal-1938916138-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1052270191-title { + .terminal-1938916138-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1052270191-r1 { fill: #c5c8c6 } - .terminal-1052270191-r2 { fill: #e8e0e7 } - .terminal-1052270191-r3 { fill: #eae3e5 } - .terminal-1052270191-r4 { fill: #ede6e6 } - .terminal-1052270191-r5 { fill: #efe9e4 } - .terminal-1052270191-r6 { fill: #efeedf } - .terminal-1052270191-r7 { fill: #e9eee5 } - .terminal-1052270191-r8 { fill: #e4eee8 } - .terminal-1052270191-r9 { fill: #e2edeb } - .terminal-1052270191-r10 { fill: #dfebed } - .terminal-1052270191-r11 { fill: #ddedf9 } + .terminal-1938916138-r1 { fill: #c5c8c6 } + .terminal-1938916138-r2 { fill: #e8e0e7 } + .terminal-1938916138-r3 { fill: #eae3e5 } + .terminal-1938916138-r4 { fill: #ede6e6 } + .terminal-1938916138-r5 { fill: #efe9e4 } + .terminal-1938916138-r6 { fill: #efeedf } + .terminal-1938916138-r7 { fill: #e9eee5 } + .terminal-1938916138-r8 { fill: #e4eee8 } + .terminal-1938916138-r9 { fill: #e2edeb } + .terminal-1938916138-r10 { fill: #dfebed } + .terminal-1938916138-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeightComparisonApp + HeightComparisonApp - - - - - - - - - - - - - - - #cells#percent#w#h#vw#vh#auto#fr1#fr3 - - - - - - - - - - - - ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• + + + + + + + + + + + + + + + #cells#percent#w#h#vw#vh#auto#fr1#fr3 + + + + + + + + + + + + ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• @@ -13562,168 +13569,168 @@ font-weight: 700; } - .terminal-614458704-matrix { + .terminal-4033540874-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-614458704-title { + .terminal-4033540874-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-614458704-r1 { fill: #c5c8c6 } - .terminal-614458704-r2 { fill: #e3e3e3 } - .terminal-614458704-r3 { fill: #e1e1e1 } - .terminal-614458704-r4 { fill: #e2e2e2 } - .terminal-614458704-r5 { fill: #14191f } - .terminal-614458704-r6 { fill: #004578 } - .terminal-614458704-r7 { fill: #262626 } - .terminal-614458704-r8 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } - .terminal-614458704-r9 { fill: #e2e2e2;font-weight: bold } - .terminal-614458704-r10 { fill: #7ae998 } - .terminal-614458704-r11 { fill: #4ebf71;font-weight: bold } - .terminal-614458704-r12 { fill: #008139 } - .terminal-614458704-r13 { fill: #dde8f3;font-weight: bold } - .terminal-614458704-r14 { fill: #ddedf9 } + .terminal-4033540874-r1 { fill: #c5c8c6 } + .terminal-4033540874-r2 { fill: #e3e3e3 } + .terminal-4033540874-r3 { fill: #e1e1e1 } + .terminal-4033540874-r4 { fill: #e2e2e2 } + .terminal-4033540874-r5 { fill: #14191f } + .terminal-4033540874-r6 { fill: #004578 } + .terminal-4033540874-r7 { fill: #262626 } + .terminal-4033540874-r8 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } + .terminal-4033540874-r9 { fill: #e2e2e2;font-weight: bold } + .terminal-4033540874-r10 { fill: #7ae998 } + .terminal-4033540874-r11 { fill: #4ebf71;font-weight: bold } + .terminal-4033540874-r12 { fill: #008139 } + .terminal-4033540874-r13 { fill: #dde8f3;font-weight: bold } + .terminal-4033540874-r14 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Demo + Textual Demo - - - - Textual Demo - - - TOP - - ▆▆ - - Widgets - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - Rich contentTextual Demo - - Welcome! Textual is a framework for creating sophisticated - applications with the terminal. - CSS - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Start - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - -  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  + + + + Textual Demo + + + TOP + + ▆▆ + + Widgets + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + Rich contentTextual Demo + + Welcome! Textual is a framework for creating sophisticated + applications with the terminal. + CSS + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Start + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  @@ -14094,141 +14101,141 @@ font-weight: 700; } - .terminal-2216843056-matrix { + .terminal-2702154472-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2216843056-title { + .terminal-2702154472-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2216843056-r1 { fill: #c5c8c6 } - .terminal-2216843056-r2 { fill: #1e1e1e } - .terminal-2216843056-r3 { fill: #1f1f1f } - .terminal-2216843056-r4 { fill: #ff0000 } - .terminal-2216843056-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2216843056-r6 { fill: #ddedf9 } - .terminal-2216843056-r7 { fill: #c7cdd2 } + .terminal-2702154472-r1 { fill: #c5c8c6 } + .terminal-2702154472-r2 { fill: #1e1e1e } + .terminal-2702154472-r3 { fill: #1f1f1f } + .terminal-2702154472-r4 { fill: #ff0000 } + .terminal-2702154472-r5 { fill: #dde8f3;font-weight: bold } + .terminal-2702154472-r6 { fill: #ddedf9 } + .terminal-2702154472-r7 { fill: #c7cdd2 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TestApp + TestApp - - - - TestApp - ───────── - this - is - a - sample - sentence - and - here - are - some - wordsthis - is - a - sample - sentence - and - here - are - some - words -  CTRL+Q  Quit  - - - ▇▇ + + + + TestApp + ───────── + this + is + a + sample + sentence + and + here + are + some + wordsthis + is + a + sample + sentence + and + here + are + some + words +  CTRL+Q  Quit  + + + ▇▇ @@ -14428,135 +14435,135 @@ font-weight: 700; } - .terminal-2886576672-matrix { + .terminal-1801121102-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2886576672-title { + .terminal-1801121102-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2886576672-r1 { fill: #c5c8c6 } - .terminal-2886576672-r2 { fill: #e3e3e3 } - .terminal-2886576672-r3 { fill: #ffdddd } - .terminal-2886576672-r4 { fill: #e1e1e1 } - .terminal-2886576672-r5 { fill: #14191f } - .terminal-2886576672-r6 { fill: #ddedf9 } + .terminal-1801121102-r1 { fill: #c5c8c6 } + .terminal-1801121102-r2 { fill: #e3e3e3 } + .terminal-1801121102-r3 { fill: #ffdddd } + .terminal-1801121102-r4 { fill: #e1e1e1 } + .terminal-1801121102-r5 { fill: #14191f } + .terminal-1801121102-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyleBugApp + StyleBugApp - - - - StyleBugApp - test widget 0 - test widget 1 - test widget 2 - test widget 3 - test widget 4 - test widget 5 - test widget 6 - test widget 7 - test widget 8 - test widget 9 - test widget 10 - test widget 11 - test widget 12▇▇ - test widget 13 - test widget 14 - test widget 15 - test widget 16 - test widget 17 - test widget 18 - test widget 19 - test widget 20 - test widget 21 + + + + StyleBugApp + test widget 0 + test widget 1 + test widget 2 + test widget 3 + test widget 4 + test widget 5 + test widget 6 + test widget 7 + test widget 8 + test widget 9 + test widget 10 + test widget 11 + test widget 12▇▇ + test widget 13 + test widget 14 + test widget 15 + test widget 16 + test widget 17 + test widget 18 + test widget 19 + test widget 20 + test widget 21 @@ -14744,137 +14751,138 @@ font-weight: 700; } - .terminal-1298369243-matrix { + .terminal-1665781252-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1298369243-title { + .terminal-1665781252-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1298369243-r1 { fill: #008000 } - .terminal-1298369243-r2 { fill: #c5c8c6 } - .terminal-1298369243-r3 { fill: #e0e6e0 } + .terminal-1665781252-r1 { fill: #008000 } + .terminal-1665781252-r2 { fill: #c5c8c6 } + .terminal-1665781252-r3 { fill: #e0e4e0 } + .terminal-1665781252-r4 { fill: #e0e6e0 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TestApp + TestApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - Hello - - - - - - - World - - - - - - - !! - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Hello + + + + + + + World + + + + + + + !! + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -14904,135 +14912,136 @@ font-weight: 700; } - .terminal-2371169958-matrix { + .terminal-1035580841-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2371169958-title { + .terminal-1035580841-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2371169958-r1 { fill: #c5c8c6 } - .terminal-2371169958-r2 { fill: #e3e3e3 } - .terminal-2371169958-r3 { fill: #e3e4e5 } - .terminal-2371169958-r4 { fill: #e2e3e3 } - .terminal-2371169958-r5 { fill: #14191f } - .terminal-2371169958-r6 { fill: #ddedf9 } + .terminal-1035580841-r1 { fill: #c5c8c6 } + .terminal-1035580841-r2 { fill: #e3e3e3 } + .terminal-1035580841-r3 { fill: #ddddff } + .terminal-1035580841-r4 { fill: #e3e4e5 } + .terminal-1035580841-r5 { fill: #e2e3e3 } + .terminal-1035580841-r6 { fill: #14191f } + .terminal-1035580841-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScreenSplitApp + ScreenSplitApp - - - - ScreenSplitApp - This is content This is content number 0 - number 0This is content number 1 - This is content ▄▄This is content number 2 - number 1This is content number 3 - This is content This is content number 4▁▁ - number 2This is content number 5 - This is content This is content number 6 - number 3This is content number 7 - This is content This is content number 8 - number 4This is content number 9 - This is content This is content number 10 - number 5This is content number 11 - This is content This is content number 12 - number 6This is content number 13 - This is content This is content number 14 - number 7This is content number 15 - This is content This is content number 16 - number 8This is content number 17 - This is content This is content number 18 - number 9This is content number 19 - This is content This is content number 20 - number 10This is content number 21 + + + + ScreenSplitApp + This is content This is content number 0 + number 0This is content number 1 + This is content ▄▄This is content number 2 + number 1This is content number 3 + This is content This is content number 4▁▁ + number 2This is content number 5 + This is content This is content number 6 + number 3This is content number 7 + This is content This is content number 8 + number 4This is content number 9 + This is content This is content number 10 + number 5This is content number 11 + This is content This is content number 12 + number 6This is content number 13 + This is content This is content number 14 + number 7This is content number 15 + This is content This is content number 16 + number 8This is content number 17 + This is content This is content number 18 + number 9This is content number 19 + This is content This is content number 20 + number 10This is content number 21 @@ -15687,132 +15696,132 @@ font-weight: 700; } - .terminal-2648118808-matrix { + .terminal-4077214022-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2648118808-title { + .terminal-4077214022-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2648118808-r1 { fill: #c5c8c6 } - .terminal-2648118808-r2 { fill: #e3e3e3 } - .terminal-2648118808-r3 { fill: #e1e1e1 } + .terminal-4077214022-r1 { fill: #c5c8c6 } + .terminal-4077214022-r2 { fill: #e3e3e3 } + .terminal-4077214022-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeaderApp + HeaderApp - - - - HeaderApp - - - - - - - - - - - - - - - - - - - - - - + + + + HeaderApp + + + + + + + + + + + + + + + + + + + + + + @@ -16473,146 +16482,146 @@ font-weight: 700; } - .terminal-641812469-matrix { + .terminal-2572323619-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-641812469-title { + .terminal-2572323619-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-641812469-r1 { fill: #c5c8c6 } - .terminal-641812469-r2 { fill: #e3e3e3 } - .terminal-641812469-r3 { fill: #e1e1e1 } - .terminal-641812469-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-641812469-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-641812469-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-641812469-r7 { fill: #98729f;font-weight: bold } - .terminal-641812469-r8 { fill: #d0b344 } - .terminal-641812469-r9 { fill: #98a84b } - .terminal-641812469-r10 { fill: #00823d;font-style: italic; } - .terminal-641812469-r11 { fill: #ffcf56 } - .terminal-641812469-r12 { fill: #e76580 } - .terminal-641812469-r13 { fill: #211505;font-weight: bold } - .terminal-641812469-r14 { fill: #f5e5e9;font-weight: bold } - .terminal-641812469-r15 { fill: #b86b00 } - .terminal-641812469-r16 { fill: #780028 } + .terminal-2572323619-r1 { fill: #c5c8c6 } + .terminal-2572323619-r2 { fill: #e3e3e3 } + .terminal-2572323619-r3 { fill: #e1e1e1 } + .terminal-2572323619-r4 { fill: #e1e1e1;text-decoration: underline; } + .terminal-2572323619-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-2572323619-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-2572323619-r7 { fill: #98729f;font-weight: bold } + .terminal-2572323619-r8 { fill: #d0b344 } + .terminal-2572323619-r9 { fill: #98a84b } + .terminal-2572323619-r10 { fill: #00823d;font-style: italic; } + .terminal-2572323619-r11 { fill: #ffcf56 } + .terminal-2572323619-r12 { fill: #e76580 } + .terminal-2572323619-r13 { fill: #211505;font-weight: bold } + .terminal-2572323619-r14 { fill: #f5e5e9;font-weight: bold } + .terminal-2572323619-r15 { fill: #b86b00 } + .terminal-2572323619-r16 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Keys + Textual Keys - - - - Textual Keys - ╭────────────────────────────────────────────────────────────────────────────╮ - Press some keys! - - To quit the app press ctrl+ctwice or press the Quit button below. - ╰────────────────────────────────────────────────────────────────────────────╯ - Key(key='a'character='a'name='a'is_printable=True) - Key(key='b'character='b'name='b'is_printable=True) - - - - - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ClearQuit - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + Textual Keys + ╭────────────────────────────────────────────────────────────────────────────╮ + Press some keys! + + To quit the app press ctrl+ctwice or press the Quit button below. + ╰────────────────────────────────────────────────────────────────────────────╯ + Key(key='a'character='a'name='a'is_printable=True) + Key(key='b'character='b'name='b'is_printable=True) + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ClearQuit + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -16800,136 +16809,136 @@ font-weight: 700; } - .terminal-513592180-matrix { + .terminal-1675990519-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-513592180-title { + .terminal-1675990519-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-513592180-r1 { fill: #c5c8c6 } - .terminal-513592180-r2 { fill: #e3e3e3 } - .terminal-513592180-r3 { fill: #ff0000 } - .terminal-513592180-r4 { fill: #e1e1e1 } - .terminal-513592180-r5 { fill: #dde8f3;font-weight: bold } - .terminal-513592180-r6 { fill: #ddedf9 } + .terminal-1675990519-r1 { fill: #c5c8c6 } + .terminal-1675990519-r2 { fill: #e3e3e3 } + .terminal-1675990519-r3 { fill: #e1e1e1 } + .terminal-1675990519-r4 { fill: #ff0000 } + .terminal-1675990519-r5 { fill: #dde8f3;font-weight: bold } + .terminal-1675990519-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DialogIssueApp + DialogIssueApp - - - - DialogIssueApp - - - - - - ─────────────────────────────────────── - - - - - - This should not cause a scrollbar to ap - - - - - - ─────────────────────────────────────── - - - - -  D  Toggle the dialog  + + + + DialogIssueApp + + + + + + ─────────────────────────────────────── + + + + + + This should not cause a scrollbar to ap + + + + + + ─────────────────────────────────────── + + + + +  D  Toggle the dialog  @@ -17927,135 +17936,135 @@ font-weight: 700; } - .terminal-2423395429-matrix { + .terminal-543315859-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2423395429-title { + .terminal-543315859-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2423395429-r1 { fill: #c5c8c6 } - .terminal-2423395429-r2 { fill: #e3e3e3 } - .terminal-2423395429-r3 { fill: #e1e1e1 } - .terminal-2423395429-r4 { fill: #dde8f3;font-weight: bold } - .terminal-2423395429-r5 { fill: #ddedf9 } + .terminal-543315859-r1 { fill: #c5c8c6 } + .terminal-543315859-r2 { fill: #e3e3e3 } + .terminal-543315859-r3 { fill: #e1e1e1 } + .terminal-543315859-r4 { fill: #dde8f3;font-weight: bold } + .terminal-543315859-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ModalApp - - - - ModalApp - Hello - - - - - - - - - - - - - - - - - - - - - -  ⏎  Open Dialog  + + + + ModalApp + Hello + + + + + + + + + + + + + + + + + + + + + +  ⏎  Open Dialog  @@ -18722,136 +18731,136 @@ font-weight: 700; } - .terminal-1829927563-matrix { + .terminal-1812315577-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1829927563-title { + .terminal-1812315577-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1829927563-r1 { fill: #c5c8c6 } - .terminal-1829927563-r2 { fill: #e3e3e3 } - .terminal-1829927563-r3 { fill: #e1e1e1 } - .terminal-1829927563-r4 { fill: #004578 } - .terminal-1829927563-r5 { fill: #e0e8ee;font-weight: bold } - .terminal-1829927563-r6 { fill: #e2e3e3 } - .terminal-1829927563-r7 { fill: #ddedf9 } + .terminal-1812315577-r1 { fill: #c5c8c6 } + .terminal-1812315577-r2 { fill: #e3e3e3 } + .terminal-1812315577-r3 { fill: #e1e1e1 } + .terminal-1812315577-r4 { fill: #004578 } + .terminal-1812315577-r5 { fill: #e0e8ee;font-weight: bold } + .terminal-1812315577-r6 { fill: #e2e3e3 } + .terminal-1812315577-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── - Aerilon - Aquaria - Canceron - Caprica - Gemenon - Leonis - Libran - Picon - Sagittaron - Scorpia - Tauron - Virgon - - - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── + Aerilon + Aquaria + Canceron + Caprica + Gemenon + Leonis + Libran + Picon + Sagittaron + Scorpia + Tauron + Virgon + + + ────────────────────────────────────────────────────── + + + @@ -18882,139 +18891,139 @@ font-weight: 700; } - .terminal-2055091312-matrix { + .terminal-1041266590-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2055091312-title { + .terminal-1041266590-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2055091312-r1 { fill: #c5c8c6 } - .terminal-2055091312-r2 { fill: #e3e3e3 } - .terminal-2055091312-r3 { fill: #e1e1e1 } - .terminal-2055091312-r4 { fill: #004578 } - .terminal-2055091312-r5 { fill: #e0e8ee;font-weight: bold } - .terminal-2055091312-r6 { fill: #e2e3e3 } - .terminal-2055091312-r7 { fill: #42464b } - .terminal-2055091312-r8 { fill: #777a7e } - .terminal-2055091312-r9 { fill: #14191f } - .terminal-2055091312-r10 { fill: #ddedf9 } + .terminal-1041266590-r1 { fill: #c5c8c6 } + .terminal-1041266590-r2 { fill: #e3e3e3 } + .terminal-1041266590-r3 { fill: #e1e1e1 } + .terminal-1041266590-r4 { fill: #004578 } + .terminal-1041266590-r5 { fill: #e0e8ee;font-weight: bold } + .terminal-1041266590-r6 { fill: #e2e3e3 } + .terminal-1041266590-r7 { fill: #42464b } + .terminal-1041266590-r8 { fill: #777a7e } + .terminal-1041266590-r9 { fill: #14191f } + .terminal-1041266590-r10 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── - Aerilon - Aquaria - ──────────────────────────────────────────────────── - Canceron - Caprica - ──────────────────────────────────────────────────── - Gemenon - ──────────────────────────────────────────────────── - Leonis - Libran - ────────────────────────────────────────────────────▅▅ - Picon - ──────────────────────────────────────────────────── - Sagittaron - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── + Aerilon + Aquaria + ──────────────────────────────────────────────────── + Canceron + Caprica + ──────────────────────────────────────────────────── + Gemenon + ──────────────────────────────────────────────────── + Leonis + Libran + ────────────────────────────────────────────────────▅▅ + Picon + ──────────────────────────────────────────────────── + Sagittaron + ────────────────────────────────────────────────────── + + + @@ -19045,140 +19054,140 @@ font-weight: 700; } - .terminal-1395459687-matrix { + .terminal-1620527509-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1395459687-title { + .terminal-1620527509-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1395459687-r1 { fill: #c5c8c6 } - .terminal-1395459687-r2 { fill: #e3e3e3 } - .terminal-1395459687-r3 { fill: #e1e1e1 } - .terminal-1395459687-r4 { fill: #004578 } - .terminal-1395459687-r5 { fill: #e0e8ee;font-weight: bold;font-style: italic; } - .terminal-1395459687-r6 { fill: #e2e3e3 } - .terminal-1395459687-r7 { fill: #e0e8ee;font-weight: bold } - .terminal-1395459687-r8 { fill: #14191f } - .terminal-1395459687-r9 { fill: #e2e3e3;font-style: italic; } - .terminal-1395459687-r10 { fill: #e2e3e3;font-weight: bold } - .terminal-1395459687-r11 { fill: #ddedf9 } + .terminal-1620527509-r1 { fill: #c5c8c6 } + .terminal-1620527509-r2 { fill: #e3e3e3 } + .terminal-1620527509-r3 { fill: #e1e1e1 } + .terminal-1620527509-r4 { fill: #004578 } + .terminal-1620527509-r5 { fill: #e0e8ee;font-weight: bold;font-style: italic; } + .terminal-1620527509-r6 { fill: #e2e3e3 } + .terminal-1620527509-r7 { fill: #e0e8ee;font-weight: bold } + .terminal-1620527509-r8 { fill: #14191f } + .terminal-1620527509-r9 { fill: #e2e3e3;font-style: italic; } + .terminal-1620527509-r10 { fill: #e2e3e3;font-weight: bold } + .terminal-1620527509-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── -                   Data for Aerilon                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - Patron God   Population    Capital City   ▂▂ - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ - Demeter      1.2 Billion   Gaoth           - └───────────────┴────────────────┴─────────────────┘ -                   Data for Aquaria                   - ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - Patron God    Population   Capital City    - ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ - Hermes        75,000       None            - └────────────────┴───────────────┴─────────────────┘ -                  Data for Canceron                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── +                   Data for Aerilon                   + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + Patron God   Population    Capital City   ▂▂ + ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ + Demeter      1.2 Billion   Gaoth           + └───────────────┴────────────────┴─────────────────┘ +                   Data for Aquaria                   + ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + Patron God    Population   Capital City    + ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ + Hermes        75,000       None            + └────────────────┴───────────────┴─────────────────┘ +                  Data for Canceron                   + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + ────────────────────────────────────────────────────── + + + @@ -19367,136 +19376,136 @@ font-weight: 700; } - .terminal-3980370474-matrix { + .terminal-1392305496-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3980370474-title { + .terminal-1392305496-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3980370474-r1 { fill: #ffff00 } - .terminal-3980370474-r2 { fill: #e3e3e3 } - .terminal-3980370474-r3 { fill: #c5c8c6 } - .terminal-3980370474-r4 { fill: #e1e1e1 } - .terminal-3980370474-r5 { fill: #dde8f3;font-weight: bold } - .terminal-3980370474-r6 { fill: #ddedf9 } + .terminal-1392305496-r1 { fill: #ffff00 } + .terminal-1392305496-r2 { fill: #e3e3e3 } + .terminal-1392305496-r3 { fill: #c5c8c6 } + .terminal-1392305496-r4 { fill: #e1e1e1 } + .terminal-1392305496-r5 { fill: #dde8f3;font-weight: bold } + .terminal-1392305496-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + Layers - - - - ──────────────────────────────────Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - ────────────────────────────────── - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  @@ -19526,136 +19535,136 @@ font-weight: 700; } - .terminal-1053593998-matrix { + .terminal-3727479996-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1053593998-title { + .terminal-3727479996-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1053593998-r1 { fill: #ffff00 } - .terminal-1053593998-r2 { fill: #e3e3e3 } - .terminal-1053593998-r3 { fill: #c5c8c6 } - .terminal-1053593998-r4 { fill: #ddeedd } - .terminal-1053593998-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1053593998-r6 { fill: #ddedf9 } + .terminal-3727479996-r1 { fill: #ffff00 } + .terminal-3727479996-r2 { fill: #e3e3e3 } + .terminal-3727479996-r3 { fill: #c5c8c6 } + .terminal-3727479996-r4 { fill: #ddeedd } + .terminal-3727479996-r5 { fill: #dde8f3;font-weight: bold } + .terminal-3727479996-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Layers + Layers - - - - ──────────────────────────────────Layers - It's full of stars! My God! It's full of sta - - This should float over the top - - - ────────────────────────────────── - - - - - - - - - - - - - - - - -  T  Toggle Screen  + + + + ──────────────────────────────────Layers + It's full of stars! My God! It's full of sta + + This should float over the top + + + ────────────────────────────────── + + + + + + + + + + + + + + + + +  T  Toggle Screen  @@ -19685,142 +19694,142 @@ font-weight: 700; } - .terminal-700023403-matrix { + .terminal-1570661136-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-700023403-title { + .terminal-1570661136-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-700023403-r1 { fill: #c5c8c6 } - .terminal-700023403-r2 { fill: #eae3e5 } - .terminal-700023403-r3 { fill: #e8e0e7 } - .terminal-700023403-r4 { fill: #efe9e4 } - .terminal-700023403-r5 { fill: #ede6e6 } - .terminal-700023403-r6 { fill: #efeedf } - .terminal-700023403-r7 { fill: #e9eee5 } - .terminal-700023403-r8 { fill: #e2edeb } - .terminal-700023403-r9 { fill: #e4eee8;font-weight: bold } - .terminal-700023403-r10 { fill: #dfebed;font-weight: bold } - .terminal-700023403-r11 { fill: #dfe9ed } - .terminal-700023403-r12 { fill: #e3e6eb;font-weight: bold } - .terminal-700023403-r13 { fill: #e6e3e9 } + .terminal-1570661136-r1 { fill: #c5c8c6 } + .terminal-1570661136-r2 { fill: #eae3e5 } + .terminal-1570661136-r3 { fill: #e8e0e7 } + .terminal-1570661136-r4 { fill: #efe9e4 } + .terminal-1570661136-r5 { fill: #ede6e6 } + .terminal-1570661136-r6 { fill: #efeedf } + .terminal-1570661136-r7 { fill: #e9eee5 } + .terminal-1570661136-r8 { fill: #e2edeb } + .terminal-1570661136-r9 { fill: #e4eee8;font-weight: bold } + .terminal-1570661136-r10 { fill: #dfebed;font-weight: bold } + .terminal-1570661136-r11 { fill: #dfe9ed } + .terminal-1570661136-r12 { fill: #e3e6eb;font-weight: bold } + .terminal-1570661136-r13 { fill: #e6e3e9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PlaceholderApp + PlaceholderApp - - - - - Placeholder p2 here! - This is a custom label for p1. - #p4 - #p3#p5Placeholde - r - - Lorem ipsum dolor sit  - 26 x 6amet, consectetur 27 x 6 - adipiscing elit. Etiam  - feugiat ac elit sit amet  - - - Lorem ipsum dolor sit amet,  - consectetur adipiscing elit. Etiam 40 x 6 - feugiat ac elit sit amet accumsan.  - Suspendisse bibendum nec libero quis  - gravida. Phasellus id eleifend ligula. - Nullam imperdiet sem tellus, sed  - vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  - Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  - lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  - sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  + + + + + Placeholder p2 here! + This is a custom label for p1. + #p4 + #p3#p5Placeholde + r + + Lorem ipsum dolor sit  + 26 x 6amet, consectetur 27 x 6 + adipiscing elit. Etiam  + feugiat ac elit sit amet  + + + Lorem ipsum dolor sit amet,  + consectetur adipiscing elit. Etiam 40 x 6 + feugiat ac elit sit amet accumsan.  + Suspendisse bibendum nec libero quis  + gravida. Phasellus id eleifend ligula. + Nullam imperdiet sem tellus, sed  + vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  + Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  + lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  + sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  @@ -20006,135 +20015,135 @@ font-weight: 700; } - .terminal-1426024135-matrix { + .terminal-230009450-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1426024135-title { + .terminal-230009450-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1426024135-r1 { fill: #c5c8c6 } - .terminal-1426024135-r2 { fill: #4ebf71 } - .terminal-1426024135-r3 { fill: #e1e1e1 } - .terminal-1426024135-r4 { fill: #dde8f3;font-weight: bold } - .terminal-1426024135-r5 { fill: #ddedf9 } + .terminal-230009450-r1 { fill: #c5c8c6 } + .terminal-230009450-r2 { fill: #e1e1e1 } + .terminal-230009450-r3 { fill: #4ebf71 } + .terminal-230009450-r4 { fill: #dde8f3;font-weight: bold } + .terminal-230009450-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20164,136 +20173,137 @@ font-weight: 700; } - .terminal-1998155485-matrix { + .terminal-3162092160-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1998155485-title { + .terminal-3162092160-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1998155485-r1 { fill: #c5c8c6 } - .terminal-1998155485-r2 { fill: #b93c5b } - .terminal-1998155485-r3 { fill: #1e1e1e } - .terminal-1998155485-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-1998155485-r5 { fill: #dde8f3;font-weight: bold } - .terminal-1998155485-r6 { fill: #ddedf9 } + .terminal-3162092160-r1 { fill: #c5c8c6 } + .terminal-3162092160-r2 { fill: #e1e1e1 } + .terminal-3162092160-r3 { fill: #b93c5b } + .terminal-3162092160-r4 { fill: #1e1e1e } + .terminal-3162092160-r5 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3162092160-r6 { fill: #dde8f3;font-weight: bold } + .terminal-3162092160-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20323,136 +20333,136 @@ font-weight: 700; } - .terminal-836496735-matrix { + .terminal-1630089489-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-836496735-title { + .terminal-1630089489-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-836496735-r1 { fill: #c5c8c6 } - .terminal-836496735-r2 { fill: #fea62b } - .terminal-836496735-r3 { fill: #323232 } - .terminal-836496735-r4 { fill: #e1e1e1 } - .terminal-836496735-r5 { fill: #dde8f3;font-weight: bold } - .terminal-836496735-r6 { fill: #ddedf9 } + .terminal-1630089489-r1 { fill: #c5c8c6 } + .terminal-1630089489-r2 { fill: #e1e1e1 } + .terminal-1630089489-r3 { fill: #fea62b } + .terminal-1630089489-r4 { fill: #323232 } + .terminal-1630089489-r5 { fill: #dde8f3;font-weight: bold } + .terminal-1630089489-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -20482,137 +20492,138 @@ font-weight: 700; } - .terminal-1783624548-matrix { + .terminal-1532901142-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1783624548-title { + .terminal-1532901142-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1783624548-r1 { fill: #c5c8c6 } - .terminal-1783624548-r2 { fill: #004578 } - .terminal-1783624548-r3 { fill: #152939 } - .terminal-1783624548-r4 { fill: #1e1e1e } - .terminal-1783624548-r5 { fill: #e1e1e1;text-decoration: underline; } - .terminal-1783624548-r6 { fill: #dde8f3;font-weight: bold } - .terminal-1783624548-r7 { fill: #ddedf9 } + .terminal-1532901142-r1 { fill: #c5c8c6 } + .terminal-1532901142-r2 { fill: #e1e1e1 } + .terminal-1532901142-r3 { fill: #004578 } + .terminal-1532901142-r4 { fill: #152939 } + .terminal-1532901142-r5 { fill: #1e1e1e } + .terminal-1532901142-r6 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1532901142-r7 { fill: #dde8f3;font-weight: bold } + .terminal-1532901142-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -20642,136 +20653,136 @@ font-weight: 700; } - .terminal-2036756687-matrix { + .terminal-3440292978-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2036756687-title { + .terminal-3440292978-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2036756687-r1 { fill: #c5c8c6 } - .terminal-2036756687-r2 { fill: #323232 } - .terminal-2036756687-r3 { fill: #b93c5b } - .terminal-2036756687-r4 { fill: #e1e1e1 } - .terminal-2036756687-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2036756687-r6 { fill: #ddedf9 } + .terminal-3440292978-r1 { fill: #c5c8c6 } + .terminal-3440292978-r2 { fill: #e1e1e1 } + .terminal-3440292978-r3 { fill: #323232 } + .terminal-3440292978-r4 { fill: #b93c5b } + .terminal-3440292978-r5 { fill: #dde8f3;font-weight: bold } + .terminal-3440292978-r6 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20801,137 +20812,138 @@ font-weight: 700; } - .terminal-4086988071-matrix { + .terminal-4046569674-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4086988071-title { + .terminal-4046569674-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4086988071-r1 { fill: #c5c8c6 } - .terminal-4086988071-r2 { fill: #fea62b } - .terminal-4086988071-r3 { fill: #004578 } - .terminal-4086988071-r4 { fill: #1e1e1e } - .terminal-4086988071-r5 { fill: #e1e1e1;text-decoration: underline; } - .terminal-4086988071-r6 { fill: #dde8f3;font-weight: bold } - .terminal-4086988071-r7 { fill: #ddedf9 } + .terminal-4046569674-r1 { fill: #c5c8c6 } + .terminal-4046569674-r2 { fill: #e1e1e1 } + .terminal-4046569674-r3 { fill: #fea62b } + .terminal-4046569674-r4 { fill: #004578 } + .terminal-4046569674-r5 { fill: #1e1e1e } + .terminal-4046569674-r6 { fill: #e1e1e1;text-decoration: underline; } + .terminal-4046569674-r7 { fill: #dde8f3;font-weight: bold } + .terminal-4046569674-r8 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  @@ -21444,137 +21456,137 @@ font-weight: 700; } - .terminal-2779683141-matrix { + .terminal-1869274227-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2779683141-title { + .terminal-1869274227-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2779683141-r1 { fill: #c5c8c6 } - .terminal-2779683141-r2 { fill: #e3e3e3 } - .terminal-2779683141-r3 { fill: #008000 } - .terminal-2779683141-r4 { fill: #ffff00 } - .terminal-2779683141-r5 { fill: #e1e1e1 } - .terminal-2779683141-r6 { fill: #dde8f3;font-weight: bold } - .terminal-2779683141-r7 { fill: #ddedf9 } + .terminal-1869274227-r1 { fill: #c5c8c6 } + .terminal-1869274227-r2 { fill: #e3e3e3 } + .terminal-1869274227-r3 { fill: #008000 } + .terminal-1869274227-r4 { fill: #ffff00 } + .terminal-1869274227-r5 { fill: #e1e1e1 } + .terminal-1869274227-r6 { fill: #dde8f3;font-weight: bold } + .terminal-1869274227-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalRemoveApp + VerticalRemoveApp - - - - VerticalRemoveApp - ────────────────────────────────────────────────────────────────────────────── - ──────────────────── - This is a test label - ──────────────────── - ────────────────────────────────────────────────────────────────────────────── - - - - - - - - - - - - - - - - - -  A  Add  D  Delete  + + + + VerticalRemoveApp + ────────────────────────────────────────────────────────────────────────────── + ──────────────────── + This is a test label + ──────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + +  A  Add  D  Delete  @@ -21604,135 +21616,135 @@ font-weight: 700; } - .terminal-3992644605-matrix { + .terminal-1316892474-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3992644605-title { + .terminal-1316892474-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3992644605-r1 { fill: #c5c8c6 } - .terminal-3992644605-r2 { fill: #e3e3e3 } - .terminal-3992644605-r3 { fill: #e1e1e1 } - .terminal-3992644605-r4 { fill: #dde8f3;font-weight: bold } - .terminal-3992644605-r5 { fill: #ddedf9 } + .terminal-1316892474-r1 { fill: #c5c8c6 } + .terminal-1316892474-r2 { fill: #e3e3e3 } + .terminal-1316892474-r3 { fill: #e1e1e1 } + .terminal-1316892474-r4 { fill: #dde8f3;font-weight: bold } + .terminal-1316892474-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ModalApp - - - - ModalApp - B - - - - - - - - - - - - - - - - - - - - - -  A  Push screen A  + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  @@ -22075,134 +22087,134 @@ font-weight: 700; } - .terminal-2749576739-matrix { + .terminal-1647606097-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2749576739-title { + .terminal-1647606097-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2749576739-r1 { fill: #c5c8c6 } - .terminal-2749576739-r2 { fill: #e3e3e3 } - .terminal-2749576739-r3 { fill: #ff0000 } - .terminal-2749576739-r4 { fill: #dde2e8 } - .terminal-2749576739-r5 { fill: #ddedf9 } + .terminal-1647606097-r1 { fill: #c5c8c6 } + .terminal-1647606097-r2 { fill: #e3e3e3 } + .terminal-1647606097-r3 { fill: #ff0000 } + .terminal-1647606097-r4 { fill: #dde2e8 } + .terminal-1647606097-r5 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollViewTester + ScrollViewTester - - - - ScrollViewTester -  1 ────────────────────────────────────────────────────────────────────────── - Welcome to line 980 - Welcome to line 981 - Welcome to line 982 - Welcome to line 983 - Welcome to line 984 - Welcome to line 985 - Welcome to line 986 - Welcome to line 987 - Welcome to line 988 - Welcome to line 989 - Welcome to line 990 - Welcome to line 991 - Welcome to line 992 - Welcome to line 993 - Welcome to line 994 - Welcome to line 995 - Welcome to line 996 - Welcome to line 997 - Welcome to line 998 - Welcome to line 999 - ────────────────────────────────────────────────────────────────────────────── + + + + ScrollViewTester +  1 ────────────────────────────────────────────────────────────────────────── + Welcome to line 980 + Welcome to line 981 + Welcome to line 982 + Welcome to line 983 + Welcome to line 984 + Welcome to line 985 + Welcome to line 986 + Welcome to line 987 + Welcome to line 988 + Welcome to line 989 + Welcome to line 990 + Welcome to line 991 + Welcome to line 992 + Welcome to line 993 + Welcome to line 994 + Welcome to line 995 + Welcome to line 996 + Welcome to line 997 + Welcome to line 998 + Welcome to line 999 + ────────────────────────────────────────────────────────────────────────────── @@ -22233,136 +22245,136 @@ font-weight: 700; } - .terminal-4198692207-matrix { + .terminal-1422407852-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4198692207-title { + .terminal-1422407852-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4198692207-r1 { fill: #c5c8c6 } - .terminal-4198692207-r2 { fill: #e3e3e3 } - .terminal-4198692207-r3 { fill: #e1e1e1 } - .terminal-4198692207-r4 { fill: #1e1e1e } - .terminal-4198692207-r5 { fill: #121212 } - .terminal-4198692207-r6 { fill: #787878 } - .terminal-4198692207-r7 { fill: #a8a8a8 } + .terminal-1422407852-r1 { fill: #c5c8c6 } + .terminal-1422407852-r2 { fill: #e3e3e3 } + .terminal-1422407852-r3 { fill: #e1e1e1 } + .terminal-1422407852-r4 { fill: #1e1e1e } + .terminal-1422407852-r5 { fill: #121212 } + .terminal-1422407852-r6 { fill: #787878 } + .terminal-1422407852-r7 { fill: #a8a8a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -22393,140 +22405,140 @@ font-weight: 700; } - .terminal-1874975621-matrix { + .terminal-2035490498-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1874975621-title { + .terminal-2035490498-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1874975621-r1 { fill: #c5c8c6 } - .terminal-1874975621-r2 { fill: #e3e3e3 } - .terminal-1874975621-r3 { fill: #e1e1e1 } - .terminal-1874975621-r4 { fill: #1e1e1e } - .terminal-1874975621-r5 { fill: #0178d4 } - .terminal-1874975621-r6 { fill: #787878 } - .terminal-1874975621-r7 { fill: #a8a8a8 } - .terminal-1874975621-r8 { fill: #121212 } - .terminal-1874975621-r9 { fill: #ddedf9;font-weight: bold } - .terminal-1874975621-r10 { fill: #85beea;font-weight: bold } - .terminal-1874975621-r11 { fill: #e2e3e3 } + .terminal-2035490498-r1 { fill: #c5c8c6 } + .terminal-2035490498-r2 { fill: #e3e3e3 } + .terminal-2035490498-r3 { fill: #e1e1e1 } + .terminal-2035490498-r4 { fill: #1e1e1e } + .terminal-2035490498-r5 { fill: #0178d4 } + .terminal-2035490498-r6 { fill: #787878 } + .terminal-2035490498-r7 { fill: #a8a8a8 } + .terminal-2035490498-r8 { fill: #121212 } + .terminal-2035490498-r9 { fill: #ddedf9;font-weight: bold } + .terminal-2035490498-r10 { fill: #85beea;font-weight: bold } + .terminal-2035490498-r11 { fill: #e2e3e3 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total  - obliteration. - I will face my fear. - I will permit it to pass over me and through me. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total  + obliteration. + I will face my fear. + I will permit it to pass over me and through me. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -22557,136 +22569,136 @@ font-weight: 700; } - .terminal-2181889025-matrix { + .terminal-4010426174-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2181889025-title { + .terminal-4010426174-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2181889025-r1 { fill: #c5c8c6 } - .terminal-2181889025-r2 { fill: #e3e3e3 } - .terminal-2181889025-r3 { fill: #e1e1e1 } - .terminal-2181889025-r4 { fill: #1e1e1e } - .terminal-2181889025-r5 { fill: #0178d4 } - .terminal-2181889025-r6 { fill: #e2e2e2 } - .terminal-2181889025-r7 { fill: #a8a8a8 } + .terminal-4010426174-r1 { fill: #c5c8c6 } + .terminal-4010426174-r2 { fill: #e3e3e3 } + .terminal-4010426174-r3 { fill: #e1e1e1 } + .terminal-4010426174-r4 { fill: #1e1e1e } + .terminal-4010426174-r5 { fill: #0178d4 } + .terminal-4010426174-r6 { fill: #e2e2e2 } + .terminal-4010426174-r7 { fill: #a8a8a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - I must not fear. - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - I must not fear. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + I must not fear. + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -22717,136 +22729,136 @@ font-weight: 700; } - .terminal-932889121-matrix { + .terminal-2914557706-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-932889121-title { + .terminal-2914557706-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-932889121-r1 { fill: #e1e1e1 } - .terminal-932889121-r2 { fill: #c5c8c6 } - .terminal-932889121-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-932889121-r4 { fill: #1e1e1e } - .terminal-932889121-r5 { fill: #0178d4 } - .terminal-932889121-r6 { fill: #e2e3e3 } - .terminal-932889121-r7 { fill: #e3e8e8 } + .terminal-2914557706-r1 { fill: #e1e1e1 } + .terminal-2914557706-r2 { fill: #c5c8c6 } + .terminal-2914557706-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-2914557706-r4 { fill: #1e1e1e } + .terminal-2914557706-r5 { fill: #0178d4 } + .terminal-2914557706-r6 { fill: #e2e3e3 } + .terminal-2914557706-r7 { fill: #e3e8e8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SwitchApp + SwitchApp - - - - - - - - Example switches - - - ▔▔▔▔▔▔▔▔ - off:      - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - on:       - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - focused:  - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - custom:   - ▁▁▁▁▁▁▁▁ - - - - + + + + + + + + Example switches + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + From aff9bcdf9365d835e02e36a75b16f38b19e6c538 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 May 2023 11:30:26 +0100 Subject: [PATCH 35/85] Fix clearing an OptionList See #2557, credit to Will: https://github.com/Textualize/textual/issues/2557#issuecomment-1546883815 --- src/textual/widgets/_option_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 3b9413181..7d9af219b 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -631,7 +631,7 @@ class OptionList(ScrollView, can_focus=True): self.highlighted = None self._mouse_hovering_over = None self.virtual_size = Size(self.scrollable_content_region.width, 0) - self.refresh() + self._request_content_tracking_refresh() return self def _set_option_disabled(self, index: int, disabled: bool) -> Self: From fe7812d94d5bb703d01767c657e105bb0f2d2166 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: Mon, 15 May 2023 13:12:57 +0100 Subject: [PATCH 36/85] Add regression test for #2563. --- tests/test_paste.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_paste.py b/tests/test_paste.py index 774ad5038..45be6d536 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -1,5 +1,6 @@ from textual import events from textual.app import App +from textual.widgets import Input async def test_paste_app(): @@ -16,3 +17,28 @@ async def test_paste_app(): assert len(paste_events) == 1 assert paste_events[0].text == "Hello" + + +async def test_empty_paste(): + """Regression test for https://github.com/Textualize/textual/issues/2563.""" + + paste_events = [] + + class MyInput(Input): + def on_paste(self, event): + super()._on_paste(event) + paste_events.append(event) + + class PasteApp(App): + def compose(self): + yield MyInput() + + def key_p(self): + self.query_one(MyInput).post_message(events.Paste("")) + + app = PasteApp() + async with app.run_test() as pilot: + await pilot.press("p") + assert app.query_one(MyInput).value == "" + assert len(paste_events) == 1 + assert paste_events[0].text == "" From 81289c328a5f9cf63ce1a821e602471635164ae1 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: Mon, 15 May 2023 13:22:44 +0100 Subject: [PATCH 37/85] Fix empty paste. Related issues: #2563. --- CHANGELOG.md | 1 + src/textual/widgets/_input.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7cdebf4..5991f818d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TreeNode.collapse` and `TreeNode.collapse_all` not posting a `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 +- Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563 ### Added diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 9e5bf2d07..7e764e868 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -332,7 +332,7 @@ class Input(Widget, can_focus=True): event.prevent_default() def _on_paste(self, event: events.Paste) -> None: - line = event.text.splitlines()[0] + line = event.text.splitlines()[0] if event.text else "" self.insert_text_at_cursor(line) event.stop() From f02e2fcdd9a5a57e26966b72f54eb25abf761189 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: Mon, 15 May 2023 14:12:17 +0100 Subject: [PATCH 38/85] Short-circuit paste on empty text. Related comments: https://github.com/Textualize/textual/pull/2568#discussion_r1193790630 --- src/textual/widgets/_input.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 7e764e868..7ed037890 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -332,9 +332,10 @@ class Input(Widget, can_focus=True): event.prevent_default() def _on_paste(self, event: events.Paste) -> None: - line = event.text.splitlines()[0] if event.text else "" - self.insert_text_at_cursor(line) - event.stop() + if event.text: + line = event.text.splitlines()[0] + self.insert_text_at_cursor(line) + event.stop() async def _on_click(self, event: events.Click) -> None: offset = event.get_content_offset(self) From 720bd37bba7fa63b3a2bf1e299cb6272a083b3e8 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: Mon, 15 May 2023 14:22:16 +0100 Subject: [PATCH 39/85] Consume event. --- src/textual/widgets/_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 7ed037890..e14dcdf10 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -335,7 +335,7 @@ class Input(Widget, can_focus=True): if event.text: line = event.text.splitlines()[0] self.insert_text_at_cursor(line) - event.stop() + event.stop() async def _on_click(self, event: events.Click) -> None: offset = event.get_content_offset(self) From 6147c28dbf86c0d09a75b179b68fae1aeae1b26c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 15 May 2023 15:53:17 +0200 Subject: [PATCH 40/85] arrange refactor (#2569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * arrange refactor * Apply suggestions from code review Co-authored-by: Dave Pearson * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --------- Co-authored-by: Dave Pearson Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_arrange.py | 194 +++++++++++++++++------------- src/textual/_layout.py | 41 ++++++- src/textual/layouts/grid.py | 2 +- src/textual/layouts/horizontal.py | 4 +- src/textual/layouts/vertical.py | 2 +- 5 files changed, 152 insertions(+), 91 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index b415d8983..87879573a 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from fractions import Fraction from operator import attrgetter -from typing import TYPE_CHECKING, Sequence +from typing import TYPE_CHECKING, Iterable, Mapping, Sequence from ._layout import DockArrangeResult, WidgetPlacement from ._partition import partition @@ -16,6 +16,21 @@ if TYPE_CHECKING: TOP_Z = 2**31 - 1 +def _build_dock_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]: + """Organize widgets into layers. + + Args: + widgets: The widgets. + + Returns: + A mapping of layer name onto the widgets within the layer. + """ + layers: defaultdict[str, list[Widget]] = defaultdict(list) + for widget in widgets: + layers[widget.layer].append(widget) + return layers + + def arrange( widget: Widget, children: Sequence[Widget], size: Size, viewport: Size ) -> DockArrangeResult: @@ -30,107 +45,120 @@ def arrange( Widget arrangement information. """ - arrange_widgets: set[Widget] = set() - - dock_layers: defaultdict[str, list[Widget]] = defaultdict(list) - for child in children: - if child.display: - dock_layers[child.layer].append(child) - - width, height = size - placements: list[WidgetPlacement] = [] - add_placement = placements.append - - _WidgetPlacement = WidgetPlacement - top_z = TOP_Z scroll_spacing = Spacing() - null_spacing = Spacing() get_dock = attrgetter("styles.dock") styles = widget.styles + # Widgets which will be displayed + display_widgets = [child for child in children if child.styles.display != "none"] + + # Widgets organized into layers + dock_layers = _build_dock_layers(display_widgets) + layer_region = size.region for widgets in dock_layers.values(): region = layer_region + # Partition widgets into "layout" widgets (those that appears in the normal 'flow' of the + # document), and "dock" widgets which are positioned relative to an edge layout_widgets, dock_widgets = partition(get_dock, widgets) - arrange_widgets.update(dock_widgets) - top = right = bottom = left = 0 - - for dock_widget in dock_widgets: - edge = dock_widget.styles.dock - - box_model = dock_widget._get_box_model( - size, viewport, Fraction(size.width), Fraction(size.height) - ) - widget_width_fraction, widget_height_fraction, margin = box_model - - widget_width = int(widget_width_fraction) + margin.width - widget_height = int(widget_height_fraction) + margin.height - - if edge == "bottom": - dock_region = Region( - 0, height - widget_height, widget_width, widget_height - ) - bottom = max(bottom, widget_height) - elif edge == "top": - dock_region = Region(0, 0, widget_width, widget_height) - top = max(top, widget_height) - elif edge == "left": - dock_region = Region(0, 0, widget_width, widget_height) - left = max(left, widget_width) - elif edge == "right": - dock_region = Region( - width - widget_width, 0, widget_width, widget_height - ) - right = max(right, widget_width) - else: - # Should not occur, mainly to keep Mypy happy - raise AssertionError("invalid value for edge") # pragma: no-cover - - align_offset = dock_widget.styles._align_size( - (widget_width, widget_height), size - ) - dock_region = dock_region.shrink(margin).translate(align_offset) - add_placement( - _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) - ) - - dock_spacing = Spacing(top, right, bottom, left) - region = region.shrink(dock_spacing) - layout_placements, arranged_layout_widgets = widget._layout.arrange( - widget, layout_widgets, region.size + # Arrange docked widgets + _dock_placements, dock_spacing = _arrange_dock_widgets( + dock_widgets, size, viewport ) - if arranged_layout_widgets: + placements.extend(_dock_placements) + + # Reduce the region to compensate for docked widgets + region = region.shrink(dock_spacing) + + if layout_widgets: + # Arrange layout widgets (i.e. not docked) + layout_placements = widget._layout.arrange( + widget, + layout_widgets, + region.size, + ) + scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) - arrange_widgets.update(arranged_layout_widgets) placement_offset = region.offset + # Perform any alignment of the widgets. if styles.align_horizontal != "left" or styles.align_vertical != "top": - placement_size = Region.from_union( - [ - placement.region.grow(placement.margin) - for placement in layout_placements - ] - ).size + bounding_region = WidgetPlacement.get_bounds(layout_placements) placement_offset += styles._align_size( - placement_size, region.size + bounding_region.size, region.size ).clamped if placement_offset: - layout_placements = [ - _WidgetPlacement( - _region + placement_offset, - margin, - layout_widget, - order, - fixed, - overlay, - ) - for _region, margin, layout_widget, order, fixed, overlay in layout_placements - ] + # Translate placements if required. + layout_placements = WidgetPlacement.translate( + layout_placements, placement_offset + ) - placements.extend(layout_placements) + placements.extend(layout_placements) - return DockArrangeResult(placements, arrange_widgets, scroll_spacing) + return DockArrangeResult(placements, set(display_widgets), scroll_spacing) + + +def _arrange_dock_widgets( + dock_widgets: Sequence[Widget], size: Size, viewport: Size +) -> tuple[list[WidgetPlacement], Spacing]: + """Arrange widgets which are *docked*. + + Args: + dock_widgets: Widgets with a non-empty dock. + size: Size of the container. + viewport: Size of the viewport. + + Returns: + A tuple of widget placements, and additional spacing around them + """ + _WidgetPlacement = WidgetPlacement + top_z = TOP_Z + width, height = size + null_spacing = Spacing() + + top = right = bottom = left = 0 + + placements: list[WidgetPlacement] = [] + append_placement = placements.append + + for dock_widget in dock_widgets: + edge = dock_widget.styles.dock + + box_model = dock_widget._get_box_model( + size, viewport, Fraction(size.width), Fraction(size.height) + ) + widget_width_fraction, widget_height_fraction, margin = box_model + + widget_width = int(widget_width_fraction) + margin.width + widget_height = int(widget_height_fraction) + margin.height + + if edge == "bottom": + dock_region = Region(0, height - widget_height, widget_width, widget_height) + bottom = max(bottom, widget_height) + elif edge == "top": + dock_region = Region(0, 0, widget_width, widget_height) + top = max(top, widget_height) + elif edge == "left": + dock_region = Region(0, 0, widget_width, widget_height) + left = max(left, widget_width) + elif edge == "right": + dock_region = Region(width - widget_width, 0, widget_width, widget_height) + right = max(right, widget_width) + else: + # Should not occur, mainly to keep Mypy happy + raise AssertionError("invalid value for edge") # pragma: no-cover + + align_offset = dock_widget.styles._align_size( + (widget_width, widget_height), size + ) + dock_region = dock_region.shrink(margin).translate(align_offset) + append_placement( + _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) + ) + dock_spacing = Spacing(top, right, bottom, left) + + return (placements, dock_spacing) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index e0061c148..338b72192 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -2,17 +2,17 @@ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar, NamedTuple +from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple from ._spatial_map import SpatialMap -from .geometry import Region, Size, Spacing +from .geometry import Offset, Region, Size, Spacing if TYPE_CHECKING: from typing_extensions import TypeAlias from .widget import Widget -ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" +ArrangeResult: TypeAlias = "list[WidgetPlacement]" @dataclass @@ -76,6 +76,41 @@ class WidgetPlacement(NamedTuple): fixed: bool = False overlay: bool = False + @classmethod + def translate( + cls, placements: list[WidgetPlacement], offset: Offset + ) -> list[WidgetPlacement]: + """Move all placements by a given offset. + + Args: + placements: List of placements. + offset: Offset to add to placements. + + Returns: + Placements with adjusted region, or same instance if offset is null. + """ + if offset: + return [ + cls(region + offset, margin, layout_widget, order, fixed, overlay) + for region, margin, layout_widget, order, fixed, overlay in placements + ] + return placements + + @classmethod + def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region: + """Get a bounding region around all placements. + + Args: + placements: A number of placements. + + Returns: + An optimal binding box around all placements. + """ + bounding_region = Region.from_union( + [placement.region.grow(placement.margin) for placement in placements] + ) + return bounding_region + class Layout(ABC): """Responsible for arranging Widgets in a view and rendering them.""" diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 3ade70ab2..3b030f30f 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -156,4 +156,4 @@ class GridLayout(Layout): add_placement(WidgetPlacement(region, margin, widget)) add_widget(widget) - return (placements, set(widgets)) + return placements diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index d2fb3146e..a1c4eea4a 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -65,8 +65,6 @@ class HorizontalLayout(Layout): x = Fraction(box_models[0].margin.left if box_models else 0) - displayed_children = [child for child in children if child.display] - _Region = Region _WidgetPlacement = WidgetPlacement for widget, box_model, margin in zip(children, box_models, margins): @@ -86,4 +84,4 @@ class HorizontalLayout(Layout): if not overlay: x = next_x + margin - return placements, set(displayed_children) + return placements diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 0001efdb6..c74332535 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -86,4 +86,4 @@ class VerticalLayout(Layout): if not overlay: y = next_y + margin - return placements, set(children) + return placements From 9b09b19e5f075b363707a2f2bfe8b2276f733930 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: Tue, 16 May 2023 10:17:45 +0100 Subject: [PATCH 41/85] Update workflows. This caches the virtual environment so we don't have to download it every time (the cache can be cleared from the repository > Actions > Caches (on the left). We also split black formatting into a separate workflow. This means we can run black ONLY when *.py files are changed. It also means all other testing jobs don't need to _also_ check formatting. --- .github/workflows/black_format.yml | 21 +++++++++++++++++++++ .github/workflows/comment.yml | 5 +++-- .github/workflows/new_issue.yml | 3 ++- .github/workflows/pythonpackage.yml | 25 +++++++++++++------------ 4 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/black_format.yml diff --git a/.github/workflows/black_format.yml b/.github/workflows/black_format.yml new file mode 100644 index 000000000..aba9ba1f5 --- /dev/null +++ b/.github/workflows/black_format.yml @@ -0,0 +1,21 @@ + +name: Black format check + +on: + pull_request: + paths: + - '**.py' + +jobs: + black-format-check: + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v3.5.2 + - name: Set up Python 3.11 + uses: actions/setup-python@v4.6.0 + with: + python-version: 3.11 + - name: Install black + run: python -m pip install black + - name: Run black + run: black --check src diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index 46cf1677f..d26259360 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -1,7 +1,8 @@ -name: issues +name: Closed issue comment on: issues: types: [closed] + jobs: add-comment: runs-on: ubuntu-latest @@ -14,5 +15,5 @@ jobs: issue-number: ${{ github.event.issue.number }} body: | Don't forget to [star](https://github.com/Textualize/textual) the repository! - + Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates. diff --git a/.github/workflows/new_issue.yml b/.github/workflows/new_issue.yml index 3dd9d9a3a..cb55213ec 100644 --- a/.github/workflows/new_issue.yml +++ b/.github/workflows/new_issue.yml @@ -1,7 +1,8 @@ -name: issues +name: FAQ issue comment on: issues: types: [opened] + jobs: add-comment: if: ${{ !contains( 'willmcgugan,darrenburns,davep,rodrigogiraoserrao', github.actor ) }} diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 2891b4bcf..79504215b 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -21,27 +21,28 @@ jobs: run: shell: bash steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install and configure Poetry + - uses: actions/checkout@v3.5.2 + - name: Install and configure Poetry # This could be cached, too... uses: snok/install-poetry@v1.3.3 with: version: 1.4.2 virtualenvs-in-project: true + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4.6.0 + with: + python-version: ${{ matrix.python-version }} + architecture: x64 + - name: Load cached venv + id: cached-poetry-dependencies + uses: actions/cache@v3 + with: + path: .venv + key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} - name: Install dependencies run: poetry install --extras "dev" if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' - - name: Format check with black - run: | - source $VENV - make format-check # - name: Typecheck with mypy # run: | -# source $VENV # make typecheck - name: Test with pytest run: | From 7a8d6920e81a819e44ee3a8d7bba83ba35140e02 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: Tue, 16 May 2023 11:14:54 +0100 Subject: [PATCH 42/85] Error when dismissing non-active screen. Related issues: #2575. --- CHANGELOG.md | 1 + src/textual/app.py | 2 +- src/textual/screen.py | 9 +++++++++ tests/test_screens.py | 14 ++++++++++++++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5991f818d..bce29efda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521 - Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 +- Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575 ### Fixed diff --git a/src/textual/app.py b/src/textual/app.py index 12f66cf04..c1376cd22 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -156,7 +156,7 @@ class ScreenError(Exception): class ScreenStackError(ScreenError): - """Raised when attempting to pop the last screen from the stack.""" + """Raised when trying to manipulate the screen stack incorrectly.""" class CssPathError(Exception): diff --git a/src/textual/screen.py b/src/textual/screen.py index af0b006be..a16273121 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -28,6 +28,7 @@ from ._callback import invoke from ._compositor import Compositor, MapGeometry from ._context import visible_screen_stack from ._types import CallbackType +from .app import ScreenStackError from .binding import Binding from .css.match import match from .css.parse import parse_selectors @@ -771,6 +772,10 @@ class Screen(Generic[ScreenResultType], Widget): Args: result: The optional result to be passed to the result callback. + Raises: + ScreenStackError: If trying to dismiss a screen that is not at the top of + the stack. + Note: If the screen was pushed with a callback, the callback will be called with the given result and then a call to @@ -778,6 +783,10 @@ class Screen(Generic[ScreenResultType], Widget): no callback was provided calling this method is the same as simply calling [`App.pop_screen`][textual.app.App.pop_screen]. """ + if self is not self.app.screen: + raise ScreenStackError( + f"Can't dismiss screen {self} that's not at the top of the stack." + ) if result is not self._NoResult and self._result_callbacks: self._result_callbacks[-1](cast(ScreenResultType, result)) self.app.pop_screen() diff --git a/tests/test_screens.py b/tests/test_screens.py index 2e3dbfcbe..5b29b1dd5 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -192,3 +192,17 @@ async def test_auto_focus(): assert app.focused is None app.pop_screen() assert app.focused.id == "two" + + +async def test_dismiss_non_top_screen(): + class MyApp(App[None]): + async def key_p(self) -> None: + self.bottom, top = Screen(), Screen() + await self.push_screen(self.bottom) + await self.push_screen(top) + + app = MyApp() + async with app.run_test() as pilot: + await pilot.press("p") + with pytest.raises(ScreenStackError): + app.bottom.dismiss() From b592ac077ab3bd4d9e4b5dade314c0cfc4cc9f19 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: Tue, 16 May 2023 11:27:24 +0100 Subject: [PATCH 43/85] AUTO_FOCUS targets first focusable widget. Related issues: #2578. --- CHANGELOG.md | 1 + src/textual/screen.py | 7 +++++-- tests/test_screens.py | 21 ++++++++++++++++++--- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5991f818d..216339e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 - Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563 +- `Screen.AUTO_FOCUS` now focuses the first _focusable_ widget that matches the selector https://github.com/Textualize/textual/issues/2578 ### Added diff --git a/src/textual/screen.py b/src/textual/screen.py index af0b006be..9f065dfbf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -668,11 +668,14 @@ class Screen(Generic[ScreenResultType], Widget): size = self.app.size if self.AUTO_FOCUS is not None and self.focused is None: try: - to_focus = self.query(self.AUTO_FOCUS).first() + focus_candidates = self.query(self.AUTO_FOCUS) except NoMatches: pass else: - self.set_focus(to_focus) + for widget in focus_candidates: + if widget.focusable: + self.set_focus(widget) + break self._refresh_layout(size, full=True) self.refresh() diff --git a/tests/test_screens.py b/tests/test_screens.py index 2e3dbfcbe..7ddc8b20e 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -6,7 +6,7 @@ import pytest from textual.app import App, ScreenStackError from textual.screen import Screen -from textual.widgets import Button, Input +from textual.widgets import Button, Input, Label skip_py310 = pytest.mark.skipif( sys.version_info.minor == 10 and sys.version_info.major == 3, @@ -155,8 +155,7 @@ async def test_screens(): async def test_auto_focus(): class MyScreen(Screen[None]): - def compose(self) -> None: - print("composing") + def compose(self): yield Button() yield Input(id="one") yield Input(id="two") @@ -192,3 +191,19 @@ async def test_auto_focus(): assert app.focused is None app.pop_screen() assert app.focused.id == "two" + + +async def test_auto_focus_skips_non_focusable_widgets(): + class MyScreen(Screen[None]): + def compose(self): + yield Label() + yield Button() + + class MyApp(App[None]): + def on_mount(self): + self.push_screen(MyScreen()) + + app = MyApp() + async with app.run_test(): + assert app.focused is not None + assert isinstance(app.focused, Button) From 93f4de918ce5dfa9f29ea693a2f815282def6625 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: Tue, 16 May 2023 11:30:14 +0100 Subject: [PATCH 44/85] Fix circular import. --- src/textual/screen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index a16273121..09505cc53 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -28,7 +28,6 @@ from ._callback import invoke from ._compositor import Compositor, MapGeometry from ._context import visible_screen_stack from ._types import CallbackType -from .app import ScreenStackError from .binding import Binding from .css.match import match from .css.parse import parse_selectors @@ -784,6 +783,8 @@ class Screen(Generic[ScreenResultType], Widget): simply calling [`App.pop_screen`][textual.app.App.pop_screen]. """ if self is not self.app.screen: + from .app import ScreenStackError + raise ScreenStackError( f"Can't dismiss screen {self} that's not at the top of the stack." ) From a6ee867ee36506c65d3cc14041e0f79b66ac9cdc 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: Tue, 16 May 2023 11:39:38 +0100 Subject: [PATCH 45/85] Fix tests. --- src/textual/screen.py | 4 ++-- tests/snapshot_tests/test_snapshots.py | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 9f065dfbf..5cec44b88 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -666,6 +666,8 @@ class Screen(Generic[ScreenResultType], Widget): """Screen has resumed.""" self.stack_updates += 1 size = self.app.size + self._refresh_layout(size, full=True) + self.refresh() if self.AUTO_FOCUS is not None and self.focused is None: try: focus_candidates = self.query(self.AUTO_FOCUS) @@ -676,8 +678,6 @@ class Screen(Generic[ScreenResultType], Widget): if widget.focusable: self.set_focus(widget) break - self._refresh_layout(size, full=True) - self.refresh() def _on_screen_suspend(self) -> None: """Screen has suspended.""" diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ea5e32153..d70fc761e 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -203,9 +203,11 @@ def test_option_list(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py") + def test_option_list_build(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py") + def test_progress_bar_indeterminate(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"]) @@ -440,7 +442,7 @@ def test_modal_dialog_bindings_input(snap_compare): # Check https://github.com/Textualize/textual/issues/2194 assert snap_compare( SNAPSHOT_APPS_DIR / "modal_screen_bindings.py", - press=["enter", "tab", "h", "!", "left", "i", "tab"], + press=["enter", "h", "!", "left", "i", "tab"], ) From a9a04dc37a9dad6c1a47cf2a842e109ea2b9ffbd 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: Tue, 16 May 2023 11:43:31 +0100 Subject: [PATCH 46/85] Run workflows when they are changed. Related comments: https://github.com/Textualize/textual/pull/2577\#issuecomment-1549411724 --- .github/workflows/black_format.yml | 1 + .github/workflows/pythonpackage.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/black_format.yml b/.github/workflows/black_format.yml index aba9ba1f5..063068c35 100644 --- a/.github/workflows/black_format.yml +++ b/.github/workflows/black_format.yml @@ -4,6 +4,7 @@ name: Black format check on: pull_request: paths: + - '.github/workflows/black_format.yml' - '**.py' jobs: diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 79504215b..27f36d406 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -3,6 +3,7 @@ name: Test Textual module on: pull_request: paths: + - '.github/workflows/pythonpackage.yml' - '**.py' - '**.pyi' - '**.css' From dde3ad397f82cbd32c17dfd812dfdb04fdbc0730 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 12:01:45 +0100 Subject: [PATCH 47/85] Remove duplicated setting of height for Select (#2576) --- src/textual/widgets/_select.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 25564af95..dddd9e157 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -185,10 +185,6 @@ class Select(Generic[SelectType], Vertical, can_focus=True): border: tall $accent; } - Select { - height: auto; - } - Select > SelectOverlay { width: 1fr; display: none; From f12aeb00d2b9e1835ccaf9b7e194818db25438a0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 13:33:57 +0100 Subject: [PATCH 48/85] Remove forced content tracking refresh in clear_options While the fix for #2557 likely isn't *the* fix (see #2582 for some context around that), it is a fix that works for now. As such, with the change, there was a double attempt to refresh the content tracking in the clearing of options in the OptionList, which shouldn't be necessary. This removes that. --- src/textual/widgets/_option_list.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 7d9af219b..5b4a39b48 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -627,7 +627,6 @@ class OptionList(ScrollView, can_focus=True): """ self._contents.clear() self._options.clear() - self._refresh_content_tracking(force=True) self.highlighted = None self._mouse_hovering_over = None self.virtual_size = Size(self.scrollable_content_region.width, 0) From 32fa259c94261dab70337314c2d55f406e919973 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 13:38:08 +0100 Subject: [PATCH 49/85] Add a TODO comment to the effect that this is a temp fix --- src/textual/widgets/_option_list.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 5b4a39b48..dfe530543 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -630,6 +630,13 @@ class OptionList(ScrollView, can_focus=True): self.highlighted = None self._mouse_hovering_over = None self.virtual_size = Size(self.scrollable_content_region.width, 0) + # TODO: See https://github.com/Textualize/textual/issues/2582 -- it + # should not be necessary to do this like this here; ideally here in + # clear_options it would be a forced refresh, and also in a + # `on_show` it would be the same (which, I think, would actually + # solve the problem we're seeing). But, until such a time as we get + # to the bottom of 2582... this seems to delay the refresh enough + # that things fall into place. self._request_content_tracking_refresh() return self From faa67a82934e2de26471357cfd40fc8ee9b61fa8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 May 2023 13:44:06 +0100 Subject: [PATCH 50/85] Screen docs (#2579) * screen docs * docstrings * modal example * docstring * docstrings * Apply suggestions from code review Co-authored-by: Dave Pearson --------- Co-authored-by: Dave Pearson --- docs/examples/guide/screens/modal02.py | 1 + docs/examples/guide/screens/modal03.py | 57 ++++++++++++++++++++++++++ docs/guide/screens.md | 37 +++++++++++++++++ src/textual/app.py | 2 +- src/textual/screen.py | 9 ++-- 5 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 docs/examples/guide/screens/modal03.py diff --git a/docs/examples/guide/screens/modal02.py b/docs/examples/guide/screens/modal02.py index bb6c49c59..418030f80 100644 --- a/docs/examples/guide/screens/modal02.py +++ b/docs/examples/guide/screens/modal02.py @@ -42,6 +42,7 @@ class ModalApp(App): yield Footer() def action_request_quit(self) -> None: + """Action to display the quit dialog.""" self.push_screen(QuitScreen()) diff --git a/docs/examples/guide/screens/modal03.py b/docs/examples/guide/screens/modal03.py new file mode 100644 index 000000000..e19fc527b --- /dev/null +++ b/docs/examples/guide/screens/modal03.py @@ -0,0 +1,57 @@ +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.screen import ModalScreen +from textual.widgets import Button, Footer, Header, Label + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class QuitScreen(ModalScreen[bool]): # (1)! + """Screen with a dialog to quit.""" + + def compose(self) -> ComposeResult: + yield Grid( + Label("Are you sure you want to quit?", id="question"), + Button("Quit", variant="error", id="quit"), + Button("Cancel", variant="primary", id="cancel"), + id="dialog", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "quit": + self.dismiss(True) + else: + self.dismiss(False) + + +class ModalApp(App): + """An app with a modal dialog.""" + + CSS_PATH = "modal01.css" + BINDINGS = [("q", "request_quit", "Quit")] + + def compose(self) -> ComposeResult: + yield Header() + yield Label(TEXT * 8) + yield Footer() + + def action_request_quit(self) -> None: + """Action to display the quit dialog.""" + + def check_quit(quit: bool) -> None: + """Called when QuitScreen is dismissed.""" + if quit: + self.exit() + + self.push_screen(QuitScreen(), check_quit) + + +if __name__ == "__main__": + app = ModalApp() + app.run() diff --git a/docs/guide/screens.md b/docs/guide/screens.md index fe8e3616f..0119ef655 100644 --- a/docs/guide/screens.md +++ b/docs/guide/screens.md @@ -219,3 +219,40 @@ Let's see what happens when we use `ModalScreen`. Now when we press ++q++, the dialog is displayed over the main screen. The main screen is darkened to indicate to the user that it is not active, and only the dialog will respond to input. + +## Returning data from screens + +It is a common requirement for screens to be able to return data. +For instance, you may want a screen to show a dialog and have the result of that dialog processed *after* the screen has been popped. + +To return data from a screen, call [`dismiss()`][textual.screen.dismiss] on the screen with the data you wish to return. +This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.push_screen]). + +Let's modify the previous example to use `dismiss` rather than an explicit `pop_screen`. + +=== "modal03.py" + + ```python title="modal03.py" hl_lines="15 27-30 47-50 52" + --8<-- "docs/examples/guide/screens/modal03.py" + ``` + + 1. See below for an explanation of the `[bool]` + +=== "modal01.css" + + ```sass title="modal01.css" + --8<-- "docs/examples/guide/screens/modal01.css" + ``` + +In the `on_button_pressed` message handler we call `dismiss` with a boolean that indicates if the user has chosen to quit the app. +This boolean is passed to the `check_quit` function we provided when `QuitScreen` was pushed. + +Although this example behaves the same as the previous code, it is more flexible because it has removed responsibility for exiting from the modal screen to the caller. +This makes it easier for the app to perform any cleanup actions prior to exiting, for example. + +Returning data in this way can help keep your code manageable by making it easy to re-use your `Screen` classes in other contexts. + +### Typing screen results + +You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`. +The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs. diff --git a/src/textual/app.py b/src/textual/app.py index c1376cd22..841d854e2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1416,7 +1416,7 @@ class App(Generic[ReturnType], DOMNode): Args: screen: A Screen instance or the name of an installed screen. - callback: An optional callback function that is called if the screen is dismissed with a result. + callback: An optional callback function that will be called if the screen is [dismissed][textual.screen.Screen.dismiss] with a result. Returns: An optional awaitable that awaits the mounting of the screen and its children. diff --git a/src/textual/screen.py b/src/textual/screen.py index 09505cc53..535b621ea 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -768,6 +768,9 @@ class Screen(Generic[ScreenResultType], Widget): def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None: """Dismiss the screen, optionally with a result. + If `result` is provided and a callback was set when the screen was [pushed][textual.app.push_screen], then + the callback will be invoked with `result`. + Args: result: The optional result to be passed to the result callback. @@ -775,12 +778,6 @@ class Screen(Generic[ScreenResultType], Widget): ScreenStackError: If trying to dismiss a screen that is not at the top of the stack. - Note: - If the screen was pushed with a callback, the callback will be - called with the given result and then a call to - [`App.pop_screen`][textual.app.App.pop_screen] is performed. If - no callback was provided calling this method is the same as - simply calling [`App.pop_screen`][textual.app.App.pop_screen]. """ if self is not self.app.screen: from .app import ScreenStackError From 3d2e3d909277c02753c9fbb597c3395589e9e97c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 14:34:18 +0100 Subject: [PATCH 51/85] Add a snapshot test for a rebuilt Select This helps test the practical impact of the fix added for #2557. --- .../snapshot_apps/select_rebuild.py | 21 +++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 9 ++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/select_rebuild.py diff --git a/tests/snapshot_tests/snapshot_apps/select_rebuild.py b/tests/snapshot_tests/snapshot_apps/select_rebuild.py new file mode 100644 index 000000000..190db3b4b --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/select_rebuild.py @@ -0,0 +1,21 @@ +"""Test https://github.com/Textualize/textual/issues/2557""" + +from textual.app import App, ComposeResult +from textual.widgets import Select, Button + + +class SelectRebuildApp(App[None]): + + def compose(self) -> ComposeResult: + yield Select[int]((("1", 1), ("2", 2))) + yield Button("Rebuild") + + def on_button_pressed(self): + self.query_one(Select).set_options(( + ("This", 0), ("Should", 1), ("Be", 2), + ("What", 3), ("Goes", 4), ("Into",5), + ("The", 6), ("Snapshit", 7) + )) + +if __name__ == "__main__": + SelectRebuildApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ea5e32153..9bb589f7a 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -493,3 +493,12 @@ def test_quickly_change_tabs(snap_compare): def test_fr_unit_with_min(snap_compare): # https://github.com/Textualize/textual/issues/2378 assert snap_compare(SNAPSHOT_APPS_DIR / "fr_with_min.py") + + +def test_select_rebuild(snap_compare): + # https://github.com/Textualize/textual/issues/2557 + assert snap_compare( + SNAPSHOT_APPS_DIR / "select_rebuild.py", + press=["tab", "space", "escape", "tab", "enter", "tab", "space"] + ) + From 1ebfe2f418ab0cc58255114345eb912ca345f4be Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 14:38:11 +0100 Subject: [PATCH 52/85] Update the snapshits --- .../__snapshots__/test_snapshots.ambr | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 8b03449d9..018558247 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -22706,6 +22706,169 @@ ''' # --- +# name: test_select_rebuild + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectRebuildApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + This + Should + Be + What + Goes + Into + The + Snapshit + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + ''' +# --- # name: test_switches ''' From 926c0a2b4f143b1bf2eb7739eaa58205ae346c56 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 15:29:36 +0100 Subject: [PATCH 53/85] Reset all DirectoryTree worker changes After deciding https://github.com/Textualize/textual/pull/2545#issuecomment-1547544057 it makes more sense to roll back to the state of `main` than to try and get to where I want to be from where we've decided we didn't want to be. Can't get there from here, so let's go rogue-like on this PR... --- src/textual/widgets/_directory_tree.py | 155 +++---------------------- 1 file changed, 13 insertions(+), 142 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9ba82d6df..fb6d7c5c5 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -2,18 +2,14 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from queue import Empty, Queue -from typing import ClassVar, Iterable, Iterator +from typing import ClassVar, Iterable from rich.style import Style from rich.text import Text, TextType -from typing_extensions import Final -from .. import work from ..events import Mount from ..message import Message from ..reactive import var -from ..worker import get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode @@ -128,21 +124,12 @@ class DirectoryTree(Tree[DirEntry]): classes=classes, disabled=disabled, ) - self._waiting_load_jobs: Queue[TreeNode[DirEntry]] = Queue() - self._running_load_jobs: set[int] = set() self.path = path def reload(self) -> None: """Reload the `DirectoryTree` contents.""" - # We're about to nuke the whole tree and start over, so we don't - # want any dangling load jobs. Before we do anything else, ensure - # they're all marked as cancelled and that the queue of pending jobs - # has been emptied. - self._cancel_all_jobs() - # That out of the way, we can reset the tree and start loading the - # root's content. self.reset(str(self.path), DirEntry(Path(self.path))) - self._add_load_job(self.root) + self._load_directory(self.root) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -242,30 +229,18 @@ class DirectoryTree(Tree[DirEntry]): """ return paths - def _directory_content(self, directory: Path) -> Iterator[Path]: - """Get the entries within a given directory. + def _load_directory(self, node: TreeNode[DirEntry]) -> None: + """Load the directory contents for a given node. Args: - directory: The directory to get the content of. - - Returns: - An iterator of `Path` objects. - """ - worker = get_current_worker() - for entry in directory.iterdir(): - if worker.is_cancelled: - return - yield entry - - def _populate_node( - self, node: TreeNode[DirEntry], directory: Iterable[Path] - ) -> None: - """Populate the given node with the contents of a directory. - - Args: - node: The node to populate. - directory: The directory contents to populate it with. + node: The node to load the directory contents for. """ + assert node.data is not None + node.data.loaded = True + directory = sorted( + self.filter_paths(node.data.path.iterdir()), + key=lambda path: (not path.is_dir(), path.name.lower()), + ) for path in directory: node.add( path.name, @@ -274,112 +249,8 @@ class DirectoryTree(Tree[DirEntry]): ) node.expand() - @dataclass - class _LoadFinished(Message): - """Internal message to mark when a load of a node is finished.""" - - node: TreeNode[DirEntry] - """The node that has finished loading.""" - - @work - def _load_directory(self, node: TreeNode[DirEntry]) -> None: - """Load the directory contents for a given node. - - Args: - node: The node to load the directory contents for. - """ - - # We should not ever be asked to load a directory for a node that - # has no directory information. - assert node.data is not None - - # Mark the node as loaded; we do this as soon as possible. - node.data.loaded = True - - # From now on we get the directory content and populate the node in - # simple steps, checking that the worker hasn't been cancelled at - # every step of the way. We /could/ just run to the end, but we - # might as well drop out of here as soon as we can tell we've been - # asked to stop. - worker = get_current_worker() - - # Load up the content of the directory. - content = self.filter_paths(self._directory_content(node.data.path)) - if worker.is_cancelled: - return - - # We're still going, sort the content, case-insensitive, placing - # directory entries up front. - content = sorted( - content, - key=lambda path: (not path.is_dir(), path.name.lower()), - ) - if worker.is_cancelled: - return - - # We have directory content, it's filtered, it's sorted, we're still - # working, so now let's update the actual node in the tree. - self.app.call_from_thread(self._populate_node, node, content) - - # Finally, if we're 100% sure we've not been cancelled, post a - # message to say the load has finished. Our caller should not be - # told we finished fine if they've cancelled us. - if not worker.is_cancelled: - self.post_message(self._LoadFinished(node)) - - _MAX_CONCURRENT_JOBS: Final[int] = 5 - """The maximum number of load jobs to run at the same time.""" - - def _cancel_all_jobs(self) -> None: - """Cancel all running load jobs.""" - self._waiting_load_jobs = Queue() - self._running_load_jobs = set() - # TODO: Check if there's an Textual-API-way to say "get all workers - # in this DOM node", or "cancel all of the works I made", or - # something. This seems fine, but I want to be 100% sure. - for job in (worker for worker in self.app.workers if worker.node == self): - job.cancel() - - def _process_load_jobs(self) -> None: - """Process the incoming load request queue.""" - # While we still have spare capacity... - while len(self._running_load_jobs) <= self._MAX_CONCURRENT_JOBS: - try: - # ...pull a load job off the queue. - new_job = self._waiting_load_jobs.get(block=False) - except Empty: - # Queue is empty; our work here is done. - return - # At this point we've got a new directory load job; add it to - # the collection of running jobs and kick off the load, but only - # if there isn't already a job for it. - if not new_job.id in self._running_load_jobs: - self._running_load_jobs.add(new_job.id) - self._load_directory(new_job) - - def _on_directory_tree__load_finished( - self, event: DirectoryTree._LoadFinished - ) -> None: - """Act on a signal that a node has finished loading. - - Args: - event: The event to process. - """ - event.stop() - self._running_load_jobs.remove(event.node.id) - self._process_load_jobs() - - def _add_load_job(self, node: TreeNode[DirEntry]) -> None: - """Add a directory loading job to the queue. - - Args: - node: The node that needs loading. - """ - self._waiting_load_jobs.put(node) - self._process_load_jobs() - def _on_mount(self, _: Mount) -> None: - self._add_load_job(self.root) + self._load_directory(self.root) def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() @@ -388,7 +259,7 @@ class DirectoryTree(Tree[DirEntry]): return if dir_entry.path.is_dir(): if not dir_entry.loaded: - self._add_load_job(event.node) + self._load_directory(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From 58f0d11a93d6b1398f2c21850217acc0f3fcd2d8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 16:41:36 +0100 Subject: [PATCH 54/85] Change to a single loader thread with a queue --- src/textual/widgets/_directory_tree.py | 80 +++++++++++++++++++++----- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index fb6d7c5c5..be7fe9794 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -2,14 +2,18 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path -from typing import ClassVar, Iterable +from queue import Empty, Queue +from typing import ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType +from typing_extensions import Final +from .. import work from ..events import Mount from ..message import Message from ..reactive import var +from ..worker import Worker, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode @@ -116,6 +120,7 @@ class DirectoryTree(Tree[DirEntry]): classes: A space-separated list of classes, or None for no classes. disabled: Whether the directory tree is disabled or not. """ + self._to_load: Queue[TreeNode[DirEntry]] = Queue() super().__init__( str(path), data=DirEntry(Path(path)), @@ -129,7 +134,13 @@ class DirectoryTree(Tree[DirEntry]): def reload(self) -> None: """Reload the `DirectoryTree` contents.""" self.reset(str(self.path), DirEntry(Path(self.path))) - self._load_directory(self.root) + # Orphan the old queue... + self._to_load = Queue() + # ...and replace the old load with a new one. + self._loader() + # We have a fresh queue, we have a fresh loader, get the fresh root + # loading up. + self._to_load.put_nowait(self.root) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -229,19 +240,14 @@ class DirectoryTree(Tree[DirEntry]): """ return paths - def _load_directory(self, node: TreeNode[DirEntry]) -> None: - """Load the directory contents for a given node. + def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None: + """Populate the given tree node with the given directory content. Args: - node: The node to load the directory contents for. + node: The Tree node to populate. + content: The collection of `Path` objects to populate the node with. """ - assert node.data is not None - node.data.loaded = True - directory = sorted( - self.filter_paths(node.data.path.iterdir()), - key=lambda path: (not path.is_dir(), path.name.lower()), - ) - for path in directory: + for path in content: node.add( path.name, data=DirEntry(path), @@ -249,8 +255,52 @@ class DirectoryTree(Tree[DirEntry]): ) node.expand() - def _on_mount(self, _: Mount) -> None: - self._load_directory(self.root) + def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]: + """Load the content of a given directory. + + Args: + location: The location to load from. + worker: The worker that the loading is taking place in. + + Yields: + Path: A entry within the location. + """ + for entry in location.iterdir(): + if worker.is_cancelled: + break + yield entry + + def _load_directory(self, node: TreeNode[DirEntry], worker: Worker) -> None: + """Load the directory contents for a given node. + + Args: + node: The node to load the directory contents for. + """ + assert node.data is not None + node.data.loaded = True + self.app.call_from_thread( + self._populate_node, + node, + sorted( + self.filter_paths(self._directory_content(node.data.path, worker)), + key=lambda path: (not path.is_dir(), path.name.lower()), + ), + ) + + _LOADER_INTERVAL: Final[float] = 0.2 + """How long the loader should block while waiting for queue content.""" + + @work(exclusive=True) + def _loader(self) -> None: + """Background loading queue processor.""" + worker = get_current_worker() + while not worker.is_cancelled: + try: + self._load_directory( + self._to_load.get(timeout=self._LOADER_INTERVAL), worker + ) + except Empty: + pass def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() @@ -259,7 +309,7 @@ class DirectoryTree(Tree[DirEntry]): return if dir_entry.path.is_dir(): if not dir_entry.loaded: - self._load_directory(event.node) + self._to_load.put_nowait(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From e69e57d7c02e02da706845200439127c45a855b3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 16 May 2023 20:53:18 +0100 Subject: [PATCH 55/85] Remove unused import Recent changes meant it wasn't needed any more. --- src/textual/widgets/_directory_tree.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index be7fe9794..437811758 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -10,7 +10,6 @@ from rich.text import Text, TextType from typing_extensions import Final from .. import work -from ..events import Mount from ..message import Message from ..reactive import var from ..worker import Worker, get_current_worker From abb7705ed0463e00d403d0fad7b659aa46e995fc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 May 2023 21:06:09 +0100 Subject: [PATCH 56/85] wait for screen (#2584) * wait for screen * comments and changelog * wait for screen after keys * extra wait for animation * comment * comment * docstring --- CHANGELOG.md | 1 + src/textual/message_pump.py | 18 ++++++++++++---- src/textual/pilot.py | 42 ++++++++++++++++++++++++++++++++++++- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bce29efda..145e578a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521 - Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 - Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575 +- `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 ### Fixed diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 7439919ce..a4dfc8256 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -349,20 +349,25 @@ class MessagePump(metaclass=_MessagePumpMeta): self._timers.add(timer) return timer - def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> None: + def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> bool: """Schedule a callback to run after all messages are processed and the screen has been refreshed. Positional and keyword arguments are passed to the callable. Args: callback: A callable. + + Returns: + `True` if the callback was scheduled, or `False` if the callback could not be + scheduled (may occur if the message pump was closed or closing). + """ # We send the InvokeLater message to ourselves first, to ensure we've cleared # out anything already pending in our own queue. message = messages.InvokeLater(partial(callback, *args, **kwargs)) - self.post_message(message) + return self.post_message(message) - def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> None: + def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> bool: """Schedule a callback to run after all messages are processed in this object. Positional and keywords arguments are passed to the callable. @@ -370,9 +375,14 @@ class MessagePump(metaclass=_MessagePumpMeta): callback: Callable to call next. *args: Positional arguments to pass to the callable. **kwargs: Keyword arguments to pass to the callable. + + Returns: + `True` if the callback was scheduled, or `False` if the callback could not be + scheduled (may occur if the message pump was closed or closing). + """ message = events.Callback(callback=partial(callback, *args, **kwargs)) - self.post_message(message) + return self.post_message(message) def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None: """Schedule a callback to run immediately after processing the current message. diff --git a/src/textual/pilot.py b/src/textual/pilot.py index eaab42334..041e00e13 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -65,6 +65,7 @@ class Pilot(Generic[ReturnType]): """ if keys: await self._app._press_keys(keys) + await self._wait_for_screen() async def click( self, @@ -132,13 +133,49 @@ class Pilot(Generic[ReturnType]): app.post_message(MouseMove(**message_arguments)) await self.pause() + async def _wait_for_screen(self, timeout: float = 30.0) -> bool: + """Wait for the current screen to have processed all pending events. + + Args: + timeout: A timeout in seconds to wait. + + Returns: + `True` if all events were processed, or `False` if the wait timed out. + """ + children = [self.app, *self.app.screen.walk_children(with_self=True)] + count = 0 + count_zero_event = asyncio.Event() + + def decrement_counter() -> None: + """Decrement internal counter, and set an event if it reaches zero.""" + nonlocal count + count -= 1 + if count == 0: + # When count is zero, all messages queued at the start of the method have been processed + count_zero_event.set() + + # Increase the count for every successful call_later + for child in children: + if child.call_later(decrement_counter): + count += 1 + + if count: + # Wait for the count to return to zero, or a timeout + try: + await asyncio.wait_for(count_zero_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + return False + + return True + async def pause(self, delay: float | None = None) -> None: """Insert a pause. Args: delay: Seconds to pause, or None to wait for cpu idle. """ - # These sleep zeros, are to force asyncio to give up a time-slice, + # These sleep zeros, are to force asyncio to give up a time-slice. + await self._wait_for_screen() if delay is None: await wait_for_idle(0) else: @@ -152,7 +189,9 @@ class Pilot(Generic[ReturnType]): async def wait_for_scheduled_animations(self) -> None: """Wait for any current and scheduled animations to complete.""" + await self._wait_for_screen() await self._app.animator.wait_until_complete() + await self._wait_for_screen() await wait_for_idle() self.app.screen._on_timer_update() @@ -162,5 +201,6 @@ class Pilot(Generic[ReturnType]): Args: result: The app result returned by `run` or `run_async`. """ + await self._wait_for_screen() await wait_for_idle() self.app.exit(result) From 3a17a762334d9a45106321ef5dfdc43ffb848ed1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 May 2023 21:34:34 +0100 Subject: [PATCH 57/85] Exit debug (#2554) * show single error by default * changelog * show numbers of errors * changelog --- CHANGELOG.md | 1 + src/textual/app.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 145e578a2..cd3e91dda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521 +- Only a single error will be written by default, unless in dev mode ("debug" in App.features) https://github.com/Textualize/textual/issues/2480 - Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 - Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575 - `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 diff --git a/src/textual/app.py b/src/textual/app.py index 841d854e2..c8863d764 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1630,8 +1630,22 @@ class App(Generic[ReturnType], DOMNode): def _print_error_renderables(self) -> None: """Print and clear exit renderables.""" - for renderable in self._exit_renderables: - self.error_console.print(renderable) + error_count = len(self._exit_renderables) + if "debug" in self.features: + for renderable in self._exit_renderables: + self.error_console.print(renderable) + if error_count > 1: + self.error_console.print( + f"\n[b]NOTE:[/b] {error_count} errors show above.", markup=True + ) + elif self._exit_renderables: + self.error_console.print(self._exit_renderables[0]) + if error_count > 1: + self.error_console.print( + f"\n[b]NOTE:[/b] 1 of {error_count} errors show. Run with [b]--dev[/] to see all errors.", + markup=True, + ) + self._exit_renderables.clear() async def _process_messages( From 53e765f7d62a31de288842a65451c7d3efa43cef Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 16 May 2023 21:34:59 +0100 Subject: [PATCH 58/85] Avoid docks when scrolling (#2571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * handle docked layers * handle scroll better * snapshot update * remove commented out code * superflous * dock gutter * snapshit * snapshit test * changelog * mistake * docstrings * changelog * whitespace * missing punctuation * ofx docstring * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- CHANGELOG.md | 2 + src/textual/_compositor.py | 21 +- src/textual/_layout.py | 3 +- src/textual/_spatial_map.py | 4 +- src/textual/geometry.py | 10 +- src/textual/screen.py | 2 + src/textual/widget.py | 22 +- .../__snapshots__/test_snapshots.ambr | 491 ++++++++++++++++++ .../snapshot_apps/dock_scroll2.py | 33 ++ .../snapshot_apps/dock_scroll_off_by_one.py | 17 + .../snapshot_apps/horizontal_auto_width.css | 4 +- .../snapshot_tests/snapshot_apps/scroll_to.py | 19 + tests/snapshot_tests/test_snapshots.py | 19 + 13 files changed, 624 insertions(+), 23 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/dock_scroll2.py create mode 100644 tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py create mode 100644 tests/snapshot_tests/snapshot_apps/scroll_to.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd3e91dda..990180d91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 - Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563 +- Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525 ### Added - Class variable `AUTO_FOCUS` to screens https://github.com/Textualize/textual/issues/2457 +- Added `NULL_SPACING` and `NULL_REGION` to geometry.py ## [0.24.1] - 2023-05-08 diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 61e4f3de8..300b52029 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -33,7 +33,7 @@ from . import errors from ._cells import cell_len from ._context import visible_screen_stack from ._loop import loop_last -from .geometry import NULL_OFFSET, Offset, Region, Size +from .geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing from .strip import Strip, StripRenderable if TYPE_CHECKING: @@ -71,6 +71,8 @@ class MapGeometry(NamedTuple): """The container [size][textual.geometry.Size] (area not occupied by scrollbars).""" virtual_region: Region """The [region][textual.geometry.Region] relative to the container (but not necessarily visible).""" + dock_gutter: Spacing + """Space from the container reserved by docked widgets.""" @property def visible_region(self) -> Region: @@ -484,7 +486,7 @@ class Compositor: # Widgets and regions in render order visible_widgets = [ (order, widget, region, clip) - for widget, (region, order, clip, _, _, _) in map.items() + for widget, (region, order, clip, _, _, _, _) in map.items() if in_screen(region) and overlaps(clip, region) ] visible_widgets.sort(key=itemgetter(0), reverse=True) @@ -522,6 +524,7 @@ class Compositor: layer_order: int, clip: Region, visible: bool, + dock_gutter: Spacing, _MapGeometry: type[MapGeometry] = MapGeometry, ) -> None: """Called recursively to place a widget and its children in the map. @@ -591,10 +594,8 @@ class Compositor: get_layer_index = layers_to_index.get - scroll_spacing = arrange_result.scroll_spacing - # Add all the widgets - for sub_region, margin, sub_widget, z, fixed, overlay in reversed( + for sub_region, _, sub_widget, z, fixed, overlay in reversed( placements ): layer_index = get_layer_index(sub_widget.layer, 0) @@ -602,11 +603,6 @@ class Compositor: if fixed: widget_region = sub_region + placement_offset else: - total_region = total_region.union( - sub_region.grow( - margin if layer_index else margin + scroll_spacing - ) - ) widget_region = sub_region + placement_scroll_offset widget_order = order + ((layer_index, z, layer_order),) @@ -629,6 +625,7 @@ class Compositor: layer_order, no_clip if overlay else sub_clip, visible, + arrange_result.scroll_spacing, ) layer_order -= 1 @@ -646,6 +643,7 @@ class Compositor: container_size, container_size, chrome_region, + dock_gutter, ) map[widget] = _MapGeometry( @@ -655,6 +653,7 @@ class Compositor: total_region.size, container_size, virtual_region, + dock_gutter, ) elif visible: @@ -666,6 +665,7 @@ class Compositor: region.size, container_size, virtual_region, + dock_gutter, ) # Add top level (root) widget @@ -677,6 +677,7 @@ class Compositor: layer_order, size.region, True, + NULL_SPACING, ) return map, widgets diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 338b72192..575dc547f 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -51,7 +51,8 @@ class DockArrangeResult: Returns: A Region. """ - return self.spatial_map.total_region + _top, right, bottom, _left = self.scroll_spacing + return self.spatial_map.total_region.grow((0, right, bottom, 0)) def get_visible_placements(self, region: Region) -> list[WidgetPlacement]: """Get the placements visible within the given region. diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index af38065dd..3f778a06b 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -72,11 +72,11 @@ class SpatialMap(Generic[ValueType]): _region_to_grid = self._region_to_grid_coordinates total_region = self.total_region for region, fixed, overlay, value in regions_and_values: - if not overlay: - total_region = total_region.union(region) if fixed: append_fixed(value) else: + if not overlay: + total_region = total_region.union(region) for grid in _region_to_grid(region): get_grid_list(grid).append(value) self.total_region = total_region diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 4b337a4d9..41ca459f6 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -907,7 +907,7 @@ class Region(NamedTuple): class Spacing(NamedTuple): - """The spacing around a renderable, such as padding and border + """The spacing around a renderable, such as padding and border. Spacing is defined by four integers for the space at the top, right, bottom, and left of a region. @@ -940,7 +940,7 @@ class Spacing(NamedTuple): top: int = 0 """Space from the top of a region.""" right: int = 0 - """Space from the left of a region.""" + """Space from the right of a region.""" bottom: int = 0 """Space from the bottom of a region.""" left: int = 0 @@ -1095,3 +1095,9 @@ class Spacing(NamedTuple): NULL_OFFSET: Final = Offset(0, 0) """An [offset][textual.geometry.Offset] constant for (0, 0).""" + +NULL_REGION: Final = Region(0, 0, 0, 0) +"""A [Region][textual.geometry.Region] constant for a null region (at the origin, with both width and height set to zero).""" + +NULL_SPACING: Final = Spacing(0, 0, 0, 0) +"""A [Spacing][textual.geometry.Spacing] constant for no space.""" diff --git a/src/textual/screen.py b/src/textual/screen.py index 535b621ea..775223a03 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -584,6 +584,7 @@ class Screen(Generic[ScreenResultType], Widget): virtual_size, container_size, _, + _, ) in layers: if widget in exposed_widgets: if widget._size_updated( @@ -614,6 +615,7 @@ class Screen(Generic[ScreenResultType], Widget): virtual_size, container_size, _, + _, ) in layers: widget._size_updated(region.size, virtual_size, container_size) if widget in send_resize: diff --git a/src/textual/widget.py b/src/textual/widget.py index b116f5393..6d14cad74 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -57,7 +57,7 @@ from .box_model import BoxModel from .css.query import NoMatches, WrongType from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen -from .geometry import Offset, Region, Size, Spacing, clamp +from .geometry import NULL_REGION, NULL_SPACING, Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .messages import CallbackType @@ -1375,10 +1375,20 @@ class Widget(DOMNode): """ try: return self.screen.find_widget(self).region - except NoScreen: - return Region() - except errors.NoWidget: - return Region() + except (NoScreen, errors.NoWidget): + return NULL_REGION + + @property + def dock_gutter(self) -> Spacing: + """Space allocated to docks in the parent. + + Returns: + Space to be subtracted from scrollable area. + """ + try: + return self.screen.find_widget(self).dock_gutter + except (NoScreen, errors.NoWidget): + return NULL_SPACING @property def container_viewport(self) -> Region: @@ -2263,7 +2273,7 @@ class Widget(DOMNode): else: scroll_offset = container.scroll_to_region( region, - spacing=widget.parent.gutter, + spacing=widget.parent.gutter + widget.dock_gutter, animate=animate, speed=speed, duration=duration, diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 018558247..a5c7a6ea6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -14242,6 +14242,333 @@ ''' # --- +# name: test_dock_scroll2 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TestApp + + + + + + + + + + TestApp + ───────── + this + is + a + sample + sentence + and + here + are + some + wordsthis + is + a▅▅ + sample + sentence + and + here + are + some + words +  CTRL+Q  Quit  + + + + + + + + ''' +# --- +# name: test_dock_scroll_off_by_one + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollOffByOne + + + + + + + + + + X76 + X77 + X78 + X79 + X80 + X81 + X82 + X83 + X84 + X85 + X86 + X87 + X88 + X89 + X90 + X91 + X92 + X93 + X94▂▂ + X95 + X96 + X97 + X98 + X99 + + + + + + ''' +# --- # name: test_easing_preview ''' @@ -21751,6 +22078,170 @@ ''' # --- +# name: test_scroll_to + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ScrollOffByOne + + + + + + + + + + X27 + X28 + X29 + X30 + X31 + X32 + X33▄▄ + X34 + X35 + X36 + X37 + X38 + X39▂▂ + X40 + X41 + X42 + X43 + X44 + X45 + X46 + X47 + X48 + X49 + X50 + + + + + + ''' +# --- # name: test_scroll_to_center ''' diff --git a/tests/snapshot_tests/snapshot_apps/dock_scroll2.py b/tests/snapshot_tests/snapshot_apps/dock_scroll2.py new file mode 100644 index 000000000..fe2a1b234 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/dock_scroll2.py @@ -0,0 +1,33 @@ +from textual.app import App +from textual.widgets import Header, Label, Footer + + +# Same as dock_scroll.py but with 2 labels +class TestApp(App): + BINDINGS = [("ctrl+q", "app.quit", "Quit")] + CSS = """ + + Label { + border: solid red; + } + Footer { + height: 4; + } + """ + + def compose(self): + text = ( + "this is a sample sentence and here are some words".replace(" ", "\n") * 2 + ) + yield Header() + yield Label(text) + yield Label(text) + yield Footer() + + def on_mount(self): + self.dark = False + + +if __name__ == "__main__": + app = TestApp() + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py new file mode 100644 index 000000000..f9a5a00fd --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/dock_scroll_off_by_one.py @@ -0,0 +1,17 @@ +from textual.app import App, ComposeResult +from textual.widgets import Checkbox, Footer + + +class ScrollOffByOne(App): + def compose(self) -> ComposeResult: + for number in range(1, 100): + yield Checkbox(str(number)) + yield Footer() + + def on_mount(self) -> None: + self.query_one("Screen").scroll_end() + + +app = ScrollOffByOne() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css index 50ea9edff..f135a09c5 100644 --- a/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css +++ b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css @@ -19,6 +19,6 @@ #horizontal { width: auto; - height: auto; + height: 4; background: darkslateblue; -} \ No newline at end of file +} diff --git a/tests/snapshot_tests/snapshot_apps/scroll_to.py b/tests/snapshot_tests/snapshot_apps/scroll_to.py new file mode 100644 index 000000000..9dd21a3fc --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/scroll_to.py @@ -0,0 +1,19 @@ +from textual.app import App, ComposeResult +from textual.widgets import Checkbox, Footer + + +class ScrollOffByOne(App): + """Scroll to item 50.""" + + def compose(self) -> ComposeResult: + for number in range(1, 100): + yield Checkbox(str(number), id=f"number-{number}") + yield Footer() + + def on_ready(self) -> None: + self.query_one("#number-50").scroll_visible() + + +app = ScrollOffByOne() +if __name__ == "__main__": + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 9bb589f7a..0dcd49b19 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -203,9 +203,11 @@ def test_option_list(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py") assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py") + def test_option_list_build(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py") + def test_progress_bar_indeterminate(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"]) @@ -457,6 +459,23 @@ def test_dock_scroll(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll.py", terminal_size=(80, 25)) +def test_dock_scroll2(snap_compare): + # https://github.com/Textualize/textual/issues/2525 + assert snap_compare(SNAPSHOT_APPS_DIR / "dock_scroll2.py", terminal_size=(80, 25)) + + +def test_dock_scroll_off_by_one(snap_compare): + # https://github.com/Textualize/textual/issues/2525 + assert snap_compare( + SNAPSHOT_APPS_DIR / "dock_scroll_off_by_one.py", terminal_size=(80, 25) + ) + + +def test_scroll_to(snap_compare): + # https://github.com/Textualize/textual/issues/2525 + assert snap_compare(SNAPSHOT_APPS_DIR / "scroll_to.py", terminal_size=(80, 25)) + + def test_auto_fr(snap_compare): # https://github.com/Textualize/textual/issues/2220 assert snap_compare(SNAPSHOT_APPS_DIR / "auto_fr.py", terminal_size=(80, 25)) From 8753aa5ed0e3c4523ac01630c2a4d5501a15c32f Mon Sep 17 00:00:00 2001 From: Glenn McAllister Date: Tue, 16 May 2023 16:36:24 -0400 Subject: [PATCH 59/85] Update poetry-core requirement (#2572) Related issues: #2562 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index af9942407..56ada690a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,5 +83,5 @@ markers = [ ] [build-system] -requires = ["poetry-core>=1.0.0"] +requires = ["poetry-core>=1.2.0"] build-backend = "poetry.core.masonry.api" From c12fa0e4da15b8005b504d71b73bb5d5cc716566 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 May 2023 07:44:36 +0100 Subject: [PATCH 60/85] fix for dark switch (#2585) --- CHANGELOG.md | 1 + src/textual/app.py | 9 +-------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 990180d91..c35703853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TreeNode.toggle` and `TreeNode.toggle_all` not posting a `Tree.NodeExpanded` or `Tree.NodeCollapsed` message https://github.com/Textualize/textual/issues/2535 - `footer--description` component class was being ignored https://github.com/Textualize/textual/issues/2544 - Pasting empty selection in `Input` would raise an exception https://github.com/Textualize/textual/issues/2563 +- Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583 - Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index c8863d764..74585c166 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -604,14 +604,7 @@ class App(Generic[ReturnType], DOMNode): """ self.set_class(dark, "-dark-mode") self.set_class(not dark, "-light-mode") - try: - self.refresh_css() - except ScreenStackError: - # It's possible that `dark` can be set before we have a default - # screen, in an app's `on_load`, for example. So let's eat the - # ScreenStackError -- the above styles will be handled once the - # screen is spun up anyway. - pass + self.call_later(self.refresh_css) def get_driver_class(self) -> Type[Driver]: """Get a driver class for this platform. From 765c7ce037cf2a54f88f9813b1aa0501f91c6f19 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: Wed, 17 May 2023 10:01:01 +0100 Subject: [PATCH 61/85] Simplify auto focus code. Related comments: https://github.com/Textualize/textual/pull/2581\#discussion_r1195595104. --- src/textual/screen.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 8528aeecc..3cb9b65f2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -669,15 +669,10 @@ class Screen(Generic[ScreenResultType], Widget): self._refresh_layout(size, full=True) self.refresh() if self.AUTO_FOCUS is not None and self.focused is None: - try: - focus_candidates = self.query(self.AUTO_FOCUS) - except NoMatches: - pass - else: - for widget in focus_candidates: - if widget.focusable: - self.set_focus(widget) - break + for widget in self.query(self.AUTO_FOCUS): + if widget.focusable: + self.set_focus(widget) + break def _on_screen_suspend(self) -> None: """Screen has suspended.""" From 8399a31a461e102b951506493c444c2c28406b7d 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: Wed, 17 May 2023 10:09:52 +0100 Subject: [PATCH 62/85] Post ScreenResume to default screen. When the default screen is first created it was not getting the event ScreenResume. All other screens receive a ScreenResume when first created and _all_ screens (the default one and custom screens) receive this event when they become the active screen again, so this was kind of an edge case that needed the event to be posted by hand. Related comments: https://github.com/Textualize/textual/pull/2581\#issuecomment-1550231559 --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index c1376cd22..75703d8f7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2133,6 +2133,7 @@ class App(Generic[ReturnType], DOMNode): screen = Screen(id="_default") self._register(self, screen) self._screen_stack.append(screen) + screen.post_message(events.ScreenResume()) await super().on_event(event) elif isinstance(event, events.InputEvent) and not event.is_forwarded: From 45686c8acaed9263fb31b364381a311c8681de16 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: Wed, 17 May 2023 10:17:20 +0100 Subject: [PATCH 63/85] Fix tests. --- .../__snapshots__/test_snapshots.ambr | 1808 +++++++++-------- tests/snapshot_tests/test_snapshots.py | 14 +- tests/test_app.py | 3 +- tests/test_on.py | 2 +- tests/test_paste.py | 1 + tests/toggles/test_radioset.py | 7 +- 6 files changed, 917 insertions(+), 918 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 8b03449d9..0bc5ae9fd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,138 +21,138 @@ font-weight: 700; } - .terminal-1593336641-matrix { + .terminal-644510384-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1593336641-title { + .terminal-644510384-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1593336641-r1 { fill: #c5c8c6 } - .terminal-1593336641-r2 { fill: #7ae998 } - .terminal-1593336641-r3 { fill: #0a180e;font-weight: bold } - .terminal-1593336641-r4 { fill: #008139 } - .terminal-1593336641-r5 { fill: #e3dbce } - .terminal-1593336641-r6 { fill: #e1e1e1 } - .terminal-1593336641-r7 { fill: #e76580 } - .terminal-1593336641-r8 { fill: #f5e5e9;font-weight: bold } - .terminal-1593336641-r9 { fill: #780028 } + .terminal-644510384-r1 { fill: #c5c8c6 } + .terminal-644510384-r2 { fill: #7ae998 } + .terminal-644510384-r3 { fill: #4ebf71;font-weight: bold } + .terminal-644510384-r4 { fill: #008139 } + .terminal-644510384-r5 { fill: #e3dbce } + .terminal-644510384-r6 { fill: #e1e1e1 } + .terminal-644510384-r7 { fill: #e76580 } + .terminal-644510384-r8 { fill: #f5e5e9;font-weight: bold } + .terminal-644510384-r9 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignContainersApp + AlignContainersApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - center - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - middle - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + center + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + middle + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -1739,139 +1739,140 @@ font-weight: 700; } - .terminal-2222688117-matrix { + .terminal-78223076-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2222688117-title { + .terminal-78223076-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2222688117-r1 { fill: #c5c8c6 } - .terminal-2222688117-r2 { fill: #e1e1e1 } - .terminal-2222688117-r3 { fill: #454a50 } - .terminal-2222688117-r4 { fill: #e2e3e3;font-weight: bold } - .terminal-2222688117-r5 { fill: #000000 } - .terminal-2222688117-r6 { fill: #004578 } - .terminal-2222688117-r7 { fill: #dde6ed;font-weight: bold } - .terminal-2222688117-r8 { fill: #dde6ed } - .terminal-2222688117-r9 { fill: #211505 } - .terminal-2222688117-r10 { fill: #e2e3e3 } + .terminal-78223076-r1 { fill: #c5c8c6 } + .terminal-78223076-r2 { fill: #e1e1e1 } + .terminal-78223076-r3 { fill: #454a50 } + .terminal-78223076-r4 { fill: #24292f;font-weight: bold } + .terminal-78223076-r5 { fill: #e2e3e3;font-weight: bold } + .terminal-78223076-r6 { fill: #000000 } + .terminal-78223076-r7 { fill: #004578 } + .terminal-78223076-r8 { fill: #dde6ed;font-weight: bold } + .terminal-78223076-r9 { fill: #dde6ed } + .terminal-78223076-r10 { fill: #211505 } + .terminal-78223076-r11 { fill: #e2e3e3 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ContentSwitcherApp + ContentSwitcherApp - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DataTableMarkdown - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ──────────────────────────────────────────────────────────────────── -  Book                                 Year  -  Dune                                 1965  -  Dune Messiah                         1969  -  Children of Dune                     1976  -  God Emperor of Dune                  1981  -  Heretics of Dune                     1984  -  Chapterhouse: Dune                   1985  - - - - - - - - - - - ──────────────────────────────────────────────────────────────────── + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ──────────────────────────────────────────────────────────────────── +  Book                                 Year  +  Dune                                 1965  +  Dune Messiah                         1969  +  Children of Dune                     1976  +  God Emperor of Dune                  1981  +  Heretics of Dune                     1984  +  Chapterhouse: Dune                   1985  + + + + + + + + + + + ──────────────────────────────────────────────────────────────────── @@ -13760,161 +13761,162 @@ font-weight: 700; } - .terminal-65653754-matrix { + .terminal-1022810985-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-65653754-title { + .terminal-1022810985-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-65653754-r1 { fill: #454a50 } - .terminal-65653754-r2 { fill: #507bb3 } - .terminal-65653754-r3 { fill: #7ae998 } - .terminal-65653754-r4 { fill: #ffcf56 } - .terminal-65653754-r5 { fill: #e76580 } - .terminal-65653754-r6 { fill: #c5c8c6 } - .terminal-65653754-r7 { fill: #e2e3e3;font-weight: bold } - .terminal-65653754-r8 { fill: #dde6ed;font-weight: bold } - .terminal-65653754-r9 { fill: #0a180e;font-weight: bold } - .terminal-65653754-r10 { fill: #211505;font-weight: bold } - .terminal-65653754-r11 { fill: #f5e5e9;font-weight: bold } - .terminal-65653754-r12 { fill: #000000 } - .terminal-65653754-r13 { fill: #001541 } - .terminal-65653754-r14 { fill: #008139 } - .terminal-65653754-r15 { fill: #b86b00 } - .terminal-65653754-r16 { fill: #780028 } - .terminal-65653754-r17 { fill: #35383c } - .terminal-65653754-r18 { fill: #3c5577 } - .terminal-65653754-r19 { fill: #559767 } - .terminal-65653754-r20 { fill: #a5883f } - .terminal-65653754-r21 { fill: #964858 } - .terminal-65653754-r22 { fill: #7c7d7e;font-weight: bold } - .terminal-65653754-r23 { fill: #75828b;font-weight: bold } - .terminal-65653754-r24 { fill: #192e1f;font-weight: bold } - .terminal-65653754-r25 { fill: #3a2a13;font-weight: bold } - .terminal-65653754-r26 { fill: #978186;font-weight: bold } - .terminal-65653754-r27 { fill: #0c0c0c } - .terminal-65653754-r28 { fill: #0c1833 } - .terminal-65653754-r29 { fill: #0c592e } - .terminal-65653754-r30 { fill: #7a4c0c } - .terminal-65653754-r31 { fill: #540c24 } + .terminal-1022810985-r1 { fill: #454a50 } + .terminal-1022810985-r2 { fill: #507bb3 } + .terminal-1022810985-r3 { fill: #7ae998 } + .terminal-1022810985-r4 { fill: #ffcf56 } + .terminal-1022810985-r5 { fill: #e76580 } + .terminal-1022810985-r6 { fill: #c5c8c6 } + .terminal-1022810985-r7 { fill: #24292f;font-weight: bold } + .terminal-1022810985-r8 { fill: #dde6ed;font-weight: bold } + .terminal-1022810985-r9 { fill: #0a180e;font-weight: bold } + .terminal-1022810985-r10 { fill: #211505;font-weight: bold } + .terminal-1022810985-r11 { fill: #f5e5e9;font-weight: bold } + .terminal-1022810985-r12 { fill: #000000 } + .terminal-1022810985-r13 { fill: #001541 } + .terminal-1022810985-r14 { fill: #008139 } + .terminal-1022810985-r15 { fill: #b86b00 } + .terminal-1022810985-r16 { fill: #780028 } + .terminal-1022810985-r17 { fill: #35383c } + .terminal-1022810985-r18 { fill: #3c5577 } + .terminal-1022810985-r19 { fill: #559767 } + .terminal-1022810985-r20 { fill: #a5883f } + .terminal-1022810985-r21 { fill: #964858 } + .terminal-1022810985-r22 { fill: #7c7d7e;font-weight: bold } + .terminal-1022810985-r23 { fill: #75828b;font-weight: bold } + .terminal-1022810985-r24 { fill: #192e1f;font-weight: bold } + .terminal-1022810985-r25 { fill: #3a2a13;font-weight: bold } + .terminal-1022810985-r26 { fill: #978186;font-weight: bold } + .terminal-1022810985-r27 { fill: #0c0c0c } + .terminal-1022810985-r28 { fill: #0c1833 } + .terminal-1022810985-r29 { fill: #0c592e } + .terminal-1022810985-r30 { fill: #7a4c0c } + .terminal-1022810985-r31 { fill: #540c24 } + .terminal-1022810985-r32 { fill: #e2e3e3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidgetDisableTestApp + WidgetDisableTestApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -14265,147 +14267,148 @@ font-weight: 700; } - .terminal-2863933047-matrix { + .terminal-432027110-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2863933047-title { + .terminal-432027110-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2863933047-r1 { fill: #454a50 } - .terminal-2863933047-r2 { fill: #e1e1e1 } - .terminal-2863933047-r3 { fill: #c5c8c6 } - .terminal-2863933047-r4 { fill: #e2e3e3;font-weight: bold } - .terminal-2863933047-r5 { fill: #262626 } - .terminal-2863933047-r6 { fill: #000000 } - .terminal-2863933047-r7 { fill: #e2e2e2 } - .terminal-2863933047-r8 { fill: #e3e3e3 } - .terminal-2863933047-r9 { fill: #14191f } - .terminal-2863933047-r10 { fill: #b93c5b } - .terminal-2863933047-r11 { fill: #121212 } - .terminal-2863933047-r12 { fill: #1e1e1e } - .terminal-2863933047-r13 { fill: #fea62b } - .terminal-2863933047-r14 { fill: #211505;font-weight: bold } - .terminal-2863933047-r15 { fill: #211505 } - .terminal-2863933047-r16 { fill: #dde8f3;font-weight: bold } - .terminal-2863933047-r17 { fill: #ddedf9 } + .terminal-432027110-r1 { fill: #454a50 } + .terminal-432027110-r2 { fill: #e1e1e1 } + .terminal-432027110-r3 { fill: #c5c8c6 } + .terminal-432027110-r4 { fill: #24292f;font-weight: bold } + .terminal-432027110-r5 { fill: #262626 } + .terminal-432027110-r6 { fill: #000000 } + .terminal-432027110-r7 { fill: #e2e2e2 } + .terminal-432027110-r8 { fill: #e3e3e3 } + .terminal-432027110-r9 { fill: #e2e3e3;font-weight: bold } + .terminal-432027110-r10 { fill: #14191f } + .terminal-432027110-r11 { fill: #b93c5b } + .terminal-432027110-r12 { fill: #121212 } + .terminal-432027110-r13 { fill: #1e1e1e } + .terminal-432027110-r14 { fill: #fea62b } + .terminal-432027110-r15 { fill: #211505;font-weight: bold } + .terminal-432027110-r16 { fill: #211505 } + .terminal-432027110-r17 { fill: #dde8f3;font-weight: bold } + .terminal-432027110-r18 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - EasingApp + EasingApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - round▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:1.0 - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - out_sine - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - out_quint - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Welcome to Textual! - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - out_quartI must not fear. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Fear is the  - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔mind-killer. - out_quadFear is the  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁little-death that  - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔brings total  - out_expoobliteration. - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I will face my fear. - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔I will permit it to  - out_elasticpass over me and  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁through me. - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔And when it has gone  - out_cubic - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P  Focus: Duration Input  CTRL+B  Toggle Dark  + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + round▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Animation Duration:1.0 + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + out_sine + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + out_quint + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Welcome to Textual! + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + out_quartI must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Fear is the  + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔mind-killer. + out_quadFear is the  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁little-death that  + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔brings total  + out_expoobliteration. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I will face my fear. + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔I will permit it to  + out_elasticpass over me and  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁through me. + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔And when it has gone  + out_cubic + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ CTRL+P  Focus: Duration Input  CTRL+B  Toggle Dark  @@ -16482,146 +16485,146 @@ font-weight: 700; } - .terminal-2572323619-matrix { + .terminal-4085160594-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2572323619-title { + .terminal-4085160594-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2572323619-r1 { fill: #c5c8c6 } - .terminal-2572323619-r2 { fill: #e3e3e3 } - .terminal-2572323619-r3 { fill: #e1e1e1 } - .terminal-2572323619-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-2572323619-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-2572323619-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-2572323619-r7 { fill: #98729f;font-weight: bold } - .terminal-2572323619-r8 { fill: #d0b344 } - .terminal-2572323619-r9 { fill: #98a84b } - .terminal-2572323619-r10 { fill: #00823d;font-style: italic; } - .terminal-2572323619-r11 { fill: #ffcf56 } - .terminal-2572323619-r12 { fill: #e76580 } - .terminal-2572323619-r13 { fill: #211505;font-weight: bold } - .terminal-2572323619-r14 { fill: #f5e5e9;font-weight: bold } - .terminal-2572323619-r15 { fill: #b86b00 } - .terminal-2572323619-r16 { fill: #780028 } + .terminal-4085160594-r1 { fill: #c5c8c6 } + .terminal-4085160594-r2 { fill: #e3e3e3 } + .terminal-4085160594-r3 { fill: #e1e1e1 } + .terminal-4085160594-r4 { fill: #e1e1e1;text-decoration: underline; } + .terminal-4085160594-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-4085160594-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-4085160594-r7 { fill: #98729f;font-weight: bold } + .terminal-4085160594-r8 { fill: #d0b344 } + .terminal-4085160594-r9 { fill: #98a84b } + .terminal-4085160594-r10 { fill: #00823d;font-style: italic; } + .terminal-4085160594-r11 { fill: #ffcf56 } + .terminal-4085160594-r12 { fill: #e76580 } + .terminal-4085160594-r13 { fill: #fea62b;font-weight: bold } + .terminal-4085160594-r14 { fill: #f5e5e9;font-weight: bold } + .terminal-4085160594-r15 { fill: #b86b00 } + .terminal-4085160594-r16 { fill: #780028 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Keys + Textual Keys - - - - Textual Keys - ╭────────────────────────────────────────────────────────────────────────────╮ - Press some keys! - - To quit the app press ctrl+ctwice or press the Quit button below. - ╰────────────────────────────────────────────────────────────────────────────╯ - Key(key='a'character='a'name='a'is_printable=True) - Key(key='b'character='b'name='b'is_printable=True) - - - - - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ClearQuit - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + Textual Keys + ╭────────────────────────────────────────────────────────────────────────────╮ + Press some keys! + + To quit the app press ctrl+ctwice or press the Quit button below. + ╰────────────────────────────────────────────────────────────────────────────╯ + Key(key='a'character='a'name='a'is_printable=True) + Key(key='b'character='b'name='b'is_printable=True) + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ClearQuit + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -17125,143 +17128,144 @@ font-weight: 700; } - .terminal-4197777529-matrix { + .terminal-4055437288-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4197777529-title { + .terminal-4055437288-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4197777529-r1 { fill: #7ae998 } - .terminal-4197777529-r2 { fill: #e76580 } - .terminal-4197777529-r3 { fill: #1e1e1e } - .terminal-4197777529-r4 { fill: #121212 } - .terminal-4197777529-r5 { fill: #c5c8c6 } - .terminal-4197777529-r6 { fill: #0a180e;font-weight: bold } - .terminal-4197777529-r7 { fill: #f5e5e9;font-weight: bold } - .terminal-4197777529-r8 { fill: #e2e2e2 } - .terminal-4197777529-r9 { fill: #008139 } - .terminal-4197777529-r10 { fill: #780028 } - .terminal-4197777529-r11 { fill: #e1e1e1 } - .terminal-4197777529-r12 { fill: #23568b } - .terminal-4197777529-r13 { fill: #14191f } + .terminal-4055437288-r1 { fill: #7ae998 } + .terminal-4055437288-r2 { fill: #e76580 } + .terminal-4055437288-r3 { fill: #1e1e1e } + .terminal-4055437288-r4 { fill: #121212 } + .terminal-4055437288-r5 { fill: #c5c8c6 } + .terminal-4055437288-r6 { fill: #4ebf71;font-weight: bold } + .terminal-4055437288-r7 { fill: #f5e5e9;font-weight: bold } + .terminal-4055437288-r8 { fill: #e2e2e2 } + .terminal-4055437288-r9 { fill: #0a180e;font-weight: bold } + .terminal-4055437288-r10 { fill: #008139 } + .terminal-4055437288-r11 { fill: #780028 } + .terminal-4055437288-r12 { fill: #e1e1e1 } + .terminal-4055437288-r13 { fill: #23568b } + .terminal-4055437288-r14 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - AcceptDeclineAcceptDecline - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - AcceptAccept - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DeclineDecline - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - 00 - - 10000001000000 + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + AcceptDeclineAcceptDecline + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + AcceptAccept + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DeclineDecline + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▆▆ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + 00 + + 10000001000000 @@ -18731,136 +18735,136 @@ font-weight: 700; } - .terminal-1812315577-matrix { + .terminal-4119903855-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1812315577-title { + .terminal-4119903855-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1812315577-r1 { fill: #c5c8c6 } - .terminal-1812315577-r2 { fill: #e3e3e3 } - .terminal-1812315577-r3 { fill: #e1e1e1 } - .terminal-1812315577-r4 { fill: #004578 } - .terminal-1812315577-r5 { fill: #e0e8ee;font-weight: bold } - .terminal-1812315577-r6 { fill: #e2e3e3 } - .terminal-1812315577-r7 { fill: #ddedf9 } + .terminal-4119903855-r1 { fill: #c5c8c6 } + .terminal-4119903855-r2 { fill: #e3e3e3 } + .terminal-4119903855-r3 { fill: #e1e1e1 } + .terminal-4119903855-r4 { fill: #004578 } + .terminal-4119903855-r5 { fill: #ddedf9;font-weight: bold } + .terminal-4119903855-r6 { fill: #e2e3e3 } + .terminal-4119903855-r7 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── - Aerilon - Aquaria - Canceron - Caprica - Gemenon - Leonis - Libran - Picon - Sagittaron - Scorpia - Tauron - Virgon - - - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── + Aerilon + Aquaria + Canceron + Caprica + Gemenon + Leonis + Libran + Picon + Sagittaron + Scorpia + Tauron + Virgon + + + ────────────────────────────────────────────────────── + + + @@ -18891,139 +18895,139 @@ font-weight: 700; } - .terminal-1041266590-matrix { + .terminal-3443619924-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1041266590-title { + .terminal-3443619924-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1041266590-r1 { fill: #c5c8c6 } - .terminal-1041266590-r2 { fill: #e3e3e3 } - .terminal-1041266590-r3 { fill: #e1e1e1 } - .terminal-1041266590-r4 { fill: #004578 } - .terminal-1041266590-r5 { fill: #e0e8ee;font-weight: bold } - .terminal-1041266590-r6 { fill: #e2e3e3 } - .terminal-1041266590-r7 { fill: #42464b } - .terminal-1041266590-r8 { fill: #777a7e } - .terminal-1041266590-r9 { fill: #14191f } - .terminal-1041266590-r10 { fill: #ddedf9 } + .terminal-3443619924-r1 { fill: #c5c8c6 } + .terminal-3443619924-r2 { fill: #e3e3e3 } + .terminal-3443619924-r3 { fill: #e1e1e1 } + .terminal-3443619924-r4 { fill: #004578 } + .terminal-3443619924-r5 { fill: #ddedf9;font-weight: bold } + .terminal-3443619924-r6 { fill: #e2e3e3 } + .terminal-3443619924-r7 { fill: #42464b } + .terminal-3443619924-r8 { fill: #777a7e } + .terminal-3443619924-r9 { fill: #14191f } + .terminal-3443619924-r10 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── - Aerilon - Aquaria - ──────────────────────────────────────────────────── - Canceron - Caprica - ──────────────────────────────────────────────────── - Gemenon - ──────────────────────────────────────────────────── - Leonis - Libran - ────────────────────────────────────────────────────▅▅ - Picon - ──────────────────────────────────────────────────── - Sagittaron - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── + Aerilon + Aquaria + ──────────────────────────────────────────────────── + Canceron + Caprica + ──────────────────────────────────────────────────── + Gemenon + ──────────────────────────────────────────────────── + Leonis + Libran + ────────────────────────────────────────────────────▅▅ + Picon + ──────────────────────────────────────────────────── + Sagittaron + ────────────────────────────────────────────────────── + + + @@ -19054,140 +19058,140 @@ font-weight: 700; } - .terminal-1620527509-matrix { + .terminal-589190207-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1620527509-title { + .terminal-589190207-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1620527509-r1 { fill: #c5c8c6 } - .terminal-1620527509-r2 { fill: #e3e3e3 } - .terminal-1620527509-r3 { fill: #e1e1e1 } - .terminal-1620527509-r4 { fill: #004578 } - .terminal-1620527509-r5 { fill: #e0e8ee;font-weight: bold;font-style: italic; } - .terminal-1620527509-r6 { fill: #e2e3e3 } - .terminal-1620527509-r7 { fill: #e0e8ee;font-weight: bold } - .terminal-1620527509-r8 { fill: #14191f } - .terminal-1620527509-r9 { fill: #e2e3e3;font-style: italic; } - .terminal-1620527509-r10 { fill: #e2e3e3;font-weight: bold } - .terminal-1620527509-r11 { fill: #ddedf9 } + .terminal-589190207-r1 { fill: #c5c8c6 } + .terminal-589190207-r2 { fill: #e3e3e3 } + .terminal-589190207-r3 { fill: #e1e1e1 } + .terminal-589190207-r4 { fill: #004578 } + .terminal-589190207-r5 { fill: #ddedf9;font-weight: bold;font-style: italic; } + .terminal-589190207-r6 { fill: #e2e3e3 } + .terminal-589190207-r7 { fill: #ddedf9;font-weight: bold } + .terminal-589190207-r8 { fill: #14191f } + .terminal-589190207-r9 { fill: #e2e3e3;font-style: italic; } + .terminal-589190207-r10 { fill: #e2e3e3;font-weight: bold } + .terminal-589190207-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── -                   Data for Aerilon                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - Patron God   Population    Capital City   ▂▂ - ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ - Demeter      1.2 Billion   Gaoth           - └───────────────┴────────────────┴─────────────────┘ -                   Data for Aquaria                   - ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - Patron God    Population   Capital City    - ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ - Hermes        75,000       None            - └────────────────┴───────────────┴─────────────────┘ -                  Data for Canceron                   - ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── +                   Data for Aerilon                   + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + Patron God   Population    Capital City   ▂▂ + ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ + Demeter      1.2 Billion   Gaoth           + └───────────────┴────────────────┴─────────────────┘ +                   Data for Aquaria                   + ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + Patron God    Population   Capital City    + ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ + Hermes        75,000       None            + └────────────────┴───────────────┴─────────────────┘ +                  Data for Canceron                   + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + ────────────────────────────────────────────────────── + + + @@ -19218,134 +19222,135 @@ font-weight: 700; } - .terminal-2380819869-matrix { + .terminal-1891986877-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2380819869-title { + .terminal-1891986877-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2380819869-r1 { fill: #e1e9ef;font-weight: bold } - .terminal-2380819869-r2 { fill: #c5c8c6 } - .terminal-2380819869-r3 { fill: #e4e5e6 } - .terminal-2380819869-r4 { fill: #4f5459 } - .terminal-2380819869-r5 { fill: #cc555a } + .terminal-1891986877-r1 { fill: #ddedf9;font-weight: bold } + .terminal-1891986877-r2 { fill: #e1e9ef;font-weight: bold } + .terminal-1891986877-r3 { fill: #c5c8c6 } + .terminal-1891986877-r4 { fill: #e4e5e6 } + .terminal-1891986877-r5 { fill: #4f5459 } + .terminal-1891986877-r6 { fill: #cc555a } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OneOneOne - TwoTwoTwo - ──────────────────────────────────────────────────────────────────────────────── - ThreeThreeThree - - - - - - - - - - - - - - - - - - - + + + + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────────────────── + ThreeThreeThree + + + + + + + + + + + + + + + + + + + @@ -20973,135 +20978,135 @@ font-weight: 700; } - .terminal-1586716314-matrix { + .terminal-1484676870-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1586716314-title { + .terminal-1484676870-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1586716314-r1 { fill: #c5c8c6 } - .terminal-1586716314-r2 { fill: #737373 } - .terminal-1586716314-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1586716314-r4 { fill: #323232 } - .terminal-1586716314-r5 { fill: #0178d4 } - .terminal-1586716314-r6 { fill: #e1e1e1 } + .terminal-1484676870-r1 { fill: #c5c8c6 } + .terminal-1484676870-r2 { fill: #737373 } + .terminal-1484676870-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-1484676870-r4 { fill: #474747 } + .terminal-1484676870-r5 { fill: #0178d4 } + .terminal-1484676870-r6 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - QuicklyChangeTabsApp + QuicklyChangeTabsApp - + - - - onetwothree - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - three - - - - - - - - - - - - - - - - - - + + + onetwothree + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + three + + + + + + + + + + + + + + + + + + @@ -22245,136 +22250,136 @@ font-weight: 700; } - .terminal-1422407852-matrix { + .terminal-1161182100-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1422407852-title { + .terminal-1161182100-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1422407852-r1 { fill: #c5c8c6 } - .terminal-1422407852-r2 { fill: #e3e3e3 } - .terminal-1422407852-r3 { fill: #e1e1e1 } - .terminal-1422407852-r4 { fill: #1e1e1e } - .terminal-1422407852-r5 { fill: #121212 } - .terminal-1422407852-r6 { fill: #787878 } - .terminal-1422407852-r7 { fill: #a8a8a8 } + .terminal-1161182100-r1 { fill: #c5c8c6 } + .terminal-1161182100-r2 { fill: #e3e3e3 } + .terminal-1161182100-r3 { fill: #e1e1e1 } + .terminal-1161182100-r4 { fill: #1e1e1e } + .terminal-1161182100-r5 { fill: #0178d4 } + .terminal-1161182100-r6 { fill: #787878 } + .terminal-1161182100-r7 { fill: #a8a8a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectApp + SelectApp - - - - SelectApp - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Select - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -22889,140 +22894,141 @@ font-weight: 700; } - .terminal-1328081937-matrix { + .terminal-3875007613-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1328081937-title { + .terminal-3875007613-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1328081937-r1 { fill: #c5c8c6 } - .terminal-1328081937-r2 { fill: #737373 } - .terminal-1328081937-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1328081937-r4 { fill: #323232 } - .terminal-1328081937-r5 { fill: #0178d4 } - .terminal-1328081937-r6 { fill: #121212 } - .terminal-1328081937-r7 { fill: #0053aa } - .terminal-1328081937-r8 { fill: #dde8f3;font-weight: bold } - .terminal-1328081937-r9 { fill: #e1e1e1 } - .terminal-1328081937-r10 { fill: #ddedf9 } + .terminal-3875007613-r1 { fill: #c5c8c6 } + .terminal-3875007613-r2 { fill: #737373 } + .terminal-3875007613-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-3875007613-r4 { fill: #474747 } + .terminal-3875007613-r5 { fill: #0178d4 } + .terminal-3875007613-r6 { fill: #121212 } + .terminal-3875007613-r7 { fill: #0053aa } + .terminal-3875007613-r8 { fill: #dde8f3;font-weight: bold } + .terminal-3875007613-r9 { fill: #e1e1e1 } + .terminal-3875007613-r10 { fill: #323232 } + .terminal-3875007613-r11 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TabbedApp + TabbedApp - + - - - LetoJessicaPaul - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Lady Jessica - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Bene Gesserit and concubine of Leto, and mother of Paul and Alia. - - - - PaulAlia - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - First child - - - - - - -  L  Leto  J  Jessica  P  Paul  + + + LetoJessicaPaul + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Lady Jessica + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Bene Gesserit and concubine of Leto, and mother of Paul and Alia. + + + + PaulAlia + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + First child + + + + + + +  L  Leto  J  Jessica  P  Paul  @@ -23682,133 +23688,133 @@ font-weight: 700; } - .terminal-1336653930-matrix { + .terminal-3137592172-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1336653930-title { + .terminal-3137592172-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1336653930-r1 { fill: #e2e3e3 } - .terminal-1336653930-r2 { fill: #1a1000;font-weight: bold } - .terminal-1336653930-r3 { fill: #c5c8c6 } - .terminal-1336653930-r4 { fill: #008139 } + .terminal-3137592172-r1 { fill: #e2e3e3 } + .terminal-3137592172-r2 { fill: #211505;font-weight: bold } + .terminal-3137592172-r3 { fill: #c5c8c6 } + .terminal-3137592172-r4 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TreeApp + TreeApp - - - - ▼ Dune - └── ▼ Characters - ├── Paul - ├── Jessica - └── Chani - - - - - - - - - - - - - - - - - - + + + + ▼ Dune + ┗━━ ▼ Characters + ┣━━ Paul + ┣━━ Jessica + ┗━━ Chani + + + + + + + + + + + + + + + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index d70fc761e..0cda94276 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,5 +1,4 @@ from pathlib import Path -import sys import pytest @@ -78,8 +77,7 @@ def test_switches(snap_compare): def test_input_and_focus(snap_compare): press = [ - "tab", - *"Darren", # Focus first input, write "Darren" + *"Darren", # Write "Darren" "tab", *"Burns", # Focus second input, write "Burns" ] @@ -88,7 +86,7 @@ def test_input_and_focus(snap_compare): def test_buttons_render(snap_compare): # Testing button rendering. We press tab to focus the first button too. - assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab", "tab"]) + assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"]) def test_placeholder_render(snap_compare): @@ -189,7 +187,7 @@ def test_content_switcher_example_initial(snap_compare): def test_content_switcher_example_switch(snap_compare): assert snap_compare( WIDGET_EXAMPLES_DIR / "content_switcher.py", - press=["tab", "tab", "enter", "wait:500"], + press=["tab", "enter", "wait:500"], terminal_size=(50, 50), ) @@ -315,7 +313,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare): def test_borders_preview(snap_compare): - assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "enter"]) + assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["enter"]) def test_colors_preview(snap_compare): @@ -379,9 +377,7 @@ def test_disabled_widgets(snap_compare): def test_focus_component_class(snap_compare): - assert snap_compare( - SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab", "tab"] - ) + assert snap_compare(SNAPSHOT_APPS_DIR / "focus_component_class.py", press=["tab"]) def test_line_api_scrollbars(snap_compare): diff --git a/tests/test_app.py b/tests/test_app.py index 54bde8221..9cbb82fd0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,5 +1,5 @@ from textual.app import App, ComposeResult -from textual.widgets import Button +from textual.widgets import Button, Input def test_batch_update(): @@ -20,6 +20,7 @@ def test_batch_update(): class MyApp(App): def compose(self) -> ComposeResult: + yield Input() yield Button("Click me!") diff --git a/tests/test_on.py b/tests/test_on.py index 7812cd616..740af6a94 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -36,7 +36,7 @@ async def test_on_button_pressed() -> None: app = ButtonApp() async with app.run_test() as pilot: - await pilot.press("tab", "enter", "tab", "enter", "tab", "enter") + await pilot.press("enter", "tab", "enter", "tab", "enter") await pilot.pause() assert pressed == [ diff --git a/tests/test_paste.py b/tests/test_paste.py index 45be6d536..6cfc3951f 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -38,6 +38,7 @@ async def test_empty_paste(): app = PasteApp() async with app.run_test() as pilot: + app.set_focus(None) await pilot.press("p") assert app.query_one(MyInput).value == "" assert len(paste_events) == 1 diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 95025bf37..51765b99e 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -39,6 +39,7 @@ async def test_radio_sets_initial_state(): async def test_click_sets_focus(): """Clicking within a radio set should set focus.""" async with RadioSetApp().run_test() as pilot: + pilot.app.set_focus(None) assert pilot.app.screen.focused is None await pilot.click("#clickme") assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons") @@ -72,8 +73,6 @@ async def test_radioset_same_button_mash(): async def test_radioset_inner_navigation(): """Using the cursor keys should navigate between buttons in a set.""" async with RadioSetApp().run_test() as pilot: - assert pilot.app.screen.focused is None - await pilot.press("tab") for key, landing in ( ("down", 1), ("up", 0), @@ -88,8 +87,6 @@ async def test_radioset_inner_navigation(): == pilot.app.query_one("#from_buttons").children[landing] ) async with RadioSetApp().run_test() as pilot: - assert pilot.app.screen.focused is None - await pilot.press("tab") assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons") await pilot.press("tab") assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings") @@ -101,8 +98,6 @@ async def test_radioset_inner_navigation(): async def test_radioset_breakout_navigation(): """Shift/Tabbing while in a radioset should move to the previous/next focsuable after the set itself.""" async with RadioSetApp().run_test() as pilot: - assert pilot.app.screen.focused is None - await pilot.press("tab") assert pilot.app.screen.focused is pilot.app.query_one("#from_buttons") await pilot.press("tab") assert pilot.app.screen.focused is pilot.app.query_one("#from_strings") From 80d00ce4bf8bbcbbdb83e190f697b688c35394ca Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 10:49:46 +0100 Subject: [PATCH 64/85] Logging and experimenting for Windows --- src/textual/widgets/_directory_tree.py | 32 +++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 437811758..0c615f26e 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from queue import Empty, Queue +from threading import RLock from typing import ClassVar, Iterable, Iterator from rich.style import Style @@ -15,6 +16,8 @@ from ..reactive import var from ..worker import Worker, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode +read_dir = RLock() + @dataclass class DirEntry: @@ -239,6 +242,9 @@ class DirectoryTree(Tree[DirEntry]): """ return paths + def _tlog(self, message: str) -> None: + self.app.call_from_thread(self.log.debug, f"{self.id} - {message}") + def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None: """Populate the given tree node with the given directory content. @@ -268,6 +274,7 @@ class DirectoryTree(Tree[DirEntry]): if worker.is_cancelled: break yield entry + self._tlog(f"Loaded entry {entry} from {location}") def _load_directory(self, node: TreeNode[DirEntry], worker: Worker) -> None: """Load the directory contents for a given node. @@ -277,14 +284,15 @@ class DirectoryTree(Tree[DirEntry]): """ assert node.data is not None node.data.loaded = True - self.app.call_from_thread( - self._populate_node, - node, - sorted( - self.filter_paths(self._directory_content(node.data.path, worker)), - key=lambda path: (not path.is_dir(), path.name.lower()), - ), - ) + with read_dir: + self.app.call_from_thread( + self._populate_node, + node, + sorted( + self.filter_paths(self._directory_content(node.data.path, worker)), + key=lambda path: (not path.is_dir(), path.name.lower()), + ), + ) _LOADER_INTERVAL: Final[float] = 0.2 """How long the loader should block while waiting for queue content.""" @@ -292,12 +300,14 @@ class DirectoryTree(Tree[DirEntry]): @work(exclusive=True) def _loader(self) -> None: """Background loading queue processor.""" + self._tlog("_loader started") worker = get_current_worker() while not worker.is_cancelled: try: - self._load_directory( - self._to_load.get(timeout=self._LOADER_INTERVAL), worker - ) + next_node = self._to_load.get(timeout=self._LOADER_INTERVAL) + self._tlog(f"Received {next_node} for loading") + self._load_directory(next_node, worker) + self._tlog(f"Loaded {next_node}") except Empty: pass From 6876a041a4954607bfa4ada6f781a5b3fe97c3cc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 11:02:04 +0100 Subject: [PATCH 65/85] More Windows thread oddness experimenting --- src/textual/widgets/_directory_tree.py | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0c615f26e..d6aec5aaa 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from queue import Empty, Queue -from threading import RLock from typing import ClassVar, Iterable, Iterator from rich.style import Style @@ -16,8 +15,6 @@ from ..reactive import var from ..worker import Worker, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode -read_dir = RLock() - @dataclass class DirEntry: @@ -270,11 +267,15 @@ class DirectoryTree(Tree[DirEntry]): Yields: Path: A entry within the location. """ - for entry in location.iterdir(): - if worker.is_cancelled: - break - yield entry - self._tlog(f"Loaded entry {entry} from {location}") + yield Path("Foo") + yield Path("Bar") + yield Path("Baz") + yield Path("Wibble") + # for entry in location.iterdir(): + # if worker.is_cancelled: + # break + # yield entry + # self._tlog(f"Loaded entry {entry} from {location}") def _load_directory(self, node: TreeNode[DirEntry], worker: Worker) -> None: """Load the directory contents for a given node. @@ -284,15 +285,14 @@ class DirectoryTree(Tree[DirEntry]): """ assert node.data is not None node.data.loaded = True - with read_dir: - self.app.call_from_thread( - self._populate_node, - node, - sorted( - self.filter_paths(self._directory_content(node.data.path, worker)), - key=lambda path: (not path.is_dir(), path.name.lower()), - ), - ) + self.app.call_from_thread( + self._populate_node, + node, + sorted( + self.filter_paths(self._directory_content(node.data.path, worker)), + key=lambda path: (not path.is_dir(), path.name.lower()), + ), + ) _LOADER_INTERVAL: Final[float] = 0.2 """How long the loader should block while waiting for queue content.""" From 64d9c602670616db8c310740619a90d5e2dd189b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 11:26:33 +0100 Subject: [PATCH 66/85] Revert experimental code --- src/textual/widgets/_directory_tree.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index d6aec5aaa..7fe50c3f1 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -267,15 +267,11 @@ class DirectoryTree(Tree[DirEntry]): Yields: Path: A entry within the location. """ - yield Path("Foo") - yield Path("Bar") - yield Path("Baz") - yield Path("Wibble") - # for entry in location.iterdir(): - # if worker.is_cancelled: - # break - # yield entry - # self._tlog(f"Loaded entry {entry} from {location}") + for entry in location.iterdir(): + if worker.is_cancelled: + break + yield entry + self._tlog(f"Loaded entry {entry} from {location}") def _load_directory(self, node: TreeNode[DirEntry], worker: Worker) -> None: """Load the directory contents for a given node. From 82924c2d7c69e35fa748b081ee159e41f90c69c3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 11:34:05 +0100 Subject: [PATCH 67/85] Make the main load worker into a asyncio task Turns out, there's a maximum number of threads you can have going in the underlying pool, that's tied to the number of CPUs. As such, there was a limit on how many directory trees you could have up and running before it would start to block all sorts of operations in the surrounding application (in Parallels on macOS, with the Windows VM appearing to have just the one CPU, it would give up after 8 directory trees). So here we move to a slightly different approach: have the main loader still run "forever", but be an async task; it then in turn farms the loading out to threads which close once the loading is done. So far tested on macOS and behaves as expected. Next to test on Windows. --- src/textual/widgets/_directory_tree.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 7fe50c3f1..2bd41e230 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -1,8 +1,8 @@ from __future__ import annotations +from asyncio import Queue, QueueEmpty from dataclasses import dataclass from pathlib import Path -from queue import Empty, Queue from typing import ClassVar, Iterable, Iterator from rich.style import Style @@ -239,9 +239,6 @@ class DirectoryTree(Tree[DirEntry]): """ return paths - def _tlog(self, message: str) -> None: - self.app.call_from_thread(self.log.debug, f"{self.id} - {message}") - def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None: """Populate the given tree node with the given directory content. @@ -271,9 +268,9 @@ class DirectoryTree(Tree[DirEntry]): if worker.is_cancelled: break yield entry - self._tlog(f"Loaded entry {entry} from {location}") - def _load_directory(self, node: TreeNode[DirEntry], worker: Worker) -> None: + @work + def _load_directory(self, node: TreeNode[DirEntry]) -> None: """Load the directory contents for a given node. Args: @@ -281,6 +278,7 @@ class DirectoryTree(Tree[DirEntry]): """ assert node.data is not None node.data.loaded = True + worker = get_current_worker() self.app.call_from_thread( self._populate_node, node, @@ -290,21 +288,14 @@ class DirectoryTree(Tree[DirEntry]): ), ) - _LOADER_INTERVAL: Final[float] = 0.2 - """How long the loader should block while waiting for queue content.""" - @work(exclusive=True) - def _loader(self) -> None: + async def _loader(self) -> None: """Background loading queue processor.""" - self._tlog("_loader started") worker = get_current_worker() while not worker.is_cancelled: try: - next_node = self._to_load.get(timeout=self._LOADER_INTERVAL) - self._tlog(f"Received {next_node} for loading") - self._load_directory(next_node, worker) - self._tlog(f"Loaded {next_node}") - except Empty: + self._load_directory(await self._to_load.get()) + except QueueEmpty: pass def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: From 38f9500642c7076497a99a8c8c42d279407bbec3 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: Wed, 17 May 2023 11:35:10 +0100 Subject: [PATCH 68/85] Fix test. --- tests/snapshot_tests/test_snapshots.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6ced5f476..bdedbced3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -514,6 +514,5 @@ def test_select_rebuild(snap_compare): # https://github.com/Textualize/textual/issues/2557 assert snap_compare( SNAPSHOT_APPS_DIR / "select_rebuild.py", - press=["tab", "space", "escape", "tab", "enter", "tab", "space"] + press=["space", "escape", "tab", "enter", "tab", "space"], ) - From a42250daa3137db501f00c0a59906c61e66def2f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 12:29:03 +0100 Subject: [PATCH 69/85] async Queue get blocks when empty, so don't handle empty exception --- src/textual/widgets/_directory_tree.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 2bd41e230..3e88a4a2d 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -1,6 +1,6 @@ from __future__ import annotations -from asyncio import Queue, QueueEmpty +from asyncio import Queue from dataclasses import dataclass from pathlib import Path from typing import ClassVar, Iterable, Iterator @@ -293,10 +293,7 @@ class DirectoryTree(Tree[DirEntry]): """Background loading queue processor.""" worker = get_current_worker() while not worker.is_cancelled: - try: - self._load_directory(await self._to_load.get()) - except QueueEmpty: - pass + self._load_directory(await self._to_load.get()) def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() From ecde90b1c31d814646a0d2d444ffb3893d0e8b20 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 12:29:36 +0100 Subject: [PATCH 70/85] Remove unused import The code that was using this was removed earlier. --- src/textual/widgets/_directory_tree.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3e88a4a2d..ba28c7f0f 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -7,7 +7,6 @@ from typing import ClassVar, Iterable, Iterator from rich.style import Style from rich.text import Text, TextType -from typing_extensions import Final from .. import work from ..message import Message From 26e6dbbfa35c19dff841c5eaf025a0b626cec5ce Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 13:28:07 +0100 Subject: [PATCH 71/85] Swap to a dual-working approach Plan C; or is it plan D? Something like that. Anyway... in this approach we keep a single "forever" async task worker per directory tree, which in turn looks at the async Queue, and when a new node appears on it it starts a short-lived thread to load the directory data. This seems to be working fine on macOS. Next up is testing on Windows. --- src/textual/widgets/_directory_tree.py | 39 +++++++++++++++++++------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index ba28c7f0f..c87ab42a3 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -11,7 +11,7 @@ from rich.text import Text, TextType from .. import work from ..message import Message from ..reactive import var -from ..worker import Worker, get_current_worker +from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode @@ -269,22 +269,22 @@ class DirectoryTree(Tree[DirEntry]): yield entry @work - def _load_directory(self, node: TreeNode[DirEntry]) -> None: + def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]: """Load the directory contents for a given node. Args: node: The node to load the directory contents for. + + Returns: + The list of entries within the directory associated with the node. """ assert node.data is not None node.data.loaded = True - worker = get_current_worker() - self.app.call_from_thread( - self._populate_node, - node, - sorted( - self.filter_paths(self._directory_content(node.data.path, worker)), - key=lambda path: (not path.is_dir(), path.name.lower()), + return sorted( + self.filter_paths( + self._directory_content(node.data.path, get_current_worker()) ), + key=lambda path: (not path.is_dir(), path.name.lower()), ) @work(exclusive=True) @@ -292,7 +292,26 @@ class DirectoryTree(Tree[DirEntry]): """Background loading queue processor.""" worker = get_current_worker() while not worker.is_cancelled: - self._load_directory(await self._to_load.get()) + # Get the next node that needs loading off the queue. Note that + # this blocks if the queue is empty. + node = await self._to_load.get() + content: list[Path] = [] + try: + # Spin up a short-lived thread that will load the content of + # the directory associated with that node. + content = await self._load_directory(node).wait() + except WorkerCancelled: + # The worker was cancelled, that would suggest we're all + # done here and we should get out of the loader in general. + break + except WorkerFailed: + # This particular worker failed to start. We don't know the + # reason so let's no-op that (for now anyway). + pass + # We're still here and we have directory content, get it into + # the tree. + if content: + self._populate_node(node, content) def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() From c04bbd1e2eb55c73418ee74223d80a6df5b59e7d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 13:41:58 +0100 Subject: [PATCH 72/85] Ensure the loader kicks off when starting up with . as the directory --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index c87ab42a3..6423454db 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -92,7 +92,7 @@ class DirectoryTree(Tree[DirEntry]): """ return self.tree - path: var[str | Path] = var["str | Path"](Path("."), init=False) + path: var[str | Path] = var["str | Path"](Path("."), init=False, always_update=True) """The path that is the root of the directory tree. Note: From dadd7c0a145d1dc342c552f45db6ab963287a6d6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 14:13:52 +0100 Subject: [PATCH 73/85] Guard against PermissionError Normally it's not a great idea to eat and hide exceptions within library code; but I think it makes sense to make an exception here. This is a UI element that lets the user navigate about a filesystem. If there is something they don't have permission for, that should not cause an exception, it should just give up with the best possible outcome. If actually doing something with the exception is important, the developer using this could use the filter to do tests and act accordingly. See #2564. --- src/textual/widgets/_directory_tree.py | 40 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 6423454db..6efef7e9b 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -238,6 +238,27 @@ class DirectoryTree(Tree[DirEntry]): """ return paths + @staticmethod + def _safe_is_dir(path: Path) -> bool: + """Safely check if a path is a directory. + + Args: + path: The path to check. + + Returns: + `True` if the path is for a directory, `False` if not. + """ + try: + return path.is_dir() + except PermissionError: + # We may or may not have been looking at a directory, but we + # don't have the rights or permissions to even know that. Best + # we can do, short of letting the error blow up, is assume it's + # not a directory. A possible improvement in here could be to + # have a third state which is "unknown", and reflect that in the + # tree. + return False + def _populate_node(self, node: TreeNode[DirEntry], content: Iterable[Path]) -> None: """Populate the given tree node with the given directory content. @@ -249,7 +270,7 @@ class DirectoryTree(Tree[DirEntry]): node.add( path.name, data=DirEntry(path), - allow_expand=path.is_dir(), + allow_expand=self._safe_is_dir(path), ) node.expand() @@ -263,10 +284,13 @@ class DirectoryTree(Tree[DirEntry]): Yields: Path: A entry within the location. """ - for entry in location.iterdir(): - if worker.is_cancelled: - break - yield entry + try: + for entry in location.iterdir(): + if worker.is_cancelled: + break + yield entry + except PermissionError: + pass @work def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]: @@ -284,7 +308,7 @@ class DirectoryTree(Tree[DirEntry]): self.filter_paths( self._directory_content(node.data.path, get_current_worker()) ), - key=lambda path: (not path.is_dir(), path.name.lower()), + key=lambda path: (not self._safe_is_dir(path), path.name.lower()), ) @work(exclusive=True) @@ -318,7 +342,7 @@ class DirectoryTree(Tree[DirEntry]): dir_entry = event.node.data if dir_entry is None: return - if dir_entry.path.is_dir(): + if self._safe_is_dir(dir_entry.path): if not dir_entry.loaded: self._to_load.put_nowait(event.node) else: @@ -329,5 +353,5 @@ class DirectoryTree(Tree[DirEntry]): dir_entry = event.node.data if dir_entry is None: return - if not dir_entry.path.is_dir(): + if not self._safe_is_dir(dir_entry.path): self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From 2a91e13ca3bbcc24787db79d477cc3f76b317019 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 14:45:08 +0100 Subject: [PATCH 74/85] Mark each load task as done when it's done --- src/textual/widgets/_directory_tree.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 6efef7e9b..b85af246c 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -336,6 +336,8 @@ class DirectoryTree(Tree[DirEntry]): # the tree. if content: self._populate_node(node, content) + # Mark this iteration as done. + self._to_load.task_done() def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() From 86bee6c49573df7cb45846096f5040d18d65efc4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 15:17:05 +0100 Subject: [PATCH 75/85] Rename _to_load to _load_queue As per https://github.com/Textualize/textual/pull/2545#discussion_r1196580316 --- src/textual/widgets/_directory_tree.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index b85af246c..4c8ff7490 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -118,7 +118,7 @@ class DirectoryTree(Tree[DirEntry]): classes: A space-separated list of classes, or None for no classes. disabled: Whether the directory tree is disabled or not. """ - self._to_load: Queue[TreeNode[DirEntry]] = Queue() + self._load_queue: Queue[TreeNode[DirEntry]] = Queue() super().__init__( str(path), data=DirEntry(Path(path)), @@ -133,12 +133,12 @@ class DirectoryTree(Tree[DirEntry]): """Reload the `DirectoryTree` contents.""" self.reset(str(self.path), DirEntry(Path(self.path))) # Orphan the old queue... - self._to_load = Queue() + self._load_queue = Queue() # ...and replace the old load with a new one. self._loader() # We have a fresh queue, we have a fresh loader, get the fresh root # loading up. - self._to_load.put_nowait(self.root) + self._load_queue.put_nowait(self.root) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -318,7 +318,7 @@ class DirectoryTree(Tree[DirEntry]): while not worker.is_cancelled: # Get the next node that needs loading off the queue. Note that # this blocks if the queue is empty. - node = await self._to_load.get() + node = await self._load_queue.get() content: list[Path] = [] try: # Spin up a short-lived thread that will load the content of @@ -337,7 +337,7 @@ class DirectoryTree(Tree[DirEntry]): if content: self._populate_node(node, content) # Mark this iteration as done. - self._to_load.task_done() + self._load_queue.task_done() def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: event.stop() @@ -346,7 +346,7 @@ class DirectoryTree(Tree[DirEntry]): return if self._safe_is_dir(dir_entry.path): if not dir_entry.loaded: - self._to_load.put_nowait(event.node) + self._load_queue.put_nowait(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From 522d56c601f53235784af59f5dc7ea95c1db077b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 15:21:38 +0100 Subject: [PATCH 76/85] Be more optimistic about when the node content is loaded As per https://github.com/Textualize/textual/pull/2545#discussion_r1196589864 --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 4c8ff7490..ebebccb1c 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -303,7 +303,6 @@ class DirectoryTree(Tree[DirEntry]): The list of entries within the directory associated with the node. """ assert node.data is not None - node.data.loaded = True return sorted( self.filter_paths( self._directory_content(node.data.path, get_current_worker()) @@ -346,6 +345,7 @@ class DirectoryTree(Tree[DirEntry]): return if self._safe_is_dir(dir_entry.path): if not dir_entry.loaded: + dir_entry.loaded = True self._load_queue.put_nowait(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From e381c26165ca85afdff731fee6550d4750768a6a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 15:27:01 +0100 Subject: [PATCH 77/85] Create a single method for adding a node to the load queue In doing so fix an issue where, after the previous change, I wasn't marking the root of the tree as loaded. --- src/textual/widgets/_directory_tree.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index ebebccb1c..b914bd7bc 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -129,6 +129,16 @@ class DirectoryTree(Tree[DirEntry]): ) self.path = path + def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> None: + """Add the given node to the load queue. + + Args: + node: The node to add to the load queue. + """ + assert node.data is not None + node.data.loaded = True + self._load_queue.put_nowait(node) + def reload(self) -> None: """Reload the `DirectoryTree` contents.""" self.reset(str(self.path), DirEntry(Path(self.path))) @@ -138,7 +148,7 @@ class DirectoryTree(Tree[DirEntry]): self._loader() # We have a fresh queue, we have a fresh loader, get the fresh root # loading up. - self._load_queue.put_nowait(self.root) + self._add_to_load_queue(self.root) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -345,8 +355,7 @@ class DirectoryTree(Tree[DirEntry]): return if self._safe_is_dir(dir_entry.path): if not dir_entry.loaded: - dir_entry.loaded = True - self._load_queue.put_nowait(event.node) + self._add_to_load_queue(event.node) else: self.post_message(self.FileSelected(self, event.node, dir_entry.path)) From abbffbfa6a28808677bbb4335a9feefebde08fc4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 15:30:13 +0100 Subject: [PATCH 78/85] Code tidy As per https://github.com/Textualize/textual/pull/2545#discussion_r1196591147 --- src/textual/widgets/_directory_tree.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index b914bd7bc..72a19ddf1 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -341,10 +341,11 @@ class DirectoryTree(Tree[DirEntry]): # This particular worker failed to start. We don't know the # reason so let's no-op that (for now anyway). pass - # We're still here and we have directory content, get it into - # the tree. - if content: - self._populate_node(node, content) + else: + # We're still here and we have directory content, get it into + # the tree. + if content: + self._populate_node(node, content) # Mark this iteration as done. self._load_queue.task_done() From f820598846f3ccc4e5f6fe13e6c7fec9ac9b2f6a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 May 2023 15:30:31 +0100 Subject: [PATCH 79/85] How to (#2592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * words * how to * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/custom_theme/main.html | 13 ++ docs/examples/how-to/layout.css | 0 docs/examples/how-to/layout.py | 69 ++++++++ docs/examples/how-to/layout01.py | 27 ++++ docs/examples/how-to/layout02.py | 37 +++++ docs/examples/how-to/layout03.py | 48 ++++++ docs/examples/how-to/layout04.py | 39 +++++ docs/examples/how-to/layout05.py | 55 +++++++ docs/examples/how-to/layout06.py | 69 ++++++++ docs/how-to/design-a-layout.md | 194 +++++++++++++++++++++++ docs/how-to/index.md | 7 + docs/images/how-to/layout.excalidraw.svg | 16 ++ mkdocs-nav.yml | 4 + 13 files changed, 578 insertions(+) create mode 100644 docs/examples/how-to/layout.css create mode 100644 docs/examples/how-to/layout.py create mode 100644 docs/examples/how-to/layout01.py create mode 100644 docs/examples/how-to/layout02.py create mode 100644 docs/examples/how-to/layout03.py create mode 100644 docs/examples/how-to/layout04.py create mode 100644 docs/examples/how-to/layout05.py create mode 100644 docs/examples/how-to/layout06.py create mode 100644 docs/how-to/design-a-layout.md create mode 100644 docs/how-to/index.md create mode 100644 docs/images/how-to/layout.excalidraw.svg diff --git a/docs/custom_theme/main.html b/docs/custom_theme/main.html index 2f490a483..fbbfd659a 100644 --- a/docs/custom_theme/main.html +++ b/docs/custom_theme/main.html @@ -17,4 +17,17 @@ + + {% endblock %} diff --git a/docs/examples/how-to/layout.css b/docs/examples/how-to/layout.css new file mode 100644 index 000000000..e69de29bb diff --git a/docs/examples/how-to/layout.py b/docs/examples/how-to/layout.py new file mode 100644 index 000000000..430670673 --- /dev/null +++ b/docs/examples/how-to/layout.py @@ -0,0 +1,69 @@ +from textual.app import App, ComposeResult +from textual.containers import HorizontalScroll, VerticalScroll +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class Tweet(Placeholder): + DEFAULT_CSS = """ + Tweet { + height: 5; + width: 1fr; + border: tall $background; + } + """ + + +class Column(VerticalScroll): + DEFAULT_CSS = """ + Column { + height: 1fr; + width: 32; + margin: 0 2; + } + """ + + def compose(self) -> ComposeResult: + for tweet_no in range(1, 20): + yield Tweet(id=f"Tweet{tweet_no}") + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + with HorizontalScroll(): + yield Column() + yield Column() + yield Column() + yield Column() + + +class LayoutApp(App): + CSS_PATH = "layout.css" + + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout01.py b/docs/examples/how-to/layout01.py new file mode 100644 index 000000000..2d9c7964b --- /dev/null +++ b/docs/examples/how-to/layout01.py @@ -0,0 +1,27 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): # (1)! + pass + + +class Footer(Placeholder): # (2)! + pass + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") # (3)! + yield Footer(id="Footer") # (4)! + + +class LayoutApp(App): + def on_mount(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout02.py b/docs/examples/how-to/layout02.py new file mode 100644 index 000000000..7aa39c349 --- /dev/null +++ b/docs/examples/how-to/layout02.py @@ -0,0 +1,37 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + + +class LayoutApp(App): + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout03.py b/docs/examples/how-to/layout03.py new file mode 100644 index 000000000..f5d2d856c --- /dev/null +++ b/docs/examples/how-to/layout03.py @@ -0,0 +1,48 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class ColumnsContainer(Placeholder): + DEFAULT_CSS = """ + ColumnsContainer { + width: 1fr; + height: 1fr; + border: solid white; + } + """ # (1)! + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + yield ColumnsContainer(id="Columns") + + +class LayoutApp(App): + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout04.py b/docs/examples/how-to/layout04.py new file mode 100644 index 000000000..fbd541336 --- /dev/null +++ b/docs/examples/how-to/layout04.py @@ -0,0 +1,39 @@ +from textual.app import App, ComposeResult +from textual.containers import HorizontalScroll +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + yield HorizontalScroll() # (1)! + + +class LayoutApp(App): + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout05.py b/docs/examples/how-to/layout05.py new file mode 100644 index 000000000..5b2cf6497 --- /dev/null +++ b/docs/examples/how-to/layout05.py @@ -0,0 +1,55 @@ +from textual.app import App, ComposeResult +from textual.containers import HorizontalScroll, VerticalScroll +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class Tweet(Placeholder): + pass + + +class Column(VerticalScroll): + def compose(self) -> ComposeResult: + for tweet_no in range(1, 20): + yield Tweet(id=f"Tweet{tweet_no}") + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + with HorizontalScroll(): + yield Column() + yield Column() + yield Column() + yield Column() + + +class LayoutApp(App): + CSS_PATH = "layout.css" + + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/examples/how-to/layout06.py b/docs/examples/how-to/layout06.py new file mode 100644 index 000000000..430670673 --- /dev/null +++ b/docs/examples/how-to/layout06.py @@ -0,0 +1,69 @@ +from textual.app import App, ComposeResult +from textual.containers import HorizontalScroll, VerticalScroll +from textual.screen import Screen +from textual.widgets import Placeholder + + +class Header(Placeholder): + DEFAULT_CSS = """ + Header { + height: 3; + dock: top; + } + """ + + +class Footer(Placeholder): + DEFAULT_CSS = """ + Footer { + height: 3; + dock: bottom; + } + """ + + +class Tweet(Placeholder): + DEFAULT_CSS = """ + Tweet { + height: 5; + width: 1fr; + border: tall $background; + } + """ + + +class Column(VerticalScroll): + DEFAULT_CSS = """ + Column { + height: 1fr; + width: 32; + margin: 0 2; + } + """ + + def compose(self) -> ComposeResult: + for tweet_no in range(1, 20): + yield Tweet(id=f"Tweet{tweet_no}") + + +class TweetScreen(Screen): + def compose(self) -> ComposeResult: + yield Header(id="Header") + yield Footer(id="Footer") + with HorizontalScroll(): + yield Column() + yield Column() + yield Column() + yield Column() + + +class LayoutApp(App): + CSS_PATH = "layout.css" + + def on_ready(self) -> None: + self.push_screen(TweetScreen()) + + +if __name__ == "__main__": + app = LayoutApp() + app.run() diff --git a/docs/how-to/design-a-layout.md b/docs/how-to/design-a-layout.md new file mode 100644 index 000000000..3a8fe689b --- /dev/null +++ b/docs/how-to/design-a-layout.md @@ -0,0 +1,194 @@ +# Design a Layout + +This article discusses an approach you can take when designing the layout for your applications. + +Textual's layout system is flexible enough to accommodate just about any application design you could conceive of, but it may be hard to know where to start. We will go through a few tips which will help you get over the initial hurdle of designing an application layout. + + +## Tip 1. Make a sketch + +The initial design of your application is best done with a sketch. +You could use a drawing package such as [Excalidraw](https://excalidraw.com/) for your sketch, but pen and paper is equally as good. + +Start by drawing a rectangle to represent a blank terminal, then draw a rectangle for each element in your application. Annotate each of the rectangles with the content they will contain, and note wether they will scroll (and in what direction). + +For the purposes of this article we are going to design a layout for a Twitter or Mastodon client, which will have a header / footer and a number of columns. + +!!! note + + The approach we are discussing here is applicable even if the app you want to build looks nothing like our sketch! + +Here's our sketch: + +
+--8<-- "docs/images/how-to/layout.excalidraw.svg" +
+ +It's rough, but it's all we need. + + +## Tip 2. Work outside in + +Like a sculpture with a block of marble, it is best to work from the outside towards the center. +If your design has fixed elements (like a header, footer, or sidebar), start with those first. + +In our sketch we have a header and footer. +Since these are the outermost widgets, we will begin by adding them. + +!!! tip + + Textual has builtin [Header](../widgets/header.md) and [Footer](../widgets/footer.md) widgets which you could use in a real application. + +The following example defines an [app](../guide/app.md), a [screen](../guide/screens.md), and our header and footer widgets. +Since we're starting from scratch and don't have any functionality for our widgets, we are going to use the [Placeholder][textual.widgets.Placeholder] widget to help us visualize our design. + +In a real app, we would replace these placeholders with more useful content. + +=== "layout01.py" + + ```python + --8<-- "docs/examples/how-to/layout01.py" + ``` + + 1. The Header widget extends Placeholder. + 2. The footer widget extends Placeholder. + 3. Creates the header widget (the id will be displayed within the placeholder widget). + 4. Creates the footer widget. + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout01.py"} + ``` + +## Tip 3. Apply docks + +This app works, but the header and footer don't behave as expected. +We want both of these widgets to be fixed to an edge of the screen and limited in height. +In Textual this is known as *docking* which you can apply with the [dock](../styles/dock.md) rule. + +We will dock the header and footer to the top and bottom edges of the screen respectively, by adding a little [CSS](../guide/CSS.md) to the widget classes: + +=== "layout02.py" + + ```python hl_lines="7-12 16-21" + --8<-- "docs/examples/how-to/layout02.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout02.py"} + ``` + +The `DEFAULT_CSS` class variable is used to set CSS directly in Python code. +We could define these in an external CSS file, but writing the CSS inline like this can be convenient if it isn't too complex. + +When you dock a widget, it reduces the available area for other widgets. +This means that Textual will automatically compensate for the 6 additional lines reserved for the header and footer. + +## Tip 4. Use FR Units for flexible things + +After we've added the header and footer, we want the remaining space to be used for the main interface, which will contain the columns in the sketch. +This area is flexible (will change according to the size of the terminal), so how do we ensure that it takes up precisely the space needed? + +The simplest way is to use [fr](../css_types/scalar.md#fraction) units. +By setting both the width and height to `1fr`, we are telling Textual to divide the space equally amongst the remaining widgets. +There is only a single widget, so that widget will fill all of the remaining space. + +Let's make that change. + +=== "layout03.py" + + ```python hl_lines="24-31 38" + --8<-- "docs/examples/how-to/layout03.py" + ``` + + 1. Here's where we set the width and height to `1fr`. We also add a border just to illustrate the dimensions better. + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout03.py"} + ``` + +As you can see, the central Columns area will resize with the terminal window. + +## Tip 5. Use containers + +Before we add content to the Columns area, we have an opportunity to simplify. +Rather than extend `Placeholder` for our `ColumnsContainer` widget, we can use one of the builtin *containers*. +A container is simply a widget designed to *contain* other widgets. +Containers are styled with `fr` units to fill the remaining space so we won't need to add any more CSS. + +Let's replace the `ColumnsContainer` class in the previous example with a `HorizontalScroll` container, which also adds an automatic horizontal scrollbar. + +=== "layout04.py" + + ```python hl_lines="2 29" + --8<-- "docs/examples/how-to/layout04.py" + ``` + + 1. The builtin container widget. + + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout04.py"} + ``` + +The container will appear as blank space until we add some widgets to it. + +Let's add the columns to the `HorizontalScroll`. +A column is itself a container which will have a vertical scrollbar, so we will define our `Column` by subclassing `VerticalScroll`. +In a real app, these columns will likely be added dynamically from some kind of configuration, but let's add 4 to visualize the layout. + +We will also define a `Tweet` placeholder and add a few to each column. + +=== "layout05.py" + + ```python hl_lines="2 25-26 29-32 39-43" + --8<-- "docs/examples/how-to/layout05.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout05.py"} + ``` + +Note from the output that each `Column` takes a quarter of the screen width. +This happens because `Column` extends a container which has a width of `1fr`. + +It makes more sense for a column in a Twitter / Mastodon client to use a fixed width. +Let's set the width of the columns to 32. + +We also want to reduce the height of each "tweet". +In the real app, you might set the height to "auto" so it fits the content, but lets set it to 5 lines for now. + + +=== "layout06.py" + + ```python hl_lines="25-32 36-46" + --8<-- "docs/examples/how-to/layout06.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/how-to/layout06.py" columns="100" lines="32"} + ``` + +You should see from the output that we have fixed width columns that will scroll horizontally. +You can also scroll the "tweets" in each column vertically. + +This last example is a relatively complete design. +There are plenty of things you might want to tweak, but this contains all the elements you might need. + +## Summary + +Layout is the first thing you will tackle when building a Textual app. +The following tips will help you get started. + +1. Make a sketch (pen and paper is fine). +2. Work outside in. Start with the entire space of the terminal, add the outermost content first. +3. Dock fixed widgets. If the content doesn't move or scroll, you probably want to *dock* it. +4. Make use of `fr` for flexible space within layouts. +5. Use containers to contain other widgets, particularly if they scroll! + +If you need further help, we are here to [help](/help/). diff --git a/docs/how-to/index.md b/docs/how-to/index.md new file mode 100644 index 000000000..c127ae174 --- /dev/null +++ b/docs/how-to/index.md @@ -0,0 +1,7 @@ +# How To + +Welcome to the How To section. + +Here you will find How To articles which cover various topics at a higher level than the Guide or Reference. +We will be adding more articles in the future. +If there is anything you would like to see covered, [open an issue](https://github.com/Textualize/textual/issues) in the Textual repository! diff --git a/docs/images/how-to/layout.excalidraw.svg b/docs/images/how-to/layout.excalidraw.svg new file mode 100644 index 000000000..9e4b53f19 --- /dev/null +++ b/docs/images/how-to/layout.excalidraw.svg @@ -0,0 +1,16 @@ + + + eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1daW9cdTAwMWK7kv1+f0WQ92VcdTAwMDa46ktcdTAwMTbJXCJ5gcHAi1x1MDAxY1mOl8iKt8GDoaUtyda+eHvIf5+ivKjVUsstW7JbuTaQxGktzWafOudcdTAwMTRZTf7njy9fvvbv2v7Xv7989W9LhXqt3C3cfP3TXHUwMDFkv/a7vVqrSS/B8P+91qBbXHUwMDFhvrPa77d7f//1V6PQvfL77Xqh5HvXtd6gUO/1XHUwMDA35VrLK7VcdTAwMWF/1fp+o/e/7u+9QsP/n3arUe53vdFJUn651m91XHUwMDFmzuXX/Ybf7Pfo2/+P/v/ly3+Gf1x1MDAwN1rX9Uv9QrNS94dcdTAwMWZcdTAwMTi+NGogZ0aFXHUwMDBm77Waw9ZyLoxGxVxyPL+j1tukXHUwMDEz9v0yvXxBjfZHr7hDX1x1MDAwZnL9dT+3q/ay56WbXCK2djHdXHUwMDFkjM57UavXXHUwMDBm+3f1YbtK3Vavl6pcdTAwMTb6peroXHUwMDFkvX63deVcdTAwMWbXyv3qU/dcdTAwMDWOP3+216KuXHUwMDE4farbXHUwMDFhVKpNv+d6gT9cdTAwMWZttVx1MDAwYqVa/254lez56ENX/P1ldOSW/pdC4Vx0LY1VqLW1KtAr7lx1MDAwYkAoXHUwMDBmhFx1MDAxNKhcdTAwMDVarahfQi3baNXpjlDL/sWGP6O2XHUwMDE1XHUwMDBipatcbjWwWX5+T79baPbahS7dt9H7blx1MDAxZa9ZSemhcCdjinM+akfVr1WqfXqHQOZcdTAwMTnOrJQohLs9OGqMP7w1XHUwMDFjjNCopVx1MDAxNs+vuCa0t8tDnPw72HHN8mPHNVx1MDAwN/X6qNXuhXRcdTAwMDBbo89cZtrlwlx1MDAwM1x1MDAwMjhcdTAwMWFcdIpJ1Fxio1x1MDAwZanXmlfhr6u3Slcj0Fxmj/7681x1MDAxNXDVKlx1MDAxYa1WcVx1MDAwNIlcdTAwMThcdTAwMWKsuVLn5367sL63s3+Lza2fXHUwMDA3nZ3tTlx1MDAwNFhDgPtImFx1MDAxYcGVtIpxJjhBIIRT6Vx1MDAxOaUlgcJcdTAwMDJKi8vEqfBcdTAwMThHXHUwMDA1dD6urTaTQFx1MDAwNeMhMiFccjJAQaCeXHUwMDAwKt0zJYGuZvVw6tfrtXZvKkpxXHUwMDA2p1x1MDAxYZCcI1cmNkzbP86OS+lstssvc9f5+3TqrtG8fVxyTPn7wVRxz1xuhdTxQljOQIbolDjOMCMl11x1MDAwZcqgw1x1MDAxMTRcdTAwMTdM/3VRUKBgXHUwMDEyolxceJJcdTAwMDEoi0D/SGPkJEY5eFxuKVKISFx1MDAxOVxiXHUwMDAxUocxyql9ilNcdTAwMWL5b1x1MDAwNVItbFx1MDAxNEi1QXfNXHUwMDEwX/dP1jrpu8ZB7qC4tnXT2upvna3jTcIxXG7KY9TdyLnhlsBcdTAwMDEhiFxuT1x1MDAxObRkXG4kUZhV8o1cdTAwMTAtMqaWXHUwMDA1Uc1cdTAwMTXpgeC/XHUwMDE5Qq2IQijnjJSFS81iQ7TeSbdTV1x1MDAxNexs1upXWLrrb53UXHUwMDBlk1xyUVx1MDAxMljgXHUwMDAyQWjBmWRWh1x1MDAxMFxujmQl3Vx1MDAxNVxyxLVv41BcdTAwMGVFY3BZXHUwMDAwpcsgqkcuV1DoZ+dPKlx1MDAxMqPu1iihXHUwMDAzb3hcdKLr37ZcbrsldbLV2V1vnt3+OL6pXHUwMDFjNJJcctFcdTAwMTTlXCKk4lZcdTAwMTFFcnBJyThGXHUwMDExXHQ+hFxmTTTKJcOlYFRJ5oym4Vx1MDAxNCnaQiBOXHUwMDAyOZMnNaekytphO0CFIUquTJGLNYrNXHUwMDAx0SewjOBcIlx1MDAxZY/8ikbu82dGn1x1MDAwZcCt79+O3HZcdTAwMDBcdTAwMWPd9mG1cbB9tj/YK+VzlbXWgVx1MDAxOdx9fX7fr8ffosJCXGImjTLWLiosxtpcdTAwMTmMXGJcdTAwMTNcdTAwMTVcdTAwMTCczs5dvqBiR8T0i55cdTAwMWVcdTAwMTHVQqk66PpcdTAwMWZcdTAwMWZcdTAwMTNcXCO5W+JKQIK+YTLE21x1MDAxYTxcdTAwMDdB4nNcdTAwMTlcbpnXXHUwMDA0XHUwMDA1xVx1MDAxZbuYYn4p8oBcdTAwMTPaXHUwMDE5p1x1MDAwZSeRmJafTVx1MDAwNoFcdTAwMDWuyZ3PXHUwMDE1XHUwMDA0r+Pp11x1MDAwMHJ041vN/mHt3vU9sLGjW4VGrX43du+GSKWuyviFst/9OnZ8rV6rONR+LVF7gy9cdTAwMTJ2+7VSof78hkatXFxcdTAwMGVqQIlOVqg1/e52XHUwMDFj7m51a5Vas1DPR7SFrt3PPOupXHUwMDE3uDPFQs93r1x1MDAwZVx1MDAwM+j1Qlx1MDAwNVx1MDAxOJmTkns0QltcdTAwMTN/5GRtc6O+M2Bddn/XuK/YfL5/lc68LizfcexEXHUwMDE5j5NRkoxsfUhcdTAwMDPcN1xihp7ipGQ8XHUwMDE4XHUwMDE3XHUwMDBiXHUwMDFlNeGKuEFScvEkiKPrXHUwMDFmhSX5KbCGXHUwMDFisnXKqCA/PLkp0IZy6sDgz1x1MDAxMt2UJt5cdTAwMGVkP0t1U1x1MDAwMapcZouHM/zW5fmxUdqpbbT2sr3NXGZ2N1x1MDAwZTK57av9evpV43vvOXBcIjy6t+hcdTAwMTJwMis6hFBuKCdcdTAwMTX0gmIhYLzGTalCoXgh5TTPzz1GSIUpg89Selx1MDAxMjSzj01cYjbxXHUwMDExnUJcdTAwMWKlmVx0XGZcYr5cdTAwMTWcrzNM2Xbnvt45Ov/x48f5Sf3i6vgwfXxcdTAwMWMwTH9O/9qHXHUwMDBm51t7lUIu5Z9095pH9lx1MDAxMCv1y+1cdTAwMWbjZ3k6f6Hbbd3MYcQ4XHUwMDAwhZRQi4qoSCNcdTAwMTY5vkMmjGhcdTAwMGWtiZ+aTO/MhFx1MDAxYrFcdTAwMTRcdTAwMTGpIOtDySBlXHUwMDA1aMLhJJin6bBloFxirnYh2cm0eJLW46QsmnJBy6ZGVcDEPI2MazJvxr5cdTAwMDPFXHUwMDBiSpjIi1x1MDAwNYj1dUaM49jRXHUwMDE5Rix/4/v9JfmwXHUwMDE3SD/sw0JNiWfD5Fx1MDAxYmyYgPDR5/FcdTAwMDLBXGYjb1x1MDAxMt+GVXnaNrYvdzqp6lx1MDAwNuxfyrPTn0onX+BcZnJcdTAwMDGGh+1cdTAwMTdcdTAwMWGPRIVcdTAwMDJEaGCaiVCbPsUteOu3mtlccr9/36hWz5pq/7qU7drcSXxcdTAwMTFcdTAwMDLFKVx1MDAwMdNq+aNcdTAwMDFcdTAwMDFWXGKrkLbcXHUwMDE4w+dcdTAwMTggm37VK6BCJD+KSWHleHYxxL1hXHUwMDFlJSXyzXnHylx1MDAwYpCYXHUwMDAzjMlcdTAwMTWgXHUwMDE3SPnDXHUwMDA1yLLw0ZEtVJJroUT8uen1k5udY5VLnzXRXFzdf9va6ejNXtJcdTAwMTWISN5cdTAwMDBcdC03XHUwMDFjJ4dcdTAwMDEkMOp24iVOIYtGvNFcdTAwMTT+5jqUXHUwMDFmXFyv5fa+X/LU/u3deW692Pl29X1OXHUwMDFkUjJQXHUwMDE4sixcdTAwMWTSkb5cdTAwMGKYkFxcQrBW5iXYT7/qxOuQ8JRigCBcdTAwMTXnlIKG0iHiYU9qXHUwMDEwlFx1MDAwYjFBXHUwMDE27Y1cdTAwMDbsU4xcdTAwMTIgRi/w80eLkTA8fPQpKlxySM1cdTAwMDH1aIzrpaBs9b5cdTAwMWSet9bWe2epn+1jW7lnJ7yQdC1SXHUwMDFlkTjprlx1MDAxNVO1SLteXHUwMDE3Slx1MDAxYm6QXHTFlydGU8ah5cTAc1JU5yB9WG9uXHUwMDFmVlx1MDAwN/5ajde+dUSnXdqZU3U0LF91Zk26kOxcdTAwMDCoOZKf6Vx1MDAxN71cdTAwMDKig0hcdTAwMTIvNJsqOkYsXHUwMDE04Z+qk1x1MDAwMNV5gYk/WnU4RqpcdTAwMGVcdTAwMDHEWUGr45codPZcdTAwMGWvXHUwMDFi6Hc6WXPon9rDwkb7eDPZsmOYp13dNVk9YVx0fCM6f5hlolBcdTAwMTGCXCKTQmk8Yl9cdTAwMTOSXHUwMDAwpuhPKywzXHUwMDFlc1Vj04ORXGLa01x1MDAxOJ6MfZz+ZHTcXG7BR/q0xFwiXHUwMDA1VFxc2PcpJlx1MDAwM1x1MDAxYpmkcCGlq+Ww8f3Qjl83379l6rtcdTAwMTfqqr2pdLfWbq4nXHUwMDFimK6aXGaBOdplXHUwMDE2UYcqXHUwMDFlldJcdTAwMWWlxXRYg5VcdTAwMTbeVvFooGS5P1x0zH9ENVmnWMzk8T6T7V3ft3WuslNax/R8XHUwMDBlytBcdTAwMWTgi4qLyLxcdTAwMWRlVEhoXHUwMDAzwDmzc1D11ItOuIPiWntcdTAwMTKtq8IgymMqVE2mUHiMoXtcdTAwMDe5TXzjXHUwMDEwcmQxmSTAWyvoNCBcZlx1MDAwNNK2hFx1MDAxNJMtyD/FLybbarX6Sysme4G6w1x1MDAwNirclqVcdTAwMTeTXHUwMDA1XHUwMDA3JENRKSkkiacxvk5cdTAwMWSfyduLTnFXZHbSm5mN3Lp/1j9KejFcdTAwMTnnnPyJtGjMw2zl6GtcdTAwMWVqySxFpXJPOHF8c43nSlaUTdelx6Gajla63vJ57fREXFyKrl/cuD1fWHVccmk2W5gwzTZsMnp2k9hcdTAwMWHcJEL86fxcdTAwMDNd6N521i94tre/0Thkx1x1MDAwN+e902RcdTAwMWI2QrdngFx1MDAxMftCuJZ5XHUwMDE4XHUwMDA2wDyrjEW7iDD47SdT1vKnu9vbP1x1MDAwZnY2+1x1MDAxOdk46lx1MDAxZmyd5W3cirXs9o+LU6wp1chu9ZrXR5XCzdXu4irW3NTg0s1cdTAwMWXMKP90s0RcdTAwMWGZjVx1MDAxZE7TezPpbo9SXHUwMDFjrYiOXHUwMDE5Ob6JXHUwMDA0SFxi4ZGlUlxcPZYwvy2cPlx1MDAwN8timr1lXHUwMDBllr3A+lx1MDAxZj1YJmT0xKlcIoiilvGHsEV3/fJ252fZ7MHZcVVV8VStdZOvcKRvJOVcdTAwMTiWsGFEalI4So0oXHUwMDAzs1x1MDAwYnjA7TdcdTAwMTe4/PptrWvWXHUwMDBlb1tcdTAwMWIoikff7zavXHUwMDBi+3M9wyZcdTAwMTRcYlxc+qhDcGooLEQgmUBleXzUT7/qxFx1MDAwYlx1MDAxMXpcdTAwMTZdMkdJ/6RcdTAwMTBcdTAwMTlcdTAwMTJcIvc4r3vY9+3FmqsqRK/AY3KF6Fx1MDAwNXL+cCGy0c8zUL5H2TaK+KtcdTAwMDGcsfNO6qJvMsfVtZ3Uxam6zlx1MDAwNzLQhCqR8lx1MDAxNFx1MDAwNzR0qUJPKJFcdTAwMDTwXGZl92RcdTAwMWQ1uHj5VKJcdTAwMTlKxLLp0rbMs61Byp5cdTAwMWOcXHUwMDE2JWtcdTAwMTVcdTAwMDbzjX9LYiZcdTAwMTbE/1KUKLpCXHUwMDA2NWHBTVx1MDAwYsVcdTAwMDb99IteXHUwMDAxIZJuXHUwMDEwmaDjVlx1MDAxYVx1MDAxOFx1MDAxN1wiKZTHkFx1MDAwMGdcZqi3V2uuqlx1MDAxMP1WXHUwMDE50Vx1MDAwYtz80UIkZ1RQk1WSXHUwMDA20cZcdTAwMTeiXvNO+er7fjs/yFxmWje8t7X5bTfpQiQ98r9aXHUwMDAyQ+VcbmdHXGb0ULQmSIiY0kYwMz5h9Y8uWjtcdTAwMTPrezc3crN4spMvpG4vq7uVg0pcdTAwMDIlR+jIKVeyIHS/jOXxNWf6Va+A5lxi4chcdTAwMWXc8LWRIatl1EJcdTAwMTG+4qozz9pcdTAwMDDJVZ1cdTAwMTeI+KNVh7xeVFSCMlxiwOJPNG01N3c7W6ksbMP5oLnm64vNb1FcdTAwMDNcdTAwMTJcdNFcdTAwMWNQzFOMXHUwMDFiTVx1MDAxZW+yXGJCXHUwMDAwp9RILWpJnZUuWVx1MDAxYq7Ywd6rZE1gdMlcdTAwMWFcdTAwMThB3oDJ+KVcdTAwMDCbOyeH7eyp3fyW/nFweqK+i3w/asWOxJRcdTAwMDLQZXqKYFx1MDAwMUrgtFx1MDAxMWLOPFSWhJPDm59r/u0qXHUwMDAxdvs3p8Wj01x1MDAxY5rMnbzoraW3blpnXHUwMDBim7WUYFx1MDAwMlx1MDAxNfhLrVx1MDAwNFx1MDAxMJFcdTAwMTUxnJO2U0Ns/JVrjptnxVRd18o1xnJZOEinipuphFx1MDAxM7QxRNBaI2pwSz2HaopcdTAwMDE8gr5WXGa5q/JXn/Mks1KFa57ezd3traV+yPtTuGfFjctKMW4hwPFdeW3ztsVcdTAwMGYzuWo5n8FBrnmbW1xcIYCVbOkpyMxVXHUwMDAztNKcXHUwMDFiXHUwMDFi/2nN6b2Z8Fx1MDAxNITMtSeFVdZcdTAwMTlszoJcdTAwMTHzUFx04HJw7Vx1MDAxYziGXHUwMDE20/hMQVY1XHUwMDA1eYH2PzpcdTAwMDVcdTAwMTFcInLgXHUwMDBiKE92i1xixlx1MDAxZvdK93aE/H7q31xcXHUwMDFmd1x1MDAwYmv3ftpcdTAwMWVVo8aiXHUwMDEzI3HaXHUwMDE1u1lheFjEXHUwMDFlSlx1MDAwMcAjkUelXHUwMDFmpkTDXHL7lLjg/b+6YoNGZuOuVsZu75JlZfv84GLO0TD3Z9lSXHUwMDA0PPpcdTAwMDFcdTAwMDRAVFx1MDAwNIb4XHUwMDBmIEy/6MQrkXElaVx1MDAxYbmRjPAvxlFvSIhIXHUwMDE4mISQ7/vUoVXVoVx1MDAxN7j5w3XIRJaJUrRcbuJnmGNcdJutVvU8ZVtHfnX/JNPM1lxuulHNJl+IXGJtQqCSROdhIZIgPWRMuVx1MDAxZFjQ2OC04KdcdTAwMTJNKtH29fqV8Sv5Sr3Fi6Zlv12y9LzzMu+iRFx1MDAxOF1cdTAwMDAjlVuxiFx1MDAwNYpcdTAwMDVegv30q15cdTAwMDEpcvVWyEBcdTAwMWJcdTAwMTGWXCIp0DPcjXxcdG5ccshFbIjxKUVcdTAwMWYrRS+w80dLkdSRUoSKvJLSc9Sk2SNbNlBLr/XyV6XizV1e/cgmfCVcdTAwMDEw6GlGcWCUskqHXHUwMDAzUitPUpxQsqRcdTAwMDAkLi8jWqlCgFx1MDAxMzi9zJTklrxs71c3dnZz+6U7lUDBXHUwMDEx0etkSPdwXHUwMDFiSX98vZl+0SugN1x1MDAxY6zbeskyJoIrRD/UXHUwMDAx6EXi+1NwXHUwMDEyIDgvkPBHXHUwMDBiXHUwMDBlRG+AqrVGXHUwMDAwruOnPt30UZrt7O83hTpJy8Za5uw+9zPZguOqnIGSXG5BXHUwMDA0NNysYywgXHUwMDA1uMdcdTAwMTJcdTAwMDJjcG9cbsjVL1x1MDAwM1x1MDAxMDqQXGIvtVxmQEfv+oRCgWVyjpVr9lmrv5nL+oY31/X6xe4mXjRcdTAwMTO/qix6buEowp52i5mFXHUwMDE28qNcdTAwMWXwrFvv+nFcdTAwMTW0t42SRVx1MDAwMVO6labdhlNuq1x1MDAwM/c1OFx0T5Bu116SXHUwMDExPVxcx1x1MDAwMyZcdTAwMGJcdTAwMDDeXHUwMDE3o1x1MDAxMoRcXPoq5DNYU7n1XHUwMDAyOJujiv460ylmXHUwMDFh9udm+eLozFx1MDAxN1x1MDAwNzlTgGbSnVxmXHUwMDExJlx1MDAwMVx1MDAxMNFwI1x1MDAxMVhgfYSHrSS14yxOyTORhjViedtGk5eR5ExcdTAwMTRcYlx1MDAwYqFAmbGUXGY1yY1wwLKt+9KLUlx1MDAxNuSV5linpnbrlyO8Ut2/6M9wSv1WO8omjTV3Yjma8VMuYjWa6MdjXCLHxITbbFCjiV8nUDXr+3fn1fIgla1s5Vx1MDAxYq3LuzxL/OxcZoWFR1xcL4XVhlEqMl58hpx0h6SXQKeMU55/dFxc58hcdTAwMTZcdTAwMTbyV2dcXF9cdTAwMWNuX36HUjrTb3/G9Vx1MDAwN8b1QzdPM5Qztlx1MDAxMVVuu2vkMv7I2mxCT2pkg/bc9ijKKKVItsdcdTAwMWQloPBcdTAwMDSS41x1MDAxNNxcIuCbxrpnXHUwMDA2tmWeNW5FVMtcdTAwMTRcdTAwMTnHXHUwMDEx31x1MDAwNi0loqA2MOOe0MNcdEvJiXtcdTAwMTSToOdcdTAwMTmMmFxcXGZcdTAwMTFcdTAwMWWPzFhcZnFG5Zog9tOv2my01y90++u1ZrnWrIw3zH849XZcZl84jOjSwLWSeaApYXLzXHUwMDE0bjhSju6s65hC2/G2Ry6c2lxmlj9MsE1cXLrfLL/cpNn11IEmpZhnXHUwMDE0pztkhit6XHUwMDE4hWKiTZR5o9vcW5I9dONQdqJN9UKvv9FqNGp96vmDVq3ZXHUwMDBm9/CwK9dcXNBX/cJcdTAwMDTh0DVcdTAwMDVfXHUwMDBis0PbfeM4yY9++zKKnuF/nn//959T352KwrX7mUD06Nv+XGL+Ozet2ehHq1xmUr9Solx1MDAxY3/4ZracJZXVXHUwMDE0o1x1MDAwNJQpUIbwL9n4g1x1MDAxY1xi3ONusFx1MDAwMJlcdTAwMTaISyyWt8JDbYZLW4xtXGY4mj/gXHUwMDFlXHUwMDEzlFxmW0roXHRcdTAwMWWT01x0XHUwMDFjKWWiTJnNY15cdTAwMTbOasxcYnzVY78xWW22KVx1MDAxZadcdTAwMTBiNNdXXGJARKLZiO6fOcR40m1cdTAwMDIurGTuwVFjZvPa+HWsXHUwMDE0u0yH1/C1XHRkzckukfM1NnIyUlx0NG5p8/iL1lx1MDAxY1X29zZQ9vzaXHUwMDFl+Nnvt53Dw42bpJNLSqJLd4hBrEIlJIyzXHUwMDBiV+5JXHUwMDFkrVxyMolumONNw3Czn8Ux2uNCXHUwMDE5lNHZ0MScXHK3lFxyKVx1MDAwN4x3SYfesMftn7O+91xyK1x1MDAxMc783iU+2OD2one9/8b0Lf5cdTAwMTRcdTAwMTahaNBo9r7811N29qVX6rbq9f9+35QuRjNcdTAwMTYxpVx1MDAxNemHzIxcdTAwMDc9lFtseq5nzWdcdTAwMDM6oZwljEdGXHUwMDEzrHFcdTAwMWJccihcdTAwMDXjXHUwMDAzOI5HXHUwMDE0XHUwMDEzRltcdTAwMTKS5Y7MkunVJM1cbshccmshg9tCjCa3QHiGh2r+nthcdTAwMGKcnVx1MDAxYptSeG8/5MLY7dm3RD80W1x1MDAxN8ezPNBkcyVKpa1Ukk2meZy6XFxcInPVkbONUFRrZu92PO7OXHUwMDE4Uk5jQIOWXG5IlfREczQhkfwsvW4lXHUwMDEycuVEo1bKg0VcdTAwMDPa/Vx1MDAwNKE8p1x1MDAwMYukM2TRdCY510zMs13BbFx1MDAxZE0onVnjaW5cdGSEXCJcdCZUmsxccvfc7CRcYiWXTGfaeJRmSLe/MFx1MDAxMavk09hMoEfo0I55hzvdXHUwMDA0nkp95LRhnVx1MDAwMVx1MDAwNFx1MDAxN194f05T1Ly5dtpYXHUwMDFhp1x1MDAxMYtI4G6tU1wiXG6plbTTSMRyx2jSXHUwMDBlf8zkMFEsZpu9LO5cdTAwMTjPckKVXHUwMDE0YIxcdTAwMDX6VUzSLHiUhJEyuM50T2/halx1MDAxM1skst3PJKZcdTAwMTdGbjxyXHUwMDEynVTOclxu9vjcNtvLJ5TbXGI4XHUwMDFlKkBKLCWA0eOT6NxQz1x1MDAxM945d0umkl1b3pg8R+5m891CXHUwMDBlklx1MDAwMlLBlEJcdTAwMGbKOSlYJcWhXHUwMDFlrvRcdTAwMTBMdlx1MDAxZshNKtRqrlxc8/d1a47ZyPRcZvdop0ZJXHUwMDE2XHUwMDFjXHUwMDEwfnJrZFx1MDAxOEjFXHTsZlg680rPNvsp33FmQ+ZG+MDtduVOOTko71x1MDAxNlx1MDAxNzFuSS73yFx1MDAwMzVcdHGiUatEbdHAXHUwMDFlvjpcdTAwMDHpObkter3nSNvGnfZqPUel88Fpo8GuQF9tnaX3i93SbvbnbtRcIueJoTaNnuSkXHUwMDE3XHUwMDA0fjfXKMeX70CiNiNcZqM/Llx1MDAwZmVLXHUwMDFjN1NIpt1cdTAwMWHlRuhcZuppS9hMKatcdTAwMTSMzLVcclxc5YruXvNYRvDWcaj4ZVx1MDAwNNVWt3bvxoueRn7ed/xpxumXWl5cdTAwMTCoXHUwMDFiXHUwMDBll1xyKcvA7VpcdTAwMTY73mfDIaHxzqX2iF6F21x1MDAwN5TcitLjhdSopVx1MDAwN4xcdTAwMWOkojcuN+Ct52qDrZ3Ykz7gZKwnnVx1MDAwMNqIkSdtXHUwMDE5XHSHlfOE/6In4ubedWpeKzNbVr6EJuKoP9zjXCJ0f8mowlx1MDAxNN/gJvNcdTAwMWZ2d32li5m5a9uYi2Hu4Vx1MDAxOMVcdTAwMDRnROtcdTAwMDKnjIOBoFx1MDAxYqxcdTAwMWTna5fIMTZ5L1bKxURh2v2kJuBcdTAwMWNlYv54PMHXQrt92Ce4Pd9cdTAwMGXCd638yPWjq/x6XfNv1qfUi19cZn+cXGZccvvTUZI/jIFff/z6f3dfuVAifQ== + + + + HeaderTweetTweetTweetTweetFooterTweetTweetTweetTweetTweetTweetTweetTweetFixedFixedColumns (vertical scroll)horizontal scroll diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 6688f3ca3..f5a9ab4f1 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -157,6 +157,7 @@ nav: - "widgets/text_log.md" - "widgets/tree.md" - API: + - "api/index.md" - "api/app.md" - "api/await_remove.md" - "api/binding.md" @@ -189,6 +190,9 @@ nav: - "api/work.md" - "api/worker.md" - "api/worker_manager.md" + - "How To": + - "how-to/index.md" + - "how-to/design-a-layout.md" - "roadmap.md" - "Blog": - blog/index.md From e25c6290ba4bc694cfaa4e260c9850df68f9fd40 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 17 May 2023 16:21:32 +0100 Subject: [PATCH 80/85] Add classes to Tab widget (#2589) * Add classes to Tab widget * Update CHANGELOG --- CHANGELOG.md | 3 ++- src/textual/widgets/_tabs.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c2cd5a3..80b9b9d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unrealeased +## Unreleased ### Changed @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Screen.AUTO_FOCUS` now works on the default screen on startup https://github.com/Textualize/textual/pull/2581 - Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583 - Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525 +- Fix not being able to use CSS classes with `Tab` https://github.com/Textualize/textual/pull/2589 ### Added diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ffeb3fe5b..78c7a6869 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -117,15 +117,17 @@ class Tab(Static): label: TextType, *, id: str | None = None, + classes: str | None = None, ) -> None: """Initialise a Tab. Args: label: The label to use in the tab. id: Optional ID for the widget. + classes: Space separated list of class names. """ self.label = Text.from_markup(label) if isinstance(label, str) else label - super().__init__(id=id) + super().__init__(id=id, classes=classes) self.update(label) @property From 84de8a89499aa6d7d2bcb9121364002a9260f19c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 17 May 2023 16:26:33 +0100 Subject: [PATCH 81/85] Typo fix (#2596) --- src/textual/widgets/_directory_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 72a19ddf1..46001882d 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -292,7 +292,7 @@ class DirectoryTree(Tree[DirEntry]): worker: The worker that the loading is taking place in. Yields: - Path: A entry within the location. + Path: An entry within the location. """ try: for entry in location.iterdir(): From a9c8b59df5abf4dfabcc5d34551ee8701b711c4e 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: Wed, 17 May 2023 16:27:32 +0100 Subject: [PATCH 82/85] Use default string on error inside work. (#2595) Related issues #2588. --- src/textual/_work_decorator.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 2b47848b9..2688eccc4 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -87,14 +87,15 @@ def work( self = args[0] assert isinstance(self, DOMNode) - positional_arguments = ", ".join(repr(arg) for arg in args[1:]) - keyword_arguments = ", ".join( - f"{name}={value!r}" for name, value in kwargs.items() - ) - tokens = [positional_arguments, keyword_arguments] - worker_description = ( - f"{method.__name__}({', '.join(token for token in tokens if token)})" - ) + try: + positional_arguments = ", ".join(repr(arg) for arg in args[1:]) + keyword_arguments = ", ".join( + f"{name}={value!r}" for name, value in kwargs.items() + ) + tokens = [positional_arguments, keyword_arguments] + worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" + except Exception: + worker_description = "" worker = cast( "Worker[ReturnType]", self.run_worker( From ff5665051b8b80868b34168b1104d23d7b4fad23 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 17 May 2023 16:30:36 +0100 Subject: [PATCH 83/85] Release0250 (#2598) * version bump * changelog --- CHANGELOG.md | 5 +++-- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80b9b9d67..42bf44a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.25.0] - 2023-05-17 ### Changed @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Only a single error will be written by default, unless in dev mode ("debug" in App.features) https://github.com/Textualize/textual/issues/2480 - Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 - Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575 -- `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 +- `MessagePump.call_after_refresh` and `MessagePump.call_later` will now return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 ### Fixed @@ -965,6 +965,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.25.0]: https://github.com/Textualize/textual/compare/v0.24.1...v0.25.0 [0.24.1]: https://github.com/Textualize/textual/compare/v0.24.0...v0.24.1 [0.24.0]: https://github.com/Textualize/textual/compare/v0.23.0...v0.24.0 [0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0 diff --git a/pyproject.toml b/pyproject.toml index 56ada690a..5f84cbc23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.24.1" +version = "0.25.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] From 8fd5aec454df217ba1299fa9660d28837ade9808 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 18 May 2023 09:28:33 +0100 Subject: [PATCH 84/85] fix layout --- docs/examples/how-to/layout.css | 0 docs/examples/how-to/layout05.py | 2 -- docs/examples/how-to/layout06.py | 2 -- 3 files changed, 4 deletions(-) delete mode 100644 docs/examples/how-to/layout.css diff --git a/docs/examples/how-to/layout.css b/docs/examples/how-to/layout.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/examples/how-to/layout05.py b/docs/examples/how-to/layout05.py index 5b2cf6497..1fb6de3a3 100644 --- a/docs/examples/how-to/layout05.py +++ b/docs/examples/how-to/layout05.py @@ -44,8 +44,6 @@ class TweetScreen(Screen): class LayoutApp(App): - CSS_PATH = "layout.css" - def on_ready(self) -> None: self.push_screen(TweetScreen()) diff --git a/docs/examples/how-to/layout06.py b/docs/examples/how-to/layout06.py index 430670673..79be0ab9a 100644 --- a/docs/examples/how-to/layout06.py +++ b/docs/examples/how-to/layout06.py @@ -58,8 +58,6 @@ class TweetScreen(Screen): class LayoutApp(App): - CSS_PATH = "layout.css" - def on_ready(self) -> None: self.push_screen(TweetScreen()) From 49e10802796cd522c9f371071d67e02e875c98d8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 18 May 2023 10:03:26 +0100 Subject: [PATCH 85/85] update howto --- docs/how-to/design-a-layout.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/how-to/design-a-layout.md b/docs/how-to/design-a-layout.md index 3a8fe689b..5123e574a 100644 --- a/docs/how-to/design-a-layout.md +++ b/docs/how-to/design-a-layout.md @@ -162,6 +162,7 @@ Let's set the width of the columns to 32. We also want to reduce the height of each "tweet". In the real app, you might set the height to "auto" so it fits the content, but lets set it to 5 lines for now. +Here's the final example and a reminder of the sketch. === "layout06.py" @@ -174,11 +175,16 @@ In the real app, you might set the height to "auto" so it fits the content, but ```{.textual path="docs/examples/how-to/layout06.py" columns="100" lines="32"} ``` -You should see from the output that we have fixed width columns that will scroll horizontally. -You can also scroll the "tweets" in each column vertically. +=== "Sketch" + +
+ --8<-- "docs/images/how-to/layout.excalidraw.svg" +
+ + +A layout like this is a great starting point. +In a real app, you would start replacing each of the placeholders with [builtin](../widget_gallery.md) or [custom](../guide/widgets.md) widgets. -This last example is a relatively complete design. -There are plenty of things you might want to tweak, but this contains all the elements you might need. ## Summary