diff --git a/CHANGELOG.md b/CHANGELOG.md index b403661c3..ce0bf2b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.14.0] - Unreleased +## [0.15.0] - Unreleased + +### Changed + +- Renamed `Vertical` to `VerticalScroll` https://github.com/Textualize/textual/issues/1957 + +### Added + +- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957 + + +## [0.14.0] - 2023-03-09 ### Changed @@ -16,7 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Updated styling to make it clear DataTable grows horizontally https://github.com/Textualize/textual/pull/1946 - Changed the `Checkbox` character due to issues with Windows Terminal and Windows 10 https://github.com/Textualize/textual/issues/1934 - Changed the `RadioButton` character due to issues with Windows Terminal and Windows 10 and 11 https://github.com/Textualize/textual/issues/1934 -- Renamed `Vertical` to `VerticalScroll` https://github.com/Textualize/textual/issues/1957 +- Changed the `Markdown` initial bullet character due to issues with Windows Terminal and Windows 10 and 11 https://github.com/Textualize/textual/issues/1982 ### Added @@ -28,12 +39,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: Added `toggle_button` attribute to RadioButton and Checkbox events, replaces `input` https://github.com/Textualize/textual/pull/1940 - A percentage alpha can now be applied to a border https://github.com/Textualize/textual/issues/1863 - Added `Color.multiply_alpha`. -- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957 +- Added `ContentSwitcher` https://github.com/Textualize/textual/issues/1945 ### Fixed - Fixed bug that prevented pilot from pressing some keys https://github.com/Textualize/textual/issues/1815 - DataTable race condition that caused crash https://github.com/Textualize/textual/pull/1962 +- Fixed scrollbar getting "stuck" to cursor when cursor leaves window during drag https://github.com/Textualize/textual/pull/1968 https://github.com/Textualize/textual/pull/2003 - DataTable crash when enter pressed when table is empty https://github.com/Textualize/textual/pull/1973 ## [0.13.0] - 2023-03-02 @@ -561,6 +573,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.14.0]: https://github.com/Textualize/textual/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/Textualize/textual/compare/v0.12.1...v0.13.0 [0.12.1]: https://github.com/Textualize/textual/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/Textualize/textual/compare/v0.11.1...v0.12.0 diff --git a/docs/api/content_switcher.md b/docs/api/content_switcher.md new file mode 100644 index 000000000..ef2cfe930 --- /dev/null +++ b/docs/api/content_switcher.md @@ -0,0 +1 @@ +::: textual.widgets.ContentSwitcher diff --git a/docs/blog/posts/creating-tasks-overhead.md b/docs/blog/posts/creating-tasks-overhead.md new file mode 100644 index 000000000..c21a19565 --- /dev/null +++ b/docs/blog/posts/creating-tasks-overhead.md @@ -0,0 +1,76 @@ +--- +draft: false +date: 2023-03-08 +categories: + - DevLog +authors: + - willmcgugan +--- + +# Overhead of Python Asyncio tasks + +Every widget in Textual, be it a button, tree view, or a text input, runs an [asyncio](https://docs.python.org/3/library/asyncio.html) task. There is even a task for [scrollbar corners](https://github.com/Textualize/textual/blob/e95a65fa56e5b19715180f9e17c7f6747ba15ec5/src/textual/scrollbar.py#L365) (the little space formed when horizontal and vertical scrollbars meet). + + + +!!! info + + It may be IO that gives AsyncIO its name, but Textual doesn't do any IO of its own. Those tasks are used to power *message queues*, so that widgets (UI components) can do whatever they do at their own pace. + +Its fair to say that Textual apps launch a lot of tasks. Which is why when I was trying to optimize startup (for apps with 1000s of widgets) I suspected it was task related. + +I needed to know how much of an overhead it was to launch tasks. Tasks are lighter weight than threads, but how much lighter? The only way to know for certain was to profile. + +The following code launches a load of *do nothing* tasks, then waits for them to shut down. This would give me an idea of how performant `create_task` is, and also a *baseline* for optimizations. I would know the absolute limit of any optimizations I make. + +```python +from asyncio import create_task, wait, run +from time import process_time as time + + +async def time_tasks(count=100) -> float: + """Time creating and destroying tasks.""" + + async def nop_task() -> None: + """Do nothing task.""" + pass + + start = time() + tasks = [create_task(nop_task()) for _ in range(count)] + await wait(tasks) + elapsed = time() - start + return elapsed + + +for count in range(100_000, 1000_000 + 1, 100_000): + create_time = run(time_tasks(count)) + create_per_second = 1 / (create_time / count) + print(f"{count:,} tasks \t {create_per_second:0,.0f} tasks per/s") +``` + +And here is the output: + +``` +100,000 tasks 280,003 tasks per/s +200,000 tasks 255,275 tasks per/s +300,000 tasks 248,713 tasks per/s +400,000 tasks 248,383 tasks per/s +500,000 tasks 241,624 tasks per/s +600,000 tasks 260,660 tasks per/s +700,000 tasks 244,510 tasks per/s +800,000 tasks 247,455 tasks per/s +900,000 tasks 242,744 tasks per/s +1,000,000 tasks 259,715 tasks per/s +``` + +!!! info + + Running on an M1 MacBook Pro. + +This tells me I can create, run, and shutdown 260K tasks per second. + +That's fast. + +Clearly `create_task` is as close as you get to free in the Python world, and I would need to look elsewhere for optimizations. Turns out Textual spends far more time processing CSS rules than creating tasks (obvious in retrospect). I've noticed some big wins there, so the next version of Textual will be faster to start apps with a metric tonne of widgets. + +But I still need to know what to do with those scrollbar corners. A task for two characters. I don't even... diff --git a/docs/blog/posts/release0-14-0.md b/docs/blog/posts/release0-14-0.md new file mode 100644 index 000000000..359750338 --- /dev/null +++ b/docs/blog/posts/release0-14-0.md @@ -0,0 +1,77 @@ +--- +draft: false +date: 2023-03-09 +categories: + - Release +title: "Textual 0.14.0 shakes up posting messages" +authors: + - willmcgugan +--- + +# Textual 0.14.0 shakes up posting messages + +Textual version 0.14.0 has landed just a week after 0.13.0. + +!!! note + + We like fast releases for Textual. Fast releases means quicker feedback, which means better code. + +What's new? + + + +We did a little shake-up of posting [messages](../../guide/events.md) which will simplify building widgets. But this does mean a few breaking changes. + +There are two methods in Textual to post messages: `post_message` and `post_message_no_wait`. The former was asynchronous (you needed to `await` it), and the latter was a regular method call. These two methods have been replaced with a single `post_message` method. + +To upgrade your project to Textual 0.14.0, you will need to do the following: + +- Remove `await` keywords from any calls to `post_message`. +- Replace any calls to `post_message_no_wait` with `post_message`. + + +Additionally, we've simplified constructing messages classes. Previously all messages required a `sender` argument, which had to be manually set. This was a clear violation of our "no boilerplate" policy, and has been dropped. There is still a `sender` property on messages / events, but it is set automatically. + +So prior to 0.14.0 you might have posted messages like the following: + +```python +async self.post_message(self.Changed(self, item=self.item)) +``` + +You can now replace it with this simpler function call: + +```python +self.post_message(self.Change(item=self.item)) +``` + +This also means that you will need to drop the sender from any custom messages you have created. + +If this was code pre-0.14.0: + +```python +class MyWidget(Widget): + + class Changed(Message): + """My widget change event.""" + def __init__(self, sender:MessageTarget, item_index:int) -> None: + self.item_index = item_index + super().__init__(sender) + +``` + +You would need to make the following change (dropping `sender`). + +```python +class MyWidget(Widget): + + class Changed(Message): + """My widget change event.""" + def __init__(self, item_index:int) -> None: + self.item_index = item_index + super().__init__() + +``` + +If you have any problems upgrading, join our [Discord server](https://discord.gg/Enf6Z3qhVr), we would be happy to help. + +See the [release notes](https://github.com/Textualize/textual/releases/tag/v0.14.0) for the full details on this update. diff --git a/docs/custom_theme/assets/fonts/BerkeleyMono-Bold.ttf b/docs/custom_theme/assets/fonts/BerkeleyMono-Bold.ttf new file mode 100644 index 000000000..651bbc55b Binary files /dev/null and b/docs/custom_theme/assets/fonts/BerkeleyMono-Bold.ttf differ diff --git a/docs/custom_theme/assets/fonts/BerkeleyMono-BoldItalic.ttf b/docs/custom_theme/assets/fonts/BerkeleyMono-BoldItalic.ttf new file mode 100644 index 000000000..bfa5c5925 Binary files /dev/null and b/docs/custom_theme/assets/fonts/BerkeleyMono-BoldItalic.ttf differ diff --git a/docs/custom_theme/assets/fonts/BerkeleyMono-Italic.ttf b/docs/custom_theme/assets/fonts/BerkeleyMono-Italic.ttf new file mode 100644 index 000000000..40b16e2c7 Binary files /dev/null and b/docs/custom_theme/assets/fonts/BerkeleyMono-Italic.ttf differ diff --git a/docs/custom_theme/assets/fonts/BerkeleyMono-Regular.ttf b/docs/custom_theme/assets/fonts/BerkeleyMono-Regular.ttf new file mode 100644 index 000000000..e8b037739 Binary files /dev/null and b/docs/custom_theme/assets/fonts/BerkeleyMono-Regular.ttf differ diff --git a/docs/examples/guide/compound/compound01.py b/docs/examples/guide/compound/compound01.py index 66d8745ea..fcec10f72 100644 --- a/docs/examples/guide/compound/compound01.py +++ b/docs/examples/guide/compound/compound01.py @@ -42,7 +42,7 @@ class CompoundApp(App): """ def compose(self) -> ComposeResult: - yield InputWithLabel("Fist Name") + yield InputWithLabel("First Name") yield InputWithLabel("Last Name") yield InputWithLabel("Email") diff --git a/docs/examples/widgets/content_switcher.css b/docs/examples/widgets/content_switcher.css new file mode 100644 index 000000000..b8546b117 --- /dev/null +++ b/docs/examples/widgets/content_switcher.css @@ -0,0 +1,27 @@ +Screen { + align: center middle; +} + +#buttons { + margin-top: 1; + height: 3; + width: auto; +} + +ContentSwitcher { + background: $panel; + border: round $primary; + width: 90%; + height: 80%; +} + +DataTable { + background: $panel; +} + +MarkdownH2 { + background: $primary; + color: yellow; + border: none; + padding: 0; +} diff --git a/docs/examples/widgets/content_switcher.py b/docs/examples/widgets/content_switcher.py new file mode 100644 index 000000000..1a774a805 --- /dev/null +++ b/docs/examples/widgets/content_switcher.py @@ -0,0 +1,64 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, ContentSwitcher, DataTable, Markdown + +MARKDOWN_EXAMPLE = """# Three Flavours Cornetto + +The Three Flavours Cornetto trilogy is an anthology series of British +comedic genre films directed by Edgar Wright. + +## Shaun of the Dead + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Strawberry | 2004-04-09 | Edgar Wright | + +## Hot Fuzz + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Classico | 2007-02-17 | Edgar Wright | + +## The World's End + +| Flavour | UK Release Date | Director | +| -- | -- | -- | +| Mint | 2013-07-19 | Edgar Wright | +""" + + +class ContentSwitcherApp(App[None]): + CSS_PATH = "content_switcher.css" + + def compose(self) -> ComposeResult: + with Horizontal(id="buttons"): # (1)! + yield Button("DataTable", id="data-table") # (2)! + yield Button("Markdown", id="markdown") # (3)! + + with ContentSwitcher(initial="data-table"): # (4)! + yield DataTable(id="data-table") + yield Markdown(MARKDOWN_EXAMPLE, id="markdown") + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.query_one(ContentSwitcher).current = event.button.id # (5)! + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Book", "Year") + table.add_rows( + [ + (title.ljust(35), year) + for title, year in ( + ("Dune", 1965), + ("Dune Messiah", 1969), + ("Children of Dune", 1976), + ("God Emperor of Dune", 1981), + ("Heretics of Dune", 1984), + ("Chapterhouse: Dune", 1985), + ) + ] + ) + + +if __name__ == "__main__": + ContentSwitcherApp().run() diff --git a/docs/index.md b/docs/index.md index 1ce69322d..b3ca42e71 100644 --- a/docs/index.md +++ b/docs/index.md @@ -71,7 +71,7 @@ Build sophisticated user interfaces with a simple Python API. Run your apps in t -```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,_,_,_,_,_,_,_,_"} +```{.textual path="examples/calculator.py" columns=100 lines=41 press="3,.,1,4,5,9,2,wait:400"} ``` ```{.textual path="examples/pride.py"} diff --git a/docs/roadmap.md b/docs/roadmap.md index 0c10fef40..478ac534e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,7 +41,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c * [x] Error / warning variants - [ ] Color picker - [X] Checkbox -- [ ] Content switcher +- [X] Content switcher - [x] DataTable * [x] Cell select * [x] Row / Column select diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 400340bae..149804326 100644 --- a/docs/widget_gallery.md +++ b/docs/widget_gallery.md @@ -35,6 +35,14 @@ A classic checkbox control. ``` +## ContentSwitcher + +A widget for containing and switching display between multiple child +widgets. + +[ContentSwitcher reference](./widgets/content_switcher.md){ .md-button .md-button--primary } + + ## DataTable A powerful data table, with configurable cursors. diff --git a/docs/widgets/button.md b/docs/widgets/button.md index d5b07ec96..38359d0e8 100644 --- a/docs/widgets/button.md +++ b/docs/widgets/button.md @@ -10,7 +10,7 @@ when it has focus. ## Example The example below shows each button variant, and its disabled equivalent. -Clicking any of the non-disabled buttons in the example app below will result the app exiting and the details of the selected button being printed to the console. +Clicking any of the non-disabled buttons in the example app below will result in the app exiting and the details of the selected button being printed to the console. === "Output" diff --git a/docs/widgets/content_switcher.md b/docs/widgets/content_switcher.md new file mode 100644 index 000000000..b9369452b --- /dev/null +++ b/docs/widgets/content_switcher.md @@ -0,0 +1,54 @@ +# ContentSwitcher + +A widget for containing and switching display between multiple child +widgets. + +- [ ] Focusable +- [X] Container + +## Example + +The example below uses a `ContentSwitcher` in combination with two `Button`s +to create a simple tabbed view. Note how each `Button` has an ID set, and +how each child of the `ContentSwitcher` has a corresponding ID; then a +`Button.Clicked` handler is used to set `ContentSwitcher.current` to switch +between the different views. + +=== "Output" + + ```{.textual path="docs/examples/widgets/content_switcher.py"} + ``` + +=== "content_switcher.py" + + ~~~python + --8<-- "docs/examples/widgets/content_switcher.py" + ~~~ + + 1. A `Horizontal` to hold the buttons, each with a unique ID. + 2. This button will select the `DataTable` in the `ContentSwitcher`. + 3. This button will select the `Markdown` in the `ContentSwitcher`. + 4. Note that the intial visible content is set by its ID, see below. + 5. When a button is pressed, its ID is used to switch to a different widget in the `ContentSwitcher`. Remember that IDs are unique within parent, so the buttons and the widgets in the `ContentSwitcher` can share IDs. + +=== "content_switcher.css" + + ~~~sass + --8<-- "docs/examples/widgets/content_switcher.css" + ~~~ + +When the user presses the "Markdown" button the view is switched: + +```{.textual path="docs/examples/widgets/content_switcher.py" lines="40" press="tab,tab,enter"} +``` + +## Reactive Attributes + +| Name | Type | Default | Description | +|-----------|-----------------|---------|----------------------------------------------------------------------| +| `current` | `str` \| `None` | `None` | The ID of the currently-visible child. `None` means nothing is visible. | + + +## See Also + +* [ContentSwitcher][textual.widgets.ContentSwitcher] code reference diff --git a/docs/widgets/list_item.md b/docs/widgets/list_item.md index 698214159..7a33e29d2 100644 --- a/docs/widgets/list_item.md +++ b/docs/widgets/list_item.md @@ -1,4 +1,4 @@ -# List Item +# ListItem `ListItem` is the type of the elements in a `ListView`. diff --git a/docs/widgets/list_view.md b/docs/widgets/list_view.md index 8bca015df..6e294323e 100644 --- a/docs/widgets/list_view.md +++ b/docs/widgets/list_view.md @@ -1,4 +1,4 @@ -# List View +# ListView Displays a vertical list of `ListItem`s which can be highlighted and selected. Supports keyboard navigation. diff --git a/docs/widgets/markdown_viewer.md b/docs/widgets/markdown_viewer.md index d865f4466..0fc2f06e4 100644 --- a/docs/widgets/markdown_viewer.md +++ b/docs/widgets/markdown_viewer.md @@ -1,4 +1,4 @@ -# Markdown Viewer +# MarkdownViewer A Widget to display Markdown content with an optional Table of Contents. diff --git a/mkdocs-common.yml b/mkdocs-common.yml index 017255951..5b419ffb7 100644 --- a/mkdocs-common.yml +++ b/mkdocs-common.yml @@ -80,8 +80,7 @@ plugins: - "!^can_replace$" watch: - mkdocs-common.yml - - mkdocs-nav-offline.yml - - mkdocs-nav-online.yml + - mkdocs-nav.yml - mkdocs-offline.yml - mkdocs-online.yml - src/textual diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 3750fc9d7..820d601a7 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -122,6 +122,7 @@ nav: - Widgets: - "widgets/button.md" - "widgets/checkbox.md" + - "widgets/content_switcher.md" - "widgets/data_table.md" - "widgets/directory_tree.md" - "widgets/footer.md" @@ -148,6 +149,7 @@ nav: - "api/checkbox.md" - "api/color.md" - "api/containers.md" + - "api/content_switcher.md" - "api/coordinate.md" - "api/data_table.md" - "api/directory_tree.md" diff --git a/pyproject.toml b/pyproject.toml index ce2d1fbdf..a3291312e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.13.0" +version = "0.14.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] @@ -42,7 +42,7 @@ rich = ">12.6.0" markdown-it-py = {extras = ["plugins", "linkify"], version = "^2.1.0"} #rich = {path="../rich", develop=true} importlib-metadata = "^4.11.3" -typing-extensions = "^4.0.0" +typing-extensions = "^4.4.0" # Dependencies below are required for devtools only aiohttp = { version = ">=3.8.1", optional = true } diff --git a/src/textual/app.py b/src/textual/app.py index b387a718a..add4df575 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -868,6 +868,8 @@ class App(Generic[ReturnType], DOMNode): _, wait_ms = key.split(":") print(f"(pause {wait_ms}ms)") await asyncio.sleep(float(wait_ms) / 1000) + await app._animator.wait_until_complete() + await wait_for_idle(0) else: if len(key) == 1 and not key.isalnum(): key = _character_to_key(key) @@ -1458,7 +1460,6 @@ class App(Generic[ReturnType], DOMNode): Args: error: An exception instance. """ - if hasattr(error, "__rich__"): # Exception has a rich method, so we can defer to that for the rendering self.panic(error) @@ -1983,7 +1984,6 @@ class App(Generic[ReturnType], DOMNode): Returns: True if the event has handled. """ - print("ACTION", action, default_namespace) if isinstance(action, str): target, params = actions.parse(action) else: diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 1c6c9de5f..e3f9dd0b1 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -128,6 +128,7 @@ class Stylesheet: self.__variable_tokens: dict[str, list[Token]] | None = None self.source: dict[str, CssSource] = {} self._require_parse = False + self._invalid_css: set[str] = set() def __rich_repr__(self) -> rich.repr.Result: yield list(self.source.keys()) @@ -188,6 +189,7 @@ class Stylesheet: """ self._variables = variables self.__variable_tokens = None + self._invalid_css = set() def _parse_rules( self, @@ -304,10 +306,20 @@ class Stylesheet: """ rules: list[RuleSet] = [] add_rules = rules.extend + for path, (css, is_default_rules, tie_breaker) in self.source.items(): - css_rules = self._parse_rules( - css, path, is_default_rules=is_default_rules, tie_breaker=tie_breaker - ) + if css in self._invalid_css: + continue + try: + css_rules = self._parse_rules( + css, + path, + is_default_rules=is_default_rules, + tie_breaker=tie_breaker, + ) + except Exception: + self._invalid_css.add(css) + raise if any(rule.errors for rule in css_rules): error_renderable = StylesheetErrors(css_rules) raise StylesheetParseError(error_renderable) diff --git a/src/textual/driver.py b/src/textual/driver.py index 7707d736b..0e8ac6412 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from . import _clock, events from ._types import MessageTarget +from .events import MouseUp if TYPE_CHECKING: from rich.console import Console @@ -26,6 +27,8 @@ class Driver(ABC): self._size = size self._loop = asyncio.get_running_loop() self._mouse_down_time = _clock.get_time_no_wait() + self._down_buttons: list[int] = [] + self._last_move_event: events.MouseMove | None = None @property def is_headless(self) -> bool: @@ -41,6 +44,37 @@ class Driver(ABC): """Performs some additional processing of events.""" if isinstance(event, events.MouseDown): self._mouse_down_time = event.time + if event.button: + self._down_buttons.append(event.button) + elif isinstance(event, events.MouseUp): + if event.button: + self._down_buttons.remove(event.button) + elif isinstance(event, events.MouseMove): + if ( + self._down_buttons + and not event.button + and self._last_move_event is not None + ): + buttons = list(dict.fromkeys(self._down_buttons).keys()) + self._down_buttons.clear() + move_event = self._last_move_event + for button in buttons: + self.send_event( + MouseUp( + x=move_event.x, + y=move_event.y, + delta_x=0, + delta_y=0, + button=button, + shift=event.shift, + meta=event.meta, + ctrl=event.ctrl, + screen_x=move_event.screen_x, + screen_y=move_event.screen_y, + style=event.style, + ) + ) + self._last_move_event = event self.send_event(event) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index c5fd1b368..b67f05ed4 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -17,7 +17,6 @@ if TYPE_CHECKING: import rich.repr from .. import events, log -from .._profile import timer from .._types import MessageTarget from .._xterm_parser import XTermParser from ..driver import Driver diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index bc2ed6dd7..df5516ce9 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -291,6 +291,7 @@ class ScrollBar(Widget): def _on_hide(self, event: events.Hide) -> None: if self.grabbed: self.release_mouse() + self.grabbed = None def _on_enter(self, event: events.Enter) -> None: self.mouse_over = True @@ -299,10 +300,12 @@ class ScrollBar(Widget): self.mouse_over = False def action_scroll_down(self) -> None: - self.post_message(ScrollDown() if self.vertical else ScrollRight()) + if not self.grabbed: + self.post_message(ScrollDown() if self.vertical else ScrollRight()) def action_scroll_up(self) -> None: - self.post_message(ScrollUp() if self.vertical else ScrollLeft()) + if not self.grabbed: + self.post_message(ScrollUp() if self.vertical else ScrollLeft()) def action_grab(self) -> None: self.capture_mouse() @@ -313,6 +316,7 @@ class ScrollBar(Widget): async def _on_mouse_up(self, event: events.MouseUp) -> None: if self.grabbed: self.release_mouse() + self.grabbed = None event.stop() def _on_mouse_capture(self, event: events.MouseCapture) -> None: diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 0b85c9126..5dc515dea 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -12,6 +12,7 @@ if typing.TYPE_CHECKING: from ..widget import Widget from ._button import Button from ._checkbox import Checkbox + from ._content_switcher import ContentSwitcher from ._data_table import DataTable from ._directory_tree import DirectoryTree from ._footer import Footer @@ -35,6 +36,7 @@ if typing.TYPE_CHECKING: __all__ = [ "Button", "Checkbox", + "ContentSwitcher", "DataTable", "DirectoryTree", "Footer", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 3d2edfc06..5fe292f2d 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -1,6 +1,7 @@ # This stub file must re-export every classes exposed in the __init__.py's `__all__` list: from ._button import Button as Button from ._checkbox import Checkbox as Checkbox +from ._content_switcher import ContentSwitcher as ContentSwitcher from ._data_table import DataTable as DataTable from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer diff --git a/src/textual/widgets/_content_switcher.py b/src/textual/widgets/_content_switcher.py new file mode 100644 index 000000000..72ab0f96a --- /dev/null +++ b/src/textual/widgets/_content_switcher.py @@ -0,0 +1,90 @@ +"""Provides a widget for switching between the display of its immediate children.""" + +from __future__ import annotations + +from typing import Optional + +from ..containers import Container +from ..reactive import reactive +from ..widget import Widget + + +class ContentSwitcher(Container): + """A widget for switching between different children. + + Note: + All child widgets that are to be switched between need a unique ID. + Children that have no ID will be hidden and ignored. + """ + + current: reactive[str | None] = reactive[Optional[str]](None) + """The ID of the currently-displayed widget. + + If set to `None` then no widget is visible. + + Note: + If set to an unknown ID, this will result in + [NoMatches][textual.css.query.NoMatches] being raised. + """ + + def __init__( + self, + *children: Widget, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + initial: str | None = None, + ) -> None: + """Initialise the content switching widget. + + Args: + *children: The widgets to switch between. + name: The name of the content switcher. + id: The ID of the content switcher in the DOM. + classes: The CSS classes of the content switcher. + disabled: Whether the content switcher is disabled or not. + initial: The ID of the initial widget to show. + + Note: + If `initial` is not supplied no children will be shown to start + with. + """ + super().__init__( + *children, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + self._initial = initial + + def on_mount(self) -> None: + """Perform the initial setup of the widget once the DOM is ready.""" + # On startup, ensure everything is hidden. + with self.app.batch_update(): + for child in self.children: + child.display = False + # Then set the initial display. + self.current = self._initial + + @property + def visible_content(self) -> Widget | None: + """A reference to the currently-visible widget. + + `None` if nothing is visible. + """ + return self.get_child_by_id(self.current) if self.current is not None else None + + def watch_current(self, old: str | None, new: str | None) -> None: + """React to the current visible child choice being changed. + + Args: + old: The old widget ID (or `None` if there was no widget). + new: The new widget ID (or `None` if nothing should be shown). + """ + with self.app.batch_update(): + if old is not None: + self.get_child_by_id(old).display = False + if new is not None: + self.get_child_by_id(new).display = True diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index c55fe1f94..36931fd59 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -393,7 +393,7 @@ class MarkdownBullet(Widget): } """ - symbol = reactive("●​") + symbol = reactive("\u25CF") """The symbol for the bullet.""" def render(self) -> Text: @@ -500,7 +500,7 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} - BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "] + BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] def __init__( self, diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index c6c0346c4..b69597054 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1167,6 +1167,440 @@ ''' # --- +# name: test_content_switcher_example_initial + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ContentSwitcherApp + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ────────────────────────────────────────────────────────────────────── +  Book                                 Year  +  Dune                                 1965  +  Dune Messiah                         1969  +  Children of Dune                     1976  +  God Emperor of Dune                  1981  +  Heretics of Dune                     1984  +  Chapterhouse: Dune                   1985  + + + + + + + + + + + ────────────────────────────────────────────────────────────────────── + + + + + + ''' +# --- +# name: test_content_switcher_example_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ContentSwitcherApp + + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DataTableMarkdown + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ─────────────────────────────────────────── + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Three Flavours Cornetto + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + The Three Flavours Cornetto trilogy + is an anthology series of British + comedic genre films directed by  + Edgar Wright. + + Shaun of the Dead + +  Flavour     UK Release Date  Dir + + + Hot Fuzz + +  Flavour   UK Release Date  Direc + + + The World's End + +  Flavour  UK Release Date  Direct + + + + + + + + + + + + + + + + ─────────────────────────────────────────── + + + + + + + + ''' +# --- # name: test_css_property[align.py] ''' @@ -14707,140 +15141,140 @@ font-weight: 700; } - .terminal-2166823333-matrix { + .terminal-4078770422-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2166823333-title { + .terminal-4078770422-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2166823333-r1 { fill: #e1e1e1 } - .terminal-2166823333-r2 { fill: #121212 } - .terminal-2166823333-r3 { fill: #c5c8c6 } - .terminal-2166823333-r4 { fill: #0053aa } - .terminal-2166823333-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2166823333-r6 { fill: #939393;font-weight: bold } - .terminal-2166823333-r7 { fill: #24292f } - .terminal-2166823333-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-2166823333-r9 { fill: #4ebf71;font-weight: bold } - .terminal-2166823333-r10 { fill: #e1e1e1;font-style: italic; } - .terminal-2166823333-r11 { fill: #e1e1e1;font-weight: bold } + .terminal-4078770422-r1 { fill: #e1e1e1 } + .terminal-4078770422-r2 { fill: #121212 } + .terminal-4078770422-r3 { fill: #c5c8c6 } + .terminal-4078770422-r4 { fill: #0053aa } + .terminal-4078770422-r5 { fill: #dde8f3;font-weight: bold } + .terminal-4078770422-r6 { fill: #939393;font-weight: bold } + .terminal-4078770422-r7 { fill: #24292f } + .terminal-4078770422-r8 { fill: #e2e3e3;font-weight: bold } + .terminal-4078770422-r9 { fill: #4ebf71;font-weight: bold } + .terminal-4078770422-r10 { fill: #e1e1e1;font-style: italic; } + .terminal-4078770422-r11 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - + - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Markdown Document - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's Markdown widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ⏺ Typography emphasisstronginline code etc. - ⏺ Headers - ⏺ Lists (bullet and ordered) - ⏺ Syntax highlighted code blocks - ⏺ Tables! - - - - + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Markdown Document + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's Markdown widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + + + @@ -14871,145 +15305,145 @@ font-weight: 700; } - .terminal-3185906023-matrix { + .terminal-2722304184-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3185906023-title { + .terminal-2722304184-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3185906023-r1 { fill: #c5c8c6 } - .terminal-3185906023-r2 { fill: #24292f } - .terminal-3185906023-r3 { fill: #121212 } - .terminal-3185906023-r4 { fill: #e1e1e1 } - .terminal-3185906023-r5 { fill: #e2e3e3 } - .terminal-3185906023-r6 { fill: #96989b } - .terminal-3185906023-r7 { fill: #0053aa } - .terminal-3185906023-r8 { fill: #008139 } - .terminal-3185906023-r9 { fill: #dde8f3;font-weight: bold } - .terminal-3185906023-r10 { fill: #939393;font-weight: bold } - .terminal-3185906023-r11 { fill: #e2e3e3;font-weight: bold } - .terminal-3185906023-r12 { fill: #14191f } - .terminal-3185906023-r13 { fill: #4ebf71;font-weight: bold } - .terminal-3185906023-r14 { fill: #e1e1e1;font-style: italic; } - .terminal-3185906023-r15 { fill: #e1e1e1;font-weight: bold } + .terminal-2722304184-r1 { fill: #c5c8c6 } + .terminal-2722304184-r2 { fill: #24292f } + .terminal-2722304184-r3 { fill: #121212 } + .terminal-2722304184-r4 { fill: #e1e1e1 } + .terminal-2722304184-r5 { fill: #e2e3e3 } + .terminal-2722304184-r6 { fill: #96989b } + .terminal-2722304184-r7 { fill: #0053aa } + .terminal-2722304184-r8 { fill: #008139 } + .terminal-2722304184-r9 { fill: #dde8f3;font-weight: bold } + .terminal-2722304184-r10 { fill: #939393;font-weight: bold } + .terminal-2722304184-r11 { fill: #e2e3e3;font-weight: bold } + .terminal-2722304184-r12 { fill: #14191f } + .terminal-2722304184-r13 { fill: #4ebf71;font-weight: bold } + .terminal-2722304184-r14 { fill: #e1e1e1;font-style: italic; } + .terminal-2722304184-r15 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - + - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼  Markdown Viewer - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features▇▇ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ⏺ Typography emphasisstronginline code - etc. - ⏺ Headers - ⏺ Lists (bullet and ordered) - ⏺ Syntax highlighted code blocks - ⏺ Tables! - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼  Markdown Viewer + ├──  FeaturesMarkdown Viewer + ├──  Tables + └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features▇▇ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ● Typography emphasisstronginline code + etc. + ● Headers + ● Lists (bullet and ordered) + ● Syntax highlighted code blocks + ● Tables! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 26e044060..7d338f1d5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,3 +1,4 @@ +from os import terminal_size from pathlib import Path import pytest @@ -164,6 +165,16 @@ def test_radio_set_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "radio_set.py") +def test_content_switcher_example_initial(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "content_switcher.py") + + +def test_content_switcher_example_switch(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "content_switcher.py", press=[ + "tab", "tab", "enter", "wait:500" + ], terminal_size=(50, 50)) + + # --- 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_content_switcher.py b/tests/test_content_switcher.py new file mode 100644 index 000000000..8c194b5a9 --- /dev/null +++ b/tests/test_content_switcher.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import pytest + +from textual.app import App, ComposeResult +from textual.css.query import NoMatches +from textual.widget import Widget +from textual.widgets import ContentSwitcher + + +class SwitcherApp(App[None]): + def __init__(self, initial: str | None = None) -> None: + super().__init__() + self._initial = initial + + def compose(self) -> ComposeResult: + with ContentSwitcher(initial=self._initial): + for n in range(5): + yield Widget(id=f"w{n}") + + +async def test_no_initial_display() -> None: + """Test starting a content switcher with nothing shown.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + + +async def test_initial_display() -> None: + """Test starting a content switcher with a widget initially shown.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + + +async def test_no_initial_display_then_set() -> None: + """Test starting a content switcher with nothing shown then setting the display.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + pilot.app.query_one(ContentSwitcher).current = "w3" + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + + +async def test_initial_display_then_change() -> None: + """Test starting a content switcher with a widget initially shown then changing it.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + pilot.app.query_one(ContentSwitcher).current = "w2" + assert pilot.app.query_one(ContentSwitcher).current == "w2" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w2") + + +async def test_initial_display_then_hide() -> None: + """Test starting a content switcher with a widget initially shown then hide all.""" + async with SwitcherApp("w3").run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current == "w3" + for child in pilot.app.query_one(ContentSwitcher).children: + assert child.display is (child.id == "w3") + pilot.app.query_one(ContentSwitcher).current = None + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + + +@pytest.mark.xfail( + reason="The expected exception doesn't appear to make it to pytest -- perhaps related to https://github.com/Textualize/textual/issues/1972" +) +async def test_initial_display_unknown_id() -> None: + """Test setting an initial display to an unknown widget ID.""" + with pytest.raises(NoMatches): + async with SwitcherApp("does-not-exist").run_test(): + pass + + +async def test_set_current_to_unknown_id() -> None: + """Test attempting to switch to an unknown widget ID.""" + async with SwitcherApp().run_test() as pilot: + assert pilot.app.query_one(ContentSwitcher).current is None + assert all( + not child.display for child in pilot.app.query_one(ContentSwitcher).children + ) + with pytest.raises(NoMatches): + pilot.app.query_one(ContentSwitcher).current = "does-not-exist"