From 3203c4e695b119ad6f361008abb487e976f6c636 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:34:23 +0000 Subject: [PATCH 001/149] Add auxiliary module for widget navigation. Implement a simple API to handle navigation inside sequences of possibly-disabled candidates. --- src/textual/_widget_navigation.py | 139 ++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 src/textual/_widget_navigation.py diff --git a/src/textual/_widget_navigation.py b/src/textual/_widget_navigation.py new file mode 100644 index 0000000000..ddd6c24f00 --- /dev/null +++ b/src/textual/_widget_navigation.py @@ -0,0 +1,139 @@ +""" +Utilities to move index-based selections backward/forward. + +These utilities concern themselves with selections where not all options are available, +otherwise it would be enough to increment/decrement the index and use the operator `%` +to implement wrapping. +""" + +from __future__ import annotations + +from functools import partial +from typing import TYPE_CHECKING, Literal, Protocol, Sequence + +from typing_extensions import TypeAlias, TypeVar + +if TYPE_CHECKING: + from .widget import Widget + + +class Disableable(Protocol): + """Non-widgets that have an enabled/disabled status.""" + + disabled: bool + + +Direction: TypeAlias = Literal[-1, 1] +"""Valid values to determine navigation direction. + +In a vertical setting, 1 points down and -1 points up. +In a horizontal setting, 1 points right and -1 points left. +""" +_D = TypeVar("_D", bound=Disableable) +_W = TypeVar("_W", bound="Widget") + + +def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: + """Computes the distance going from `start` to `index` in the given direction + + Starting at `start`, this is the number of steps you need to take in the given + `direction` to reach `index`, assuming there is wrapping at 0 and `wrap_at`. + This is also the smallest non-negative integer solution `d` to + `(start + d * direction) % wrap_at == index`. + + The diagram below schematises the computation of `d1 = distance(2, 8, 1, 10)` and + `d2 = distance(2, 8, -1, 10)`: + + start ────────────────────┐ + index ────────┐ │ + indices 0 1 2 3 4 5 6 7 8 9 + d1 2 3 4 0 1 + > > > > > (direction == 1) + d2 6 5 4 3 2 1 0 + < < < < < < < (direction == -1) + + Args: + index: The index that we want to reach. + start: The starting point to consider when computing the distance. + direction: The direction in which we want to compute the distance. + wrap_at: Controls at what point wrapping around takes place. + + Returns: + The computed distance. + """ + return direction * (index - start) % wrap_at + + +def find_first_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: + """Find the first enabled candidate in a sequence of possibly-disabled objects. + + Args: + candidates: The sequence of candidates to consider. + + Returns: + The first enabled candidate or `None` if none were available. + """ + return next( + (index for index, candidate in enumerate(candidates) if not candidate.disabled), + None, + ) + + +def find_last_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: + """Find the last enabled candidate in a sequence of possibly-disabled objects. + + Args: + candidates: The sequence of candidates to consider. + + Returns: + The last enabled candidate or `None` if none were available. + """ + total_candidates = len(candidates) + return next( + ( + total_candidates - offset_from_end + for offset_from_end, candidate in enumerate(reversed(candidates), start=1) + if not candidate.disabled + ), + None, + ) + + +def find_next_enabled( + candidates: Sequence[_D] | Sequence[_W], anchor: int | None, direction: Direction +) -> int | None: + """Find the next enabled candidate if we're currently at the given anchor. + + The definition of "next" depends on the given direction and this function will wrap + around the ends of the sequence of candidates. + + Args: + candidates: The sequence of candidates to consider. + anchor: The point of the sequence from which we'll start looking for the next + candidate. + direction: The direction in which to traverse the candidates when looking for + the next available candidate. + + Returns: + The next enabled candidate. If none are available, return `None`. + """ + + if anchor is None: + if candidates: + return ( + find_first_enabled(candidates) + if direction == 1 + else find_last_enabled(candidates) + ) + return None + + key_function = partial( + distance, + start=anchor + direction, + direction=direction, + wrap_at=len(candidates), + ) + enabled_candidates = [ + index for index, candidate in enumerate(candidates) if not candidate.disabled + ] + return min(enabled_candidates, key=key_function, default=anchor) From 8c895b6c8e38bd6ccc3a3f1e3e22919adfca9cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:36:49 +0000 Subject: [PATCH 002/149] Refactor keyboard navigation in radio set. --- src/textual/widgets/_radio_set.py | 58 ++++++------------------------- tests/toggles/test_radioset.py | 2 +- 2 files changed, 12 insertions(+), 48 deletions(-) diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 8223e69713..2946eabf4a 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -2,11 +2,11 @@ from __future__ import annotations -from contextlib import suppress -from typing import ClassVar, Literal, Optional +from typing import ClassVar, Optional import rich.repr +from .. import _widget_navigation from ..binding import Binding, BindingType from ..containers import Container from ..events import Click, Mount @@ -248,59 +248,23 @@ def action_previous_button(self) -> None: Note that this will wrap around to the end if at the start. """ - self._move_selected_button(-1) + self._selected = _widget_navigation.find_next_enabled( + self.children, + anchor=self._selected, + direction=-1, + ) def action_next_button(self) -> None: """Navigate to the next button in the set. Note that this will wrap around to the start if at the end. """ - self._move_selected_button(1) - - def _move_selected_button(self, direction: Literal[-1, 1]) -> None: - """Move the selected button to the next or previous one. - - Note that this will wrap around the start/end of the button list. - - We compute the available buttons by ignoring the disabled ones and then - we induce an ordering by computing the distance to the currently selected one if - we start at the selected button and then start moving in the direction indicated. - - For example, if the direction is `1` and self._selected is 2, we have this: - selected: v - buttons: X X X X X X X - indices: 0 1 2 3 4 5 6 - distance: 5 6 0 1 2 3 4 - - Args: - direction: `1` to move to the next button and `-1` for the previous. - """ - - candidate_indices = ( - index - for index, button in enumerate(self.children) - if not button.disabled and index != self._selected + self._selected = _widget_navigation.find_next_enabled( + self.children, + anchor=self._selected, + direction=1, ) - if self._selected is None: - with suppress(StopIteration): - self._selected = next(candidate_indices) - else: - selected = self._selected - - def distance(index: int) -> int: - """Induce a distance between the given index and the selected button. - - Args: - index: The index of the button to consider. - - Returns: - The distance between the two buttons. - """ - return direction * (index - selected) % len(self.children) - - self._selected = min(candidate_indices, key=distance, default=selected) - def action_toggle(self) -> None: """Toggle the state of the currently-selected button.""" if self._selected is not None: diff --git a/tests/toggles/test_radioset.py b/tests/toggles/test_radioset.py index 457dbd2331..1bd6eeb1b0 100644 --- a/tests/toggles/test_radioset.py +++ b/tests/toggles/test_radioset.py @@ -110,7 +110,7 @@ def on_mount(self) -> None: async with EmptyRadioSetApp().run_test() as pilot: assert pilot.app.query_one(RadioSet)._selected is None await pilot.press("up") - assert pilot.app.query_one(RadioSet)._selected == 0 + assert pilot.app.query_one(RadioSet)._selected == 4 async def test_radioset_breakout_navigation(): From dc696b94663ff20405ce87ef3176b5e3881eeb49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:37:49 +0000 Subject: [PATCH 003/149] Fix keyboard navigation in option list. --- src/textual/widgets/_option_list.py | 61 ++++++++------- src/textual/widgets/_select.py | 4 + src/textual/widgets/_selection_list.py | 23 +++--- .../option_list/test_option_list_movement.py | 76 ++++++++++++++++--- tests/option_list/test_option_messages.py | 9 --- 5 files changed, 114 insertions(+), 59 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index bed8c1e01e..028f461550 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -16,6 +16,7 @@ from rich.style import Style from typing_extensions import Literal, Self, TypeAlias +from .. import _widget_navigation from ..binding import Binding, BindingType from ..events import Click, Idle, Leave, MouseMove from ..geometry import Region, Size @@ -378,8 +379,7 @@ def __init__( # Finally, cause the highlighted property to settle down based on # the state of the option list in regard to its available options. - # Be sure to have a look at validate_highlighted. - self.highlighted = None + self.action_first() def _request_content_tracking_refresh( self, rescroll_to_highlight: bool = False @@ -435,8 +435,8 @@ async def _on_click(self, event: Click) -> None: Args: event: The click event. """ - clicked_option = event.style.meta.get("option") - if clicked_option is not None: + clicked_option: int | None = event.style.meta.get("option") + if clicked_option is not None and not self._options[clicked_option].disabled: self.highlighted = clicked_option self.action_select() @@ -749,6 +749,10 @@ def _set_option_disabled(self, index: int, disabled: bool) -> Self: The `OptionList` instance. """ self._options[index].disabled = disabled + if index == self.highlighted: + self.highlighted = _widget_navigation.find_next_enabled( + self._options, anchor=index, direction=1 + ) # TODO: Refresh only if the affected option is visible. self.refresh() return self @@ -989,11 +993,16 @@ def scroll_to_highlight(self, top: bool = False) -> None: def validate_highlighted(self, highlighted: int | None) -> int | None: """Validate the `highlighted` property value on access.""" - if not self._options: + if highlighted is None: return None - if highlighted is None or highlighted < 0: - return 0 - return min(highlighted, len(self._options) - 1) + elif highlighted < 0: + return _widget_navigation.find_first_enabled(self._options) + elif highlighted >= len(self._options): + return _widget_navigation.find_last_enabled(self._options) + elif self._options[highlighted].disabled: + return self.highlighted + + return highlighted def watch_highlighted(self, highlighted: int | None) -> None: """React to the highlighted option having changed.""" @@ -1004,33 +1013,27 @@ def watch_highlighted(self, highlighted: int | None) -> None: def action_cursor_up(self) -> None: """Move the highlight up by one option.""" - if self.highlighted is not None: - if self.highlighted > 0: - self.highlighted -= 1 - else: - self.highlighted = len(self._options) - 1 - elif self._options: - self.action_first() + self.highlighted = _widget_navigation.find_next_enabled( + self._options, + anchor=self.highlighted, + direction=-1, + ) def action_cursor_down(self) -> None: """Move the highlight down by one option.""" - if self.highlighted is not None: - if self.highlighted < len(self._options) - 1: - self.highlighted += 1 - else: - self.highlighted = 0 - elif self._options: - self.action_first() + self.highlighted = _widget_navigation.find_next_enabled( + self._options, + anchor=self.highlighted, + direction=1, + ) def action_first(self) -> None: """Move the highlight to the first option.""" - if self._options: - self.highlighted = 0 + self.highlighted = _widget_navigation.find_first_enabled(self._options) def action_last(self) -> None: """Move the highlight to the last option.""" - if self._options: - self.highlighted = len(self._options) - 1 + self.highlighted = _widget_navigation.find_last_enabled(self._options) def _page(self, direction: Literal[-1, 1]) -> None: """Move the highlight by one page. @@ -1063,18 +1066,18 @@ def _page(self, direction: Literal[-1, 1]) -> None: target_option = self._lines[target_line].option_index except IndexError: # An index error suggests we've gone out of bounds, let's - # settle on whatever the call things is a good place to wrap + # settle on whatever the call thinks is a good place to wrap # to. fallback() else: # Looks like we've figured out the next option to jump to. self.highlighted = target_option - def action_page_up(self): + def action_page_up(self) -> None: """Move the highlight up one page.""" self._page(-1) - def action_page_down(self): + def action_page_down(self) -> None: """Move the highlight down one page.""" self._page(1) diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index f9d9378ce0..07eb593c36 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -519,6 +519,10 @@ def action_show_overlay(self) -> None: select_current = self.query_one(SelectCurrent) select_current.has_value = True self.expanded = True + # If we haven't opened the overlay yet, highlight the first option. + select_overlay = self.query_one(SelectOverlay) + if select_overlay.highlighted is None: + select_overlay.action_first() def is_blank(self) -> bool: """Indicates whether this `Select` is blank or not. diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index f8dece7142..c42ca6ff32 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -157,7 +157,9 @@ class SelectionList(Generic[SelectionType], OptionList): class SelectionMessage(Generic[MessageSelectionType], Message): """Base class for all selection messages.""" - def __init__(self, selection_list: SelectionList, index: int) -> None: + def __init__( + self, selection_list: SelectionList[MessageSelectionType], index: int + ) -> None: """Initialise the selection message. Args: @@ -189,14 +191,14 @@ def __rich_repr__(self) -> Result: yield "selection", self.selection yield "selection_index", self.selection_index - class SelectionHighlighted(SelectionMessage): + class SelectionHighlighted(SelectionMessage[MessageSelectionType]): """Message sent when a selection is highlighted. Can be handled using `on_selection_list_selection_highlighted` in a subclass of [`SelectionList`][textual.widgets.SelectionList] or in a parent node in the DOM. """ - class SelectionToggled(SelectionMessage): + class SelectionToggled(SelectionMessage[MessageSelectionType]): """Message sent when a selection is toggled. Can be handled using `on_selection_list_selection_toggled` in a subclass of @@ -229,7 +231,7 @@ def control(self) -> SelectionList[MessageSelectionType]: def __init__( self, - *selections: Selection + *selections: Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool], name: str | None = None, @@ -308,7 +310,10 @@ def _apply_to_all(self, state_change: Callable[[SelectionType], bool]) -> Self: # expecting a message storm from this. with self.prevent(self.SelectedChanged): for selection in self._options: - changed = state_change(cast(Selection, selection).value) or changed + changed = ( + state_change(cast(Selection[SelectionType], selection).value) + or changed + ) # If the above did make a change, *then* send a message. if changed: @@ -440,7 +445,7 @@ def toggle_all(self) -> Self: def _make_selection( self, - selection: Selection + selection: Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool], ) -> Selection[SelectionType]: @@ -632,7 +637,7 @@ def add_options( self, items: Iterable[ NewOptionListContent - | Selection + | Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool] ], @@ -655,7 +660,7 @@ def add_options( # extend the types of accepted items to keep mypy and friends happy, # but then we runtime check that we've been given sensible types (in # this case the supported tuple values). - cleaned_options: list[Selection] = [] + cleaned_options: list[Selection[SelectionType]] = [] for item in items: if isinstance(item, tuple): cleaned_options.append( @@ -677,7 +682,7 @@ def add_options( def add_option( self, item: NewOptionListContent - | Selection + | Selection[SelectionType] | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool] = None, ) -> Self: diff --git a/tests/option_list/test_option_list_movement.py b/tests/option_list/test_option_list_movement.py index d5b3d1c641..447d0ba3c8 100644 --- a/tests/option_list/test_option_list_movement.py +++ b/tests/option_list/test_option_list_movement.py @@ -2,8 +2,11 @@ from __future__ import annotations +import pytest + from textual.app import App, ComposeResult from textual.widgets import OptionList +from textual.widgets.option_list import Option class OptionListApp(App[None]): @@ -140,20 +143,69 @@ async def test_empty_list_movement() -> None: assert option_list.highlighted is None -async def test_no_highlight_movement() -> None: - """Attempting to move around in a list with no highlight should select the most appropriate item.""" - for movement, landing in ( - ("up", 0), +@pytest.mark.parametrize( + ["movement", "landing"], + [ + ("up", 99), ("down", 0), ("home", 0), ("end", 99), ("pageup", 0), ("pagedown", 99), - ): - async with EmptyOptionListApp().run_test() as pilot: - option_list = pilot.app.query_one(OptionList) - for _ in range(100): - option_list.add_option("test") - await pilot.press("tab") - await pilot.press(movement) - assert option_list.highlighted == landing + ], +) +async def test_no_highlight_movement(movement: str, landing: int) -> None: + """Attempting to move around in a list with no highlight should select the most appropriate item.""" + async with EmptyOptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + for _ in range(100): + option_list.add_option("test") + await pilot.press("tab") + await pilot.press(movement) + assert option_list.highlighted == landing + + +class OptionListDisabledOptionsApp(App[None]): + def compose(self) -> ComposeResult: + self.highlighted = [] + yield OptionList( + Option("0", disabled=True), + Option("1"), + Option("2", disabled=True), + Option("3", disabled=True), + Option("4"), + Option("5"), + Option("6", disabled=True), + Option("7"), + Option("8", disabled=True), + ) + + def _on_option_list_option_highlighted( + self, message: OptionList.OptionHighlighted + ) -> None: + self.highlighted.append(str(message.option.prompt)) + + +async def test_keyboard_navigation_with_disabled_options() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3881.""" + + app = OptionListDisabledOptionsApp() + async with app.run_test() as pilot: + for _ in range(5): + await pilot.press("down") + for _ in range(5): + await pilot.press("up") + + assert app.highlighted == [ + "1", + "4", + "5", + "7", + "1", + "4", + "1", + "7", + "5", + "4", + "1", + ] diff --git a/tests/option_list/test_option_messages.py b/tests/option_list/test_option_messages.py index e66debc458..7d0d899653 100644 --- a/tests/option_list/test_option_messages.py +++ b/tests/option_list/test_option_messages.py @@ -92,15 +92,6 @@ async def test_select_message_with_keyboard() -> None: ] -async def test_select_disabled_option_with_keyboard() -> None: - """Hitting enter on an option should result in a message.""" - async with OptionListApp().run_test() as pilot: - assert isinstance(pilot.app, OptionListApp) - pilot.app.query_one(OptionList).disable_option("1") - await pilot.press("tab", "down", "enter") - assert pilot.app.messages[1:] == [] - - async def test_click_option_with_mouse() -> None: """Clicking on an option via the mouse should result in highlight and select messages.""" async with OptionListApp().run_test() as pilot: From 5ad6249417168d03eb02f8771f23be90b33bcd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:38:54 +0000 Subject: [PATCH 004/149] Fix keyboard navigation in list view. --- src/textual/widgets/_list_item.py | 3 + src/textual/widgets/_list_view.py | 73 ++++++++++++++-------- tests/listview/test_listview_navigation.py | 42 +++++++++++++ 3 files changed, 91 insertions(+), 27 deletions(-) create mode 100644 tests/listview/test_listview_navigation.py diff --git a/src/textual/widgets/_list_item.py b/src/textual/widgets/_list_item.py index e87b8cf4fc..e450767c77 100644 --- a/src/textual/widgets/_list_item.py +++ b/src/textual/widgets/_list_item.py @@ -25,6 +25,9 @@ class ListItem(Widget, can_focus=False): background: $panel-lighten-1; overflow: hidden hidden; } + ListItem > :disabled { + background: $panel-darken-1; + } ListItem > Widget :hover { background: $boost; } diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index b102a17d20..a3667806e3 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -4,15 +4,15 @@ from typing_extensions import TypeGuard -from textual.await_remove import AwaitRemove -from textual.binding import Binding, BindingType -from textual.containers import VerticalScroll -from textual.events import Mount -from textual.geometry import clamp -from textual.message import Message -from textual.reactive import reactive -from textual.widget import AwaitMount, Widget -from textual.widgets._list_item import ListItem +from .. import _widget_navigation +from ..await_remove import AwaitRemove +from ..binding import Binding, BindingType +from ..containers import VerticalScroll +from ..events import Mount +from ..message import Message +from ..reactive import reactive +from ..widget import AwaitMount, Widget +from ..widgets._list_item import ListItem class ListView(VerticalScroll, can_focus=True, can_focus_children=False): @@ -38,7 +38,7 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False): | down | Move the cursor down. | """ - index = reactive[Optional[int]](0, always_update=True) + index = reactive[Optional[int]](0, always_update=True, init=False) """The index of the currently highlighted item.""" class Highlighted(Message): @@ -117,7 +117,12 @@ def __init__( super().__init__( *children, name=name, id=id, classes=classes, disabled=disabled ) - self._index = initial_index + # Set the index to the given initial index, or the first available index after. + self._index = _widget_navigation.find_next_enabled( + self._nodes, + anchor=initial_index - 1 if initial_index is not None else None, + direction=1, + ) def _on_mount(self, _: Mount) -> None: """Ensure the ListView is fully-settled after mounting.""" @@ -142,14 +147,16 @@ def validate_index(self, index: int | None) -> int | None: Returns: The clamped index. """ - if not self._nodes or index is None: + if index is None: return None - return self._clamp_index(index) + elif index < 0: + return _widget_navigation.find_first_enabled(self._nodes) + elif index >= len(self._nodes): + return _widget_navigation.find_last_enabled(self._nodes) + elif self._nodes[index].disabled: + return self.index - def _clamp_index(self, index: int) -> int: - """Clamp the index to a valid value given the current list of children""" - last_index = max(len(self._nodes) - 1, 0) - return clamp(index, 0, last_index) + return index def _is_valid_index(self, index: int | None) -> TypeGuard[int]: """Return True if the current index is valid given the current list of children""" @@ -165,7 +172,7 @@ def watch_index(self, old_index: int | None, new_index: int | None) -> None: old_child.highlighted = False new_child: Widget | None - if self._is_valid_index(new_index): + if self._is_valid_index(new_index) and not self._nodes[new_index].disabled: new_child = self._nodes[new_index] assert isinstance(new_child, ListItem) new_child.highlighted = True @@ -222,19 +229,30 @@ def action_select_cursor(self) -> None: def action_cursor_down(self) -> None: """Highlight the next item in the list.""" - if self.index is None: - self.index = 0 - return - self.index += 1 + candidate = _widget_navigation.find_next_enabled( + self._nodes, + anchor=self.index, + direction=1, + ) + if self.index is not None and candidate is not None and candidate < self.index: + return # Avoid wrapping around. + + self.index = candidate def action_cursor_up(self) -> None: """Highlight the previous item in the list.""" - if self.index is None: - self.index = 0 - return - self.index -= 1 + candidate = _widget_navigation.find_next_enabled( + self._nodes, + anchor=self.index, + direction=-1, + ) + if self.index is not None and candidate is not None and candidate > self.index: + return # Avoid wrapping around. + + self.index = candidate def _on_list_item__child_clicked(self, event: ListItem._ChildClicked) -> None: + event.stop() self.focus() self.index = self._nodes.index(event.item) self.post_message(self.Selected(self, event.item)) @@ -244,5 +262,6 @@ def _scroll_highlighted_region(self) -> None: if self.highlighted_child is not None: self.scroll_to_widget(self.highlighted_child, animate=False) - def __len__(self): + def __len__(self) -> int: + """Compute the length (in number of items) of the list view.""" return len(self._nodes) diff --git a/tests/listview/test_listview_navigation.py b/tests/listview/test_listview_navigation.py new file mode 100644 index 0000000000..4f3ebb4052 --- /dev/null +++ b/tests/listview/test_listview_navigation.py @@ -0,0 +1,42 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label, ListItem, ListView + + +class ListViewDisabledItemsApp(App[None]): + def compose(self) -> ComposeResult: + self.highlighted = [] + yield ListView( + ListItem(Label("0"), disabled=True), + ListItem(Label("1")), + ListItem(Label("2"), disabled=True), + ListItem(Label("3"), disabled=True), + ListItem(Label("4")), + ListItem(Label("5")), + ListItem(Label("6"), disabled=True), + ListItem(Label("7")), + ListItem(Label("8"), disabled=True), + ) + + def _on_list_view_highlighted(self, message: ListView.Highlighted) -> None: + self.highlighted.append(str(message.item.children[0].renderable)) + + +async def test_keyboard_navigation_with_disabled_items() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3881.""" + + app = ListViewDisabledItemsApp() + async with app.run_test() as pilot: + for _ in range(5): + await pilot.press("down") + for _ in range(5): + await pilot.press("up") + + assert app.highlighted == [ + "1", + "4", + "5", + "7", + "5", + "4", + "1", + ] From 3164bacbbb55ffcab7bf5d69428b6db5c471e55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 20 Dec 2023 16:39:04 +0000 Subject: [PATCH 005/149] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1772024442..3d8c9e8c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - `Widget.move_child` would break if `before`/`after` is set to the index of the widget in `child` https://github.com/Textualize/textual/issues/1743 +- Keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 ### Changed From 72bca2e67295a35fc976001de739d52b86d176d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:47:32 +0000 Subject: [PATCH 006/149] Remove unused component classes. As pointed out by Dave in https://github.com/Textualize/textual/pull/3912#discussion_r1439514599. --- src/textual/widgets/_option_list.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 028f461550..b55d264409 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -159,11 +159,9 @@ class OptionList(ScrollView, can_focus=True): "option-list--option", "option-list--option-disabled", "option-list--option-highlighted", - "option-list--option-highlighted-disabled", "option-list--option-hover", "option-list--option-hover-disabled", "option-list--option-hover-highlighted", - "option-list--option-hover-highlighted-disabled", "option-list--separator", } """ @@ -171,11 +169,9 @@ class OptionList(ScrollView, can_focus=True): | :- | :- | | `option-list--option-disabled` | Target disabled options. | | `option-list--option-highlighted` | Target the highlighted option. | - | `option-list--option-highlighted-disabled` | Target a disabled option that is also highlighted. | | `option-list--option-hover` | Target an option that has the mouse over it. | | `option-list--option-hover-disabled` | Target a disabled option that has the mouse over it. | | `option-list--option-hover-highlighted` | Target a highlighted option that has the mouse over it. | - | `option-list--option-hover-highlighted-disabled` | Target a disabled highlighted option that has the mouse over it. | | `option-list--separator` | Target the separators. | """ @@ -211,15 +207,6 @@ class OptionList(ScrollView, can_focus=True): color: $text-disabled; } - OptionList > .option-list--option-highlighted-disabled { - color: $text-disabled; - background: $accent 20%; - } - - OptionList:focus > .option-list--option-highlighted-disabled { - background: $accent 30%; - } - OptionList > .option-list--option-hover { background: $boost; } @@ -240,11 +227,6 @@ class OptionList(ScrollView, can_focus=True): color: $text; text-style: bold; } - - OptionList > .option-list--option-hover-highlighted-disabled { - color: $text-disabled; - background: $accent 60%; - } """ highlighted: reactive[int | None] = reactive["int | None"](None) @@ -922,15 +904,6 @@ def render_line(self, y: int) -> Strip: # Handle drawing a disabled option. if self._options[option_index].disabled: - # Disabled but the highlight? - if option_index == highlighted: - return strip.apply_style( - self.get_component_rich_style( - "option-list--option-hover-highlighted-disabled" - if option_index == mouse_over - else "option-list--option-highlighted-disabled" - ) - ) # Disabled but mouse hover? if option_index == mouse_over: return strip.apply_style( From f3f274744e53f77c927b50dbd0135a541b7272e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 4 Jan 2024 14:57:12 +0000 Subject: [PATCH 007/149] Simplify docstring language. As per review comment: https://github.com/Textualize/textual/pull/3912#discussion_r1439491581 Co-authored-by: Dave Pearson --- src/textual/_widget_navigation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_widget_navigation.py b/src/textual/_widget_navigation.py index ddd6c24f00..62f1fb2505 100644 --- a/src/textual/_widget_navigation.py +++ b/src/textual/_widget_navigation.py @@ -41,7 +41,7 @@ def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: This is also the smallest non-negative integer solution `d` to `(start + d * direction) % wrap_at == index`. - The diagram below schematises the computation of `d1 = distance(2, 8, 1, 10)` and + The diagram below illustrates the computation of `d1 = distance(2, 8, 1, 10)` and `d2 = distance(2, 8, -1, 10)`: start ────────────────────┐ From 2b1e4e0f73d253a23b37290cb75dd6024a0ac4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 4 Jan 2024 15:16:39 +0000 Subject: [PATCH 008/149] Publish module widget_navigation. See review comment https://github.com/Textualize/textual/pull/3912#discussion_r1439489659. --- docs/api/widget_navigation.md | 5 +++++ mkdocs-nav.yml | 1 + ...widget_navigation.py => widget_navigation.py} | 4 +++- src/textual/widgets/_list_view.py | 12 ++++++------ src/textual/widgets/_option_list.py | 16 ++++++++-------- src/textual/widgets/_radio_set.py | 6 +++--- 6 files changed, 26 insertions(+), 18 deletions(-) create mode 100644 docs/api/widget_navigation.md rename src/textual/{_widget_navigation.py => widget_navigation.py} (99%) diff --git a/docs/api/widget_navigation.md b/docs/api/widget_navigation.md new file mode 100644 index 0000000000..58872c40d2 --- /dev/null +++ b/docs/api/widget_navigation.md @@ -0,0 +1,5 @@ +::: textual.widget_navigation + options: + filters: + - "!^_" + - "^__init__$" diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 68f1723917..87d2d35d26 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -208,6 +208,7 @@ nav: - "api/validation.md" - "api/walk.md" - "api/widget.md" + - "api/widget_navigation.md" - "api/work.md" - "api/worker.md" - "api/worker_manager.md" diff --git a/src/textual/_widget_navigation.py b/src/textual/widget_navigation.py similarity index 99% rename from src/textual/_widget_navigation.py rename to src/textual/widget_navigation.py index 62f1fb2505..47e597284e 100644 --- a/src/textual/_widget_navigation.py +++ b/src/textual/widget_navigation.py @@ -34,7 +34,7 @@ class Disableable(Protocol): def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: - """Computes the distance going from `start` to `index` in the given direction + """Computes the distance going from `start` to `index` in the given direction. Starting at `start`, this is the number of steps you need to take in the given `direction` to reach `index`, assuming there is wrapping at 0 and `wrap_at`. @@ -44,6 +44,7 @@ def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: The diagram below illustrates the computation of `d1 = distance(2, 8, 1, 10)` and `d2 = distance(2, 8, -1, 10)`: + ``` start ────────────────────┐ index ────────┐ │ indices 0 1 2 3 4 5 6 7 8 9 @@ -51,6 +52,7 @@ def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: > > > > > (direction == 1) d2 6 5 4 3 2 1 0 < < < < < < < (direction == -1) + ``` Args: index: The index that we want to reach. diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index a3667806e3..14436ab0f2 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -4,7 +4,7 @@ from typing_extensions import TypeGuard -from .. import _widget_navigation +from .. import widget_navigation from ..await_remove import AwaitRemove from ..binding import Binding, BindingType from ..containers import VerticalScroll @@ -118,7 +118,7 @@ def __init__( *children, name=name, id=id, classes=classes, disabled=disabled ) # Set the index to the given initial index, or the first available index after. - self._index = _widget_navigation.find_next_enabled( + self._index = widget_navigation.find_next_enabled( self._nodes, anchor=initial_index - 1 if initial_index is not None else None, direction=1, @@ -150,9 +150,9 @@ def validate_index(self, index: int | None) -> int | None: if index is None: return None elif index < 0: - return _widget_navigation.find_first_enabled(self._nodes) + return widget_navigation.find_first_enabled(self._nodes) elif index >= len(self._nodes): - return _widget_navigation.find_last_enabled(self._nodes) + return widget_navigation.find_last_enabled(self._nodes) elif self._nodes[index].disabled: return self.index @@ -229,7 +229,7 @@ def action_select_cursor(self) -> None: def action_cursor_down(self) -> None: """Highlight the next item in the list.""" - candidate = _widget_navigation.find_next_enabled( + candidate = widget_navigation.find_next_enabled( self._nodes, anchor=self.index, direction=1, @@ -241,7 +241,7 @@ def action_cursor_down(self) -> None: def action_cursor_up(self) -> None: """Highlight the previous item in the list.""" - candidate = _widget_navigation.find_next_enabled( + candidate = widget_navigation.find_next_enabled( self._nodes, anchor=self.index, direction=-1, diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index b55d264409..8a0709160e 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -16,7 +16,7 @@ from rich.style import Style from typing_extensions import Literal, Self, TypeAlias -from .. import _widget_navigation +from .. import widget_navigation from ..binding import Binding, BindingType from ..events import Click, Idle, Leave, MouseMove from ..geometry import Region, Size @@ -732,7 +732,7 @@ def _set_option_disabled(self, index: int, disabled: bool) -> Self: """ self._options[index].disabled = disabled if index == self.highlighted: - self.highlighted = _widget_navigation.find_next_enabled( + self.highlighted = widget_navigation.find_next_enabled( self._options, anchor=index, direction=1 ) # TODO: Refresh only if the affected option is visible. @@ -969,9 +969,9 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: if highlighted is None: return None elif highlighted < 0: - return _widget_navigation.find_first_enabled(self._options) + return widget_navigation.find_first_enabled(self._options) elif highlighted >= len(self._options): - return _widget_navigation.find_last_enabled(self._options) + return widget_navigation.find_last_enabled(self._options) elif self._options[highlighted].disabled: return self.highlighted @@ -986,7 +986,7 @@ def watch_highlighted(self, highlighted: int | None) -> None: def action_cursor_up(self) -> None: """Move the highlight up by one option.""" - self.highlighted = _widget_navigation.find_next_enabled( + self.highlighted = widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, direction=-1, @@ -994,7 +994,7 @@ def action_cursor_up(self) -> None: def action_cursor_down(self) -> None: """Move the highlight down by one option.""" - self.highlighted = _widget_navigation.find_next_enabled( + self.highlighted = widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, direction=1, @@ -1002,11 +1002,11 @@ def action_cursor_down(self) -> None: def action_first(self) -> None: """Move the highlight to the first option.""" - self.highlighted = _widget_navigation.find_first_enabled(self._options) + self.highlighted = widget_navigation.find_first_enabled(self._options) def action_last(self) -> None: """Move the highlight to the last option.""" - self.highlighted = _widget_navigation.find_last_enabled(self._options) + self.highlighted = widget_navigation.find_last_enabled(self._options) def _page(self, direction: Literal[-1, 1]) -> None: """Move the highlight by one page. diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 2946eabf4a..52de72b2f5 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -6,7 +6,7 @@ import rich.repr -from .. import _widget_navigation +from .. import widget_navigation from ..binding import Binding, BindingType from ..containers import Container from ..events import Click, Mount @@ -248,7 +248,7 @@ def action_previous_button(self) -> None: Note that this will wrap around to the end if at the start. """ - self._selected = _widget_navigation.find_next_enabled( + self._selected = widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=-1, @@ -259,7 +259,7 @@ def action_next_button(self) -> None: Note that this will wrap around to the start if at the end. """ - self._selected = _widget_navigation.find_next_enabled( + self._selected = widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=1, From 7cbc4d550eb1460a9ad2c385832420cc3cf159bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:14:19 +0000 Subject: [PATCH 009/149] Test widget navigation module. See https://github.com/Textualize/textual/pull/3912#discussion_r1439490041. --- src/textual/widget_navigation.py | 2 +- tests/test_widget_navigation.py | 97 ++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 tests/test_widget_navigation.py diff --git a/src/textual/widget_navigation.py b/src/textual/widget_navigation.py index 47e597284e..df404842a6 100644 --- a/src/textual/widget_navigation.py +++ b/src/textual/widget_navigation.py @@ -117,7 +117,7 @@ def find_next_enabled( the next available candidate. Returns: - The next enabled candidate. If none are available, return `None`. + The next enabled candidate. If none are available, return the anchor. """ if anchor is None: diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py new file mode 100644 index 0000000000..53c1e25a75 --- /dev/null +++ b/tests/test_widget_navigation.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest + +from textual.widget_navigation import ( + distance, + find_first_enabled, + find_last_enabled, + find_next_enabled, +) + + +class _D: + def __init__(self, disabled): + self.disabled = disabled + + +# Represent disabled/enabled objects that are compact to write in tests. +D = _D(True) +E = _D(False) + + +@pytest.mark.parametrize( + ["index", "start", "direction", "wrap_at", "dist"], + [ + (2, 8, 1, 10, 4), + (2, 8, -1, 10, 6), + (8, 2, -1, 10, 4), + (8, 2, 1, 10, 6), + (8, 2, 1, 1234123512, 6), + (2, 8, 1, 11, 5), + (2, 8, 1, 12, 6), + (5, 5, 1, 10, 0), + ], +) +def test_distance(index, start, direction, wrap_at, dist): + assert ( + distance( + index=index, + start=start, + direction=direction, + wrap_at=wrap_at, + ) + == dist + ) + + +@pytest.mark.parametrize( + "function", + [ + find_first_enabled, + find_last_enabled, + ], +) +def test_find_enabled_returns_none_on_empty(function): + assert function([]) is None + + +@pytest.mark.parametrize( + ["candidates", "anchor", "direction", "result"], + [ + # No anchor & no candidates -> no next + ([], None, 1, None), + ([], None, -1, None), + # No anchor but candidates -> get first/last one + ([E], None, 1, 0), + ([E, D], None, 1, 0), + ([E, E], None, 1, 0), + ([D, E], None, 1, 1), + ([D, D, E], None, 1, 2), + ([E], None, -1, 0), + ([E, E], None, -1, 1), + ([E, D], None, -1, 0), + ([E, D, D], None, -1, 0), + # No enabled candidates -> return the anchor + ([D, D, D], 0, 1, 0), + ([D, D, D], 1, 1, 1), + ([D, D, D], 1, -1, 1), + ([D, D, D], None, -1, None), + # General case + # 0 1 2 3 4 5 + ([E, D, D, E, E, D], 0, 1, 3), + ([E, D, D, E, E, D], 0, -1, 4), + ([E, D, D, E, E, D], 1, 1, 3), + ([E, D, D, E, E, D], 1, -1, 0), + ([E, D, D, E, E, D], 2, 1, 3), + ([E, D, D, E, E, D], 2, -1, 0), + ([E, D, D, E, E, D], 3, 1, 4), + ([E, D, D, E, E, D], 3, -1, 0), + ([E, D, D, E, E, D], 4, 1, 0), + ([E, D, D, E, E, D], 4, -1, 3), + ([E, D, D, E, E, D], 5, 1, 0), + ([E, D, D, E, E, D], 5, -1, 4), + ], +) +def test_find_next_enabled(candidates, anchor, direction, result): + assert find_next_enabled(candidates, anchor, direction) == result From b06bf490404b6ba6b34c7527dffa1ab6bf0368c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 4 Jan 2024 17:21:57 +0000 Subject: [PATCH 010/149] Remove unused component class. https://github.com/Textualize/textual/pull/3912#discussion_r1441856028. --- src/textual/widgets/_option_list.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 8a0709160e..9e890a1fe3 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -160,7 +160,6 @@ class OptionList(ScrollView, can_focus=True): "option-list--option-disabled", "option-list--option-highlighted", "option-list--option-hover", - "option-list--option-hover-disabled", "option-list--option-hover-highlighted", "option-list--separator", } @@ -170,7 +169,6 @@ class OptionList(ScrollView, can_focus=True): | `option-list--option-disabled` | Target disabled options. | | `option-list--option-highlighted` | Target the highlighted option. | | `option-list--option-hover` | Target an option that has the mouse over it. | - | `option-list--option-hover-disabled` | Target a disabled option that has the mouse over it. | | `option-list--option-hover-highlighted` | Target a highlighted option that has the mouse over it. | | `option-list--separator` | Target the separators. | """ @@ -211,11 +209,6 @@ class OptionList(ScrollView, can_focus=True): background: $boost; } - OptionList > .option-list--option-hover-disabled { - color: $text-disabled; - background: $boost; - } - OptionList > .option-list--option-hover-highlighted { background: $accent 60%; color: $text; @@ -904,12 +897,6 @@ def render_line(self, y: int) -> Strip: # Handle drawing a disabled option. if self._options[option_index].disabled: - # Disabled but mouse hover? - if option_index == mouse_over: - return strip.apply_style( - self.get_component_rich_style("option-list--option-hover-disabled") - ) - # Just a normal disabled option. return strip.apply_style( self.get_component_rich_style("option-list--option-disabled") ) From 296df6a1101ee056ce01f59cdb13d6ce13941211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:02:00 +0000 Subject: [PATCH 011/149] Add widget navigation without wrapping. --- src/textual/widget_navigation.py | 67 ++++++++++++++++++++++++++++---- tests/test_widget_navigation.py | 63 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/textual/widget_navigation.py b/src/textual/widget_navigation.py index df404842a6..19fc7d3ace 100644 --- a/src/textual/widget_navigation.py +++ b/src/textual/widget_navigation.py @@ -9,6 +9,7 @@ from __future__ import annotations from functools import partial +from itertools import count from typing import TYPE_CHECKING, Literal, Protocol, Sequence from typing_extensions import TypeAlias, TypeVar @@ -102,22 +103,27 @@ def find_last_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: def find_next_enabled( - candidates: Sequence[_D] | Sequence[_W], anchor: int | None, direction: Direction + candidates: Sequence[_D] | Sequence[_W], + anchor: int | None, + direction: Direction, + with_anchor: bool = False, ) -> int | None: - """Find the next enabled candidate if we're currently at the given anchor. + """Find the next enabled object if we're currently at the given anchor. The definition of "next" depends on the given direction and this function will wrap - around the ends of the sequence of candidates. + around the ends of the sequence of object candidates. Args: - candidates: The sequence of candidates to consider. + candidates: The sequence of object candidates to consider. anchor: The point of the sequence from which we'll start looking for the next - candidate. + enabled object. direction: The direction in which to traverse the candidates when looking for - the next available candidate. + the next enabled candidate. + with_anchor: Consider the anchor position as the first valid position instead of + the last one. Returns: - The next enabled candidate. If none are available, return the anchor. + The next enabled object. If none are available, return the anchor. """ if anchor is None: @@ -129,9 +135,10 @@ def find_next_enabled( ) return None + start = anchor + direction if not with_anchor else anchor key_function = partial( distance, - start=anchor + direction, + start=start, direction=direction, wrap_at=len(candidates), ) @@ -139,3 +146,47 @@ def find_next_enabled( index for index, candidate in enumerate(candidates) if not candidate.disabled ] return min(enabled_candidates, key=key_function, default=anchor) + + +def find_next_enabled_no_wrap( + candidates: Sequence[_D] | Sequence[_W], + anchor: int | None, + direction: Direction, + with_anchor: bool = False, +) -> int | None: + """Find the next enabled object starting from the given anchor (without wrapping). + + The meaning of "next" and "past" depend on the direction specified. + + Args: + candidates: The sequence of object candidates to consider. + anchor: The point of the sequence from which we'll start looking for the next + enabled object. + direction: The direction in which to traverse the candidates when looking for + the next enabled candidate. + with_anchor: Whether to consider the anchor or not. + + Returns: + The next enabled object. If none are available, return None. + """ + + if anchor is None: + if candidates: + return ( + find_first_enabled(candidates) + if direction == 1 + else find_last_enabled(candidates) + ) + return None + + start = anchor if with_anchor else anchor + direction + counter = count(start, direction) + valid_candidates = ( + candidates[start:] if direction == 1 else reversed(candidates[: start + 1]) + ) + + for idx, candidate in zip(counter, valid_candidates): + if candidate.disabled: + continue + return idx + return None diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py index 53c1e25a75..55b1a69476 100644 --- a/tests/test_widget_navigation.py +++ b/tests/test_widget_navigation.py @@ -7,6 +7,7 @@ find_first_enabled, find_last_enabled, find_next_enabled, + find_next_enabled_no_wrap, ) @@ -95,3 +96,65 @@ def test_find_enabled_returns_none_on_empty(function): ) def test_find_next_enabled(candidates, anchor, direction, result): assert find_next_enabled(candidates, anchor, direction) == result + + +@pytest.mark.parametrize( + ["candidates", "anchor", "direction", "result"], + [ + # No anchor & no candidates -> no next + ([], None, 1, None), + ([], None, -1, None), + # No anchor but candidates -> get first/last one + ([E], None, 1, 0), + ([E, D], None, 1, 0), + ([E, E], None, 1, 0), + ([D, E], None, 1, 1), + ([D, D, E], None, 1, 2), + ([E], None, -1, 0), + ([E, E], None, -1, 1), + ([E, D], None, -1, 0), + ([E, D, D], None, -1, 0), + # No enabled candidates -> return None + ([D, D, D], 0, 1, None), + ([D, D, D], 1, 1, None), + ([D, D, D], 1, -1, None), + ([D, D, D], None, -1, None), + # General case + # 0 1 2 3 4 5 + ([E, D, D, E, E, D], 0, 1, 3), + ([E, D, D, E, E, D], 0, -1, None), + ([E, D, D, E, E, D], 1, 1, 3), + ([E, D, D, E, E, D], 1, -1, 0), + ([E, D, D, E, E, D], 2, 1, 3), + ([E, D, D, E, E, D], 2, -1, 0), + ([E, D, D, E, E, D], 3, 1, 4), + ([E, D, D, E, E, D], 3, -1, 0), + ([E, D, D, E, E, D], 4, 1, None), + ([E, D, D, E, E, D], 4, -1, 3), + ([E, D, D, E, E, D], 5, 1, None), + ([E, D, D, E, E, D], 5, -1, 4), + ], +) +def test_find_next_enabled_no_wrap(candidates, anchor, direction, result): + assert find_next_enabled_no_wrap(candidates, anchor, direction) == result + + +@pytest.mark.parametrize( + ["function", "start", "direction"], + [ + (find_next_enabled, 0, 1), + (find_next_enabled, 0, -1), + (find_next_enabled_no_wrap, 0, 1), + (find_next_enabled_no_wrap, 0, -1), + (find_next_enabled, 1, 1), + (find_next_enabled, 1, -1), + (find_next_enabled_no_wrap, 1, 1), + (find_next_enabled_no_wrap, 1, -1), + (find_next_enabled, 2, 1), + (find_next_enabled, 2, -1), + (find_next_enabled_no_wrap, 2, 1), + (find_next_enabled_no_wrap, 2, -1), + ], +) +def test_find_next_with_anchor(function, start, direction): + assert function([E, E, E], start, direction, True) == start From 352d3ad4a3accb2e9dbc63f7c0b5c66dcb46ed3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:02:33 +0000 Subject: [PATCH 012/149] Fix paging in option list. See https://github.com/Textualize/textual/pull/3912#discussion_r1439504428. --- src/textual/widgets/_option_list.py | 44 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 9e890a1fe3..14dd317206 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -14,7 +14,7 @@ from rich.repr import Result from rich.rule import Rule from rich.style import Style -from typing_extensions import Literal, Self, TypeAlias +from typing_extensions import Self, TypeAlias from .. import widget_navigation from ..binding import Binding, BindingType @@ -24,6 +24,7 @@ from ..reactive import reactive from ..scroll_view import ScrollView from ..strip import Strip +from ..widget_navigation import Direction class DuplicateID(Exception): @@ -930,7 +931,6 @@ def scroll_to_highlight(self, top: bool = False) -> None: Args: top: Scroll highlight to top of the list. - """ highlighted = self.highlighted if highlighted is None: @@ -972,7 +972,7 @@ def watch_highlighted(self, highlighted: int | None) -> None: self.post_message(self.OptionHighlighted(self, highlighted)) def action_cursor_up(self) -> None: - """Move the highlight up by one option.""" + """Move the highlight up to the previous enabled option.""" self.highlighted = widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, @@ -980,7 +980,7 @@ def action_cursor_up(self) -> None: ) def action_cursor_down(self) -> None: - """Move the highlight down by one option.""" + """Move the highlight down to the next enabled option.""" self.highlighted = widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, @@ -988,15 +988,21 @@ def action_cursor_down(self) -> None: ) def action_first(self) -> None: - """Move the highlight to the first option.""" + """Move the highlight to the first enabled option.""" self.highlighted = widget_navigation.find_first_enabled(self._options) def action_last(self) -> None: - """Move the highlight to the last option.""" + """Move the highlight to the last enabled option.""" self.highlighted = widget_navigation.find_last_enabled(self._options) - def _page(self, direction: Literal[-1, 1]) -> None: - """Move the highlight by one page. + def _page(self, direction: Direction) -> None: + """Move the highlight roughly by one page in the given direction. + + The highlight will tentatively move by exactly one page. + If this would result in highlighting a disabled option, instead we look for + an enabled option "further down" the list of options. + If there are no such enabled options, we fallback to the "last" enabled option. + (The meaning of "further down" and "last" depend on the direction specified.) Args: direction: The direction to head, -1 for up and 1 for down. @@ -1030,15 +1036,29 @@ def _page(self, direction: Literal[-1, 1]) -> None: # to. fallback() else: - # Looks like we've figured out the next option to jump to. - self.highlighted = target_option + # Looks like we've figured where we'd like to jump to, we + # just need to make sure we jump to an option that's enabled. + if target_option is not None: + target_option = widget_navigation.find_next_enabled_no_wrap( + candidates=self._options, + anchor=target_option, + direction=direction, + with_anchor=True, + ) + # If we couldn't find an enabled option that's at least one page + # away from the current one, we instead move less than one page + # to the last enabled option in the correct direction. + if target_option is None: + fallback() + else: + self.highlighted = target_option def action_page_up(self) -> None: - """Move the highlight up one page.""" + """Move the highlight up roughly by one page.""" self._page(-1) def action_page_down(self) -> None: - """Move the highlight down one page.""" + """Move the highlight down roughly by one page.""" self._page(1) def action_select(self) -> None: From 1f24f27405b19af7cf821e309665c693e20a47b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:22:18 +0000 Subject: [PATCH 013/149] Docstring fix. --- src/textual/widgets/_list_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 14436ab0f2..d922797e49 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -159,7 +159,7 @@ def validate_index(self, index: int | None) -> int | None: return index def _is_valid_index(self, index: int | None) -> TypeGuard[int]: - """Return True if the current index is valid given the current list of children""" + """Determine whether the current index is valid into the list of children.""" if index is None: return False return 0 <= index < len(self._nodes) From 4b099bcb64b430820aaee4db1af1e816185ec85e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Fri, 5 Jan 2024 14:24:02 +0000 Subject: [PATCH 014/149] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3eff97aef..89627300ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added textual.lazy https://github.com/Textualize/textual/pull/3936 - Added App.push_screen_wait https://github.com/Textualize/textual/pull/3955 +- Added auxiliary module `textual.widget_navigation` https://github.com/Textualize/textual/pull/3912 ## [0.46.0] - 2023-12-17 From 0ac0ac5594ab1d5371c3e8596539294b63d86d35 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 Jan 2024 10:31:34 +0000 Subject: [PATCH 015/149] Rename function. https://github.com/Textualize/textual/pull/3912#discussion_r1444400213. --- src/textual/widget_navigation.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/widget_navigation.py b/src/textual/widget_navigation.py index 19fc7d3ace..b5a040aeb0 100644 --- a/src/textual/widget_navigation.py +++ b/src/textual/widget_navigation.py @@ -34,7 +34,9 @@ class Disableable(Protocol): _W = TypeVar("_W", bound="Widget") -def distance(index: int, start: int, direction: Direction, wrap_at: int) -> int: +def get_directed_distance( + index: int, start: int, direction: Direction, wrap_at: int +) -> int: """Computes the distance going from `start` to `index` in the given direction. Starting at `start`, this is the number of steps you need to take in the given @@ -137,7 +139,7 @@ def find_next_enabled( start = anchor + direction if not with_anchor else anchor key_function = partial( - distance, + get_directed_distance, start=start, direction=direction, wrap_at=len(candidates), From dc1e4c87cf1213d2d8fc4fb0dc9df9a429a8e2a6 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 Jan 2024 10:32:10 +0000 Subject: [PATCH 016/149] Rename type variables. https://github.com/Textualize/textual/pull/3912#discussion_r1444398976. --- src/textual/widget_navigation.py | 16 ++++++++++------ tests/test_widget_navigation.py | 4 ++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/textual/widget_navigation.py b/src/textual/widget_navigation.py index b5a040aeb0..6cd829521f 100644 --- a/src/textual/widget_navigation.py +++ b/src/textual/widget_navigation.py @@ -30,8 +30,8 @@ class Disableable(Protocol): In a vertical setting, 1 points down and -1 points up. In a horizontal setting, 1 points right and -1 points left. """ -_D = TypeVar("_D", bound=Disableable) -_W = TypeVar("_W", bound="Widget") +_DisableableType = TypeVar("_DisableableType", bound=Disableable) +_WidgetType = TypeVar("_WidgetType", bound="Widget") def get_directed_distance( @@ -69,7 +69,9 @@ def get_directed_distance( return direction * (index - start) % wrap_at -def find_first_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: +def find_first_enabled( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], +) -> int | None: """Find the first enabled candidate in a sequence of possibly-disabled objects. Args: @@ -84,7 +86,9 @@ def find_first_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: ) -def find_last_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: +def find_last_enabled( + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], +) -> int | None: """Find the last enabled candidate in a sequence of possibly-disabled objects. Args: @@ -105,7 +109,7 @@ def find_last_enabled(candidates: Sequence[_D] | Sequence[_W]) -> int | None: def find_next_enabled( - candidates: Sequence[_D] | Sequence[_W], + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], anchor: int | None, direction: Direction, with_anchor: bool = False, @@ -151,7 +155,7 @@ def find_next_enabled( def find_next_enabled_no_wrap( - candidates: Sequence[_D] | Sequence[_W], + candidates: Sequence[_DisableableType] | Sequence[_WidgetType], anchor: int | None, direction: Direction, with_anchor: bool = False, diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py index 55b1a69476..d628891ab3 100644 --- a/tests/test_widget_navigation.py +++ b/tests/test_widget_navigation.py @@ -3,11 +3,11 @@ import pytest from textual.widget_navigation import ( - distance, find_first_enabled, find_last_enabled, find_next_enabled, find_next_enabled_no_wrap, + get_directed_distance, ) @@ -36,7 +36,7 @@ def __init__(self, disabled): ) def test_distance(index, start, direction, wrap_at, dist): assert ( - distance( + get_directed_distance( index=index, start=start, direction=direction, From bfd413b6f9d30d8e321c9a296393f72247a57468 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 Jan 2024 11:19:17 +0000 Subject: [PATCH 017/149] Make widget_navigation module private. https://github.com/Textualize/textual/pull/3912#discussion_r1444451796. --- CHANGELOG.md | 4 ---- docs/api/widget_navigation.md | 5 ----- ...et_navigation.py => _widget_navigation.py} | 0 src/textual/types.py | 2 ++ src/textual/widgets/_list_view.py | 12 +++++------ src/textual/widgets/_option_list.py | 20 +++++++++---------- src/textual/widgets/_radio_set.py | 6 +++--- tests/test_widget_navigation.py | 2 +- 8 files changed, 22 insertions(+), 29 deletions(-) delete mode 100644 docs/api/widget_navigation.md rename src/textual/{widget_navigation.py => _widget_navigation.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20de530956..63f4cb4060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 -### Added - -- Added auxiliary module `textual.widget_navigation` https://github.com/Textualize/textual/pull/3912 - ### Fixed - Parameter `animate` from `DataTable.move_cursor` was being ignored https://github.com/Textualize/textual/issues/3840 diff --git a/docs/api/widget_navigation.md b/docs/api/widget_navigation.md deleted file mode 100644 index 58872c40d2..0000000000 --- a/docs/api/widget_navigation.md +++ /dev/null @@ -1,5 +0,0 @@ -::: textual.widget_navigation - options: - filters: - - "!^_" - - "^__init__$" diff --git a/src/textual/widget_navigation.py b/src/textual/_widget_navigation.py similarity index 100% rename from src/textual/widget_navigation.py rename to src/textual/_widget_navigation.py diff --git a/src/textual/types.py b/src/textual/types.py index 33be4449fe..8ae7ec846d 100644 --- a/src/textual/types.py +++ b/src/textual/types.py @@ -12,6 +12,7 @@ UnusedParameter, WatchCallbackType, ) +from ._widget_navigation import Direction from .actions import ActionParseResult from .css.styles import RenderStyles from .widgets._directory_tree import DirEntry @@ -32,6 +33,7 @@ "CSSPathError", "CSSPathType", "DirEntry", + "Direction", "DuplicateID", "EasingFunction", "IgnoreReturnCallbackType", diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index d922797e49..3176621836 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -4,7 +4,7 @@ from typing_extensions import TypeGuard -from .. import widget_navigation +from .. import _widget_navigation from ..await_remove import AwaitRemove from ..binding import Binding, BindingType from ..containers import VerticalScroll @@ -118,7 +118,7 @@ def __init__( *children, name=name, id=id, classes=classes, disabled=disabled ) # Set the index to the given initial index, or the first available index after. - self._index = widget_navigation.find_next_enabled( + self._index = _widget_navigation.find_next_enabled( self._nodes, anchor=initial_index - 1 if initial_index is not None else None, direction=1, @@ -150,9 +150,9 @@ def validate_index(self, index: int | None) -> int | None: if index is None: return None elif index < 0: - return widget_navigation.find_first_enabled(self._nodes) + return _widget_navigation.find_first_enabled(self._nodes) elif index >= len(self._nodes): - return widget_navigation.find_last_enabled(self._nodes) + return _widget_navigation.find_last_enabled(self._nodes) elif self._nodes[index].disabled: return self.index @@ -229,7 +229,7 @@ def action_select_cursor(self) -> None: def action_cursor_down(self) -> None: """Highlight the next item in the list.""" - candidate = widget_navigation.find_next_enabled( + candidate = _widget_navigation.find_next_enabled( self._nodes, anchor=self.index, direction=1, @@ -241,7 +241,7 @@ def action_cursor_down(self) -> None: def action_cursor_up(self) -> None: """Highlight the previous item in the list.""" - candidate = widget_navigation.find_next_enabled( + candidate = _widget_navigation.find_next_enabled( self._nodes, anchor=self.index, direction=-1, diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 14dd317206..d3c28f727b 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -16,7 +16,8 @@ from rich.style import Style from typing_extensions import Self, TypeAlias -from .. import widget_navigation +from .. import _widget_navigation +from .._widget_navigation import Direction from ..binding import Binding, BindingType from ..events import Click, Idle, Leave, MouseMove from ..geometry import Region, Size @@ -24,7 +25,6 @@ from ..reactive import reactive from ..scroll_view import ScrollView from ..strip import Strip -from ..widget_navigation import Direction class DuplicateID(Exception): @@ -726,7 +726,7 @@ def _set_option_disabled(self, index: int, disabled: bool) -> Self: """ self._options[index].disabled = disabled if index == self.highlighted: - self.highlighted = widget_navigation.find_next_enabled( + self.highlighted = _widget_navigation.find_next_enabled( self._options, anchor=index, direction=1 ) # TODO: Refresh only if the affected option is visible. @@ -956,9 +956,9 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: if highlighted is None: return None elif highlighted < 0: - return widget_navigation.find_first_enabled(self._options) + return _widget_navigation.find_first_enabled(self._options) elif highlighted >= len(self._options): - return widget_navigation.find_last_enabled(self._options) + return _widget_navigation.find_last_enabled(self._options) elif self._options[highlighted].disabled: return self.highlighted @@ -973,7 +973,7 @@ def watch_highlighted(self, highlighted: int | None) -> None: def action_cursor_up(self) -> None: """Move the highlight up to the previous enabled option.""" - self.highlighted = widget_navigation.find_next_enabled( + self.highlighted = _widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, direction=-1, @@ -981,7 +981,7 @@ def action_cursor_up(self) -> None: def action_cursor_down(self) -> None: """Move the highlight down to the next enabled option.""" - self.highlighted = widget_navigation.find_next_enabled( + self.highlighted = _widget_navigation.find_next_enabled( self._options, anchor=self.highlighted, direction=1, @@ -989,11 +989,11 @@ def action_cursor_down(self) -> None: def action_first(self) -> None: """Move the highlight to the first enabled option.""" - self.highlighted = widget_navigation.find_first_enabled(self._options) + self.highlighted = _widget_navigation.find_first_enabled(self._options) def action_last(self) -> None: """Move the highlight to the last enabled option.""" - self.highlighted = widget_navigation.find_last_enabled(self._options) + self.highlighted = _widget_navigation.find_last_enabled(self._options) def _page(self, direction: Direction) -> None: """Move the highlight roughly by one page in the given direction. @@ -1039,7 +1039,7 @@ def _page(self, direction: Direction) -> None: # Looks like we've figured where we'd like to jump to, we # just need to make sure we jump to an option that's enabled. if target_option is not None: - target_option = widget_navigation.find_next_enabled_no_wrap( + target_option = _widget_navigation.find_next_enabled_no_wrap( candidates=self._options, anchor=target_option, direction=direction, diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 52de72b2f5..2946eabf4a 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -6,7 +6,7 @@ import rich.repr -from .. import widget_navigation +from .. import _widget_navigation from ..binding import Binding, BindingType from ..containers import Container from ..events import Click, Mount @@ -248,7 +248,7 @@ def action_previous_button(self) -> None: Note that this will wrap around to the end if at the start. """ - self._selected = widget_navigation.find_next_enabled( + self._selected = _widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=-1, @@ -259,7 +259,7 @@ def action_next_button(self) -> None: Note that this will wrap around to the start if at the end. """ - self._selected = widget_navigation.find_next_enabled( + self._selected = _widget_navigation.find_next_enabled( self.children, anchor=self._selected, direction=1, diff --git a/tests/test_widget_navigation.py b/tests/test_widget_navigation.py index d628891ab3..a322f3846f 100644 --- a/tests/test_widget_navigation.py +++ b/tests/test_widget_navigation.py @@ -2,7 +2,7 @@ import pytest -from textual.widget_navigation import ( +from textual._widget_navigation import ( find_first_enabled, find_last_enabled, find_next_enabled, From 46fd0c19d14bbaa6fe92d07280996e9bcc266d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:02:45 +0000 Subject: [PATCH 018/149] Remove private module from docs. --- mkdocs-nav.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 9764bb03d5..ffd939d754 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -209,7 +209,6 @@ nav: - "api/validation.md" - "api/walk.md" - "api/widget.md" - - "api/widget_navigation.md" - "api/work.md" - "api/worker.md" - "api/worker_manager.md" From b697341906fcb34f803c242fb40e5d172196463c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 15 Jan 2024 11:41:07 +0000 Subject: [PATCH 019/149] Revert to minimal validation on reactive. When assigning to a reactive that controls some sort of highlighted option, do minimal validation on that. Related review comment: https://github.com/Textualize/textual/pull/3912#issuecomment-1891946809 --- src/textual/widgets/_list_view.py | 6 ++---- src/textual/widgets/_option_list.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 3176621836..668fffdc79 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -150,11 +150,9 @@ def validate_index(self, index: int | None) -> int | None: if index is None: return None elif index < 0: - return _widget_navigation.find_first_enabled(self._nodes) + return 0 elif index >= len(self._nodes): - return _widget_navigation.find_last_enabled(self._nodes) - elif self._nodes[index].disabled: - return self.index + return len(self._nodes) - 1 return index diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 606c387017..2cc026a893 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -965,11 +965,9 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: if highlighted is None: return None elif highlighted < 0: - return _widget_navigation.find_first_enabled(self._options) + return 0 elif highlighted >= len(self._options): - return _widget_navigation.find_last_enabled(self._options) - elif self._options[highlighted].disabled: - return self.highlighted + return len(self._options) - 1 return highlighted From 517a959c691d61a1520eb152196cc4657c8cb63b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Mon, 15 Jan 2024 13:59:57 +0000 Subject: [PATCH 020/149] Simplify validation of setting highlighted reactive. If the dev assigns the reactive 'highlighted' to an option that is disabled, we let that go through but we don't post a Highlighted message. Related review comment: https://github.com/Textualize/textual/pull/3912#issuecomment-1891946809 --- src/textual/widgets/_list_view.py | 12 +++++------- src/textual/widgets/_option_list.py | 9 ++++----- tests/listview/test_listview_navigation.py | 6 +++++- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 668fffdc79..6a1788feec 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -11,7 +11,7 @@ from ..events import Mount from ..message import Message from ..reactive import reactive -from ..widget import AwaitMount, Widget +from ..widget import AwaitMount from ..widgets._list_item import ListItem @@ -147,7 +147,7 @@ def validate_index(self, index: int | None) -> int | None: Returns: The clamped index. """ - if index is None: + if index is None or not self._nodes: return None elif index < 0: return 0 @@ -169,16 +169,14 @@ def watch_index(self, old_index: int | None, new_index: int | None) -> None: assert isinstance(old_child, ListItem) old_child.highlighted = False - new_child: Widget | None if self._is_valid_index(new_index) and not self._nodes[new_index].disabled: new_child = self._nodes[new_index] assert isinstance(new_child, ListItem) new_child.highlighted = True + self._scroll_highlighted_region() + self.post_message(self.Highlighted(self, new_child)) else: - new_child = None - - self._scroll_highlighted_region() - self.post_message(self.Highlighted(self, new_child)) + self.post_message(self.Highlighted(self, None)) def extend(self, items: Iterable[ListItem]) -> AwaitMount: """Append multiple new ListItems to the end of the ListView. diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 2cc026a893..a9ba696bc9 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -656,7 +656,7 @@ def remove_option_at_index(self, index: int) -> Self: self._remove_option(index) except IndexError: raise OptionDoesNotExist( - f"There is no option with an index of {index}" + f"There is no option with an index of {index!r}" ) from None return self @@ -962,7 +962,7 @@ def scroll_to_highlight(self, top: bool = False) -> None: def validate_highlighted(self, highlighted: int | None) -> int | None: """Validate the `highlighted` property value on access.""" - if highlighted is None: + if highlighted is None or not self._options: return None elif highlighted < 0: return 0 @@ -973,10 +973,9 @@ def validate_highlighted(self, highlighted: int | None) -> int | None: def watch_highlighted(self, highlighted: int | None) -> None: """React to the highlighted option having changed.""" - if highlighted is not None: + if highlighted is not None and not self._options[highlighted].disabled: self.scroll_to_highlight() - if not self._options[highlighted].disabled: - self.post_message(self.OptionHighlighted(self, highlighted)) + self.post_message(self.OptionHighlighted(self, highlighted)) def action_cursor_up(self) -> None: """Move the highlight up to the previous enabled option.""" diff --git a/tests/listview/test_listview_navigation.py b/tests/listview/test_listview_navigation.py index 4f3ebb4052..4a93bb2695 100644 --- a/tests/listview/test_listview_navigation.py +++ b/tests/listview/test_listview_navigation.py @@ -18,7 +18,10 @@ def compose(self) -> ComposeResult: ) def _on_list_view_highlighted(self, message: ListView.Highlighted) -> None: - self.highlighted.append(str(message.item.children[0].renderable)) + if message.item is None: + self.highlighted.append(None) + else: + self.highlighted.append(str(message.item.children[0].renderable)) async def test_keyboard_navigation_with_disabled_items() -> None: @@ -32,6 +35,7 @@ async def test_keyboard_navigation_with_disabled_items() -> None: await pilot.press("up") assert app.highlighted == [ + None, "1", "4", "5", From bbb436c9c25448e64d273e00ddc29eba7fcdc075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:19:10 +0000 Subject: [PATCH 021/149] Extract auxiliary functions. --- src/textual/reactive.py | 68 +++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d361bd0049..4e13a267d0 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -36,6 +36,41 @@ class TooManyComputesError(Exception): """Raised when an attribute has public and private compute methods.""" +async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None: + """Coroutine to await an awaitable returned from a watcher""" + _rich_traceback_omit = True + await awaitable + # Watcher may have changed the state, so run compute again + obj.post_message(events.Callback(callback=partial(Reactive._compute, obj))) + + +def invoke_watcher( + watcher_object: Reactable, + watch_function: WatchCallbackType, + old_value: object, + value: object, +) -> None: + """Invoke a watch function. + + Args: + watcher_object: The object watching for the changes. + watch_function: A watch function, which may be sync or async. + old_value: The old value of the attribute. + value: The new value of the attribute. + """ + _rich_traceback_omit = True + param_count = count_parameters(watch_function) + if param_count == 2: + watch_result = watch_function(old_value, value) + elif param_count == 1: + watch_result = watch_function(value) + else: + watch_result = watch_function() + if isawaitable(watch_result): + # Result is awaitable, so we need to await it within an async context + watcher_object.call_next(partial(await_watcher, watcher_object, watch_result)) + + @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -212,39 +247,6 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) - async def await_watcher(awaitable: Awaitable) -> None: - """Coroutine to await an awaitable returned from a watcher""" - _rich_traceback_omit = True - await awaitable - # Watcher may have changed the state, so run compute again - obj.post_message(events.Callback(callback=partial(Reactive._compute, obj))) - - def invoke_watcher( - watcher_object: Reactable, - watch_function: Callable, - old_value: object, - value: object, - ) -> None: - """Invoke a watch function. - - Args: - watcher_object: The object watching for the changes. - watch_function: A watch function, which may be sync or async. - old_value: The old value of the attribute. - value: The new value of the attribute. - """ - _rich_traceback_omit = True - param_count = count_parameters(watch_function) - if param_count == 2: - watch_result = watch_function(old_value, value) - elif param_count == 1: - watch_result = watch_function(value) - else: - watch_result = watch_function() - if isawaitable(watch_result): - # Result is awaitable, so we need to await it within an async context - watcher_object.call_next(partial(await_watcher, watch_result)) - private_watch_function = getattr(obj, f"_watch_{name}", None) if callable(private_watch_function): invoke_watcher(obj, private_watch_function, old_value, value) From c3996550c64098309bbd552c5e6d7237f2fea12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:20:32 +0000 Subject: [PATCH 022/149] Typing improvements to reactive.py --- src/textual/_types.py | 22 +++++++++++++++++----- src/textual/reactive.py | 25 ++++++++++++++++++------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/textual/_types.py b/src/textual/_types.py index b1ad7972f3..60eab2d119 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -35,12 +35,24 @@ class UnusedParameter: """Type used for arbitrary callables used in callbacks.""" IgnoreReturnCallbackType = Union[Callable[[], Awaitable[Any]], Callable[[], Any]] """A callback which ignores the return type.""" -WatchCallbackType = Union[ - Callable[[], Awaitable[None]], - Callable[[Any], Awaitable[None]], +WatchCallbackBothValuesType = Union[ Callable[[Any, Any], Awaitable[None]], - Callable[[], None], - Callable[[Any], None], Callable[[Any, Any], None], ] +"""Type for watch methods that accept the old and new values of reactive objects.""" +WatchCallbackNewValueType = Union[ + Callable[[Any], Awaitable[None]], + Callable[[Any], None], +] +"""Type for watch methods that accept only the new value of reactive objects.""" +WatchCallbackNoArgsType = Union[ + Callable[[], Awaitable[None]], + Callable[[], None], +] +"""Type for watch methods that do not require the explicit value of the reactive.""" +WatchCallbackType = Union[ + WatchCallbackBothValuesType, + WatchCallbackNewValueType, + WatchCallbackNoArgsType, +] """Type used for callbacks passed to the `watch` method of widgets.""" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 4e13a267d0..42ea619585 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -16,13 +16,20 @@ Generic, Type, TypeVar, + cast, ) import rich.repr from . import events from ._callback import count_parameters -from ._types import MessageTarget, WatchCallbackType +from ._types import ( + MessageTarget, + WatchCallbackBothValuesType, + WatchCallbackNewValueType, + WatchCallbackNoArgsType, + WatchCallbackType, +) if TYPE_CHECKING: from .dom import DOMNode @@ -61,11 +68,13 @@ def invoke_watcher( _rich_traceback_omit = True param_count = count_parameters(watch_function) if param_count == 2: - watch_result = watch_function(old_value, value) + watch_result = cast(WatchCallbackBothValuesType, watch_function)( + old_value, value + ) elif param_count == 1: - watch_result = watch_function(value) + watch_result = cast(WatchCallbackNewValueType, watch_function)(value) else: - watch_result = watch_function() + watch_result = cast(WatchCallbackNoArgsType, watch_function)() if isawaitable(watch_result): # Result is awaitable, so we need to await it within an async context watcher_object.call_next(partial(await_watcher, watcher_object, watch_result)) @@ -234,7 +243,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: obj.refresh(repaint=self._repaint, layout=self._layout) @classmethod - def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): + def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: """Check watchers, and call watch methods / computes Args: @@ -256,7 +265,7 @@ def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): invoke_watcher(obj, public_watch_function, old_value, value) # Process "global" watchers - watchers: list[tuple[Reactable, Callable]] + watchers: list[tuple[Reactable, WatchCallbackType]] watchers = getattr(obj, "__watchers", {}).get(name, []) # Remove any watchers for reactables that have since closed if watchers: @@ -365,7 +374,9 @@ def _watch( """ if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) - watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers") + watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr( + obj, "__watchers" + ) watcher_list = watchers.setdefault(attribute_name, []) if callback in watcher_list: return From 322c45c9e7241cadb3432116550f6fb993339b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:21:31 +0000 Subject: [PATCH 023/149] Fix logical bug. Typing reported (correctly) that the membership check would never evaluate to 'True' because we were comparing apples with tuples of oranges and apples. 'watcher_list' contains tuples whose second element _might_ match the callback, so we need to go over the tuples and unpack them to figure out if the callback is there. --- CHANGELOG.md | 1 + src/textual/reactive.py | 2 +- tests/test_reactive.py | 24 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f838be7d..974ffdb44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011 +- Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 ## [0.47.1] - 2023-01-05 diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 42ea619585..406a0de317 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -378,7 +378,7 @@ def _watch( obj, "__watchers" ) watcher_list = watchers.setdefault(attribute_name, []) - if callback in watcher_list: + if any(callback == callback_from_list for _, callback_from_list in watcher_list): return watcher_list.append((node, callback)) if init: diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8ee7861a2a..f51c8ac4cf 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -575,3 +575,27 @@ def callback(self): await pilot.pause() assert from_holder assert from_app + + +async def test_no_duplicate_external_watchers() -> None: + """Make sure we skip duplicated watchers.""" + + class Holder(Widget): + attr = var(None) + + class MyApp(App[None]): + def __init__(self): + super().__init__() + self.holder = Holder() + + def on_mount(self): + self.watch(self.holder, "attr", self.callback) + self.watch(self.holder, "attr", self.callback) + + def callback(self) -> None: + return + + app = MyApp() + async with app.run_test(): + pass + assert len(app.holder.__watchers["attr"]) == 1 From 3f56be22da0f7a10f66f465c1bb67ae0e32a4430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:23:34 +0000 Subject: [PATCH 024/149] Don't call all watchers on programmatic watch. When programmatically creating a watcher to a reactive attribute, init only the new watcher instead of triggering all watchers. Related issue: #3878 --- src/textual/reactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 406a0de317..90f47a83ec 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -383,4 +383,4 @@ def _watch( watcher_list.append((node, callback)) if init: current_value = getattr(obj, attribute_name, None) - Reactive._check_watchers(obj, attribute_name, current_value) + invoke_watcher(obj, callback, current_value, current_value) From ca6a499082a0235799e335ab2d2956b9fe23349a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 16 Jan 2024 14:39:35 +0000 Subject: [PATCH 025/149] Add regression test. --- CHANGELOG.md | 1 + tests/test_reactive.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 974ffdb44d..b5e41797a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011 - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 +- Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 ## [0.47.1] - 2023-01-05 diff --git a/tests/test_reactive.py b/tests/test_reactive.py index f51c8ac4cf..673884fb3b 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -599,3 +599,39 @@ def callback(self) -> None: async with app.run_test(): pass assert len(app.holder.__watchers["attr"]) == 1 + + +async def test_external_watch_init_does_not_propagate() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3878. + + Make sure that when setting an extra watcher programmatically and `init` is set, + we init only the new watcher and not the other ones. + """ + + logs: list[str] = [] + + class SomeWidget(Widget): + test_1: var[int] = var(0) + test_2: var[int] = var(0, init=False) + + def watch_test_1(self) -> None: + logs.append("test_1") + + def watch_test_2(self) -> None: + logs.append("test_2") + + class InitOverrideApp(App[None]): + def compose(self) -> ComposeResult: + yield SomeWidget() + + def on_mount(self) -> None: + def nop() -> None: + return + + self.watch(self.query_one(SomeWidget), "test_2", nop) + + app = InitOverrideApp() + async with app.run_test(): + pass + + assert logs == ["test_1"] From 828b383b9968d94e649e234665ed15ae8b1ee99f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:09:57 +0000 Subject: [PATCH 026/149] Support nested selector lists. Partially fix #3969. --- CHANGELOG.md | 1 + src/textual/css/tokenize.py | 1 + tests/css/test_tokenize.py | 89 +++++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3f838be7d..ffd94ab9bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011 +- Improve support for selector lists in nested CSS https://github.com/Textualize/textual/issues/3969 ## [0.47.1] - 2023-01-05 diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 7cf1556613..76d97a851a 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -105,6 +105,7 @@ new_selector=r",", declaration_set_start=r"\{", declaration_set_end=r"\}", + nested=r"\&", ).expect_eof(True) # A rule declaration e.g. "text: red;" diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index d4dfba888e..adbb47dc63 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -898,3 +898,92 @@ def test_allow_new_lines(): ), ] assert list(tokenize(css, ("", ""))) == expected + + +def test_nested_css_selector_list_with_ampersand(): + """Regression test for https://github.com/Textualize/textual/issues/3969.""" + css = "Label{&.foo,&.bar{border:solid red;}}" + tokens = list(tokenize(css, ("", ""))) + assert tokens == [ + Token( + name="selector_start", + value="Label", + read_from=("", ""), + code=css, + location=(0, 0), + ), + Token( + name="declaration_set_start", + value="{", + read_from=("", ""), + code=css, + location=(0, 5), + ), + Token(name="nested", value="&", read_from=("", ""), code=css, location=(0, 6)), + Token( + name="selector_class", + value=".foo", + read_from=("", ""), + code=css, + location=(0, 7), + ), + Token( + name="new_selector", + value=",", + read_from=("", ""), + code=css, + location=(0, 11), + ), + Token(name="nested", value="&", read_from=("", ""), code=css, location=(0, 12)), + Token( + name="selector_class", + value=".bar", + read_from=("", ""), + code=css, + location=(0, 13), + ), + Token( + name="declaration_set_start", + value="{", + read_from=("", ""), + code=css, + location=(0, 17), + ), + Token( + name="declaration_name", + value="border:", + read_from=("", ""), + code=css, + location=(0, 18), + ), + Token( + name="token", value="solid", read_from=("", ""), code=css, location=(0, 25) + ), + Token( + name="whitespace", value=" ", read_from=("", ""), code=css, location=(0, 30) + ), + Token( + name="token", value="red", read_from=("", ""), code=css, location=(0, 31) + ), + Token( + name="declaration_end", + value=";", + read_from=("", ""), + code=css, + location=(0, 34), + ), + Token( + name="declaration_set_end", + value="}", + read_from=("", ""), + code=css, + location=(0, 35), + ), + Token( + name="declaration_set_end", + value="}", + read_from=("", ""), + code=css, + location=(0, 36), + ), + ] From 18b8a23fd0bf3ee2012ea9608540b35470e992ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 17 Jan 2024 17:11:30 +0000 Subject: [PATCH 027/149] Improve support for declarations after nested rule sets. Partially fixes #3999. --- CHANGELOG.md | 3 +- src/textual/css/tokenize.py | 1 + tests/css/test_tokenize.py | 92 +++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd94ab9bd..a6086f2d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `SelectionList` option IDs are usable as soon as the widget is instantiated https://github.com/Textualize/textual/issues/3903 - Fix issue with `Strip.crop` when crop window start aligned with strip end https://github.com/Textualize/textual/pull/3998 - Fixed Strip.crop_extend https://github.com/Textualize/textual/pull/4011 -- Improve support for selector lists in nested CSS https://github.com/Textualize/textual/issues/3969 +- Improve support for selector lists in nested TCSS https://github.com/Textualize/textual/issues/3969 +- Improve support for rule declarations after nested TCSS rule sets https://github.com/Textualize/textual/issues/3999 ## [0.47.1] - 2023-01-05 diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 76d97a851a..ec88144f1a 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -64,6 +64,7 @@ whitespace=r"\s+", comment_start=COMMENT_START, comment_line=COMMENT_LINE, + declaration_name=r"[a-zA-Z_\-]+\:", selector_start_id=r"\#" + IDENTIFIER, selector_start_class=r"\." + IDENTIFIER, selector_start_universal=r"\*", diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index adbb47dc63..fab79ed4f6 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -987,3 +987,95 @@ def test_nested_css_selector_list_with_ampersand(): location=(0, 36), ), ] + + +def test_declaration_after_nested_declaration_set(): + """Regression test for https://github.com/Textualize/textual/issues/3999.""" + css = "Screen{Label{background:red;}background:green;}" + tokens = list(tokenize(css, ("", ""))) + assert tokens == [ + Token( + name="selector_start", + value="Screen", + read_from=("", ""), + code=css, + location=(0, 0), + ), + Token( + name="declaration_set_start", + value="{", + read_from=("", ""), + code=css, + location=(0, 6), + ), + Token( + name="selector_start", + value="Label", + read_from=("", ""), + code=css, + location=(0, 7), + ), + Token( + name="declaration_set_start", + value="{", + read_from=("", ""), + code=css, + location=(0, 12), + ), + Token( + name="declaration_name", + value="background:", + read_from=("", ""), + code=css, + location=(0, 13), + ), + Token( + name="token", + value="red", + read_from=("", ""), + code=css, + location=(0, 24), + ), + Token( + name="declaration_end", + value=";", + read_from=("", ""), + code=css, + location=(0, 27), + ), + Token( + name="declaration_set_end", + value="}", + read_from=("", ""), + code=css, + location=(0, 28), + ), + Token( + name="declaration_name", + value="background:", + read_from=("", ""), + code=css, + location=(0, 29), + ), + Token( + name="token", + value="green", + read_from=("", ""), + code=css, + location=(0, 40), + ), + Token( + name="declaration_end", + value=";", + read_from=("", ""), + code=css, + location=(0, 45), + ), + Token( + name="declaration_set_end", + value="}", + read_from=("", ""), + code=css, + location=(0, 46), + ), + ] From a6514f8517ef61b1e93da8e5d3f83b7db215f93e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Jan 2024 16:46:32 +0000 Subject: [PATCH 028/149] added cancelled event to worker --- src/textual/events.py | 4 ++-- src/textual/worker.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/events.py b/src/textual/events.py index a5518407f9..eb53d4a5db 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -475,7 +475,7 @@ class MouseUp(MouseEvent, bubble=True, verbose=True): @rich.repr.auto -class MouseScrollDown(MouseEvent, bubble=True): +class MouseScrollDown(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *down*. - [X] Bubbles @@ -484,7 +484,7 @@ class MouseScrollDown(MouseEvent, bubble=True): @rich.repr.auto -class MouseScrollUp(MouseEvent, bubble=True): +class MouseScrollUp(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *up*. - [X] Bubbles diff --git a/src/textual/worker.py b/src/textual/worker.py index d858fbe8c5..eb5189375b 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -8,6 +8,7 @@ import enum import inspect from contextvars import ContextVar +from threading import Event from time import monotonic from typing import ( TYPE_CHECKING, @@ -162,6 +163,7 @@ def __init__( self.group = group self.description = description self.exit_on_error = exit_on_error + self.cancelled_event: Event = Event() self._thread_worker = thread self._state = WorkerState.PENDING self.state = self._state @@ -409,6 +411,7 @@ def cancel(self) -> None: self._cancelled = True if self._task is not None: self._task.cancel() + self.cancelled_event.set() async def wait(self) -> ResultType: """Wait for the work to complete. From 599feb06831d1f60403da768f2c872c946ba73e3 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Wed, 10 Jan 2024 13:55:23 +0100 Subject: [PATCH 029/149] Make MarkdownFence respond to app theme changes. Closes #3997 --- CHANGELOG.md | 1 + src/textual/widgets/_markdown.py | 23 ++++++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 705eb5dd3e..00dd0c6a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Ensuring `TextArea.SelectionChanged` message only sends when the updated selection is different https://github.com/Textualize/textual/pull/3933 - Fixed declaration after nested rule set causing a parse error https://github.com/Textualize/textual/pull/4012 - ID and class validation was too lenient https://github.com/Textualize/textual/issues/3954 +- Fixed `MarkdownFence` not adapting to app theme changes https://github.com/Textualize/textual/issues/3997 ## [0.47.1] - 2023-01-05 diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f8ac0fdaff..cdfd5113df 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -7,6 +7,7 @@ from markdown_it.token import Token from rich import box from rich.style import Style +from rich.syntax import Syntax from rich.table import Table from rich.text import Text from typing_extensions import TypeAlias @@ -500,11 +501,27 @@ class MarkdownFence(MarkdownBlock): def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: self.code = code self.lexer = lexer + self.theme = "solarized-dark" if self.app.dark else "solarized-light" super().__init__(markdown) - def compose(self) -> ComposeResult: - from rich.syntax import Syntax + def _retheme(self) -> None: + """Swap between a dark and light theme when the mode changes.""" + self.theme = "solarized-dark" if self.app.dark else "solarized-light" + code_block = self.query_one(Static) + code_block.renderable = Syntax( + self.code, + lexer=self.lexer, + word_wrap=False, + indent_guides=True, + padding=(1, 2), + theme=self.theme, + ) + def _on_mount(self, _: Mount) -> None: + """Watch app theme switching.""" + self.watch(self.app, "dark", self._retheme) + + def compose(self) -> ComposeResult: yield Static( Syntax( self.code, @@ -512,7 +529,7 @@ def compose(self) -> ComposeResult: word_wrap=False, indent_guides=True, padding=(1, 2), - theme="material", + theme=self.theme, ), expand=True, shrink=False, From 52ebfe73b79dd215bf3c633238d7a2bea4d70f70 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 19 Jan 2024 12:11:12 +0100 Subject: [PATCH 030/149] Introduce dark_theme, light_theme reactive properties to Markdown. Allows for code blocks within Markdown to be styled and redrawn following the app theme. --- src/textual/widgets/_markdown.py | 51 +++++++++++++++++++------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index cdfd5113df..558b72dcae 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -18,7 +18,7 @@ from ..containers import Horizontal, Vertical, VerticalScroll from ..events import Mount from ..message import Message -from ..reactive import reactive, var +from ..reactive import Reactive, reactive, var from ..widget import Widget from ..widgets import Static, Tree @@ -499,16 +499,15 @@ class MarkdownFence(MarkdownBlock): """ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: + super().__init__(markdown) self.code = code self.lexer = lexer - self.theme = "solarized-dark" if self.app.dark else "solarized-light" - super().__init__(markdown) + self.theme = ( + self._markdown.dark_theme if self.app.dark else self._markdown.light_theme + ) - def _retheme(self) -> None: - """Swap between a dark and light theme when the mode changes.""" - self.theme = "solarized-dark" if self.app.dark else "solarized-light" - code_block = self.query_one(Static) - code_block.renderable = Syntax( + def _block(self) -> Syntax: + return Syntax( self.code, lexer=self.lexer, word_wrap=False, @@ -521,16 +520,17 @@ def _on_mount(self, _: Mount) -> None: """Watch app theme switching.""" self.watch(self.app, "dark", self._retheme) + def _retheme(self) -> None: + """Rerender when the theme changes.""" + self.theme = ( + self._markdown.dark_theme if self.app.dark else self._markdown.light_theme + ) + code_block = self.query_one(Static) + code_block.update(self._block()) + def compose(self) -> ComposeResult: yield Static( - Syntax( - self.code, - lexer=self.lexer, - word_wrap=False, - indent_guides=True, - padding=(1, 2), - theme=self.theme, - ), + self._block(), expand=True, shrink=False, ) @@ -584,6 +584,9 @@ class Markdown(Widget): BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] + dark_theme: Reactive[str] = reactive("material", layout=True, repaint=True) + light_theme: Reactive[str] = reactive("material-light") + def __init__( self, markdown: str | None = None, @@ -670,6 +673,16 @@ def _on_mount(self, _: Mount) -> None: if self._markdown is not None: self.update(self._markdown) + def watch_dark_theme(self, dark_theme: str) -> None: + if self.app.dark: + for block in self.query(MarkdownFence): + block._retheme() + + def watch_light_theme(self, light_theme: str) -> None: + if not self.app.dark: + for block in self.query(MarkdownFence): + block._retheme() + @staticmethod def sanitize_location(location: str) -> tuple[Path, str]: """Given a location, break out the path and any anchor. @@ -875,11 +888,7 @@ def update(self, markdown: str) -> AwaitComplete: stack[-1].set_content(content) elif token.type in ("fence", "code_block"): (stack[-1]._blocks if stack else output).append( - MarkdownFence( - self, - token.content.rstrip(), - token.info, - ) + MarkdownFence(self, token.content.rstrip(), token.info) ) else: external = self.unhandled_token(token) From e5839f6d4cfecabf4f239af3d167b2d4363bc6d7 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Sun, 21 Jan 2024 11:16:50 +0100 Subject: [PATCH 031/149] Add snapshot tests to test handling of theme switching in markdown --- .../__snapshots__/test_snapshots.ambr | 493 ++++++++++++++++++ .../snapshot_apps/markdown_theme_switcher.py | 33 ++ tests/snapshot_tests/test_snapshots.py | 16 + 3 files changed, 542 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index af8bffb828..b7680fafc6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21690,6 +21690,170 @@ ''' # --- +# name: test_markdown_dark_theme_override + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_markdown_example ''' @@ -21854,6 +22018,335 @@ ''' # --- +# name: test_markdown_light_theme_override + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- +# name: test_markdown_theme_switching + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MarkdownThemeSwitchertApp + + + + + + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + This is a H1 + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + defmain(): + │   print("Hello world!") + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_markdown_viewer_example ''' diff --git a/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py new file mode 100644 index 0000000000..510f2c8bf7 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py @@ -0,0 +1,33 @@ +from textual.app import App, ComposeResult +from textual.widgets import Markdown + +TEST_CODE_MARKDOWN = """ +# This is a H1 +```python +def main(): + print("Hello world!") +``` +""" + + +class MarkdownThemeSwitchertApp(App[None]): + BINDINGS = [ + ("t", "toggle_dark"), + ("d", "switch_dark"), + ("l", "switch_light"), + ] + + def action_switch_dark(self) -> None: + md = self.query_one(Markdown) + md.dark_theme = "solarized-dark" + + def action_switch_light(self) -> None: + md = self.query_one(Markdown) + md.light_theme = "solarized-light" + + def compose(self) -> ComposeResult: + yield Markdown(TEST_CODE_MARKDOWN) + + +if __name__ == "__main__": + MarkdownThemeSwitchertApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index ec51437e84..3d786fea9c 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -229,6 +229,22 @@ def test_markdown_viewer_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "markdown_viewer.py") +def test_markdown_theme_switching(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["t"]) + + +def test_markdown_dark_theme_override(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["d", "wait:100"] + ) + + +def test_markdown_light_theme_override(snap_compare): + assert snap_compare( + SNAPSHOT_APPS_DIR / "markdown_theme_switcher.py", press=["l", "t", "wait:100"] + ) + + def test_checkbox_example(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "checkbox.py") From 5a4cc5165f81abb46fcd967a8891ac9de7bd7776 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 30 Jan 2024 17:58:00 +0000 Subject: [PATCH 032/149] data binding --- src/textual/_compose.py | 4 +++ src/textual/dom.py | 60 +++++++++++++++++++++++++++++++++++++++-- src/textual/reactive.py | 30 ++++++++++++++++++--- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 482b27fb6a..89dc7757f7 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -27,6 +27,7 @@ def compose(node: App | Widget) -> list[Widget]: app._composed.append(composed) iter_compose = iter(node.compose()) is_generator = hasattr(iter_compose, "throw") + node._composing = True try: while True: try: @@ -54,6 +55,8 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None + child._compose_parent = node + if composed: nodes.extend(composed) composed.clear() @@ -73,6 +76,7 @@ def compose(node: App | Widget) -> list[Widget]: nodes.extend(composed) composed.clear() finally: + node._composing = False app._compose_stacks.pop() app._composed.pop() return nodes diff --git a/src/textual/dom.py b/src/textual/dom.py index 2664881d91..f2810ebab3 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -3,7 +3,6 @@ which includes all Widgets, Screens, and Apps. """ - from __future__ import annotations import re @@ -42,7 +41,7 @@ from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump -from .reactive import Reactive, _watch +from .reactive import Reactive, ReactiveError, _watch from .timer import Timer from .walk import walk_breadth_first, walk_depth_first @@ -196,8 +195,49 @@ def __init__( self._has_hover_style: bool = False self._has_focus_within: bool = False + self._reactive_connect: dict[str, Reactive | None] | None = None + self._compose_parent: DOMNode | None = None + self._composing: bool = False + super().__init__() + def data_bind( + self, *reactive_names: str, **bind_vars: Reactive[object] | object + ) -> Self: + """Bind reactive data. + + Raises: + ReactiveError: If the data wasn't bound. + + Returns: + Self. + """ + _rich_traceback_omit = True + if not self._composing: + raise ReactiveError("data_bind() may only be called within compose()") + if self._reactive_connect is None: + self._reactive_connect = {} + for name in reactive_names: + if name not in self._reactives: + raise ReactiveError( + f"Unable to assign non-reactive attribute {name!r} on {self}" + ) + self._reactive_connect[name] = None + for name, reactive in bind_vars.items(): + if name in reactive_names: + raise ReactiveError( + f"Keyword argument {name!r} has been used in positional arguments." + ) + if isinstance(reactive, Reactive): + self._reactive_connect[name] = reactive + else: + if name not in self._reactives: + raise ReactiveError( + f"Unable to assign non-reactive attribute {name!r} on {self}" + ) + setattr(self, name, reactive) + return self + def compose_add_child(self, widget: Widget) -> None: """Add a node to children. @@ -347,6 +387,18 @@ def _post_mount(self): """Called after the object has been mounted.""" _rich_traceback_omit = True Reactive._initialize_object(self) + if self._reactive_connect is not None: + for variable_name, reactive in self._reactive_connect.items(): + + def setter(value): + setattr(self, variable_name, value) + + if self._compose_parent is not None: + self.watch( + self._compose_parent, + variable_name if reactive is None else reactive.name, + setter, + ) def notify_style_update(self) -> None: """Called after styles are updated. @@ -1299,3 +1351,7 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: def refresh(self, *, repaint: bool = True, layout: bool = False) -> Self: return self + + async def action_toggle(self, value_name: str) -> None: + value = getattr(self, value_name) + setattr(self, value_name, not value) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d361bd0049..b10c70bf01 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -16,6 +16,7 @@ Generic, Type, TypeVar, + overload, ) import rich.repr @@ -29,10 +30,15 @@ Reactable = DOMNode -ReactiveType = TypeVar("ReactiveType") +ReactiveType = TypeVar("ReactiveType", covariant=True) +ReactableType = TypeVar("ReactableType", bound="DOMNode", contravariant=True) -class TooManyComputesError(Exception): +class ReactiveError(Exception): + """Base class for reactive errors.""" + + +class TooManyComputesError(ReactiveError): """Raised when an attribute has public and private compute methods.""" @@ -148,7 +154,25 @@ def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: default = self._default setattr(owner, f"_default_{name}", default) - def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: + @overload + def __get__( + self: Reactive[ReactiveType], obj: ReactableType, obj_type: type[ReactableType] + ) -> ReactiveType: + ... + + @overload + def __get__( + self: Reactive[ReactiveType], obj: None, obj_type: type[Reactable] + ) -> Reactive[ReactiveType]: + ... + + def __get__( + self: Reactive[ReactiveType], + obj: Reactable | None, + obj_type: type[ReactableType], + ) -> Reactive[ReactiveType] | ReactiveType: + if obj is None: + return self internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) From da9dae74b9f2b48755d0a19831183f1f13d3336e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 12:37:36 +0000 Subject: [PATCH 033/149] refresh line --- src/textual/_compose.py | 2 -- src/textual/dom.py | 4 +--- src/textual/scroll_view.py | 14 ++++++++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 89dc7757f7..fe960b90f9 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -27,7 +27,6 @@ def compose(node: App | Widget) -> list[Widget]: app._composed.append(composed) iter_compose = iter(node.compose()) is_generator = hasattr(iter_compose, "throw") - node._composing = True try: while True: try: @@ -76,7 +75,6 @@ def compose(node: App | Widget) -> list[Widget]: nodes.extend(composed) composed.clear() finally: - node._composing = False app._compose_stacks.pop() app._composed.pop() return nodes diff --git a/src/textual/dom.py b/src/textual/dom.py index a718805f94..e7700d3b44 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -213,8 +213,6 @@ def data_bind( Self. """ _rich_traceback_omit = True - if not self._composing: - raise ReactiveError("data_bind() may only be called within compose()") if self._reactive_connect is None: self._reactive_connect = {} for name in reactive_names: @@ -226,7 +224,7 @@ def data_bind( for name, reactive in bind_vars.items(): if name in reactive_names: raise ReactiveError( - f"Keyword argument {name!r} has been used in positional arguments." + f"Keyword argument {name!r} has already been used in positional arguments." ) if isinstance(reactive, Reactive): self._reactive_connect[name] = reactive diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index a4e3aa03d8..c77ef7bdcb 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -144,6 +144,16 @@ def scroll_to( on_complete=on_complete, ) + def refresh_line(self, y: int) -> None: + """Refresh a single line. + + Args: + y: Coordinate of line. + """ + width = self.virtual_size.width + scroll_x, scroll_y = self.scroll_offset + self.refresh(Region(0, y - scroll_y, width, 1)) + def refresh_lines(self, y_start: int, line_count: int = 1) -> None: """Refresh one or more lines. @@ -152,7 +162,7 @@ def refresh_lines(self, y_start: int, line_count: int = 1) -> None: line_count: Total number of lines to refresh. """ - width = self.size.width + width = self.virtual_size.width scroll_x, scroll_y = self.scroll_offset - refresh_region = Region(scroll_x, y_start - scroll_y, width, line_count) + refresh_region = Region(0, y_start - scroll_y, width, line_count) self.refresh(refresh_region) From 7e27a3364f6d92ca052963c98e250ea1166709b0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 15:47:13 +0000 Subject: [PATCH 034/149] reactive fix --- pyproject.toml | 3 --- src/textual/_compose.py | 2 ++ src/textual/dom.py | 31 +++++++++++++++++++------------ src/textual/reactive.py | 9 ++++----- 4 files changed, 25 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32ccd1b9f7..072c21a9b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,9 +73,6 @@ types-tree-sitter = "^0.20.1.4" types-tree-sitter-languages = "^1.7.0.1" griffe = "0.32.3" -[tool.black] -includes = "src" - [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/textual/_compose.py b/src/textual/_compose.py index fe960b90f9..aaf6d72da0 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -34,6 +34,8 @@ def compose(node: App | Widget) -> list[Widget]: except StopIteration: break + child._post_compose(node) + if not isinstance(child, Widget): mount_error = MountError( f"Can't mount {type(child)}; expected a Widget instance." diff --git a/src/textual/dom.py b/src/textual/dom.py index e7700d3b44..283759f805 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -213,6 +213,7 @@ def data_bind( Self. """ _rich_traceback_omit = True + if self._reactive_connect is None: self._reactive_connect = {} for name in reactive_names: @@ -234,8 +235,26 @@ def data_bind( f"Unable to assign non-reactive attribute {name!r} on {self}" ) setattr(self, name, reactive) + return self + def _post_compose(self, compose_parent: DOMNode) -> None: + if not self._reactive_connect: + return + for variable_name, reactive in self._reactive_connect.items(): + + def setter(value: object) -> None: + Reactive._initialize_object(self) + setattr(self, variable_name, value) + + self.watch( + compose_parent, + variable_name if reactive is None else reactive.name, + setter, + init=False, + ) + self._reactive_connect = None + def compose_add_child(self, widget: Widget) -> None: """Add a node to children. @@ -385,18 +404,6 @@ def _post_mount(self): """Called after the object has been mounted.""" _rich_traceback_omit = True Reactive._initialize_object(self) - if self._reactive_connect is not None: - for variable_name, reactive in self._reactive_connect.items(): - - def setter(value): - setattr(self, variable_name, value) - - if self._compose_parent is not None: - self.watch( - self._compose_parent, - variable_name if reactive is None else reactive.name, - setter, - ) def notify_style_update(self) -> None: """Called after styles are updated. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index b10c70bf01..719e29411c 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -157,14 +157,12 @@ def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: @overload def __get__( self: Reactive[ReactiveType], obj: ReactableType, obj_type: type[ReactableType] - ) -> ReactiveType: - ... + ) -> ReactiveType: ... @overload def __get__( self: Reactive[ReactiveType], obj: None, obj_type: type[Reactable] - ) -> Reactive[ReactiveType]: - ... + ) -> Reactive[ReactiveType]: ... def __get__( self: Reactive[ReactiveType], @@ -285,7 +283,7 @@ def invoke_watcher( watchers[:] = [ (reactable, callback) for reactable, callback in watchers - if reactable.is_attached and not reactable._closing + if not reactable._closing ] for reactable, callback in watchers: with reactable.prevent(*obj._prevent_message_types_stack[-1]): @@ -380,6 +378,7 @@ def _watch( """Watch a reactive variable on an object. Args: + node: The node that created the watcher. obj: The parent object. attribute_name: The attribute to watch. callback: A callable to call when the attribute changes. From 8bab37f3379314b816942f30f9846af64dc9425f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 15:51:48 +0000 Subject: [PATCH 035/149] tests --- tests/test_data_bind.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_data_bind.py diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py new file mode 100644 index 0000000000..d078001e59 --- /dev/null +++ b/tests/test_data_bind.py @@ -0,0 +1,40 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widgets import Label + + +class FooLabel(Label): + foo = reactive("Foo") + + def render(self) -> str: + return self.foo + + +class DataBindApp(App): + foo = reactive("Bar", init=False) + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind("foo") + yield FooLabel(id="label2").data_bind(foo=DataBindApp.foo) + + +async def test_data_binding(): + app = DataBindApp() + async with app.run_test() as pilot: + + assert app.foo == "Bar" + + label1 = app.query_one("#label1", FooLabel) + label2 = app.query_one("#label2", FooLabel) + assert label1.foo == "Foo" + assert label2.foo == "Foo" + + # Changing this reactive, should also change the bound widgets + app.foo = "Baz" + + # Sanity check + assert app.foo == "Baz" + + # Should also have updated bound labels + assert label1.foo == "Baz" + assert label2.foo == "Baz" From e2facd3ee735f0fdab3c4e001bcb5a06a9c519e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 16:46:59 +0000 Subject: [PATCH 036/149] test --- src/textual/_compose.py | 2 +- src/textual/dom.py | 11 +++++++---- tests/test_data_bind.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index aaf6d72da0..8719cec2f5 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -34,7 +34,7 @@ def compose(node: App | Widget) -> list[Widget]: except StopIteration: break - child._post_compose(node) + child._initialize_data_bind(node) if not isinstance(child, Widget): mount_error = MountError( diff --git a/src/textual/dom.py b/src/textual/dom.py index 283759f805..cbc6759b33 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -28,7 +28,7 @@ from rich.text import Text from rich.tree import Tree -from ._context import NoActiveAppError +from ._context import NoActiveAppError, active_message_pump from ._node_list import NodeList from ._types import WatchCallbackType from ._worker_manager import WorkerManager @@ -235,23 +235,26 @@ def data_bind( f"Unable to assign non-reactive attribute {name!r} on {self}" ) setattr(self, name, reactive) - + if self._parent is not None: + self._initialize_data_bind(active_message_pump.get()) return self - def _post_compose(self, compose_parent: DOMNode) -> None: + def _initialize_data_bind(self, compose_parent: MessagePump) -> None: if not self._reactive_connect: return for variable_name, reactive in self._reactive_connect.items(): def setter(value: object) -> None: + """Set bound data,=,""" Reactive._initialize_object(self) setattr(self, variable_name, value) + assert isinstance(compose_parent, DOMNode) self.watch( compose_parent, variable_name if reactive is None else reactive.name, setter, - init=False, + init=self._parent is not None, ) self._reactive_connect = None diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index d078001e59..765f8c0486 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -17,17 +17,23 @@ def compose(self) -> ComposeResult: yield FooLabel(id="label1").data_bind("foo") yield FooLabel(id="label2").data_bind(foo=DataBindApp.foo) + yield FooLabel(id="label3") + async def test_data_binding(): app = DataBindApp() async with app.run_test() as pilot: + # Check default assert app.foo == "Bar" label1 = app.query_one("#label1", FooLabel) label2 = app.query_one("#label2", FooLabel) + label3 = app.query_one("#label3", FooLabel) + assert label1.foo == "Foo" assert label2.foo == "Foo" + assert label3.foo == "Foo" # Changing this reactive, should also change the bound widgets app.foo = "Baz" @@ -38,3 +44,25 @@ async def test_data_binding(): # Should also have updated bound labels assert label1.foo == "Baz" assert label2.foo == "Baz" + assert label3.foo == "Foo" + + # Bind data outside of compose + label3.data_bind(foo=DataBindApp.foo) + # Confirm new binding has propagated + assert label3.foo == "Baz" + + # Set reactive and check propagation + app.foo = "Egg" + assert label1.foo == "Egg" + assert label2.foo == "Egg" + assert label3.foo == "Egg" + + # Test nothing goes awry when removing widget with bound data + await label1.remove() + + # Try one last time + app.foo = "Spam" + + # Confirm remaining widgets still propagate + assert label2.foo == "Spam" + assert label3.foo == "Spam" From 24ef676b7bb215e5195f960f92e2e1c472167308 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 17:10:41 +0000 Subject: [PATCH 037/149] more tests --- src/textual/dom.py | 15 ++++++++---- tests/test_data_bind.py | 51 +++++++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index cbc6759b33..55b770454e 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -219,7 +219,7 @@ def data_bind( for name in reactive_names: if name not in self._reactives: raise ReactiveError( - f"Unable to assign non-reactive attribute {name!r} on {self}" + f"Unable to bind non-reactive attribute {name!r} on {self}" ) self._reactive_connect[name] = None for name, reactive in bind_vars.items(): @@ -227,19 +227,24 @@ def data_bind( raise ReactiveError( f"Keyword argument {name!r} has already been used in positional arguments." ) + if name not in self._reactives: + raise ReactiveError( + f"Unable to bind non-reactive attribute {name!r} on {self}" + ) if isinstance(reactive, Reactive): self._reactive_connect[name] = reactive else: - if name not in self._reactives: - raise ReactiveError( - f"Unable to assign non-reactive attribute {name!r} on {self}" - ) setattr(self, name, reactive) if self._parent is not None: self._initialize_data_bind(active_message_pump.get()) return self def _initialize_data_bind(self, compose_parent: MessagePump) -> None: + """initialize a data binding. + + Args: + compose_parent: The node doing the binding. + """ if not self._reactive_connect: return for variable_name, reactive in self._reactive_connect.items(): diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index 765f8c0486..2317ca649b 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -1,5 +1,7 @@ +import pytest + from textual.app import App, ComposeResult -from textual.reactive import reactive +from textual.reactive import ReactiveError, reactive from textual.widgets import Label @@ -11,18 +13,17 @@ def render(self) -> str: class DataBindApp(App): - foo = reactive("Bar", init=False) + foo = reactive("Bar") def compose(self) -> ComposeResult: - yield FooLabel(id="label1").data_bind("foo") - yield FooLabel(id="label2").data_bind(foo=DataBindApp.foo) - - yield FooLabel(id="label3") + yield FooLabel(id="label1").data_bind("foo") # Bind similarly named + yield FooLabel(id="label2").data_bind(foo=DataBindApp.foo) # Explicit bind + yield FooLabel(id="label3") # Not bound async def test_data_binding(): app = DataBindApp() - async with app.run_test() as pilot: + async with app.run_test(): # Check default assert app.foo == "Bar" @@ -31,8 +32,10 @@ async def test_data_binding(): label2 = app.query_one("#label2", FooLabel) label3 = app.query_one("#label3", FooLabel) - assert label1.foo == "Foo" - assert label2.foo == "Foo" + # These are bound, so should have the same value as the App.foo + assert label1.foo == "Bar" + assert label2.foo == "Bar" + # Not yet bound, so should have its own default assert label3.foo == "Foo" # Changing this reactive, should also change the bound widgets @@ -66,3 +69,33 @@ async def test_data_binding(): # Confirm remaining widgets still propagate assert label2.foo == "Spam" assert label3.foo == "Spam" + + +async def test_data_binding_positional_error(): + + class DataBindErrorApp(App): + foo = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind("bar") # Missing reactive + + app = DataBindErrorApp() + with pytest.raises(ReactiveError): + async with app.run_test(): + pass + + +async def test_data_binding_keyword_args_errors(): + + class DataBindErrorApp(App): + foo = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind( + bar=DataBindErrorApp.foo + ) # Missing reactive + + app = DataBindErrorApp() + with pytest.raises(ReactiveError): + async with app.run_test(): + pass From 6c2e8677f6213d7bbe53cb4e809fc29e8e6c5f46 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 17:18:24 +0000 Subject: [PATCH 038/149] error test --- tests/test_data_bind.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index 2317ca649b..e9167d07ab 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -85,6 +85,22 @@ def compose(self) -> ComposeResult: pass +async def test_data_binding_positional_repeated_error(): + + class DataBindErrorApp(App): + foo = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind( + "foo", foo=DataBindErrorApp.foo + ) # Duplicate name + + app = DataBindErrorApp() + with pytest.raises(ReactiveError): + async with app.run_test(): + pass + + async def test_data_binding_keyword_args_errors(): class DataBindErrorApp(App): From 6838e878a00dabe36c10b1fd929e706894cd3694 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 19:52:36 +0000 Subject: [PATCH 039/149] fux test --- src/textual/_compose.py | 4 ++-- tests/test_data_bind.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 8719cec2f5..ca5ac14df3 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -34,8 +34,6 @@ def compose(node: App | Widget) -> list[Widget]: except StopIteration: break - child._initialize_data_bind(node) - if not isinstance(child, Widget): mount_error = MountError( f"Can't mount {type(child)}; expected a Widget instance." @@ -45,6 +43,8 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None + child._initialize_data_bind(node) + try: child.id except AttributeError: diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index e9167d07ab..74afc13f59 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -109,7 +109,7 @@ class DataBindErrorApp(App): def compose(self) -> ComposeResult: yield FooLabel(id="label1").data_bind( bar=DataBindErrorApp.foo - ) # Missing reactive + ) # Missing reactive in keyword args app = DataBindErrorApp() with pytest.raises(ReactiveError): From c66f1d4f5e17021cc9f053b20e09fbc1dae87927 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 19:59:02 +0000 Subject: [PATCH 040/149] Tidy --- src/textual/reactive.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 719e29411c..41013cc41e 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -30,8 +30,8 @@ Reactable = DOMNode -ReactiveType = TypeVar("ReactiveType", covariant=True) -ReactableType = TypeVar("ReactableType", bound="DOMNode", contravariant=True) +ReactiveType = TypeVar("ReactiveType") +ReactableType = TypeVar("ReactableType", bound="DOMNode") class ReactiveError(Exception): @@ -169,7 +169,9 @@ def __get__( obj: Reactable | None, obj_type: type[ReactableType], ) -> Reactive[ReactiveType] | ReactiveType: + _rich_traceback_omit = True if obj is None: + # obj is None means we are invoking the descriptor via the class, and not the instance return self internal_name = self.internal_name if not hasattr(obj, internal_name): @@ -178,7 +180,6 @@ def __get__( if hasattr(obj, self.compute_name): value: ReactiveType old_value = getattr(obj, internal_name) - _rich_traceback_omit = True value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) From 04e5ce24ff96daa339dbbc49223b09c4215f428b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 20:07:34 +0000 Subject: [PATCH 041/149] superfluous var --- src/textual/dom.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 55b770454e..f25d7f13f7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -197,7 +197,6 @@ def __init__( self._reactive_connect: dict[str, Reactive | None] | None = None self._compose_parent: DOMNode | None = None - self._composing: bool = False super().__init__() From 260bab083406041899d9bf9b4004397b5118b077 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 20:08:59 +0000 Subject: [PATCH 042/149] superfluous vars --- src/textual/_compose.py | 2 -- src/textual/dom.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index ca5ac14df3..b93d994818 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -56,8 +56,6 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None - child._compose_parent = node - if composed: nodes.extend(composed) composed.clear() diff --git a/src/textual/dom.py b/src/textual/dom.py index f25d7f13f7..c5e7806dba 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -194,9 +194,7 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False - self._reactive_connect: dict[str, Reactive | None] | None = None - self._compose_parent: DOMNode | None = None super().__init__() From 72e54d93669b9a4914c33f5751d05aa96a20a187 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 31 Jan 2024 20:22:45 +0000 Subject: [PATCH 043/149] test fix --- src/textual/_compose.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index b93d994818..a1266ea9e8 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -43,8 +43,6 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None - child._initialize_data_bind(node) - try: child.id except AttributeError: @@ -56,6 +54,8 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None + child._initialize_data_bind(node) + if composed: nodes.extend(composed) composed.clear() From 23156a34df531e89f6f88467b019c0a5ba119c9f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 1 Feb 2024 10:35:53 +0000 Subject: [PATCH 044/149] Use CSS by default in the TextArea --- src/textual/_text_area_theme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index 228f4bffc3..da0ec60d4f 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -187,9 +187,9 @@ def default(cls) -> TextAreaTheme: """Get the default syntax theme. Returns: - The default TextAreaTheme (probably "monokai"). + The default TextAreaTheme (probably "css"). """ - return _MONOKAI + return _CSS_THEME _MONOKAI = TextAreaTheme( From 7128c28a90ca559d8bda57267c4bf26d886f1fea Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 1 Feb 2024 10:40:11 +0000 Subject: [PATCH 045/149] Bump version to 0.48.1, update changelog --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c1d499d41..21b2a87dd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.48.1] - 2023-02-01 + +### Fixed + +- `TextArea` uses CSS theme by default instead of `monokai` https://github.com/Textualize/textual/pull/4091 + ## [0.48.0] - 2023-02-01 ### Changed @@ -1621,6 +1627,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.48.1]: https://github.com/Textualize/textual/compare/v0.48.0...v0.48.1 [0.48.0]: https://github.com/Textualize/textual/compare/v0.47.1...v0.48.0 [0.47.1]: https://github.com/Textualize/textual/compare/v0.47.0...v0.47.1 [0.47.0]: https://github.com/Textualize/textual/compare/v0.46.0...v0.47.0 diff --git a/pyproject.toml b/pyproject.toml index 94b4b1d92d..c3c4cb9315 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.48.0" +version = "0.48.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 2200a62455caec7e6f661e9614f23ebe55cd45cf Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 1 Feb 2024 10:47:49 +0000 Subject: [PATCH 046/149] Use Monokai by default when code_editor is used --- src/textual/widgets/_text_area.py | 2 +- tests/snapshot_tests/snapshot_apps/text_area_wrapping.py | 2 +- tests/snapshot_tests/test_snapshots.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 0f98234814..48e835020d 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -431,7 +431,7 @@ def code_editor( text: str = "", *, language: str | None = None, - theme: str | None = None, + theme: str | None = "monokai", soft_wrap: bool = False, tab_behaviour: Literal["focus", "indent"] = "indent", show_line_numbers: bool = True, diff --git a/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py index 8264438be4..c1fdcdff70 100644 --- a/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py +++ b/tests/snapshot_tests/snapshot_apps/text_area_wrapping.py @@ -23,7 +23,7 @@ class TextAreaWrapping(App): def compose(self) -> ComposeResult: - yield TextArea.code_editor(TEXT, language="markdown", soft_wrap=True) + yield TextArea.code_editor(TEXT, language="markdown", theme="monokai", soft_wrap=True) app = TextAreaWrapping() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index f79f9be42a..4b4f20cd03 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -879,8 +879,7 @@ def setup_theme(pilot): @pytest.mark.syntax def test_text_area_wrapping_and_folding(snap_compare): assert snap_compare( - SNAPSHOT_APPS_DIR / "text_area_wrapping.py", - terminal_size=(20, 26) + SNAPSHOT_APPS_DIR / "text_area_wrapping.py", terminal_size=(20, 26) ) From 5ec0a647445fb13059be90f6cb4e16eb7e91a71e Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 1 Feb 2024 10:57:21 +0000 Subject: [PATCH 047/149] Updating newly fetched snapshots --- .../__snapshots__/test_snapshots.ambr | 241 +++++++++--------- 1 file changed, 120 insertions(+), 121 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 5824f20be7..306d6de75e 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -2992,137 +2992,136 @@ font-weight: 700; } - .terminal-174430999-matrix { + .terminal-835230732-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-174430999-title { + .terminal-835230732-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-174430999-r1 { fill: #a2a2a2 } - .terminal-174430999-r2 { fill: #c5c8c6 } - .terminal-174430999-r3 { fill: #004578 } - .terminal-174430999-r4 { fill: #e2e3e3 } - .terminal-174430999-r5 { fill: #00ff00 } - .terminal-174430999-r6 { fill: #24292f } - .terminal-174430999-r7 { fill: #1e1e1e } - .terminal-174430999-r8 { fill: #fea62b;font-weight: bold } + .terminal-835230732-r1 { fill: #a2a2a2 } + .terminal-835230732-r2 { fill: #c5c8c6 } + .terminal-835230732-r3 { fill: #004578 } + .terminal-835230732-r4 { fill: #e2e3e3 } + .terminal-835230732-r5 { fill: #00ff00 } + .terminal-835230732-r6 { fill: #1e1e1e } + .terminal-835230732-r7 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - 🔎A - - - This is a test of this code 9 - This is a test of this code 8 - This is a test of this code 7 - This is a test of this code 6 - This is a test of this code 5 - This is a test of this code 4 - This is a test of this code 3 - This is a test of this code 2 - This is a test of this code 1 - This is a test of this code 0 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎A + + + This is a test of this code 9 + This is a test of this code 8 + This is a test of this code 7 + This is a test of this code 6 + This is a test of this code 5 + This is a test of this code 4 + This is a test of this code 3 + This is a test of this code 2 + This is a test of this code 1 + This is a test of this code 0 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + @@ -19489,137 +19488,137 @@ font-weight: 700; } - .terminal-1152611452-matrix { + .terminal-2492235927-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1152611452-title { + .terminal-2492235927-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1152611452-r1 { fill: #1e1e1e } - .terminal-1152611452-r2 { fill: #e1e1e1 } - .terminal-1152611452-r3 { fill: #c5c8c6 } - .terminal-1152611452-r4 { fill: #ff0000 } - .terminal-1152611452-r5 { fill: #f8f8f2 } - .terminal-1152611452-r6 { fill: #272822 } - .terminal-1152611452-r7 { fill: #e2e3e3;font-weight: bold } + .terminal-2492235927-r1 { fill: #1e1e1e } + .terminal-2492235927-r2 { fill: #e1e1e1 } + .terminal-2492235927-r3 { fill: #c5c8c6 } + .terminal-2492235927-r4 { fill: #ff0000 } + .terminal-2492235927-r5 { fill: #151515 } + .terminal-2492235927-r6 { fill: #e2e2e2 } + .terminal-2492235927-r7 { fill: #e2e3e3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputVsTextArea + InputVsTextArea - - - - 01234567890123456789012345678901234567890123456789012345678901234567890123456789 - ────────────────────────────────────── - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - Button - - - ────────────────────────────────────── + + + + 01234567890123456789012345678901234567890123456789012345678901234567890123456789 + ────────────────────────────────────── + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + Button + + + ────────────────────────────────────── From b3b1ce30ad010fab06e58d264c3de21ca82ca6be Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 1 Feb 2024 11:05:23 +0000 Subject: [PATCH 048/149] Update failing command palette snapshot test --- .../__snapshots__/test_snapshots.ambr | 119 +++++++++--------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 306d6de75e..a9d82110a1 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -2992,136 +2992,137 @@ font-weight: 700; } - .terminal-835230732-matrix { + .terminal-174430999-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-835230732-title { + .terminal-174430999-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-835230732-r1 { fill: #a2a2a2 } - .terminal-835230732-r2 { fill: #c5c8c6 } - .terminal-835230732-r3 { fill: #004578 } - .terminal-835230732-r4 { fill: #e2e3e3 } - .terminal-835230732-r5 { fill: #00ff00 } - .terminal-835230732-r6 { fill: #1e1e1e } - .terminal-835230732-r7 { fill: #fea62b;font-weight: bold } + .terminal-174430999-r1 { fill: #a2a2a2 } + .terminal-174430999-r2 { fill: #c5c8c6 } + .terminal-174430999-r3 { fill: #004578 } + .terminal-174430999-r4 { fill: #e2e3e3 } + .terminal-174430999-r5 { fill: #00ff00 } + .terminal-174430999-r6 { fill: #24292f } + .terminal-174430999-r7 { fill: #1e1e1e } + .terminal-174430999-r8 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - 🔎A - - - This is a test of this code 9 - This is a test of this code 8 - This is a test of this code 7 - This is a test of this code 6 - This is a test of this code 5 - This is a test of this code 4 - This is a test of this code 3 - This is a test of this code 2 - This is a test of this code 1 - This is a test of this code 0 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎A + + + This is a test of this code 9 + This is a test of this code 8 + This is a test of this code 7 + This is a test of this code 6 + This is a test of this code 5 + This is a test of this code 4 + This is a test of this code 3 + This is a test of this code 2 + This is a test of this code 1 + This is a test of this code 0 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + From 2ee8631716c8656d3b2b9c51532b45f2c81f81d9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Feb 2024 11:32:32 +0000 Subject: [PATCH 049/149] api change --- src/textual/dom.py | 38 ++++++++++++---------- src/textual/reactive.py | 6 ++-- tests/test_data_bind.py | 70 +++++++++++------------------------------ 3 files changed, 43 insertions(+), 71 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index c5e7806dba..464a6f3948 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -194,15 +194,20 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False - self._reactive_connect: dict[str, Reactive | None] | None = None + self._reactive_connect: dict[str, Reactive] | None = None super().__init__() def data_bind( - self, *reactive_names: str, **bind_vars: Reactive[object] | object + self, + parent: MessagePump | None = None, + **bind_vars: tuple[type[DOMNode], Reactive[Any]], ) -> Self: """Bind reactive data. + Args: + parent: The parent widget where the data should come from. Or `None` to auto-detect. + Raises: ReactiveError: If the data wasn't bound. @@ -211,29 +216,28 @@ def data_bind( """ _rich_traceback_omit = True + if parent is None: + parent = active_message_pump.get() + if self._reactive_connect is None: self._reactive_connect = {} - for name in reactive_names: + for name, type_and_reactive in bind_vars.items(): if name not in self._reactives: raise ReactiveError( f"Unable to bind non-reactive attribute {name!r} on {self}" ) - self._reactive_connect[name] = None - for name, reactive in bind_vars.items(): - if name in reactive_names: + if not isinstance(type_and_reactive, tuple): raise ReactiveError( - f"Keyword argument {name!r} has already been used in positional arguments." + "Expected a reactive type here, e.g MyWidget.my_reactive" ) - if name not in self._reactives: + node_type, reactive = type_and_reactive + if not isinstance(parent, node_type): raise ReactiveError( - f"Unable to bind non-reactive attribute {name!r} on {self}" + f"Reactive type {node_type.__name__!r} must be defined on class {parent.__class__.__name__!r}" ) - if isinstance(reactive, Reactive): - self._reactive_connect[name] = reactive - else: - setattr(self, name, reactive) - if self._parent is not None: - self._initialize_data_bind(active_message_pump.get()) + + self._reactive_connect[name] = reactive + self._initialize_data_bind(parent) return self def _initialize_data_bind(self, compose_parent: MessagePump) -> None: @@ -247,14 +251,14 @@ def _initialize_data_bind(self, compose_parent: MessagePump) -> None: for variable_name, reactive in self._reactive_connect.items(): def setter(value: object) -> None: - """Set bound data,=,""" + """Set bound data.""" Reactive._initialize_object(self) setattr(self, variable_name, value) assert isinstance(compose_parent, DOMNode) self.watch( compose_parent, - variable_name if reactive is None else reactive.name, + reactive.name, setter, init=self._parent is not None, ) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 41013cc41e..aa04bb705e 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -162,17 +162,17 @@ def __get__( @overload def __get__( self: Reactive[ReactiveType], obj: None, obj_type: type[Reactable] - ) -> Reactive[ReactiveType]: ... + ) -> tuple[type[ReactableType], Reactive[ReactiveType]]: ... def __get__( self: Reactive[ReactiveType], obj: Reactable | None, obj_type: type[ReactableType], - ) -> Reactive[ReactiveType] | ReactiveType: + ) -> tuple[type[ReactableType], Reactive[ReactiveType]] | ReactiveType: _rich_traceback_omit = True if obj is None: # obj is None means we are invoking the descriptor via the class, and not the instance - return self + return (obj_type, self) internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index 74afc13f59..46c486c2ce 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -13,12 +13,11 @@ def render(self) -> str: class DataBindApp(App): - foo = reactive("Bar") + bar = reactive("Bar") def compose(self) -> ComposeResult: - yield FooLabel(id="label1").data_bind("foo") # Bind similarly named - yield FooLabel(id="label2").data_bind(foo=DataBindApp.foo) # Explicit bind - yield FooLabel(id="label3") # Not bound + yield FooLabel(id="label1").data_bind(foo=DataBindApp.bar) # Explicit bind + yield FooLabel(id="label2") # Not bound async def test_data_binding(): @@ -26,90 +25,59 @@ async def test_data_binding(): async with app.run_test(): # Check default - assert app.foo == "Bar" + assert app.bar == "Bar" label1 = app.query_one("#label1", FooLabel) label2 = app.query_one("#label2", FooLabel) - label3 = app.query_one("#label3", FooLabel) # These are bound, so should have the same value as the App.foo assert label1.foo == "Bar" - assert label2.foo == "Bar" # Not yet bound, so should have its own default - assert label3.foo == "Foo" + assert label2.foo == "Foo" # Changing this reactive, should also change the bound widgets - app.foo = "Baz" + app.bar = "Baz" # Sanity check - assert app.foo == "Baz" + assert app.bar == "Baz" # Should also have updated bound labels assert label1.foo == "Baz" - assert label2.foo == "Baz" - assert label3.foo == "Foo" + assert label2.foo == "Foo" + + with pytest.raises(ReactiveError): + # THis should be an error because FooLabel.foo is not defined on the app + label2.data_bind(app, foo=FooLabel.foo) # Bind data outside of compose - label3.data_bind(foo=DataBindApp.foo) + label2.data_bind(app, foo=DataBindApp.bar) # Confirm new binding has propagated - assert label3.foo == "Baz" + assert label2.foo == "Baz" # Set reactive and check propagation - app.foo = "Egg" + app.bar = "Egg" assert label1.foo == "Egg" assert label2.foo == "Egg" - assert label3.foo == "Egg" # Test nothing goes awry when removing widget with bound data await label1.remove() # Try one last time - app.foo = "Spam" + app.bar = "Spam" # Confirm remaining widgets still propagate assert label2.foo == "Spam" - assert label3.foo == "Spam" - - -async def test_data_binding_positional_error(): - - class DataBindErrorApp(App): - foo = reactive("Bar") - - def compose(self) -> ComposeResult: - yield FooLabel(id="label1").data_bind("bar") # Missing reactive - - app = DataBindErrorApp() - with pytest.raises(ReactiveError): - async with app.run_test(): - pass - - -async def test_data_binding_positional_repeated_error(): - - class DataBindErrorApp(App): - foo = reactive("Bar") - - def compose(self) -> ComposeResult: - yield FooLabel(id="label1").data_bind( - "foo", foo=DataBindErrorApp.foo - ) # Duplicate name - - app = DataBindErrorApp() - with pytest.raises(ReactiveError): - async with app.run_test(): - pass -async def test_data_binding_keyword_args_errors(): +async def test_data_binding_missing_reactive(): class DataBindErrorApp(App): foo = reactive("Bar") def compose(self) -> ComposeResult: yield FooLabel(id="label1").data_bind( - bar=DataBindErrorApp.foo - ) # Missing reactive in keyword args + nofoo=DataBindErrorApp.foo + ) # Missing reactive app = DataBindErrorApp() with pytest.raises(ReactiveError): From db05bf5f1f019d3ba397208124da6bbf43a33ed6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Feb 2024 13:21:46 +0000 Subject: [PATCH 050/149] api change --- src/textual/dom.py | 14 +++++--------- src/textual/reactive.py | 13 ++++++++++--- tests/test_data_bind.py | 4 ++-- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 464a6f3948..588f167c96 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -201,7 +201,7 @@ def __init__( def data_bind( self, parent: MessagePump | None = None, - **bind_vars: tuple[type[DOMNode], Reactive[Any]], + **bind_vars: Reactive[Any], ) -> Self: """Bind reactive data. @@ -221,19 +221,15 @@ def data_bind( if self._reactive_connect is None: self._reactive_connect = {} - for name, type_and_reactive in bind_vars.items(): + for name, reactive in bind_vars.items(): if name not in self._reactives: raise ReactiveError( f"Unable to bind non-reactive attribute {name!r} on {self}" ) - if not isinstance(type_and_reactive, tuple): - raise ReactiveError( - "Expected a reactive type here, e.g MyWidget.my_reactive" - ) - node_type, reactive = type_and_reactive - if not isinstance(parent, node_type): + + if not isinstance(parent, reactive.owner): raise ReactiveError( - f"Reactive type {node_type.__name__!r} must be defined on class {parent.__class__.__name__!r}" + f"Reactive type {reactive.owner.__name__!r} must be defined on class {parent.__class__.__name__!r}" ) self._reactive_connect[name] = reactive diff --git a/src/textual/reactive.py b/src/textual/reactive.py index aa04bb705e..fc2ea48450 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -73,6 +73,7 @@ def __init__( self._init = init self._always_update = always_update self._run_compute = compute + self._owner: Type[MessageTarget] | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -82,6 +83,11 @@ def __rich_repr__(self) -> rich.repr.Result: yield "always_update", self._always_update yield "compute", self._run_compute + @property + def owner(self) -> Type[MessageTarget]: + assert self._owner is not None + return self._owner + def _initialize_reactive(self, obj: Reactable, name: str) -> None: """Initialized a reactive attribute on an object. @@ -132,6 +138,7 @@ def _reset_object(cls, obj: object) -> None: def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: # Check for compute method + self._owner = owner public_compute = f"compute_{name}" private_compute = f"_compute_{name}" compute_name = ( @@ -162,17 +169,17 @@ def __get__( @overload def __get__( self: Reactive[ReactiveType], obj: None, obj_type: type[Reactable] - ) -> tuple[type[ReactableType], Reactive[ReactiveType]]: ... + ) -> Reactive[ReactiveType]: ... def __get__( self: Reactive[ReactiveType], obj: Reactable | None, obj_type: type[ReactableType], - ) -> tuple[type[ReactableType], Reactive[ReactiveType]] | ReactiveType: + ) -> Reactive[ReactiveType] | ReactiveType: _rich_traceback_omit = True if obj is None: # obj is None means we are invoking the descriptor via the class, and not the instance - return (obj_type, self) + return self internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index 46c486c2ce..a595e7898c 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -16,7 +16,7 @@ class DataBindApp(App): bar = reactive("Bar") def compose(self) -> ComposeResult: - yield FooLabel(id="label1").data_bind(foo=DataBindApp.bar) # Explicit bind + yield FooLabel(id="label1").data_bind(foo=DataBindApp.bar) yield FooLabel(id="label2") # Not bound @@ -46,7 +46,7 @@ async def test_data_binding(): assert label2.foo == "Foo" with pytest.raises(ReactiveError): - # THis should be an error because FooLabel.foo is not defined on the app + # This should be an error because FooLabel.foo is not defined on the app label2.data_bind(app, foo=FooLabel.foo) # Bind data outside of compose From a382bdc85eea16607a8af8b440b3cd10b4adad71 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Thu, 1 Feb 2024 13:57:57 +0000 Subject: [PATCH 051/149] docs(text area): fix code editor link (#4093) --- docs/widgets/text_area.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index 0dca1339ec..bb0f50810b 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -18,7 +18,7 @@ and a variety of keybindings. By default, the `TextArea` widget is a standard multi-line input box with soft-wrapping enabled. -If you're interested in editing code, you may wish to use the [`TextArea.code_editor`] convenience constructor. +If you're interested in editing code, you may wish to use the [`TextArea.code_editor`][textual.widgets._text_area.TextArea.code_editor] convenience constructor. This is a method which, by default, returns a new `TextArea` with soft-wrapping disabled, line numbers enabled, and the tab key behavior configured to insert `\t`. ### Syntax highlighting dependencies From e90c76eb5eaf87fffa002404f2ea3655bf8065bb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Feb 2024 14:20:16 +0000 Subject: [PATCH 052/149] Added query.set --- src/textual/css/query.py | 29 +++++++++++++++++++++++++++++ src/textual/reactive.py | 2 +- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 10a97ed54b..af208630e0 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -444,3 +444,32 @@ def blur(self) -> DOMQuery[QueryType]: if focused in nodes: self._node.screen._reset_focus(focused, avoiding=nodes) return self + + def set( + self, + display: bool | None = None, + visible: bool | None = None, + disabled: bool | None = None, + loading: bool | None = None, + ) -> DOMQuery[QueryType]: + """Sets common attributes on matched nodes. + + Args: + display: Set `display` attribute on nodes, or `None` for no change. + visible: Set `visible` attribute on nodes, or `None` for no change. + disabled: Set `disabled` attribute on nodes, or `None` for no change. + loading: Set `loading` attribute on nodes, or `None` for no change. + + Returns: + Query for chaining. + """ + for node in self: + if display is not None: + node.display = display + if visible is not None: + node.visible = visible + if disabled is not None: + node.disabled = disabled + if loading is not None: + node.loading = loading + return self diff --git a/src/textual/reactive.py b/src/textual/reactive.py index fc2ea48450..d320613854 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -168,7 +168,7 @@ def __get__( @overload def __get__( - self: Reactive[ReactiveType], obj: None, obj_type: type[Reactable] + self: Reactive[ReactiveType], obj: None, obj_type: type[ReactableType] ) -> Reactive[ReactiveType]: ... def __get__( From 1ed613904705a32b49ead5c15181fd0a5652a1f7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Feb 2024 15:27:59 +0000 Subject: [PATCH 053/149] simplify bind API --- CHANGELOG.md | 6 ++++++ src/textual/dom.py | 21 +++++++++++---------- tests/test_data_bind.py | 4 ++-- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b2a87dd9..8bd70ca19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- Added DOMQuery.set + ## [0.48.1] - 2023-02-01 ### Fixed diff --git a/src/textual/dom.py b/src/textual/dom.py index 588f167c96..3757b11592 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -200,7 +200,7 @@ def __init__( def data_bind( self, - parent: MessagePump | None = None, + *reactives: Reactive[Any], **bind_vars: Reactive[Any], ) -> Self: """Bind reactive data. @@ -216,22 +216,20 @@ def data_bind( """ _rich_traceback_omit = True - if parent is None: - parent = active_message_pump.get() + parent = active_message_pump.get() if self._reactive_connect is None: self._reactive_connect = {} + bind_vars = {**{reactive.name: reactive for reactive in reactives}, **bind_vars} for name, reactive in bind_vars.items(): if name not in self._reactives: raise ReactiveError( f"Unable to bind non-reactive attribute {name!r} on {self}" ) - if not isinstance(parent, reactive.owner): raise ReactiveError( f"Reactive type {reactive.owner.__name__!r} must be defined on class {parent.__class__.__name__!r}" ) - self._reactive_connect[name] = reactive self._initialize_data_bind(parent) return self @@ -246,16 +244,19 @@ def _initialize_data_bind(self, compose_parent: MessagePump) -> None: return for variable_name, reactive in self._reactive_connect.items(): - def setter(value: object) -> None: - """Set bound data.""" - Reactive._initialize_object(self) - setattr(self, variable_name, value) + def make_setter(variable_name: str) -> Callable[[object], None]: + def setter(value: object) -> None: + """Set bound data.""" + Reactive._initialize_object(self) + setattr(self, variable_name, value) + + return setter assert isinstance(compose_parent, DOMNode) self.watch( compose_parent, reactive.name, - setter, + make_setter(variable_name), init=self._parent is not None, ) self._reactive_connect = None diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py index a595e7898c..ba29e87d1e 100644 --- a/tests/test_data_bind.py +++ b/tests/test_data_bind.py @@ -47,10 +47,10 @@ async def test_data_binding(): with pytest.raises(ReactiveError): # This should be an error because FooLabel.foo is not defined on the app - label2.data_bind(app, foo=FooLabel.foo) + label2.data_bind(foo=FooLabel.foo) # Bind data outside of compose - label2.data_bind(app, foo=DataBindApp.bar) + label2.data_bind(foo=DataBindApp.bar) # Confirm new binding has propagated assert label2.foo == "Baz" From 7c5f2917043d1e81d0edbdc6e43ce902ed33d857 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Thu, 1 Feb 2024 17:06:58 +0000 Subject: [PATCH 054/149] docs(suspend): fix example highlighted lines --- docs/guide/app.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/app.md b/docs/guide/app.md index ebee06748a..5a59322802 100644 --- a/docs/guide/app.md +++ b/docs/guide/app.md @@ -258,7 +258,7 @@ The following Textual app will launch [vim](https://www.vim.org/) (a text editor === "suspend.py" - ```python hl_lines="14-15" + ```python hl_lines="15-16" --8<-- "docs/examples/app/suspend.py" ``` From cf04b3c01b64c32a9b8610aeaefb55e2a2ea2650 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 1 Feb 2024 17:24:04 +0000 Subject: [PATCH 055/149] World clock examples --- src/textual/_compose.py | 2 +- src/textual/dom.py | 44 ++++++++++++++++++++++++++--------------- src/textual/reactive.py | 4 ++++ src/textual/widget.py | 10 +++++++--- 4 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index a1266ea9e8..028738c709 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -54,7 +54,7 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None - child._initialize_data_bind(node) + # child._initialize_data_bind(node) if composed: nodes.extend(composed) diff --git a/src/textual/dom.py b/src/textual/dom.py index 3757b11592..c8f658de0a 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -194,14 +194,16 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False - self._reactive_connect: dict[str, Reactive] | None = None + self._reactive_connect: ( + dict[str, tuple[MessagePump, Reactive | object]] | None + ) = None super().__init__() def data_bind( self, *reactives: Reactive[Any], - **bind_vars: Reactive[Any], + **bind_vars: Reactive[Any] | object, ) -> Self: """Bind reactive data. @@ -226,15 +228,19 @@ def data_bind( raise ReactiveError( f"Unable to bind non-reactive attribute {name!r} on {self}" ) - if not isinstance(parent, reactive.owner): - raise ReactiveError( - f"Reactive type {reactive.owner.__name__!r} must be defined on class {parent.__class__.__name__!r}" - ) - self._reactive_connect[name] = reactive - self._initialize_data_bind(parent) + self._reactive_connect[name] = (parent, reactive) + # if isinstance(reactive, Reactive): + # # if not isinstance(parent, reactive.owner): + # # raise ReactiveError( + # # f"Reactive type {reactive.owner.__name__!r} must be defined on class {parent.__class__.__name__!r}" + # # ) + # self._reactive_connect[name] = reactive + # else: + # setattr(self, name, reactive) + self._initialize_data_bind() return self - def _initialize_data_bind(self, compose_parent: MessagePump) -> None: + def _initialize_data_bind(self) -> None: """initialize a data binding. Args: @@ -242,7 +248,7 @@ def _initialize_data_bind(self, compose_parent: MessagePump) -> None: """ if not self._reactive_connect: return - for variable_name, reactive in self._reactive_connect.items(): + for variable_name, (compose_parent, reactive) in self._reactive_connect.items(): def make_setter(variable_name: str) -> Callable[[object], None]: def setter(value: object) -> None: @@ -253,12 +259,18 @@ def setter(value: object) -> None: return setter assert isinstance(compose_parent, DOMNode) - self.watch( - compose_parent, - reactive.name, - make_setter(variable_name), - init=self._parent is not None, - ) + setter = make_setter(variable_name) + if isinstance(reactive, Reactive): + self.watch( + compose_parent, + reactive.name, + setter, + init=self._parent is not None, + ) + else: + from functools import partial + + self.call_later(partial(setter, reactive)) self._reactive_connect = None def compose_add_child(self, widget: Widget) -> None: diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d320613854..d54d353f13 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -180,6 +180,10 @@ def __get__( if obj is None: # obj is None means we are invoking the descriptor via the class, and not the instance return self + if not hasattr(obj, "id"): + raise ReactiveError( + f"Reactive node {obj.__class__.__name__!r} is missing data; Do you need to call super().__init__(...) first?" + ) internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) diff --git a/src/textual/widget.py b/src/textual/widget.py index 568e11ad1a..85daa5025e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2784,9 +2784,12 @@ def __init_subclass__( ) def __rich_repr__(self) -> rich.repr.Result: - yield "id", self.id, None - if self.name: - yield "name", self.name + try: + yield "id", self.id, None + if self.name: + yield "name", self.name + except AttributeError: + pass def _get_scrollable_region(self, region: Region) -> Region: """Adjusts the Widget region to accommodate scrollbars. @@ -3430,6 +3433,7 @@ async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) async def _on_compose(self, event: events.Compose) -> None: + _rich_traceback_omit = True event.prevent_default() try: widgets = [*self._pending_children, *compose(self)] From f0e9d21156adeaea40072c774506300ef76db0f9 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Thu, 1 Feb 2024 18:50:56 +0000 Subject: [PATCH 056/149] fix(option list): add max height to fix scrolling --- src/textual/widgets/_option_list.py | 1 + .../__snapshots__/test_snapshots.ambr | 159 ++++++++++++++++++ .../snapshot_apps/option_list_long.py | 12 ++ tests/snapshot_tests/test_snapshots.py | 4 + 4 files changed, 176 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/option_list_long.py diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index a9ba696bc9..6b282f70fc 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -177,6 +177,7 @@ class OptionList(ScrollView, can_focus=True): DEFAULT_CSS = """ OptionList { height: auto; + max-height: 100%; background: $boost; color: $text; overflow-x: hidden; diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index a9d82110a1..c9ada99cef 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -25515,6 +25515,165 @@ ''' # --- +# name: test_option_list_scrolling_in_long_list + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LongOptionListApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is option #78 + This is option #79 + This is option #80 + This is option #81 + This is option #82 + This is option #83 + This is option #84 + This is option #85 + This is option #86 + This is option #87 + This is option #88 + This is option #89 + This is option #90 + This is option #91 + This is option #92 + This is option #93 + This is option #94 + This is option #95▇▇ + This is option #96 + This is option #97 + This is option #98 + This is option #99 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_option_list_strings ''' diff --git a/tests/snapshot_tests/snapshot_apps/option_list_long.py b/tests/snapshot_tests/snapshot_apps/option_list_long.py new file mode 100644 index 0000000000..7971defa10 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/option_list_long.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +class LongOptionListApp(App[None]): + def compose(self) -> ComposeResult: + yield OptionList(*[Option(f"This is option #{n}") for n in range(100)]) + + +if __name__ == "__main__": + LongOptionListApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4b4f20cd03..43cf9b6b16 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -296,6 +296,10 @@ def test_option_list_replace_prompt_from_two_lines_to_three_lines(snap_compare): ) +def test_option_list_scrolling_in_long_list(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "option_list_long.py", press=["up"]) + + def test_progress_bar_indeterminate(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"]) From 9789dc77e7f932fa5cd99fcb836f9dc387366727 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Thu, 1 Feb 2024 19:07:49 +0000 Subject: [PATCH 057/149] update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3796faf7f..4ff17fb561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed scrolling in long `OptionList` by adding max height of 100% https://github.com/Textualize/textual/issues/4021 + ### Changed - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 From bf87df3289e7eda0912d6811d4ce47c1145d4db6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 2 Feb 2024 09:34:46 +0000 Subject: [PATCH 058/149] Fix broken OptionList Option id mapping Fixes #4101 --- CHANGELOG.md | 4 ++++ src/textual/widgets/_option_list.py | 7 +++--- .../test_option_list_id_stability.py | 22 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 tests/option_list/test_option_list_id_stability.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3796faf7f..fe08307707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 + ### Changed - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index a9ba696bc9..888140bbee 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -573,15 +573,16 @@ def add_options(self, items: Iterable[NewOptionListContent]) -> Self: content = [self._make_content(item) for item in items] self._duplicate_id_check(content) self._contents.extend(content) - # Pull out the content that is genuine options. Add them to the - # list of options and map option IDs to their new indices. + # Pull out the content that is genuine options, create any new + # ID mappings required, then add the new options to the option + # list. new_options = [item for item in content if isinstance(item, Option)] - self._options.extend(new_options) for new_option_index, new_option in enumerate( new_options, start=len(self._options) ): if new_option.id: self._option_ids[new_option.id] = new_option_index + self._options.extend(new_options) self._refresh_content_tracking(force=True) self.refresh() diff --git a/tests/option_list/test_option_list_id_stability.py b/tests/option_list/test_option_list_id_stability.py new file mode 100644 index 0000000000..bd746914b0 --- /dev/null +++ b/tests/option_list/test_option_list_id_stability.py @@ -0,0 +1,22 @@ +"""Tests inspired by https://github.com/Textualize/textual/issues/4101""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option + + +class OptionListApp(App[None]): + """Test option list application.""" + + def compose(self) -> ComposeResult: + yield OptionList() + + +async def test_get_after_add() -> None: + """It should be possible to get an option by ID after adding.""" + async with OptionListApp().run_test() as pilot: + option_list = pilot.app.query_one(OptionList) + option_list.add_option(Option("0", id="0")) + assert option_list.get_option("0").id == "0" From aeeb93d69cad4e55ff819f6488a771976329a1d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 10:54:56 +0000 Subject: [PATCH 059/149] reactive init --- CHANGELOG.md | 4 ++ .../guide/reactivity/world_clock01.py | 48 +++++++++++++++++++ .../guide/reactivity/world_clock01.tcss | 16 +++++++ .../guide/reactivity/world_clock02.py | 47 ++++++++++++++++++ src/textual/css/query.py | 24 ++++++---- src/textual/reactive.py | 10 +++- 6 files changed, 137 insertions(+), 12 deletions(-) create mode 100644 docs/examples/guide/reactivity/world_clock01.py create mode 100644 docs/examples/guide/reactivity/world_clock01.tcss create mode 100644 docs/examples/guide/reactivity/world_clock02.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bd70ca19a..2d15b8786e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added DOMQuery.set +### Changed + +- Reactives with init=False will not call watchers until they are mounted + ## [0.48.1] - 2023-02-01 ### Fixed diff --git a/docs/examples/guide/reactivity/world_clock01.py b/docs/examples/guide/reactivity/world_clock01.py new file mode 100644 index 0000000000..f33f55544c --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London") + yield WorldClock("Europe/Paris") + yield WorldClock("Asia/Tokyo") + + def update_time(self) -> None: + time = datetime.now() + for world_clock in self.query(WorldClock): + world_clock.time = time + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + app = WorldClockApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock01.tcss b/docs/examples/guide/reactivity/world_clock01.tcss new file mode 100644 index 0000000000..d0b4f22695 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.tcss @@ -0,0 +1,16 @@ +Screen { + align: center middle; +} + +WorldClock { + width: auto; + height: auto; + padding: 1 2; + background: $panel; + border: wide $background; + + & Digits { + width: auto; + color: $secondary; + } +} diff --git a/docs/examples/guide/reactivity/world_clock02.py b/docs/examples/guide/reactivity/world_clock02.py new file mode 100644 index 0000000000..1dd94a67aa --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock02.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() diff --git a/src/textual/css/query.py b/src/textual/css/query.py index af208630e0..a8520af50f 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -85,6 +85,7 @@ def __init__( Raises: InvalidQueryFormat: If the format of the query is invalid. """ + _rich_traceback_omit = True self._node = node self._nodes: list[QueryType] | None = None self._filters: list[tuple[SelectorSet, ...]] = ( @@ -153,16 +154,19 @@ def __getitem__(self, index: int | slice) -> QueryType | list[QueryType]: return self.nodes[index] def __rich_repr__(self) -> rich.repr.Result: - if self._filters: - yield "query", " AND ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._filters - ) - if self._excludes: - yield "exclude", " OR ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._excludes - ) + try: + if self._filters: + yield "query", " AND ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._filters + ) + if self._excludes: + yield "exclude", " OR ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._excludes + ) + except AttributeError: + pass def filter(self, selector: str) -> DOMQuery[QueryType]: """Filter this set by the given CSS selector. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d54d353f13..be66ebe7cb 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -182,7 +182,7 @@ def __get__( return self if not hasattr(obj, "id"): raise ReactiveError( - f"Reactive node {obj.__class__.__name__!r} is missing data; Do you need to call super().__init__(...) first?" + f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before getting reactives." ) internal_name = self.internal_name if not hasattr(obj, internal_name): @@ -201,6 +201,11 @@ def __get__( def __set__(self, obj: Reactable, value: ReactiveType) -> None: _rich_traceback_omit = True + if not hasattr(obj, "_id"): + raise ReactiveError( + f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before setting reactives." + ) + self._initialize_reactive(obj, self.name) if hasattr(obj, self.compute_name): @@ -223,7 +228,8 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: setattr(obj, self.internal_name, value) # Check all watchers - self._check_watchers(obj, name, current_value) + if self._init or (not self._init and obj._is_mounted): + self._check_watchers(obj, name, current_value) if self._run_compute: self._compute(obj) From 3e59e2c7e30481a6aa6281e63f2ae98f8353eada Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 11:16:00 +0000 Subject: [PATCH 060/149] setter --- src/textual/dom.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index c8f658de0a..cd32f0f641 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -229,14 +229,6 @@ def data_bind( f"Unable to bind non-reactive attribute {name!r} on {self}" ) self._reactive_connect[name] = (parent, reactive) - # if isinstance(reactive, Reactive): - # # if not isinstance(parent, reactive.owner): - # # raise ReactiveError( - # # f"Reactive type {reactive.owner.__name__!r} must be defined on class {parent.__class__.__name__!r}" - # # ) - # self._reactive_connect[name] = reactive - # else: - # setattr(self, name, reactive) self._initialize_data_bind() return self @@ -251,6 +243,15 @@ def _initialize_data_bind(self) -> None: for variable_name, (compose_parent, reactive) in self._reactive_connect.items(): def make_setter(variable_name: str) -> Callable[[object], None]: + """Make a setter for the given variable name. + + Args: + variable_name: Name of variable being set. + + Returns: + A callable which takes the value to set. + """ + def setter(value: object) -> None: """Set bound data.""" Reactive._initialize_object(self) From 0755e899c56c6d23174d82d076719219d7d576d6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 11:40:57 +0000 Subject: [PATCH 061/149] revert init change --- CHANGELOG.md | 1 - src/textual/dom.py | 6 ++++++ src/textual/reactive.py | 3 +-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a157aa3f..57df005231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Reactives with init=False will not call watchers until they are mounted - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 ## [0.48.1] - 2023-02-01 diff --git a/src/textual/dom.py b/src/textual/dom.py index cd32f0f641..4fa33d3540 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -228,6 +228,12 @@ def data_bind( raise ReactiveError( f"Unable to bind non-reactive attribute {name!r} on {self}" ) + if isinstance(reactive, Reactive) and not isinstance( + parent, reactive.owner + ): + raise ReactiveError( + f"Unable to bind data; {reactive.owner.__name__} is not defined on {parent.__class__.__name__}." + ) self._reactive_connect[name] = (parent, reactive) self._initialize_data_bind() return self diff --git a/src/textual/reactive.py b/src/textual/reactive.py index be66ebe7cb..667bfa9d4e 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -228,8 +228,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: setattr(obj, self.internal_name, value) # Check all watchers - if self._init or (not self._init and obj._is_mounted): - self._check_watchers(obj, name, current_value) + self._check_watchers(obj, name, current_value) if self._run_compute: self._compute(obj) From 8c2b5d2d779a21535db2ad35656baecee67247b4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 2 Feb 2024 14:26:30 +0000 Subject: [PATCH 062/149] Only perform the SIGTOU test if we're hooked up to a tty Fixes #4104 Co-authored-by: Pablo Galindo --- src/textual/drivers/linux_driver.py | 49 ++++++++++++++++------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 7f33082d34..78628f6ae4 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -156,28 +156,33 @@ def _stop_again(*_) -> None: """Signal handler that will put the application back to sleep.""" os.kill(os.getpid(), signal.SIGSTOP) - # Set up handlers to ensure that, if there's a SIGTTOU or a SIGTTIN, - # we go back to sleep. - signal.signal(signal.SIGTTOU, _stop_again) - signal.signal(signal.SIGTTIN, _stop_again) - try: - # Here we perform a NOP tcsetattr. The reason for this is that, - # if we're suspended and the user has performed a `bg` in the - # shell, we'll SIGCONT *but* we won't be allowed to do terminal - # output; so rather than get into the business of spinning up - # application mode again and then finding out, we perform a - # no-consequence change and detect the problem right away. - termios.tcsetattr( - self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno) - ) - except termios.error: - # There was an error doing the tcsetattr; there is no sense in - # carrying on because we'll be doing a SIGSTOP (see above). - return - finally: - # We don't need to be hooking SIGTTOU or SIGTTIN any more. - signal.signal(signal.SIGTTOU, signal.SIG_DFL) - signal.signal(signal.SIGTTIN, signal.SIG_DFL) + # If we're working with an actual tty... + # https://github.com/Textualize/textual/issues/4104 + if os.isatty(self.fileno): + # Set up handlers to ensure that, if there's a SIGTTOU or a SIGTTIN, + # we go back to sleep. + signal.signal(signal.SIGTTOU, _stop_again) + signal.signal(signal.SIGTTIN, _stop_again) + try: + # Here we perform a NOP tcsetattr. The reason for this is + # that, if we're suspended and the user has performed a `bg` + # in the shell, we'll SIGCONT *but* we won't be allowed to + # do terminal output; so rather than get into the business + # of spinning up application mode again and then finding + # out, we perform a no-consequence change and detect the + # problem right away. + termios.tcsetattr( + self.fileno, termios.TCSANOW, termios.tcgetattr(self.fileno) + ) + except termios.error: + # There was an error doing the tcsetattr; there is no sense + # in carrying on because we'll be doing a SIGSTOP (see + # above). + return + finally: + # We don't need to be hooking SIGTTOU or SIGTTIN any more. + signal.signal(signal.SIGTTOU, signal.SIG_DFL) + signal.signal(signal.SIGTTIN, signal.SIG_DFL) loop = asyncio.get_running_loop() From 3697157c526342f6c36de849483542f25020dbab Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 2 Feb 2024 14:29:53 +0000 Subject: [PATCH 063/149] Update the ChangeLog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3796faf7f..157edeab69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 + ### Changed - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 From 6b82aef5ec0dccbf237351731472874d8451b531 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 16:21:53 +0000 Subject: [PATCH 064/149] Added set_reactive --- CHANGELOG.md | 1 + src/textual/dom.py | 30 ++++++++++++++++++++++++++++++ src/textual/widgets/_log.py | 2 +- src/textual/widgets/_switch.py | 4 ++-- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57df005231..c808aaa5d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added DOMQuery.set +- Added DOMNode.set_reactive ### Changed diff --git a/src/textual/dom.py b/src/textual/dom.py index 4fa33d3540..8736301563 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -68,6 +68,9 @@ """Valid walking methods for the [`DOMNode.walk_children` method][textual.dom.DOMNode.walk_children].""" +ReactiveType = TypeVar("ReactiveType") + + class BadIdentifier(Exception): """Exception raised if you supply a `id` attribute or class name in the wrong format.""" @@ -200,6 +203,33 @@ def __init__( super().__init__() + def set_reactive( + self, reactive: Reactive[ReactiveType], value: ReactiveType + ) -> None: + """Sets a reactive value *without* invoking validators or watchers. + + Example: + ```python + self.set_reactive(App.dark_mode, True) + ``` + + Args: + name: Name of reactive attribute. + value: New value of reactive. + + Raises: + AttributeError: If the first argument is not a reactive. + """ + if not isinstance(reactive, Reactive): + raise TypeError( + "A Reactive class is required; for example: MyApp.dark_mode" + ) + if reactive.name not in self._reactives: + raise AttributeError( + "No reactive called {name!r}; Have you called super().__init__(...) in the {self.__class__.__name__} constructor?" + ) + setattr(self, f"_reactive_{reactive.name}", value) + def data_bind( self, *reactives: Reactive[Any], diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index df5b645c4f..12eea8bc10 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -61,6 +61,7 @@ def __init__( classes: The CSS classes of the text log. disabled: Whether the text log is disabled or not. """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.highlight = highlight """Enable highlighting.""" self.max_lines = max_lines @@ -71,7 +72,6 @@ def __init__( self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024) self.highlighter = ReprHighlighter() """The Rich Highlighter object to use, if `highlight=True`""" - super().__init__(name=name, id=id, classes=classes, disabled=disabled) @property def lines(self) -> Sequence[str]: diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index a6114ff3a8..2b8d4ccc39 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -74,7 +74,7 @@ class Switch(Widget, can_focus=True): } """ - value = reactive(False, init=False) + value: reactive[bool] = reactive(False, init=False) """The value of the switch; `True` for on and `False` for off.""" slider_pos = reactive(0.0) @@ -124,7 +124,7 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 - self._reactive_value = value + self.set_reactive(Switch.value, value) self._should_animate = animate def watch_value(self, value: bool) -> None: From 3d3bc7ab79b7ce34070c16ac284c76db3bbc9264 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 16:32:28 +0000 Subject: [PATCH 065/149] bump --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 284702824d..68e578f5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.48.2] ### Fixed @@ -1638,6 +1638,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.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2 [0.48.1]: https://github.com/Textualize/textual/compare/v0.48.0...v0.48.1 [0.48.0]: https://github.com/Textualize/textual/compare/v0.47.1...v0.48.0 [0.47.1]: https://github.com/Textualize/textual/compare/v0.47.0...v0.47.1 diff --git a/pyproject.toml b/pyproject.toml index c3c4cb9315..72502f3bb2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.48.1" +version = "0.48.2" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From f3d2b20bd9e1a2343991010cbd5fffa94fabf8e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 2 Feb 2024 16:33:43 +0000 Subject: [PATCH 066/149] fix dates --- CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68e578f5b5..005b76bbfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.48.2] +## [0.48.2] - 2024-02-02 ### Fixed @@ -16,13 +16,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 -## [0.48.1] - 2023-02-01 +## [0.48.1] - 2024-02-01 ### Fixed - `TextArea` uses CSS theme by default instead of `monokai` https://github.com/Textualize/textual/pull/4091 -## [0.48.0] - 2023-02-01 +## [0.48.0] - 2024-02-01 ### Changed @@ -70,7 +70,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed display of keys when used in conjunction with other keys https://github.com/Textualize/textual/pull/3050 - Fixed double detection of Escape on Windows https://github.com/Textualize/textual/issues/4038 -## [0.47.1] - 2023-01-05 +## [0.47.1] - 2024-01-05 ### Fixed From ca2c11bdb86d7f48acadee534a1764777b444d34 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 5 Feb 2024 10:33:22 +0000 Subject: [PATCH 067/149] docs(text area): fix syntax highlighting in examples (#4099) * docs(text area): fix syntax highlighting in examples * revert text_area_extended.py * fix class method * fix extended text area example --- docs/examples/widgets/text_area_custom_language.py | 2 +- docs/examples/widgets/text_area_example.py | 2 +- docs/examples/widgets/text_area_extended.py | 2 +- docs/examples/widgets/text_area_selection.py | 2 +- src/textual/widgets/_text_area.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/examples/widgets/text_area_custom_language.py b/docs/examples/widgets/text_area_custom_language.py index 70ee7e16b9..1fba664032 100644 --- a/docs/examples/widgets/text_area_custom_language.py +++ b/docs/examples/widgets/text_area_custom_language.py @@ -18,7 +18,7 @@ class HelloWorld { class TextAreaCustomLanguage(App): def compose(self) -> ComposeResult: - text_area = TextArea(text=java_code) + text_area = TextArea.code_editor(text=java_code) text_area.cursor_blink = False # Register the Java language and highlight query diff --git a/docs/examples/widgets/text_area_example.py b/docs/examples/widgets/text_area_example.py index 2e0e31c060..f7534a449f 100644 --- a/docs/examples/widgets/text_area_example.py +++ b/docs/examples/widgets/text_area_example.py @@ -12,7 +12,7 @@ def goodbye(name): class TextAreaExample(App): def compose(self) -> ComposeResult: - yield TextArea(TEXT, language="python") + yield TextArea.code_editor(TEXT, language="python") app = TextAreaExample() diff --git a/docs/examples/widgets/text_area_extended.py b/docs/examples/widgets/text_area_extended.py index 8ac237db88..26d29ceadb 100644 --- a/docs/examples/widgets/text_area_extended.py +++ b/docs/examples/widgets/text_area_extended.py @@ -15,7 +15,7 @@ def _on_key(self, event: events.Key) -> None: class TextAreaKeyPressHook(App): def compose(self) -> ComposeResult: - yield ExtendedTextArea(language="python") + yield ExtendedTextArea.code_editor(language="python") app = TextAreaKeyPressHook() diff --git a/docs/examples/widgets/text_area_selection.py b/docs/examples/widgets/text_area_selection.py index 4165eb2d2d..980f597e1f 100644 --- a/docs/examples/widgets/text_area_selection.py +++ b/docs/examples/widgets/text_area_selection.py @@ -13,7 +13,7 @@ def goodbye(name): class TextAreaSelection(App): def compose(self) -> ComposeResult: - text_area = TextArea(TEXT, language="python") + text_area = TextArea.code_editor(TEXT, language="python") text_area.selection = Selection(start=(0, 0), end=(2, 0)) # (1)! yield text_area diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 48e835020d..6c8abd101c 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -457,7 +457,7 @@ def code_editor( classes: One or more Textual CSS compatible class names separated by spaces. disabled: True if the widget is disabled. """ - return TextArea( + return cls( text, language=language, theme=theme, From 00233700bebf3a579b95d16d799822c137eea999 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, 5 Feb 2024 16:36:39 +0000 Subject: [PATCH 068/149] Fix DirectoryTree.clear_node not having effect. --- CHANGELOG.md | 6 +++ src/textual/widgets/_directory_tree.py | 12 +----- tests/tree/test_directory_tree.py | 52 ++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 005b76bbfa..bb8c1a71bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- Fixed `DirectoryTree.clear_node` not clearing the node specified https://github.com/Textualize/textual/issues/4122 + ## [0.48.2] - 2024-02-02 ### Fixed diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3af6b4841f..3cee5fca5a 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -192,17 +192,7 @@ def clear_node(self, node: TreeNode[DirEntry]) -> Self: The `Tree` instance. """ self._clear_line_cache() - node_label = node._label - node_data = node.data - node_parent = node.parent - node = TreeNode( - self, - node_parent, - self._new_id(), - node_label, - node_data, - expanded=True, - ) + node.remove_children() self._updates += 1 self.refresh() return self diff --git a/tests/tree/test_directory_tree.py b/tests/tree/test_directory_tree.py index c56bf48d52..71be643f8d 100644 --- a/tests/tree/test_directory_tree.py +++ b/tests/tree/test_directory_tree.py @@ -1,6 +1,9 @@ from __future__ import annotations +from pathlib import Path + from rich.text import Text + from textual import on from textual.app import App, ComposeResult from textual.widgets import DirectoryTree @@ -25,7 +28,22 @@ def record( self.messages.append(event.__class__.__name__) -async def test_directory_tree_file_selected_message(tmp_path) -> None: +class RecordExpandDirectoryTreeApp(App[None]): + def __init__(self, path: Path): + super().__init__() + self.path = path + self.expanded: list[str] = [] + + def compose(self) -> ComposeResult: + yield DirectoryTree(self.path) + + @on(DirectoryTree.DirectorySelected) + def record(self, event: DirectoryTree.DirectorySelected) -> None: + assert event.node.data is not None + self.expanded.append(str(event.node.data.path.name)) + + +async def test_directory_tree_file_selected_message(tmp_path: Path) -> None: """Selecting a file should result in a file selected message being emitted.""" FILE_NAME = "hello.txt" @@ -48,7 +66,7 @@ async def test_directory_tree_file_selected_message(tmp_path) -> None: assert pilot.app.messages == ["FileSelected"] -async def test_directory_tree_directory_selected_message(tmp_path) -> None: +async def test_directory_tree_directory_selected_message(tmp_path: Path) -> None: """Selecting a directory should result in a directory selected message being emitted.""" SUBDIR = "subdir" @@ -79,7 +97,7 @@ async def test_directory_tree_directory_selected_message(tmp_path) -> None: assert pilot.app.messages == ["DirectorySelected", "DirectorySelected"] -async def test_directory_tree_reload_node(tmp_path) -> None: +async def test_directory_tree_reload_node(tmp_path: Path) -> None: """Reloading a node of a directory tree should display newly created file inside the directory.""" RELOADED_DIRECTORY = "parentdir" @@ -123,7 +141,7 @@ async def test_directory_tree_reload_node(tmp_path) -> None: ] -async def test_directory_tree_reload_other_node(tmp_path) -> None: +async def test_directory_tree_reload_other_node(tmp_path: Path) -> None: """Reloading a node of a directory tree should not reload content of other directory.""" RELOADED_DIRECTORY = "parentdir" @@ -172,3 +190,29 @@ async def test_directory_tree_reload_other_node(tmp_path) -> None: # After reloading one node, the new file under the other one does not show up assert len(unaffected_node.children) == 1 assert unaffected_node.children[0].label == Text(NOT_RELOADED_FILE3_NAME) + + +async def test_directory_tree_reloading_preserves_state(tmp_path: Path) -> None: + """Regression test for https://github.com/Textualize/textual/issues/4122. + + Ensures `clear_node` does clear the node specified. + """ + ROOT = "root" + structure = [ + ROOT, + "root/file1.txt", + "root/file2.txt", + ] + + for path in structure: + if path.endswith(".txt"): + (tmp_path / path).touch() + else: + (tmp_path / path).mkdir() + + app = DirectoryTreeApp(tmp_path / ROOT) + async with app.run_test() as pilot: + directory_tree = app.query_one(DirectoryTree) + directory_tree.clear_node(directory_tree.root) + await pilot.pause() + assert not directory_tree.root.children From 14d196b7c15463b755bd06ff6a5d1dd2ad308158 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 5 Feb 2024 17:16:10 +0000 Subject: [PATCH 069/149] correct changelog --- CHANGELOG.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c38405eb..b124b92b26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +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.48.2] - 2024-02-02 +## Unreleased ### Fixed -- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 -- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 +- Fixed scrolling in long `OptionList` by adding max height of 100% https://github.com/Textualize/textual/issues/4021 + +## [0.48.2] - 2024-02-02 ### Fixed -- Fixed scrolling in long `OptionList` by adding max height of 100% https://github.com/Textualize/textual/issues/4021 +- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 +- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 ### Changed From 43f15d8bcf5e97c03546bd8fd7aaa59783682591 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, 5 Feb 2024 16:59:27 +0000 Subject: [PATCH 070/149] Preserve state while reloading directory tree. --- CHANGELOG.md | 8 + src/textual/widgets/_directory_tree.py | 164 +++++++++++++----- src/textual/widgets/_tree.py | 37 ++-- .../__snapshots__/test_snapshots.ambr | 160 +++++++++++++++++ .../snapshot_apps/directory_tree_reload.py | 62 +++++++ tests/snapshot_tests/test_snapshots.py | 13 ++ tests/tree/test_directory_tree.py | 15 -- 7 files changed, 388 insertions(+), 71 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/directory_tree_reload.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8c1a71bf..acd43d635b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `DirectoryTree.clear_node` not clearing the node specified https://github.com/Textualize/textual/issues/4122 +### Added + +- `Tree` (and `DirectoryTree`) grew an attribute `lock` that can be used for synchronization across coroutines https://github.com/Textualize/textual/issues/4056 + +### Changed + +- `DirectoryTree.reload` and `DirectoryTree.reload_node` now preserve state when reloading https://github.com/Textualize/textual/issues/4056 + ## [0.48.2] - 2024-02-02 ### Fixed diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 3cee5fca5a..46bef1550c 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -5,20 +5,19 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Iterator -from ..await_complete import AwaitComplete - -if TYPE_CHECKING: - from typing_extensions import Self - from rich.style import Style from rich.text import Text, TextType from .. import work +from ..await_complete import AwaitComplete from ..message import Message from ..reactive import var from ..worker import Worker, WorkerCancelled, WorkerFailed, get_current_worker from ._tree import TOGGLE_STYLE, Tree, TreeNode +if TYPE_CHECKING: + from typing_extensions import Self + @dataclass class DirEntry: @@ -164,7 +163,7 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete: Returns: An optionally awaitable object that can be awaited until the - load queue has finished processing. + load queue has finished processing. """ assert node.data is not None if not node.data.loaded: @@ -174,16 +173,18 @@ def _add_to_load_queue(self, node: TreeNode[DirEntry]) -> AwaitComplete: return AwaitComplete(self._load_queue.join()) def reload(self) -> AwaitComplete: - """Reload the `DirectoryTree` contents.""" - self.reset(str(self.path), DirEntry(self.PATH(self.path))) + """Reload the `DirectoryTree` contents. + + Returns: + An optionally awaitable that ensures the tree has finished reloading. + """ # Orphan the old queue... self._load_queue = Queue() + # ... reset the root node ... + processed = self.reload_node(self.root) # ...and replace the old load with a new one. self._loader() - # We have a fresh queue, we have a fresh loader, get the fresh root - # loading up. - queue_processed = self._add_to_load_queue(self.root) - return queue_processed + return processed def clear_node(self, node: TreeNode[DirEntry]) -> Self: """Clear all nodes under the given node. @@ -215,6 +216,88 @@ def reset_node( node.data = data return self + async def _reload(self, node: TreeNode[DirEntry]) -> None: + """Reloads the subtree rooted at the given node while preserving state. + + After reloading the subtree, nodes that were expanded and still exist + will remain expanded and the highlighted node will be preserved, if it + still exists. If it doesn't, highlighting goes up to the first parent + directory that still exists. + + Args: + node: The root of the subtree to reload. + """ + async with self.lock: + # Track nodes that were expanded before reloading. + currently_open: set[Path] = set() + to_check: list[TreeNode[DirEntry]] = [node] + while to_check: + checking = to_check.pop() + if checking.allow_expand and checking.is_expanded: + if checking.data: + currently_open.add(checking.data.path) + to_check.extend(checking.children) + + # Track node that was highlighted before reloading. + highlighted_path: None | Path = None + if self.cursor_line > -1: + highlighted_node = self.get_node_at_line(self.cursor_line) + if highlighted_node is not None and highlighted_node.data is not None: + highlighted_path = highlighted_node.data.path + + if node.data is not None: + self.reset_node( + node, str(node.data.path.name), DirEntry(self.PATH(node.data.path)) + ) + + # Reopen nodes that were expanded and still exist. + to_reopen = [node] + while to_reopen: + reopening = to_reopen.pop() + if not reopening.data: + continue + if ( + reopening.allow_expand + and (reopening.data.path in currently_open or reopening == node) + and reopening.data.path.exists() + ): + try: + content = await self._load_directory(reopening).wait() + except (WorkerCancelled, WorkerFailed): + continue + reopening.data.loaded = True + self._populate_node(reopening, content) + to_reopen.extend(reopening.children) + reopening.expand() + + if highlighted_path is None: + return + + # Restore the highlighted path and consider the parents as fallbacks. + looking = [node] + highlight_candidates = set(highlighted_path.parents) + highlight_candidates.add(highlighted_path) + best_found: None | TreeNode[DirEntry] = None + while looking: + checking = looking.pop() + checking_path = ( + checking.data.path if checking.data is not None else None + ) + if checking_path in highlight_candidates: + best_found = checking + if checking_path == highlighted_path: + break + if ( + checking.allow_expand + and checking.is_expanded + and checking_path in highlighted_path.parents + ): + looking.extend(checking.children) + if best_found is not None: + # We need valid lines. Make sure the tree lines have been computed: + _ = self._tree_lines + self.cursor_line = best_found.line + def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete: """Reload the given node's contents. @@ -223,12 +306,12 @@ def reload_node(self, node: TreeNode[DirEntry]) -> AwaitComplete: or any other nodes). Args: - node: The node to reload. + node: The root of the subtree to reload. + + Returns: + An optionally awaitable that ensures the subtree has finished reloading. """ - self.reset_node( - node, str(node.data.path.name), DirEntry(self.PATH(node.data.path)) - ) - return self._add_to_load_queue(node) + return AwaitComplete(self._reload(node)) def validate_path(self, path: str | Path) -> Path: """Ensure that the path is of the `Path` type. @@ -415,28 +498,29 @@ async def _loader(self) -> None: # this blocks if the queue is empty. node = await self._load_queue.get() content: list[Path] = [] - try: - # Spin up a short-lived thread that will load the content of - # the directory associated with that node. - content = await self._load_directory(node).wait() - except WorkerCancelled: - # The worker was cancelled, that would suggest we're all - # done here and we should get out of the loader in general. - break - except WorkerFailed: - # This particular worker failed to start. We don't know the - # reason so let's no-op that (for now anyway). - pass - else: - # We're still here and we have directory content, get it into - # the tree. - if content: - self._populate_node(node, content) - finally: - # Mark this iteration as done. - self._load_queue.task_done() - - async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: + async with self.lock: + try: + # Spin up a short-lived thread that will load the content of + # the directory associated with that node. + content = await self._load_directory(node).wait() + except WorkerCancelled: + # The worker was cancelled, that would suggest we're all + # done here and we should get out of the loader in general. + break + except WorkerFailed: + # This particular worker failed to start. We don't know the + # reason so let's no-op that (for now anyway). + pass + else: + # We're still here and we have directory content, get it into + # the tree. + if content: + self._populate_node(node, content) + finally: + # Mark this iteration as done. + self._load_queue.task_done() + + async def _on_tree_node_expanded(self, event: Tree.NodeExpanded[DirEntry]) -> None: event.stop() dir_entry = event.node.data if dir_entry is None: @@ -446,7 +530,7 @@ async def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None: else: self.post_message(self.FileSelected(event.node, dir_entry.path)) - def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None: + def _on_tree_node_selected(self, event: Tree.NodeSelected[DirEntry]) -> None: event.stop() dir_entry = event.node.data if dir_entry is None: diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 43a079a5eb..3e0a2d8cb4 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -2,6 +2,7 @@ from __future__ import annotations +from asyncio import Lock from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Generic, Iterable, NewType, TypeVar, cast @@ -615,8 +616,10 @@ def __init__( self.root = self._add_node(None, text_label, data) """The root node of the tree.""" self._line_cache: LRUCache[LineCacheKey, Strip] = LRUCache(1024) - self._tree_lines_cached: list[_TreeLine] | None = None + self._tree_lines_cached: list[_TreeLine[TreeDataType]] | None = None self._cursor_node: TreeNode[TreeDataType] | None = None + self.lock = Lock() + """Used to synchronise stateful directory tree operations.""" super().__init__(name=name, id=id, classes=classes, disabled=disabled) @@ -815,7 +818,7 @@ def _invalidate(self) -> None: self.root._reset() self.refresh(layout=True) - def _on_mouse_move(self, event: events.MouseMove): + def _on_mouse_move(self, event: events.MouseMove) -> None: meta = event.style.meta if meta and "line" in meta: self.hover_line = meta["line"] @@ -948,7 +951,7 @@ def _refresh_node(self, node: TreeNode[TreeDataType]) -> None: self._refresh_line(line_no) @property - def _tree_lines(self) -> list[_TreeLine]: + def _tree_lines(self) -> list[_TreeLine[TreeDataType]]: if self._tree_lines_cached is None: self._build() assert self._tree_lines_cached is not None @@ -957,13 +960,14 @@ def _tree_lines(self) -> list[_TreeLine]: async def _on_idle(self, event: events.Idle) -> None: """Check tree needs a rebuild on idle.""" # Property calls build if required - self._tree_lines + async with self.lock: + self._tree_lines def _build(self) -> None: """Builds the tree by traversing nodes, and creating tree lines.""" TreeLine = _TreeLine - lines: list[_TreeLine] = [] + lines: list[_TreeLine[TreeDataType]] = [] add_line = lines.append root = self.root @@ -989,7 +993,7 @@ def add_node( show_root = self.show_root get_label_width = self.get_label_width - def get_line_width(line: _TreeLine) -> int: + def get_line_width(line: _TreeLine[TreeDataType]) -> int: return get_label_width(line.node) + line._get_guide_width( guide_depth, show_root ) @@ -1147,17 +1151,18 @@ def _toggle_node(self, node: TreeNode[TreeDataType]) -> None: node.expand() async def _on_click(self, event: events.Click) -> None: - meta = event.style.meta - if "line" in meta: - cursor_line = meta["line"] - if meta.get("toggle", False): - node = self.get_node_at_line(cursor_line) - if node is not None: - self._toggle_node(node) + async with self.lock: + meta = event.style.meta + if "line" in meta: + cursor_line = meta["line"] + if meta.get("toggle", False): + node = self.get_node_at_line(cursor_line) + if node is not None: + self._toggle_node(node) - else: - self.cursor_line = cursor_line - await self.run_action("select_cursor") + else: + self.cursor_line = cursor_line + await self.run_action("select_cursor") def notify_style_update(self) -> None: self._invalidate() diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index a9d82110a1..9dec40dc68 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -16740,6 +16740,166 @@ ''' # --- +# name: test_directory_tree_reloading + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + DirectoryTreeReloadApp + + + + + + + + + + 📂 test_directory_tree_reloading0 + ├── 📂 b1 + │   ├── 📂 c1 + │   │   ┣━━ 📂 d1 + │   │   ┃   ┣━━ 📄 f1.txt + │   │   ┃   ┗━━ 📄 f2.txt + │   │   ┣━━ 📄 f1.txt + │   │   ┗━━ 📄 f2.txt + │   ├── 📄 f1.txt + │   └── 📄 f2.txt + ├── 📄 f1.txt + └── 📄 f2.txt + + + + + + + + + + + + + + + + + ''' +# --- # name: test_disabled_widgets ''' diff --git a/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py new file mode 100644 index 0000000000..f1dcc07941 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/directory_tree_reload.py @@ -0,0 +1,62 @@ +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.widgets import DirectoryTree + + +class DirectoryTreeReloadApp(App[None]): + BINDINGS = [ + ("r", "reload"), + ("e", "expand"), + ("d", "delete"), + ] + + async def setup(self, path_root: Path) -> None: + self.path_root = path_root + + structure = [ + "f1.txt", + "f2.txt", + "b1/f1.txt", + "b1/f2.txt", + "b2/f1.txt", + "b2/f2.txt", + "b1/c1/f1.txt", + "b1/c1/f2.txt", + "b1/c2/f1.txt", + "b1/c2/f2.txt", + "b1/c1/d1/f1.txt", + "b1/c1/d1/f2.txt", + "b1/c1/d2/f1.txt", + "b1/c1/d2/f2.txt", + ] + for file in structure: + path = path_root / Path(file) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch(exist_ok=True) + + await self.mount(DirectoryTree(self.path_root)) + + async def action_reload(self) -> None: + dt = self.query_one(DirectoryTree) + await dt.reload() + + def action_expand(self) -> None: + self.query_one(DirectoryTree).root.expand_all() + + def action_delete(self) -> None: + self.rmdir(self.path_root / Path("b1/c1/d2")) + self.rmdir(self.path_root / Path("b1/c2")) + self.rmdir(self.path_root / Path("b2")) + + def rmdir(self, path: Path) -> None: + for file in path.iterdir(): + if file.is_file(): + file.unlink() + elif file.is_dir(): + self.rmdir(file) + path.rmdir() + + +if __name__ == "__main__": + DirectoryTreeReloadApp(Path("playground")).run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4b4f20cd03..b93d5e90d7 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -403,6 +403,19 @@ def test_collapsible_custom_symbol(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible_custom_symbol.py") +def test_directory_tree_reloading(snap_compare, tmp_path): + async def run_before(pilot): + await pilot.app.setup(tmp_path) + await pilot.press( + "e", "e", "down", "down", "down", "down", "e", "down", "d", "r" + ) + + assert snap_compare( + SNAPSHOT_APPS_DIR / "directory_tree_reload.py", + run_before=run_before, + ) + + # --- 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/tree/test_directory_tree.py b/tests/tree/test_directory_tree.py index 71be643f8d..b06b8d5edc 100644 --- a/tests/tree/test_directory_tree.py +++ b/tests/tree/test_directory_tree.py @@ -28,21 +28,6 @@ def record( self.messages.append(event.__class__.__name__) -class RecordExpandDirectoryTreeApp(App[None]): - def __init__(self, path: Path): - super().__init__() - self.path = path - self.expanded: list[str] = [] - - def compose(self) -> ComposeResult: - yield DirectoryTree(self.path) - - @on(DirectoryTree.DirectorySelected) - def record(self, event: DirectoryTree.DirectorySelected) -> None: - assert event.node.data is not None - self.expanded.append(str(event.node.data.path.name)) - - async def test_directory_tree_file_selected_message(tmp_path: Path) -> None: """Selecting a file should result in a file selected message being emitted.""" From fa8c0e8f95d28ad1c5c2ec77b2067660098134d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 5 Feb 2024 21:16:26 +0000 Subject: [PATCH 071/149] fix refresh lines --- docs/guide/queries.md | 10 ++++++---- src/textual/app.py | 3 ++- src/textual/css/query.py | 4 ++-- src/textual/dom.py | 14 +++++++++++--- src/textual/renderables/background_screen.py | 9 ++++++++- src/textual/renderables/tint.py | 6 +++++- src/textual/scroll_view.py | 6 ++++-- src/textual/widgets/_tabbed_content.py | 3 +++ 8 files changed, 41 insertions(+), 14 deletions(-) diff --git a/docs/guide/queries.md b/docs/guide/queries.md index 0b1e5fc105..d87a78a399 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -161,10 +161,12 @@ for widget in self.query("Button"): Here are the other loop-free methods on query objects: -- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. - [add_class][textual.css.query.DOMQuery.add_class] Adds a CSS class (or classes) to matched widgets. +- [blur][textual.css.query.DOMQuery.focus] Blurs (removes focus) from matching widgets. +- [focus][textual.css.query.DOMQuery.focus] Focuses the first matching widgets. +- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - [remove_class][textual.css.query.DOMQuery.remove_class] Removes a CSS class (or classes) from matched widgets. -- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. - [remove][textual.css.query.DOMQuery.remove] Removes matched widgets from the DOM. -- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - +- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. +- [set][textual.css.query.DOMQuery.set] Sets common attributes on a widget. +- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. diff --git a/src/textual/app.py b/src/textual/app.py index 5c54cb8983..68499fec50 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -424,8 +424,9 @@ def __init__( environ = dict(os.environ) no_color = environ.pop("NO_COLOR", None) if no_color is not None: - from .filter import Monochrome + from .filter import ANSIToTruecolor, Monochrome + self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) self._filters.append(Monochrome()) for filter_name in constants.FILTERS.split(","): diff --git a/src/textual/css/query.py b/src/textual/css/query.py index a8520af50f..05aa749622 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -223,7 +223,7 @@ def first( ) return first else: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on {self.node!r}") @overload def only_one(self) -> QueryType: ... @@ -293,7 +293,7 @@ def last( The matching Widget. """ if not self.nodes: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on dom{self.node!r}") last = self.nodes[-1] if expect_type is not None and not isinstance(last, expect_type): raise WrongType( diff --git a/src/textual/dom.py b/src/textual/dom.py index 8736301563..becd830294 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -290,6 +290,7 @@ def make_setter(variable_name: str) -> Callable[[object], None]: def setter(value: object) -> None: """Set bound data.""" + _rich_traceback_omit = True Reactive._initialize_object(self) setattr(self, variable_name, value) @@ -1405,6 +1406,13 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: def refresh(self, *, repaint: bool = True, layout: bool = False) -> Self: return self - async def action_toggle(self, value_name: str) -> None: - value = getattr(self, value_name) - setattr(self, value_name, not value) + async def action_toggle(self, attribute_name: str) -> None: + """Toggle an attribute on the node. + + Assumes the attribute is a bool. + + Args: + attribute_name: Name of the attribute. + """ + value = getattr(self, attribute_name) + setattr(self, attribute_name, not value) diff --git a/src/textual/renderables/background_screen.py b/src/textual/renderables/background_screen.py index 70ed79f1b1..7dd4bc04d7 100644 --- a/src/textual/renderables/background_screen.py +++ b/src/textual/renderables/background_screen.py @@ -2,11 +2,13 @@ from typing import TYPE_CHECKING, Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor if TYPE_CHECKING: from ..screen import Screen @@ -49,12 +51,17 @@ def process_segments( _Segment = Segment NULL_STYLE = Style() + truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style for segment in segments: text, style, control = segment if control: yield segment else: - style = NULL_STYLE if style is None else style.clear_meta_and_links() + style = ( + NULL_STYLE + if style is None + else truecolor_style(style.clear_meta_and_links()) + ) yield _Segment( text, ( diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index ebdd38105e..85d15e217f 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,11 +2,13 @@ from typing import Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor class Tint: @@ -43,13 +45,15 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment + ansi_filter = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI) + NULL_STYLE = Style() for segment in segments: text, style, control = segment if control: yield segment else: - style = style or NULL_STYLE + style = ansi_filter.truecolor_style(style) or NULL_STYLE yield _Segment( text, ( diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index c77ef7bdcb..4ac8619a9a 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -152,7 +152,7 @@ def refresh_line(self, y: int) -> None: """ width = self.virtual_size.width scroll_x, scroll_y = self.scroll_offset - self.refresh(Region(0, y - scroll_y, width, 1)) + self.refresh(Region(0, y - scroll_y, max(width, self.size.width), 1)) def refresh_lines(self, y_start: int, line_count: int = 1) -> None: """Refresh one or more lines. @@ -164,5 +164,7 @@ def refresh_lines(self, y_start: int, line_count: int = 1) -> None: width = self.virtual_size.width scroll_x, scroll_y = self.scroll_offset - refresh_region = Region(0, y_start - scroll_y, width, line_count) + refresh_region = Region( + 0, y_start - scroll_y, max(width, self.size.width), line_count + ) self.refresh(refresh_region) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 5f05064b16..e7ae3eb25b 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -324,6 +324,9 @@ def __init__( @property def active_pane(self) -> TabPane | None: """The currently active pane, or `None` if no pane is active.""" + active = self.active + if not active: + return None return self.get_pane(self.active) def validate_active(self, active: str) -> str: From e620ff897cff372ec7d60ca253af5853d5c12f4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 5 Feb 2024 21:21:13 +0000 Subject: [PATCH 072/149] simplify --- src/textual/scroll_view.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 4ac8619a9a..570bd3fafa 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -150,9 +150,14 @@ def refresh_line(self, y: int) -> None: Args: y: Coordinate of line. """ - width = self.virtual_size.width - scroll_x, scroll_y = self.scroll_offset - self.refresh(Region(0, y - scroll_y, max(width, self.size.width), 1)) + self.refresh( + Region( + 0, + y - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + 1, + ) + ) def refresh_lines(self, y_start: int, line_count: int = 1) -> None: """Refresh one or more lines. @@ -162,9 +167,10 @@ def refresh_lines(self, y_start: int, line_count: int = 1) -> None: line_count: Total number of lines to refresh. """ - width = self.virtual_size.width - scroll_x, scroll_y = self.scroll_offset refresh_region = Region( - 0, y_start - scroll_y, max(width, self.size.width), line_count + 0, + y_start - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + line_count, ) self.refresh(refresh_region) From bffa84f592f6c829a59b5cfc2837273a13e1b342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Tue, 6 Feb 2024 12:09:13 +0000 Subject: [PATCH 073/149] Don't block inside _reload. Address review feedback by handling the error of trying to load a directory that doesn't exist instead of checking if it exists. Review comment: https://github.com/Textualize/textual/pull/4123\#discussion_r1479504901 --- src/textual/widgets/_directory_tree.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 46bef1550c..fbddcb75d9 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -256,10 +256,8 @@ async def _reload(self, node: TreeNode[DirEntry]) -> None: reopening = to_reopen.pop() if not reopening.data: continue - if ( - reopening.allow_expand - and (reopening.data.path in currently_open or reopening == node) - and reopening.data.path.exists() + if reopening.allow_expand and ( + reopening.data.path in currently_open or reopening == node ): try: content = await self._load_directory(reopening).wait() @@ -471,7 +469,7 @@ def _directory_content(self, location: Path, worker: Worker) -> Iterator[Path]: except PermissionError: pass - @work(thread=True) + @work(thread=True, exit_on_error=False) def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]: """Load the directory contents for a given node. From 2dc534e8e21ea64928b31f2b97189c19a227dbed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 12:25:50 +0000 Subject: [PATCH 074/149] fix tint --- src/textual/renderables/tint.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index 85d15e217f..afe95f554b 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -53,7 +53,11 @@ def process_segments( if control: yield segment else: - style = ansi_filter.truecolor_style(style) or NULL_STYLE + style = ( + ansi_filter.truecolor_style(style) + if style is not None + else NULL_STYLE + ) yield _Segment( text, ( From f20515d4aea46ff8e5feca0e1bbb4fe48b744cd7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:30:27 +0000 Subject: [PATCH 075/149] reactive docs --- .../guide/reactivity/world_clock01.py | 8 +- .../guide/reactivity/world_clock02.py | 2 +- docs/guide/reactivity.md | 135 ++++++++++++++++++ src/textual/dom.py | 5 +- 4 files changed, 143 insertions(+), 7 deletions(-) diff --git a/docs/examples/guide/reactivity/world_clock01.py b/docs/examples/guide/reactivity/world_clock01.py index f33f55544c..04830ab8e9 100644 --- a/docs/examples/guide/reactivity/world_clock01.py +++ b/docs/examples/guide/reactivity/world_clock01.py @@ -28,14 +28,18 @@ def watch_time(self, time: datetime) -> None: class WorldClockApp(App): CSS_PATH = "world_clock01.tcss" + time: reactive[datetime] = reactive(datetime.now) + def compose(self) -> ComposeResult: yield WorldClock("Europe/London") yield WorldClock("Europe/Paris") yield WorldClock("Asia/Tokyo") def update_time(self) -> None: - time = datetime.now() - for world_clock in self.query(WorldClock): + self.time = datetime.now() + + def watch_time(self, time: datetime) -> None: + for world_clock in self.query(WorldClock): # (1)! world_clock.time = time def on_mount(self) -> None: diff --git a/docs/examples/guide/reactivity/world_clock02.py b/docs/examples/guide/reactivity/world_clock02.py index 1dd94a67aa..8988539193 100644 --- a/docs/examples/guide/reactivity/world_clock02.py +++ b/docs/examples/guide/reactivity/world_clock02.py @@ -31,7 +31,7 @@ class WorldClockApp(App): time: reactive[datetime] = reactive(datetime.now) def compose(self) -> ComposeResult: - yield WorldClock("Europe/London").data_bind(WorldClockApp.time) + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) # (1)! yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index c84050ad40..7edaf51e0e 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -266,3 +266,138 @@ When the result of `compute_color` changes, Textual will also call `watch_color` !!! note It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes. + +## Setting reactives without superpowers + +You may find yourself in a situation where you want to set a reactive value, but you *don't* want to invoke watchers or the other super powers. +This is fairly common in constructors which run prior to mounting; any watcher which queries the DOM may break if the widget has not yet been mounted. + +To work around this issue, you can call [set_reactive][textual.dom.DOMNode.set_reactive] as an alternative to setting the attribute. +The `set_reactive` method accepts the reactive attribute (as a class variable) and the new value. + +Let's look at an example. +The following app is intended to cycle through various greeting when you press ++space++, however it contains a bug. + +```python title="set_reactive01.py" +--8<-- "docs/examples/guide/reactivity/set_reactive01.py" +``` + +1. Setting this reactive attribute invokes a watcher. +2. The watcher attempts to update a label before it is mounted. + +If you run this app, you will find Textual raises a `NoMatches` error in `watch_greeting`. +This is because the constructor has assigned the reactive before the widget has fully mounted. + +The following app contains a fix for this issue: + +=== "set_reactive02.py" + + ```python hl_lines="33 34" + --8<-- "docs/examples/guide/reactivity/set_reactive02.py" + ``` + + 1. The attribute is set via `set_reactive`, which avoids calling the watcher. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/set_reactive02.py"} + ``` + +The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` attribute but doesn't immediately invoke the watcher. + +## Data binding + +Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). + +To bind reactive attributes, call [textual.dom.DOMNode.data_bind] on a widget. +This method accepts reactives (as class attributes) in positional arguments or keyword arguments. + +Let's look at an app that doesn't use data binding, and update it to use data binding. +In the following app we have a `WorldClock` widget which displays the time in any timezone. + + +!!! note + + This example uses the `pytz` library for working with timezones, which you can install with `pip install pytz`. + + +=== "world_clock01.py" + + ```python + --8<-- "docs/examples/guide/reactivity/world_clock01.py" + ``` + + 1. Update the `time` reactive attribute of every `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock01.py"} + ``` + +We've added three world clocks for London, Paris, and Tokyo. +The app keeps the time for the world clocks up-to-date by watching the app's `time` reactive, and updating `time` for each clock. + +While this approach works fine, it does require we take care to update every `WorldClock` we require. +Let's see how data binding can simplify this. + +The following app calls `data_bind` on the world clock widgets to connect the app's `time` with the widget's `time` attribute: + +=== "world_clock02.py" + + ```python hl_lines="34-36" + --8<-- "docs/examples/guide/reactivity/world_clock02.py" + ``` + + 1. Bind the `time` attribute, so that changes to `time` will also change the `time` attribute on the `WorldClock` widgets. The `data_bind` method also returns the widget, so we can yield its return value. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` + +Note how the addition of the `data_bind` methods negates the need for the watcher in `world_clock01.py`. + + +!!! note + + Data bindings works in a single direction. + Setting `time` on the app updates the clocks. But setting `time` on the clocks will *not* update `time` on the app. + + +In the previous example app the call to `data_bind(WorldClockApp.time)` worked because both reactive attributes were named `time`. +If you want to bind a reactive attribute which has a different name, you can use keyword arguments. + +In the following app we have changed the attribute name on `WorldClock` from `time` to `clock_time`. +We can make the app continue to work by changing the `data_bind` call to `data_bind(clock_time=WorldClockApp.time)`: + + +=== "world_clock03.py" + + ```python hl_lines="34-38" + --8<-- "docs/examples/guide/reactivity/world_clock03.py" + ``` + + 1. Uses keyword arguments to bind the `time` attribute of `WorldClockApp` to `clock_time` on `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` diff --git a/src/textual/dom.py b/src/textual/dom.py index becd830294..25cadf321f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -235,10 +235,7 @@ def data_bind( *reactives: Reactive[Any], **bind_vars: Reactive[Any] | object, ) -> Self: - """Bind reactive data. - - Args: - parent: The parent widget where the data should come from. Or `None` to auto-detect. + """Bind reactive data so that changes to a reactive automatically change the reactive on another widget. Raises: ReactiveError: If the data wasn't bound. From 11ba94ec4faf4de689876ab3456969eca0187559 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:33:04 +0000 Subject: [PATCH 076/149] changelog --- CHANGELOG.md | 6 ++++-- src/textual/reactive.py | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8105e6962..bfeb34ec11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added DOMQuery.set -- Added DOMNode.set_reactive +- Added DOMQuery.set https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.set_reactive https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.data_bind https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 ### Changed diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 667bfa9d4e..c23d5a56c4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -85,6 +85,7 @@ def __rich_repr__(self) -> rich.repr.Result: @property def owner(self) -> Type[MessageTarget]: + """The owner (class) where the reactive was declared.""" assert self._owner is not None return self._owner From cea5a491daeca8aa9b2028b78ac6c5ba26419e70 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:33:56 +0000 Subject: [PATCH 077/149] changelog --- CHANGELOG.md | 1 + src/textual/worker.py | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfeb34ec11..32facd86ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added DOMNode.set_reactive https://github.com/Textualize/textual/pull/4075 - Added DOMNode.data_bind https://github.com/Textualize/textual/pull/4075 - Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 +- Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075 ### Changed diff --git a/src/textual/worker.py b/src/textual/worker.py index eb5189375b..fa38d196be 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -164,6 +164,7 @@ def __init__( self.description = description self.exit_on_error = exit_on_error self.cancelled_event: Event = Event() + """A threading event set when the worker is cancelled.""" self._thread_worker = thread self._state = WorkerState.PENDING self.state = self._state From 34e77215523e36575ecf50bacc551a074c30fa47 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:35:25 +0000 Subject: [PATCH 078/149] examples --- .../guide/reactivity/set_reactive01.py | 67 +++++++++++++++++++ .../guide/reactivity/set_reactive02.py | 67 +++++++++++++++++++ .../guide/reactivity/world_clock03.py | 49 ++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 docs/examples/guide/reactivity/set_reactive01.py create mode 100644 docs/examples/guide/reactivity/set_reactive02.py create mode 100644 docs/examples/guide/reactivity/world_clock03.py diff --git a/docs/examples/guide/reactivity/set_reactive01.py b/docs/examples/guide/reactivity/set_reactive01.py new file mode 100644 index 0000000000..d9e34f9dcb --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive01.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.greeting = greeting # (1)! + self.who = who + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) # (2)! + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/set_reactive02.py b/docs/examples/guide/reactivity/set_reactive02.py new file mode 100644 index 0000000000..c4e36fc5cd --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive02.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.set_reactive(Greeter.greeting, greeting) # (1)! + self.set_reactive(Greeter.who, who) + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock03.py b/docs/examples/guide/reactivity/world_clock03.py new file mode 100644 index 0000000000..6d5c6dbb07 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock03.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + clock_time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_clock_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind( + clock_time=WorldClockApp.time # (1)! + ) + yield WorldClock("Europe/Paris").data_bind(clock_time=WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(clock_time=WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() From a817ecb99926bdfca7ee18e7f0d138b715a2101d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:58:07 +0000 Subject: [PATCH 079/149] docs --- docs/guide/reactivity.md | 18 ++++++++++-------- src/textual/_compose.py | 2 -- src/textual/dom.py | 12 ++++++++++++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 7edaf51e0e..d8b2202001 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -309,16 +309,17 @@ The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` att Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). -To bind reactive attributes, call [textual.dom.DOMNode.data_bind] on a widget. +To bind reactive attributes, call [data_bind][textual.dom.DOMNode.data_bind] on a widget. This method accepts reactives (as class attributes) in positional arguments or keyword arguments. -Let's look at an app that doesn't use data binding, and update it to use data binding. -In the following app we have a `WorldClock` widget which displays the time in any timezone. +Let's look at an app that could benefit from data binding. +In the following code we have a `WorldClock` widget which displays the time in any given timezone. !!! note - This example uses the `pytz` library for working with timezones, which you can install with `pip install pytz`. + This example uses the [pytz](https://pypi.org/project/pytz/) library for working with timezones. + You can install pytz with `pip install pytz`. === "world_clock01.py" @@ -341,9 +342,9 @@ In the following app we have a `WorldClock` widget which displays the time in an ``` We've added three world clocks for London, Paris, and Tokyo. -The app keeps the time for the world clocks up-to-date by watching the app's `time` reactive, and updating `time` for each clock. +The clocks are kept up-to-date by watching the app's `time` reactive, and updating the clocks in a loop. -While this approach works fine, it does require we take care to update every `WorldClock` we require. +While this approach works fine, it does require we take care to update every `WorldClock` we mount. Let's see how data binding can simplify this. The following app calls `data_bind` on the world clock widgets to connect the app's `time` with the widget's `time` attribute: @@ -373,10 +374,11 @@ Note how the addition of the `data_bind` methods negates the need for the watche !!! note Data bindings works in a single direction. - Setting `time` on the app updates the clocks. But setting `time` on the clocks will *not* update `time` on the app. + Setting `time` on the app updates the clocks. + But setting `time` on the clocks will *not* update `time` on the app. -In the previous example app the call to `data_bind(WorldClockApp.time)` worked because both reactive attributes were named `time`. +In the previous example app, the call to `data_bind(WorldClockApp.time)` worked because both reactive attributes were named `time`. If you want to bind a reactive attribute which has a different name, you can use keyword arguments. In the following app we have changed the attribute name on `WorldClock` from `time` to `clock_time`. diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 028738c709..482b27fb6a 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -54,8 +54,6 @@ def compose(node: App | Widget) -> list[Widget]: else: raise mount_error from None - # child._initialize_data_bind(node) - if composed: nodes.extend(composed) composed.clear() diff --git a/src/textual/dom.py b/src/textual/dom.py index 25cadf321f..0a6ccfa992 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -237,6 +237,18 @@ def data_bind( ) -> Self: """Bind reactive data so that changes to a reactive automatically change the reactive on another widget. + Reactives may be given as positional arguments or keyword arguments. + See the [guide on data binding](/guide/reactivity#data-binding). + + Example: + ```python + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + ``` + + Raises: ReactiveError: If the data wasn't bound. From 52ca178e803020efd5388134f532f4d6dc06c6c2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 14:59:45 +0000 Subject: [PATCH 080/149] no need for this import --- src/textual/dom.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0a6ccfa992..bc06272d58 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -315,8 +315,6 @@ def setter(value: object) -> None: init=self._parent is not None, ) else: - from functools import partial - self.call_later(partial(setter, reactive)) self._reactive_connect = None From 06394ed2563b164c830d0e14a3331f7138e57ba9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 15:04:11 +0000 Subject: [PATCH 081/149] test --- tests/test_reactive.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8ee7861a2a..df09b79694 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -575,3 +575,26 @@ def callback(self): await pilot.pause() assert from_holder assert from_app + + +async def test_set_reactive(): + """Test set_reactive doesn't call watchers.""" + + class MyWidget(Widget): + foo = reactive("") + + def __init__(self, foo: str) -> None: + super().__init__() + self.set_reactive(MyWidget.foo, foo) + + def watch_foo(self) -> None: + # Should never get here + 1 / 0 + + class MyApp(App): + def compose(self) -> ComposeResult: + yield MyWidget("foobar") + + app = MyApp() + async with app.run_test(): + assert app.query_one(MyWidget).foo == "foobar" From f39a7c96d3bd0a7b68ccc413a36676f333497899 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 6 Feb 2024 16:26:39 +0000 Subject: [PATCH 082/149] Update docs/guide/reactivity.md Co-authored-by: Darren Burns --- docs/guide/reactivity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index d8b2202001..40eacb6923 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -373,7 +373,7 @@ Note how the addition of the `data_bind` methods negates the need for the watche !!! note - Data bindings works in a single direction. + Data binding works in a single direction. Setting `time` on the app updates the clocks. But setting `time` on the clocks will *not* update `time` on the app. From e27c41c9ac11d57be6c69e17f7c26ea185e31514 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Wed, 7 Feb 2024 10:19:34 +0000 Subject: [PATCH 083/149] fix(text area)!: stop escape shifting focus if default tab behaviour (#4125) * fix(text area): stop escape shifting focus if default tab behaviour * fix recent update to changelog * address review feedback * update changelog --- CHANGELOG.md | 18 ++++++--- docs/widgets/text_area.md | 4 +- src/textual/widgets/_text_area.py | 7 +++- tests/text_area/test_escape_binding.py | 55 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 tests/text_area/test_escape_binding.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 32facd86ab..916101fc9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,7 @@ 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.48.2] - 2024-02-02 - -### Fixed - -- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 -- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 +## Unreleased ### Added @@ -20,6 +15,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 - Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075 +### Fixed + +- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110 + +## [0.48.2] - 2024-02-02 + +### Fixed + +- Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 +- Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 + ### Changed - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index bb0f50810b..57fdc719f4 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -283,11 +283,13 @@ This immediately updates the appearance of the `TextArea`: ```{.textual path="docs/examples/widgets/text_area_custom_theme.py" columns="42" lines="8"} ``` -### Tab behaviour +### Tab and Escape behaviour Pressing the ++tab++ key will shift focus to the next widget in your application by default. This matches how other widgets work in Textual. + To have ++tab++ insert a `\t` character, set the `tab_behaviour` attribute to the string value `"indent"`. +While in this mode, you can shift focus by pressing the ++escape++ key. ### Indentation diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 6c8abd101c..a263ab6d37 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -153,7 +153,6 @@ class TextArea(ScrollView, can_focus=True): """ BINDINGS = [ - Binding("escape", "screen.focus_next", "Shift Focus", show=False), # Cursor movement Binding("up", "cursor_up", "cursor up", show=False), Binding("down", "cursor_down", "cursor down", show=False), @@ -213,7 +212,6 @@ class TextArea(ScrollView, can_focus=True): """ | Key(s) | Description | | :- | :- | - | escape | Focus on the next item. | | up | Move the cursor up. | | down | Move the cursor down. | | left | Move the cursor left. | @@ -1213,6 +1211,11 @@ async def _on_key(self, event: events.Key) -> None: "enter": "\n", } if self.tab_behaviour == "indent": + if key == "escape": + event.stop() + event.prevent_default() + self.screen.focus_next() + return if self.indent_type == "tabs": insert_values["tab"] = "\t" else: diff --git a/tests/text_area/test_escape_binding.py b/tests/text_area/test_escape_binding.py new file mode 100644 index 0000000000..bc644d3085 --- /dev/null +++ b/tests/text_area/test_escape_binding.py @@ -0,0 +1,55 @@ +from textual.app import App, ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Button, TextArea + + +class TextAreaDialog(ModalScreen): + BINDINGS = [("escape", "dismiss")] + + def compose(self) -> ComposeResult: + yield TextArea( + tab_behaviour="focus", # the default + ) + yield Button("Submit") + + +class TextAreaDialogApp(App): + def on_mount(self) -> None: + self.push_screen(TextAreaDialog()) + + +async def test_escape_key_when_tab_behaviour_is_focus(): + """Regression test for https://github.com/Textualize/textual/issues/4110 + + When the `tab_behaviour` of TextArea is the default to shift focus, + pressing should not shift focus but instead skip and allow any + parent bindings to run. + """ + + app = TextAreaDialogApp() + async with app.run_test() as pilot: + # Sanity check + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, TextArea) + + # Pressing escape should dismiss the dialog screen, not focus the button + await pilot.press("escape") + assert not isinstance(pilot.app.screen, TextAreaDialog) + + +async def test_escape_key_when_tab_behaviour_is_indent(): + """When the `tab_behaviour` of TextArea is indent rather than switch focus, + pressing should instead shift focus. + """ + + app = TextAreaDialogApp() + async with app.run_test() as pilot: + # Sanity check + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, TextArea) + + pilot.app.query_one(TextArea).tab_behaviour = "indent" + # Pressing escape should focus the button, not dismiss the dialog screen + await pilot.press("escape") + assert isinstance(pilot.app.screen, TextAreaDialog) + assert isinstance(pilot.app.focused, Button) From 300074def9b670b42028d8bc850b765c5f554666 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 7 Feb 2024 11:27:47 +0000 Subject: [PATCH 084/149] Fix a TextArea crash (#4126) * Fix crash with backwards selection where content is replaced with fewer lines of text * Ensure correct cursor positioning after paste * Improving tests * Update CHANGELOG * Add missing docstrings --- CHANGELOG.md | 10 +++-- src/textual/widgets/_text_area.py | 26 +++++++---- tests/text_area/test_edit_via_bindings.py | 53 +++++++++++++++++++++++ 3 files changed, 76 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 916101fc9f..8e87a14c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Fixed + +- Fixed a crash in the TextArea when performing a backward replace https://github.com/Textualize/textual/pull/4126 +- Fixed selection not updating correctly when pasting while there's a non-zero selection https://github.com/Textualize/textual/pull/4126 +- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110 + ### Added - Added DOMQuery.set https://github.com/Textualize/textual/pull/4075 @@ -15,10 +21,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 - Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075 -### Fixed - -- Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110 - ## [0.48.2] - 2024-02-02 ### Fixed diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index a263ab6d37..6d149b888e 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -958,7 +958,6 @@ def render_line(self, widget_y: int) -> Strip: # Get the line from the Document. line_string = document.get_line(line_index) line = Text(line_string, end="") - line_character_count = len(line) line.tab_size = self.indent_width line.set_length(line_character_count + 1) # space at end for cursor @@ -1193,8 +1192,8 @@ def edit(self, edit: Edit) -> EditResult: self.wrapped_document.wrap(self.wrap_width, self.indent_width) else: self.wrapped_document.wrap_range( - edit.from_location, - edit.to_location, + edit.top, + edit.bottom, result.end_location, ) @@ -1341,7 +1340,8 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" - self.replace(event.text, *self.selection) + result = self.replace(event.text, *self.selection) + self.move_cursor(result.end_location) def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: """Return the column that the cell width corresponds to on the given row. @@ -1814,8 +1814,7 @@ def delete( Returns: An `EditResult` containing information about the edit. """ - top, bottom = sorted((start, end)) - return self.edit(Edit("", top, bottom, maintain_selection_offset)) + return self.edit(Edit("", start, end, maintain_selection_offset)) def replace( self, @@ -1989,14 +1988,13 @@ def do(self, text_area: TextArea) -> EditResult: # position in the document even if an insert happens before # their cursor position. - edit_top, edit_bottom = sorted((edit_from, edit_to)) - edit_bottom_row, edit_bottom_column = edit_bottom + edit_bottom_row, edit_bottom_column = self.bottom selection_start, selection_end = text_area.selection selection_start_row, selection_start_column = selection_start selection_end_row, selection_end_column = selection_end - replace_result = text_area.document.replace_range(edit_from, edit_to, text) + replace_result = text_area.document.replace_range(self.top, self.bottom, text) new_edit_to_row, new_edit_to_column = replace_result.end_location @@ -2051,6 +2049,16 @@ def after(self, text_area: TextArea) -> None: text_area.selection = self._updated_selection text_area.record_cursor_width() + @property + def top(self) -> Location: + """The Location impacted by this edit that is nearest the start of the document.""" + return min([self.from_location, self.to_location]) + + @property + def bottom(self) -> Location: + """The Location impacted by this edit that is nearest the end of the document.""" + return max([self.from_location, self.to_location]) + @runtime_checkable class Undoable(Protocol): diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py index f33e24b5f4..2cd5a41114 100644 --- a/tests/text_area/test_edit_via_bindings.py +++ b/tests/text_area/test_edit_via_bindings.py @@ -6,9 +6,11 @@ Note that more extensive testing for editing is done at the Document level. """ + import pytest from textual.app import App, ComposeResult +from textual.events import Paste from textual.widgets import TextArea from textual.widgets.text_area import Selection @@ -416,3 +418,54 @@ async def test_delete_word_right_at_end_of_line(): assert text_area.text == "0123456789" assert text_area.selection == Selection.cursor((0, 5)) + + +@pytest.mark.parametrize( + "selection", + [ + Selection(start=(1, 0), end=(3, 0)), + Selection(start=(3, 0), end=(1, 0)), + ], +) +async def test_replace_lines_with_fewer_lines(selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.text = SIMPLE_TEXT + text_area.selection = selection + + await pilot.press("a") + + expected_text = """\ +ABCDE +aPQRST +UVWXY +Z""" + assert text_area.text == expected_text + assert text_area.selection == Selection.cursor((1, 1)) + + +@pytest.mark.parametrize( + "selection", + [ + Selection(start=(1, 0), end=(3, 0)), + Selection(start=(3, 0), end=(1, 0)), + ], +) +async def test_paste(selection): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.text = SIMPLE_TEXT + text_area.selection = selection + + app.post_message(Paste("a")) + await pilot.pause() + + expected_text = """\ +ABCDE +aPQRST +UVWXY +Z""" + assert text_area.text == expected_text + assert text_area.selection == Selection.cursor((1, 1)) From 1abbe8a154101c0dde948f8fd3ba48658daadb72 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 7 Feb 2024 12:36:17 +0000 Subject: [PATCH 085/149] Fix TextArea cursor being visible before it has focus for first time (#4128) * Fix TextArea cursor being visible before it has focus * Ensure cursor blink reactive can be toggled when the widget does and does not have focus, and responds correctly * Update the CHANGELOG * Update snapshots * Update command palette snapshot --- CHANGELOG.md | 2 + src/textual/widgets/_text_area.py | 33 +++-- .../__snapshots__/test_snapshots.ambr | 121 +++++++++--------- 3 files changed, 82 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 691db6a528..d63a1862df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed a crash in the TextArea when performing a backward replace https://github.com/Textualize/textual/pull/4126 - Fixed selection not updating correctly when pasting while there's a non-zero selection https://github.com/Textualize/textual/pull/4126 - Breaking change: `TextArea` will not use `Escape` to shift focus if the `tab_behaviour` is the default https://github.com/Textualize/textual/issues/4110 +- `TextArea` cursor will now be invisible before first focus https://github.com/Textualize/textual/pull/4128 +- Fix toggling `TextArea.cursor_blink` reactive when widget does not have focus https://github.com/Textualize/textual/pull/4128 ### Added diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 6d149b888e..2874c00d55 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -295,7 +295,7 @@ class TextArea(ScrollView, can_focus=True): soft_wrap: Reactive[bool] = reactive(True, init=False) """True if text should soft wrap.""" - _cursor_visible: Reactive[bool] = reactive(True, repaint=False, init=False) + _cursor_visible: Reactive[bool] = reactive(False, repaint=False, init=False) """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" @@ -518,9 +518,13 @@ def _build_highlight_map(self) -> None: # Add the last line of the node range highlights[node_end_row].append((0, node_end_column, highlight_name)) - def watch_has_focus(self, value: bool) -> None: - self._cursor_visible = value - super().watch_has_focus(value) + def _watch_has_focus(self, focus: bool) -> None: + self._cursor_visible = focus + if focus: + self._restart_blink() + self.app.cursor_position = self.cursor_screen_offset + else: + self._pause_blink(visible=False) def _watch_selection( self, previous_selection: Selection, selection: Selection @@ -549,6 +553,14 @@ def _watch_selection( if previous_selection != selection: self.post_message(self.SelectionChanged(selection, self)) + def _watch_cursor_blink(self, blink: bool) -> None: + if not self.is_mounted: + return None + if blink and self.has_focus: + self._restart_blink() + else: + self._pause_blink(visible=self.has_focus) + def _recompute_cursor_offset(self): """Recompute the (x, y) coordinate of the cursor in the wrapped document.""" self._cursor_offset = self.wrapped_document.location_to_offset( @@ -1028,8 +1040,10 @@ def render_line(self, widget_y: int) -> Strip: ) if cursor_row == line_index: - draw_cursor = not self.cursor_blink or ( - self.cursor_blink and self._cursor_visible + draw_cursor = ( + self.has_focus + and not self.cursor_blink + or (self.cursor_blink and self._cursor_visible) ) if draw_matched_brackets: matching_bracket_style = theme.bracket_matching_style if theme else None @@ -1290,13 +1304,6 @@ def _on_mount(self, _: events.Mount) -> None: pause=not (self.cursor_blink and self.has_focus), ) - def _on_blur(self, _: events.Blur) -> None: - self._pause_blink(visible=True) - - def _on_focus(self, _: events.Focus) -> None: - self._restart_blink() - self.app.cursor_position = self.cursor_screen_offset - def _toggle_cursor_blink_visible(self) -> None: """Toggle visibility of the cursor for the purposes of 'cursor blink'.""" self._cursor_visible = not self._cursor_visible diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 9dec40dc68..98e31a0c51 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -19649,137 +19649,136 @@ font-weight: 700; } - .terminal-2492235927-matrix { + .terminal-2024488306-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2492235927-title { + .terminal-2024488306-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2492235927-r1 { fill: #1e1e1e } - .terminal-2492235927-r2 { fill: #e1e1e1 } - .terminal-2492235927-r3 { fill: #c5c8c6 } - .terminal-2492235927-r4 { fill: #ff0000 } - .terminal-2492235927-r5 { fill: #151515 } - .terminal-2492235927-r6 { fill: #e2e2e2 } - .terminal-2492235927-r7 { fill: #e2e3e3;font-weight: bold } + .terminal-2024488306-r1 { fill: #1e1e1e } + .terminal-2024488306-r2 { fill: #e1e1e1 } + .terminal-2024488306-r3 { fill: #c5c8c6 } + .terminal-2024488306-r4 { fill: #ff0000 } + .terminal-2024488306-r5 { fill: #e2e2e2 } + .terminal-2024488306-r6 { fill: #e2e3e3;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - InputVsTextArea + InputVsTextArea - - - - 01234567890123456789012345678901234567890123456789012345678901234567890123456789 - ────────────────────────────────────── - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - - - - ────────────────────────────────────── - ────────────────────────────────────── - - Button - - - ────────────────────────────────────── + + + + 01234567890123456789012345678901234567890123456789012345678901234567890123456789 + ────────────────────────────────────── + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + + + + ────────────────────────────────────── + ────────────────────────────────────── + + Button + + + ────────────────────────────────────── From e5893124f1d64a3112ed9c17de3bd9f238e3d83f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 7 Feb 2024 12:52:43 +0000 Subject: [PATCH 086/149] version bump --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d6df22556..244d3a055f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.49.0] - 2024-03-07 ### Fixed @@ -1664,6 +1664,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.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0 [0.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2 [0.48.1]: https://github.com/Textualize/textual/compare/v0.48.0...v0.48.1 [0.48.0]: https://github.com/Textualize/textual/compare/v0.47.1...v0.48.0 diff --git a/pyproject.toml b/pyproject.toml index ebf92ceb6f..c15d406646 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.48.2" +version = "0.49.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 3ec8ce51c22cc8714a7aabf6850e0f3b01ce0ee7 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:12:44 +0000 Subject: [PATCH 087/149] docs(changelog): fix 0.49 release-date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 244d3a055f..302b617c4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.49.0] - 2024-03-07 +## [0.49.0] - 2024-02-07 ### Fixed From 872025ba26779829f6ccea3e8e33c5a36c49eb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:38:51 +0000 Subject: [PATCH 088/149] Improve tests. Relevant review comment: https://github.com/Textualize/textual/pull/4030#pullrequestreview-1851846568 --- tests/test_reactive.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 673884fb3b..0cba344c8d 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -580,32 +580,38 @@ def callback(self): async def test_no_duplicate_external_watchers() -> None: """Make sure we skip duplicated watchers.""" + counter = 0 + class Holder(Widget): attr = var(None) class MyApp(App[None]): - def __init__(self): + def __init__(self) -> None: super().__init__() self.holder = Holder() - def on_mount(self): + def on_mount(self) -> None: self.watch(self.holder, "attr", self.callback) self.watch(self.holder, "attr", self.callback) def callback(self) -> None: - return + nonlocal counter + counter += 1 app = MyApp() async with app.run_test(): - pass - assert len(app.holder.__watchers["attr"]) == 1 + before = counter + app.holder.attr = 73 + after = counter + assert after == before + 1 async def test_external_watch_init_does_not_propagate() -> None: """Regression test for https://github.com/Textualize/textual/issues/3878. Make sure that when setting an extra watcher programmatically and `init` is set, - we init only the new watcher and not the other ones. + we init only the new watcher and not the other ones, but at the same + time make sure both watchers work in regular circumstances. """ logs: list[str] = [] @@ -625,13 +631,14 @@ def compose(self) -> ComposeResult: yield SomeWidget() def on_mount(self) -> None: - def nop() -> None: - return + def watch_test_2_extra() -> None: + logs.append("test_2_extra") - self.watch(self.query_one(SomeWidget), "test_2", nop) + self.watch(self.query_one(SomeWidget), "test_2", watch_test_2_extra) app = InitOverrideApp() async with app.run_test(): - pass - - assert logs == ["test_1"] + assert logs == ["test_1", "test_2_extra"] + app.query_one(SomeWidget).test_2 = 73 + assert logs.count("test_2_extra") == 2 + assert "test_2" in logs From 1e682c2647b391bad89088c319e9b75a567c694b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 7 Feb 2024 16:34:24 +0000 Subject: [PATCH 089/149] Update tests/test_reactive.py Co-authored-by: Darren Burns --- tests/test_reactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 14cc9edfa6..f909c31899 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -664,4 +664,4 @@ def watch_test_2_extra() -> None: assert logs == ["test_1", "test_2_extra"] app.query_one(SomeWidget).test_2 = 73 assert logs.count("test_2_extra") == 2 - assert "test_2" in logs + assert logs.count("test_2") == 1 From a3984c39daee6fe89dde60d3a6015d7f7a0ad118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Wed, 7 Feb 2024 17:08:15 +0000 Subject: [PATCH 090/149] Fix bug with watch. Review comment: https://github.com/Textualize/textual/pull/4030#discussion_r1481683768 --- src/textual/reactive.py | 2 +- tests/test_reactive.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 88eb894455..ec6703835f 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -421,7 +421,7 @@ def _watch( watcher_list = watchers.setdefault(attribute_name, []) if any(callback == callback_from_list for _, callback_from_list in watcher_list): return - watcher_list.append((node, callback)) if init: current_value = getattr(obj, attribute_name, None) invoke_watcher(obj, callback, current_value, current_value) + watcher_list.append((node, callback)) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index f909c31899..cf88ba67b0 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -615,7 +615,6 @@ def __init__(self) -> None: def on_mount(self) -> None: self.watch(self.holder, "attr", self.callback) - self.watch(self.holder, "attr", self.callback) def callback(self) -> None: nonlocal counter @@ -623,10 +622,9 @@ def callback(self) -> None: app = MyApp() async with app.run_test(): - before = counter + assert counter == 1 app.holder.attr = 73 - after = counter - assert after == before + 1 + assert counter == 2 async def test_external_watch_init_does_not_propagate() -> None: From 2bd85897c0b4bcaf3427672fcdc527fa3c53129e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 8 Feb 2024 10:48:53 +0000 Subject: [PATCH 091/149] Fix test. See first two paragraphs of https://github.com/Textualize/textual/pull/4030#discussion_r1481995473. --- tests/test_reactive.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index cf88ba67b0..2ffd5e481e 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -615,6 +615,7 @@ def __init__(self) -> None: def on_mount(self) -> None: self.watch(self.holder, "attr", self.callback) + self.watch(self.holder, "attr", self.callback) def callback(self) -> None: nonlocal counter From 8b349e315b6c3c7ce68906d735f84ae745a1f105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 8 Feb 2024 10:49:20 +0000 Subject: [PATCH 092/149] Add test. See third paragraph of https://github.com/Textualize/textual/pull/4030#discussion_r1481995473. --- tests/test_reactive.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 2ffd5e481e..7bddb3df79 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -664,3 +664,44 @@ def watch_test_2_extra() -> None: app.query_one(SomeWidget).test_2 = 73 assert logs.count("test_2_extra") == 2 assert logs.count("test_2") == 1 + + +async def test_external_watch_init_does_not_propagate_to_externals() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3878. + + Make sure that when setting an extra watcher programmatically and `init` is set, + we init only the new watcher and not the other ones (even if they were + added dynamically with `watch`), but at the same time make sure all watchers + work in regular circumstances. + """ + + logs: list[str] = [] + + class SomeWidget(Widget): + test_var: var[int] = var(0) + + class MyApp(App[None]): + def compose(self) -> ComposeResult: + yield SomeWidget() + + def add_first_watcher(self) -> None: + def first_callback() -> None: + logs.append("first") + + self.watch(self.query_one(SomeWidget), "test_var", first_callback) + + def add_second_watcher(self) -> None: + def second_callback() -> None: + logs.append("second") + + self.watch(self.query_one(SomeWidget), "test_var", second_callback) + + app = MyApp() + async with app.run_test(): + assert logs == [] + app.add_first_watcher() + assert logs == ["first"] + app.add_second_watcher() + assert logs == ["first", "second"] + app.query_one(SomeWidget).test_var = 73 + assert logs == ["first", "second", "first", "second"] From 6673ac0119ea06343975468a12e87f77eefe3484 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 10:53:33 +0000 Subject: [PATCH 093/149] always perform ansi filter --- src/textual/app.py | 11 ++++------- src/textual/renderables/background_screen.py | 10 ++-------- src/textual/renderables/tint.py | 10 +--------- 3 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 68499fec50..cc8f36633f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -94,6 +94,7 @@ from .errors import NoWidget from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor +from .filter import ANSIToTruecolor, DimFilter, Monochrome from .geometry import Offset, Region, Size from .keys import ( REPLACED_KEYS, @@ -420,21 +421,17 @@ def __init__( super().__init__() self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) - self._filters: list[LineFilter] = [] + self._filters: list[LineFilter] = [ + ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI) + ] environ = dict(os.environ) no_color = environ.pop("NO_COLOR", None) if no_color is not None: - from .filter import ANSIToTruecolor, Monochrome - - self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) self._filters.append(Monochrome()) for filter_name in constants.FILTERS.split(","): filter = filter_name.lower().strip() if filter == "dim": - from .filter import ANSIToTruecolor, DimFilter - - self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) self._filters.append(DimFilter()) self.console = Console( diff --git a/src/textual/renderables/background_screen.py b/src/textual/renderables/background_screen.py index 7dd4bc04d7..e0167d05e7 100644 --- a/src/textual/renderables/background_screen.py +++ b/src/textual/renderables/background_screen.py @@ -2,13 +2,11 @@ from typing import TYPE_CHECKING, Iterable -from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color -from ..filter import ANSIToTruecolor if TYPE_CHECKING: from ..screen import Screen @@ -51,17 +49,13 @@ def process_segments( _Segment = Segment NULL_STYLE = Style() - truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style + for segment in segments: text, style, control = segment if control: yield segment else: - style = ( - NULL_STYLE - if style is None - else truecolor_style(style.clear_meta_and_links()) - ) + style = NULL_STYLE if style is None else style.clear_meta_and_links() yield _Segment( text, ( diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index afe95f554b..c9fabe8477 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,13 +2,11 @@ from typing import Iterable -from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color -from ..filter import ANSIToTruecolor class Tint: @@ -45,19 +43,13 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment - ansi_filter = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI) - NULL_STYLE = Style() for segment in segments: text, style, control = segment if control: yield segment else: - style = ( - ansi_filter.truecolor_style(style) - if style is not None - else NULL_STYLE - ) + style = style if style is not None else NULL_STYLE yield _Segment( text, ( From d15f5060956c1f96596acbf8e7102c61eeb59721 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 10:55:33 +0000 Subject: [PATCH 094/149] version bump --- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 302b617c4d..2cd484dd8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.49.0] - 2023-02-08 + +### Fixed + +- Fixed issue with ANSI colors not being converted to truecolor https://github.com/Textualize/textual/pull/4138 + ## [0.49.0] - 2024-02-07 ### Fixed @@ -1664,6 +1670,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.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1 [0.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0 [0.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2 [0.48.1]: https://github.com/Textualize/textual/compare/v0.48.0...v0.48.1 diff --git a/pyproject.toml b/pyproject.toml index c15d406646..f19b6025f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.49.0" +version = "0.49.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From eb313ab743c9d23265c047af05c3a49fb2a0b6f5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 11:11:57 +0000 Subject: [PATCH 095/149] snapshots --- .../__snapshots__/test_snapshots.ambr | 1138 ++++++++--------- 1 file changed, 569 insertions(+), 569 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 719a153644..c93dbc0c18 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1872,144 +1872,144 @@ font-weight: 700; } - .terminal-1822845634-matrix { + .terminal-1403746156-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1822845634-title { + .terminal-1403746156-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1822845634-r1 { fill: #e1e1e1 } - .terminal-1822845634-r2 { fill: #c5c8c6 } - .terminal-1822845634-r3 { fill: #262626 } - .terminal-1822845634-r4 { fill: #e2e2e2 } - .terminal-1822845634-r5 { fill: #4a4a4a } - .terminal-1822845634-r6 { fill: #2e2e2e;font-weight: bold } - .terminal-1822845634-r7 { fill: #e3e3e3 } - .terminal-1822845634-r8 { fill: #e3e3e3;font-weight: bold } - .terminal-1822845634-r9 { fill: #98729f } - .terminal-1822845634-r10 { fill: #4ebf71;font-weight: bold } - .terminal-1822845634-r11 { fill: #0178d4 } - .terminal-1822845634-r12 { fill: #14191f } - .terminal-1822845634-r13 { fill: #5d5d5d } - .terminal-1822845634-r14 { fill: #e3e3e3;text-decoration: underline; } + .terminal-1403746156-r1 { fill: #e1e1e1 } + .terminal-1403746156-r2 { fill: #c5c8c6 } + .terminal-1403746156-r3 { fill: #262626 } + .terminal-1403746156-r4 { fill: #e2e2e2 } + .terminal-1403746156-r5 { fill: #4a4a4a } + .terminal-1403746156-r6 { fill: #2e2e2e;font-weight: bold } + .terminal-1403746156-r7 { fill: #e3e3e3 } + .terminal-1403746156-r8 { fill: #e3e3e3;font-weight: bold } + .terminal-1403746156-r9 { fill: #855c8d } + .terminal-1403746156-r10 { fill: #4ebf71;font-weight: bold } + .terminal-1403746156-r11 { fill: #0178d4 } + .terminal-1403746156-r12 { fill: #14191f } + .terminal-1403746156-r13 { fill: #5d5d5d } + .terminal-1403746156-r14 { fill: #e3e3e3;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CheckboxApp + CheckboxApp - + - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Arrakis 😓 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Caladan - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔ - X Chusuk - ▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - XGiedi Prime - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔ - XGinaz - ▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔ - X Grumman - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▃▃ - XKaitain - ▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Arrakis 😓 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Caladan + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔ + X Chusuk + ▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + XGiedi Prime + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔ + XGinaz + ▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔ + X Grumman + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▃▃ + XKaitain + ▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -4847,141 +4847,141 @@ font-weight: 700; } - .terminal-2893669067-matrix { + .terminal-1056312446-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2893669067-title { + .terminal-1056312446-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2893669067-r1 { fill: #e1e1e1 } - .terminal-2893669067-r2 { fill: #c5c8c6 } - .terminal-2893669067-r3 { fill: #fea62b } - .terminal-2893669067-r4 { fill: #fea62b;font-weight: bold } - .terminal-2893669067-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-2893669067-r6 { fill: #cc555a;font-weight: bold } - .terminal-2893669067-r7 { fill: #1e1e1e } - .terminal-2893669067-r8 { fill: #1e1e1e;text-decoration: underline; } - .terminal-2893669067-r9 { fill: #fea62b;text-decoration: underline; } - .terminal-2893669067-r10 { fill: #4b4e55;text-decoration: underline; } - .terminal-2893669067-r11 { fill: #4ebf71 } - .terminal-2893669067-r12 { fill: #b93c5b } + .terminal-1056312446-r1 { fill: #e1e1e1 } + .terminal-1056312446-r2 { fill: #c5c8c6 } + .terminal-1056312446-r3 { fill: #fea62b } + .terminal-1056312446-r4 { fill: #fea62b;font-weight: bold } + .terminal-1056312446-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-1056312446-r6 { fill: #be3f48;font-weight: bold } + .terminal-1056312446-r7 { fill: #1e1e1e } + .terminal-1056312446-r8 { fill: #1e1e1e;text-decoration: underline; } + .terminal-1056312446-r9 { fill: #fea62b;text-decoration: underline; } + .terminal-1056312446-r10 { fill: #3a3d43;text-decoration: underline; } + .terminal-1056312446-r11 { fill: #4ebf71 } + .terminal-1056312446-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… + @@ -16255,137 +16255,137 @@ font-weight: 700; } - .terminal-1146140386-matrix { + .terminal-905695451-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1146140386-title { + .terminal-905695451-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1146140386-r1 { fill: #e1e1e1 } - .terminal-1146140386-r2 { fill: #c5c8c6 } - .terminal-1146140386-r3 { fill: #dde6ed;font-weight: bold } - .terminal-1146140386-r4 { fill: #dde6ed } - .terminal-1146140386-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } - .terminal-1146140386-r6 { fill: #e1e2e3 } - .terminal-1146140386-r7 { fill: #cc555a } - .terminal-1146140386-r8 { fill: #cc555a;font-weight: bold;font-style: italic; } + .terminal-905695451-r1 { fill: #e1e1e1 } + .terminal-905695451-r2 { fill: #c5c8c6 } + .terminal-905695451-r3 { fill: #dde6ed;font-weight: bold } + .terminal-905695451-r4 { fill: #dde6ed } + .terminal-905695451-r5 { fill: #fea62b;font-weight: bold;font-style: italic; } + .terminal-905695451-r6 { fill: #e1e2e3 } + .terminal-905695451-r7 { fill: #be3f48 } + .terminal-905695451-r8 { fill: #be3f48;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - DataTableCursorStyles + DataTableCursorStyles - - - - Foreground is 'css', background is 'css': -  Movies      -  Severance   - Foundation - Dark - - Foreground is 'css', background is 'renderable': -  Movies      - Severance - Foundation - Dark - - Foreground is 'renderable', background is 'renderable': -  Movies      - Severance - Foundation - Dark - - Foreground is 'renderable', background is 'css': -  Movies      - Severance - Foundation - Dark + + + + Foreground is 'css', background is 'css': +  Movies      +  Severance   + Foundation + Dark + + Foreground is 'css', background is 'renderable': +  Movies      + Severance + Foundation + Dark + + Foreground is 'renderable', background is 'renderable': +  Movies      + Severance + Foundation + Dark + + Foreground is 'renderable', background is 'css': +  Movies      + Severance + Foundation + Dark @@ -24888,138 +24888,138 @@ font-weight: 700; } - .terminal-2860072847-matrix { + .terminal-2568645244-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2860072847-title { + .terminal-2568645244-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2860072847-r1 { fill: #1e1e1e } - .terminal-2860072847-r2 { fill: #0178d4 } - .terminal-2860072847-r3 { fill: #c5c8c6 } - .terminal-2860072847-r4 { fill: #ddedf9;font-weight: bold } - .terminal-2860072847-r5 { fill: #e2e2e2;font-weight: bold } - .terminal-2860072847-r6 { fill: #e2e2e2 } - .terminal-2860072847-r7 { fill: #434343 } - .terminal-2860072847-r8 { fill: #cc555a } - .terminal-2860072847-r9 { fill: #e1e1e1 } + .terminal-2568645244-r1 { fill: #1e1e1e } + .terminal-2568645244-r2 { fill: #0178d4 } + .terminal-2568645244-r3 { fill: #c5c8c6 } + .terminal-2568645244-r4 { fill: #ddedf9;font-weight: bold } + .terminal-2568645244-r5 { fill: #e2e2e2;font-weight: bold } + .terminal-2568645244-r6 { fill: #e2e2e2 } + .terminal-2568645244-r7 { fill: #434343 } + .terminal-2568645244-r8 { fill: #be3f48 } + .terminal-2568645244-r9 { fill: #e1e1e1 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OptionListApp + OptionListApp - + - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - OneOneOne - TwoTwoTwo - ──────────────────────────────────────────────────────────────────── - ThreeThreeThree - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - - - - - - - - - - - - + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + OneOneOne + TwoTwoTwo + ──────────────────────────────────────────────────────────────────── + ThreeThreeThree + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + + + + + + + @@ -28257,140 +28257,140 @@ font-weight: 700; } - .terminal-2449642391-matrix { + .terminal-386310630-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2449642391-title { + .terminal-386310630-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2449642391-r1 { fill: #e1e1e1 } - .terminal-2449642391-r2 { fill: #c5c8c6 } - .terminal-2449642391-r3 { fill: #1e1e1e } - .terminal-2449642391-r4 { fill: #0178d4 } - .terminal-2449642391-r5 { fill: #575757 } - .terminal-2449642391-r6 { fill: #262626;font-weight: bold } - .terminal-2449642391-r7 { fill: #e2e2e2 } - .terminal-2449642391-r8 { fill: #e2e2e2;text-decoration: underline; } - .terminal-2449642391-r9 { fill: #434343 } - .terminal-2449642391-r10 { fill: #4ebf71;font-weight: bold } - .terminal-2449642391-r11 { fill: #cc555a;font-weight: bold;font-style: italic; } + .terminal-386310630-r1 { fill: #e1e1e1 } + .terminal-386310630-r2 { fill: #c5c8c6 } + .terminal-386310630-r3 { fill: #1e1e1e } + .terminal-386310630-r4 { fill: #0178d4 } + .terminal-386310630-r5 { fill: #575757 } + .terminal-386310630-r6 { fill: #262626;font-weight: bold } + .terminal-386310630-r7 { fill: #e2e2e2 } + .terminal-386310630-r8 { fill: #e2e2e2;text-decoration: underline; } + .terminal-386310630-r9 { fill: #434343 } + .terminal-386310630-r10 { fill: #4ebf71;font-weight: bold } + .terminal-386310630-r11 { fill: #be3f48;font-weight: bold;font-style: italic; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - RadioChoicesApp + RadioChoicesApp - + - - - - - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Battlestar Galactica Amanda -  Dune 1984 Connor MacLeod -  Dune 2021 Duncan MacLeod -  Serenity Heather MacLeod -  Star Trek: The Motion Pictur Joe Dawson -  Star Wars: A New Hope Kurgan, The -  The Last Starfighter Methos -  Total Recall 👉 🔴 Rachel Ellenstein -  Wing Commander Ramírez - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - - - + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Battlestar Galactica Amanda +  Dune 1984 Connor MacLeod +  Dune 2021 Duncan MacLeod +  Serenity Heather MacLeod +  Star Trek: The Motion Pictur Joe Dawson +  Star Wars: A New Hope Kurgan, The +  The Last Starfighter Methos +  Total Recall 👉 🔴 Rachel Ellenstein +  Wing Commander Ramírez + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + @@ -29684,137 +29684,137 @@ font-weight: 700; } - .terminal-1777085921-matrix { + .terminal-565918768-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1777085921-title { + .terminal-565918768-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1777085921-r1 { fill: #e1e1e1 } - .terminal-1777085921-r2 { fill: #c5c8c6 } - .terminal-1777085921-r3 { fill: #004578 } - .terminal-1777085921-r4 { fill: #23568b } - .terminal-1777085921-r5 { fill: #fea62b } - .terminal-1777085921-r6 { fill: #cc555a } - .terminal-1777085921-r7 { fill: #14191f } + .terminal-565918768-r1 { fill: #e1e1e1 } + .terminal-565918768-r2 { fill: #c5c8c6 } + .terminal-565918768-r3 { fill: #004578 } + .terminal-565918768-r4 { fill: #23568b } + .terminal-565918768-r5 { fill: #fea62b } + .terminal-565918768-r6 { fill: #be3f48 } + .terminal-565918768-r7 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MyApp + MyApp - + - - SPAM - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────────── - SPAM - SPAM - SPAM - SPAM - SPAM - SPAM▄▄ - SPAM - SPAM - ──────────────────────────────────────────────────────────────────────── - @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ - ▇▇ - ▄▄ - - - - - - - - ──────────────────────────────────────────────────────────────────────────── + + SPAM + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────────── + SPAM + SPAM + SPAM + SPAM + SPAM + SPAM▄▄ + SPAM + SPAM + ──────────────────────────────────────────────────────────────────────── + @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@>>bullseye<<@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ + ▇▇ + ▄▄ + + + + + + + + ──────────────────────────────────────────────────────────────────────────── @@ -31289,141 +31289,141 @@ font-weight: 700; } - .terminal-2124086361-matrix { + .terminal-1048388837-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2124086361-title { + .terminal-1048388837-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2124086361-r1 { fill: #c5c8c6 } - .terminal-2124086361-r2 { fill: #e3e3e3 } - .terminal-2124086361-r3 { fill: #e1e1e1 } - .terminal-2124086361-r4 { fill: #0178d4 } - .terminal-2124086361-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-2124086361-r6 { fill: #575757 } - .terminal-2124086361-r7 { fill: #4ebf71;font-weight: bold } - .terminal-2124086361-r8 { fill: #ddedf9;font-weight: bold } - .terminal-2124086361-r9 { fill: #98a84b } - .terminal-2124086361-r10 { fill: #262626;font-weight: bold } - .terminal-2124086361-r11 { fill: #e2e2e2 } - .terminal-2124086361-r12 { fill: #ddedf9 } + .terminal-1048388837-r1 { fill: #c5c8c6 } + .terminal-1048388837-r2 { fill: #e3e3e3 } + .terminal-1048388837-r3 { fill: #e1e1e1 } + .terminal-1048388837-r4 { fill: #0178d4 } + .terminal-1048388837-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-1048388837-r6 { fill: #575757 } + .terminal-1048388837-r7 { fill: #4ebf71;font-weight: bold } + .terminal-1048388837-r8 { fill: #ddedf9;font-weight: bold } + .terminal-1048388837-r9 { fill: #879a3b } + .terminal-1048388837-r10 { fill: #262626;font-weight: bold } + .terminal-1048388837-r11 { fill: #e2e2e2 } + .terminal-1048388837-r12 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + SelectionListApp - + - - SelectionListApp - - -  Shall we play some games? ── Selected games ───────────── - [ - XFalken's Maze'secret_back_door', - XBlack Jack'a_nice_game_of_chess', - XGin Rummy'fighter_combat' - XHearts] - XBridge────────────────────────────── - XCheckers - XChess - XPoker - XFighter Combat - - ────────────────────────────── - - - - - - - + + SelectionListApp + + +  Shall we play some games? ── Selected games ───────────── + [ + XFalken's Maze'secret_back_door', + XBlack Jack'a_nice_game_of_chess', + XGin Rummy'fighter_combat' + XHearts] + XBridge────────────────────────────── + XCheckers + XChess + XPoker + XFighter Combat + + ────────────────────────────── + + + + + + + @@ -33374,136 +33374,136 @@ font-weight: 700; } - .terminal-2727430444-matrix { + .terminal-1404658517-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2727430444-title { + .terminal-1404658517-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2727430444-r1 { fill: #e1e1e1 } - .terminal-2727430444-r2 { fill: #c5c8c6 } - .terminal-2727430444-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-2727430444-r4 { fill: #98a84b;font-weight: bold;font-style: italic; } - .terminal-2727430444-r5 { fill: #98729f;font-weight: bold } - .terminal-2727430444-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-2727430444-r7 { fill: #e1e1e1;text-decoration: underline; } + .terminal-1404658517-r1 { fill: #e1e1e1 } + .terminal-1404658517-r2 { fill: #c5c8c6 } + .terminal-1404658517-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-1404658517-r4 { fill: #879a3b;font-weight: bold;font-style: italic; } + .terminal-1404658517-r5 { fill: #855c8d;font-weight: bold } + .terminal-1404658517-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-1404658517-r7 { fill: #e1e1e1;text-decoration: underline; } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - TableStaticApp + TableStaticApp - + - - ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ - FooBar   baz       - ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ - Hello World!ItalicUnderline - └──────────────┴────────┴───────────┘ - - - - - - - - - - - - - - - - - - + + ┏━━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ + FooBar   baz       + ┡━━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━━┩ + Hello World!ItalicUnderline + └──────────────┴────────┴───────────┘ + + + + + + + + + + + + + + + + + + @@ -38087,146 +38087,146 @@ font-weight: 700; } - .terminal-4085160594-matrix { + .terminal-3972235479-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4085160594-title { + .terminal-3972235479-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4085160594-r1 { fill: #c5c8c6 } - .terminal-4085160594-r2 { fill: #e3e3e3 } - .terminal-4085160594-r3 { fill: #e1e1e1 } - .terminal-4085160594-r4 { fill: #e1e1e1;text-decoration: underline; } - .terminal-4085160594-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-4085160594-r6 { fill: #e1e1e1;font-style: italic; } - .terminal-4085160594-r7 { fill: #98729f;font-weight: bold } - .terminal-4085160594-r8 { fill: #d0b344 } - .terminal-4085160594-r9 { fill: #98a84b } - .terminal-4085160594-r10 { fill: #00823d;font-style: italic; } - .terminal-4085160594-r11 { fill: #ffcf56 } - .terminal-4085160594-r12 { fill: #e76580 } - .terminal-4085160594-r13 { fill: #fea62b;font-weight: bold } - .terminal-4085160594-r14 { fill: #f5e5e9;font-weight: bold } - .terminal-4085160594-r15 { fill: #b86b00 } - .terminal-4085160594-r16 { fill: #780028 } + .terminal-3972235479-r1 { fill: #c5c8c6 } + .terminal-3972235479-r2 { fill: #e3e3e3 } + .terminal-3972235479-r3 { fill: #e1e1e1 } + .terminal-3972235479-r4 { fill: #e1e1e1;text-decoration: underline; } + .terminal-3972235479-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-3972235479-r6 { fill: #e1e1e1;font-style: italic; } + .terminal-3972235479-r7 { fill: #855c8d;font-weight: bold } + .terminal-3972235479-r8 { fill: #c5a635 } + .terminal-3972235479-r9 { fill: #879a3b } + .terminal-3972235479-r10 { fill: #0f722f;font-style: italic; } + .terminal-3972235479-r11 { fill: #ffcf56 } + .terminal-3972235479-r12 { fill: #e76580 } + .terminal-3972235479-r13 { fill: #fea62b;font-weight: bold } + .terminal-3972235479-r14 { fill: #f5e5e9;font-weight: bold } + .terminal-3972235479-r15 { fill: #b86b00 } + .terminal-3972235479-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 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ From 91e253e4f52b72c43aec6807577624da8b20d97b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 11:29:08 +0000 Subject: [PATCH 096/149] release version --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cd484dd8c..bd9c072e08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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.49.0] - 2023-02-08 +## [0.49.1] - 2023-02-08 ### Fixed From b2c373780edc1162a63bd6dd658e0919e993fc7d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 11:38:54 +0000 Subject: [PATCH 097/149] Remove layout and repaint I'm not sure these are needed. --- src/textual/widgets/_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 558b72dcae..706b47e6e2 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -584,8 +584,8 @@ class Markdown(Widget): BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] - dark_theme: Reactive[str] = reactive("material", layout=True, repaint=True) light_theme: Reactive[str] = reactive("material-light") + dark_theme: reactive[str] = reactive("material") def __init__( self, From 4260ef505749a1b0be760bf1c0e44742fa109a4d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 11:39:21 +0000 Subject: [PATCH 098/149] Add docstrings to the dark and light theme properties --- src/textual/widgets/_markdown.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 706b47e6e2..f4fe3a0cb3 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -584,8 +584,11 @@ class Markdown(Widget): BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] - light_theme: Reactive[str] = reactive("material-light") dark_theme: reactive[str] = reactive("material") + """The theme to use for code blocks when in [dark mode][textual.app.App.dark].""" + + light_theme: reactive[str] = reactive("material-light") + """The theme to use for code blocks when in [light mode][textual.app.App.dark].""" def __init__( self, From 70d3af799cc3c0f2a75274c824748ae27615a86a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 11:51:34 +0000 Subject: [PATCH 099/149] Rename the code theme reactives This helps make it clear that we're talking about themes for the code in Markdown, rather than a whole Markdown theme. --- src/textual/widgets/_markdown.py | 14 ++++++++------ .../snapshot_apps/markdown_theme_switcher.py | 4 ++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f4fe3a0cb3..635b97dc34 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -503,7 +503,7 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: self.code = code self.lexer = lexer self.theme = ( - self._markdown.dark_theme if self.app.dark else self._markdown.light_theme + self._markdown.code_dark_theme if self.app.dark else self._markdown.code_light_theme ) def _block(self) -> Syntax: @@ -523,7 +523,7 @@ def _on_mount(self, _: Mount) -> None: def _retheme(self) -> None: """Rerender when the theme changes.""" self.theme = ( - self._markdown.dark_theme if self.app.dark else self._markdown.light_theme + self._markdown.code_dark_theme if self.app.dark else self._markdown.code_light_theme ) code_block = self.query_one(Static) code_block.update(self._block()) @@ -584,10 +584,10 @@ class Markdown(Widget): BULLETS = ["\u25CF ", "▪ ", "‣ ", "• ", "⭑ "] - dark_theme: reactive[str] = reactive("material") + code_dark_theme: reactive[str] = reactive("material") """The theme to use for code blocks when in [dark mode][textual.app.App.dark].""" - light_theme: reactive[str] = reactive("material-light") + code_light_theme: reactive[str] = reactive("material-light") """The theme to use for code blocks when in [light mode][textual.app.App.dark].""" def __init__( @@ -676,12 +676,14 @@ def _on_mount(self, _: Mount) -> None: if self._markdown is not None: self.update(self._markdown) - def watch_dark_theme(self, dark_theme: str) -> None: + def _watch_code_dark_theme(self) -> None: + """React to the dark theme being changed.""" if self.app.dark: for block in self.query(MarkdownFence): block._retheme() - def watch_light_theme(self, light_theme: str) -> None: + def _watch_code_light_theme(self) -> None: + """React to the light theme being changed.""" if not self.app.dark: for block in self.query(MarkdownFence): block._retheme() diff --git a/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py index 510f2c8bf7..698d0c2c78 100644 --- a/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py +++ b/tests/snapshot_tests/snapshot_apps/markdown_theme_switcher.py @@ -19,11 +19,11 @@ class MarkdownThemeSwitchertApp(App[None]): def action_switch_dark(self) -> None: md = self.query_one(Markdown) - md.dark_theme = "solarized-dark" + md.code_dark_theme = "solarized-dark" def action_switch_light(self) -> None: md = self.query_one(Markdown) - md.light_theme = "solarized-light" + md.code_light_theme = "solarized-light" def compose(self) -> ComposeResult: yield Markdown(TEST_CODE_MARKDOWN) From 88dfe45dcc85a357fd85f3a9b63cc34f80335253 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 13:46:23 +0000 Subject: [PATCH 100/149] Keep Black happy --- src/textual/widgets/_markdown.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 635b97dc34..ce950cd7bf 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -503,7 +503,9 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: self.code = code self.lexer = lexer self.theme = ( - self._markdown.code_dark_theme if self.app.dark else self._markdown.code_light_theme + self._markdown.code_dark_theme + if self.app.dark + else self._markdown.code_light_theme ) def _block(self) -> Syntax: @@ -523,7 +525,9 @@ def _on_mount(self, _: Mount) -> None: def _retheme(self) -> None: """Rerender when the theme changes.""" self.theme = ( - self._markdown.code_dark_theme if self.app.dark else self._markdown.code_light_theme + self._markdown.code_dark_theme + if self.app.dark + else self._markdown.code_light_theme ) code_block = self.query_one(Static) code_block.update(self._block()) From 29fad94c3e09f145010bef62613200da80b8bd92 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 13:58:09 +0000 Subject: [PATCH 101/149] Simplify the retheme method Also use get_child_by_type to ensure it's just our child (not really necessary but more in keeping with the makeup of the widget). --- src/textual/widgets/_markdown.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index ce950cd7bf..69d8a066d2 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -529,8 +529,7 @@ def _retheme(self) -> None: if self.app.dark else self._markdown.code_light_theme ) - code_block = self.query_one(Static) - code_block.update(self._block()) + self.get_child_by_type(Static).update(self._block()) def compose(self) -> ComposeResult: yield Static( From 690ca0dc43f8360c07875ebb987e2ca70acf8d90 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 8 Feb 2024 14:15:35 +0000 Subject: [PATCH 102/149] Remove unused import --- src/textual/widgets/_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 69d8a066d2..24cde3dd91 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -18,7 +18,7 @@ from ..containers import Horizontal, Vertical, VerticalScroll from ..events import Mount from ..message import Message -from ..reactive import Reactive, reactive, var +from ..reactive import reactive, var from ..widget import Widget from ..widgets import Static, Tree From d857b649054cd64f293e5f1f2e2ead6369e44aed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 17:33:53 +0000 Subject: [PATCH 103/149] version bump --- CHANGELOG.md | 11 +++++------ pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d46af308e..a0f1da3158 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -### Unreleased -### Added - -- Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 - -## [0.49.1] - 2023-02-08 +## [0.50.0] - 2023-02-08 ### Fixed @@ -19,6 +14,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 - Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 +### Added + +- Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 + ## [0.49.0] - 2024-02-07 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index f19b6025f0..1131d4feca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.49.1" +version = "0.50.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 7d8efa01ed8be6b1e13a99f4960135196f28073e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 8 Feb 2024 17:34:44 +0000 Subject: [PATCH 104/149] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f1da3158..7f56f39065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1677,6 +1677,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.50.0]: https://github.com/Textualize/textual/compare/v0.49.0...v0.50.0 [0.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1 [0.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0 [0.48.2]: https://github.com/Textualize/textual/compare/v0.48.1...v0.48.2 From fcc96c323814691c999b9955801483744544a6f8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Feb 2024 18:09:31 +0000 Subject: [PATCH 105/149] ansi fix --- CHANGELOG.md | 7 +++++++ src/textual/renderables/tint.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f56f39065..43a77fbb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.51.1] - 2023-02-09 + +### Fixed + +- Fixed tint applied to ANSI colors + ## [0.50.0] - 2023-02-08 ### Fixed @@ -1677,6 +1683,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.50.1]: https://github.com/Textualize/textual/compare/v0.50.0...v0.50.1 [0.50.0]: https://github.com/Textualize/textual/compare/v0.49.0...v0.50.0 [0.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1 [0.49.0]: https://github.com/Textualize/textual/compare/v0.48.2...v0.49.0 diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index c9fabe8477..358bbca3fc 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,11 +2,13 @@ from typing import Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor class Tint: @@ -43,13 +45,15 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment + truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style + NULL_STYLE = Style() for segment in segments: text, style, control = segment if control: yield segment else: - style = style if style is not None else NULL_STYLE + style = truecolor_style(style) if style is not None else NULL_STYLE yield _Segment( text, ( From 40863d0c2d97535664a858127c338229aae929fb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Feb 2024 18:10:23 +0000 Subject: [PATCH 106/149] version bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1131d4feca..71c101fcb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.50.0" +version = "0.50.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From e1749ac36a88ae7a015a5c0fc16923df9b6ee3a9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 9 Feb 2024 18:23:10 +0000 Subject: [PATCH 107/149] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a77fbb38..068d466723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed tint applied to ANSI colors +- Fixed tint applied to ANSI colors https://github.com/Textualize/textual/pull/4142 ## [0.50.0] - 2023-02-08 From adecf4aae868350c013be65a558cf36366ee5cfe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 11 Feb 2024 11:40:30 +0000 Subject: [PATCH 108/149] fix year --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 068d466723..0a104dd737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.51.1] - 2023-02-09 +## [0.51.1] - 2024-02-09 ### Fixed - Fixed tint applied to ANSI colors https://github.com/Textualize/textual/pull/4142 -## [0.50.0] - 2023-02-08 +## [0.50.0] - 2024-02-08 ### Fixed From 00eb1dcac0557e4fd4c58131bcb7dc788162748d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 11 Feb 2024 14:19:48 +0000 Subject: [PATCH 109/149] new blog --- docs/blog/posts/toolong-retrospective.md | 143 +++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/blog/posts/toolong-retrospective.md diff --git a/docs/blog/posts/toolong-retrospective.md b/docs/blog/posts/toolong-retrospective.md new file mode 100644 index 0000000000..29c843aa54 --- /dev/null +++ b/docs/blog/posts/toolong-retrospective.md @@ -0,0 +1,143 @@ +--- +draft: false +date: 2024-02-11 +categories: + - DevLog +authors: + - willmcgugan +--- + +# File magic with the Python standard library + +I recently published [Toolong](https://github.com/textualize/toolong), an app for viewing log files. +There were some interesting technical challenges in building Toolong that I'd like to cover in this post. + + + +!!! note "Python is awesome" + + This isn't specifically [Textual](https://github.com/textualize/textual/) related. These techniques could be employed in any Python project. + +These techniques aren't difficult, and shouldn't be beyond anyone with an intermediate understanding of Python. +They are the kind of "if you know it you kow it" knowledge that you may not need often, but can make a massive difference when you do! + +## Opening large files + +If you were to open a very large text file (multiple gigabyte in size) in an editor, you will almost certainly find that it takes a while. You may also find that it doesn't load at all because you don't have enough memory, or it disables features like syntax highlighting. + +This is because most app will do something analogous to this: + +```python +with open("access.log", "rb") as log_file: + log_data = log_file.read() +``` + +All the data is read in to memory, where it can be easily processed. +This is fine for most files of a reasonable size, but when you get in to the gigabyte territory the read and any additional processing will start to use a significant amount of time and memory. + +Yet Toolong can open a file of *any* size in a second or so, with syntax highlighting. +It can do this because it doesn't need to read the entire log file in to memory. +Toolong opens a file and reads only the portion of it required to display whatever is on screen at that moment. +When you scroll around the log file, Toolong reads the data off disk as required -- fast enough that you may never even notice it. + +### Scanning lines + +There is an additional bit of work that Toolong has to do up front in order to show the file. +If you open a large file you may see a progress bar and a message about "scanning". + +Toolong needs to know where every line starts and ends in a log file, so it can display a scrollbar bar and allow the user to navigate lines in the file. +In other words it needs to know the offset of every new line (`\n`) character within the file. + +This isn't a hard problem in itself. +You might have imagined a loop that reads a chunk at a time and searches for new lines characters. +And that would likely have worked just fine, but there is a bit of magic in the Python standard library that can speed that up. + +The [mmap](https://docs.python.org/3/library/mmap.html) module is a real gem for this kind of thing. +A *memory mapped file* is an OS-level construct that *appears* to load a file instantaneously. +In Python you get an object which behaves like a `bytearray`, but loads data from disk when it is accessed. +The beauty of this module is that you can work with files in much the same way as if you had read the entire file in to memory, while leaving the actually reading of the file to the OS. + +Here's the method that Toolong uses to scan for line breaks. +Forgive the micro-optimizations, I was going for raw execution speed here. + +```python + def scan_line_breaks( + self, batch_time: float = 0.25 + ) -> Iterable[tuple[int, list[int]]]: + """Scan the file for line breaks. + + Args: + batch_time: Time to group the batches. + + Returns: + An iterable of tuples, containing the scan position and a list of offsets of new lines. + """ + fileno = self.fileno + size = self.size + if not size: + return + log_mmap = mmap.mmap(fileno, size, prot=mmap.PROT_READ) + rfind = log_mmap.rfind + position = size + batch: list[int] = [] + append = batch.append + get_length = batch.__len__ + monotonic = time.monotonic + break_time = monotonic() + + while (position := rfind(b"\n", 0, position)) != -1: + append(position) + if get_length() % 1000 == 0 and monotonic() - break_time > batch_time: + break_time = monotonic() + yield (position, batch) + batch = [] + append = batch.append + yield (0, batch) + log_mmap.close() +``` + +This code runs in a thread (actually a [worker](https://textual.textualize.io/guide/workers/)), and will generate line breaks in batches. Without batching, it risks slowing down the UI with millions of rapid events. + +It's fast because most of the work is done in `rfind`, which runs at C speed, while the OS reads from the disk. + +## Watching a file for changes + +Toolong can tail files in realtime. +When something appends to the file, it will be read and displayed virtually instantly. +How is this done? + +You can easily *poll* a file for changes, by periodically querying the size or timestamp of a file until it changes. +The downside of this is that you don't get notified immediately if a file changes between polls. +You could poll at a very fast rate, but if you were to do that you would end up burning a lot of CPU for no good reason. + +There is a very good solution for this in the standard library. +The [selectors](https://docs.python.org/3/library/selectors.html) module is typically used for working with sockets (network data), but can also work with files (at least on macOS and Linux). + +!!! info "Software developers are an unimaginative bunch when it comes to naming things" + + Not to be confused with CSS [selectors](https://textual.textualize.io/guide/CSS/#selectors)! + +The selectors module can tell you precisely when a file can be read. +It can do this very efficiently, because it relies on the OS to tell us when a file can be read, and doesn't need to poll. + +You register a file with a `Selector` object, then call `select()` which returns as soon as there is new data available for reading. + +See [watcher.py](https://github.com/Textualize/toolong/blob/main/src/toolong/watcher.py) in Toolong, which runs a thread to monitors files for changes with a selector. + +## Textual learnings + +This project was a chance for me to "dogfood" Textual. +Other Textual devs have build some cool projects ([Trogon](https://github.com/Textualize/trogon) and [Frogmouth](https://github.com/Textualize/frogmouth)), but before Toolong I had only every written example apps for docs. + +I paid particular attention to Textual error messages when working on Toolong, and improved many of them in Textual. +Much of what I improved were general programming errors, and not Textual errors per se. +For instance, if you forget to call `super()` on a widget constructor, Textual used to give a fairly cryptic error. +It's a fairly common gotcha, even for experience devs, but now Textual will detect that and tell you how to fix it. + +There's a lot of other improvements which I thought about when working on this app. +Mostly quality of life features that will make implementing some features more intuitive. +Keep an eye out for those in the next few weeks. + +## Found this interesting? + +If you would like to talk about this post or anything Textual related, join us on the [Discord server](https://discord.gg/Enf6Z3qhVr). From 6b14305837898bb6e76d14625ec6ac7cabfef215 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 11 Feb 2024 15:46:15 +0000 Subject: [PATCH 110/149] typo --- docs/blog/posts/toolong-retrospective.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/toolong-retrospective.md b/docs/blog/posts/toolong-retrospective.md index 29c843aa54..dff9b79220 100644 --- a/docs/blog/posts/toolong-retrospective.md +++ b/docs/blog/posts/toolong-retrospective.md @@ -127,7 +127,7 @@ See [watcher.py](https://github.com/Textualize/toolong/blob/main/src/toolong/wat ## Textual learnings This project was a chance for me to "dogfood" Textual. -Other Textual devs have build some cool projects ([Trogon](https://github.com/Textualize/trogon) and [Frogmouth](https://github.com/Textualize/frogmouth)), but before Toolong I had only every written example apps for docs. +Other Textual devs have build some cool projects ([Trogon](https://github.com/Textualize/trogon) and [Frogmouth](https://github.com/Textualize/frogmouth)), but before Toolong I had only ever written example apps for docs. I paid particular attention to Textual error messages when working on Toolong, and improved many of them in Textual. Much of what I improved were general programming errors, and not Textual errors per se. From db4760b3b7a35953bb48165f252be1d3ae47febb Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 12 Feb 2024 11:37:07 +0000 Subject: [PATCH 111/149] Add default syntax mapping to CSS theme in TextArea (#4149) * Add default syntax mapping to CSS theme in TextArea * Update CHANGELOG --- CHANGELOG.md | 5 ++ src/textual/_text_area_theme.py | 2 +- src/textual/widgets/_text_area.py | 30 ++++----- .../__snapshots__/test_snapshots.ambr | 67 ++++++++++--------- 4 files changed, 57 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a104dd737..2241508c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Added + +- Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 ## [0.51.1] - 2024-02-09 diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index da0ec60d4f..b9f920600b 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -379,7 +379,7 @@ def default(cls) -> TextAreaTheme: }, ) -_CSS_THEME = TextAreaTheme(name="css") +_CSS_THEME = TextAreaTheme(name="css", syntax_styles=_DARK_VS.syntax_styles) _BUILTIN_THEMES = { "css": _CSS_THEME, diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 2874c00d55..d5f4d8efef 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -983,21 +983,6 @@ def render_line(self, widget_y: int) -> Strip: selection_top_row, selection_top_column = selection_top selection_bottom_row, selection_bottom_column = selection_bottom - highlights = self._highlights - if highlights and theme: - line_bytes = _utf8_encode(line_string) - byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes) - get_highlight_from_theme = theme.syntax_styles.get - line_highlights = highlights[line_index] - for highlight_start, highlight_end, highlight_name in line_highlights: - node_style = get_highlight_from_theme(highlight_name) - if node_style is not None: - line.stylize( - node_style, - byte_to_codepoint.get(highlight_start, 0), - byte_to_codepoint.get(highlight_end) if highlight_end else None, - ) - cursor_line_style = theme.cursor_line_style if theme else None if cursor_line_style and cursor_row == line_index: line.stylize(cursor_line_style) @@ -1032,6 +1017,21 @@ def render_line(self, widget_y: int) -> Strip: else: line.stylize(selection_style, end=line_character_count) + highlights = self._highlights + if highlights and theme: + line_bytes = _utf8_encode(line_string) + byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes) + get_highlight_from_theme = theme.syntax_styles.get + line_highlights = highlights[line_index] + for highlight_start, highlight_end, highlight_name in line_highlights: + node_style = get_highlight_from_theme(highlight_name) + if node_style is not None: + line.stylize( + node_style, + byte_to_codepoint.get(highlight_start, 0), + byte_to_codepoint.get(highlight_end) if highlight_end else None, + ) + # Highlight the cursor matching_bracket = self._matching_bracket_location match_cursor_bracket = self.match_cursor_bracket diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 4c00834c2a..f73d96309d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -37227,77 +37227,82 @@ font-weight: 700; } - .terminal-3978829874-matrix { + .terminal-421307802-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3978829874-title { + .terminal-421307802-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3978829874-r1 { fill: #1e1e1e } - .terminal-3978829874-r2 { fill: #0178d4 } - .terminal-3978829874-r3 { fill: #c5c8c6 } - .terminal-3978829874-r4 { fill: #7d7e7a } - .terminal-3978829874-r5 { fill: #f8f8f2 } - .terminal-3978829874-r6 { fill: #abaca9;font-weight: bold } - .terminal-3978829874-r7 { fill: #151515 } + .terminal-421307802-r1 { fill: #1e1e1e } + .terminal-421307802-r2 { fill: #0178d4 } + .terminal-421307802-r3 { fill: #c5c8c6 } + .terminal-421307802-r4 { fill: #7d7e7a } + .terminal-421307802-r5 { fill: #569cd6 } + .terminal-421307802-r6 { fill: #f8f8f2 } + .terminal-421307802-r7 { fill: #4ec9b0 } + .terminal-421307802-r8 { fill: #abaca9;font-weight: bold } + .terminal-421307802-r9 { fill: #b5cea8 } + .terminal-421307802-r10 { fill: #151515 } + .terminal-421307802-r11 { fill: #7daf9c } + .terminal-421307802-r12 { fill: #ce9178 } - + - + - + - + - + - + - + - + - + - TextAreaSnapshot + TextAreaSnapshot - - - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - 1  def hello(name): - 2      x = 123                               - 3      while not False:  - 4          print("hello " + name)  - 5          continue  - 6   - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  defhello(name): + 2      x =123 + 3  whilenotFalse + 4  print("hello "+ name)  + 5  continue + 6   + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ From b1ae27e92c6799aba5dda22e12ce045c82bc903c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 12 Feb 2024 15:19:18 +0000 Subject: [PATCH 112/149] Remove a type warning --- src/textual/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/command.py b/src/textual/command.py index 9136bf9d5b..41d464df58 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -794,7 +794,7 @@ def _refresh_command_list( else None ) command_list.clear_options().add_options(sorted(commands, reverse=True)) - if highlighted is not None: + if highlighted is not None and highlighted.id: command_list.highlighted = command_list.get_option_index(highlighted.id) self._list_visible = bool(command_list.option_count) From 6a6c04040ef400bd30bb7594bc5b497523992890 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 12 Feb 2024 17:32:44 +0000 Subject: [PATCH 113/149] Proof-of-concept "discovery" phase for the command palette --- src/textual/_system_commands.py | 32 ++++++++++++++- src/textual/command.py | 70 +++++++++++++++++++++++++++++++-- 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/textual/_system_commands.py b/src/textual/_system_commands.py index 8cbb6ef017..0796deb891 100644 --- a/src/textual/_system_commands.py +++ b/src/textual/_system_commands.py @@ -4,7 +4,7 @@ actions available via the [command palette][textual.command.CommandPalette]. """ -from .command import Hit, Hits, Provider +from .command import DiscoveryHit, Hit, Hits, Provider class SystemCommands(Provider): @@ -53,3 +53,33 @@ async def search(self, query: str) -> Hits: runnable, help=help_text, ) + + async def discover(self) -> Hits: + """Handle a request for the discovery commands for this provider. + + Yields: + Commands that can be discovered. + """ + # TODO: Dedupe these from the above. + for name, runnable, help_text in ( + ( + "Toggle light/dark mode", + self.app.action_toggle_dark, + "Toggle the application between light and dark mode", + ), + ( + "Quit the application", + self.app.action_quit, + "Quit the application as soon as possible", + ), + ( + "Ring the bell", + self.app.action_bell, + "Ring the terminal's 'bell'", + ), + ): + yield DiscoveryHit( + name, + runnable, + help=help_text, + ) diff --git a/src/textual/command.py b/src/textual/command.py index 41d464df58..9df835391c 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -49,6 +49,7 @@ __all__ = [ "CommandPalette", + "DiscoveryHit", "Hit", "Hits", "Matcher", @@ -105,7 +106,52 @@ def __post_init__(self) -> None: ) -Hits: TypeAlias = AsyncIterator[Hit] +@dataclass +class DiscoveryHit: + """Holds the details of a single command search hit.""" + + match_display: RenderableType + """A string or Rich renderable representation of the hit.""" + + command: IgnoreReturnCallbackType + """The function to call when the command is chosen.""" + + text: str | None = None + """The command text associated with the hit, as plain text. + + If `match_display` is not simple text, this attribute should be provided by the + [Provider][textual.command.Provider] object. + """ + + help: str | None = None + """Optional help text for the command.""" + + def __lt__(self, other: object) -> bool: + if isinstance(other, DiscoveryHit): + assert self.text is not None + assert other.text is not None + return self.text < other.text + return NotImplemented + + def __eq__(self, other: object) -> bool: + if isinstance(other, Hit): + return self.text == other.text + return NotImplemented + + def __post_init__(self) -> None: + """Ensure 'text' is populated.""" + if self.text is None: + if isinstance(self.match_display, str): + self.text = self.match_display + elif isinstance(self.match_display, Text): + self.text = self.match_display.plain + else: + raise ValueError( + "A value for 'text' is required if 'match_display' is not a str or Text" + ) + + +Hits: TypeAlias = AsyncIterator[DiscoveryHit | Hit] """Return type for the command provider's `search` method.""" @@ -199,9 +245,10 @@ async def _search(self, query: str) -> Hits: """ await self._wait_init() if self._init_success: - hits = self.search(query) + hits = self.search(query) if query else self.discover() async for hit in hits: - yield hit + if hit is not NotImplemented: + yield hit @abstractmethod async def search(self, query: str) -> Hits: @@ -215,6 +262,22 @@ async def search(self, query: str) -> Hits: """ yield NotImplemented + async def discover(self) -> Hits: + """A default collection of hits for the provider. + + Yields: + Instances of [`Hit`][textual.command.Hit]. + + Note: + This is different from + [`search`][textual.command.Provider.search] in that it should + yield [`Hit`s][textual.command.Hit] that should be shown by + default; before user input. + + It is permitted to *not* implement this method. + """ + yield NotImplemented + async def _shutdown(self) -> None: """Internal method to call shutdown and log errors.""" try: @@ -561,6 +624,7 @@ def _on_mount(self, _: Mount) -> None: ] for provider in self._providers: provider._post_init() + self._gather_commands("") async def _on_unmount(self) -> None: """Shutdown providers when command palette is closed.""" From 3847b50e2b788b23126a3f1bde3042326c41666a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 11:37:16 +0000 Subject: [PATCH 114/149] Refine the command palette discovery system somewhat --- src/textual/command.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 9df835391c..2e13dc48fa 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -83,6 +83,11 @@ class Hit: help: str | None = None """Optional help text for the command.""" + @property + def prompt(self) -> RenderableType: + """The prompt to use when displaying the hit in the command palette.""" + return self.match_display + def __lt__(self, other: object) -> bool: if isinstance(other, Hit): return self.score < other.score @@ -110,7 +115,7 @@ def __post_init__(self) -> None: class DiscoveryHit: """Holds the details of a single command search hit.""" - match_display: RenderableType + display: RenderableType """A string or Rich renderable representation of the hit.""" command: IgnoreReturnCallbackType @@ -119,13 +124,18 @@ class DiscoveryHit: text: str | None = None """The command text associated with the hit, as plain text. - If `match_display` is not simple text, this attribute should be provided by the - [Provider][textual.command.Provider] object. + If `display` is not simple text, this attribute should be provided by + the [Provider][textual.command.Provider] object. """ help: str | None = None """Optional help text for the command.""" + @property + def prompt(self) -> RenderableType: + """The prompt to use when displaying the discovery hit in the command palette.""" + return self.display + def __lt__(self, other: object) -> bool: if isinstance(other, DiscoveryHit): assert self.text is not None @@ -141,17 +151,17 @@ def __eq__(self, other: object) -> bool: def __post_init__(self) -> None: """Ensure 'text' is populated.""" if self.text is None: - if isinstance(self.match_display, str): - self.text = self.match_display - elif isinstance(self.match_display, Text): - self.text = self.match_display.plain + if isinstance(self.display, str): + self.text = self.display + elif isinstance(self.display, Text): + self.text = self.display.plain else: raise ValueError( - "A value for 'text' is required if 'match_display' is not a str or Text" + "A value for 'text' is required if 'display' is not a str or Text" ) -Hits: TypeAlias = AsyncIterator[DiscoveryHit | Hit] +Hits: TypeAlias = AsyncIterator["DiscoveryHit | Hit"] """Return type for the command provider's `search` method.""" @@ -941,7 +951,7 @@ async def _gather_commands(self, search_value: str) -> None: while hit: # Turn the command into something for display, and add it to the # list of commands that have been gathered so far. - prompt = hit.match_display + prompt = hit.prompt if hit.help: prompt = Group(prompt, Text(hit.help, style=help_style)) gathered_commands.append(Command(prompt, hit, id=str(command_id))) From a272c6de96d74263c6060cb6f6d2de97fa350f84 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 11:50:08 +0000 Subject: [PATCH 115/149] Alpha sort discovered hits in the correct order --- src/textual/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/command.py b/src/textual/command.py index 2e13dc48fa..922204b9e6 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -140,7 +140,7 @@ def __lt__(self, other: object) -> bool: if isinstance(other, DiscoveryHit): assert self.text is not None assert other.text is not None - return self.text < other.text + return other.text < self.text return NotImplemented def __eq__(self, other: object) -> bool: From 97371c13a3bab3ecbf17870a20ad1cd5e5db70e9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 11:50:36 +0000 Subject: [PATCH 116/149] Tidy up some typing --- src/textual/command.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 922204b9e6..4f23dcc45f 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -313,7 +313,7 @@ class Command(Option): def __init__( self, prompt: RenderableType, - command: Hit, + command: DiscoveryHit | Hit, id: str | None = None, disabled: bool = False, ) -> None: @@ -538,7 +538,7 @@ class CommandPalette(_SystemModalScreen[CallbackType]): def __init__(self) -> None: """Initialise the command palette.""" super().__init__(id=self._PALETTE_ID) - self._selected_command: Hit | None = None + self._selected_command: DiscoveryHit | Hit | None = None """The command that was selected by the user.""" self._busy_timer: Timer | None = None """Keeps track of if there's a busy indication timer in effect.""" @@ -714,7 +714,7 @@ async def _watch__show_busy(self) -> None: self.query_one(CommandList).set_class(self._show_busy, "--populating") @staticmethod - async def _consume(hits: Hits, commands: Queue[Hit]) -> None: + async def _consume(hits: Hits, commands: Queue[DiscoveryHit | Hit]) -> None: """Consume a source of matching commands, feeding the given command queue. Args: @@ -724,7 +724,9 @@ async def _consume(hits: Hits, commands: Queue[Hit]) -> None: async for hit in hits: await commands.put(hit) - async def _search_for(self, search_value: str) -> AsyncGenerator[Hit, bool]: + async def _search_for( + self, search_value: str + ) -> AsyncGenerator[DiscoveryHit | Hit, bool]: """Search for a given search value amongst all of the command providers. Args: @@ -735,7 +737,7 @@ async def _search_for(self, search_value: str) -> AsyncGenerator[Hit, bool]: """ # Set up a queue to stream in the command hits from all the providers. - commands: Queue[Hit] = Queue() + commands: Queue[DiscoveryHit | Hit] = Queue() # Fire up an instance of each command provider, inside a task, and # have them go start looking for matches. From e59fd0e2a20892afca210ad3ab6a7b13fbbc3147 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 12:43:24 +0000 Subject: [PATCH 117/149] Deduplicate the collection of system commands in the provider --- src/textual/_system_commands.py | 77 +++++++++++++++------------------ 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/src/textual/_system_commands.py b/src/textual/_system_commands.py index 0796deb891..b164b75a26 100644 --- a/src/textual/_system_commands.py +++ b/src/textual/_system_commands.py @@ -4,7 +4,10 @@ actions available via the [command palette][textual.command.CommandPalette]. """ +from __future__ import annotations + from .command import DiscoveryHit, Hit, Hits, Provider +from .types import IgnoreReturnCallbackType class SystemCommands(Provider): @@ -13,22 +16,10 @@ class SystemCommands(Provider): Used by default in [`App.COMMANDS`][textual.app.App.COMMANDS]. """ - async def search(self, query: str) -> Hits: - """Handle a request to search for system commands that match the query. - - Args: - query: The user input to be matched. - - Yields: - Command hits for use in the command palette. - """ - # We're going to use Textual's builtin fuzzy matcher to find - # matching commands. - matcher = self.matcher(query) - - # Loop over all applicable commands, find those that match and offer - # them up to the command palette. - for name, runnable, help_text in ( + @property + def _system_commands(self) -> tuple[tuple[str, IgnoreReturnCallbackType, str], ...]: + """The system commands to reveal to the command palette.""" + return ( ( "Toggle light/dark mode", self.app.action_toggle_dark, @@ -44,15 +35,7 @@ async def search(self, query: str) -> Hits: self.app.action_bell, "Ring the terminal's 'bell'", ), - ): - match = matcher.match(name) - if match > 0: - yield Hit( - match, - matcher.highlight(name), - runnable, - help=help_text, - ) + ) async def discover(self) -> Hits: """Handle a request for the discovery commands for this provider. @@ -60,26 +43,34 @@ async def discover(self) -> Hits: Yields: Commands that can be discovered. """ - # TODO: Dedupe these from the above. - for name, runnable, help_text in ( - ( - "Toggle light/dark mode", - self.app.action_toggle_dark, - "Toggle the application between light and dark mode", - ), - ( - "Quit the application", - self.app.action_quit, - "Quit the application as soon as possible", - ), - ( - "Ring the bell", - self.app.action_bell, - "Ring the terminal's 'bell'", - ), - ): + for name, runnable, help_text in self._system_commands: yield DiscoveryHit( name, runnable, help=help_text, ) + + async def search(self, query: str) -> Hits: + """Handle a request to search for system commands that match the query. + + Args: + query: The user input to be matched. + + Yields: + Command hits for use in the command palette. + """ + # We're going to use Textual's builtin fuzzy matcher to find + # matching commands. + matcher = self.matcher(query) + + # Loop over all applicable commands, find those that match and offer + # them up to the command palette. + for name, runnable, help_text in self._system_commands: + match = matcher.match(name) + if match > 0: + yield Hit( + match, + matcher.highlight(name), + runnable, + help=help_text, + ) From ec32ed5032b579cc332b5877c1279885e995e9c9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 12:48:52 +0000 Subject: [PATCH 118/149] Throw in a walrus because reasons --- src/textual/_system_commands.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/_system_commands.py b/src/textual/_system_commands.py index b164b75a26..ffa73b263a 100644 --- a/src/textual/_system_commands.py +++ b/src/textual/_system_commands.py @@ -66,8 +66,7 @@ async def search(self, query: str) -> Hits: # Loop over all applicable commands, find those that match and offer # them up to the command palette. for name, runnable, help_text in self._system_commands: - match = matcher.match(name) - if match > 0: + if (match := matcher.match(name)) > 0: yield Hit( match, matcher.highlight(name), From e1be32cf34d4eb4f874e896559c37e531162ffd7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 13:24:41 +0000 Subject: [PATCH 119/149] Handle showing discovery results when the input goes empty --- src/textual/command.py | 43 ++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 4f23dcc45f..228e497540 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -672,23 +672,36 @@ def _stop_no_matches_countdown(self) -> None: _NO_MATCHES_COUNTDOWN: Final[float] = 0.5 """How many seconds to wait before showing 'No matches found'.""" - def _start_no_matches_countdown(self) -> None: + def _start_no_matches_countdown(self, search_value: str) -> None: """Start a countdown to showing that there are no matches for the query. - Adds a 'No matches found' option to the command list after `_NO_MATCHES_COUNTDOWN` seconds. + Args: + search_value: The value being searched for. + + Adds a 'No matches found' option to the command list after + `_NO_MATCHES_COUNTDOWN` seconds. """ self._stop_no_matches_countdown() def _show_no_matches() -> None: - command_list = self.query_one(CommandList) - command_list.add_option( - Option( - Align.center(Text("No matches found")), - disabled=True, - id=self._NO_MATCHES, + # If we were actually searching for something, show that we + # found no matches. + if search_value: + command_list = self.query_one(CommandList) + command_list.add_option( + Option( + Align.center(Text("No matches found")), + disabled=True, + id=self._NO_MATCHES, + ) ) - ) - self._list_visible = True + self._list_visible = True + else: + # The search value was empty, which means we were in + # discover mode; in that case it makes no sense to show that + # no matches were found. Lack of commands that can be + # discovered is a situation we don't need to highlight. + self._list_visible = False self._no_matches_timer = self.set_timer( self._NO_MATCHES_COUNTDOWN, @@ -1002,7 +1015,7 @@ async def _gather_commands(self, search_value: str) -> None: # mean nothing was found. Give the user positive feedback to that # effect. if command_list.option_count == 0 and not worker.is_cancelled: - self._start_no_matches_countdown() + self._start_no_matches_countdown(search_value) def _cancel_gather_commands(self) -> None: """Cancel any operation that is gather commands.""" @@ -1018,13 +1031,7 @@ def _input(self, event: Input.Changed) -> None: event.stop() self._cancel_gather_commands() self._stop_no_matches_countdown() - - search_value = event.value.strip() - if search_value: - self._gather_commands(search_value) - else: - self._list_visible = False - self.query_one(CommandList).clear_options() + self._gather_commands(event.value.strip()) @on(OptionList.OptionSelected) def _select_command(self, event: OptionList.OptionSelected) -> None: From 9a8ab7d37408c2dc1cad1e789d96da613a1c9b17 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 14:52:26 +0000 Subject: [PATCH 120/149] Add a unit test for provider discovery --- tests/command_palette/test_discover.py | 34 ++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/command_palette/test_discover.py diff --git a/tests/command_palette/test_discover.py b/tests/command_palette/test_discover.py new file mode 100644 index 0000000000..24849adf07 --- /dev/null +++ b/tests/command_palette/test_discover.py @@ -0,0 +1,34 @@ +from textual.app import App +from textual.command import CommandPalette, DiscoveryHit, Hit, Hits, Provider +from textual.widgets import OptionList + + +class SimpleSource(Provider): + + async def discover(self) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + yield DiscoveryHit("XD-1", goes_nowhere_does_nothing, "XD-1") + + async def search(self, query: str) -> Hits: + def goes_nowhere_does_nothing() -> None: + pass + + yield Hit(1, query, goes_nowhere_does_nothing, query) + + +class CommandPaletteApp(App[None]): + COMMANDS = {SimpleSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +async def test_discovery_visible() -> None: + """A provider with discovery should cause the command palette to be opened right away.""" + async with CommandPaletteApp().run_test() as pilot: + assert CommandPalette.is_open(pilot.app) + results = pilot.app.screen.query_one(OptionList) + assert results.visible is True + assert results.option_count == 1 From e6ad1bd991857e9ce74db62c7bb916c71f760433 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 13 Feb 2024 15:16:57 +0000 Subject: [PATCH 121/149] Text area read only (#4151) * Add read_only reactive * Using nested CSS in TextArea and adding COMPONENT_CLASS for read-only cursor * Applying/removing CSS class `.-read-only` in TextArea * Preventing some edits in read-only mode. * Clearer distinction between user/keyboard driven edits and programmatic edits * Ensure we refresh cursor correctly when pressing key in read-only mode * Add test of paste in read-only mode * Fix typo in docstring * Ensure "delete line" keybinding doesnt move cursor in read_only mode in TextArea * Add clarification to docs based on issue #4145 * Add test to ensure read-only cursor colour * Update CHANGELOG * Fix cursor styling in CSS on read-only * Fix a docstring * Improving docstrings * Improving docstrings * Simplify fixtures * Test to ensure API driven editing still works on TextArea.read_only=True --- CHANGELOG.md | 1 + src/textual/_text_area_theme.py | 2 +- src/textual/document/_document_navigator.py | 6 +- src/textual/widgets/_text_area.py | 186 +++++++++++++----- .../__snapshots__/test_snapshots.ambr | 83 ++++++++ tests/snapshot_tests/test_snapshots.py | 14 ++ tests/text_area/test_edit_via_api.py | 22 ++- tests/text_area/test_edit_via_bindings.py | 43 ++++ tests/text_area/test_selection_bindings.py | 87 ++++---- 9 files changed, 338 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2241508c9c..220e7e0f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +- TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 ## [0.51.1] - 2024-02-09 diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index b9f920600b..5fbcee9967 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -107,7 +107,7 @@ def apply_css(self, text_area: TextArea) -> None: self.cursor_style = cursor_style else: # There's no component style either, fallback to a default. - self.cursor_style = Style( + self.cursor_style = Style.from_color( color=background_color.rich_color, bgcolor=background_color.inverse.rich_color, ) diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index e265f03b2a..25b44e8422 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -101,11 +101,15 @@ def is_start_of_wrapped_line(self, location: Location) -> bool: def is_end_of_document_line(self, location: Location) -> bool: """True if the location is at the end of a line in the document. + Note that the "end" of a line is equal to its length (one greater + than the final index), since there is a space at the end of the line + for the cursor to rest. + Args: location: The location to examine. Returns: - True if and only if the document is on the last line of the document. + True if and only if the document is at the end of a line in the document. """ row, column = location row_length = len(self._document[row]) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index d5f4d8efef..085ce16a3f 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -91,41 +91,51 @@ class TextArea(ScrollView, can_focus=True): border: tall $background; padding: 0 1; + & .text-area--gutter { + color: $text 40%; + } + + & .text-area--cursor-gutter { + color: $text 60%; + background: $boost; + text-style: bold; + } + + & .text-area--cursor-line { + background: $boost; + } + + & .text-area--selection { + background: $accent-lighten-1 40%; + } + + & .text-area--matching-bracket { + background: $foreground 30%; + } + &:focus { border: tall $accent; } -} - -.text-area--cursor { - color: $text 90%; - background: $foreground 90%; -} - -TextArea:light .text-area--cursor { - color: $text 90%; - background: $foreground 70%; -} - -.text-area--gutter { - color: $text 40%; -} - -.text-area--cursor-line { - background: $boost; -} - -.text-area--cursor-gutter { - color: $text 60%; - background: $boost; - text-style: bold; -} - -.text-area--selection { - background: $accent-lighten-1 40%; -} - -.text-area--matching-bracket { - background: $foreground 30%; + + &:dark { + .text-area--cursor { + color: $text 90%; + background: $foreground 90%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } + + &:light { + .text-area--cursor { + color: $text 90%; + background: $foreground 70%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } } """ @@ -295,6 +305,14 @@ class TextArea(ScrollView, can_focus=True): soft_wrap: Reactive[bool] = reactive(True, init=False) """True if text should soft wrap.""" + read_only: Reactive[bool] = reactive(False) + """True if the content is read-only. + + Read-only means end users cannot insert, delete or replace content. + + The document can still be edited programmatically via the API. + """ + _cursor_visible: Reactive[bool] = reactive(False, repaint=False, init=False) """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" @@ -337,6 +355,7 @@ def __init__( language: str | None = None, theme: str | None = None, soft_wrap: bool = True, + read_only: bool = False, tab_behaviour: Literal["focus", "indent"] = "focus", show_line_numbers: bool = False, name: str | None = None, @@ -351,7 +370,9 @@ def __init__( language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. - tab_behaviour: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + read_only: Enable read-only mode. This prevents edits using the keyboard. + tab_behaviour: If 'focus', pressing tab will switch focus. + If 'indent', pressing tab will insert a tab. show_line_numbers: Show line numbers on the left edge. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. @@ -414,9 +435,9 @@ def __init__( self.theme = theme - self._reactive_soft_wrap = soft_wrap - - self._reactive_show_line_numbers = show_line_numbers + self.set_reactive(TextArea.soft_wrap, soft_wrap) + self.set_reactive(TextArea.read_only, read_only) + self.set_reactive(TextArea.show_line_numbers, show_line_numbers) self.tab_behaviour = tab_behaviour @@ -561,6 +582,10 @@ def _watch_cursor_blink(self, blink: bool) -> None: else: self._pause_blink(visible=self.has_focus) + def _watch_read_only(self, read_only: bool) -> None: + self.set_class(read_only, "-read-only") + self._set_theme(self._theme.name) + def _recompute_cursor_offset(self): """Recompute the (x, y) coordinate of the cursor in the wrapped document.""" self._cursor_offset = self.wrapped_document.location_to_offset( @@ -656,10 +681,10 @@ def _watch_theme(self, theme: str | None) -> None: if padding is applied, the colours match.""" self._set_theme(theme) - def _app_dark_toggled(self): + def _app_dark_toggled(self) -> None: self._set_theme(self._theme.name) - def _set_theme(self, theme: str | None): + def _set_theme(self, theme: str | None) -> None: theme_object: TextAreaTheme | None if theme is None: # If the theme is None, use the default. @@ -1219,6 +1244,10 @@ def edit(self, edit: Edit) -> EditResult: async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" + self._restart_blink() + if self.read_only: + return + key = event.key insert_values = { "enter": "\n", @@ -1234,7 +1263,6 @@ async def _on_key(self, event: events.Key) -> None: else: insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() - self._restart_blink() if event.is_printable or key in insert_values: event.stop() event.prevent_default() @@ -1243,7 +1271,7 @@ async def _on_key(self, event: events.Key) -> None: # None because we've checked that it's printable. assert insert is not None start, end = self.selection - self.replace(insert, start, end, maintain_selection_offset=False) + self._replace_via_keyboard(insert, start, end) def _find_columns_to_next_tab_stop(self) -> int: """Get the location of the next tab stop after the cursors position on the current line. @@ -1310,6 +1338,11 @@ def _toggle_cursor_blink_visible(self) -> None: _, cursor_y = self._cursor_offset self.refresh_lines(cursor_y) + def _watch__cursor_visible(self) -> None: + """When the cursor visibility is toggled, ensure the row is refreshed.""" + _, cursor_y = self._cursor_offset + self.refresh_lines(cursor_y) + def _restart_blink(self) -> None: """Reset the cursor blink timer.""" if self.cursor_blink: @@ -1347,7 +1380,9 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" - result = self.replace(event.text, *self.selection) + if self.read_only: + return + result = self._replace_via_keyboard(event.text, *self.selection) self.move_cursor(result.end_location) def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: @@ -1847,12 +1882,54 @@ def replace( """ return self.edit(Edit(insert, start, end, maintain_selection_offset)) - def clear(self) -> None: - """Delete all text from the document.""" + def clear(self) -> EditResult: + """Delete all text from the document. + + Returns: + An EditResult relating to the deletion of all content. + """ document = self.document last_line = document[-1] document_end = (document.line_count, len(last_line)) - self.delete((0, 0), document_end, maintain_selection_offset=False) + return self.delete((0, 0), document_end, maintain_selection_offset=False) + + def _delete_via_keyboard( + self, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a deletion performed using a keyboard (as opposed to the API). + + Args: + start: The start location of the text to delete. + end: The end location of the text to delete. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.delete(start, end, maintain_selection_offset=False) + + def _replace_via_keyboard( + self, + insert: str, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a replacement performed using a keyboard (as opposed to the API). + + Args: + insert: The text to insert into the document. + start: The start location of the text to replace. + end: The end location of the text to replace. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.replace(insert, start, end, maintain_selection_offset=False) def action_delete_left(self) -> None: """Deletes the character to the left of the cursor and updates the cursor location. @@ -1865,7 +1942,7 @@ def action_delete_left(self) -> None: if selection.is_empty: end = self.get_cursor_left_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_right(self) -> None: """Deletes the character to the right of the cursor and keeps the cursor at the same location. @@ -1878,7 +1955,7 @@ def action_delete_right(self) -> None: if selection.is_empty: end = self.get_cursor_right_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" @@ -1895,20 +1972,21 @@ def action_delete_line(self) -> None: from_location = (start_row, 0) to_location = (end_row + 1, 0) - self.delete(from_location, to_location, maintain_selection_offset=False) - self.move_cursor_relative(columns=end_column, record_width=False) + deletion = self._delete_via_keyboard(from_location, to_location) + if deletion is not None: + self.move_cursor_relative(columns=end_column, record_width=False) def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_start_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_to_end_of_line(self) -> None: """Deletes from the cursor location to the end of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_end_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_word_left(self) -> None: """Deletes the word to the left of the cursor and updates the cursor location.""" @@ -1919,11 +1997,11 @@ def action_delete_word_left(self) -> None: # deletes the characters within the selection range, ignoring word boundaries. start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return to_location = self.get_cursor_word_left_location() - self.delete(self.selection.end, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(self.selection.end, to_location) def action_delete_word_right(self) -> None: """Deletes the word to the right of the cursor and keeps the cursor at the same location. @@ -1937,7 +2015,7 @@ def action_delete_word_right(self) -> None: start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return cursor_row, cursor_column = end @@ -1957,7 +2035,7 @@ def action_delete_word_right(self) -> None: else: to_location = (cursor_row, current_row_length) - self.delete(end, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(end, to_location) @dataclass diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f73d96309d..f4e6208590 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -36636,6 +36636,89 @@ ''' # --- +# name: test_text_area_read_only_cursor_rendering + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  Hello, world!           + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_text_area_selection_rendering[selection0] ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 799027f92e..ceb08b05b6 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -879,6 +879,20 @@ def setup_selection(pilot): ) +def test_text_area_read_only_cursor_rendering(snap_compare): + def setup_selection(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.theme = "css" + text_area.text = "Hello, world!" + text_area.read_only = True + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_selection, + terminal_size=(30, 5), + ) + + @pytest.mark.syntax @pytest.mark.parametrize( "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()] diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py index 98072d35ae..e217bfa210 100644 --- a/tests/text_area/test_edit_via_api.py +++ b/tests/text_area/test_edit_via_api.py @@ -5,6 +5,7 @@ Note that more extensive testing for editing is done at the Document level. """ + import pytest from textual.app import App, ComposeResult @@ -521,10 +522,29 @@ async def test_replace_fully_within_selection(): ) assert text_area.selected_text == "XX56" + async def test_text_setter(): app = TextAreaApp() async with app.run_test(): text_area = app.query_one(TextArea) new_text = "hello\nworld\n" text_area.text = new_text - assert text_area.text == new_text \ No newline at end of file + assert text_area.text == new_text + + +async def test_edits_on_read_only_mode(): + """API edits should still be permitted on read-only mode.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.text = "0123456789" + text_area.read_only = True + + text_area.replace("X", (0, 1), (0, 5)) + assert text_area.text == "0X56789" + + text_area.insert("X") + assert text_area.text == "X0X56789" + + text_area.delete((0, 0), (0, 2)) + assert text_area.text == "X56789" diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py index 2cd5a41114..17d7f52f02 100644 --- a/tests/text_area/test_edit_via_bindings.py +++ b/tests/text_area/test_edit_via_bindings.py @@ -420,6 +420,37 @@ async def test_delete_word_right_at_end_of_line(): assert text_area.selection == Selection.cursor((0, 5)) +@pytest.mark.parametrize( + "binding", + [ + "enter", + "backspace", + "ctrl+u", + "ctrl+f", + "ctrl+w", + "ctrl+k", + "ctrl+x", + "space", + "1", + "tab", + ], +) +async def test_edit_read_only_mode_does_nothing(binding): + """Try out various key-presses and bindings and ensure they don't alter + the document when read_only=True.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + selection = Selection.cursor((0, 2)) + text_area.selection = selection + + await pilot.press(binding) + + assert text_area.text == TEXT + assert text_area.selection == selection + + @pytest.mark.parametrize( "selection", [ @@ -469,3 +500,15 @@ async def test_paste(selection): Z""" assert text_area.text == expected_text assert text_area.selection == Selection.cursor((1, 1)) + + +async def test_paste_read_only_does_nothing(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + + app.post_message(Paste("hello")) + await pilot.pause() + + assert text_area.text == TEXT # No change diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py index 7efdcef164..06b180485e 100644 --- a/tests/text_area/test_selection_bindings.py +++ b/tests/text_area/test_selection_bindings.py @@ -13,34 +13,41 @@ class TextAreaApp(App): + def __init__(self, read_only: bool = False): + super().__init__() + self.read_only = read_only + def compose(self) -> ComposeResult: - text_area = TextArea(show_line_numbers=True) - text_area.load_text(TEXT) - yield text_area + yield TextArea(TEXT, show_line_numbers=True, read_only=self.read_only) + +@pytest.fixture(params=[True, False]) +async def app(request): + """Each test that receives an `app` will execute twice. + Once with read_only=True, and once with read_only=False. + """ + return TextAreaApp(read_only=request.param) -async def test_mouse_click(): + +async def test_mouse_click(app: TextAreaApp): """When you click the TextArea, the cursor moves to the expected location.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=5, y=2)) assert text_area.selection == Selection.cursor((1, 0)) -async def test_mouse_click_clamp_from_right(): +async def test_mouse_click_clamp_from_right(app: TextAreaApp): """When you click to the right of the document bounds, the cursor is clamped to within the document bounds.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=8, y=20)) assert text_area.selection == Selection.cursor((4, 0)) -async def test_mouse_click_gutter_clamp(): +async def test_mouse_click_gutter_clamp(app: TextAreaApp): """When you click the gutter, it selects the start of the line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=0, y=3)) @@ -66,19 +73,17 @@ async def test_cursor_movement_basic(): assert text_area.selection == Selection.cursor((0, 0)) -async def test_cursor_selection_right(): +async def test_cursor_selection_right(app: TextAreaApp): """When you press shift+right the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.press(*["shift+right"] * 3) assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_selection_right_to_previous_line(): +async def test_cursor_selection_right_to_previous_line(app: TextAreaApp): """When you press shift+right resulting in the cursor moving to the next line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((0, 15)) @@ -86,9 +91,8 @@ async def test_cursor_selection_right_to_previous_line(): assert text_area.selection == Selection((0, 15), (1, 2)) -async def test_cursor_selection_left(): +async def test_cursor_selection_left(app: TextAreaApp): """When you press shift+left the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 5)) @@ -96,10 +100,9 @@ async def test_cursor_selection_left(): assert text_area.selection == Selection((2, 5), (2, 2)) -async def test_cursor_selection_left_to_previous_line(): +async def test_cursor_selection_left_to_previous_line(app: TextAreaApp): """When you press shift+left resulting in the cursor moving back to the previous line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -110,9 +113,8 @@ async def test_cursor_selection_left_to_previous_line(): assert text_area.selection == Selection((2, 2), (1, end_of_previous_line)) -async def test_cursor_selection_up(): +async def test_cursor_selection_up(app: TextAreaApp): """When you press shift+up the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 3)) @@ -121,9 +123,8 @@ async def test_cursor_selection_up(): assert text_area.selection == Selection((2, 3), (1, 3)) -async def test_cursor_selection_up_when_cursor_on_first_line(): +async def test_cursor_selection_up_when_cursor_on_first_line(app: TextAreaApp): """When you press shift+up the on the first line, it selects to the start.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((0, 4)) @@ -134,8 +135,7 @@ async def test_cursor_selection_up_when_cursor_on_first_line(): assert text_area.selection == Selection((0, 4), (0, 0)) -async def test_cursor_selection_down(): - app = TextAreaApp() +async def test_cursor_selection_down(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 5)) @@ -144,8 +144,7 @@ async def test_cursor_selection_down(): assert text_area.selection == Selection((2, 5), (3, 5)) -async def test_cursor_selection_down_when_cursor_on_last_line(): - app = TextAreaApp() +async def test_cursor_selection_down_when_cursor_on_last_line(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABCDEF\nGHIJK") @@ -157,8 +156,7 @@ async def test_cursor_selection_down_when_cursor_on_last_line(): assert text_area.selection == Selection((1, 2), (1, 5)) -async def test_cursor_word_right(): - app = TextAreaApp() +async def test_cursor_word_right(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -168,8 +166,7 @@ async def test_cursor_word_right(): assert text_area.selection == Selection.cursor((0, 3)) -async def test_cursor_word_right_select(): - app = TextAreaApp() +async def test_cursor_word_right_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -179,8 +176,7 @@ async def test_cursor_word_right_select(): assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_word_left(): - app = TextAreaApp() +async def test_cursor_word_left(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -191,8 +187,7 @@ async def test_cursor_word_left(): assert text_area.selection == Selection.cursor((0, 4)) -async def test_cursor_word_left_select(): - app = TextAreaApp() +async def test_cursor_word_left_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -204,9 +199,8 @@ async def test_cursor_word_left_select(): @pytest.mark.parametrize("key", ["end", "ctrl+e"]) -async def test_cursor_to_line_end(key): +async def test_cursor_to_line_end(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the end of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -217,9 +211,8 @@ async def test_cursor_to_line_end(key): @pytest.mark.parametrize("key", ["home", "ctrl+a"]) -async def test_cursor_to_line_home_basic_behaviour(key): +async def test_cursor_to_line_home_basic_behaviour(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the start of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -239,11 +232,12 @@ async def test_cursor_to_line_home_basic_behaviour(key): ((0, 15), (0, 4)), ], ) -async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): +async def test_cursor_line_home_smart_home( + cursor_start, cursor_destination, app: TextAreaApp +): """If the line begins with whitespace, pressing home firstly goes to the start of the (non-whitespace) content. Pressing it again takes you to column 0. If you press it again, it goes back to the first non-whitespace column.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text(" hello world") @@ -252,9 +246,8 @@ async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): assert text_area.selection == Selection.cursor(cursor_destination) -async def test_cursor_page_down(): +async def test_cursor_page_down(app: TextAreaApp): """Pagedown moves the cursor down 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -266,9 +259,8 @@ async def test_cursor_page_down(): ) -async def test_cursor_page_up(): +async def test_cursor_page_up(app: TextAreaApp): """Pageup moves the cursor up 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -280,10 +272,9 @@ async def test_cursor_page_up(): ) -async def test_cursor_vertical_movement_visual_alignment_snapping(): +async def test_cursor_vertical_movement_visual_alignment_snapping(app: TextAreaApp): """When you move the cursor vertically, it should stay vertically aligned even when double-width characters are used.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.text = "こんにちは\n012345" @@ -301,8 +292,7 @@ async def test_cursor_vertical_movement_visual_alignment_snapping(): assert text_area.selection == Selection.cursor((1, 3)) -async def test_select_line_binding(): - app = TextAreaApp() +async def test_select_line_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 2)) @@ -312,8 +302,7 @@ async def test_select_line_binding(): assert text_area.selection == Selection((2, 0), (2, 56)) -async def test_select_all_binding(): - app = TextAreaApp() +async def test_select_all_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) From 5ef45e9cffcb0d6cb0a775ab1844e1004f8b404e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 15:38:04 +0000 Subject: [PATCH 122/149] Add a snapshot test for command palette discovery --- .../__snapshots__/test_snapshots.ambr | 161 ++++++++++++++++++ .../command_palette_discovery.py | 38 +++++ tests/snapshot_tests/test_snapshots.py | 8 + 3 files changed, 207 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/command_palette_discovery.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 4c00834c2a..02eba3ca29 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -3130,6 +3130,167 @@ ''' # --- +# name: test_command_palette_discovery + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CommandPaletteApp + + + + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + 🔎Command Palette Search... + + + This is a test of this code 0 + This is a test of this code 1 + This is a test of this code 2 + This is a test of this code 3 + This is a test of this code 4 + This is a test of this code 5 + This is a test of this code 6 + This is a test of this code 7 + This is a test of this code 8 + This is a test of this code 9 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + ''' +# --- # name: test_content_switcher_example_initial ''' diff --git a/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py new file mode 100644 index 0000000000..a989a86968 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py @@ -0,0 +1,38 @@ +from textual.app import App +from textual.command import DiscoveryHit, Hit, Hits, Provider + + +class TestSource(Provider): + def goes_nowhere_does_nothing(self) -> None: + pass + + async def discover(self) -> Hits: + for n in range(10): + command = f"This is a test of this code {n}" + yield DiscoveryHit( + command, + self.goes_nowhere_does_nothing, + command, + ) + + async def search(self, query: str) -> Hits: + matcher = self.matcher(query) + for n in range(10): + command = f"This is a test of this code {n}" + yield Hit( + n / 10, + matcher.highlight(command), + self.goes_nowhere_does_nothing, + command, + ) + + +class CommandPaletteApp(App[None]): + COMMANDS = {TestSource} + + def on_mount(self) -> None: + self.action_command_palette() + + +if __name__ == "__main__": + CommandPaletteApp().run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 799027f92e..cce17d0192 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -757,6 +757,14 @@ async def run_before(pilot) -> None: assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette.py", run_before=run_before) +def test_command_palette_discovery(snap_compare) -> None: + async def run_before(pilot) -> None: + pilot.app.screen.query_one(Input).cursor_blink = False + await pilot.app.screen.workers.wait_for_complete() + + assert snap_compare(SNAPSHOT_APPS_DIR / "command_palette_discovery.py", run_before=run_before) + + # --- textual-dev library preview tests --- From 267926d321e12d256508b19ca4603d461584f4d0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 13 Feb 2024 15:44:01 +0000 Subject: [PATCH 123/149] Have the provider discovery snapshot differ in search and discovery While it would normally be the case that these things would be the same; for the purposes of this test have them different so we know if search ever leaks into discovery for some bizarre reason. --- tests/snapshot_tests/snapshot_apps/command_palette_discovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py index a989a86968..3b8a03496a 100644 --- a/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py +++ b/tests/snapshot_tests/snapshot_apps/command_palette_discovery.py @@ -18,7 +18,7 @@ async def discover(self) -> Hits: async def search(self, query: str) -> Hits: matcher = self.matcher(query) for n in range(10): - command = f"This is a test of this code {n}" + command = f"This should not appear {n}" yield Hit( n / 10, matcher.highlight(command), From b736c93a5e34cfabf0e75bcca5d650f751f3da7f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 13 Feb 2024 16:24:16 +0000 Subject: [PATCH 124/149] words --- docs/blog/posts/toolong-retrospective.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/blog/posts/toolong-retrospective.md b/docs/blog/posts/toolong-retrospective.md index dff9b79220..0fa480d033 100644 --- a/docs/blog/posts/toolong-retrospective.md +++ b/docs/blog/posts/toolong-retrospective.md @@ -19,7 +19,7 @@ There were some interesting technical challenges in building Toolong that I'd li This isn't specifically [Textual](https://github.com/textualize/textual/) related. These techniques could be employed in any Python project. These techniques aren't difficult, and shouldn't be beyond anyone with an intermediate understanding of Python. -They are the kind of "if you know it you kow it" knowledge that you may not need often, but can make a massive difference when you do! +They are the kind of "if you know it you know it" knowledge that you may not need often, but can make a massive difference when you do! ## Opening large files @@ -55,7 +55,7 @@ And that would likely have worked just fine, but there is a bit of magic in the The [mmap](https://docs.python.org/3/library/mmap.html) module is a real gem for this kind of thing. A *memory mapped file* is an OS-level construct that *appears* to load a file instantaneously. In Python you get an object which behaves like a `bytearray`, but loads data from disk when it is accessed. -The beauty of this module is that you can work with files in much the same way as if you had read the entire file in to memory, while leaving the actually reading of the file to the OS. +The beauty of this module is that you can work with files in much the same way as if you had read the entire file in to memory, while leaving the actual reading of the file to the OS. Here's the method that Toolong uses to scan for line breaks. Forgive the micro-optimizations, I was going for raw execution speed here. From 92ed4bfaa9f90fa5920a4f429b0a1adf6d46d9b9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 09:35:39 +0000 Subject: [PATCH 125/149] Clarify a choice in the heart of the command palette search For the reader who has made it this far, highlight the point at which the crucial decision is made. --- src/textual/command.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/command.py b/src/textual/command.py index 228e497540..6a7796bde2 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -255,6 +255,8 @@ async def _search(self, query: str) -> Hits: """ await self._wait_init() if self._init_success: + # An empty search string is a discovery search, anything else is + # a conventional search. hits = self.search(query) if query else self.discover() async for hit in hits: if hit is not NotImplemented: From 51d7f28e0936d40977122c0f7d624342974b7f4b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 09:45:57 +0000 Subject: [PATCH 126/149] Clarify what Provider.discover should yield up --- src/textual/command.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 6a7796bde2..215d78875b 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -278,13 +278,13 @@ async def discover(self) -> Hits: """A default collection of hits for the provider. Yields: - Instances of [`Hit`][textual.command.Hit]. + Instances of [`DiscoveryHit`][textual.command.DiscoveryHit]. Note: This is different from [`search`][textual.command.Provider.search] in that it should - yield [`Hit`s][textual.command.Hit] that should be shown by - default; before user input. + yield [`DiscoveryHit`s][textual.command.DiscoveryHit] that + should be shown by default; before user input. It is permitted to *not* implement this method. """ From a705c349651d56b533c8febb05992d478c14b00b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 10:05:38 +0000 Subject: [PATCH 127/149] Fix a case typo that already existed in the document --- docs/guide/command_palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index bdfd45b398..d614475a86 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -99,7 +99,7 @@ In the example above, the callback is a lambda which calls the `open_file` metho This is a deliberate design decision taken to prevent a single broken `Provider` class from making the command palette unusable. Errors in command providers will be logged to the [console](./devtools.md). -### Shutdown method +### shutdown method The [`shutdown`][textual.command.Provider.shutdown] method is called when the command palette is closed. You can use this as a hook to gracefully close any objects you created in [`startup`][textual.command.Provider.startup]. From cbb44bb50cbfd4a2fefd7dc803ec9df32f8cceb3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 10:06:00 +0000 Subject: [PATCH 128/149] Mention the discover method in the docs --- docs/guide/command_palette.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index d614475a86..d6f2d30338 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -68,7 +68,7 @@ The following example will display a blank screen initially, but if you bring up 5. Highlights matching letters in the search. 6. Adds our custom command provider and the default command provider. -There are three methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], and [`shutdown`][textual.command.Provider.shutdown]. +There are four methods you can override in a command provider: [`startup`][textual.command.Provider.startup], [`search`][textual.command.Provider.search], [`discover`][textual.command.Provider.discover] and [`shutdown`][textual.command.Provider.shutdown]. All of these methods should be coroutines (`async def`). Only `search` is required, the other methods are optional. Let's explore those methods in detail. @@ -99,6 +99,17 @@ In the example above, the callback is a lambda which calls the `open_file` metho This is a deliberate design decision taken to prevent a single broken `Provider` class from making the command palette unusable. Errors in command providers will be logged to the [console](./devtools.md). +### discover method + +The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *hits*) that should be shown to the user when the command palette input is empty; +this is to aid in command discoverability. + +`discover` is similar to `search` but with the difference that no search value is passed to it, and it should *yield* instances of [`DiscoveryHit`][textual.command.DiscoveryHit]. +There is no matching and no match score is needed. +The [`DiscoveryHit`][textual.command.DiscoveryHit] contains information about how the hit should be displayed, and an optional help string; +discovery hits are sorted in ascending alphabetical order. +It also contains a callback, which will be run if the user selects that command. + ### shutdown method The [`shutdown`][textual.command.Provider.shutdown] method is called when the command palette is closed. From 4f6f15a06690d83dc6c89efe310786983c8a3716 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 10:08:54 +0000 Subject: [PATCH 129/149] Add a note about best practice of the user of discover --- docs/guide/command_palette.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index d6f2d30338..2da2ec592f 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -104,6 +104,11 @@ In the example above, the callback is a lambda which calls the `open_file` metho The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *hits*) that should be shown to the user when the command palette input is empty; this is to aid in command discoverability. +!!! note + + Because `discover` hits are shown the moment the command palette is opened, these should ideally be quick to generate; + commands that might take time to generate are best left for `search` -- use `discover` to help the user easily find the most important commands. + `discover` is similar to `search` but with the difference that no search value is passed to it, and it should *yield* instances of [`DiscoveryHit`][textual.command.DiscoveryHit]. There is no matching and no match score is needed. The [`DiscoveryHit`][textual.command.DiscoveryHit] contains information about how the hit should be displayed, and an optional help string; From 66c19fab01b337eb460bfbb0db9ccde40b945062 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 10:20:46 +0000 Subject: [PATCH 130/149] Make it clear we're talking about discovery hits --- docs/guide/command_palette.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 2da2ec592f..1d7fb55423 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -101,7 +101,7 @@ In the example above, the callback is a lambda which calls the `open_file` metho ### discover method -The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *hits*) that should be shown to the user when the command palette input is empty; +The [`discover`][textual.command.Provider.discover] method is responsible for providing results (or *discovery hits*) that should be shown to the user when the command palette input is empty; this is to aid in command discoverability. !!! note From f982dd7672751fdc78b9d5160694c3978ca03b2b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 10:22:41 +0000 Subject: [PATCH 131/149] Update the ChangeLog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 220e7e0f2c..b87acd3178 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 +- Added support for command palette command discoverability https://github.com/Textualize/textual/pull/4154 ## [0.51.1] - 2024-02-09 @@ -26,7 +27,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 - Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 -### Added +### Added - Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 From fa4f75fd25c625d150e11c476bf468bf506c3400 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 14 Feb 2024 13:11:14 +0000 Subject: [PATCH 132/149] Text area undo redo (#4124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial undo related machinery added to TextArea * Initial undo implementation * Basic undo and redo * Some more fleshing out of undo and redo * Skeleton code for managing TextArea history * Initial implementation of undo & redo checkpointing in TextArea * Increase checkpoint characters * Restoring the selection in the TextArea and then restoring it on undo * Adding docstrings to undo_batch and redo_batch in the TextArea * Batching edits of the same type * Batching edits of the same type * Keeping edits containing newlines in their own batch * Checking for newline characters in insertion or replacement during undo checkpoint creation. Updating docstrings in history.py * Fix mypy warning * Performance improvement * Add history checkpoint on cursor movement * Fixing merge conflict in Edit class * Fixing error in merge conflict resolution * Remove unused test file * Remove unused test file * Initial testing of undo and redo * Testing for undo redo * Updating lockfile * Add an extra test * Fix: setting the `text` property programmatically should invalidate the edit history * Improving docstrings * Rename EditHistory.reset() to EditHistory.clear() * Add docstring to an exception * Add a pause after focus/blur in a test * Forcing CI colour * Update focus checkpoint test * Try to force color in pytest by setting --color=yes in PYTEST_ADDOPTS in env var on Github Actions * Add extra assertion in a test * Toggle text_area has focus to trigger checkpoint in history * Apply grammar/wording suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Making max checkpoints configurable in TextArea history * Improve a docstring * Update changelog * Spelling fixes * More spelling fixes * Americanize spelling of tab_behaviour (->tab_behavior) * Update CHANGELOG regarding `tab_behaviour`->`tab_behavior` --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- .github/workflows/pythonpackage.yml | 3 + .pre-commit-config.yaml | 8 +- CHANGELOG.md | 5 + docs/widgets/text_area.md | 2 +- poetry.lock | 758 ++++++++++++------------- pyproject.toml | 1 - src/textual/document/_document.py | 2 +- src/textual/document/_edit.py | 143 +++++ src/textual/document/_history.py | 183 ++++++ src/textual/widgets/_data_table.py | 4 +- src/textual/widgets/_text_area.py | 282 +++++---- src/textual/widgets/text_area.py | 4 +- tests/text_area/test_escape_binding.py | 12 +- tests/text_area/test_history.py | 343 +++++++++++ 14 files changed, 1180 insertions(+), 570 deletions(-) create mode 100644 src/textual/document/_edit.py create mode 100644 src/textual/document/_history.py create mode 100644 tests/text_area/test_history.py diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 02e367aa44..1772620e98 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -11,6 +11,9 @@ on: - "**.lock" - "Makefile" +env: + PYTEST_ADDOPTS: "--color=yes" + jobs: build: runs-on: ${{ matrix.os }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d173178cf8..f53b9c445b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,20 +17,20 @@ repos: - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline - id: mixed-line-ending # replaces or checks mixed line ending - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: '5.12.0' hooks: - id: isort name: isort (python) language_version: '3.11' - args: ["--profile", "black", "--filter-files"] + args: ['--profile', 'black', '--filter-files'] - repo: https://github.com/psf/black - rev: 24.1.1 + rev: '24.1.1' hooks: - id: black - repo: https://github.com/hadialqattan/pycln # removes unused imports rev: v2.3.0 hooks: - id: pycln - language_version: "3.11" + language_version: '3.11' args: [--all] exclude: ^tests/snapshot_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 220e7e0f2c..5826989c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 +- Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124 + +### Changed + +- Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 ## [0.51.1] - 2024-02-09 diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index 57fdc719f4..4449bcd668 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -288,7 +288,7 @@ This immediately updates the appearance of the `TextArea`: Pressing the ++tab++ key will shift focus to the next widget in your application by default. This matches how other widgets work in Textual. -To have ++tab++ insert a `\t` character, set the `tab_behaviour` attribute to the string value `"indent"`. +To have ++tab++ insert a `\t` character, set the `tab_behavior` attribute to the string value `"indent"`. While in this mode, you can shift focus by pressing the ++escape++ key. ### Indentation diff --git a/poetry.lock b/poetry.lock index f248950165..73ec9fbf67 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,88 +1,88 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "aiohttp" -version = "3.9.2" +version = "3.9.3" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.8" files = [ - {file = "aiohttp-3.9.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:772fbe371788e61c58d6d3d904268e48a594ba866804d08c995ad71b144f94cb"}, - {file = "aiohttp-3.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:edd4f1af2253f227ae311ab3d403d0c506c9b4410c7fc8d9573dec6d9740369f"}, - {file = "aiohttp-3.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cfee9287778399fdef6f8a11c9e425e1cb13cc9920fd3a3df8f122500978292b"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc158466f6a980a6095ee55174d1de5730ad7dec251be655d9a6a9dd7ea1ff9"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54ec82f45d57c9a65a1ead3953b51c704f9587440e6682f689da97f3e8defa35"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abeb813a18eb387f0d835ef51f88568540ad0325807a77a6e501fed4610f864e"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc91d07280d7d169f3a0f9179d8babd0ee05c79d4d891447629ff0d7d8089ec2"}, - {file = "aiohttp-3.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b65e861f4bebfb660f7f0f40fa3eb9f2ab9af10647d05dac824390e7af8f75b7"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:04fd8ffd2be73d42bcf55fd78cde7958eeee6d4d8f73c3846b7cba491ecdb570"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:3d8d962b439a859b3ded9a1e111a4615357b01620a546bc601f25b0211f2da81"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:8ceb658afd12b27552597cf9a65d9807d58aef45adbb58616cdd5ad4c258c39e"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:0e4ee4df741670560b1bc393672035418bf9063718fee05e1796bf867e995fad"}, - {file = "aiohttp-3.9.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2dec87a556f300d3211decf018bfd263424f0690fcca00de94a837949fbcea02"}, - {file = "aiohttp-3.9.2-cp310-cp310-win32.whl", hash = "sha256:3e1a800f988ce7c4917f34096f81585a73dbf65b5c39618b37926b1238cf9bc4"}, - {file = "aiohttp-3.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:ea510718a41b95c236c992b89fdfc3d04cc7ca60281f93aaada497c2b4e05c46"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6aaa6f99256dd1b5756a50891a20f0d252bd7bdb0854c5d440edab4495c9f973"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a27d8c70ad87bcfce2e97488652075a9bdd5b70093f50b10ae051dfe5e6baf37"}, - {file = "aiohttp-3.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:54287bcb74d21715ac8382e9de146d9442b5f133d9babb7e5d9e453faadd005e"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb3d05569aa83011fcb346b5266e00b04180105fcacc63743fc2e4a1862a891"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c8534e7d69bb8e8d134fe2be9890d1b863518582f30c9874ed7ed12e48abe3c4"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd9d5b989d57b41e4ff56ab250c5ddf259f32db17159cce630fd543376bd96b"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa6904088e6642609981f919ba775838ebf7df7fe64998b1a954fb411ffb4663"}, - {file = "aiohttp-3.9.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda42eb410be91b349fb4ee3a23a30ee301c391e503996a638d05659d76ea4c2"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:193cc1ccd69d819562cc7f345c815a6fc51d223b2ef22f23c1a0f67a88de9a72"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b9f1cb839b621f84a5b006848e336cf1496688059d2408e617af33e3470ba204"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:d22a0931848b8c7a023c695fa2057c6aaac19085f257d48baa24455e67df97ec"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4112d8ba61fbd0abd5d43a9cb312214565b446d926e282a6d7da3f5a5aa71d36"}, - {file = "aiohttp-3.9.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c4ad4241b52bb2eb7a4d2bde060d31c2b255b8c6597dd8deac2f039168d14fd7"}, - {file = "aiohttp-3.9.2-cp311-cp311-win32.whl", hash = "sha256:ee2661a3f5b529f4fc8a8ffee9f736ae054adfb353a0d2f78218be90617194b3"}, - {file = "aiohttp-3.9.2-cp311-cp311-win_amd64.whl", hash = "sha256:4deae2c165a5db1ed97df2868ef31ca3cc999988812e82386d22937d9d6fed52"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:6f4cdba12539215aaecf3c310ce9d067b0081a0795dd8a8805fdb67a65c0572a"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:84e843b33d5460a5c501c05539809ff3aee07436296ff9fbc4d327e32aa3a326"}, - {file = "aiohttp-3.9.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8008d0f451d66140a5aa1c17e3eedc9d56e14207568cd42072c9d6b92bf19b52"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61c47ab8ef629793c086378b1df93d18438612d3ed60dca76c3422f4fbafa792"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc71f748e12284312f140eaa6599a520389273174b42c345d13c7e07792f4f57"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1c3a4d0ab2f75f22ec80bca62385db2e8810ee12efa8c9e92efea45c1849133"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a87aa0b13bbee025faa59fa58861303c2b064b9855d4c0e45ec70182bbeba1b"}, - {file = "aiohttp-3.9.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2cc0d04688b9f4a7854c56c18aa7af9e5b0a87a28f934e2e596ba7e14783192"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1956e3ac376b1711c1533266dec4efd485f821d84c13ce1217d53e42c9e65f08"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:114da29f39eccd71b93a0fcacff178749a5c3559009b4a4498c2c173a6d74dff"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:3f17999ae3927d8a9a823a1283b201344a0627272f92d4f3e3a4efe276972fe8"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:f31df6a32217a34ae2f813b152a6f348154f948c83213b690e59d9e84020925c"}, - {file = "aiohttp-3.9.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7a75307ffe31329928a8d47eae0692192327c599113d41b278d4c12b54e1bd11"}, - {file = "aiohttp-3.9.2-cp312-cp312-win32.whl", hash = "sha256:972b63d589ff8f305463593050a31b5ce91638918da38139b9d8deaba9e0fed7"}, - {file = "aiohttp-3.9.2-cp312-cp312-win_amd64.whl", hash = "sha256:200dc0246f0cb5405c80d18ac905c8350179c063ea1587580e3335bfc243ba6a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:158564d0d1020e0d3fe919a81d97aadad35171e13e7b425b244ad4337fc6793a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:da1346cd0ccb395f0ed16b113ebb626fa43b7b07fd7344fce33e7a4f04a8897a"}, - {file = "aiohttp-3.9.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:eaa9256de26ea0334ffa25f1913ae15a51e35c529a1ed9af8e6286dd44312554"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1543e7fb00214fb4ccead42e6a7d86f3bb7c34751ec7c605cca7388e525fd0b4"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:186e94570433a004e05f31f632726ae0f2c9dee4762a9ce915769ce9c0a23d89"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d52d20832ac1560f4510d68e7ba8befbc801a2b77df12bd0cd2bcf3b049e52a4"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c45e4e815ac6af3b72ca2bde9b608d2571737bb1e2d42299fc1ffdf60f6f9a1"}, - {file = "aiohttp-3.9.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa906b9bdfd4a7972dd0628dbbd6413d2062df5b431194486a78f0d2ae87bd55"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:68bbee9e17d66f17bb0010aa15a22c6eb28583edcc8b3212e2b8e3f77f3ebe2a"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4c189b64bd6d9a403a1a3f86a3ab3acbc3dc41a68f73a268a4f683f89a4dec1f"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:8a7876f794523123bca6d44bfecd89c9fec9ec897a25f3dd202ee7fc5c6525b7"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:d23fba734e3dd7b1d679b9473129cd52e4ec0e65a4512b488981a56420e708db"}, - {file = "aiohttp-3.9.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b141753be581fab842a25cb319f79536d19c2a51995d7d8b29ee290169868eab"}, - {file = "aiohttp-3.9.2-cp38-cp38-win32.whl", hash = "sha256:103daf41ff3b53ba6fa09ad410793e2e76c9d0269151812e5aba4b9dd674a7e8"}, - {file = "aiohttp-3.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:328918a6c2835861ff7afa8c6d2c70c35fdaf996205d5932351bdd952f33fa2f"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5264d7327c9464786f74e4ec9342afbbb6ee70dfbb2ec9e3dfce7a54c8043aa3"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:07205ae0015e05c78b3288c1517afa000823a678a41594b3fdc870878d645305"}, - {file = "aiohttp-3.9.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ae0a1e638cffc3ec4d4784b8b4fd1cf28968febc4bd2718ffa25b99b96a741bd"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d43302a30ba1166325974858e6ef31727a23bdd12db40e725bec0f759abce505"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16a967685907003765855999af11a79b24e70b34dc710f77a38d21cd9fc4f5fe"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fa3ee92cd441d5c2d07ca88d7a9cef50f7ec975f0117cd0c62018022a184308"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b500c5ad9c07639d48615a770f49618130e61be36608fc9bc2d9bae31732b8f"}, - {file = "aiohttp-3.9.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c07327b368745b1ce2393ae9e1aafed7073d9199e1dcba14e035cc646c7941bf"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc7d6502c23a0ec109687bf31909b3fb7b196faf198f8cff68c81b49eb316ea9"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:07be2be7071723c3509ab5c08108d3a74f2181d4964e869f2504aaab68f8d3e8"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:122468f6fee5fcbe67cb07014a08c195b3d4c41ff71e7b5160a7bcc41d585a5f"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:00a9abcea793c81e7f8778ca195a1714a64f6d7436c4c0bb168ad2a212627000"}, - {file = "aiohttp-3.9.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a9825fdd64ecac5c670234d80bb52bdcaa4139d1f839165f548208b3779c6c6"}, - {file = "aiohttp-3.9.2-cp39-cp39-win32.whl", hash = "sha256:5422cd9a4a00f24c7244e1b15aa9b87935c85fb6a00c8ac9b2527b38627a9211"}, - {file = "aiohttp-3.9.2-cp39-cp39-win_amd64.whl", hash = "sha256:7d579dcd5d82a86a46f725458418458fa43686f6a7b252f2966d359033ffc8ab"}, - {file = "aiohttp-3.9.2.tar.gz", hash = "sha256:b0ad0a5e86ce73f5368a164c10ada10504bf91869c05ab75d982c6048217fbf7"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:939677b61f9d72a4fa2a042a5eee2a99a24001a67c13da113b2e30396567db54"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1f5cd333fcf7590a18334c90f8c9147c837a6ec8a178e88d90a9b96ea03194cc"}, + {file = "aiohttp-3.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:82e6aa28dd46374f72093eda8bcd142f7771ee1eb9d1e223ff0fa7177a96b4a5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f56455b0c2c7cc3b0c584815264461d07b177f903a04481dfc33e08a89f0c26b"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bca77a198bb6e69795ef2f09a5f4c12758487f83f33d63acde5f0d4919815768"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e083c285857b78ee21a96ba1eb1b5339733c3563f72980728ca2b08b53826ca5"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab40e6251c3873d86ea9b30a1ac6d7478c09277b32e14745d0d3c6e76e3c7e29"}, + {file = "aiohttp-3.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df822ee7feaaeffb99c1a9e5e608800bd8eda6e5f18f5cfb0dc7eeb2eaa6bbec"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:acef0899fea7492145d2bbaaaec7b345c87753168589cc7faf0afec9afe9b747"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cd73265a9e5ea618014802ab01babf1940cecb90c9762d8b9e7d2cc1e1969ec6"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:a78ed8a53a1221393d9637c01870248a6f4ea5b214a59a92a36f18151739452c"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:6b0e029353361f1746bac2e4cc19b32f972ec03f0f943b390c4ab3371840aabf"}, + {file = "aiohttp-3.9.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7cf5c9458e1e90e3c390c2639f1017a0379a99a94fdfad3a1fd966a2874bba52"}, + {file = "aiohttp-3.9.3-cp310-cp310-win32.whl", hash = "sha256:3e59c23c52765951b69ec45ddbbc9403a8761ee6f57253250c6e1536cacc758b"}, + {file = "aiohttp-3.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:055ce4f74b82551678291473f66dc9fb9048a50d8324278751926ff0ae7715e5"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b88f9386ff1ad91ace19d2a1c0225896e28815ee09fc6a8932fded8cda97c3d"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c46956ed82961e31557b6857a5ca153c67e5476972e5f7190015018760938da2"}, + {file = "aiohttp-3.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:07b837ef0d2f252f96009e9b8435ec1fef68ef8b1461933253d318748ec1acdc"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad46e6f620574b3b4801c68255492e0159d1712271cc99d8bdf35f2043ec266"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ed3e046ea7b14938112ccd53d91c1539af3e6679b222f9469981e3dac7ba1ce"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:039df344b45ae0b34ac885ab5b53940b174530d4dd8a14ed8b0e2155b9dddccb"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7943c414d3a8d9235f5f15c22ace69787c140c80b718dcd57caaade95f7cd93b"}, + {file = "aiohttp-3.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:84871a243359bb42c12728f04d181a389718710129b36b6aad0fc4655a7647d4"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5eafe2c065df5401ba06821b9a054d9cb2848867f3c59801b5d07a0be3a380ae"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9d3c9b50f19704552f23b4eaea1fc082fdd82c63429a6506446cbd8737823da3"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:f033d80bc6283092613882dfe40419c6a6a1527e04fc69350e87a9df02bbc283"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:2c895a656dd7e061b2fd6bb77d971cc38f2afc277229ce7dd3552de8313a483e"}, + {file = "aiohttp-3.9.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1f5a71d25cd8106eab05f8704cd9167b6e5187bcdf8f090a66c6d88b634802b4"}, + {file = "aiohttp-3.9.3-cp311-cp311-win32.whl", hash = "sha256:50fca156d718f8ced687a373f9e140c1bb765ca16e3d6f4fe116e3df7c05b2c5"}, + {file = "aiohttp-3.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:5fe9ce6c09668063b8447f85d43b8d1c4e5d3d7e92c63173e6180b2ac5d46dd8"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:38a19bc3b686ad55804ae931012f78f7a534cce165d089a2059f658f6c91fa60"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:770d015888c2a598b377bd2f663adfd947d78c0124cfe7b959e1ef39f5b13869"}, + {file = "aiohttp-3.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee43080e75fc92bf36219926c8e6de497f9b247301bbf88c5c7593d931426679"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52df73f14ed99cee84865b95a3d9e044f226320a87af208f068ecc33e0c35b96"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9b311743a78043b26ffaeeb9715dc360335e5517832f5a8e339f8a43581e4d"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b955ed993491f1a5da7f92e98d5dad3c1e14dc175f74517c4e610b1f2456fb11"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504b6981675ace64c28bf4a05a508af5cde526e36492c98916127f5a02354d53"}, + {file = "aiohttp-3.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6fe5571784af92b6bc2fda8d1925cccdf24642d49546d3144948a6a1ed58ca5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ba39e9c8627edc56544c8628cc180d88605df3892beeb2b94c9bc857774848ca"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e5e46b578c0e9db71d04c4b506a2121c0cb371dd89af17a0586ff6769d4c58c1"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:938a9653e1e0c592053f815f7028e41a3062e902095e5a7dc84617c87267ebd5"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:c3452ea726c76e92f3b9fae4b34a151981a9ec0a4847a627c43d71a15ac32aa6"}, + {file = "aiohttp-3.9.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff30218887e62209942f91ac1be902cc80cddb86bf00fbc6783b7a43b2bea26f"}, + {file = "aiohttp-3.9.3-cp312-cp312-win32.whl", hash = "sha256:38f307b41e0bea3294a9a2a87833191e4bcf89bb0365e83a8be3a58b31fb7f38"}, + {file = "aiohttp-3.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:b791a3143681a520c0a17e26ae7465f1b6f99461a28019d1a2f425236e6eedb5"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0ed621426d961df79aa3b963ac7af0d40392956ffa9be022024cd16297b30c8c"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7f46acd6a194287b7e41e87957bfe2ad1ad88318d447caf5b090012f2c5bb528"}, + {file = "aiohttp-3.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feeb18a801aacb098220e2c3eea59a512362eb408d4afd0c242044c33ad6d542"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f734e38fd8666f53da904c52a23ce517f1b07722118d750405af7e4123933511"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b40670ec7e2156d8e57f70aec34a7216407848dfe6c693ef131ddf6e76feb672"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdd215b7b7fd4a53994f238d0f46b7ba4ac4c0adb12452beee724ddd0743ae5d"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:017a21b0df49039c8f46ca0971b3a7fdc1f56741ab1240cb90ca408049766168"}, + {file = "aiohttp-3.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e99abf0bba688259a496f966211c49a514e65afa9b3073a1fcee08856e04425b"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:648056db9a9fa565d3fa851880f99f45e3f9a771dd3ff3bb0c048ea83fb28194"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8aacb477dc26797ee089721536a292a664846489c49d3ef9725f992449eda5a8"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:522a11c934ea660ff8953eda090dcd2154d367dec1ae3c540aff9f8a5c109ab4"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5bce0dc147ca85caa5d33debc4f4d65e8e8b5c97c7f9f660f215fa74fc49a321"}, + {file = "aiohttp-3.9.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b4af9f25b49a7be47c0972139e59ec0e8285c371049df1a63b6ca81fdd216a2"}, + {file = "aiohttp-3.9.3-cp38-cp38-win32.whl", hash = "sha256:298abd678033b8571995650ccee753d9458dfa0377be4dba91e4491da3f2be63"}, + {file = "aiohttp-3.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:69361bfdca5468c0488d7017b9b1e5ce769d40b46a9f4a2eed26b78619e9396c"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0fa43c32d1643f518491d9d3a730f85f5bbaedcbd7fbcae27435bb8b7a061b29"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:835a55b7ca49468aaaac0b217092dfdff370e6c215c9224c52f30daaa735c1c1"}, + {file = "aiohttp-3.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:06a9b2c8837d9a94fae16c6223acc14b4dfdff216ab9b7202e07a9a09541168f"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abf151955990d23f84205286938796c55ff11bbfb4ccfada8c9c83ae6b3c89a3"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59c26c95975f26e662ca78fdf543d4eeaef70e533a672b4113dd888bd2423caa"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f95511dd5d0e05fd9728bac4096319f80615aaef4acbecb35a990afebe953b0e"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:595f105710293e76b9dc09f52e0dd896bd064a79346234b521f6b968ffdd8e58"}, + {file = "aiohttp-3.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7c8b816c2b5af5c8a436df44ca08258fc1a13b449393a91484225fcb7545533"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f1088fa100bf46e7b398ffd9904f4808a0612e1d966b4aa43baa535d1b6341eb"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f59dfe57bb1ec82ac0698ebfcdb7bcd0e99c255bd637ff613760d5f33e7c81b3"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:361a1026c9dd4aba0109e4040e2aecf9884f5cfe1b1b1bd3d09419c205e2e53d"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:363afe77cfcbe3a36353d8ea133e904b108feea505aa4792dad6585a8192c55a"}, + {file = "aiohttp-3.9.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e2c45c208c62e955e8256949eb225bd8b66a4c9b6865729a786f2aa79b72e9d"}, + {file = "aiohttp-3.9.3-cp39-cp39-win32.whl", hash = "sha256:f7217af2e14da0856e082e96ff637f14ae45c10a5714b63c77f26d8884cf1051"}, + {file = "aiohttp-3.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:27468897f628c627230dba07ec65dc8d0db566923c48f29e084ce382119802bc"}, + {file = "aiohttp-3.9.3.tar.gz", hash = "sha256:90842933e5d1ff760fae6caca4b2b3edba53ba8f4b71e95dacf2818a2aca06f7"}, ] [package.dependencies] @@ -227,13 +227,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -763,13 +763,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "linkify-it-py" -version = "2.0.2" +version = "2.0.3" description = "Links recognition library with FULL unicode support." optional = false python-versions = ">=3.7" files = [ - {file = "linkify-it-py-2.0.2.tar.gz", hash = "sha256:19f3060727842c254c808e99d465c80c49d2c7306788140987a1a7a29b0d6ad2"}, - {file = "linkify_it_py-2.0.2-py3-none-any.whl", hash = "sha256:a3a24428f6c96f27370d7fe61d2ac0be09017be5190d68d8658233171f1b6541"}, + {file = "linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048"}, + {file = "linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79"}, ] [package.dependencies] @@ -827,71 +827,71 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "2.1.4" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"}, - {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -996,13 +996,13 @@ mkdocs = "*" [[package]] name = "mkdocs-material" -version = "9.5.6" +version = "9.5.8" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.6-py3-none-any.whl", hash = "sha256:e115b90fccf5cd7f5d15b0c2f8e6246b21041628b8f590630e7fca66ed7fcf6c"}, - {file = "mkdocs_material-9.5.6.tar.gz", hash = "sha256:5b24df36d8ac6cecd611241ce6f6423ccde3e1ad89f8360c3f76d5565fc2d82a"}, + {file = "mkdocs_material-9.5.8-py3-none-any.whl", hash = "sha256:14563314bbf97da4bfafc69053772341babfaeb3329cde01d3e63cec03997af8"}, + {file = "mkdocs_material-9.5.8.tar.gz", hash = "sha256:2a429213e83f84eda7a588e2b186316d806aac602b7f93990042f7a1f3d3cf65"}, ] [package.dependencies] @@ -1019,7 +1019,7 @@ regex = ">=2022.4" requests = ">=2.26,<3.0" [package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2,<2.0)"] +git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] @@ -1163,85 +1163,101 @@ files = [ [[package]] name = "multidict" -version = "6.0.4" +version = "6.0.5" description = "multidict implementation" optional = false python-versions = ">=3.7" files = [ - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1a97283e0c85772d613878028fec909f003993e1007eafa715b24b377cb9b8"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eeb6dcc05e911516ae3d1f207d4b0520d07f54484c49dfc294d6e7d63b734171"}, - {file = "multidict-6.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d6d635d5209b82a3492508cf5b365f3446afb65ae7ebd755e70e18f287b0adf7"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c048099e4c9e9d615545e2001d3d8a4380bd403e1a0578734e0d31703d1b0c0b"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea20853c6dbbb53ed34cb4d080382169b6f4554d394015f1bef35e881bf83547"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:16d232d4e5396c2efbbf4f6d4df89bfa905eb0d4dc5b3549d872ab898451f569"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36c63aaa167f6c6b04ef2c85704e93af16c11d20de1d133e39de6a0e84582a93"}, - {file = "multidict-6.0.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64bdf1086b6043bf519869678f5f2757f473dee970d7abf6da91ec00acb9cb98"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:43644e38f42e3af682690876cff722d301ac585c5b9e1eacc013b7a3f7b696a0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7582a1d1030e15422262de9f58711774e02fa80df0d1578995c76214f6954988"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:ddff9c4e225a63a5afab9dd15590432c22e8057e1a9a13d28ed128ecf047bbdc"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:ee2a1ece51b9b9e7752e742cfb661d2a29e7bcdba2d27e66e28a99f1890e4fa0"}, - {file = "multidict-6.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a2e4369eb3d47d2034032a26c7a80fcb21a2cb22e1173d761a162f11e562caa5"}, - {file = "multidict-6.0.4-cp310-cp310-win32.whl", hash = "sha256:574b7eae1ab267e5f8285f0fe881f17efe4b98c39a40858247720935b893bba8"}, - {file = "multidict-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:4dcbb0906e38440fa3e325df2359ac6cb043df8e58c965bb45f4e406ecb162cc"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0dfad7a5a1e39c53ed00d2dd0c2e36aed4650936dc18fd9a1826a5ae1cad6f03"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:64da238a09d6039e3bd39bb3aee9c21a5e34f28bfa5aa22518581f910ff94af3"}, - {file = "multidict-6.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff959bee35038c4624250473988b24f846cbeb2c6639de3602c073f10410ceba"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01a3a55bd90018c9c080fbb0b9f4891db37d148a0a18722b42f94694f8b6d4c9"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c5cb09abb18c1ea940fb99360ea0396f34d46566f157122c92dfa069d3e0e982"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:666daae833559deb2d609afa4490b85830ab0dfca811a98b70a205621a6109fe"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11bdf3f5e1518b24530b8241529d2050014c884cf18b6fc69c0c2b30ca248710"}, - {file = "multidict-6.0.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d18748f2d30f94f498e852c67d61261c643b349b9d2a581131725595c45ec6c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:458f37be2d9e4c95e2d8866a851663cbc76e865b78395090786f6cd9b3bbf4f4"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b1a2eeedcead3a41694130495593a559a668f382eee0727352b9a41e1c45759a"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7d6ae9d593ef8641544d6263c7fa6408cc90370c8cb2bbb65f8d43e5b0351d9c"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:5979b5632c3e3534e42ca6ff856bb24b2e3071b37861c2c727ce220d80eee9ed"}, - {file = "multidict-6.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dcfe792765fab89c365123c81046ad4103fcabbc4f56d1c1997e6715e8015461"}, - {file = "multidict-6.0.4-cp311-cp311-win32.whl", hash = "sha256:3601a3cece3819534b11d4efc1eb76047488fddd0c85a3948099d5da4d504636"}, - {file = "multidict-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:81a4f0b34bd92df3da93315c6a59034df95866014ac08535fc819f043bfd51f0"}, - {file = "multidict-6.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:67040058f37a2a51ed8ea8f6b0e6ee5bd78ca67f169ce6122f3e2ec80dfe9b78"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:853888594621e6604c978ce2a0444a1e6e70c8d253ab65ba11657659dcc9100f"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:39ff62e7d0f26c248b15e364517a72932a611a9b75f35b45be078d81bdb86603"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:af048912e045a2dc732847d33821a9d84ba553f5c5f028adbd364dd4765092ac"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e8b901e607795ec06c9e42530788c45ac21ef3aaa11dbd0c69de543bfb79a9"}, - {file = "multidict-6.0.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62501642008a8b9871ddfccbf83e4222cf8ac0d5aeedf73da36153ef2ec222d2"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:99b76c052e9f1bc0721f7541e5e8c05db3941eb9ebe7b8553c625ef88d6eefde"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:509eac6cf09c794aa27bcacfd4d62c885cce62bef7b2c3e8b2e49d365b5003fe"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:21a12c4eb6ddc9952c415f24eef97e3e55ba3af61f67c7bc388dcdec1404a067"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:5cad9430ab3e2e4fa4a2ef4450f548768400a2ac635841bc2a56a2052cdbeb87"}, - {file = "multidict-6.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ab55edc2e84460694295f401215f4a58597f8f7c9466faec545093045476327d"}, - {file = "multidict-6.0.4-cp37-cp37m-win32.whl", hash = "sha256:5a4dcf02b908c3b8b17a45fb0f15b695bf117a67b76b7ad18b73cf8e92608775"}, - {file = "multidict-6.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6ed5f161328b7df384d71b07317f4d8656434e34591f20552c7bcef27b0ab88e"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5fc1b16f586f049820c5c5b17bb4ee7583092fa0d1c4e28b5239181ff9532e0c"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1502e24330eb681bdaa3eb70d6358e818e8e8f908a22a1851dfd4e15bc2f8161"}, - {file = "multidict-6.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b692f419760c0e65d060959df05f2a531945af31fda0c8a3b3195d4efd06de11"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45e1ecb0379bfaab5eef059f50115b54571acfbe422a14f668fc8c27ba410e7e"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddd3915998d93fbcd2566ddf9cf62cdb35c9e093075f862935573d265cf8f65d"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:59d43b61c59d82f2effb39a93c48b845efe23a3852d201ed2d24ba830d0b4cf2"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc8e1d0c705233c5dd0c5e6460fbad7827d5d36f310a0fadfd45cc3029762258"}, - {file = "multidict-6.0.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6aa0418fcc838522256761b3415822626f866758ee0bc6632c9486b179d0b52"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6748717bb10339c4760c1e63da040f5f29f5ed6e59d76daee30305894069a660"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4d1a3d7ef5e96b1c9e92f973e43aa5e5b96c659c9bc3124acbbd81b0b9c8a951"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4372381634485bec7e46718edc71528024fcdc6f835baefe517b34a33c731d60"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:fc35cb4676846ef752816d5be2193a1e8367b4c1397b74a565a9d0389c433a1d"}, - {file = "multidict-6.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4b9d9e4e2b37daddb5c23ea33a3417901fa7c7b3dee2d855f63ee67a0b21e5b1"}, - {file = "multidict-6.0.4-cp38-cp38-win32.whl", hash = "sha256:e41b7e2b59679edfa309e8db64fdf22399eec4b0b24694e1b2104fb789207779"}, - {file = "multidict-6.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:d6c254ba6e45d8e72739281ebc46ea5eb5f101234f3ce171f0e9f5cc86991480"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:16ab77bbeb596e14212e7bab8429f24c1579234a3a462105cda4a66904998664"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:bc779e9e6f7fda81b3f9aa58e3a6091d49ad528b11ed19f6621408806204ad35"}, - {file = "multidict-6.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ceef517eca3e03c1cceb22030a3e39cb399ac86bff4e426d4fc6ae49052cc60"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:281af09f488903fde97923c7744bb001a9b23b039a909460d0f14edc7bf59706"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52f2dffc8acaba9a2f27174c41c9e57f60b907bb9f096b36b1a1f3be71c6284d"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b41156839806aecb3641f3208c0dafd3ac7775b9c4c422d82ee2a45c34ba81ca"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3fc56f88cc98ef8139255cf8cd63eb2c586531e43310ff859d6bb3a6b51f1"}, - {file = "multidict-6.0.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8316a77808c501004802f9beebde51c9f857054a0c871bd6da8280e718444449"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f70b98cd94886b49d91170ef23ec5c0e8ebb6f242d734ed7ed677b24d50c82cf"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bf6774e60d67a9efe02b3616fee22441d86fab4c6d335f9d2051d19d90a40063"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e69924bfcdda39b722ef4d9aa762b2dd38e4632b3641b1d9a57ca9cd18f2f83a"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:6b181d8c23da913d4ff585afd1155a0e1194c0b50c54fcfe286f70cdaf2b7176"}, - {file = "multidict-6.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:52509b5be062d9eafc8170e53026fbc54cf3b32759a23d07fd935fb04fc22d95"}, - {file = "multidict-6.0.4-cp39-cp39-win32.whl", hash = "sha256:27c523fbfbdfd19c6867af7346332b62b586eed663887392cff78d614f9ec313"}, - {file = "multidict-6.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:33029f5734336aa0d4c0384525da0387ef89148dc7191aae00ca5fb23d7aafc2"}, - {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:228b644ae063c10e7f324ab1ab6b548bdf6f8b47f3ec234fef1093bc2735e5f9"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:896ebdcf62683551312c30e20614305f53125750803b614e9e6ce74a96232604"}, + {file = "multidict-6.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:411bf8515f3be9813d06004cac41ccf7d1cd46dfe233705933dd163b60e37600"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d147090048129ce3c453f0292e7697d333db95e52616b3793922945804a433c"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:215ed703caf15f578dca76ee6f6b21b7603791ae090fbf1ef9d865571039ade5"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c6390cf87ff6234643428991b7359b5f59cc15155695deb4eda5c777d2b880f"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21fd81c4ebdb4f214161be351eb5bcf385426bf023041da2fd9e60681f3cebae"}, + {file = "multidict-6.0.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cc2ad10255f903656017363cd59436f2111443a76f996584d1077e43ee51182"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6939c95381e003f54cd4c5516740faba40cf5ad3eeff460c3ad1d3e0ea2549bf"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:220dd781e3f7af2c2c1053da9fa96d9cf3072ca58f057f4c5adaaa1cab8fc442"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:766c8f7511df26d9f11cd3a8be623e59cca73d44643abab3f8c8c07620524e4a"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:fe5d7785250541f7f5019ab9cba2c71169dc7d74d0f45253f8313f436458a4ef"}, + {file = "multidict-6.0.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1c1496e73051918fcd4f58ff2e0f2f3066d1c76a0c6aeffd9b45d53243702cc"}, + {file = "multidict-6.0.5-cp310-cp310-win32.whl", hash = "sha256:7afcdd1fc07befad18ec4523a782cde4e93e0a2bf71239894b8d61ee578c1319"}, + {file = "multidict-6.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:99f60d34c048c5c2fabc766108c103612344c46e35d4ed9ae0673d33c8fb26e8"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f285e862d2f153a70586579c15c44656f888806ed0e5b56b64489afe4a2dbfba"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:53689bb4e102200a4fafa9de9c7c3c212ab40a7ab2c8e474491914d2305f187e"}, + {file = "multidict-6.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:612d1156111ae11d14afaf3a0669ebf6c170dbb735e510a7438ffe2369a847fd"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7be7047bd08accdb7487737631d25735c9a04327911de89ff1b26b81745bd4e3"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de170c7b4fe6859beb8926e84f7d7d6c693dfe8e27372ce3b76f01c46e489fcf"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04bde7a7b3de05732a4eb39c94574db1ec99abb56162d6c520ad26f83267de29"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85f67aed7bb647f93e7520633d8f51d3cbc6ab96957c71272b286b2f30dc70ed"}, + {file = "multidict-6.0.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425bf820055005bfc8aa9a0b99ccb52cc2f4070153e34b701acc98d201693733"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d3eb1ceec286eba8220c26f3b0096cf189aea7057b6e7b7a2e60ed36b373b77f"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7901c05ead4b3fb75113fb1dd33eb1253c6d3ee37ce93305acd9d38e0b5f21a4"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:e0e79d91e71b9867c73323a3444724d496c037e578a0e1755ae159ba14f4f3d1"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:29bfeb0dff5cb5fdab2023a7a9947b3b4af63e9c47cae2a10ad58394b517fddc"}, + {file = "multidict-6.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e030047e85cbcedbfc073f71836d62dd5dadfbe7531cae27789ff66bc551bd5e"}, + {file = "multidict-6.0.5-cp311-cp311-win32.whl", hash = "sha256:2f4848aa3baa109e6ab81fe2006c77ed4d3cd1e0ac2c1fbddb7b1277c168788c"}, + {file = "multidict-6.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:2faa5ae9376faba05f630d7e5e6be05be22913782b927b19d12b8145968a85ea"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:51d035609b86722963404f711db441cf7134f1889107fb171a970c9701f92e1e"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cbebcd5bcaf1eaf302617c114aa67569dd3f090dd0ce8ba9e35e9985b41ac35b"}, + {file = "multidict-6.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ffc42c922dbfddb4a4c3b438eb056828719f07608af27d163191cb3e3aa6cc5"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ceb3b7e6a0135e092de86110c5a74e46bda4bd4fbfeeb3a3bcec79c0f861e450"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79660376075cfd4b2c80f295528aa6beb2058fd289f4c9252f986751a4cd0496"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e4428b29611e989719874670fd152b6625500ad6c686d464e99f5aaeeaca175a"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84a5c3a5f7ce6db1f999fb9438f686bc2e09d38143f2d93d8406ed2dd6b9226"}, + {file = "multidict-6.0.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76c0de87358b192de7ea9649beb392f107dcad9ad27276324c24c91774ca5271"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:79a6d2ba910adb2cbafc95dad936f8b9386e77c84c35bc0add315b856d7c3abb"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:92d16a3e275e38293623ebf639c471d3e03bb20b8ebb845237e0d3664914caef"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:fb616be3538599e797a2017cccca78e354c767165e8858ab5116813146041a24"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:14c2976aa9038c2629efa2c148022ed5eb4cb939e15ec7aace7ca932f48f9ba6"}, + {file = "multidict-6.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:435a0984199d81ca178b9ae2c26ec3d49692d20ee29bc4c11a2a8d4514c67eda"}, + {file = "multidict-6.0.5-cp312-cp312-win32.whl", hash = "sha256:9fe7b0653ba3d9d65cbe7698cca585bf0f8c83dbbcc710db9c90f478e175f2d5"}, + {file = "multidict-6.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:01265f5e40f5a17f8241d52656ed27192be03bfa8764d88e8220141d1e4b3556"}, + {file = "multidict-6.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:19fe01cea168585ba0f678cad6f58133db2aa14eccaf22f88e4a6dccadfad8b3"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bf7a982604375a8d49b6cc1b781c1747f243d91b81035a9b43a2126c04766f5"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:107c0cdefe028703fb5dafe640a409cb146d44a6ae201e55b35a4af8e95457dd"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:403c0911cd5d5791605808b942c88a8155c2592e05332d2bf78f18697a5fa15e"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aeaf541ddbad8311a87dd695ed9642401131ea39ad7bc8cf3ef3967fd093b626"}, + {file = "multidict-6.0.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4972624066095e52b569e02b5ca97dbd7a7ddd4294bf4e7247d52635630dd83"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d946b0a9eb8aaa590df1fe082cee553ceab173e6cb5b03239716338629c50c7a"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b55358304d7a73d7bdf5de62494aaf70bd33015831ffd98bc498b433dfe5b10c"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:a3145cb08d8625b2d3fee1b2d596a8766352979c9bffe5d7833e0503d0f0b5e5"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:d65f25da8e248202bd47445cec78e0025c0fe7582b23ec69c3b27a640dd7a8e3"}, + {file = "multidict-6.0.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c9bf56195c6bbd293340ea82eafd0071cb3d450c703d2c93afb89f93b8386ccc"}, + {file = "multidict-6.0.5-cp37-cp37m-win32.whl", hash = "sha256:69db76c09796b313331bb7048229e3bee7928eb62bab5e071e9f7fcc4879caee"}, + {file = "multidict-6.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:fce28b3c8a81b6b36dfac9feb1de115bab619b3c13905b419ec71d03a3fc1423"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76f067f5121dcecf0d63a67f29080b26c43c71a98b10c701b0677e4a065fbd54"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b82cc8ace10ab5bd93235dfaab2021c70637005e1ac787031f4d1da63d493c1d"}, + {file = "multidict-6.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5cb241881eefd96b46f89b1a056187ea8e9ba14ab88ba632e68d7a2ecb7aadf7"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e94e6912639a02ce173341ff62cc1201232ab86b8a8fcc05572741a5dc7d93"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09a892e4a9fb47331da06948690ae38eaa2426de97b4ccbfafbdcbe5c8f37ff8"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55205d03e8a598cfc688c71ca8ea5f66447164efff8869517f175ea632c7cb7b"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37b15024f864916b4951adb95d3a80c9431299080341ab9544ed148091b53f50"}, + {file = "multidict-6.0.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2a1dee728b52b33eebff5072817176c172050d44d67befd681609b4746e1c2e"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:edd08e6f2f1a390bf137080507e44ccc086353c8e98c657e666c017718561b89"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:60d698e8179a42ec85172d12f50b1668254628425a6bd611aba022257cac1386"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:3d25f19500588cbc47dc19081d78131c32637c25804df8414463ec908631e453"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:4cc0ef8b962ac7a5e62b9e826bd0cd5040e7d401bc45a6835910ed699037a461"}, + {file = "multidict-6.0.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:eca2e9d0cc5a889850e9bbd68e98314ada174ff6ccd1129500103df7a94a7a44"}, + {file = "multidict-6.0.5-cp38-cp38-win32.whl", hash = "sha256:4a6a4f196f08c58c59e0b8ef8ec441d12aee4125a7d4f4fef000ccb22f8d7241"}, + {file = "multidict-6.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:0275e35209c27a3f7951e1ce7aaf93ce0d163b28948444bec61dd7badc6d3f8c"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e7be68734bd8c9a513f2b0cfd508802d6609da068f40dc57d4e3494cefc92929"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1d9ea7a7e779d7a3561aade7d596649fbecfa5c08a7674b11b423783217933f9"}, + {file = "multidict-6.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ea1456df2a27c73ce51120fa2f519f1bea2f4a03a917f4a43c8707cf4cbbae1a"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf590b134eb70629e350691ecca88eac3e3b8b3c86992042fb82e3cb1830d5e1"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5c0631926c4f58e9a5ccce555ad7747d9a9f8b10619621f22f9635f069f6233e"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dce1c6912ab9ff5f179eaf6efe7365c1f425ed690b03341911bf4939ef2f3046"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0868d64af83169e4d4152ec612637a543f7a336e4a307b119e98042e852ad9c"}, + {file = "multidict-6.0.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:141b43360bfd3bdd75f15ed811850763555a251e38b2405967f8e25fb43f7d40"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7df704ca8cf4a073334e0427ae2345323613e4df18cc224f647f251e5e75a527"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:6214c5a5571802c33f80e6c84713b2c79e024995b9c5897f794b43e714daeec9"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:cd6c8fca38178e12c00418de737aef1261576bd1b6e8c6134d3e729a4e858b38"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:e02021f87a5b6932fa6ce916ca004c4d441509d33bbdbeca70d05dff5e9d2479"}, + {file = "multidict-6.0.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ebd8d160f91a764652d3e51ce0d2956b38efe37c9231cd82cfc0bed2e40b581c"}, + {file = "multidict-6.0.5-cp39-cp39-win32.whl", hash = "sha256:04da1bb8c8dbadf2a18a452639771951c662c5ad03aefe4884775454be322c9b"}, + {file = "multidict-6.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:d6f6d4f185481c9669b9447bf9d9cf3b95a0e9df9d169bbc17e363b7d5487755"}, + {file = "multidict-6.0.5-py3-none-any.whl", hash = "sha256:0d63c74e3d7ab26de115c49bffc92cc77ed23395303d496eae515d4204a625e7"}, + {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] [[package]] @@ -1350,18 +1366,18 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" @@ -1543,6 +1559,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1550,8 +1567,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1568,6 +1592,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1575,6 +1600,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -1835,74 +1861,6 @@ msgpack = ">=1.0.3" textual = ">=0.36.0" typing-extensions = ">=4.4.0,<5.0.0" -[[package]] -name = "time-machine" -version = "2.13.0" -description = "Travel through time in your tests." -optional = false -python-versions = ">=3.8" -files = [ - {file = "time_machine-2.13.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:685d98593f13649ad5e7ce3e58efe689feca1badcf618ba397d3ab877ee59326"}, - {file = "time_machine-2.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ccbce292380ebf63fb9a52e6b03d91677f6a003e0c11f77473efe3913a75f289"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:679cbf9b15bfde1654cf48124128d3fbe52f821fa158a98fcee5fe7e05db1917"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a26bdf3462d5f12a4c1009fdbe54366c6ef22c7b6f6808705b51dedaaeba8296"}, - {file = "time_machine-2.13.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dabb3b155819811b4602f7e9be936e2024e20dc99a90f103e36b45768badf9c3"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0db97f92be3efe0ac62fd3f933c91a78438cef13f283b6dfc2ee11123bfd7d8a"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:12eed2e9171c85b703d75c985dab2ecad4fe7025b7d2f842596fce1576238ece"}, - {file = "time_machine-2.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bdfe4a7f033e6783c3e9a7f8d8fc0b115367330762e00a03ff35fedf663994f3"}, - {file = "time_machine-2.13.0-cp310-cp310-win32.whl", hash = "sha256:3a7a0a49ce50d9c306c4343a7d6a3baa11092d4399a4af4355c615ccc321a9d3"}, - {file = "time_machine-2.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:1812e48c6c58707db9988445a219a908a710ea065b2cc808d9a50636291f27d4"}, - {file = "time_machine-2.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:5aee23cd046abf9caeddc982113e81ba9097a01f3972e9560f5ed64e3495f66d"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e9a9d150e098be3daee5c9f10859ab1bd14a61abebaed86e6d71f7f18c05b9d7"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2bd4169b808745d219a69094b3cb86006938d45e7293249694e6b7366225a186"}, - {file = "time_machine-2.13.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:8d526cdcaca06a496877cfe61cc6608df2c3a6fce210e076761964ebac7f77cc"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfef4ebfb4f055ce3ebc7b6c1c4d0dbfcffdca0e783ad8c6986c992915a57ed3"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f128db8997c3339f04f7f3946dd9bb2a83d15e0a40d35529774da1e9e501511"}, - {file = "time_machine-2.13.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21bef5854d49b62e2c33848b5c3e8acf22a3b46af803ef6ff19529949cb7cf9f"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:32b71e50b07f86916ac04bd1eefc2bd2c93706b81393748b08394509ee6585dc"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1ac8ff145c63cd0dcfd9590fe694b5269aacbc130298dc7209b095d101f8cdde"}, - {file = "time_machine-2.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:19a3b10161c91ca8e0fd79348665cca711fd2eac6ce336ff9e6b447783817f93"}, - {file = "time_machine-2.13.0-cp311-cp311-win32.whl", hash = "sha256:5f87787d562e42bf1006a87eb689814105b98c4d5545874a281280d0f8b9a2d9"}, - {file = "time_machine-2.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:62fd14a80b8b71726e07018628daaee0a2e00937625083f96f69ed6b8e3304c0"}, - {file = "time_machine-2.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:e9935aff447f5400a2665ab10ed2da972591713080e1befe1bb8954e7c0c7806"}, - {file = "time_machine-2.13.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:34dcdbbd25c1e124e17fe58050452960fd16a11f9d3476aaa87260e28ecca0fd"}, - {file = "time_machine-2.13.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:e58d82fe0e59d6e096ada3281d647a2e7420f7da5453b433b43880e1c2e8e0c5"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71acbc1febbe87532c7355eca3308c073d6e502ee4ce272b5028967847c8e063"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dec0ec2135a4e2a59623e40c31d6e8a8ae73305ade2634380e4263d815855750"}, - {file = "time_machine-2.13.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e3a2611f8788608ebbcb060a5e36b45911bc3b8adc421b1dc29d2c81786ce4d"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:42ef5349135626ad6cd889a0a81400137e5c6928502b0817ea9e90bb10702000"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:6c16d90a597a8c2d3ce22d6be2eb3e3f14786974c11b01886e51b3cf0d5edaf7"}, - {file = "time_machine-2.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4f2ae8d0e359b216b695f1e7e7256f208c390db0480601a439c5dd1e1e4e16ce"}, - {file = "time_machine-2.13.0-cp312-cp312-win32.whl", hash = "sha256:f5fa9610f7e73fff42806a2ed8b06d862aa59ce4d178a52181771d6939c3e237"}, - {file = "time_machine-2.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:02b33a8c19768c94f7ffd6aa6f9f64818e88afce23250016b28583929d20fb12"}, - {file = "time_machine-2.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:0cc116056a8a2a917a4eec85661dfadd411e0d8faae604ef6a0e19fe5cd57ef1"}, - {file = "time_machine-2.13.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:de01f33aa53da37530ad97dcd17e9affa25a8df4ab822506bb08101bab0c2673"}, - {file = "time_machine-2.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:67fa45cd813821e4f5bec0ac0820869e8e37430b15509d3f5fad74ba34b53852"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a2d3db2c3b8e519d5ef436cd405abd33542a7b7761fb05ef5a5f782a8ce0b1"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7558622a62243be866a7e7c41da48eacd82c874b015ecf67d18ebf65ca3f7436"}, - {file = "time_machine-2.13.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab04cf4e56e1ee65bee2adaa26a04695e92eb1ed1ccc65fbdafd0d114399595a"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b0c8f24ae611a58782773af34dd356f1f26756272c04be2be7ea73b47e5da37d"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ca20f85a973a4ca8b00cf466cd72c27ccc72372549b138fd48d7e70e5a190ab"}, - {file = "time_machine-2.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9fad549521c4c13bdb1e889b2855a86ec835780d534ffd8f091c2647863243be"}, - {file = "time_machine-2.13.0-cp38-cp38-win32.whl", hash = "sha256:20205422fcf2caf9a7488394587df86e5b54fdb315c1152094fbb63eec4e9304"}, - {file = "time_machine-2.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:2dc76ee55a7d915a55960a726ceaca7b9097f67e4b4e681ef89871bcf98f00be"}, - {file = "time_machine-2.13.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7693704c0f2f6b9beed912ff609781edf5fcf5d63aff30c92be4093e09d94b8e"}, - {file = "time_machine-2.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:918f8389de29b4f41317d121f1150176fae2cdb5fa41f68b2aee0b9dc88df5c3"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fe3fda5fa73fec74278912e438fce1612a79c36fd0cc323ea3dc2d5ce629f31"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6245db573863b335d9ca64b3230f623caf0988594ae554c0c794e7f80e3e66"}, - {file = "time_machine-2.13.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e433827eccd6700a34a2ab28fd9361ff6e4d4923f718d2d1dac6d1dcd9d54da6"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:924377d398b1c48e519ad86a71903f9f36117f69e68242c99fb762a2465f5ad2"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:66fb3877014dca0b9286b0f06fa74062357bd23f2d9d102d10e31e0f8fa9b324"}, - {file = "time_machine-2.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0c9829b2edfcf6b5d72a6ff330d4380f36a937088314c675531b43d3423dd8af"}, - {file = "time_machine-2.13.0-cp39-cp39-win32.whl", hash = "sha256:1a22be4df364f49a507af4ac9ea38108a0105f39da3f9c60dce62d6c6ea4ccdc"}, - {file = "time_machine-2.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:88601de1da06c7cab3d5ed3d5c3801ef683366e769e829e96383fdab6ae2fe42"}, - {file = "time_machine-2.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:3c87856105dcb25b5bbff031d99f06ef4d1c8380d096222e1bc63b496b5258e6"}, - {file = "time_machine-2.13.0.tar.gz", hash = "sha256:c23b2408e3adcedec84ea1131e238f0124a5bc0e491f60d1137ad7239b37c01a"}, -] - -[package.dependencies] -python-dateutil = "*" - [[package]] name = "toml" version = "0.10.2" @@ -2015,79 +1973,70 @@ setuptools = {version = ">=60.0.0", markers = "python_version >= \"3.12\""} [[package]] name = "tree-sitter-languages" -version = "1.9.1" +version = "1.10.2" description = "Binary Python wheels for all tree sitter languages." optional = true python-versions = "*" files = [ - {file = "tree_sitter_languages-1.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5dee458cf1bd1e725470949124e24db842dc789039ea7ff5ba46b338e5f0dc60"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81921135fa15469586b1528088f78553e60a900d3045f4f37021ad3836219216"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edd60780d14c727179acb7bb48fbe4f79da9b830abdeb0d12c06a9f2c37928c7"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a28da3f60a6bc23195d6850836e477c149d4aaf58cdb0eb662741dca4f6401e2"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9778c00a58ee77006abc5af905b591551b158ce106c8cc6c3b4148d624ccabf"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6f68cfec0d74d6344db9c83414f401dcfc753916e71fac7d37f3a5e35b79e5ec"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:02142d81b2cd759b5fe246d403e4fba80b70268d108bd2b108301e64a84437a6"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ca4e0041c2ead2a8b354b9c229faee152bfd4617480c85cf2b352acf459db3cc"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-win32.whl", hash = "sha256:506ff5c3646e7b3a533f9e925221d4fe63b88dad0b7ffc1fb96db4c271994606"}, - {file = "tree_sitter_languages-1.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:3ac3899e05f2bf0a7c8da70ef5a077ab3dbd442f99eb7452aabbe67bc7b29ddf"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823426c3768eea88b6a4fd70dc668b72de90cc9f44d041a579c76d024d7d0697"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:51f64b11f30cef3c5c9741e06221a46948f7c82d53ea2468139028eaf4858cca"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1c384bcd2695ebf873bc63eccfa0b9e1c3c944cd6a6ebdd1139a2528d2d6f"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fecf8553645fc1ad84921e97b03615d84aca22c35d020f629bb44cb6a28a302e"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1a499004189bf9f338f3412d4c1c05a643e86d4619a60ba4b3ae56bc4bf5db9"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:634ef22744b4af2ed9a43fea8309ec1171b062e37c609c3463364c790a08dae3"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9394eb34208abcfa9c26ece39778037a8d97da3ef59501185303fef0ab850290"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:221c367be0129da540fbb84170e18c5b8c56c09fd2f6143e116eebbef72c780e"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-win32.whl", hash = "sha256:15d03f54f913f47ac36277d8a521cd425415a25b020e0845d7b8843f5f5e1209"}, - {file = "tree_sitter_languages-1.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:7c565c18cebc72417ebc8f0f4cd5cb91dda51874164045cc274f47c913b194aa"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cde380cdc37594e7fcbade6a4b396dbeab52a1cecfe884cd814e1a1541ca6b93"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9c4f2409e5460bdec5921ee445f748ea7c319469e347a13373e3c7086dbf0315"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a17bbe91a78a29a9c14ab8bb07ed3761bb2708b58815bafc02d0965b15cb99e5"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:369402e2b395de2655d769e515401fe7c7df247a83aa28a6362e808b8a017fae"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4a382d1e463e6ae60bbbd0c1f3db48e83b3c1a3af98d652af11de4c0e6171fc"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bc60fb35f377143b30f4319fbaac0503b12cfb49de34082a479c7f0cc28927f1"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e953fb43767e327bf5c1d0585ee39236eaff47683cbda2811cbe0227fd41ad7"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c5a6df25eae23a5e2d448218b130207476cb8a613ac40570d49008243b0915bb"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-win32.whl", hash = "sha256:2720f9a639f5d5c17692135f3f2d60506c240699d0c1becdb895546e553f2339"}, - {file = "tree_sitter_languages-1.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:f19157c33ddc1e75ae7843b813e65575ed2040e1638643251bd603bb0f52046b"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:40880b5e774c3d5759b726273c36f83042d39c600c3aeefaf39248c3adec92d0"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad71366ee2458bda6df5a7476fc0e465a1e1579f53335ce901935efc5c67fdeb"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8000c6bf889e35e8b75407ea2d56153534b3f80c3b768378f4ca5a6fe286c0f"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7e20ead363d70b3f0f0b04cf6da30257d22a166700fa39e06c9f263b527688"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:444d2662912bc439c54c1b0ffe38354ae648f1f1ac8d1254b14fa768aa1a8587"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cceac9018359310fee46204b452860bfdcb3da00f4518d430790f909cbbf6b4c"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:332c182afbd9f7601e268426470e8c453740769a6227e7d1a9636d905cd7d707"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-win32.whl", hash = "sha256:25e993a41ad11fc433cb18ce0cc1d51eb7a285560c5cdddf781139312dac1881"}, - {file = "tree_sitter_languages-1.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:57419c215092ba9ba1964e07620dd386fc88ebb075b981fbb80f68f58004d4b4"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06747cac4789c436affa7c6b3483f68cc234e6a75b508a0f8369c77eb1faa04b"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b40bc82005543309c9cd4059f362c9d0d51277c942c71a5fdbed118389e5543a"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44920c9654ae03e94baa45c6e8c4b36a5f7bdd0c93877c72931bd77e862adaf1"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82e44f63a5449a41c5de3e9350967dc1c9183d9375881af5efb970c58c3fcfd8"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:df177fa87b655f6234e4dae540ba3917cf8e87c3646423b809415711e926765e"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:abdc8793328aa13fbd1cef3a0dff1c2e057a430fe2a64251628bbc97c4774eba"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8b3f319f95f4464c35381755422f6dc0a518ad7d295d3cfe57bbaa564d225f3f"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-win32.whl", hash = "sha256:9f3a59bb4e8ec0a598566e02b7900eb8142236bda6c8b1069c4f3cdaf641950d"}, - {file = "tree_sitter_languages-1.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:517bdfe34bf24a05a496d441bee836fa77a6864f256508b82457ac28a9ac36bc"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9383331026f736bcbdf6b67f9b45417fe8fbb47225fe2517a1e4f974c319d9a8"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bba45ff3715e20e6e9a9b402f1ec2f2fc5ce11ce7b223584d0b5be5a4f8c60bb"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03558927c6e731d81706e3a8b26276eaa4fadba17e2fd83a5e0bc2a32b261975"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f0231140e2d29fcf987216277483c93bc7ce4c2f88b8af77756d796e17a2957"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ead59b416f03da262df26e282cd40eb487f15384c90290f5105451e9a8ecfea"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fd27b7bdb95a2b35b730069d7dea60d0f6cc37e5ab2e900d2940a82d1db608bd"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d8b65a5fafd774a6c6dcacd9ac8b4c258c9f1efe2bfdca0a63818c83e591b949"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f32f7a7b8fd9952f82e2b881c1c8701a467b27db209590e0effb2fb4d71fe3d3"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-win32.whl", hash = "sha256:b52321e2a3a7cd1660cd7dadea16d7c7b9c981e177e0f77f9735e04cd89de015"}, - {file = "tree_sitter_languages-1.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8752bec9372937094a2557d9bfff357f30f5aa398e41e76e656baf53b4939d3"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:119f32cfc7c561e252e8958259ef997f2adfd4587ae43e82819b56f2810b8b42"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:582b04e11c67706b0a5ea64fd53ce4910fe11ad29d74ec7680c4014a02d09d4a"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a816f76c52f6c9fb3316c5d44195f8de48e09f2214b7fdb5f9232395033c789c"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a099b2f69cf22ab77de811b148de7d2d8ba8c51176a64bc56304cf42a627dd4"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:447b6c62c59255c89341ec0968e467e8c59c60fc5c2c3dc1f7dfe159a820dd3c"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:41f4fee9b7de9646ef9711b6dbcdd5a4e7079e3d175089c8ef3f2c68b5adb5f4"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ee3b70594b79ff1155d5d9fea64e3af240d9327a52526d446e6bd792ac5b43cf"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:087b82cc3943fc5ffac30dc1b4192936a27c3c06fbd8718935a269e30dedc83b"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-win32.whl", hash = "sha256:155483058dc11de302f47922d31feec5e1bb9888e661aed7be0dad6f70bfe691"}, - {file = "tree_sitter_languages-1.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:5335405a937f788a2608d1b25c654461dddddbc6a1341672c833d2c8943397a8"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5580348f0b20233b1d5431fa178ccd3d07423ca4a3275df02a44608fd72344b9"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:103c7466644486b1e9e03850df46fc6aa12f13ca636c74f173270276220ac80b"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d13db84511c6f1a7dc40383b66deafa74dabd8b877e3d65ab253f3719eccafd6"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57adfa32be7e465b54aa72f915f6c78a2b66b227df4f656b5d4fbd1ca7a92b3f"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c6385e033e460ceb8f33f3f940335f422ef2b763700a04f0089391a68b56153"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dfa3f38cc5381c5aba01dd7494f59b8a9050e82ff6e06e1233e3a0cbae297e3c"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:9f195155acf47f8bc5de7cee46ecd07b2f5697f007ba89435b51ef4c0b953ea5"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2de330e2ac6d7426ca025a3ec0f10d5640c3682c1d0c7702e812dcfb44b58120"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-win32.whl", hash = "sha256:c9731cf745f135d9770eeba9bb4e2ff4dabc107b5ae9b8211e919f6b9100ea6d"}, + {file = "tree_sitter_languages-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:6dd75851c41d0c3c4987a9b7692d90fa8848706c23115669d8224ffd6571e357"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7eb7d7542b2091c875fe52719209631fca36f8c10fa66970d2c576ae6a1b8289"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6b41bcb00974b1c8a1800c7f1bb476a1d15a0463e760ee24872f2d53b08ee424"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f370cd7845c6c81df05680d5bd96db8a99d32b56f4728c5d05978911130a853"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1dc195c88ef4c72607e112a809a69190e096a2e5ebc6201548b3e05fdd169ad"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae34ac314a7170be24998a0f994c1ac80761d8d4bd126af27ee53a023d3b849"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:01b5742d5f5bd675489486b582bd482215880b26dde042c067f8265a6e925d9c"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ab1cbc46244d34fd16f21edaa20231b2a57f09f092a06ee3d469f3117e6eb954"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0b1149e7467a4e92b8a70e6005fe762f880f493cf811fc003554b29f04f5e7c8"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-win32.whl", hash = "sha256:049276343962f4696390ee555acc2c1a65873270c66a6cbe5cb0bca83bcdf3c6"}, + {file = "tree_sitter_languages-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:7f3fdd468a577f04db3b63454d939e26e360229b53c80361920aa1ebf2cd7491"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c0f4c8b2734c45859edc7fcaaeaab97a074114111b5ba51ab4ec7ed52104763c"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eecd3c1244ac3425b7a82ba9125b4ddb45d953bbe61de114c0334fd89b7fe782"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15db3c8510bc39a80147ee7421bf4782c15c09581c1dc2237ea89cefbd95b846"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92c6487a6feea683154d3e06e6db68c30e0ae749a7ce4ce90b9e4e46b78c85c7"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2f1cd1d1bdd65332f9c2b67d49dcf148cf1ded752851d159ac3e5ee4f4d260"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:976c8039165b8e12f17a01ddee9f4e23ec6e352b165ad29b44d2bf04e2fbe77e"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:dafbbdf16bf668a580902e1620f4baa1913e79438abcce721a50647564c687b9"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1aeabd3d60d6d276b73cd8f3739d595b1299d123cc079a317f1a5b3c5461e2ca"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-win32.whl", hash = "sha256:fab8ee641914098e8933b87ea3d657bea4dd00723c1ee7038b847b12eeeef4f5"}, + {file = "tree_sitter_languages-1.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:5e606430d736367e5787fa5a7a0c5a1ec9b85eded0b3596bbc0d83532a40810b"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:838d5b48a7ed7a17658721952c77fda4570d2a069f933502653b17e15a9c39c9"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:987b3c71b1d278c2889e018ee77b8ee05c384e2e3334dec798f8b611c4ab2d1e"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:faa00abcb2c819027df58472da055d22fa7dfcb77c77413d8500c32ebe24d38b"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e102fbbf02322d9201a86a814e79a9734ac80679fdb9682144479044f401a73"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0b87cf1a7b03174ba18dfd81582be82bfed26803aebfe222bd20e444aba003"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c0f1b9af9cb67f0b942b020da9fdd000aad5e92f2383ae0ba7a330b318d31912"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5a4076c921f7a4d31e643843de7dfe040b65b63a238a5aa8d31d93aabe6572aa"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win32.whl", hash = "sha256:fa6391a3a5d83d32db80815161237b67d70576f090ce5f38339206e917a6f8bd"}, + {file = "tree_sitter_languages-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:55649d3f254585a064121513627cf9788c1cfdadbc5f097f33d5ba750685a4c0"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6f85d1edaa2d22d80d4ea5b6d12b95cf3644017b6c227d0d42854439e02e8893"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d78feed4a764ef3141cb54bf00fe94d514d8b6e26e09423e23b4c616fcb7938c"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da1aca27531f9dd5308637d76643372856f0f65d0d28677d1bcf4211e8ed1ad0"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1031ea440dafb72237437d754eff8940153a3b051e3d18932ac25e75ce060a15"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99d3249beaef2c9fe558ecc9a97853c260433a849dcc68266d9770d196c2e102"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:59a4450f262a55148fb7e68681522f0c2a2f6b7d89666312a2b32708d8f416e1"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ce74eab0e430370d5e15a96b6c6205f93405c177a8b2e71e1526643b2fb9bab1"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9b4dd2b6b3d24c85dffe33d6c343448869eaf4f41c19ddba662eb5d65d8808f4"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-win32.whl", hash = "sha256:92d734fb968fe3927a7596d9f0459f81a8fa7b07e16569476b28e27d0d753348"}, + {file = "tree_sitter_languages-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:46a13f7d38f2eeb75f7cf127d1201346093748c270d686131f0cbc50e42870a1"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f8c6a936ae99fdd8857e91f86c11c2f5e507ff30631d141d98132bb7ab2c8638"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c283a61423f49cdfa7b5a5dfbb39221e3bd126fca33479cd80749d4d7a6b7349"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76e60be6bdcff923386a54a5edcb6ff33fc38ab0118636a762024fa2bc98de55"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c00069f9575bd831eabcce2cdfab158dde1ed151e7e5614c2d985ff7d78a7de1"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:475ff53203d8a43ccb19bb322fa2fb200d764001cc037793f1fadd714bb343da"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26fe7c9c412e4141dea87ea4b3592fd12e385465b5bdab106b0d5125754d4f60"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8fed27319957458340f24fe14daad467cd45021da034eef583519f83113a8c5e"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3657a491a7f96cc75a3568ddd062d25f3be82b6a942c68801a7b226ff7130181"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-win32.whl", hash = "sha256:33f7d584d01a7a3c893072f34cfc64ec031f3cfe57eebc32da2f8ac046e101a7"}, + {file = "tree_sitter_languages-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:1b944af3ee729fa70fc8ae82224a9ff597cdb63addea084e0ea2fa2b0ec39bb7"}, ] [package.dependencies] @@ -2117,13 +2066,13 @@ files = [ [[package]] name = "types-tree-sitter-languages" -version = "1.8.0.0" +version = "1.10.0.20240201" description = "Typing stubs for tree-sitter-languages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "types-tree-sitter-languages-1.8.0.0.tar.gz", hash = "sha256:a066d1c91d5fe8b8fce08669816d9e8c41bbe348085b3cb9799fa74070a30604"}, - {file = "types_tree_sitter_languages-1.8.0.0-py3-none-any.whl", hash = "sha256:9d4a8e2a435a4a0d356e643fb53993e3c491749ce0b7a628c22cb87904c6daca"}, + {file = "types-tree-sitter-languages-1.10.0.20240201.tar.gz", hash = "sha256:10822bc9d2b98f7e8019a97f0233c68555d5c447ba4ef24284e93fd866ec73de"}, + {file = "types_tree_sitter_languages-1.10.0.20240201-py3-none-any.whl", hash = "sha256:3cb72f9df4c9b92a8710f0c1966de2d2295584b7cbea3194d0ba577fa50be56c"}, ] [package.dependencies] @@ -2167,17 +2116,18 @@ test = ["coverage", "pytest", "pytest-cov"] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.0-py3-none-any.whl", hash = "sha256:ce3711610ddce217e6d113a2732fafad960a03fd0318c91faa79481e35c11224"}, + {file = "urllib3-2.2.0.tar.gz", hash = "sha256:051d961ad0c62a94e50ecf1af379c3aba230c66c710493493560c0c223c49f20"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -2203,38 +2153,40 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [[package]] name = "watchdog" -version = "3.0.0" +version = "4.0.0" description = "Filesystem events monitoring" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"}, - {file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"}, - {file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"}, - {file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"}, - {file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"}, - {file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"}, - {file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"}, - {file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"}, - {file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"}, - {file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"}, - {file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"}, - {file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"}, - {file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"}, - {file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:39cb34b1f1afbf23e9562501673e7146777efe95da24fab5707b88f7fb11649b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c522392acc5e962bcac3b22b9592493ffd06d1fc5d755954e6be9f4990de932b"}, + {file = "watchdog-4.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6c47bdd680009b11c9ac382163e05ca43baf4127954c5f6d0250e7d772d2b80c"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8350d4055505412a426b6ad8c521bc7d367d1637a762c70fdd93a3a0d595990b"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c17d98799f32e3f55f181f19dd2021d762eb38fdd381b4a748b9f5a36738e935"}, + {file = "watchdog-4.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4986db5e8880b0e6b7cd52ba36255d4793bf5cdc95bd6264806c233173b1ec0b"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:11e12fafb13372e18ca1bbf12d50f593e7280646687463dd47730fd4f4d5d257"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5369136a6474678e02426bd984466343924d1df8e2fd94a9b443cb7e3aa20d19"}, + {file = "watchdog-4.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76ad8484379695f3fe46228962017a7e1337e9acadafed67eb20aabb175df98b"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:45cc09cc4c3b43fb10b59ef4d07318d9a3ecdbff03abd2e36e77b6dd9f9a5c85"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eed82cdf79cd7f0232e2fdc1ad05b06a5e102a43e331f7d041e5f0e0a34a51c4"}, + {file = "watchdog-4.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba30a896166f0fee83183cec913298151b73164160d965af2e93a20bbd2ab605"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d18d7f18a47de6863cd480734613502904611730f8def45fc52a5d97503e5101"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2895bf0518361a9728773083908801a376743bcc37dfa252b801af8fd281b1ca"}, + {file = "watchdog-4.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87e9df830022488e235dd601478c15ad73a0389628588ba0b028cb74eb72fed8"}, + {file = "watchdog-4.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6e949a8a94186bced05b6508faa61b7adacc911115664ccb1923b9ad1f1ccf7b"}, + {file = "watchdog-4.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6a4db54edea37d1058b08947c789a2354ee02972ed5d1e0dca9b0b820f4c7f92"}, + {file = "watchdog-4.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d31481ccf4694a8416b681544c23bd271f5a123162ab603c7d7d2dd7dd901a07"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8fec441f5adcf81dd240a5fe78e3d83767999771630b5ddfc5867827a34fa3d3"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:6a9c71a0b02985b4b0b6d14b875a6c86ddea2fdbebd0c9a720a806a8bbffc69f"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:557ba04c816d23ce98a06e70af6abaa0485f6d94994ec78a42b05d1c03dcbd50"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0f9bd1fd919134d459d8abf954f63886745f4660ef66480b9d753a7c9d40927"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:f9b2fdca47dc855516b2d66eef3c39f2672cbf7e7a42e7e67ad2cbfcd6ba107d"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:73c7a935e62033bd5e8f0da33a4dcb763da2361921a69a5a95aaf6c93aa03a87"}, + {file = "watchdog-4.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:6a80d5cae8c265842c7419c560b9961561556c4361b297b4c431903f8c33b269"}, + {file = "watchdog-4.0.0-py3-none-win32.whl", hash = "sha256:8f9a542c979df62098ae9c58b19e03ad3df1c9d8c6895d96c0d51da17b243b1c"}, + {file = "watchdog-4.0.0-py3-none-win_amd64.whl", hash = "sha256:f970663fa4f7e80401a7b0cbeec00fa801bf0287d93d48368fc3e6fa32716245"}, + {file = "watchdog-4.0.0-py3-none-win_ia64.whl", hash = "sha256:9a03e16e55465177d416699331b0f3564138f1807ecc5f2de9d55d8f188d08c7"}, + {file = "watchdog-4.0.0.tar.gz", hash = "sha256:e3e7065cbdabe6183ab82199d7a4f6b3ba0a438c5a512a68559846ccb76a78ec"}, ] [package.extras] @@ -2364,4 +2316,4 @@ syntax = ["tree-sitter", "tree_sitter_languages"] [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "e82a92dbbd8916df2ee09402ca7c4bc4c8dddb713e2723dc6eb144b373c99d1c" +content-hash = "014186a223d4236fb0ace86bef24fb030d6921cf714a8b4d2b889ba066a26375" diff --git a/pyproject.toml b/pyproject.toml index 71c101fcb1..f8b4180db7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,7 +62,6 @@ mkdocstrings-python = "0.10.1" mkdocs-material = "^9.0.11" mkdocs-exclude = "^1.0.2" pre-commit = "^2.13.0" -time-machine = "^2.6.0" mkdocs-rss-plugin = "^1.5.0" httpx = "^0.23.1" types-setuptools = "^67.2.0.1" diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py index afd4348e12..3a4e5729b2 100644 --- a/src/textual/document/_document.py +++ b/src/textual/document/_document.py @@ -52,7 +52,7 @@ def _detect_newline_style(text: str) -> Newline: text: The text to inspect. Returns: - The NewlineStyle used in the file. + The Newline used in the file. """ if "\r\n" in text: # Windows newline return "\r\n" diff --git a/src/textual/document/_edit.py b/src/textual/document/_edit.py new file mode 100644 index 0000000000..d0bd5ad83d --- /dev/null +++ b/src/textual/document/_edit.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from textual.document._document import EditResult, Location, Selection + +if TYPE_CHECKING: + from textual.widgets import TextArea + + +@dataclass +class Edit: + """Implements the Undoable protocol to replace text at some range within a document.""" + + text: str + """The text to insert. An empty string is equivalent to deletion.""" + + from_location: Location + """The start location of the insert.""" + + to_location: Location + """The end location of the insert""" + + maintain_selection_offset: bool + """If True, the selection will maintain its offset to the replacement range.""" + + _original_selection: Selection | None = field(init=False, default=None) + """The Selection when the edit was originally performed, to be restored on undo.""" + + _updated_selection: Selection | None = field(init=False, default=None) + """Where the selection should move to after the replace happens.""" + + _edit_result: EditResult | None = field(init=False, default=None) + """The result of doing the edit.""" + + def do(self, text_area: TextArea, record_selection: bool = True) -> EditResult: + """Perform the edit operation. + + Args: + text_area: The `TextArea` to perform the edit on. + record_selection: If True, record the current selection in the TextArea + so that it may be restored if this Edit is undone in the future. + + Returns: + An `EditResult` containing information about the replace operation. + """ + if record_selection: + self._original_selection = text_area.selection + + text = self.text + + # This code is mostly handling how we adjust TextArea.selection + # when an edit is made to the document programmatically. + # We want a user who is typing away to maintain their relative + # position in the document even if an insert happens before + # their cursor position. + + edit_bottom_row, edit_bottom_column = self.bottom + + selection_start, selection_end = text_area.selection + selection_start_row, selection_start_column = selection_start + selection_end_row, selection_end_column = selection_end + + edit_result = text_area.document.replace_range(self.top, self.bottom, text) + + new_edit_to_row, new_edit_to_column = edit_result.end_location + + column_offset = new_edit_to_column - edit_bottom_column + target_selection_start_column = ( + selection_start_column + column_offset + if edit_bottom_row == selection_start_row + and edit_bottom_column <= selection_start_column + else selection_start_column + ) + target_selection_end_column = ( + selection_end_column + column_offset + if edit_bottom_row == selection_end_row + and edit_bottom_column <= selection_end_column + else selection_end_column + ) + + row_offset = new_edit_to_row - edit_bottom_row + target_selection_start_row = selection_start_row + row_offset + target_selection_end_row = selection_end_row + row_offset + + if self.maintain_selection_offset: + self._updated_selection = Selection( + start=(target_selection_start_row, target_selection_start_column), + end=(target_selection_end_row, target_selection_end_column), + ) + else: + self._updated_selection = Selection.cursor(edit_result.end_location) + + self._edit_result = edit_result + return edit_result + + def undo(self, text_area: TextArea) -> EditResult: + """Undo the edit operation. + + Looks at the data stored in the edit, and performs the inverse operation of `Edit.do`. + + Args: + text_area: The `TextArea` to undo the insert operation on. + + Returns: + An `EditResult` containing information about the replace operation. + """ + replaced_text = self._edit_result.replaced_text + edit_end = self._edit_result.end_location + + # Replace the span of the edit with the text that was originally there. + undo_edit_result = text_area.document.replace_range( + self.top, edit_end, replaced_text + ) + self._updated_selection = self._original_selection + + return undo_edit_result + + def after(self, text_area: TextArea) -> None: + """Hook for running code after an Edit has been performed via `Edit.do` *and* + side effects such as re-wrapping the document and refreshing the display + have completed. + + For example, we can't record cursor visual offset until we know where the cursor will + land *after* wrapping has been performed, so we must wait until here to do it. + + Args: + text_area: The `TextArea` this operation was performed on. + """ + if self._updated_selection is not None: + text_area.selection = self._updated_selection + text_area.record_cursor_width() + + @property + def top(self) -> Location: + """The Location impacted by this edit that is nearest the start of the document.""" + return min([self.from_location, self.to_location]) + + @property + def bottom(self) -> Location: + """The Location impacted by this edit that is nearest the end of the document.""" + return max([self.from_location, self.to_location]) diff --git a/src/textual/document/_history.py b/src/textual/document/_history.py new file mode 100644 index 0000000000..c779fdd7ab --- /dev/null +++ b/src/textual/document/_history.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass, field + +from textual.document._edit import Edit + + +class HistoryException(Exception): + """Indicates misuse of the EditHistory API. + + For example, trying to undo() an Edit that has yet to be done. + """ + + +@dataclass +class EditHistory: + """Manages batching/checkpointing of Edits into groups that can be undone/redone in the TextArea.""" + + max_checkpoints: int + + checkpoint_timer: float + """Maximum number of seconds since last edit until a new batch is created.""" + + checkpoint_max_characters: int + """Maximum number of characters that can appear in a batch before a new batch is formed.""" + + _last_edit_time: float = field(init=False, default_factory=time.monotonic) + + _character_count: int = field(init=False, default=0) + """Track number of characters replaced + inserted since last batch creation.""" + + _force_end_batch: bool = field(init=False, default=False) + """Flag to force the creation of a new batch for the next recorded edit.""" + + _previously_replaced: bool = field(init=False, default=False) + """Records whether the most recent edit was a replacement or a pure insertion. + + If an edit removes any text from the document at all, it's considered a replacement. + Every other edit is considered a pure insertion. + """ + + def __post_init__(self) -> None: + self._undo_stack: deque[list[Edit]] = deque(maxlen=self.max_checkpoints) + """Batching Edit operations together (edits are simply grouped together in lists).""" + self._redo_stack: deque[list[Edit]] = deque() + """Stores batches that have been undone, allowing them to be redone.""" + + def record(self, edit: Edit) -> None: + """Record an Edit so that it may be undone and redone. + + Determines whether to batch the Edit with previous Edits, or create a new batch/checkpoint. + + This method must be called exactly once per edit, in chronological order. + + A new batch/checkpoint is created when: + + - The undo stack is empty. + - The checkpoint timer expires. + - The maximum number of characters permitted in a checkpoint is reached. + - A redo is performed (we should not add new edits to a batch that has been redone). + - The programmer has requested a new batch via a call to `force_new_batch`. + - e.g. the TextArea widget may call this method in some circumstances. + - Clicking to move the cursor elsewhere in the document should create a new batch. + - Movement of the cursor via a keyboard action that is NOT an edit. + - Blurring the TextArea creates a new checkpoint. + - The current edit involves a deletion/replacement and the previous edit did not. + - The current edit is a pure insertion and the previous edit was not. + - The edit involves insertion or deletion of one or more newline characters. + - An edit which inserts more than a single character (a paste) gets an isolated batch. + + Args: + edit: The edit to record. + """ + edit_result = edit._edit_result + if edit_result is None: + raise HistoryException( + "Cannot add an edit to history before it has been performed using `Edit.do`." + ) + + if edit.text == "" and edit_result.replaced_text == "": + return None + + is_replacement = bool(edit_result.replaced_text) + undo_stack = self._undo_stack + current_time = self._get_time() + edit_characters = len(edit.text) + contains_newline = "\n" in edit.text or "\n" in edit_result.replaced_text + + # Determine whether to create a new batch, or add to the latest batch. + if ( + not undo_stack + or self._force_end_batch + or edit_characters > 1 + or contains_newline + or is_replacement != self._previously_replaced + or current_time - self._last_edit_time > self.checkpoint_timer + or self._character_count + edit_characters > self.checkpoint_max_characters + ): + # Create a new batch (creating a "checkpoint"). + undo_stack.append([edit]) + self._character_count = edit_characters + self._last_edit_time = current_time + self._force_end_batch = False + else: + # Update the latest batch. + undo_stack[-1].append(edit) + self._character_count += edit_characters + self._last_edit_time = current_time + + self._previously_replaced = is_replacement + self._redo_stack.clear() + + # For some edits, we want to ensure the NEXT edit cannot be added to its batch, + # so enforce a checkpoint now. + if contains_newline or edit_characters > 1: + self.checkpoint() + + def _pop_undo(self) -> list[Edit] | None: + """Pop the latest batch from the undo stack and return it. + + This will also place it on the redo stack. + + Returns: + The batch of Edits from the top of the undo stack or None if it's empty. + """ + undo_stack = self._undo_stack + redo_stack = self._redo_stack + if undo_stack: + batch = undo_stack.pop() + redo_stack.append(batch) + return batch + return None + + def _pop_redo(self) -> list[Edit] | None: + """Redo the latest batch on the redo stack and return it. + + This will also place it on the undo stack (with a forced checkpoint to ensure + this undo does not get batched with other edits). + + Returns: + The batch of Edits from the top of the redo stack or None if it's empty. + """ + undo_stack = self._undo_stack + redo_stack = self._redo_stack + if redo_stack: + batch = redo_stack.pop() + undo_stack.append(batch) + # Ensure edits which follow cannot be added to the redone batch. + self.checkpoint() + return batch + return None + + def clear(self) -> None: + """Completely clear the history.""" + self._undo_stack.clear() + self._redo_stack.clear() + self._last_edit_time = time.monotonic() + self._force_end_batch = False + self._previously_replaced = False + + def checkpoint(self) -> None: + """Ensure the next recorded edit starts a new batch.""" + self._force_end_batch = True + + @property + def undo_stack(self) -> list[list[Edit]]: + """A copy of the undo stack, with references to the original Edits.""" + return list(self._undo_stack) + + @property + def redo_stack(self) -> list[list[Edit]]: + """A copy of the redo stack, with references to the original Edits.""" + return list(self._redo_stack) + + def _get_time(self) -> float: + """Get the time from the monotonic clock. + + Returns: + The result of `time.monotonic()` as a float. + """ + return time.monotonic() diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d8b694797d..245af7938f 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -606,7 +606,7 @@ def __init__( classes: str | None = None, disabled: bool = False, ) -> None: - """Initialises a widget to display tabular data. + """Initializes a widget to display tabular data. Args: show_header: Whether the table header should be visible or not. @@ -662,7 +662,7 @@ def __init__( RowCacheKey, tuple[SegmentLines, SegmentLines] ] = LRUCache(1000) """For each row (a row can have a height of multiple lines), we maintain a - cache of the fixed and scrollable lines within that row to minimise how often + cache of the fixed and scrollable lines within that row to minimize how often we need to re-render it. """ self._cell_render_cache: LRUCache[CellCacheKey, SegmentLines] = LRUCache(10000) """Cache for individual cells.""" diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 085ce16a3f..40b12f1768 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -3,14 +3,14 @@ import dataclasses import re from collections import defaultdict -from dataclasses import dataclass, field +from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional, Tuple +from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple from rich.style import Style from rich.text import Text -from typing_extensions import Literal, Protocol, runtime_checkable +from typing_extensions import Literal from textual._text_area_theme import TextAreaTheme from textual._tree_sitter import TREE_SITTER @@ -24,6 +24,8 @@ _utf8_encode, ) from textual.document._document_navigator import DocumentNavigator +from textual.document._edit import Edit +from textual.document._history import EditHistory from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import ( SyntaxAwareDocument, @@ -355,9 +357,10 @@ def __init__( language: str | None = None, theme: str | None = None, soft_wrap: bool = True, + tab_behavior: Literal["focus", "indent"] = "focus", read_only: bool = False, - tab_behaviour: Literal["focus", "indent"] = "focus", show_line_numbers: bool = False, + max_checkpoints: int = 50, name: str | None = None, id: str | None = None, classes: str | None = None, @@ -370,10 +373,10 @@ def __init__( language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. + tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. read_only: Enable read-only mode. This prevents edits using the keyboard. - tab_behaviour: If 'focus', pressing tab will switch focus. - If 'indent', pressing tab will insert a tab. show_line_numbers: Show line numbers on the left edge. + max_checkpoints: The maximum number of undo history checkpoints to retain. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. classes: One or more Textual CSS compatible class names separated by spaces. @@ -394,7 +397,11 @@ def __init__( self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)") """Compiled regular expression for what we consider to be a 'word'.""" - self._undo_stack: list[Undoable] = [] + self.history: EditHistory = EditHistory( + max_checkpoints=max_checkpoints, + checkpoint_timer=2.0, + checkpoint_max_characters=100, + ) """A stack (the end of the list is the top of the stack) for tracking edits.""" self._selecting = False @@ -439,7 +446,7 @@ def __init__( self.set_reactive(TextArea.read_only, read_only) self.set_reactive(TextArea.show_line_numbers, show_line_numbers) - self.tab_behaviour = tab_behaviour + self.tab_behavior = tab_behavior # When `app.dark` is toggled, reset the theme (since it caches values). self.watch(self.app, "dark", self._app_dark_toggled, init=False) @@ -452,7 +459,7 @@ def code_editor( language: str | None = None, theme: str | None = "monokai", soft_wrap: bool = False, - tab_behaviour: Literal["focus", "indent"] = "indent", + tab_behavior: Literal["focus", "indent"] = "indent", show_line_numbers: bool = True, name: str | None = None, id: str | None = None, @@ -462,14 +469,14 @@ def code_editor( """Construct a new `TextArea` with sensible defaults for editing code. This instantiates a `TextArea` with line numbers enabled, soft wrapping - disabled, and "indent" tab behaviour. + disabled, "indent" tab behavior, and the "monokai" theme. Args: text: The initial text to load into the TextArea. language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. - tab_behaviour: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. show_line_numbers: Show line numbers on the left edge. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. @@ -481,7 +488,7 @@ def code_editor( language=language, theme=theme, soft_wrap=soft_wrap, - tab_behaviour=tab_behaviour, + tab_behavior=tab_behavior, show_line_numbers=show_line_numbers, name=name, id=id, @@ -544,6 +551,7 @@ def _watch_has_focus(self, focus: bool) -> None: if focus: self._restart_blink() self.app.cursor_position = self.cursor_screen_offset + self.history.checkpoint() else: self._pause_blink(visible=False) @@ -870,11 +878,12 @@ def _watch_scroll_y(self) -> None: def load_text(self, text: str) -> None: """Load text into the TextArea. - This will replace the text currently in the TextArea. + This will replace the text currently in the TextArea and clear the edit history. Args: text: The text to load into the TextArea. """ + self.history.clear() self._set_document(text, self.language) def _on_resize(self) -> None: @@ -1189,6 +1198,8 @@ def text(self) -> str: def text(self, value: str) -> None: """Replace the text currently in the TextArea. This is an alias of `load_text`. + Setting this value will clear the edit history. + Args: value: The text to load into the TextArea. """ @@ -1225,6 +1236,7 @@ def edit(self, edit: Edit) -> EditResult: """ old_gutter_width = self.gutter_width result = edit.do(self) + self.history.record(edit) new_gutter_width = self.gutter_width if old_gutter_width != new_gutter_width: @@ -1242,6 +1254,108 @@ def edit(self, edit: Edit) -> EditResult: self.post_message(self.Changed(self)) return result + def undo(self) -> None: + """Undo the edits since the last checkpoint (the most recent batch of edits).""" + edits = self.history._pop_undo() + self._undo_batch(edits) + + def redo(self) -> None: + """Redo the most recently undone batch of edits.""" + edits = self.history._pop_redo() + self._redo_batch(edits) + + def _undo_batch(self, edits: Sequence[Edit]) -> None: + """Undo a batch of Edits. + + The sequence must be chronologically ordered by edit time. + + There must be no edits missing from the sequence, or the resulting content + will be incorrect. + + Args: + edits: The edits to undo, in the order they were originally performed. + """ + if not edits: + return + + old_gutter_width = self.gutter_width + minimum_from = edits[-1].from_location + maximum_old_end = (0, 0) + maximum_new_end = (0, 0) + for edit in reversed(edits): + edit.undo(self) + end_location = ( + edit._edit_result.end_location if edit._edit_result else (0, 0) + ) + if edit.from_location < minimum_from: + minimum_from = edit.from_location + if end_location > maximum_old_end: + maximum_old_end = end_location + if edit.to_location > maximum_new_end: + maximum_new_end = edit.to_location + + new_gutter_width = self.gutter_width + if old_gutter_width != new_gutter_width: + self.wrapped_document.wrap(self.wrap_width, self.indent_width) + else: + self.wrapped_document.wrap_range( + minimum_from, maximum_old_end, maximum_new_end + ) + + self._refresh_size() + for edit in reversed(edits): + edit.after(self) + self._build_highlight_map() + self.post_message(self.Changed(self)) + + def _redo_batch(self, edits: Sequence[Edit]) -> None: + """Redo a batch of Edits in order. + + The sequence must be chronologically ordered by edit time. + + Edits are applied from the start of the sequence to the end. + + There must be no edits missing from the sequence, or the resulting content + will be incorrect. + + Args: + edits: The edits to redo. + """ + if not edits: + return + + old_gutter_width = self.gutter_width + minimum_from = edits[0].from_location + maximum_old_end = (0, 0) + maximum_new_end = (0, 0) + for edit in edits: + edit.do(self, record_selection=False) + end_location = ( + edit._edit_result.end_location if edit._edit_result else (0, 0) + ) + if edit.from_location < minimum_from: + minimum_from = edit.from_location + if end_location > maximum_new_end: + maximum_new_end = end_location + if edit.to_location > maximum_old_end: + maximum_old_end = edit.to_location + + new_gutter_width = self.gutter_width + if old_gutter_width != new_gutter_width: + self.wrapped_document.wrap(self.wrap_width, self.indent_width) + else: + self.wrapped_document.wrap_range( + minimum_from, + maximum_old_end, + maximum_new_end, + ) + + self._refresh_size() + for edit in edits: + edit.after(self) + self._build_highlight_map() + self.post_message(self.Changed(self)) + async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" self._restart_blink() @@ -1252,7 +1366,7 @@ async def _on_key(self, event: events.Key) -> None: insert_values = { "enter": "\n", } - if self.tab_behaviour == "indent": + if self.tab_behavior == "indent": if key == "escape": event.stop() event.prevent_default() @@ -1363,6 +1477,7 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None: # TextArea widget while selecting, the widget still scrolls. self.capture_mouse() self._pause_blink(visible=True) + self.history.checkpoint() async def _on_mouse_move(self, event: events.MouseMove) -> None: """Handles click and drag to expand and contract the selection.""" @@ -1474,6 +1589,8 @@ def move_cursor( if center: self.scroll_cursor_visible(center) + self.history.checkpoint() + def move_cursor_relative( self, rows: int = 0, @@ -2038,143 +2155,6 @@ def action_delete_word_right(self) -> None: self._delete_via_keyboard(end, to_location) -@dataclass -class Edit: - """Implements the Undoable protocol to replace text at some range within a document.""" - - text: str - """The text to insert. An empty string is equivalent to deletion.""" - from_location: Location - """The start location of the insert.""" - to_location: Location - """The end location of the insert""" - maintain_selection_offset: bool - """If True, the selection will maintain its offset to the replacement range.""" - _updated_selection: Selection | None = field(init=False, default=None) - """Where the selection should move to after the replace happens.""" - - def do(self, text_area: TextArea) -> EditResult: - """Perform the edit operation. - - Args: - text_area: The `TextArea` to perform the edit on. - - Returns: - An `EditResult` containing information about the replace operation. - """ - text = self.text - - edit_from = self.from_location - edit_to = self.to_location - - # This code is mostly handling how we adjust TextArea.selection - # when an edit is made to the document programmatically. - # We want a user who is typing away to maintain their relative - # position in the document even if an insert happens before - # their cursor position. - - edit_bottom_row, edit_bottom_column = self.bottom - - selection_start, selection_end = text_area.selection - selection_start_row, selection_start_column = selection_start - selection_end_row, selection_end_column = selection_end - - replace_result = text_area.document.replace_range(self.top, self.bottom, text) - - new_edit_to_row, new_edit_to_column = replace_result.end_location - - # TODO: We could maybe improve the situation where the selection - # and the edit range overlap with each other. - column_offset = new_edit_to_column - edit_bottom_column - target_selection_start_column = ( - selection_start_column + column_offset - if edit_bottom_row == selection_start_row - and edit_bottom_column <= selection_start_column - else selection_start_column - ) - target_selection_end_column = ( - selection_end_column + column_offset - if edit_bottom_row == selection_end_row - and edit_bottom_column <= selection_end_column - else selection_end_column - ) - - row_offset = new_edit_to_row - edit_bottom_row - target_selection_start_row = selection_start_row + row_offset - target_selection_end_row = selection_end_row + row_offset - - if self.maintain_selection_offset: - self._updated_selection = Selection( - start=(target_selection_start_row, target_selection_start_column), - end=(target_selection_end_row, target_selection_end_column), - ) - else: - self._updated_selection = Selection.cursor(replace_result.end_location) - - return replace_result - - def undo(self, text_area: TextArea) -> EditResult: - """Undo the edit operation. - - Args: - text_area: The `TextArea` to undo the insert operation on. - - Returns: - An `EditResult` containing information about the replace operation. - """ - raise NotImplementedError() - - def after(self, text_area: TextArea) -> None: - """Possibly update the cursor location after the widget has been refreshed. - - Args: - text_area: The `TextArea` this operation was performed on. - """ - if self._updated_selection is not None: - text_area.selection = self._updated_selection - text_area.record_cursor_width() - - @property - def top(self) -> Location: - """The Location impacted by this edit that is nearest the start of the document.""" - return min([self.from_location, self.to_location]) - - @property - def bottom(self) -> Location: - """The Location impacted by this edit that is nearest the end of the document.""" - return max([self.from_location, self.to_location]) - - -@runtime_checkable -class Undoable(Protocol): - """Protocol for actions performed in the text editor which can be done and undone. - - These are typically actions which affect the document (e.g. inserting and deleting - text), but they can really be anything. - - To perform an edit operation, pass the Edit to `TextArea.edit()`""" - - def do(self, text_area: TextArea) -> Any: - """Do the action. - - Args: - The `TextArea` to perform the action on. - - Returns: - Anything. This protocol doesn't prescribe what is returned. - """ - - def undo(self, text_area: TextArea) -> Any: - """Undo the action. - - Args: - The `TextArea` to perform the action on. - - Returns: - Anything. This protocol doesn't prescribe what is returned. - """ - - @lru_cache(maxsize=128) def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: """Build a mapping of utf-8 byte offsets to codepoint offsets for the given data. diff --git a/src/textual/widgets/text_area.py b/src/textual/widgets/text_area.py index 10683d4b09..058f823542 100644 --- a/src/textual/widgets/text_area.py +++ b/src/textual/widgets/text_area.py @@ -7,11 +7,12 @@ Selection, ) from textual.document._document_navigator import DocumentNavigator +from textual.document._edit import Edit +from textual.document._history import EditHistory from textual.document._languages import BUILTIN_LANGUAGES from textual.document._syntax_aware_document import SyntaxAwareDocument from textual.document._wrapped_document import WrappedDocument from textual.widgets._text_area import ( - Edit, EndColumn, Highlight, HighlightName, @@ -27,6 +28,7 @@ "DocumentNavigator", "Edit", "EditResult", + "EditHistory", "EndColumn", "Highlight", "HighlightName", diff --git a/tests/text_area/test_escape_binding.py b/tests/text_area/test_escape_binding.py index bc644d3085..8d837e5c75 100644 --- a/tests/text_area/test_escape_binding.py +++ b/tests/text_area/test_escape_binding.py @@ -8,7 +8,7 @@ class TextAreaDialog(ModalScreen): def compose(self) -> ComposeResult: yield TextArea( - tab_behaviour="focus", # the default + tab_behavior="focus", # the default ) yield Button("Submit") @@ -18,10 +18,10 @@ def on_mount(self) -> None: self.push_screen(TextAreaDialog()) -async def test_escape_key_when_tab_behaviour_is_focus(): +async def test_escape_key_when_tab_behavior_is_focus(): """Regression test for https://github.com/Textualize/textual/issues/4110 - When the `tab_behaviour` of TextArea is the default to shift focus, + When the `tab_behavior` of TextArea is the default to shift focus, pressing should not shift focus but instead skip and allow any parent bindings to run. """ @@ -37,8 +37,8 @@ async def test_escape_key_when_tab_behaviour_is_focus(): assert not isinstance(pilot.app.screen, TextAreaDialog) -async def test_escape_key_when_tab_behaviour_is_indent(): - """When the `tab_behaviour` of TextArea is indent rather than switch focus, +async def test_escape_key_when_tab_behavior_is_indent(): + """When the `tab_behavior` of TextArea is indent rather than switch focus, pressing should instead shift focus. """ @@ -48,7 +48,7 @@ async def test_escape_key_when_tab_behaviour_is_indent(): assert isinstance(pilot.app.screen, TextAreaDialog) assert isinstance(pilot.app.focused, TextArea) - pilot.app.query_one(TextArea).tab_behaviour = "indent" + pilot.app.query_one(TextArea).tab_behavior = "indent" # Pressing escape should focus the button, not dismiss the dialog screen await pilot.press("escape") assert isinstance(pilot.app.screen, TextAreaDialog) diff --git a/tests/text_area/test_history.py b/tests/text_area/test_history.py new file mode 100644 index 0000000000..b6a30e4132 --- /dev/null +++ b/tests/text_area/test_history.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import dataclasses + +import pytest + +from textual.app import App, ComposeResult +from textual.events import Paste +from textual.pilot import Pilot +from textual.widgets import TextArea +from textual.widgets.text_area import EditHistory, Selection + +MAX_CHECKPOINTS = 5 +SIMPLE_TEXT = """\ +ABCDE +FGHIJ +KLMNO +PQRST +UVWXY +Z +""" + + +@dataclasses.dataclass +class TimeMockableEditHistory(EditHistory): + mock_time: float | None = dataclasses.field(default=None, init=False) + + def _get_time(self) -> float: + """Return the mocked time if it is set, otherwise use default behaviour.""" + if self.mock_time is None: + return super()._get_time() + return self.mock_time + + +class TextAreaApp(App): + def compose(self) -> ComposeResult: + text_area = TextArea() + # Update the history object to a version that supports mocking the time. + text_area.history = TimeMockableEditHistory( + max_checkpoints=MAX_CHECKPOINTS, + checkpoint_timer=2.0, + checkpoint_max_characters=100, + ) + self.text_area = text_area + yield text_area + + +@pytest.fixture +async def pilot(): + app = TextAreaApp() + async with app.run_test() as pilot: + yield pilot + + +@pytest.fixture +async def text_area(pilot): + return pilot.app.text_area + + +async def test_simple_undo_redo(pilot, text_area: TextArea): + text_area.insert("123", (0, 0)) + + assert text_area.text == "123" + text_area.undo() + assert text_area.text == "" + text_area.redo() + assert text_area.text == "123" + + +async def test_undo_selection_retained(pilot: Pilot, text_area: TextArea): + # Select a range of text and press backspace. + text_area.text = SIMPLE_TEXT + text_area.selection = Selection((0, 0), (2, 3)) + await pilot.press("backspace") + assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n" + assert text_area.selection == Selection.cursor((0, 0)) + + # Undo the deletion - the text comes back, and the selection is restored. + text_area.undo() + assert text_area.selection == Selection((0, 0), (2, 3)) + assert text_area.text == SIMPLE_TEXT + + # Redo the deletion - the text is gone again. The selection goes to the post-delete location. + text_area.redo() + assert text_area.text == "NO\nPQRST\nUVWXY\nZ\n" + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_undo_checkpoint_created_on_cursor_move( + pilot: Pilot, text_area: TextArea +): + text_area.text = SIMPLE_TEXT + # Characters are inserted on line 0 and 1. + checkpoint_one = text_area.text + checkpoint_one_selection = text_area.selection + await pilot.press("1") # Added to initial batch. + + # This cursor movement ensures a new checkpoint is created. + post_insert_one_location = text_area.selection + await pilot.press("down") + + checkpoint_two = text_area.text + checkpoint_two_selection = text_area.selection + await pilot.press("2") # Added to new batch. + + checkpoint_three = text_area.text + checkpoint_three_selection = text_area.selection + + # Going back to checkpoint two + text_area.undo() + assert text_area.text == checkpoint_two + assert text_area.selection == checkpoint_two_selection + + # Back again to checkpoint one (initial state) + text_area.undo() + assert text_area.text == checkpoint_one + assert text_area.selection == checkpoint_one_selection + + # Redo to move forward to checkpoint two. + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == post_insert_one_location + + # Redo to move forward to checkpoint three. + text_area.redo() + assert text_area.text == checkpoint_three + assert text_area.selection == checkpoint_three_selection + + +async def test_setting_text_property_resets_history(pilot: Pilot, text_area: TextArea): + await pilot.press("1") + + # Programmatically setting text, which should invalidate the history + text = "Hello, world!" + text_area.text = text + + # The undo doesn't do anything, since we set the `text` property. + text_area.undo() + assert text_area.text == text + + +async def test_edits_batched_by_time(pilot: Pilot, text_area: TextArea): + # The first "12" is batched since they happen within 2 seconds. + text_area.history.mock_time = 0 + await pilot.press("1") + + text_area.history.mock_time = 1.0 + await pilot.press("2") + + # Since "3" appears 10 seconds later, it's in a separate batch. + text_area.history.mock_time += 10.0 + await pilot.press("3") + + assert text_area.text == "123" + + text_area.undo() + assert text_area.text == "12" + + text_area.undo() + assert text_area.text == "" + + +async def test_undo_checkpoint_character_limit_reached( + pilot: Pilot, text_area: TextArea +): + await pilot.press("1") + # Since the insertion below is > 100 characters it goes to a new batch. + text_area.insert("2" * 120) + + text_area.undo() + assert text_area.text == "1" + text_area.undo() + assert text_area.text == "" + + +async def test_redo_with_no_undo_is_noop(text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.redo() + assert text_area.text == SIMPLE_TEXT + + +async def test_undo_with_empty_undo_stack_is_noop(text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.undo() + assert text_area.text == SIMPLE_TEXT + + +async def test_redo_stack_cleared_on_edit(pilot: Pilot, text_area: TextArea): + text_area.text = "" + await pilot.press("1") + text_area.history.checkpoint() + await pilot.press("2") + text_area.history.checkpoint() + await pilot.press("3") + + text_area.undo() + text_area.undo() + text_area.undo() + assert text_area.text == "" + assert text_area.selection == Selection.cursor((0, 0)) + + # Redo stack has 3 edits in it now. + await pilot.press("f") + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + + # Redo stack is cleared because of the edit, so redo has no effect. + text_area.redo() + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + text_area.redo() + assert text_area.text == "f" + assert text_area.selection == Selection.cursor((0, 1)) + + +async def test_inserts_not_batched_with_deletes(pilot: Pilot, text_area: TextArea): + # 3 batches here: __1___ ___________2____________ __3__ + await pilot.press(*"123", "backspace", "backspace", *"23") + + assert text_area.text == "123" + + # Undo batch 1: the "23" insertion. + text_area.undo() + assert text_area.text == "1" + + # Undo batch 2: the double backspace. + text_area.undo() + assert text_area.text == "123" + + # Undo batch 3: the "123" insertion. + text_area.undo() + assert text_area.text == "" + + +async def test_paste_is_an_isolated_batch(pilot: Pilot, text_area: TextArea): + pilot.app.post_message(Paste("hello ")) + pilot.app.post_message(Paste("world")) + await pilot.pause() + + assert text_area.text == "hello world" + + await pilot.press("!") + + # The insertion of "!" does not get batched with the paste of "world". + text_area.undo() + assert text_area.text == "hello world" + + text_area.undo() + assert text_area.text == "hello " + + text_area.undo() + assert text_area.text == "" + + +async def test_focus_creates_checkpoint(pilot: Pilot, text_area: TextArea): + await pilot.press(*"123") + text_area.has_focus = False + text_area.has_focus = True + await pilot.press(*"456") + assert text_area.text == "123456" + + # Since we re-focused, a checkpoint exists between 123 and 456, + # so when we use undo, only the 456 is removed. + text_area.undo() + assert text_area.text == "123" + + +async def test_undo_redo_deletions_batched(pilot: Pilot, text_area: TextArea): + text_area.text = SIMPLE_TEXT + text_area.selection = Selection((0, 2), (1, 2)) + + # Perform a single delete of some selected text. It'll live in it's own + # batch since it's a multi-line operation. + await pilot.press("backspace") + checkpoint_one = "ABHIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Pressing backspace a few times to delete more characters. + await pilot.press("backspace", "backspace", "backspace") + checkpoint_two = "HIJ\nKLMNO\nPQRST\nUVWXY\nZ\n" + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + # When we undo, the 3 deletions above should be batched, but not + # the original deletion since it contains a newline character. + text_area.undo() + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Undoing again restores us back to our initial text and selection. + text_area.undo() + assert text_area.text == SIMPLE_TEXT + assert text_area.selection == Selection((0, 2), (1, 2)) + + # At this point, the undo stack contains two items, so we can redo twice. + + # Redo to go back to checkpoint one. + text_area.redo() + assert text_area.text == checkpoint_one + assert text_area.selection == Selection.cursor((0, 2)) + + # Redo again to go back to checkpoint two + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + # Redo again does nothing. + text_area.redo() + assert text_area.text == checkpoint_two + assert text_area.selection == Selection.cursor((0, 0)) + + +async def test_max_checkpoints(pilot: Pilot, text_area: TextArea): + assert len(text_area.history.undo_stack) == 0 + for index in range(MAX_CHECKPOINTS): + # Press enter since that will ensure a checkpoint is created. + await pilot.press("enter") + + assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS + await pilot.press("enter") + # Ensure we don't go over the limit. + assert len(text_area.history.undo_stack) == MAX_CHECKPOINTS + + +async def test_redo_stack(pilot: Pilot, text_area: TextArea): + assert len(text_area.history.redo_stack) == 0 + await pilot.press("enter") + await pilot.press(*"123") + assert len(text_area.history.undo_stack) == 2 + assert len(text_area.history.redo_stack) == 0 + text_area.undo() + assert len(text_area.history.undo_stack) == 1 + assert len(text_area.history.redo_stack) == 1 + text_area.undo() + assert len(text_area.history.undo_stack) == 0 + assert len(text_area.history.redo_stack) == 2 + text_area.redo() + assert len(text_area.history.undo_stack) == 1 + assert len(text_area.history.redo_stack) == 1 + text_area.redo() + assert len(text_area.history.undo_stack) == 2 + assert len(text_area.history.redo_stack) == 0 From ed9a8f7b7c623585250c6b5751b4d6f562161e1b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 14 Feb 2024 13:49:38 +0000 Subject: [PATCH 133/149] Fix active tab not coming into view This fixes one of the issues reported in #4150. --- src/textual/widgets/_tabs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 0123e17688..276f7e58ec 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -583,6 +583,7 @@ def watch_active(self, previously_active: str, active: str) -> None: self.query("#tabs-list > Tab.-active").remove_class("-active") active_tab.add_class("-active") self._highlight_active(animate=previously_active != "") + self._scroll_active_tab() self.post_message(self.TabActivated(self, active_tab)) else: underline = self.query_one(Underline) From efb8b483473561fd7aded311a4a07584faacef51 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 09:28:45 +0000 Subject: [PATCH 134/149] Tweak the layout of the `discover` description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/command_palette.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 1d7fb55423..2d4b17506b 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -109,11 +109,11 @@ this is to aid in command discoverability. Because `discover` hits are shown the moment the command palette is opened, these should ideally be quick to generate; commands that might take time to generate are best left for `search` -- use `discover` to help the user easily find the most important commands. -`discover` is similar to `search` but with the difference that no search value is passed to it, and it should *yield* instances of [`DiscoveryHit`][textual.command.DiscoveryHit]. -There is no matching and no match score is needed. -The [`DiscoveryHit`][textual.command.DiscoveryHit] contains information about how the hit should be displayed, and an optional help string; -discovery hits are sorted in ascending alphabetical order. -It also contains a callback, which will be run if the user selects that command. +`discover` is similar to `search` but with these differences: + - `discover` accepts no parameters (instead of the search value); + - `discover` yields instances of [`DiscoveryHit`][textual.command.DiscoveryHit]` (instead of instances of xxx); + - discovery hits are sorted in ascending alphabetical order because there is no matching and no match score is generated. +Instances of [`DiscoveryHit`][textual.command.DiscoveryHit] contain information about how the hit should be displayed, an optional help string, and a callback which will be run if the user selects that command. ### shutdown method From 3971449c1690fd8faab0259675fc1674cd00d5b7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 09:37:47 +0000 Subject: [PATCH 135/149] Tidy up a suggested edit to the discover docs --- docs/guide/command_palette.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/guide/command_palette.md b/docs/guide/command_palette.md index 2d4b17506b..5fa1bebc08 100644 --- a/docs/guide/command_palette.md +++ b/docs/guide/command_palette.md @@ -110,9 +110,11 @@ this is to aid in command discoverability. commands that might take time to generate are best left for `search` -- use `discover` to help the user easily find the most important commands. `discover` is similar to `search` but with these differences: - - `discover` accepts no parameters (instead of the search value); - - `discover` yields instances of [`DiscoveryHit`][textual.command.DiscoveryHit]` (instead of instances of xxx); - - discovery hits are sorted in ascending alphabetical order because there is no matching and no match score is generated. + +- `discover` accepts no parameters (instead of the search value) +- `discover` yields instances of [`DiscoveryHit`][textual.command.DiscoveryHit] (instead of instances of [`Hit`][textual.command.Hit]) +- discovery hits are sorted in ascending alphabetical order because there is no matching and no match score is generated + Instances of [`DiscoveryHit`][textual.command.DiscoveryHit] contain information about how the hit should be displayed, an optional help string, and a callback which will be run if the user selects that command. ### shutdown method From 45f13255874b79be40ead788a667f08cf3c41c78 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 09:56:43 +0000 Subject: [PATCH 136/149] Accept docstring tweak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/command.py b/src/textual/command.py index 215d78875b..94d3aa07dc 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -284,7 +284,7 @@ async def discover(self) -> Hits: This is different from [`search`][textual.command.Provider.search] in that it should yield [`DiscoveryHit`s][textual.command.DiscoveryHit] that - should be shown by default; before user input. + should be shown by default (before user input). It is permitted to *not* implement this method. """ From a2d6eec9790b081d4e40deb8f1852e205ce6680a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 10:29:17 +0000 Subject: [PATCH 137/149] Tidy up some typing errors in _on_tabs_tab_activated These have been kicking around for a wee while; made sense to clean them up now. --- src/textual/widgets/_tabbed_content.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index e7ae3eb25b..ad80c24641 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -511,13 +511,16 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: if self._is_associated_tabs(event.tabs): # The message is relevant, so consume it and update state accordingly. event.stop() + assert event.tab.id is not None switcher = self.get_child_by_type(ContentSwitcher) switcher.current = ContentTab.sans_prefix(event.tab.id) self.active = ContentTab.sans_prefix(event.tab.id) self.post_message( TabbedContent.TabActivated( tabbed_content=self, - tab=event.tab, + tab=self.get_child_by_type(ContentTabs).get_content_tab( + self.active + ), ) ) From 5d9e563410e4ab45d591ea1132c09cc3783691db Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 10:33:20 +0000 Subject: [PATCH 138/149] Fix assignment to TabbedContent.active not posting the appropriate message Fixes #4150 --- src/textual/widgets/_tabbed_content.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index ad80c24641..410b317809 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -514,7 +514,13 @@ def _on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None: assert event.tab.id is not None switcher = self.get_child_by_type(ContentSwitcher) switcher.current = ContentTab.sans_prefix(event.tab.id) - self.active = ContentTab.sans_prefix(event.tab.id) + with self.prevent(self.TabActivated): + # We prevent TabbedContent.TabActivated because it is also + # posted from the watcher for active, we're also about to + # post it below too, which is valid as here we're reacting + # to what the Tabs are doing. This ensures we don't get + # doubled-up messages. + self.active = ContentTab.sans_prefix(event.tab.id) self.post_message( TabbedContent.TabActivated( tabbed_content=self, @@ -552,6 +558,12 @@ def _watch_active(self, active: str) -> None: with self.prevent(Tabs.TabActivated): self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) self.get_child_by_type(ContentSwitcher).current = active + self.post_message( + TabbedContent.TabActivated( + tabbed_content=self, + tab=self.get_child_by_type(ContentTabs).get_content_tab(active), + ) + ) @property def tab_count(self) -> int: From c4373dcdd85610baba16e0c1e6cbd0fb29fd6157 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 10:36:58 +0000 Subject: [PATCH 139/149] Only post TabbedContent.TabActivated if active is truthy --- src/textual/widgets/_tabbed_content.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 410b317809..b53769802c 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -558,12 +558,13 @@ def _watch_active(self, active: str) -> None: with self.prevent(Tabs.TabActivated): self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) self.get_child_by_type(ContentSwitcher).current = active - self.post_message( - TabbedContent.TabActivated( - tabbed_content=self, - tab=self.get_child_by_type(ContentTabs).get_content_tab(active), + if active: + self.post_message( + TabbedContent.TabActivated( + tabbed_content=self, + tab=self.get_child_by_type(ContentTabs).get_content_tab(active), + ) ) - ) @property def tab_count(self) -> int: From 907eaffba25e2a52747afb99c19e58a48a56ca6a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 10:49:37 +0000 Subject: [PATCH 140/149] Update the ChangeLog --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5826989c77..48df1194d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 - Add undo and redo to TextArea https://github.com/Textualize/textual/pull/4124 +### Fixed + +- Fixed out-of-view `Tab` not being scrolled into view when `Tabs.active` is assigned https://github.com/Textualize/textual/issues/4150 +- Fixed `TabbedContent.TabActivate` not being posted when `TabbedContent.active` is assigned https://github.com/Textualize/textual/issues/4150 + ### Changed - Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 @@ -31,7 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed duplicate watch methods being attached to DOM nodes https://github.com/Textualize/textual/pull/4030 - Fixed using `watch` to create additional watchers would trigger other watch methods https://github.com/Textualize/textual/issues/3878 -### Added +### Added - Added support for configuring dark and light themes for code in `Markdown` https://github.com/Textualize/textual/issues/3997 From 5cc1361da3787967e257803b0479a30c336f8d21 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 10:53:06 +0000 Subject: [PATCH 141/149] Tidy TabbedContent._watch_active The "with prevent" was covering more code than was necessary. While it doesn't make a whole load of difference, here I make it clear what bit of code actually needs the prevention. --- src/textual/widgets/_tabbed_content.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index b53769802c..0edced54b2 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -557,14 +557,14 @@ def _watch_active(self, active: str) -> None: """Switch tabs when the active attributes changes.""" with self.prevent(Tabs.TabActivated): self.get_child_by_type(ContentTabs).active = ContentTab.add_prefix(active) - self.get_child_by_type(ContentSwitcher).current = active - if active: - self.post_message( - TabbedContent.TabActivated( - tabbed_content=self, - tab=self.get_child_by_type(ContentTabs).get_content_tab(active), - ) + self.get_child_by_type(ContentSwitcher).current = active + if active: + self.post_message( + TabbedContent.TabActivated( + tabbed_content=self, + tab=self.get_child_by_type(ContentTabs).get_content_tab(active), ) + ) @property def tab_count(self) -> int: From 9ca997b1cdf932017e710f2306084e4eb6a676d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 15 Feb 2024 11:58:40 +0000 Subject: [PATCH 142/149] Fix Changelog header version. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 579e3a6abd..2bb816e2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 -## [0.51.1] - 2024-02-09 +## [0.50.1] - 2024-02-09 ### Fixed From 3c18e47466a644fc323798de0d64f2b386c26c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 15 Feb 2024 14:08:42 +0000 Subject: [PATCH 143/149] Test nested CSS worked at higher level. Addresses review feedback. See the three comments starting at https://github.com/Textualize/textual/pull/4040#issuecomment-1934279826. --- tests/css/test_nested_css.py | 48 ++++++++++ tests/css/test_tokenize.py | 181 ----------------------------------- 2 files changed, 48 insertions(+), 181 deletions(-) diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py index 821c655ff3..20424ad297 100644 --- a/tests/css/test_nested_css.py +++ b/tests/css/test_nested_css.py @@ -44,6 +44,54 @@ async def test_nest_app(): assert app.query_one("#foo .paul").styles.background == Color.parse("blue") +class ListOfNestedSelectorsApp(App[None]): + CSS = """ + Label { + &.foo, &.bar { + background: red; + } + } + """ + + def compose(self) -> ComposeResult: + yield Label("one", classes="foo") + yield Label("two", classes="bar") + yield Label("three", classes="heh") + + +async def test_lists_of_selectors_in_nested_css() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3969.""" + app = ListOfNestedSelectorsApp() + red = Color.parse("red") + async with app.run_test(): + assert app.query_one(".foo").styles.background == red + assert app.query_one(".bar").styles.background == red + assert app.query_one(".heh").styles.background != red + + +class DeclarationAfterNestedApp(App[None]): + # css = "Screen{Label{background:red;}background:green;}" + CSS = """ + Screen { + Label { + background: red; + } + background: green; + } + """ + + def compose(self) -> ComposeResult: + yield Label("one") + + +async def test_rule_declaration_after_nested() -> None: + """Regression test for https://github.com/Textualize/textual/issues/3999.""" + app = DeclarationAfterNestedApp() + async with app.run_test(): + assert app.screen.styles.background == Color.parse("green") + assert app.query_one(Label).styles.background == Color.parse("red") + + @pytest.mark.parametrize( ("css", "exception"), [ diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index fab79ed4f6..d4dfba888e 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -898,184 +898,3 @@ def test_allow_new_lines(): ), ] assert list(tokenize(css, ("", ""))) == expected - - -def test_nested_css_selector_list_with_ampersand(): - """Regression test for https://github.com/Textualize/textual/issues/3969.""" - css = "Label{&.foo,&.bar{border:solid red;}}" - tokens = list(tokenize(css, ("", ""))) - assert tokens == [ - Token( - name="selector_start", - value="Label", - read_from=("", ""), - code=css, - location=(0, 0), - ), - Token( - name="declaration_set_start", - value="{", - read_from=("", ""), - code=css, - location=(0, 5), - ), - Token(name="nested", value="&", read_from=("", ""), code=css, location=(0, 6)), - Token( - name="selector_class", - value=".foo", - read_from=("", ""), - code=css, - location=(0, 7), - ), - Token( - name="new_selector", - value=",", - read_from=("", ""), - code=css, - location=(0, 11), - ), - Token(name="nested", value="&", read_from=("", ""), code=css, location=(0, 12)), - Token( - name="selector_class", - value=".bar", - read_from=("", ""), - code=css, - location=(0, 13), - ), - Token( - name="declaration_set_start", - value="{", - read_from=("", ""), - code=css, - location=(0, 17), - ), - Token( - name="declaration_name", - value="border:", - read_from=("", ""), - code=css, - location=(0, 18), - ), - Token( - name="token", value="solid", read_from=("", ""), code=css, location=(0, 25) - ), - Token( - name="whitespace", value=" ", read_from=("", ""), code=css, location=(0, 30) - ), - Token( - name="token", value="red", read_from=("", ""), code=css, location=(0, 31) - ), - Token( - name="declaration_end", - value=";", - read_from=("", ""), - code=css, - location=(0, 34), - ), - Token( - name="declaration_set_end", - value="}", - read_from=("", ""), - code=css, - location=(0, 35), - ), - Token( - name="declaration_set_end", - value="}", - read_from=("", ""), - code=css, - location=(0, 36), - ), - ] - - -def test_declaration_after_nested_declaration_set(): - """Regression test for https://github.com/Textualize/textual/issues/3999.""" - css = "Screen{Label{background:red;}background:green;}" - tokens = list(tokenize(css, ("", ""))) - assert tokens == [ - Token( - name="selector_start", - value="Screen", - read_from=("", ""), - code=css, - location=(0, 0), - ), - Token( - name="declaration_set_start", - value="{", - read_from=("", ""), - code=css, - location=(0, 6), - ), - Token( - name="selector_start", - value="Label", - read_from=("", ""), - code=css, - location=(0, 7), - ), - Token( - name="declaration_set_start", - value="{", - read_from=("", ""), - code=css, - location=(0, 12), - ), - Token( - name="declaration_name", - value="background:", - read_from=("", ""), - code=css, - location=(0, 13), - ), - Token( - name="token", - value="red", - read_from=("", ""), - code=css, - location=(0, 24), - ), - Token( - name="declaration_end", - value=";", - read_from=("", ""), - code=css, - location=(0, 27), - ), - Token( - name="declaration_set_end", - value="}", - read_from=("", ""), - code=css, - location=(0, 28), - ), - Token( - name="declaration_name", - value="background:", - read_from=("", ""), - code=css, - location=(0, 29), - ), - Token( - name="token", - value="green", - read_from=("", ""), - code=css, - location=(0, 40), - ), - Token( - name="declaration_end", - value=";", - read_from=("", ""), - code=css, - location=(0, 45), - ), - Token( - name="declaration_set_end", - value="}", - read_from=("", ""), - code=css, - location=(0, 46), - ), - ] From 3abc8ee577a18a4dc0d1c945646d2c417bb1d878 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 15 Feb 2024 14:21:13 +0000 Subject: [PATCH 144/149] Text area fixes (#4157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial undo related machinery added to TextArea * Initial undo implementation * Basic undo and redo * Some more fleshing out of undo and redo * Skeleton code for managing TextArea history * Initial implementation of undo & redo checkpointing in TextArea * Increase checkpoint characters * Restoring the selection in the TextArea and then restoring it on undo * Adding docstrings to undo_batch and redo_batch in the TextArea * Batching edits of the same type * Batching edits of the same type * Keeping edits containing newlines in their own batch * Checking for newline characters in insertion or replacement during undo checkpoint creation. Updating docstrings in history.py * Fix mypy warning * Performance improvement * Add history checkpoint on cursor movement * Fixing merge conflict in Edit class * Fixing error in merge conflict resolution * Remove unused test file * Remove unused test file * Initial testing of undo and redo * Testing for undo redo * Updating lockfile * Add an extra test * Fix: setting the `text` property programmatically should invalidate the edit history * Improving docstrings * Rename EditHistory.reset() to EditHistory.clear() * Add docstring to an exception * Add a pause after focus/blur in a test * Forcing CI colour * Update focus checkpoint test * Try to force color in pytest by setting --color=yes in PYTEST_ADDOPTS in env var on Github Actions * Add extra assertion in a test * Toggle text_area has focus to trigger checkpoint in history * Apply grammar/wording suggestions from code review Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Making max checkpoints configurable in TextArea history * Improve a docstring * Update changelog * Spelling fixes * More spelling fixes * Americanize spelling of tab_behaviour (->tab_behavior) * Update CHANGELOG regarding `tab_behaviour`->`tab_behavior` * Various fixes * Various fixes and improvements * Updating tests to account for themes always being non-None * Update CHANGELOG. * Add TextArea.read_only to reactive attr table in TextArea docs * Update TextArea docs regarding new features * Cleaning up some typing issues * Add actions for undo and redo * Fix a typo * Fix wording in docs/widgets/text_area.md Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Re-add return type hint * PR feedback and fixing typos * Mark breaking change in CHANGELOG * Add undo/redo to docstring * Add note on undo/redo bindings --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- CHANGELOG.md | 3 +- docs/widgets/text_area.md | 55 +++++++++++---- src/textual/_text_area_theme.py | 15 ---- src/textual/widgets/_text_area.py | 98 +++++++++++++------------- tests/text_area/test_setting_themes.py | 17 +---- 5 files changed, 95 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bb816e2ab..674eb30a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 +- Breaking change: Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124 +- `TextArea.theme` now defaults to `"css"` instead of None, and is no longer optional https://github.com/Textualize/textual/pull/4157 ## [0.50.1] - 2024-02-09 diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md index 4449bcd668..c0a95c265a 100644 --- a/docs/widgets/text_area.md +++ b/docs/widgets/text_area.md @@ -1,7 +1,6 @@ - # TextArea -!!! tip +!!! tip Added in version 0.38.0. Soft wrapping added in version 0.48.0. @@ -68,7 +67,6 @@ text_area.language = "markdown" !!! note More built-in languages will be added in the future. For now, you can [add your own](#adding-support-for-custom-languages). - ### Reading content from `TextArea` There are a number of ways to retrieve content from the `TextArea`: @@ -136,7 +134,7 @@ There are a number of additional utility methods available for interacting with ##### Location information -A number of properties exist on `TextArea` which give information about the current cursor location. +Many properties exist on `TextArea` which give information about the current cursor location. These properties begin with `cursor_at_`, and return booleans. For example, [`cursor_at_start_of_line`][textual.widgets._text_area.TextArea.cursor_at_start_of_line] tells us if the cursor is at a start of line. @@ -175,12 +173,14 @@ the cursor, selection, gutter, and more. #### Default theme -The default `TextArea` theme is called `css`. -This a theme which takes values entirely from CSS. +The default `TextArea` theme is called `css`, which takes it's values entirely from CSS. This means that the default appearance of the widget fits nicely into a standard Textual application, and looks right on both dark and light mode. -More complex applications such as code editors will likely want to use pre-defined themes such as `monokai`. +When using the `css` theme, you can make use of [component classes][textual.widgets.TextArea.COMPONENT_CLASSES] to style elements of the `TextArea`. +For example, the CSS code `TextArea .text-area--cursor { background: green; }` will make the cursor `green`. + +More complex applications such as code editors may want to use pre-defined themes such as `monokai`. This involves using a `TextAreaTheme` object, which we cover in detail below. This allows full customization of the `TextArea`, including syntax highlighting, at the code level. @@ -190,7 +190,7 @@ The initial theme of the `TextArea` is determined by the `theme` parameter. ```python # Create a TextArea with the 'dracula' theme. -yield TextArea("print(123)", language="python", theme="dracula") +yield TextArea.code_editor("print(123)", language="python", theme="dracula") ``` You can check which themes are available using the [`available_themes`][textual.widgets._text_area.TextArea.available_themes] property. @@ -198,7 +198,7 @@ You can check which themes are available using the [`available_themes`][textual. ```python >>> text_area = TextArea() >>> print(text_area.available_themes) -{'dracula', 'github_light', 'monokai', 'vscode_dark'} +{'css', 'dracula', 'github_light', 'monokai', 'vscode_dark'} ``` After creating a `TextArea`, you can change the theme by setting the [`theme`][textual.widgets._text_area.TextArea.theme] @@ -212,7 +212,12 @@ On setting this attribute the `TextArea` will immediately refresh to display the #### Custom themes -Using custom (non-builtin) themes is two-step process: +!!! note + + Custom themes are only relevant for people who are looking to customize syntax highlighting. + If you're only editing plain text, and wish to recolor aspects of the `TextArea`, you should use the [provided component classes][textual.widgets.TextArea.COMPONENT_CLASSES]. + +Using custom (non-builtin) themes is a two-step process: 1. Create an instance of [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme]. 2. Register it using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme]. @@ -297,6 +302,27 @@ The character(s) inserted when you press tab is controlled by setting the `inden If `indent_type == "spaces"`, pressing ++tab++ will insert up to `indent_width` spaces in order to align with the next tab stop. +### Undo and redo + +`TextArea` offers `undo` and `redo` methods. +By default, `undo` is bound to Ctrl+Z and `redo` to Ctrl+Y. + +The `TextArea` uses a heuristic to place _checkpoints_ after certain types of edit. +When you call `undo`, all of the edits between now and the most recent checkpoint are reverted. +You can manually add a checkpoint by calling the [`TextArea.history.checkpoint()`][textual.widgets.text_area.EditHistory.checkpoint] instance method. + +The undo and redo history uses a stack-based system, where a single item on the stack represents a single checkpoint. +In memory-constrained environments, you may wish to reduce the maximum number of checkpoints that can exist. +You can do this by passing the `max_checkpoints` argument to the `TextArea` constructor. + +### Read-only mode + +`TextArea.read_only` is a boolean reactive attribute which, if `True`, will prevent users from modifying content in the `TextArea`. + +While `read_only=True`, you can still modify the content programmatically. + +While this mode is active, the `TextArea` receives the `-read-only` CSS class, which you can use to supply custom styles for read-only mode. + ### Line separators When content is loaded into `TextArea`, the content is scanned from beginning to end @@ -459,7 +485,6 @@ If you notice some highlights are missing after registering a language, the issu The names assigned in tree-sitter highlight queries are often reused across multiple languages. For example, `@string` is used in many languages to highlight strings. - #### Navigation and wrapping information If you're building functionality on top of `TextArea`, it may be useful to inspect the `navigator` and `wrapped_document` attributes. @@ -473,14 +498,15 @@ A detailed view of these classes is out of scope, but do note that a lot of the | Name | Type | Default | Description | |------------------------|--------------------------|---------------|------------------------------------------------------------------| -| `language` | `str | None` | `None` | The language to use for syntax highlighting. | -| `theme` | `str | None` | `TextAreaTheme.default()` | The theme to use for syntax highlighting. | +| `language` | `str | None` | `None` | The language to use for syntax highlighting. | +| `theme` | `str` | `"css"` | The theme to use. | | `selection` | `Selection` | `Selection()` | The current selection. | | `show_line_numbers` | `bool` | `False` | Show or hide line numbers. | | `indent_width` | `int` | `4` | The number of spaces to indent and width of tabs. | | `match_cursor_bracket` | `bool` | `True` | Enable/disable highlighting matching brackets under cursor. | | `cursor_blink` | `bool` | `True` | Enable/disable blinking of the cursor when the widget has focus. | | `soft_wrap` | `bool` | `True` | Enable/disable soft wrapping. | +| `read_only` | `bool` | `False` | Enable/disable read-only mode. | ## Messages @@ -496,7 +522,6 @@ The `TextArea` widget defines the following bindings: show_root_heading: false show_root_toc_entry: false - ## Component classes The `TextArea` defines component classes that can style various aspects of the widget. @@ -513,11 +538,11 @@ Styles from the `theme` attribute take priority. - [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme] - theming the `TextArea` - [`DocumentNavigator`][textual.widgets.text_area.DocumentNavigator] - guides cursor movement - [`WrappedDocument`][textual.widgets.text_area.WrappedDocument] - manages wrapping the document +- [`EditHistory`][textual.widgets.text_area.EditHistory] - manages the undo stack - The tree-sitter documentation [website](https://tree-sitter.github.io/tree-sitter/). - The tree-sitter Python bindings [repository](https://github.com/tree-sitter/py-tree-sitter). - `py-tree-sitter-languages` [repository](https://github.com/grantjenks/py-tree-sitter-languages) (provides binary wheels for a large variety of tree-sitter languages). - ## Additional notes - To remove the outline effect when the `TextArea` is focused, you can set `border: none; padding: 0;` in your CSS. diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index 5fbcee9967..14bc6ffd37 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -119,9 +119,6 @@ def apply_css(self, text_area: TextArea) -> None: if self.cursor_line_gutter_style is None: self.cursor_line_gutter_style = get_style("text-area--cursor-gutter") - if self.cursor_line_gutter_style is None and self.cursor_line_style is not None: - self.cursor_line_gutter_style = self.cursor_line_style.copy() - if self.bracket_matching_style is None: matching_bracket_style = get_style("text-area--matching-bracket") if matching_bracket_style: @@ -182,15 +179,6 @@ def builtin_themes(cls) -> list[TextAreaTheme]: """ return list(_BUILTIN_THEMES.values()) - @classmethod - def default(cls) -> TextAreaTheme: - """Get the default syntax theme. - - Returns: - The default TextAreaTheme (probably "css"). - """ - return _CSS_THEME - _MONOKAI = TextAreaTheme( name="monokai", @@ -388,6 +376,3 @@ def default(cls) -> TextAreaTheme: "vscode_dark": _DARK_VS, "github_light": _GITHUB_LIGHT, } - -DEFAULT_THEME = TextAreaTheme.get_builtin_theme("basic") -"""The default TextAreaTheme used by Textual.""" diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 40b12f1768..f700a742f3 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -85,7 +85,7 @@ class TextAreaLanguage: highlight_query: str -class TextArea(ScrollView, can_focus=True): +class TextArea(ScrollView): DEFAULT_CSS = """\ TextArea { width: 1fr; @@ -220,6 +220,8 @@ class TextArea(ScrollView, can_focus=True): "ctrl+u", "delete_to_start_of_line", "delete to line start", show=False ), Binding("ctrl+k", "delete_to_end_of_line", "delete to line end", show=False), + Binding("ctrl+z", "undo", "Undo", show=False), + Binding("ctrl+y", "redo", "Redo", show=False), ] """ | Key(s) | Description | @@ -251,6 +253,8 @@ class TextArea(ScrollView, can_focus=True): | ctrl+k | Delete from cursor to the end of the line. | | f6 | Select the current line. | | f7 | Select all text in the document. | + | ctrl+z | Undo. | + | ctrl+y | Redo. | """ language: Reactive[str | None] = reactive(None, always_update=True, init=False) @@ -264,7 +268,7 @@ class TextArea(ScrollView, can_focus=True): it first using [`TextArea.register_language`][textual.widgets._text_area.TextArea.register_language]. """ - theme: Reactive[str | None] = reactive(None, always_update=True, init=False) + theme: Reactive[str] = reactive("css", always_update=True, init=False) """The name of the theme to use. Themes must be registered using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] before they can be used. @@ -355,7 +359,7 @@ def __init__( text: str = "", *, language: str | None = None, - theme: str | None = None, + theme: str = "css", soft_wrap: bool = True, tab_behavior: Literal["focus", "indent"] = "focus", read_only: bool = False, @@ -383,7 +387,6 @@ def __init__( disabled: True if the widget is disabled. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) - self._initial_text = text self._languages: dict[str, TextAreaLanguage] = {} """Maps language names to TextAreaLanguage.""" @@ -415,7 +418,7 @@ def __init__( self._highlights: dict[int, list[Highlight]] = defaultdict(list) """Mapping line numbers to the set of highlights for that line.""" - self._highlight_query: "Query" | None = None + self._highlight_query: "Query | None" = None """The query that's currently being used for highlighting.""" self.document: DocumentBase = Document(text) @@ -433,15 +436,14 @@ def __init__( self._set_document(text, language) - self._theme: TextAreaTheme | None = None + self.language = language + self.theme = theme + + self._theme: TextAreaTheme """The `TextAreaTheme` corresponding to the set theme name. When the `theme` reactive is set as a string, the watcher will update this attribute to the corresponding `TextAreaTheme` object.""" - self.language = language - - self.theme = theme - self.set_reactive(TextArea.soft_wrap, soft_wrap) self.set_reactive(TextArea.read_only, read_only) self.set_reactive(TextArea.show_line_numbers, show_line_numbers) @@ -457,7 +459,7 @@ def code_editor( text: str = "", *, language: str | None = None, - theme: str | None = "monokai", + theme: str = "monokai", soft_wrap: bool = False, tab_behavior: Literal["focus", "indent"] = "indent", show_line_numbers: bool = True, @@ -614,7 +616,7 @@ def find_matching_bracket( If the character is not available for bracket matching, `None` is returned. """ match_location = None - bracket_stack = [] + bracket_stack: list[str] = [] if bracket in _OPENING_BRACKETS: for candidate, candidate_location in self._yield_character_locations( search_from @@ -664,11 +666,7 @@ def _watch_language(self, language: str | None) -> None: f"then switch to it by setting the `TextArea.language` attribute." ) - self._set_document( - self.document.text if self.document is not None else self._initial_text, - language, - ) - self._initial_text = "" + self._set_document(self.document.text, language) def _watch_show_line_numbers(self) -> None: """The line number gutter contributes to virtual size, so recalculate.""" @@ -684,32 +682,28 @@ def _watch_show_vertical_scrollbar(self) -> None: self._rewrap_and_refresh_virtual_size() self.scroll_cursor_visible() - def _watch_theme(self, theme: str | None) -> None: + def _watch_theme(self, theme: str) -> None: """We set the styles on this widget when the theme changes, to ensure that - if padding is applied, the colours match.""" + if padding is applied, the colors match.""" self._set_theme(theme) def _app_dark_toggled(self) -> None: self._set_theme(self._theme.name) - def _set_theme(self, theme: str | None) -> None: + def _set_theme(self, theme: str) -> None: theme_object: TextAreaTheme | None - if theme is None: - # If the theme is None, use the default. - theme_object = TextAreaTheme.default() - else: - # If the user supplied a string theme name, find it and apply it. - try: - theme_object = self._themes[theme] - except KeyError: - theme_object = TextAreaTheme.get_builtin_theme(theme) + # If the user supplied a string theme name, find it and apply it. + try: + theme_object = self._themes[theme] + except KeyError: + theme_object = TextAreaTheme.get_builtin_theme(theme) if theme_object is None: raise ThemeDoesNotExist( f"{theme!r} is not a builtin theme, or it has not been registered. " f"To use a custom theme, register it first using `register_theme`, " f"then switch to that theme by setting the `TextArea.theme` attribute." - ) + ) from None self._theme = dataclasses.replace(theme_object) if theme_object: @@ -768,7 +762,7 @@ def available_languages(self) -> set[str]: def register_language( self, - language: str | "Language", + language: "str | Language", highlight_query: str, ) -> None: """Register a language and corresponding highlight query. @@ -825,7 +819,7 @@ def _set_document(self, text: str, language: str | None) -> None: if TREE_SITTER and language: # Attempt to get the override language. text_area_language = self._languages.get(language, None) - document_language: str | "Language" + document_language: "str | Language" if text_area_language: document_language = text_area_language.language highlight_query = text_area_language.highlight_query @@ -964,11 +958,11 @@ def _refresh_size(self) -> None: width, height = self.document.get_size(self.indent_width) self.virtual_size = Size(width + self.gutter_width + 1, height) - def render_line(self, widget_y: int) -> Strip: + def render_line(self, y: int) -> Strip: """Render a single line of the TextArea. Called by Textual. Args: - widget_y: Y Coordinate of line relative to the widget region. + y: Y Coordinate of line relative to the widget region. Returns: A rendered line. @@ -982,7 +976,7 @@ def render_line(self, widget_y: int) -> Strip: scroll_x, scroll_y = self.scroll_offset # Account for how much the TextArea is scrolled. - y_offset = widget_y + scroll_y + y_offset = y + scroll_y # If we're beyond the height of the document, render blank lines out_of_bounds = y_offset >= wrapped_document.height @@ -1007,7 +1001,7 @@ def render_line(self, widget_y: int) -> Strip: line_character_count = len(line) line.tab_size = self.indent_width line.set_length(line_character_count + 1) # space at end for cursor - virtual_width, virtual_height = self.virtual_size + virtual_width, _virtual_height = self.virtual_size selection = self.selection start, end = selection @@ -1256,13 +1250,21 @@ def edit(self, edit: Edit) -> EditResult: def undo(self) -> None: """Undo the edits since the last checkpoint (the most recent batch of edits).""" - edits = self.history._pop_undo() - self._undo_batch(edits) + if edits := self.history._pop_undo(): + self._undo_batch(edits) + + def action_undo(self) -> None: + """Undo the edits since the last checkpoint (the most recent batch of edits).""" + self.undo() def redo(self) -> None: """Redo the most recently undone batch of edits.""" - edits = self.history._pop_redo() - self._redo_batch(edits) + if edits := self.history._pop_redo(): + self._redo_batch(edits) + + def action_redo(self) -> None: + """Redo the most recently undone batch of edits.""" + self.redo() def _undo_batch(self, edits: Sequence[Edit]) -> None: """Undo a batch of Edits. @@ -1439,7 +1441,7 @@ def gutter_width(self) -> int: ) return gutter_width - def _on_mount(self, _: events.Mount) -> None: + def _on_mount(self, event: events.Mount) -> None: self.blink_timer = self.set_interval( 0.5, self._toggle_cursor_blink_visible, @@ -1487,7 +1489,7 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: self.selection = Selection(selection_start, target) async def _on_mouse_up(self, event: events.MouseUp) -> None: - """Finalise the selection that has been made using the mouse.""" + """Finalize the selection that has been made using the mouse.""" self._selecting = False self.release_mouse() self.record_cursor_width() @@ -1497,8 +1499,8 @@ async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" if self.read_only: return - result = self._replace_via_keyboard(event.text, *self.selection) - self.move_cursor(result.end_location) + if result := self._replace_via_keyboard(event.text, *self.selection): + self.move_cursor(result.end_location) def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: """Return the column that the cell width corresponds to on the given row. @@ -1578,7 +1580,7 @@ def move_cursor( that is wide enough. """ if select: - start, end = self.selection + start, _end = self.selection self.selection = Selection(start, location) else: self.selection = Selection.cursor(location) @@ -1611,7 +1613,7 @@ def move_cursor_relative( that is wide enough. """ clamp_visitable = self.clamp_visitable - start, end = self.selection + _start, end = self.selection current_row, current_column = end target = clamp_visitable((current_row + rows, current_column + columns)) self.move_cursor(target, select, center, record_width) @@ -2078,7 +2080,7 @@ def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" start, end = self.selection start, end = sorted((start, end)) - start_row, start_column = start + start_row, _start_column = start end_row, end_column = end # Generally editors will only delete line the end line of the @@ -2165,7 +2167,7 @@ def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]: Returns: A `dict[int, int]` mapping byte indices to codepoint indices within `data`. """ - byte_to_codepoint = {} + byte_to_codepoint: dict[int, int] = {} current_byte_offset = 0 code_point_offset = 0 diff --git a/tests/text_area/test_setting_themes.py b/tests/text_area/test_setting_themes.py index 8d165a98a9..a3ee7aa35e 100644 --- a/tests/text_area/test_setting_themes.py +++ b/tests/text_area/test_setting_themes.py @@ -6,7 +6,7 @@ from textual.widgets._text_area import ThemeDoesNotExist -class TextAreaApp(App): +class TextAreaApp(App[None]): def compose(self) -> ComposeResult: yield TextArea("print('hello')", language="python") @@ -16,11 +16,11 @@ async def test_default_theme(): async with app.run_test(): text_area = app.query_one(TextArea) - assert text_area.theme is None + assert text_area.theme is "css" async def test_setting_builtin_themes(): - class MyTextAreaApp(App): + class MyTextAreaApp(App[None]): def compose(self) -> ComposeResult: yield TextArea("print('hello')", language="python", theme="vscode_dark") @@ -34,17 +34,6 @@ def compose(self) -> ComposeResult: assert text_area.theme == "monokai" -async def test_setting_theme_to_none(): - app = TextAreaApp() - - async with app.run_test(): - text_area = app.query_one(TextArea) - text_area.theme = None - assert text_area.theme is None - # When theme is None, we use the default theme. - assert text_area._theme.name == TextAreaTheme.default().name - - async def test_setting_unknown_theme_raises_exception(): app = TextAreaApp() async with app.run_test(): From d5a1a3ef8640494b8309c7b466cade72d119fedb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 14:33:45 +0000 Subject: [PATCH 145/149] Improve the TabActivated history unit test --- tests/test_tabbed_content.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 28765e8fc3..9dd7dad0b3 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -137,13 +137,12 @@ def compose(self) -> ComposeResult: async def test_tabbed_content_messages(): class TabbedApp(App): - message = None + activation_history: list[Tab] = [] def compose(self) -> ComposeResult: - with TabbedContent(initial="bar"): + with TabbedContent(): with TabPane("foo", id="foo"): yield Label("Foo", id="foo-label") - with TabPane("bar", id="bar"): yield Label("Bar", id="bar-label") with TabPane("baz", id="baz"): @@ -152,15 +151,19 @@ def compose(self) -> ComposeResult: def on_tabbed_content_tab_activated( self, event: TabbedContent.TabActivated ) -> None: - self.message = event + self.activation_history.append(event.tab) app = TabbedApp() async with app.run_test() as pilot: tabbed_content = app.query_one(TabbedContent) tabbed_content.active = "bar" await pilot.pause() - assert isinstance(app.message, TabbedContent.TabActivated) - assert app.message.tab.label.plain == "bar" + assert app.activation_history == [ + # foo was originally activated. + app.query_one(TabbedContent).get_tab("foo"), + # then we did bar "by hand" + app.query_one(TabbedContent).get_tab("bar"), + ] async def test_tabbed_content_add_later_from_empty(): From 8050acdf82aa15212ffbb098158881eabfcd2ed9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 15 Feb 2024 14:40:37 +0000 Subject: [PATCH 146/149] Allow for older Pythons --- tests/test_tabbed_content.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_tabbed_content.py b/tests/test_tabbed_content.py index 9dd7dad0b3..6d7b0976e9 100644 --- a/tests/test_tabbed_content.py +++ b/tests/test_tabbed_content.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest from textual.app import App, ComposeResult From 159a54e109c2ae271db878eca43727597578d855 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 15 Feb 2024 16:34:54 +0000 Subject: [PATCH 147/149] Update tests/css/test_nested_css.py --- tests/css/test_nested_css.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py index 20424ad297..32086f2e4d 100644 --- a/tests/css/test_nested_css.py +++ b/tests/css/test_nested_css.py @@ -70,7 +70,6 @@ async def test_lists_of_selectors_in_nested_css() -> None: class DeclarationAfterNestedApp(App[None]): - # css = "Screen{Label{background:red;}background:green;}" CSS = """ Screen { Label { From c2bb30957ac983e5f8bd33c9852f3cf3fed13995 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 15 Feb 2024 17:19:43 +0000 Subject: [PATCH 148/149] version bump --- CHANGELOG.md | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f930d4baa5..c793d5f578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## Unreleased +## [0.51.0] - 2024-02-15 ### Added @@ -1706,6 +1706,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.51.0]: https://github.com/Textualize/textual/compare/v0.50.1...v0.51.0 [0.50.1]: https://github.com/Textualize/textual/compare/v0.50.0...v0.50.1 [0.50.0]: https://github.com/Textualize/textual/compare/v0.49.0...v0.50.0 [0.49.1]: https://github.com/Textualize/textual/compare/v0.49.0...v0.49.1 diff --git a/pyproject.toml b/pyproject.toml index f8b4180db7..429bfa777a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.50.1" +version = "0.51.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From c680bd790516d959ed87c30b4aea0deabaeee056 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Fri, 16 Feb 2024 17:41:55 +0000 Subject: [PATCH 149/149] docs(events): add tcss to on decorator examples --- docs/guide/events.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/guide/events.md b/docs/guide/events.md index da18527f0c..bd526341c8 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -210,6 +210,12 @@ In the following example we have three buttons, each of which does something dif 1. The message handler is called when any button is pressed +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator01.py"} @@ -233,6 +239,12 @@ The following example uses the decorator approach to write individual message ha 2. Matches the button with class names "toggle" *and* "dark" 3. Matches the button with an id of "quit" +=== "on_decorator.tcss" + + ```python title="on_decorator.tcss" + --8<-- "docs/examples/events/on_decorator.tcss" + ``` + === "Output" ```{.textual path="docs/examples/events/on_decorator02.py"}