From 71c5a44fdb82f194db17f7ae4759f353fd70c7b2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 08:53:24 +0100 Subject: [PATCH 01/31] Take Tree.show_root into account when drawing guides Rather than always start at the root, the code should start at the beginning of the path. See #2397. --- src/textual/widgets/_tree.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 91ffe794e..9d5d9e0d2 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -976,8 +976,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): "tree--guides-selected", partial=True ) - hover = self.root._hover - selected = self.root._selected and self.has_focus + hover = line.path[0]._hover + selected = line.path[0]._selected and self.has_focus def get_guides(style: Style) -> tuple[str, str, str, str]: """Get the guide strings for a given style. From f6da3e9fb2bb58a728cfa66e58a79bcf2ba7564b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 14:16:34 +0100 Subject: [PATCH 02/31] Add always_update as a parameter for a var reactive --- CHANGELOG.md | 1 + src/textual/widgets/_tree.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56b6aff24..9de584fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459 +- Added `always_update` as an optional argument for `reactive.var` ## [0.23.0] - 2023-05-03 diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 9d5d9e0d2..85255cf1e 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -429,7 +429,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """Show the root of the tree.""" hover_line = var(-1) """The line number under the mouse pointer, or -1 if not under the mouse pointer.""" - cursor_line = var(-1) + cursor_line = var(-1, always_update=True) """The line with the cursor, or -1 if no cursor.""" show_guides = reactive(True) """Enable display of tree guide lines.""" From 10e61987e36a45b8a174d179f86e2280e66cfb94 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 14:17:20 +0100 Subject: [PATCH 03/31] Add always_update as a parameter for a var reactive Redux. I managed to commit the wrong thing last time; although it was using this and this was done for that. --- src/textual/reactive.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 08259200d..998d4079b 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -76,11 +76,13 @@ class Reactive(Generic[ReactiveType]): def var( cls, default: ReactiveType | Callable[[], ReactiveType], + always_update: bool = False, ) -> Reactive: """A reactive variable that doesn't update or layout. Args: default: A default value or callable that returns a default. + always_update: Call watchers even when the new value equals the old value. Returns: A Reactive descriptor. @@ -326,18 +328,21 @@ class var(Reactive[ReactiveType]): Args: default: A default value or callable that returns a default. init: Call watchers on initialize (post mount). + always_update: Call watchers even when the new value equals the old value. """ def __init__( self, default: ReactiveType | Callable[[], ReactiveType], init: bool = True, + always_update: bool = False, ) -> None: super().__init__( default, layout=False, repaint=False, init=init, + always_update=always_update, ) From bba694e93af0a7f33c288e2530849e169d0ba4ab Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 14:30:20 +0100 Subject: [PATCH 04/31] Update the ChangeLog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d584709d8..53070b80f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed crash when creating a `DirectoryTree` starting anywhere other than `.` +- Fixed line drawing in `Tree` when `Tree.show_root` is `True` https://github.com/Textualize/textual/issues/2397 +- Fixed line drawing in `Tree` not marking branches as selected when first getting focus https://github.com/Textualize/textual/issues/2397 ### Changed From 0b4d7fb091dfc84dd7ec446002c48d38f1a64f2e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:10:45 +0100 Subject: [PATCH 05/31] Test to ensure that Changed.control is Control.radio_button --- tests/toggles/test_radiobutton.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/toggles/test_radiobutton.py b/tests/toggles/test_radiobutton.py index dc32e8c0f..de4340c0c 100644 --- a/tests/toggles/test_radiobutton.py +++ b/tests/toggles/test_radiobutton.py @@ -15,7 +15,13 @@ class RadioButtonApp(App[None]): yield RadioButton(value=True, id="rb3") def on_radio_button_changed(self, event: RadioButton.Changed) -> None: - self.events_received.append((event.radio_button.id, event.radio_button.value)) + self.events_received.append( + ( + event.radio_button.id, + event.radio_button.value, + event.radio_button is event.control, + ) + ) async def test_radio_button_initial_state() -> None: @@ -51,7 +57,7 @@ async def test_radio_button_toggle() -> None: ] await pilot.pause() assert pilot.app.events_received == [ - ("rb1", True), - ("rb2", True), - ("rb3", False), + ("rb1", True, True), + ("rb2", True, True), + ("rb3", False, True), ] From b7cdbb0baad4ed3f12723885249a582877b3fc19 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:12:15 +0100 Subject: [PATCH 06/31] Test to ensure that Changed.control is Control.checkbox --- tests/toggles/test_checkbox.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/toggles/test_checkbox.py b/tests/toggles/test_checkbox.py index 573953f23..e0ae1e1fb 100644 --- a/tests/toggles/test_checkbox.py +++ b/tests/toggles/test_checkbox.py @@ -15,7 +15,9 @@ class CheckboxApp(App[None]): yield Checkbox(value=True, id="cb3") def on_checkbox_changed(self, event: Checkbox.Changed) -> None: - self.events_received.append((event.checkbox.id, event.checkbox.value)) + self.events_received.append( + (event.checkbox.id, event.checkbox.value, event.checkbox is event.control) + ) async def test_checkbox_initial_state() -> None: @@ -43,7 +45,7 @@ async def test_checkbox_toggle() -> None: ] await pilot.pause() assert pilot.app.events_received == [ - ("cb1", True), - ("cb2", True), - ("cb3", False), + ("cb1", True, True), + ("cb2", True, True), + ("cb3", False, True), ] From 2113f415a0318a9737d560aa373f55cb7e7ad426 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:17:52 +0100 Subject: [PATCH 07/31] Add a test that toggling a pressed radio button has no effect --- tests/toggles/test_radioset.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index dc9de5b1b..20980b8d9 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -52,6 +52,15 @@ async def test_radio_sets_toggle(): ] +async def test_radioset_same_button_mash(): + """Mashing the same button should have no effect.""" + async with RadioSetApp().run_test() as pilot: + assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2 + pilot.app.query_one("#from_buttons", RadioSet)._nodes[2].toggle() + assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2 + assert pilot.app.events_received == [] + + async def test_radioset_inner_navigation(): """Using the cursor keys should navigate between buttons in a set.""" async with RadioSetApp().run_test() as pilot: From b1443c01622f6bd5f3eaa75950d9732c177613a4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:22:30 +0100 Subject: [PATCH 08/31] Test that radioset wraps around when going off the top --- tests/toggles/test_radioset.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 20980b8d9..66140e252 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -66,7 +66,13 @@ async def test_radioset_inner_navigation(): 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), ("right", 1), ("left", 0)): + for key, landing in ( + ("down", 1), + ("up", 0), + ("right", 1), + ("left", 0), + ("up", 2), + ): await pilot.press(key, "enter") assert ( pilot.app.query_one("#from_buttons", RadioSet).pressed_button From e7d3b94334383631cf6f41fe69acae35a80dfc8b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:25:33 +0100 Subject: [PATCH 09/31] Test that radioset wraps around when going off the bottom --- tests/toggles/test_radioset.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 66140e252..66cfb80e7 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -72,6 +72,7 @@ async def test_radioset_inner_navigation(): ("right", 1), ("left", 0), ("up", 2), + ("down", 0), ): await pilot.press(key, "enter") assert ( From 19f4f64d96585ffe555dcab4c30b7b88e4845572 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 15:39:33 +0100 Subject: [PATCH 10/31] Add tests for selection navigation in a radioset with no buttons pressed --- tests/toggles/test_radioset.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 66cfb80e7..b2869eb5a 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -79,6 +79,15 @@ async def test_radioset_inner_navigation(): pilot.app.query_one("#from_buttons", RadioSet).pressed_button == 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") + assert pilot.app.query_one("#from_strings", RadioSet)._selected == 0 + await pilot.press("down") + assert pilot.app.query_one("#from_strings", RadioSet)._selected == 1 async def test_radioset_breakout_navigation(): From 8b36d29e74412729d67d1da151d7a3532a83d83f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 16:22:39 +0100 Subject: [PATCH 11/31] Add a test for a radio set getting focus when a button gets clicked --- tests/toggles/test_radioset.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index b2869eb5a..95025bf37 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -11,7 +11,7 @@ class RadioSetApp(App[None]): def compose(self) -> ComposeResult: with RadioSet(id="from_buttons"): - yield RadioButton() + yield RadioButton(id="clickme") yield RadioButton() yield RadioButton(value=True) yield RadioSet("One", "True", "Three", id="from_strings") @@ -36,6 +36,14 @@ async def test_radio_sets_initial_state(): assert pilot.app.events_received == [] +async def test_click_sets_focus(): + """Clicking within a radio set should set focus.""" + async with RadioSetApp().run_test() as pilot: + assert pilot.app.screen.focused is None + await pilot.click("#clickme") + assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons") + + async def test_radio_sets_toggle(): """Test the status of the radio sets after they've been toggled.""" async with RadioSetApp().run_test() as pilot: From 6139c95f3aa068b319d850d776a15b7137788377 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 4 May 2023 16:43:20 +0100 Subject: [PATCH 12/31] Test that the event aliases are actually the same reference I thought I thought I wanted what I thought but now I think about it I think I thought wrong and now I think better. --- tests/toggles/test_checkbox.py | 2 +- tests/toggles/test_radiobutton.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/toggles/test_checkbox.py b/tests/toggles/test_checkbox.py index e0ae1e1fb..095f87485 100644 --- a/tests/toggles/test_checkbox.py +++ b/tests/toggles/test_checkbox.py @@ -16,7 +16,7 @@ class CheckboxApp(App[None]): def on_checkbox_changed(self, event: Checkbox.Changed) -> None: self.events_received.append( - (event.checkbox.id, event.checkbox.value, event.checkbox is event.control) + (event.checkbox.id, event.checkbox.value, event.checkbox == event.control) ) diff --git a/tests/toggles/test_radiobutton.py b/tests/toggles/test_radiobutton.py index de4340c0c..0dc4fda05 100644 --- a/tests/toggles/test_radiobutton.py +++ b/tests/toggles/test_radiobutton.py @@ -19,7 +19,7 @@ class RadioButtonApp(App[None]): ( event.radio_button.id, event.radio_button.value, - event.radio_button is event.control, + event.radio_button == event.control, ) ) From 630f59dbd4f220b68e936e372eb4458ee2b70529 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 17:52:55 +0100 Subject: [PATCH 13/31] Remove unnecessary imports from example in docs Perhaps a hangover from a previous take on this, or just a copy/paste-o. Either way I just noticed this so thought I'd roll it in here. --- docs/examples/widgets/option_list_tables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/examples/widgets/option_list_tables.py b/docs/examples/widgets/option_list_tables.py index aaf51a0a2..ff4f2d054 100644 --- a/docs/examples/widgets/option_list_tables.py +++ b/docs/examples/widgets/option_list_tables.py @@ -4,7 +4,6 @@ from rich.table import Table from textual.app import App, ComposeResult from textual.widgets import Footer, Header, OptionList -from textual.widgets.option_list import Option, Separator COLONIES: tuple[tuple[str, str, str, str], ...] = ( ("Aerilon", "Demeter", "1.2 Billion", "Gaoth"), From b44983e8d4815e86b4ec664951f4e5a04307a52f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 17:56:02 +0100 Subject: [PATCH 14/31] Add an add_options method to the OptionList In doing so, pretty much make the add_option code into the add_options code, and then just have add_option call add_options. See #2507. --- src/textual/widgets/_option_list.py | 36 ++++++++++++++------ tests/option_list/test_option_list_create.py | 4 +++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 97744971a..d6e6af537 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -7,7 +7,7 @@ forms of bounce-bar menu. from __future__ import annotations -from typing import ClassVar, NamedTuple +from typing import ClassVar, Iterable, NamedTuple from rich.console import RenderableType from rich.repr import Result @@ -508,6 +508,30 @@ class OptionList(ScrollView, can_focus=True): # list, set the virtual size. self.virtual_size = Size(self.scrollable_content_region.width, len(self._lines)) + def add_options(self, items: Iterable[NewOptionListContent]) -> Self: + """Add new options to the end of the option list. + + Args: + items: The new items to add. + + Returns: + The `OptionList` instance. + + Raises: + DuplicateID: If there is an attempt to use a duplicate ID. + """ + # Turn any incoming values into valid content for the list. + content = [self._make_content(item) for item in items] + self._contents.extend(content) + # Pull out the content that is genuine options and add them to the + # list of options. + options = [item for item in content if isinstance(item, Option)] + if options: + self._options.extend(options) + self._refresh_content_tracking(force=True) + self.refresh() + return self + def add_option(self, item: NewOptionListContent = None) -> Self: """Add a new option to the end of the option list. @@ -520,15 +544,7 @@ class OptionList(ScrollView, can_focus=True): Raises: DuplicateID: If there is an attempt to use a duplicate ID. """ - # Turn any incoming value into valid content for the list. - content = self._make_content(item) - self._contents.append(content) - # If the content is a genuine option, add it to the list of options. - if isinstance(content, Option): - self._options.append(content) - self._refresh_content_tracking(force=True) - self.refresh() - return self + return self.add_options([item]) def _remove_option(self, index: int) -> None: """Remove an option from the option list. diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py index ed3239122..043e3e584 100644 --- a/tests/option_list/test_option_list_create.py +++ b/tests/option_list/test_option_list_create.py @@ -106,6 +106,10 @@ async def test_add_later() -> None: assert option_list.option_count == 6 option_list.add_option(Option("even more")) assert option_list.option_count == 7 + option_list.add_options( + [Option("more still"), "Yet more options", "so many options!"] + ) + assert option_list.option_count == 10 async def test_create_with_duplicate_id() -> None: From 010356a23ef20b84349253d010ba017da39820bd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 18:01:56 +0100 Subject: [PATCH 15/31] Allow zero items to be passed to add_options and do zero work --- src/textual/widgets/_option_list.py | 23 +++++++++++--------- tests/option_list/test_option_list_create.py | 4 ++++ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index d6e6af537..f72c8b7d8 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -520,16 +520,19 @@ class OptionList(ScrollView, can_focus=True): Raises: DuplicateID: If there is an attempt to use a duplicate ID. """ - # Turn any incoming values into valid content for the list. - content = [self._make_content(item) for item in items] - self._contents.extend(content) - # Pull out the content that is genuine options and add them to the - # list of options. - options = [item for item in content if isinstance(item, Option)] - if options: - self._options.extend(options) - self._refresh_content_tracking(force=True) - self.refresh() + # Only work if we have items to add; but don't make a fuss out of + # zero items to add, just carry on like nothing happened. + if items: + # Turn any incoming values into valid content for the list. + content = [self._make_content(item) for item in items] + self._contents.extend(content) + # Pull out the content that is genuine options and add them to the + # list of options. + options = [item for item in content if isinstance(item, Option)] + if options: + self._options.extend(options) + self._refresh_content_tracking(force=True) + self.refresh() return self def add_option(self, item: NewOptionListContent = None) -> Self: diff --git a/tests/option_list/test_option_list_create.py b/tests/option_list/test_option_list_create.py index 043e3e584..7e0bbc49c 100644 --- a/tests/option_list/test_option_list_create.py +++ b/tests/option_list/test_option_list_create.py @@ -110,6 +110,10 @@ async def test_add_later() -> None: [Option("more still"), "Yet more options", "so many options!"] ) assert option_list.option_count == 10 + option_list.add_option(None) + assert option_list.option_count == 10 + option_list.add_options([]) + assert option_list.option_count == 10 async def test_create_with_duplicate_id() -> None: From e526a32220dca5d023da7d0464b7e1790e417933 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 18:07:31 +0100 Subject: [PATCH 16/31] Update the CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53070b80f..2f4184830 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` +### Added + +- Added `OptionList.add_options` https://github.com/Textualize/textual/pull/2508 + ## [0.23.0] - 2023-05-03 ### Fixed From b3b98f1089589e0be63038ce6bf4a6ee0af342e4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 18:11:14 +0100 Subject: [PATCH 17/31] Simplify the adding of genuine options Extending a list with an empty list is petty much a no-op so don't bother to faff around testing for what Python will be testing for anyway. --- src/textual/widgets/_option_list.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index f72c8b7d8..dba669592 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -528,9 +528,7 @@ class OptionList(ScrollView, can_focus=True): self._contents.extend(content) # Pull out the content that is genuine options and add them to the # list of options. - options = [item for item in content if isinstance(item, Option)] - if options: - self._options.extend(options) + self._options.extend([item for item in content if isinstance(item, Option)]) self._refresh_content_tracking(force=True) self.refresh() return self From 84ddd092230ce7c9d38c8e4766afc5b1ce375ea5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 7 May 2023 20:26:12 +0100 Subject: [PATCH 18/31] Add extra snapshit tests This tests the three main ways of making an option list, and ensures they all turn out the same. --- .../__snapshots__/test_snapshots.ambr | 158 ++++++++++++++++++ .../snapshot_apps/option_list.py | 45 +++++ tests/snapshot_tests/test_snapshots.py | 2 + 3 files changed, 205 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/option_list.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index b69b5dfe4..5b06d31c0 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -19195,6 +19195,164 @@ ''' # --- +# name: test_option_list_build + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + OptionListApp + + + + + + + + + + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────────────────── + ThreeThreeThree + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_order_independence ''' diff --git a/tests/snapshot_tests/snapshot_apps/option_list.py b/tests/snapshot_tests/snapshot_apps/option_list.py new file mode 100644 index 000000000..36e4691dd --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/option_list.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from rich.text import Text + +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +class OptionListApp(App[None]): + + def compose( self ) -> ComposeResult: + with Horizontal(): + yield OptionList( + "One", + Option("Two"), + None, + Text.from_markup("[red]Three[/]") + ) + yield OptionList(id="later-individual") + yield OptionList(id="later-at-once") + + def on_mount(self) -> None: + options: list[None | str | Text | Option] = [ + "One", + Option("Two"), + None, + Text.from_markup("[red]Three[/]"), + ] + option_list = self.query_one("#later-individual", OptionList) + for option in options: + option_list.add_option(option) + option_list.highlighted = 0 + option_list = self.query_one("#later-at-once", OptionList) + option_list.add_options([ + "One", + Option("Two"), + None, + Text.from_markup("[red]Three[/]"), + ]) + option_list.highlighted = 0 + +if __name__ == "__main__": + OptionListApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c9aee9636..fde61ae64 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -203,6 +203,8 @@ 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"]) From 4c0d3766a8f4a1df5d8911b51afb40325b5c5919 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 08:44:12 +0100 Subject: [PATCH 19/31] Add TreeNode.is_root --- src/textual/widgets/_tree.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 85255cf1e..275fd15e5 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -184,6 +184,11 @@ class TreeNode(Generic[TreeDataType]): self._parent._children and self._parent._children[-1] == self, ) + @property + def is_root(self) -> bool: + """Is this node the root of the tree?""" + return self == self._tree.root + @property def allow_expand(self) -> bool: """Is this node allowed to expand?""" From 1d985abf26851c5d6321ffc572426844a73a321b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 08:44:29 +0100 Subject: [PATCH 20/31] Add TreeNode.remove This is for removing an individual node, via the node. Note that attempting to remove the root node of a Tree is an error and will case TreeNode.RemoveRootError to be raised. See #2462. --- src/textual/widgets/_tree.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 275fd15e5..df355ccdf 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -349,6 +349,21 @@ class TreeNode(Generic[TreeDataType]): node = self.add(label, data, expand=False, allow_expand=False) return node + class RemoveRootError(Exception): + """Exception raised when trying to remove a tree's root node.""" + + def remove(self) -> None: + """Remove this node from the tree. + + Raises: + TreeNode.RemoveRootError: If there is an attempt to remove the root. + """ + if self.is_root: + raise self.RemoveRootError("Attempt to remove the root node of a Tree.") + assert self._parent is not None + del self._parent._children[self._parent._children.index(self)] + self._tree._invalidate() + class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """A widget for displaying and navigating data in a tree.""" From 2c39f50150e9dacc5bcedb4d60c36592db2f1b08 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 09:15:28 +0100 Subject: [PATCH 21/31] Ensure the Tree's internal tracker gets updated on node delete See #2462. --- src/textual/widgets/_tree.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index df355ccdf..27bb2487f 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -352,6 +352,18 @@ class TreeNode(Generic[TreeDataType]): class RemoveRootError(Exception): """Exception raised when trying to remove a tree's root node.""" + def _remove(self) -> None: + """Remove the current node and all its children. + + Note: + This is the internal support method for `remove`. + """ + for child in reversed(self._children): + child._remove() + assert self._parent is not None + del self._parent._children[self._parent._children.index(self)] + del self._tree._tree_nodes[self.id] + def remove(self) -> None: """Remove this node from the tree. @@ -360,8 +372,7 @@ class TreeNode(Generic[TreeDataType]): """ if self.is_root: raise self.RemoveRootError("Attempt to remove the root node of a Tree.") - assert self._parent is not None - del self._parent._children[self._parent._children.index(self)] + self._remove() self._tree._invalidate() From f34e738ddd6230d434b3cb709ccdce4499437882 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 09:16:58 +0100 Subject: [PATCH 22/31] Ensure the cursor node is set to None when the tree goes empty Up until now there wasn't really a way for the tree to go empty, outside of clear/reset. Now that we can remove nodes on the fly, that is possible. This takes that into account. See #2462. --- src/textual/widgets/_tree.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 27bb2487f..1d399fd52 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -845,6 +845,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): self._cursor_node = node if previous_node != node: self.post_message(self.NodeHighlighted(node)) + else: + self._cursor_node = None def watch_guide_depth(self, guide_depth: int) -> None: self._invalidate() From 64d9455d946470135453cd46896e2ea80698b803 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 09:33:21 +0100 Subject: [PATCH 23/31] Add support for removing the children of a node Sometimes the user may wish to delete a given node, other times it might be a requirement to just remove the children (this will be especially useful for #2448 where we'll want to keep the directory entry itself, but remove and recreate its contents). See #2462. --- src/textual/widgets/_tree.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 1d399fd52..0f3998914 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -352,14 +352,24 @@ class TreeNode(Generic[TreeDataType]): class RemoveRootError(Exception): """Exception raised when trying to remove a tree's root node.""" + def _remove_children(self) -> None: + """Remove child nodes of this node. + + Note: + This is the internal support method for `remove_children`. Call + `remove_children` to ensure the tree gets refreshed. + """ + for child in reversed(self._children): + child._remove() + def _remove(self) -> None: """Remove the current node and all its children. Note: - This is the internal support method for `remove`. + This is the internal support method for `remove`. Call `remove` + to ensure the tree gets refreshed. """ - for child in reversed(self._children): - child._remove() + self._remove_children() assert self._parent is not None del self._parent._children[self._parent._children.index(self)] del self._tree._tree_nodes[self.id] @@ -375,6 +385,11 @@ class TreeNode(Generic[TreeDataType]): self._remove() self._tree._invalidate() + def remove_children(self) -> None: + """Remove any child nodes of this node.""" + self._remove_children() + self._tree._invalidate() + class Tree(Generic[TreeDataType], ScrollView, can_focus=True): """A widget for displaying and navigating data in a tree.""" From 745d595d4ac8336ae74e558c9a4fd375e3edbdcb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 09:55:32 +0100 Subject: [PATCH 24/31] Add unit tests for the new node removal methods See #2462. --- tests/tree/test_tree_clearing.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/tree/test_tree_clearing.py b/tests/tree/test_tree_clearing.py index 87543c4c8..bd868ee6d 100644 --- a/tests/tree/test_tree_clearing.py +++ b/tests/tree/test_tree_clearing.py @@ -1,7 +1,10 @@ from __future__ import annotations +import pytest + from textual.app import App, ComposeResult from textual.widgets import Tree +from textual.widgets.tree import TreeNode class VerseBody: @@ -71,3 +74,37 @@ async def test_tree_reset_with_label_and_data() -> None: assert len(tree.root.children) == 0 assert str(tree.root.label) == "Jiangyin" assert isinstance(tree.root.data, VersePlanet) + + +async def test_remove_node(): + async with TreeClearApp().run_test() as pilot: + tree = pilot.app.query_one(VerseTree) + assert len(tree.root.children) == 2 + tree.root.children[0].remove() + assert len(tree.root.children) == 1 + + +async def test_remove_node_children(): + async with TreeClearApp().run_test() as pilot: + tree = pilot.app.query_one(VerseTree) + assert len(tree.root.children) == 2 + assert len(tree.root.children[0].children) == 2 + tree.root.children[0].remove_children() + assert len(tree.root.children) == 2 + assert len(tree.root.children[0].children) == 0 + + +async def test_tree_remove_children_of_root(): + """Test removing the children of the root.""" + async with TreeClearApp().run_test() as pilot: + tree = pilot.app.query_one(VerseTree) + assert len(tree.root.children) > 1 + tree.root.remove_children() + assert len(tree.root.children) == 0 + + +async def test_attempt_to_remove_root(): + """Attempting to remove the root should be an error.""" + async with TreeClearApp().run_test() as pilot: + with pytest.raises(TreeNode.RemoveRootError): + pilot.app.query_one(VerseTree).root.remove() From 6bb2b2109130abeb6838ef9120801ac8123a0757 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 10:17:36 +0100 Subject: [PATCH 25/31] Update the CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53070b80f..9c46cfc77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` +### Added + +- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510 +- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510 + ## [0.23.0] - 2023-05-03 ### Fixed From c9d7bb84e2259031d1642bfc8a1d112197dd7ab6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 8 May 2023 10:18:34 +0100 Subject: [PATCH 26/31] Update the CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c46cfc77..a81f64523 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510 From 819b2f1eb34d6ac7c5f8c72417766c70be2b803c 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, 8 May 2023 10:44:34 +0100 Subject: [PATCH 27/31] Add `DataTable.move_cursor` (#2479) * Add 'DataTable.move_cursor'. Related issues: #2472. * Fix #2471. * Simplify cursor changes. * Address review feedback. Related comments: https://github.com/Textualize/textual/pull/2479\#discussion_r1185016002 --- CHANGELOG.md | 5 ++++ src/textual/widgets/_data_table.py | 47 +++++++++++++++++++++++++----- tests/test_data_table.py | 35 ++++++++++++++-------- 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53070b80f..69042e4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` + +### Added + +- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472 + ## [0.23.0] - 2023-05-03 ### Fixed diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index bbe07ebc6..0f40552be 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -311,6 +311,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_coordinate: Reactive[Coordinate] = Reactive( Coordinate(0, 0), repaint=False, always_update=True ) + """Current cursor [`Coordinate`][textual.coordinate.Coordinate]. + + This can be set programmatically or changed via the method + [`move_cursor`][textual.widgets.DataTable.move_cursor]. + """ hover_coordinate: Reactive[Coordinate] = Reactive( Coordinate(0, 0), repaint=False, always_update=True ) @@ -953,7 +958,41 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): elif self.cursor_type == "column": self.refresh_column(old_coordinate.column) self._highlight_column(new_coordinate.column) - self._scroll_cursor_into_view() + # If the coordinate was changed via `move_cursor`, give priority to its + # scrolling because it may be animated. + self.call_next(self._scroll_cursor_into_view) + + def move_cursor( + self, + *, + row: int | None = None, + column: int | None = None, + animate: bool = False, + ) -> None: + """Move the cursor to the given position. + + Example: + ```py + datatable = app.query_one(DataTable) + datatable.move_cursor(row=4, column=6) + # datatable.cursor_coordinate == Coordinate(4, 6) + datatable.move_cursor(row=3) + # datatable.cursor_coordinate == Coordinate(3, 6) + ``` + + Args: + row: The new row to move the cursor to. + column: The new column to move the cursor to. + animate: Whether to animate the change of coordinates. + """ + cursor_row, cursor_column = self.cursor_coordinate + if row is not None: + cursor_row = row + if column is not None: + cursor_column = column + destination = Coordinate(cursor_row, cursor_column) + self.cursor_coordinate = destination + self._scroll_cursor_into_view(animate=animate) def _highlight_coordinate(self, coordinate: Coordinate) -> None: """Apply highlighting to the cell at the coordinate, and post event.""" @@ -2055,7 +2094,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.cursor_coordinate = Coordinate( row_index + rows_to_scroll - 1, column_index ) - self._scroll_cursor_into_view() else: super().action_page_down() @@ -2079,7 +2117,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self.cursor_coordinate = Coordinate( row_index - rows_to_scroll + 1, column_index ) - self._scroll_cursor_into_view() else: super().action_page_up() @@ -2090,7 +2127,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): row_index, column_index = self.cursor_coordinate self.cursor_coordinate = Coordinate(0, column_index) - self._scroll_cursor_into_view() else: super().action_scroll_home() @@ -2101,7 +2137,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): row_index, column_index = self.cursor_coordinate self.cursor_coordinate = Coordinate(self.row_count - 1, column_index) - self._scroll_cursor_into_view() else: super().action_scroll_end() @@ -2110,7 +2145,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): self.cursor_coordinate = self.cursor_coordinate.up() - self._scroll_cursor_into_view() else: # If the cursor doesn't move up (e.g. column cursor can't go up), # then ensure that we instead scroll the DataTable. @@ -2121,7 +2155,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cursor_type = self.cursor_type if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"): self.cursor_coordinate = self.cursor_coordinate.down() - self._scroll_cursor_into_view() else: super().action_scroll_down() diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 0bba677cf..a00da77af 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -996,23 +996,34 @@ def test_key_string_lookup(): async def test_scrolling_cursor_into_view(): """Regression test for https://github.com/Textualize/textual/issues/2459""" - class TableApp(App): + class ScrollingApp(DataTableApp): CSS = "DataTable { height: 100%; }" - def compose(self): - yield DataTable() - - def on_mount(self) -> None: - table = self.query_one(DataTable) - table.add_column("n") - table.add_rows([(n,) for n in range(300)]) - def key_c(self): self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0) - app = TableApp() + app = ScrollingApp() async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_column("n") + table.add_rows([(n,) for n in range(300)]) + await pilot.press("c") - await pilot.pause() - assert app.query_one(DataTable).scroll_y > 100 + assert table.scroll_y > 100 + + +async def test_move_cursor(): + app = DataTableApp() + + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns(*"These are some columns in your nice table".split()) + table.add_rows(["These are some columns in your nice table".split()] * 10) + + table.move_cursor(row=4, column=6) + assert table.cursor_coordinate == Coordinate(4, 6) + table.move_cursor(row=3) + assert table.cursor_coordinate == Coordinate(3, 6) + table.move_cursor(column=3) + assert table.cursor_coordinate == Coordinate(3, 3) From 483aa54bd6ca0c1a5eb3ef59b025576ed153babc Mon Sep 17 00:00:00 2001 From: Willi Ballenthin Date: Mon, 8 May 2023 11:45:56 +0200 Subject: [PATCH 28/31] Catch OSError when source code is not available (#2469) * Catch OSError when source code is not available * update changelog with reference to fixed bug --- CHANGELOG.md | 1 + src/textual/app.py | 2 +- src/textual/dom.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69042e4a8..6e2d125b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `!important` not applying to `overflow` https://github.com/Textualize/textual/issues/2420 - Fixed `!important` not applying to `scrollbar-size` https://github.com/Textualize/textual/issues/2420 - Fixed `outline-right` not being recognised https://github.com/Textualize/textual/issues/2446 +- Fixed OSError when a file system is not available https://github.com/Textualize/textual/issues/2468 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index e2199a47f..113a7a220 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1660,7 +1660,7 @@ class App(Generic[ReturnType], DOMNode): app_css_path = ( f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}" ) - except TypeError: + except (TypeError, OSError): app_css_path = f"{self.__class__.__name__}" self.stylesheet.add_source( self.CSS, path=app_css_path, is_default_css=False diff --git a/src/textual/dom.py b/src/textual/dom.py index 1c8512f11..ef73557f3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -418,7 +418,7 @@ class DOMNode(MessagePump): """Get a path to the DOM Node""" try: return f"{getfile(base)}:{base.__name__}" - except TypeError: + except (TypeError, OSError): return f"{base.__name__}" for tie_breaker, base in enumerate(self._node_bases): From 1e2f632fc6f1a429d583b16d65f83661b46b6d74 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, 8 May 2023 10:47:15 +0100 Subject: [PATCH 29/31] Fix TabbedContent __init__ signature. (#2497) --- CHANGELOG.md | 1 + src/textual/widgets/_tabbed_content.py | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e2d125b2..0a53ea7bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459 - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` +- `TabbedContent` now takes kwargs `id`, `name`, `classes`, and `disabled`, upon initialization, like other widgets https://github.com/Textualize/textual/pull/2497 ### Added diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index e842f56ce..c16c3d3b8 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -103,17 +103,29 @@ class TabbedContent(Widget): yield self.tabbed_content yield self.tab - def __init__(self, *titles: TextType, initial: str = "") -> None: + def __init__( + self, + *titles: TextType, + initial: str = "", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): """Initialize a TabbedContent widgets. Args: *titles: Positional argument will be used as title. initial: The id of the initial tab, or empty string to select the first tab. + name: The name of the button. + id: The ID of the button in the DOM. + classes: The CSS classes of the button. + disabled: Whether the button is disabled or not. """ self.titles = [self.render_str(title) for title in titles] self._tab_content: list[Widget] = [] self._initial = initial - super().__init__() + super().__init__(name=name, id=id, classes=classes, disabled=disabled) def validate_active(self, active: str) -> str: """It doesn't make sense for `active` to be an empty string. From 7db7139bb8aa4edb698d05f3cd878e4b9ec44238 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 May 2023 10:55:39 +0100 Subject: [PATCH 30/31] Select widget (#2501) * overlay rule * select WIP * select control, made binding description optional * changelog * style tweak * Added constrain * changelog * test fix * drop markup, tidy * tidy * select namespace * tests * docs * Added changed event * changelog * expanded * tests and snapshits * examples and docs * simplify * update reactive attributes * type fix * docstrings * allow renderables * superfluous init * typing fix * optimization * revert optimizations * fixed words * changelog * docstrings * don't need this * changelog * comment * Update docs/widgets/select.md Co-authored-by: Dave Pearson * review changes * review updates --------- Co-authored-by: Dave Pearson --- CHANGELOG.md | 21 +- docs/examples/widgets/select.css | 11 + docs/examples/widgets/select_widget.py | 26 + docs/widget_gallery.md | 10 + docs/widgets/select.md | 90 + mkdocs-nav.yml | 1 + src/textual/_arrange.py | 11 +- src/textual/_compositor.py | 29 +- src/textual/_layout.py | 2 + src/textual/_spatial_map.py | 7 +- src/textual/binding.py | 12 +- src/textual/css/_styles_builder.py | 26 + src/textual/css/constants.py | 3 +- src/textual/css/query.py | 5 +- src/textual/css/styles.py | 19 +- src/textual/css/types.py | 2 + src/textual/dom.py | 1 + src/textual/geometry.py | 38 + src/textual/layouts/horizontal.py | 8 +- src/textual/layouts/vertical.py | 17 +- src/textual/message.py | 6 +- src/textual/message_pump.py | 1 + src/textual/widget.py | 19 +- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_option_list.py | 18 +- src/textual/widgets/_select.py | 378 ++ .../__snapshots__/test_snapshots.ambr | 5494 +++++++++-------- tests/snapshot_tests/test_snapshots.py | 17 + tests/test_binding.py | 2 +- tests/test_geometry.py | 12 + tests/test_spatial_map.py | 6 +- 32 files changed, 3731 insertions(+), 2564 deletions(-) create mode 100644 docs/examples/widgets/select.css create mode 100644 docs/examples/widgets/select_widget.py create mode 100644 docs/widgets/select.md create mode 100644 src/textual/widgets/_select.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e3a58db0..c27597851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,23 +18,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459 - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` + +- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501 +- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501 + +### Added + +- Experimental: Added "overlay" rule https://github.com/Textualize/textual/pull/2501 +- Experimental: Added "constrain" rule https://github.com/Textualize/textual/pull/2501 +- Added textual.widgets.Select https://github.com/Textualize/textual/pull/2501 +- Added Region.translate_inside https://github.com/Textualize/textual/pull/2501 - `TabbedContent` now takes kwargs `id`, `name`, `classes`, and `disabled`, upon initialization, like other widgets https://github.com/Textualize/textual/pull/2497 - - -### Added - - Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472 - -### Added - - Added `OptionList.add_options` https://github.com/Textualize/textual/pull/2508 - -### Added - - Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510 + ## [0.23.0] - 2023-05-03 ### Fixed diff --git a/docs/examples/widgets/select.css b/docs/examples/widgets/select.css new file mode 100644 index 000000000..2be424430 --- /dev/null +++ b/docs/examples/widgets/select.css @@ -0,0 +1,11 @@ +Screen { + align: center top; +} + +Select { + width: 60; + margin: 2; +} +Input { + width: 60; +} diff --git a/docs/examples/widgets/select_widget.py b/docs/examples/widgets/select_widget.py new file mode 100644 index 000000000..73b02c25b --- /dev/null +++ b/docs/examples/widgets/select_widget.py @@ -0,0 +1,26 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Header, Select + +LINES = """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.""".splitlines() + + +class SelectApp(App): + CSS_PATH = "select.css" + + def compose(self) -> ComposeResult: + yield Header() + yield Select((line, line) for line in LINES) + + @on(Select.Changed) + def select_changed(self, event: Select.Changed) -> None: + self.title = str(event.value) + + +if __name__ == "__main__": + app = SelectApp() + app.run() diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index fe4ea065d..04e6fada2 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -188,6 +188,16 @@ A collection of radio buttons, that enforces uniqueness. ```{.textual path="docs/examples/widgets/radio_set.py"} ``` +## Select + +Select from a number of possible options. + +[Select reference](./widgets/select.md){ .md-button .md-button--primary } + +```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"} +``` + + ## Static Displays simple static content. Typically used as a base class. diff --git a/docs/widgets/select.md b/docs/widgets/select.md new file mode 100644 index 000000000..31a36989c --- /dev/null +++ b/docs/widgets/select.md @@ -0,0 +1,90 @@ +# Select + +!!! tip "Added in version 0.24.0" + +A Select widget is a compact control to allow the user to select between a number of possible options. + + +- [X] Focusable +- [ ] Container + + +The options in a select control may be passed in to the constructor or set later with [set_options][textual.widgets.Select.set_options]. +Options should be given as a sequence of tuples consisting of two values: the first is the string (or [Rich Renderable](https://rich.readthedocs.io/en/latest/protocol.html)) to display in the control and list of options, the second is the value of option. + +The value of the currently selected option is stored in the `value` attribute of the widget, and the `value` attribute of the [Changed][textual.widgets.Select.Changed] message. + + +## Typing + +The `Select` control is a typing Generic which allows you to set the type of the option values. +For instance, if the data type for your values is an integer, you would type the widget as follows: + +```python +options = [("First", 1), ("Second", 2)] +my_select: Select[int] = Select(options) +``` + +!!! note + + Typing is entirely optional. + + If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it. + +## Example + +The following example presents a `Select` with a number of options. + +=== "Output" + + ```{.textual path="docs/examples/widgets/select_widget.py"} + ``` + +=== "Output (expanded)" + + ```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"} + ``` + + +=== "select_widget.py" + + ```python + --8<-- "docs/examples/widgets/select_widget.py" + ``` + +=== "select.css" + + ```sass + --8<-- "docs/examples/widgets/select.css" + ``` + +## Messages + +- [Select.Changed][textual.widgets.Select.Changed] + + +## Reactive attributes + + +| Name | Type | Default | Description | +| ---------- | -------------------- | ------- | ----------------------------------- | +| `expanded` | `bool` | `False` | True to expand the options overlay. | +| `value` | `SelectType \| None` | `None` | Current value of the Select. | + + +## Bindings + +The Select widget defines the following bindings: + +::: textual.widgets.Select.BINDINGS + options: + show_root_heading: false + show_root_toc_entry: false + + +--- + + +::: textual.widgets.Select + options: + heading_level: 2 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 4a0fc2cc4..6688f3ca3 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -149,6 +149,7 @@ nav: - "widgets/progress_bar.md" - "widgets/radiobutton.md" - "widgets/radioset.md" + - "widgets/select.md" - "widgets/static.md" - "widgets/switch.md" - "widgets/tabbed_content.md" diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index f1035688e..b415d8983 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -35,7 +35,7 @@ def arrange( dock_layers: defaultdict[str, list[Widget]] = defaultdict(list) for child in children: if child.display: - dock_layers[child.styles.layer or "default"].append(child) + dock_layers[child.layer].append(child) width, height = size @@ -121,9 +121,14 @@ def arrange( if placement_offset: layout_placements = [ _WidgetPlacement( - _region + placement_offset, margin, layout_widget, order, fixed + _region + placement_offset, + margin, + layout_widget, + order, + fixed, + overlay, ) - for _region, margin, layout_widget, order, fixed in layout_placements + for _region, margin, layout_widget, order, fixed, overlay in layout_placements ] placements.extend(layout_placements) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1ec7f5c2a..61e4f3de8 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -512,6 +512,8 @@ class Compositor: add_new_widget = widgets.add layer_order: int = 0 + no_clip = size.region + def add_widget( widget: Widget, virtual_region: Region, @@ -586,12 +588,13 @@ class Compositor: layers_to_index = { layer_name: index for index, layer_name in enumerate(_layers) } + 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 in reversed( + for sub_region, margin, sub_widget, z, fixed, overlay in reversed( placements ): layer_index = get_layer_index(sub_widget.layer, 0) @@ -608,13 +611,23 @@ class Compositor: widget_order = order + ((layer_index, z, layer_order),) + if overlay: + constrain = sub_widget.styles.constrain + if constrain != "none": + # Constrain to avoid clipping + widget_region = widget_region.translate_inside( + no_clip, + constrain in ("x", "both"), + constrain in ("y", "both"), + ) + add_widget( sub_widget, sub_region, widget_region, - widget_order, + ((1, 0, 0),) if overlay else widget_order, layer_order, - sub_clip, + no_clip if overlay else sub_clip, visible, ) @@ -991,13 +1004,9 @@ class Compositor: first_cut, last_cut = render_region.column_span final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)] - if len(final_cuts) <= 2: - # Two cuts, which means the entire line - cut_strips = [strip] - else: - render_x = render_region.x - relative_cuts = [cut - render_x for cut in final_cuts[1:]] - cut_strips = strip.divide(relative_cuts) + render_x = render_region.x + relative_cuts = [cut - render_x for cut in final_cuts[1:]] + cut_strips = strip.divide(relative_cuts) # Since we are painting front to back, the first segments for a cut "wins" get_chops_line = chops_line.get diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 9054435bf..e0061c148 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -36,6 +36,7 @@ class DockArrangeResult: ( placement.region.grow(placement.margin), placement.fixed, + placement.overlay, placement, ) for placement in self.placements @@ -73,6 +74,7 @@ class WidgetPlacement(NamedTuple): widget: Widget order: int = 0 fixed: bool = False + overlay: bool = False class Layout(ABC): diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 48b96ab39..af38065dd 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -57,7 +57,7 @@ class SpatialMap(Generic[ValueType]): ) def insert( - self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] + self, regions_and_values: Iterable[tuple[Region, bool, bool, ValueType]] ) -> None: """Insert values into the Spatial map. @@ -71,8 +71,9 @@ class SpatialMap(Generic[ValueType]): get_grid_list = self._map.__getitem__ _region_to_grid = self._region_to_grid_coordinates total_region = self.total_region - for region, fixed, value in regions_and_values: - total_region = total_region.union(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: diff --git a/src/textual/binding.py b/src/textual/binding.py index 36498fd20..87f2c608b 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -18,7 +18,7 @@ from .keys import _character_to_key if TYPE_CHECKING: from typing_extensions import TypeAlias -BindingType: TypeAlias = "Binding | tuple[str, str, str]" +BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]" class BindingError(Exception): @@ -41,7 +41,7 @@ class Binding: """Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" action: str """Action to bind to.""" - description: str + description: str = "" """Description of action.""" show: bool = True """Show the action in Footer, or False to hide.""" @@ -74,9 +74,9 @@ class _Bindings: for binding in bindings: # If it's a tuple of length 3, convert into a Binding first if isinstance(binding, tuple): - if len(binding) != 3: + if len(binding) not in (2, 3): raise BindingError( - f"BINDINGS must contain a tuple of three strings, not {binding!r}" + f"BINDINGS must contain a tuple of two or three strings, not {binding!r}" ) binding = Binding(*binding) @@ -95,7 +95,7 @@ class _Bindings: key=key, action=binding.action, description=binding.description, - show=binding.show, + show=bool(binding.description and binding.show), key_display=binding.key_display, priority=binding.priority, ) @@ -165,7 +165,7 @@ class _Bindings: key, action, description, - show=show, + show=bool(description and show), key_display=key_display, priority=priority, ) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index f38506311..d5d8048cc 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -39,9 +39,11 @@ from .constants import ( VALID_ALIGN_VERTICAL, VALID_BORDER, VALID_BOX_SIZING, + VALID_CONSTRAIN, VALID_DISPLAY, VALID_EDGE, VALID_OVERFLOW, + VALID_OVERLAY, VALID_SCROLLBAR_GUTTER, VALID_STYLE_FLAGS, VALID_TEXT_ALIGN, @@ -1003,6 +1005,30 @@ class StylesBuilder: else: self.error(name, tokens[0], "expected two integers here") + def process_overlay(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_OVERLAY) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_OVERLAY, context="css"), + ) + else: + self.styles._rules[name] = value # type: ignore + + def process_constrain(self, name: str, tokens: list[Token]) -> None: + try: + value = self._process_enum(name, tokens, VALID_CONSTRAIN) + except StyleValueError: + self.error( + name, + tokens[0], + string_enum_help_text(name, VALID_CONSTRAIN, context="css"), + ) + else: + self.styles._rules[name] = value # type: ignore + def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None: """ Returns a valid CSS property "Python" name, or None if no close matches could be found. diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 1c7fa9f51..4cfa9580b 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -69,6 +69,7 @@ VALID_PSEUDO_CLASSES: Final = { "focus", "hover", } - +VALID_OVERLAY: Final = {"none", "screen"} +VALID_CONSTRAIN: Final = {"x", "y", "both", "none"} NULL_SPACING: Final = Spacing.all(0) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index e63177886..ce966d6b1 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -153,9 +153,8 @@ class DOMQuery(Generic[QueryType]): return self.nodes[index] def __rich_repr__(self) -> rich.repr.Result: - yield self.node if self._filters: - yield "filter", " AND ".join( + yield "query", " AND ".join( ",".join(selector.css for selector in selectors) for selectors in self._filters ) @@ -214,6 +213,7 @@ class DOMQuery(Generic[QueryType]): Returns: The matching Widget. """ + _rich_traceback_omit = True if self.nodes: first = self.nodes[0] if expect_type is not None: @@ -250,6 +250,7 @@ class DOMQuery(Generic[QueryType]): Returns: The matching Widget. """ + _rich_traceback_omit = True # Call on first to get the first item. Here we'll use all of the # testing and checking it provides. the_one = self.first(expect_type) if expect_type is not None else self.first() diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6eef8b1f1..dc73265da 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -39,8 +39,10 @@ from .constants import ( VALID_ALIGN_HORIZONTAL, VALID_ALIGN_VERTICAL, VALID_BOX_SIZING, + VALID_CONSTRAIN, VALID_DISPLAY, VALID_OVERFLOW, + VALID_OVERLAY, VALID_SCROLLBAR_GUTTER, VALID_TEXT_ALIGN, VALID_VISIBILITY, @@ -52,9 +54,11 @@ from .types import ( AlignHorizontal, AlignVertical, BoxSizing, + Constrain, Display, Edge, Overflow, + Overlay, ScrollbarGutter, Specificity3, Specificity6, @@ -177,6 +181,9 @@ class RulesMap(TypedDict, total=False): border_subtitle_background: Color border_subtitle_style: Style + overlay: Overlay + constrain: Constrain + RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) @@ -223,7 +230,7 @@ class StylesBase(ABC): node: DOMNode | None = None display = StringEnumProperty( - VALID_DISPLAY, "block", layout=True, refresh_parent=True + VALID_DISPLAY, "block", layout=True, refresh_parent=True, refresh_children=True ) visibility = StringEnumProperty( VALID_VISIBILITY, "visible", layout=True, refresh_parent=True @@ -341,6 +348,11 @@ class StylesBase(ABC): border_subtitle_background = ColorProperty(Color(0, 0, 0, 0)) border_subtitle_style = StyleFlagsProperty() + overlay = StringEnumProperty( + VALID_OVERLAY, "none", layout=True, refresh_parent=True + ) + constrain = StringEnumProperty(VALID_CONSTRAIN, "none") + def __textual_animation__( self, attribute: str, @@ -1024,7 +1036,10 @@ class Styles(StylesBase): ) if "border_subtitle_text_style" in rules: append_declaration("subtitle-text-style", str(self.border_subtitle_style)) - + if "overlay" in rules: + append_declaration("overlay", str(self.overlay)) + if "constrain" in rules: + append_declaration("constrain", str(self.constrain)) lines.sort() return lines diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 395b549e0..c723e0b5f 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -37,6 +37,8 @@ BoxSizing = Literal["border-box", "content-box"] Overflow = Literal["scroll", "hidden", "auto"] EdgeStyle = Tuple[EdgeType, Color] TextAlign = Literal["left", "start", "center", "right", "end", "justify"] +Constrain = Literal["none", "x", "y", "both"] +Overlay = Literal["none", "screen"] Specificity3 = Tuple[int, int, int] Specificity6 = Tuple[int, int, int, int, int, int] diff --git a/src/textual/dom.py b/src/textual/dom.py index ef73557f3..e136a5588 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1058,6 +1058,7 @@ class DOMNode(MessagePump): Returns: A widget matching the selector. """ + _rich_traceback_omit = True from .css.query import DOMQuery if isinstance(selector, str): diff --git a/src/textual/geometry.py b/src/textual/geometry.py index d111cb4aa..706e264f5 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -867,6 +867,44 @@ class Region(NamedTuple): Region(x, y + cut, width, height - cut), ) + def translate_inside( + self, container: Region, x_axis: bool = True, y_axis: bool = True + ) -> Region: + """Translate this region, so it fits within a container. + + This will ensure that there is as little overlap as possible. + The top left of the returned region is guaranteed to be within the container. + + ``` + ┌──────────────────┐ ┌──────────────────┐ + │ container │ │ container │ + │ │ │ ┌─────────────┤ + │ │ ──▶ │ │ return │ + │ ┌──────────┴──┐ │ │ │ + │ │ self │ │ │ │ + └───────┤ │ └────┴─────────────┘ + │ │ + └─────────────┘ + ``` + + + Args: + container: A container region. + x_axis: Allow translation of X axis. + y_axis: Allow translation of Y axis. + + Returns: + A new region with same dimensions that fits with inside container. + """ + x1, y1, width1, height1 = container + x2, y2, width2, height2 = self + return Region( + max(min(x2, x1 + width1 - width2), x1) if x_axis else x2, + max(min(y2, y1 + height1 - height2), y1) if y_axis else y2, + width2, + height2, + ) + class Spacing(NamedTuple): """The spacing around a renderable, such as padding and border diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 5b5c8fdae..d2fb3146e 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -70,6 +70,7 @@ class HorizontalLayout(Layout): _Region = Region _WidgetPlacement = WidgetPlacement for widget, box_model, margin in zip(children, box_models, margins): + overlay = widget.styles.overlay == "screen" content_width, content_height, box_margin = box_model offset_y = box_margin.top next_x = x + content_width @@ -79,7 +80,10 @@ class HorizontalLayout(Layout): max_height = max( max_height, content_height + offset_y + box_model.margin.bottom ) - add_placement(_WidgetPlacement(region, box_model.margin, widget, 0)) - x = next_x + margin + add_placement( + _WidgetPlacement(region, box_model.margin, widget, 0, False, overlay) + ) + if not overlay: + x = next_x + margin return placements, set(displayed_children) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 148c1854f..0001efdb6 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -64,13 +64,26 @@ class VerticalLayout(Layout): _Region = Region _WidgetPlacement = WidgetPlacement + for widget, box_model, margin in zip(children, box_models, margins): + overlay = widget.styles.overlay == "screen" content_width, content_height, box_margin = box_model next_y = y + content_height + region = _Region( box_margin.left, int(y), int(content_width), int(next_y) - int(y) ) - add_placement(_WidgetPlacement(region, box_model.margin, widget, 0)) - y = next_y + margin + add_placement( + _WidgetPlacement( + region, + box_model.margin, + widget, + 0, + False, + overlay, + ) + ) + if not overlay: + y = next_y + margin return placements, set(children) diff --git a/src/textual/message.py b/src/textual/message.py index 2a706b448..cdb731ad1 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -38,6 +38,10 @@ class Message: namespace: ClassVar[str] = "" # Namespace to disambiguate messages def __init__(self) -> None: + self.__post_init__() + + def __post_init__(self) -> None: + """Allow dataclasses to initialize the object.""" self._sender: MessageTarget | None = active_message_pump.get(None) self.time: float = _time.get_time() self._forwarded = False @@ -48,7 +52,6 @@ class Message: f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}" ) self._prevent: set[type[Message]] = set() - super().__init__() def __rich_repr__(self) -> rich.repr.Result: yield from () @@ -71,6 +74,7 @@ class Message: @property def is_forwarded(self) -> bool: + """Has the message been forwarded?""" return self._forwarded @property diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 18578606a..1466f1161 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -648,6 +648,7 @@ class MessagePump(metaclass=_MessagePumpMeta): Returns: `True` if the messages was processed, `False` if it wasn't. """ + _rich_traceback_omit = True if self._closing or self._closed: return False if not self.check_message_enabled(message): diff --git a/src/textual/widget.py b/src/textual/widget.py index b4e9e506e..4fc364ebf 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1569,12 +1569,13 @@ class Widget(DOMNode): Returns: Tuple of layer names. """ + layers: tuple[str, ...] = ("default",) for node in self.ancestors_with_self: if not isinstance(node, Widget): break if node.styles.has_rule("layers"): - return node.styles.layers - return ("default",) + layers = node.styles.layers + return layers @property def link_style(self) -> Style: @@ -1637,8 +1638,11 @@ class Widget(DOMNode): self._dirty_regions.clear() self._repaint_regions.clear() self._styles_cache.clear() - self._dirty_regions.add(self.outer_size.region) - self._repaint_regions.add(self.outer_size.region) + + outer_size = self.outer_size + self._dirty_regions.add(outer_size.region) + if outer_size: + self._repaint_regions.add(outer_size.region) def _exchange_repaint_regions(self) -> Collection[Region]: """Get a copy of the regions which need a repaint, and clear internal cache. @@ -2921,6 +2925,13 @@ class Widget(DOMNode): Returns: True if the message was posted, False if this widget was closed / closing. """ + _rich_traceback_omit = True + # Catch a common error. + # This will error anyway, but at least we can offer a helpful message here. + if not hasattr(message, "_prevent"): + raise RuntimeError( + f"{type(message)!r} is missing expected attributes; did you forget to call super().__init__() in the constructor?" + ) if constants.DEBUG and not self.is_running and not message.no_dispatch: try: diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index ee852564a..04e00e53b 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -29,6 +29,7 @@ if typing.TYPE_CHECKING: from ._progress_bar import ProgressBar from ._radio_button import RadioButton from ._radio_set import RadioSet + from ._select import Select from ._static import Static from ._switch import Switch from ._tabbed_content import TabbedContent, TabPane @@ -59,6 +60,7 @@ __all__ = [ "ProgressBar", "RadioButton", "RadioSet", + "Select", "Static", "Switch", "Tab", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 9ceffb742..27e7f4ed1 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -19,6 +19,7 @@ from ._pretty import Pretty as Pretty from ._progress_bar import ProgressBar as ProgressBar from ._radio_button import RadioButton as RadioButton from ._radio_set import RadioSet as RadioSet +from ._select import Select as Select from ._static import Static as Static from ._switch import Switch as Switch from ._tabbed_content import TabbedContent as TabbedContent diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index dba669592..193c0385f 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -10,6 +10,7 @@ from __future__ import annotations from typing import ClassVar, Iterable, NamedTuple from rich.console import RenderableType +from rich.padding import Padding from rich.repr import Result from rich.rule import Rule from rich.style import Style @@ -146,6 +147,7 @@ class OptionList(ScrollView, can_focus=True): """ COMPONENT_CLASSES: ClassVar[set[str]] = { + "option-list--option", "option-list--option-disabled", "option-list--option-highlighted", "option-list--option-highlighted-disabled", @@ -474,6 +476,7 @@ class OptionList(ScrollView, can_focus=True): # also set up the tracking of the actual options. line = 0 option = 0 + padding = self.get_component_styles("option-list--option").padding for content in self._contents: if isinstance(content, Option): # The content is an option, so render out the prompt and @@ -483,7 +486,10 @@ class OptionList(ScrollView, can_focus=True): Strip(prompt_line).apply_style(Style(meta={"option": option})), option, ) - for prompt_line in lines_from(content.prompt, options) + for prompt_line in lines_from( + Padding(content.prompt, padding) if padding else content.prompt, + options, + ) ] # Record the span information for the option. add_span(OptionLineSpan(line, len(new_lines))) @@ -838,8 +844,13 @@ class OptionList(ScrollView, can_focus=True): # It's a normal option line. return strip.apply_style(self.rich_style) - def scroll_to_highlight(self) -> None: - """Ensure that the highlighted option is in view.""" + def scroll_to_highlight(self, top: bool = False) -> None: + """Ensure that the highlighted option is in view. + + Args: + top: Scroll highlight to top of the list. + + """ highlighted = self.highlighted if highlighted is None: return @@ -856,6 +867,7 @@ class OptionList(ScrollView, can_focus=True): ), force=True, animate=False, + top=top, ) def validate_highlighted(self, highlighted: int | None) -> int | None: diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py new file mode 100644 index 000000000..25564af95 --- /dev/null +++ b/src/textual/widgets/_select.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar + +from rich.console import RenderableType +from rich.text import Text + +from .. import events, on +from ..app import ComposeResult +from ..containers import Horizontal, Vertical +from ..message import Message +from ..reactive import var +from ..widgets import Static +from ._option_list import Option, OptionList + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + + +class SelectOverlay(OptionList): + """The 'pop-up' overlay for the Select control.""" + + BINDINGS = [("escape", "dismiss")] + + DEFAULT_CSS = """ + SelectOverlay { + border: tall $background; + background: $panel; + color: $text; + width: 100%; + padding: 0 1; + } + SelectOverlay > .option-list--option { + padding: 0 1; + } + """ + + @dataclass + class Dismiss(Message): + """Inform ancestor the overlay should be dismissed.""" + + lost_focus: bool = False + """True if the overlay lost focus.""" + + @dataclass + class UpdateSelection(Message): + """Inform ancestor the selection was changed.""" + + option_index: int + """The index of the new selection.""" + + def select(self, index: int | None) -> None: + """Move selection. + + Args: + index: Index of new selection. + """ + self.highlighted = index + self.scroll_to_highlight(top=True) + + def action_dismiss(self) -> None: + """Dismiss the overlay.""" + self.post_message(self.Dismiss()) + + def _on_blur(self, _event: events.Blur) -> None: + """On blur we want to dismiss the overlay.""" + self.post_message(self.Dismiss(lost_focus=True)) + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Inform parent when an option is selected.""" + event.stop() + self.post_message(self.UpdateSelection(event.option_index)) + + +class SelectCurrent(Horizontal): + """Displays the currently selected option.""" + + DEFAULT_CSS = """ + SelectCurrent { + border: tall $background; + background: $boost; + color: $text; + width: 100%; + height: auto; + padding: 0 2; + } + SelectCurrent Static#label { + width: 1fr; + height: auto; + color: $text-disabled; + background: transparent; + } + SelectCurrent.-has-value Static#label { + color: $text; + } + SelectCurrent .arrow { + box-sizing: content-box; + width: 1; + height: 1; + padding: 0 0 0 1; + color: $text-muted; + background: transparent; + } + SelectCurrent .arrow { + box-sizing: content-box; + width: 1; + height: 1; + padding: 0 0 0 1; + color: $text-muted; + background: transparent; + } + """ + + has_value: var[bool] = var(False) + """True if there is a current value, or False if it is None.""" + + class Toggle(Message): + """Request toggle overlay.""" + + def __init__(self, placeholder: str) -> None: + """Initialize the SelectCurrent. + + Args: + placeholder: A string to display when there is nothing selected. + """ + super().__init__() + self.placeholder = placeholder + self.label: RenderableType | None = None + + def update(self, label: RenderableType | None) -> None: + """Update the content in the widget. + + Args: + label: A renderable to display, or `None` for the placeholder. + """ + self.label = label + self.has_value = label is not None + self.query_one("#label", Static).update( + self.placeholder if label is None else label + ) + + def compose(self) -> ComposeResult: + """Compose label and down arrow.""" + yield Static(self.placeholder, id="label") + yield Static("▼", classes="arrow down-arrow") + yield Static("▲", classes="arrow up-arrow") + + def _watch_has_value(self, has_value: bool) -> None: + """Toggle the class.""" + self.set_class(has_value, "-has-value") + + async def _on_click(self, event: events.Click) -> None: + """Inform ancestor we want to toggle.""" + self.post_message(self.Toggle()) + + +SelectType = TypeVar("SelectType") +"""The type used for data in the Select.""" +SelectOption: TypeAlias = "tuple[str, SelectType]" +"""The type used for options in the Select.""" + + +class Select(Generic[SelectType], Vertical, can_focus=True): + """Widget to select from a list of possible options. + + A Select displays the current selection. + When activated with ++enter++ the widget displays an overlay with a list of all possible options. + + """ + + BINDINGS = [("enter,down,space,up", "show_overlay")] + """ + | Key(s) | Description | + | :- | :- | + | enter,down,space,up | Activate the overlay | + """ + + DEFAULT_CSS = """ + Select { + height: auto; + } + + Select:focus > SelectCurrent { + border: tall $accent; + } + + Select { + height: auto; + } + + Select > SelectOverlay { + width: 1fr; + display: none; + height: auto; + max-height: 10; + overlay: screen; + constrain: y; + } + + Select .up-arrow { + display:none; + } + + Select.-expanded .down-arrow { + display:none; + } + + Select.-expanded .up-arrow { + display: block; + } + + Select.-expanded > SelectOverlay { + display: block; + } + + Select.-expanded > SelectCurrent { + border: tall $accent; + } + """ + + expanded: var[bool] = var(False, init=False) + """True to show the overlay, otherwise False.""" + prompt: var[str] = var[str]("Select") + """The prompt to show when no value is selected.""" + value: var[SelectType | None] = var[Optional[SelectType]](None) + """The value of the select.""" + + class Changed(Message, bubble=True): + """Posted when the select value was changed. + + This message can be handled using a `on_select_changed` method. + + """ + + def __init__(self, control: Select, value: SelectType | None) -> None: + """ + Initialize the Changed message. + + """ + super().__init__() + self.control = control + """The select control.""" + self.value = value + """The value of the Select when it changed.""" + + def __init__( + self, + options: Iterable[tuple[str, SelectType]], + *, + prompt: str = "Select", + allow_blank: bool = True, + value: SelectType | None = None, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + """Initialize the Select control + + Args: + options: Options to select from. + prompt: Text to show in the control when no option is select. + allow_blank: Allow the selection of a blank option. + value: Initial value (should be one of the values in `options`). + name: The name of the select control. + id: The ID of the control the DOM. + classes: The CSS classes of the control. + disabled: Whether the control is disabled or not. + """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) + self._allow_blank = allow_blank + self.prompt = prompt + self._initial_options = list(options) + self._value: SelectType | None = value + + def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None: + """Set the options for the Select. + + Args: + options: An iterable of tuples containing (STRING, VALUE). + """ + self._options: list[tuple[RenderableType, SelectType | None]] = list(options) + + if self._allow_blank: + self._options.insert(0, ("", None)) + + self._select_options: list[Option] = [ + ( + Option(Text(self.prompt, style="dim")) + if value is None + else Option(prompt) + ) + for prompt, value in self._options + ] + + option_list = self.query_one(SelectOverlay) + option_list.clear_options() + for option in self._select_options: + option_list.add_option(option) + + def _watch_value(self, value: SelectType | None) -> None: + """Update the current value when it changes.""" + self._value = value + if value is None: + self.query_one(SelectCurrent).update(None) + else: + for index, (prompt, _value) in enumerate(self._options): + if _value == value: + select_overlay = self.query_one(SelectOverlay) + select_overlay.highlighted = index + self.query_one(SelectCurrent).update(prompt) + break + else: + self.query_one(SelectCurrent).update(None) + + def compose(self) -> ComposeResult: + """Compose Select with overlay and current value.""" + yield SelectCurrent(self.prompt) + yield SelectOverlay() + + def _on_mount(self, _event: events.Mount) -> None: + """Set initial values.""" + self.set_options(self._initial_options) + self.value = self._value + + def _watch_expanded(self, expanded: bool) -> None: + """Display or hide overlay.""" + overlay = self.query_one(SelectOverlay) + self.set_class(expanded, "-expanded") + if expanded: + overlay.focus() + if self.value is None: + overlay.select(None) + self.query_one(SelectCurrent).has_value = False + else: + value = self.value + for index, (_prompt, prompt_value) in enumerate(self._options): + if value == prompt_value: + overlay.select(index) + break + self.query_one(SelectCurrent).has_value = True + + @on(SelectCurrent.Toggle) + def _select_current_toggle(self, event: SelectCurrent.Toggle) -> None: + """Show the overlay when toggled.""" + event.stop() + self.expanded = not self.expanded + + @on(SelectOverlay.Dismiss) + def _select_overlay_dismiss(self, event: SelectOverlay.Dismiss) -> None: + """Dismiss the overlay.""" + event.stop() + self.expanded = False + if not event.lost_focus: + # If the overlay didn't lose focus, we want to re-focus the select. + self.focus() + + @on(SelectOverlay.UpdateSelection) + def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None: + """Update the current selection.""" + event.stop() + value = self._options[event.option_index][1] + self.value = value + + async def update_focus() -> None: + """Update focus and reset overlay.""" + self.focus() + self.expanded = False + + self.call_after_refresh(update_focus) # Prevents a little flicker + self.post_message(self.Changed(self, value)) + + def action_show_overlay(self) -> None: + """Show the overlay.""" + select_current = self.query_one(SelectCurrent) + select_current.has_value = True + self.expanded = True diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5b06d31c0..cbee7ba35 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,138 +21,137 @@ font-weight: 700; } - .terminal-1593336641-matrix { + .terminal-369237853-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1593336641-title { + .terminal-369237853-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-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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - AlignContainersApp + AlignContainersApp - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - center - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - middle - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + center + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + middle + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + @@ -347,202 +346,202 @@ font-weight: 700; } - .terminal-3056812568-matrix { + .terminal-2978213952-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3056812568-title { + .terminal-2978213952-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ───────────────────────────────────────────────────────────────────────────────────────────── + + ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── @@ -572,136 +571,136 @@ font-weight: 700; } - .terminal-1625062503-matrix { + .terminal-3956291897-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1625062503-title { + .terminal-3956291897-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputWidthAutoApp + InputWidthAutoApp - - - - InputWidthAutoApp - ▔▔▔▔▔▔▔▔▔▔ - Hello - ▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - - - + + + + InputWidthAutoApp + ▔▔▔▔▔▔▔▔▔▔ + Hello + ▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + @@ -732,137 +731,136 @@ font-weight: 700; } - .terminal-2470781732-matrix { + .terminal-2059832628-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2470781732-title { + .terminal-2059832628-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - BorderAlphaApp + BorderAlphaApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - - - + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + + @@ -1057,162 +1055,161 @@ font-weight: 700; } - .terminal-619468389-matrix { + .terminal-3643133712-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-619468389-title { + .terminal-3643133712-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -2485,135 +2482,134 @@ font-weight: 700; } - .terminal-1331556511-matrix { + .terminal-2323733830-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1331556511-title { + .terminal-2323733830-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .terminal-2323733830-r1 { fill: #808080 } + .terminal-2323733830-r2 { fill: #e1e1e1 } + .terminal-2323733830-r3 { fill: #c5c8c6 } + .terminal-2323733830-r4 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ──────────────────────────────────────────────────────────────────────── @@ -3277,141 +3273,141 @@ font-weight: 700; } - .terminal-1997861159-matrix { + .terminal-1536397390-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1997861159-title { + .terminal-1536397390-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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… + @@ -5018,132 +5014,132 @@ font-weight: 700; } - .terminal-1840966081-matrix { + .terminal-1564714526-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1840966081-title { + .terminal-1564714526-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1840966081-r1 { fill: #e1e1e1 } - .terminal-1840966081-r2 { fill: #c5c8c6 } - .terminal-1840966081-r3 { fill: #ffffff } + .terminal-1564714526-r1 { fill: #e1e1e1 } + .terminal-1564714526-r2 { fill: #c5c8c6 } + .terminal-1564714526-r3 { fill: #ffffff } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DockAllApp + DockAllApp - - - - - - - ────────────────────────────────────────────────────────── - top - - - - - - - leftright - - - - - - - - bottom - ────────────────────────────────────────────────────────── - - + + + + + + + ────────────────────────────────────────────────────────── + top + + + + + + + leftright + + + + + + + + bottom + ────────────────────────────────────────────────────────── + + @@ -6431,134 +6427,132 @@ font-weight: 700; } - .terminal-2838975926-matrix { + .terminal-2726481143-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2838975926-title { + .terminal-2726481143-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .terminal-2726481143-r1 { fill: #c5c8c6 } + .terminal-2726481143-r2 { fill: #000000 } + .terminal-2726481143-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - LayoutApp + LayoutApp - - - - - Layout - - Is - - Vertical - - - LayoutIsHorizontal - - - - - - - - - - - - - - + + + + + Layout + + Is + + Vertical + + + LayoutIsHorizontal + + + + + + + + + + + + + + @@ -7845,141 +7839,140 @@ font-weight: 700; } - .terminal-2245771963-matrix { + .terminal-4172255139-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2245771963-title { + .terminal-4172255139-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + + + + ────────────────────────────────────────────────────────────────── @@ -8167,134 +8160,134 @@ font-weight: 700; } - .terminal-1398959741-matrix { + .terminal-987506037-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1398959741-title { + .terminal-987506037-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + + @@ -8644,134 +8637,134 @@ font-weight: 700; } - .terminal-292160688-matrix { + .terminal-3520697079-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-292160688-title { + .terminal-3520697079-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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) + + + + ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ + + + @@ -9436,136 +9429,136 @@ font-weight: 700; } - .terminal-2990670852-matrix { + .terminal-3720200886-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2990670852-title { + .terminal-3720200886-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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. @@ -9750,138 +9743,138 @@ font-weight: 700; } - .terminal-1642992271-matrix { + .terminal-2103878337-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1642992271-title { + .terminal-2103878337-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + + + + + + @@ -12291,141 +12284,141 @@ font-weight: 700; } - .terminal-1938916138-matrix { + .terminal-1052270191-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1938916138-title { + .terminal-1052270191-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeightComparisonApp + HeightComparisonApp - - - - - - - - - - - - - - - #cells#percent#w#h#vw#vh#auto#fr1#fr3 - - - - - - - - - - - - ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• + + + + + + + + + + + + + + + #cells#percent#w#h#vw#vh#auto#fr1#fr3 + + + + + + + + + + + + ····•····•····•····•····•····•····•····•····•····•····•····•····•····•····•····• @@ -13569,168 +13562,168 @@ font-weight: 700; } - .terminal-4033540874-matrix { + .terminal-614458704-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4033540874-title { + .terminal-614458704-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  @@ -14101,141 +14094,141 @@ font-weight: 700; } - .terminal-2702154472-matrix { + .terminal-2216843056-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2702154472-title { + .terminal-2216843056-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  + + + ▇▇ @@ -14435,135 +14428,135 @@ font-weight: 700; } - .terminal-1801121102-matrix { + .terminal-2886576672-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1801121102-title { + .terminal-2886576672-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 @@ -14751,138 +14744,137 @@ font-weight: 700; } - .terminal-1665781252-matrix { + .terminal-1298369243-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1665781252-title { + .terminal-1298369243-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1665781252-r1 { fill: #008000 } - .terminal-1665781252-r2 { fill: #c5c8c6 } - .terminal-1665781252-r3 { fill: #e0e4e0 } - .terminal-1665781252-r4 { fill: #e0e6e0 } + .terminal-1298369243-r1 { fill: #008000 } + .terminal-1298369243-r2 { fill: #c5c8c6 } + .terminal-1298369243-r3 { fill: #e0e6e0 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TestApp + TestApp - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - - Hello - - - - - - - World - - - - - - - !! - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + Hello + + + + + + + World + + + + + + + !! + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -14912,136 +14904,135 @@ font-weight: 700; } - .terminal-1035580841-matrix { + .terminal-2371169958-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1035580841-title { + .terminal-2371169958-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 @@ -15696,132 +15687,132 @@ font-weight: 700; } - .terminal-4077214022-matrix { + .terminal-2648118808-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4077214022-title { + .terminal-2648118808-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4077214022-r1 { fill: #c5c8c6 } - .terminal-4077214022-r2 { fill: #e3e3e3 } - .terminal-4077214022-r3 { fill: #e1e1e1 } + .terminal-2648118808-r1 { fill: #c5c8c6 } + .terminal-2648118808-r2 { fill: #e3e3e3 } + .terminal-2648118808-r3 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - HeaderApp + HeaderApp - - - - HeaderApp - - - - - - - - - - - - - - - - - - - - - - + + + + HeaderApp + + + + + + + + + + + + + + + + + + + + + + @@ -16482,146 +16473,146 @@ font-weight: 700; } - .terminal-2572323619-matrix { + .terminal-641812469-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2572323619-title { + .terminal-641812469-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-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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ @@ -16809,136 +16800,136 @@ font-weight: 700; } - .terminal-1675990519-matrix { + .terminal-513592180-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1675990519-title { + .terminal-513592180-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  @@ -17936,135 +17927,135 @@ font-weight: 700; } - .terminal-543315859-matrix { + .terminal-2423395429-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-543315859-title { + .terminal-2423395429-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ModalApp - - - - ModalApp - Hello - - - - - - - - - - - - - - - - - - - - - -  ⏎  Open Dialog  + + + + ModalApp + Hello + + + + + + + + + + + + + + + + + + + + + +  ⏎  Open Dialog  @@ -18731,136 +18722,136 @@ font-weight: 700; } - .terminal-1812315577-matrix { + .terminal-1829927563-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1812315577-title { + .terminal-1829927563-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-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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 +18882,139 @@ font-weight: 700; } - .terminal-1041266590-matrix { + .terminal-2055091312-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1041266590-title { + .terminal-2055091312-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-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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - - - - OptionListApp - - - - ────────────────────────────────────────────────────── - Aerilon - Aquaria - ──────────────────────────────────────────────────── - Canceron - Caprica - ──────────────────────────────────────────────────── - Gemenon - ──────────────────────────────────────────────────── - Leonis - Libran - ────────────────────────────────────────────────────▅▅ - Picon - ──────────────────────────────────────────────────── - Sagittaron - ────────────────────────────────────────────────────── - - - + + + + OptionListApp + + + + ────────────────────────────────────────────────────── + Aerilon + Aquaria + ──────────────────────────────────────────────────── + Canceron + Caprica + ──────────────────────────────────────────────────── + Gemenon + ──────────────────────────────────────────────────── + Leonis + Libran + ────────────────────────────────────────────────────▅▅ + Picon + ──────────────────────────────────────────────────── + Sagittaron + ────────────────────────────────────────────────────── + + + @@ -19054,140 +19045,140 @@ font-weight: 700; } - .terminal-1620527509-matrix { + .terminal-1395459687-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1620527509-title { + .terminal-1395459687-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-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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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                   + ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ + ────────────────────────────────────────────────────── + + + @@ -19376,136 +19367,136 @@ font-weight: 700; } - .terminal-1392305496-matrix { + .terminal-3980370474-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1392305496-title { + .terminal-3980370474-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  @@ -19535,136 +19526,136 @@ font-weight: 700; } - .terminal-3727479996-matrix { + .terminal-1053593998-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3727479996-title { + .terminal-1053593998-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  @@ -19694,142 +19685,142 @@ font-weight: 700; } - .terminal-1570661136-matrix { + .terminal-700023403-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1570661136-title { + .terminal-700023403-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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  @@ -20015,135 +20006,135 @@ font-weight: 700; } - .terminal-230009450-matrix { + .terminal-1426024135-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-230009450-title { + .terminal-1426024135-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20173,137 +20164,136 @@ font-weight: 700; } - .terminal-3162092160-matrix { + .terminal-1998155485-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3162092160-title { + .terminal-1998155485-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━100%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20333,136 +20323,136 @@ font-weight: 700; } - .terminal-1630089489-matrix { + .terminal-836496735-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1630089489-title { + .terminal-836496735-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -20492,138 +20482,137 @@ font-weight: 700; } - .terminal-1532901142-matrix { + .terminal-1783624548-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1532901142-title { + .terminal-1783624548-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━39%00:00:07 + + + + + + + + + + + +  S  Start  @@ -20653,136 +20642,136 @@ font-weight: 700; } - .terminal-3440292978-matrix { + .terminal-2036756687-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3440292978-title { + .terminal-2036756687-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - IndeterminateProgressBar + IndeterminateProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  @@ -20812,138 +20801,137 @@ font-weight: 700; } - .terminal-4046569674-matrix { + .terminal-4086988071-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4046569674-title { + .terminal-4086988071-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - StyledProgressBar + StyledProgressBar - - - - - - - - - - - - - - - ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - -  S  Start  + + + + + + + + + + + + + + + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + +  S  Start  @@ -21456,137 +21444,137 @@ font-weight: 700; } - .terminal-1869274227-matrix { + .terminal-2779683141-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1869274227-title { + .terminal-2779683141-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - VerticalRemoveApp + VerticalRemoveApp - - - - VerticalRemoveApp - ────────────────────────────────────────────────────────────────────────────── - ──────────────────── - This is a test label - ──────────────────── - ────────────────────────────────────────────────────────────────────────────── - - - - - - - - - - - - - - - - - -  A  Add  D  Delete  + + + + VerticalRemoveApp + ────────────────────────────────────────────────────────────────────────────── + ──────────────────── + This is a test label + ──────────────────── + ────────────────────────────────────────────────────────────────────────────── + + + + + + + + + + + + + + + + + +  A  Add  D  Delete  @@ -21616,135 +21604,135 @@ font-weight: 700; } - .terminal-1316892474-matrix { + .terminal-3992644605-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1316892474-title { + .terminal-3992644605-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ModalApp + ModalApp - - - - ModalApp - B - - - - - - - - - - - - - - - - - - - - - -  A  Push screen A  + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  @@ -22087,134 +22075,618 @@ font-weight: 700; } - .terminal-1647606097-matrix { + .terminal-2749576739-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1647606097-title { + .terminal-2749576739-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - 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 + ────────────────────────────────────────────────────────────────────────────── + + + + + + ''' +# --- +# name: test_select + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + SelectApp + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Select + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_select_expanded + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + ''' +# --- +# name: test_select_expanded_changed + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + I must not fear. + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + I must not fear. + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -22245,136 +22717,136 @@ font-weight: 700; } - .terminal-2914557706-matrix { + .terminal-932889121-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2914557706-title { + .terminal-932889121-title { font-size: 18px; font-weight: bold; font-family: arial; } - .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 } + .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 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SwitchApp + SwitchApp - - - - - - - - Example switches - - - ▔▔▔▔▔▔▔▔ - off:      - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - on:       - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - focused:  - ▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔ - custom:   - ▁▁▁▁▁▁▁▁ - - - - + + + + + + + + Example switches + + + ▔▔▔▔▔▔▔▔ + off:      + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + on:       + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + focused:  + ▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔ + custom:   + ▁▁▁▁▁▁▁▁ + + + + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index fde61ae64..ea5e32153 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -230,6 +230,23 @@ def test_progress_bar_completed_styled(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_styled_.py", press=["u"]) +def test_select(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py") + + +def test_select_expanded(snap_compare): + assert snap_compare( + WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"] + ) + + +def test_select_expanded_changed(snap_compare): + assert snap_compare( + WIDGET_EXAMPLES_DIR / "select_widget.py", + press=["tab", "enter", "down", "enter"], + ) + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. diff --git a/tests/test_binding.py b/tests/test_binding.py index e73ecfb3d..5c545d7a6 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -52,7 +52,7 @@ def test_bindings_merge_overlap(): def test_bad_binding_tuple(): with pytest.raises(BindingError): - _ = _Bindings((("a", "action"),)) + _ = _Bindings((("a",),)) with pytest.raises(BindingError): _ = _Bindings((("a", "action", "description", "too much"),)) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index c8c663df5..b36845c39 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -447,3 +447,15 @@ def test_split_horizontal_negative(): Region(10, 5, 22, 14), Region(10, 19, 22, 1), ) + + +def test_translate_inside(): + # Needs to be moved up + assert Region(10, 20, 10, 20).translate_inside(Region(0, 0, 30, 25)) == Region( + 10, 5, 10, 20 + ) + + # Already inside + assert Region(10, 10, 20, 5).translate_inside(Region(0, 0, 100, 100)) == Region( + 10, 10, 20, 5 + ) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index 413ca4cad..6b053633c 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -44,9 +44,9 @@ def test_get_values_in_region() -> None: spatial_map.insert( [ - (Region(10, 5, 5, 5), False, "foo"), - (Region(5, 20, 5, 5), False, "bar"), - (Region(0, 0, 40, 1), True, "title"), + (Region(10, 5, 5, 5), False, False, "foo"), + (Region(5, 20, 5, 5), False, False, "bar"), + (Region(0, 0, 40, 1), True, False, "title"), ] ) From 9c9829eb0feb5db0ad22beee51b2daa94a787e87 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, 8 May 2023 11:15:19 +0100 Subject: [PATCH 31/31] Add 'control' alias to Tabs messages. (#2483) * Add 'control' alias to Tabs messages. Related issues: #2478. * Changelog. --- CHANGELOG.md | 3 +-- src/textual/widgets/_tabs.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27597851..a26e4cc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,12 +18,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459 - run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470 - Added `always_update` as an optional argument for `reactive.var` - - Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501 - Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501 ### Added +- Property `control` as alias for attribute `tabs` in `Tabs` messages https://github.com/Textualize/textual/pull/2483 - Experimental: Added "overlay" rule https://github.com/Textualize/textual/pull/2501 - Experimental: Added "constrain" rule https://github.com/Textualize/textual/pull/2501 - Added textual.widgets.Select https://github.com/Textualize/textual/pull/2501 @@ -35,7 +35,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510 - Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510 - ## [0.23.0] - 2023-05-03 ### Fixed diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 0af93be65..7394d106c 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -194,6 +194,15 @@ class Tabs(Widget, can_focus=True): self.tab = tab super().__init__() + @property + def control(self) -> Tabs: + """The tabs widget containing the tab that was activated. + + This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs] + which is used by the [`on`][textual.on] decorator. + """ + return self.tabs + def __rich_repr__(self) -> rich.repr.Result: yield self.tabs yield self.tab @@ -213,6 +222,15 @@ class Tabs(Widget, can_focus=True): self.tabs = tabs super().__init__() + @property + def control(self) -> Tabs: + """The tabs widget which was cleared. + + This is an alias for [`Cleared.tabs`][textual.widgets.Tabs.Cleared] which + is used by the [`on`][textual.on] decorator. + """ + return self.tabs + def __rich_repr__(self) -> rich.repr.Result: yield self.tabs