From c37e58b15a4f79836930827e6e7406c3a3f381ef Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Dec 2022 11:35:29 +0000 Subject: [PATCH 01/47] Remove the link to the forums --- docs/help.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docs/help.md b/docs/help.md index 488d0919b..645f7bd65 100644 --- a/docs/help.md +++ b/docs/help.md @@ -9,8 +9,3 @@ Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/tex ## Discord Server For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr). - -## Forum - -Visit the [Textual forum](https://community.textualize.io/) for Textual (and Rich) discussions. - From f5f525f1c8d7e0dc5740bb16eb4ebdebbe67843d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 4 Dec 2022 11:38:03 +0000 Subject: [PATCH 02/47] Emphasise that help can be found in GitHub discussions --- docs/help.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/help.md b/docs/help.md index 645f7bd65..a27dae4e0 100644 --- a/docs/help.md +++ b/docs/help.md @@ -6,6 +6,10 @@ If you need help with any aspect of Textual, let us know! We would be happy to h Report bugs via GitHub on the Textual [issues](https://github.com/Textualize/textual/issues) page. You can also post feature requests via GitHub issues, but see the [roadmap](./roadmap.md) first. +## Help with using Textual + +You can seek help with using Textual [in the discussion area on GitHub](https://github.com/Textualize/textual/discussions). + ## Discord Server For more realtime feedback or chat, join our discord server to connect with the [Textual community](https://discord.gg/Enf6Z3qhVr). From d3c91075c658d1d366824c862f05449ad3f5016d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 21 Dec 2022 16:19:23 +0000 Subject: [PATCH 03/47] Add test for line crop issue --- tests/test_input.py | 8 ++++++++ tests/test_segment_tools.py | 11 +++++++++++ 2 files changed, 19 insertions(+) create mode 100644 tests/test_input.py diff --git a/tests/test_input.py b/tests/test_input.py new file mode 100644 index 000000000..87971757b --- /dev/null +++ b/tests/test_input.py @@ -0,0 +1,8 @@ +from textual.widgets._input import _InputRenderable, Input + + +def test_input_renderable(): + input_widget = Input(value="a1あ11bcdaef123a1a") + + renderable_cursor = _InputRenderable(input_widget, cursor_visible=True) + renderable_no_cursor = _InputRenderable(input_widget, cursor_visible=False) diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index 630114ed9..7483d2b15 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -63,6 +63,17 @@ def test_line_crop_edge_2(): assert result == expected +def test_line_crop_highlight_reverse_bug(): + """Regression test for #818""" + segments_joined = [Segment('a1あ11bcdaef123a1a')] + segments_split = [Segment('a1あ11bcdaef'), Segment('1'), Segment('23a1a')] + + joined1 = "".join(seg.text for seg in line_crop(segments_split, start=9, end=16, total=23)) + joined2 = "".join(seg.text for seg in line_crop(segments_joined, start=9, end=16, total=23)) + + assert joined1 == joined2 + + def test_line_trim(): segments = [Segment("foo")] From 27a7cfc489b1f2fc874e2ca0559b088f700180ae Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 10:35:25 +0000 Subject: [PATCH 04/47] Add a public read-only parent property to TreeNode See #1397. --- CHANGELOG.md | 10 ++++++++-- src/textual/widgets/_tree.py | 5 +++++ tests/tree/test_tree_node_parent.py | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 tests/tree/test_tree_node_parent.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee7b9a67..625235542 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.10.0] - Unreleased + +### Added + +- Added `TreeNode.parent` -- a read-only property for accessing a node's parent https://github.com/Textualize/textual/issues/1397 + ## [0.9.1] - 2022-12-30 ### Added @@ -23,8 +29,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widget.render_line now returns a Strip - Fix for slow updates on Windows -- Bumped Rich dependency - +- Bumped Rich dependency + ## [0.8.2] - 2022-12-28 ### Fixed diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e23385502..f6638ca9c 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -121,6 +121,11 @@ class TreeNode(Generic[TreeDataType]): """NodeID: Get the node ID.""" return self._id + @property + def parent(self) -> TreeNode[TreeDataType] | None: + """TreeNode[TreeDataType] | None: The parent of the node.""" + return self._parent + @property def is_expanded(self) -> bool: """bool: Check if the node is expanded.""" diff --git a/tests/tree/test_tree_node_parent.py b/tests/tree/test_tree_node_parent.py new file mode 100644 index 000000000..90353ea92 --- /dev/null +++ b/tests/tree/test_tree_node_parent.py @@ -0,0 +1,10 @@ +from textual.widgets import TreeNode, Tree + +def test_tree_node_parent() -> None: + """It should be possible to access a TreeNode's parent.""" + tree = Tree[None]("Anakin") + child = tree.root.add("Leia") + grandchild = child.add("Ben") + assert tree.root.parent is None + assert grandchild.parent == child + assert child.parent == tree.root From 30d5c1e66bc340398685262545fcd1105223fe4a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 21:11:14 +0000 Subject: [PATCH 05/47] Add a generic immutable sequence wrapper class In anticipation of satisfying #1398, this adds a generic immutable sequence wrapper class. The idea being that it can be used to wrap up a list or similar, that you don't want the caller to modify. This commit aims to get the basics down for this, and also adds a minimal set of unit tests. --- src/textual/_collections.py | 65 +++++++++++++++++++++++++++++ tests/test_collections.py | 81 +++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/textual/_collections.py create mode 100644 tests/test_collections.py diff --git a/src/textual/_collections.py b/src/textual/_collections.py new file mode 100644 index 000000000..09b8af206 --- /dev/null +++ b/src/textual/_collections.py @@ -0,0 +1,65 @@ +"""Provides collection-based utility code.""" + +from __future__ import annotations +from typing import Generic, TypeVar, Iterator, overload, Iterable + +T = TypeVar("T") + + +class ImmutableSequence(Generic[T]): + """Class to wrap a sequence of some sort, but not allow modification.""" + + def __init__(self, wrap: Iterable[T]) -> None: + """Initialise the immutable sequence. + + Args: + wrap (Iterable[T]): The iterable value being wrapped. + """ + self._list = list(wrap) + + @overload + def __getitem__(self, index: int) -> T: + ... + + @overload + def __getitem__(self, index: slice) -> ImmutableSequence[T]: + ... + + def __getitem__(self, index: int | slice) -> T | ImmutableSequence[T]: + return ( + self._list[index] + if isinstance(index, int) + else ImmutableSequence[T](self._list[index]) + ) + + def __iter__(self) -> Iterator[T]: + return iter(self._list) + + def __len__(self) -> int: + return len(self._list) + + def __length_hint__(self) -> int: + return len(self) + + def __bool__(self) -> bool: + return bool(len(self)) + + def __contains__(self, item: T) -> bool: + return item in self._list + + def index(self, item: T) -> int: + """Return the index of the given item. + + Args: + item (T): The item to find in the sequence. + + Returns: + int: The index of the item in the sequence. + + Raises: + ValueError: If the item is not in the sequence. + """ + return self._list.index(item) + + def __reversed__(self) -> Iterator[T]: + return reversed(self._list) diff --git a/tests/test_collections.py b/tests/test_collections.py new file mode 100644 index 000000000..cd4d0f71e --- /dev/null +++ b/tests/test_collections.py @@ -0,0 +1,81 @@ +import pytest + +from typing import Iterable +from textual._collections import ImmutableSequence + +def wrap(source: Iterable[int]) -> ImmutableSequence[int]: + """Wrap an itertable of integers inside an immutable sequence.""" + return ImmutableSequence[int](source) + + +def test_empty_immutable_sequence() -> None: + """An empty immutable sequence should act as anticipated.""" + assert len(wrap([])) == 0 + assert bool(wrap([])) is False + assert list(wrap([])) == [] + + +def test_non_empty_immutable_sequence() -> None: + """A non-empty immutable sequence should act as anticipated.""" + assert len(wrap([0])) == 1 + assert bool(wrap([0])) is True + assert list(wrap([0])) == [0] + + +def test_immutable_sequence_from_empty_iter() -> None: + """An immutable sequence around an empty iterator should act as anticipated.""" + assert len(wrap([])) == 0 + assert bool(wrap([])) is False + assert list(wrap(iter([]))) == [] + + +def test_immutable_sequence_from_non_empty_iter() -> None: + """An immutable sequence around a non-empty iterator should act as anticipated.""" + assert len(wrap(range(23))) == 23 + assert bool(wrap(range(23))) is True + assert list(wrap(range(23))) == list(range(23)) + + +def test_no_assign_to_immutable_sequence() -> None: + """It should not be possible to assign into an immutable sequence.""" + tester = wrap([1,2,3,4,5]) + with pytest.raises(TypeError): + tester[0] = 23 + with pytest.raises(TypeError): + tester[0:3] = 23 + + +def test_no_del_from_iummutable_sequence() -> None: + """It should not be possible delete an item from an immutable sequence.""" + tester = wrap([1,2,3,4,5]) + with pytest.raises(TypeError): + del tester[0] + + +def test_get_item_from_immutable_sequence() -> None: + """It should be possible to get an item from an immutable sequence.""" + assert wrap(range(10))[0] == 0 + assert wrap(range(10))[-1] == 9 + +def test_get_slice_from_immutable_sequence() -> None: + """It should be possible to get a slice from an immutable sequence.""" + assert list(wrap(range(10))[0:2]) == [0,1] + assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8] + + +def test_immutable_sequence_contains() -> None: + """It should be possible to see if an immutable sequence contains a value.""" + tester = wrap([1,2,3,4,5]) + assert 1 in tester + assert 11 not in tester + + +def test_immutable_sequence_index() -> None: + tester = wrap([1,2,3,4,5]) + assert tester.index(1) == 0 + with pytest.raises(ValueError): + _ = tester.index(11) + + +def test_reverse_immutable_sequence() -> None: + assert list(reversed(wrap([1,2]))) == [2,1] From 7779211dcf50449aafa57a556fe91da34b6eaded Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 21:24:47 +0000 Subject: [PATCH 06/47] Add read-only access to the children of a TreeNode See #1398. --- CHANGELOG.md | 8 +++++-- src/textual/widgets/_tree.py | 12 +++++++++- tests/tree/test_tree_node_children.py | 32 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 tests/tree/test_tree_node_children.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 876edeb4b..f31ca177a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.10.0] - Unreleased +### Added + +- Added read-only public access to the children of a `TreeNode` via `TreeNode.children` https://github.com/Textualize/textual/issues/1398 + ### Changed - `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458 @@ -29,8 +33,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Widget.render_line now returns a Strip - Fix for slow updates on Windows -- Bumped Rich dependency - +- Bumped Rich dependency + ## [0.8.2] - 2022-12-28 ### Fixed diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e23385502..4d4d251a9 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -14,6 +14,7 @@ from .._loop import loop_last from .._segment_tools import line_crop, line_pad from .._types import MessageTarget from .._typing import TypeAlias +from .._collections import ImmutableSequence from ..binding import Binding from ..geometry import Region, Size, clamp from ..message import Message @@ -53,6 +54,10 @@ class _TreeLine: return guides +class TreeNodes(ImmutableSequence["TreeNode[TreeDataType]"]): + """An immutable collection of `TreeNode`.""" + + @rich.repr.auto class TreeNode(Generic[TreeDataType]): """An object that represents a "node" in a tree control.""" @@ -74,7 +79,7 @@ class TreeNode(Generic[TreeDataType]): self._label = label self.data = data self._expanded = expanded - self._children: list[TreeNode] = [] + self._children: list[TreeNode[TreeDataType]] = [] self._hover_ = False self._selected_ = False @@ -91,6 +96,11 @@ class TreeNode(Generic[TreeDataType]): self._selected_ = False self._updates += 1 + @property + def children(self) -> TreeNodes[TreeDataType]: + """TreeNodes[TreeDataType]: The child nodes of a TreeNode.""" + return TreeNodes(self._children) + @property def line(self) -> int: """int: Get the line number for this node, or -1 if it is not displayed.""" diff --git a/tests/tree/test_tree_node_children.py b/tests/tree/test_tree_node_children.py new file mode 100644 index 000000000..22df664db --- /dev/null +++ b/tests/tree/test_tree_node_children.py @@ -0,0 +1,32 @@ +import pytest +from textual.widgets import Tree, TreeNode + +def label_of(node: TreeNode[None]): + """Get the label of a node. + + TODO: This is just a helper function to reduce the number of type + errors, which can and will be remove once this code is merged with a + version of main that also has the TreeNode.label PR merged. + """ + return str(node._label) + + +def test_tree_node_children() -> None: + """A node's children property should act like an immutable list.""" + CHILDREN=23 + tree = Tree[None]("Root") + for child in range(CHILDREN): + tree.root.add(str(child)) + assert len(tree.root.children)==CHILDREN + for child in range(CHILDREN): + assert label_of(tree.root.children[child]) == str(child) + assert label_of(tree.root.children[0]) == "0" + assert label_of(tree.root.children[-1]) == str(CHILDREN-1) + assert [label_of(node) for node in tree.root.children] == [str(n) for n in range(CHILDREN)] + assert [label_of(node) for node in tree.root.children[:2]] == [str(n) for n in range(2)] + with pytest.raises(TypeError): + tree.root.children[0] = tree.root.children[1] + with pytest.raises(TypeError): + del tree.root.children[0] + with pytest.raises(TypeError): + del tree.root.children[0:2] From f9e00628abd130eade586fe13edd204e8087ea71 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 23:06:42 +0000 Subject: [PATCH 07/47] Simplify ImmutableSequence.__bool__ --- src/textual/_collections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_collections.py b/src/textual/_collections.py index 09b8af206..cbdef05b2 100644 --- a/src/textual/_collections.py +++ b/src/textual/_collections.py @@ -42,7 +42,7 @@ class ImmutableSequence(Generic[T]): return len(self) def __bool__(self) -> bool: - return bool(len(self)) + return bool(self._list) def __contains__(self, item: T) -> bool: return item in self._list From 8d5ea89a65aba38c6fd15aabc4541287233a6703 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 5 Jan 2023 23:16:51 +0000 Subject: [PATCH 08/47] Focus less on it being a list and more a thing that's wrapped --- src/textual/_collections.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/textual/_collections.py b/src/textual/_collections.py index cbdef05b2..91def6bde 100644 --- a/src/textual/_collections.py +++ b/src/textual/_collections.py @@ -15,7 +15,7 @@ class ImmutableSequence(Generic[T]): Args: wrap (Iterable[T]): The iterable value being wrapped. """ - self._list = list(wrap) + self._wrap = tuple(wrap) @overload def __getitem__(self, index: int) -> T: @@ -27,25 +27,25 @@ class ImmutableSequence(Generic[T]): def __getitem__(self, index: int | slice) -> T | ImmutableSequence[T]: return ( - self._list[index] + self._wrap[index] if isinstance(index, int) - else ImmutableSequence[T](self._list[index]) + else ImmutableSequence[T](self._wrap[index]) ) def __iter__(self) -> Iterator[T]: - return iter(self._list) + return iter(self._wrap) def __len__(self) -> int: - return len(self._list) + return len(self._wrap) def __length_hint__(self) -> int: return len(self) def __bool__(self) -> bool: - return bool(self._list) + return bool(self._wrap) def __contains__(self, item: T) -> bool: - return item in self._list + return item in self._wrap def index(self, item: T) -> int: """Return the index of the given item. @@ -59,7 +59,7 @@ class ImmutableSequence(Generic[T]): Raises: ValueError: If the item is not in the sequence. """ - return self._list.index(item) + return self._wrap.index(item) def __reversed__(self) -> Iterator[T]: - return reversed(self._list) + return reversed(self._wrap) From 83c0ef73e3cd656ad3b0339adc091fb4ab28fd27 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 06:01:59 +0000 Subject: [PATCH 09/47] Only convert to an indexable sequence if absolutely necessary --- src/textual/_collections.py | 4 ++-- tests/test_collections.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/textual/_collections.py b/src/textual/_collections.py index 91def6bde..2b3f0ec16 100644 --- a/src/textual/_collections.py +++ b/src/textual/_collections.py @@ -1,7 +1,7 @@ """Provides collection-based utility code.""" from __future__ import annotations -from typing import Generic, TypeVar, Iterator, overload, Iterable +from typing import Generic, TypeVar, Iterator, overload, Iterable, Sequence T = TypeVar("T") @@ -15,7 +15,7 @@ class ImmutableSequence(Generic[T]): Args: wrap (Iterable[T]): The iterable value being wrapped. """ - self._wrap = tuple(wrap) + self._wrap = wrap if isinstance(wrap, Sequence) else tuple(wrap) @overload def __getitem__(self, index: int) -> T: diff --git a/tests/test_collections.py b/tests/test_collections.py index cd4d0f71e..de2cbf686 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -57,10 +57,11 @@ def test_get_item_from_immutable_sequence() -> None: assert wrap(range(10))[0] == 0 assert wrap(range(10))[-1] == 9 + def test_get_slice_from_immutable_sequence() -> None: """It should be possible to get a slice from an immutable sequence.""" - assert list(wrap(range(10))[0:2]) == [0,1] - assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8] + assert list(wrap(iter(range(10)))[0:2]) == [0,1] + assert list(wrap(iter(range(10)))[0:-1]) == [0,1,2,3,4,5,6,7,8] def test_immutable_sequence_contains() -> None: From a15fa7fd01654cb31390ee270b439a56a68439aa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 06:14:49 +0000 Subject: [PATCH 10/47] Tweak the unit tests for ImmutableSequence Make it 100% clear that the tests that are about wrapping iterators actually are wrapping iterators. --- tests/test_collections.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_collections.py b/tests/test_collections.py index de2cbf686..56d44cd70 100644 --- a/tests/test_collections.py +++ b/tests/test_collections.py @@ -24,16 +24,16 @@ def test_non_empty_immutable_sequence() -> None: def test_immutable_sequence_from_empty_iter() -> None: """An immutable sequence around an empty iterator should act as anticipated.""" - assert len(wrap([])) == 0 - assert bool(wrap([])) is False + assert len(wrap(iter([]))) == 0 + assert bool(wrap(iter([]))) is False assert list(wrap(iter([]))) == [] def test_immutable_sequence_from_non_empty_iter() -> None: """An immutable sequence around a non-empty iterator should act as anticipated.""" - assert len(wrap(range(23))) == 23 - assert bool(wrap(range(23))) is True - assert list(wrap(range(23))) == list(range(23)) + assert len(wrap(iter(range(23)))) == 23 + assert bool(wrap(iter(range(23)))) is True + assert list(wrap(iter(range(23)))) == list(range(23)) def test_no_assign_to_immutable_sequence() -> None: @@ -60,8 +60,8 @@ def test_get_item_from_immutable_sequence() -> None: def test_get_slice_from_immutable_sequence() -> None: """It should be possible to get a slice from an immutable sequence.""" - assert list(wrap(iter(range(10)))[0:2]) == [0,1] - assert list(wrap(iter(range(10)))[0:-1]) == [0,1,2,3,4,5,6,7,8] + assert list(wrap(range(10))[0:2]) == [0,1] + assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8] def test_immutable_sequence_contains() -> None: From 0abeba620ef6e583ed956a9d935be079e4299928 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 11:14:15 +0000 Subject: [PATCH 11/47] Method for converting Strip index to cell position --- src/textual/strip.py | 35 +++++++++++++++++++++++++++++++++++ tests/test_strip.py | 29 +++++++++++++++++++++++------ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/textual/strip.py b/src/textual/strip.py index 7a83d5e40..be9dc80b0 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -68,6 +68,41 @@ class Strip: """ return [cls(segments, cell_length) for segments in lines] + def index_to_cell_position(self, index: int) -> int: + """Given a character index, return the cell position of that character. + This is the sum of the cell lengths of all the characters *before* the character + at `index`. + + Args: + index (int): The index to convert. + + Returns: + int: The cell position of the character at `index`. + """ + if index == 0: + return 0 + + cell_position_end = 0 + segment_length = 0 + segment_end_index = 0 + segment_cell_length = 0 + text = "" + iter_segments = iter(self) + while segment_end_index < index: + segment = next(iter_segments) + text = segment.text + segment_length = len(text) + segment_cell_length = cell_len(text) + cell_position_end += segment_cell_length + segment_end_index += segment_length + + # Check how far into this segment the target index is + segment_index_start = segment_end_index - segment_length + index_within_segment = index - segment_index_start + segment_cell_start = cell_position_end - segment_cell_length + + return segment_cell_start + cell_len(text[:index_within_segment]) + @property def cell_length(self) -> int: """Get the number of cells required to render this object.""" diff --git a/tests/test_strip.py b/tests/test_strip.py index 891af9845..15d527119 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -1,3 +1,4 @@ +import pytest from rich.segment import Segment from rich.style import Style @@ -62,9 +63,7 @@ def test_eq(): def test_adjust_cell_length(): - for repeat in range(3): - assert Strip([]).adjust_cell_length(3) == Strip([Segment(" ")]) assert Strip([Segment("f")]).adjust_cell_length(3) == Strip( [Segment("f"), Segment(" ")] @@ -119,9 +118,7 @@ def test_style_links(): def test_crop(): - for repeat in range(3): - assert Strip([Segment("foo")]).crop(0, 3) == Strip([Segment("foo")]) assert Strip([Segment("foo")]).crop(0, 2) == Strip([Segment("fo")]) assert Strip([Segment("foo")]).crop(0, 1) == Strip([Segment("f")]) @@ -136,10 +133,30 @@ def test_crop(): def test_divide(): - for repeat in range(3): - assert Strip([Segment("foo")]).divide([1, 2]) == [ Strip([Segment("f")]), Strip([Segment("o")]), ] + + +@pytest.mark.parametrize( + "index,cell_position", + [ + (0, 0), + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 6), + (6, 8), + (7, 10), + (8, 11), + (9, 12), + (10, 13), + (11, 14), + ], +) +def test_index_to_cell_position(index, cell_position): + strip = Strip([Segment("ab"), Segment("cd日本語ef"), Segment("gh")]) + assert cell_position == strip.index_to_cell_position(index) From 71cc1bca1e71b2cb6724ad9e645e0016dfc784ab Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 12:23:51 +0000 Subject: [PATCH 12/47] Rename ImmutableSequence to ImmutableSequenceView Also, in doing so, drop support for unrolling iterators and making them into indexable sequences. See the following feedback: https://github.com/Textualize/textual/pull/1495#pullrequestreview-1238616797 https://github.com/Textualize/textual/pull/1495#issuecomment-1373553580 --- ...ections.py => _immutable_sequence_view.py} | 18 +++++++------- src/textual/widgets/_tree.py | 4 ++-- ...ons.py => test_immutable_sequence_view.py} | 24 ++++--------------- 3 files changed, 16 insertions(+), 30 deletions(-) rename src/textual/{_collections.py => _immutable_sequence_view.py} (72%) rename tests/{test_collections.py => test_immutable_sequence_view.py} (69%) diff --git a/src/textual/_collections.py b/src/textual/_immutable_sequence_view.py similarity index 72% rename from src/textual/_collections.py rename to src/textual/_immutable_sequence_view.py index 2b3f0ec16..39815d96b 100644 --- a/src/textual/_collections.py +++ b/src/textual/_immutable_sequence_view.py @@ -1,35 +1,35 @@ -"""Provides collection-based utility code.""" +"""Provides an immutable sequence view class.""" from __future__ import annotations -from typing import Generic, TypeVar, Iterator, overload, Iterable, Sequence +from typing import Generic, TypeVar, Iterator, overload, Sequence T = TypeVar("T") -class ImmutableSequence(Generic[T]): +class ImmutableSequenceView(Generic[T]): """Class to wrap a sequence of some sort, but not allow modification.""" - def __init__(self, wrap: Iterable[T]) -> None: + def __init__(self, wrap: Sequence[T]) -> None: """Initialise the immutable sequence. Args: - wrap (Iterable[T]): The iterable value being wrapped. + wrap (Sequence[T]): The sequence being wrapped. """ - self._wrap = wrap if isinstance(wrap, Sequence) else tuple(wrap) + self._wrap = wrap @overload def __getitem__(self, index: int) -> T: ... @overload - def __getitem__(self, index: slice) -> ImmutableSequence[T]: + def __getitem__(self, index: slice) -> ImmutableSequenceView[T]: ... - def __getitem__(self, index: int | slice) -> T | ImmutableSequence[T]: + def __getitem__(self, index: int | slice) -> T | ImmutableSequenceView[T]: return ( self._wrap[index] if isinstance(index, int) - else ImmutableSequence[T](self._wrap[index]) + else ImmutableSequenceView[T](self._wrap[index]) ) def __iter__(self) -> Iterator[T]: diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 64b95f3ba..d3d4f4ecf 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -14,7 +14,7 @@ from .._loop import loop_last from .._segment_tools import line_crop, line_pad from .._types import MessageTarget from .._typing import TypeAlias -from .._collections import ImmutableSequence +from .._immutable_sequence_view import ImmutableSequenceView from ..binding import Binding from ..geometry import Region, Size, clamp from ..message import Message @@ -54,7 +54,7 @@ class _TreeLine: return guides -class TreeNodes(ImmutableSequence["TreeNode[TreeDataType]"]): +class TreeNodes(ImmutableSequenceView["TreeNode[TreeDataType]"]): """An immutable collection of `TreeNode`.""" diff --git a/tests/test_collections.py b/tests/test_immutable_sequence_view.py similarity index 69% rename from tests/test_collections.py rename to tests/test_immutable_sequence_view.py index 56d44cd70..5af7f5133 100644 --- a/tests/test_collections.py +++ b/tests/test_immutable_sequence_view.py @@ -1,11 +1,11 @@ import pytest -from typing import Iterable -from textual._collections import ImmutableSequence +from typing import Sequence +from textual._immutable_sequence_view import ImmutableSequenceView -def wrap(source: Iterable[int]) -> ImmutableSequence[int]: - """Wrap an itertable of integers inside an immutable sequence.""" - return ImmutableSequence[int](source) +def wrap(source: Sequence[int]) -> ImmutableSequenceView[int]: + """Wrap a sequence of integers inside an immutable sequence view.""" + return ImmutableSequenceView[int](source) def test_empty_immutable_sequence() -> None: @@ -22,20 +22,6 @@ def test_non_empty_immutable_sequence() -> None: assert list(wrap([0])) == [0] -def test_immutable_sequence_from_empty_iter() -> None: - """An immutable sequence around an empty iterator should act as anticipated.""" - assert len(wrap(iter([]))) == 0 - assert bool(wrap(iter([]))) is False - assert list(wrap(iter([]))) == [] - - -def test_immutable_sequence_from_non_empty_iter() -> None: - """An immutable sequence around a non-empty iterator should act as anticipated.""" - assert len(wrap(iter(range(23)))) == 23 - assert bool(wrap(iter(range(23)))) is True - assert list(wrap(iter(range(23)))) == list(range(23)) - - def test_no_assign_to_immutable_sequence() -> None: """It should not be possible to assign into an immutable sequence.""" tester = wrap([1,2,3,4,5]) From 395ed4b153008ca1406fc071455d9dbb36e996af Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 12:31:27 +0000 Subject: [PATCH 13/47] Correct docstring for the return type of index --- src/textual/_immutable_sequence_view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_immutable_sequence_view.py b/src/textual/_immutable_sequence_view.py index 39815d96b..21cc22fef 100644 --- a/src/textual/_immutable_sequence_view.py +++ b/src/textual/_immutable_sequence_view.py @@ -54,7 +54,7 @@ class ImmutableSequenceView(Generic[T]): item (T): The item to find in the sequence. Returns: - int: The index of the item in the sequence. + T: The index of the item in the sequence. Raises: ValueError: If the item is not in the sequence. From 02174623980ba1c72b9b01f4baf6d95b012c80c6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 6 Jan 2023 12:31:59 +0000 Subject: [PATCH 14/47] Add support for star/stop values on index See https://github.com/Textualize/textual/pull/1495#pullrequestreview-1238616797 --- src/textual/_immutable_sequence_view.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/textual/_immutable_sequence_view.py b/src/textual/_immutable_sequence_view.py index 21cc22fef..27283dbc4 100644 --- a/src/textual/_immutable_sequence_view.py +++ b/src/textual/_immutable_sequence_view.py @@ -1,6 +1,7 @@ """Provides an immutable sequence view class.""" from __future__ import annotations +from sys import maxsize from typing import Generic, TypeVar, Iterator, overload, Sequence T = TypeVar("T") @@ -47,11 +48,13 @@ class ImmutableSequenceView(Generic[T]): def __contains__(self, item: T) -> bool: return item in self._wrap - def index(self, item: T) -> int: + def index(self, item: T, start: int = 0, stop: int = maxsize) -> int: """Return the index of the given item. Args: item (T): The item to find in the sequence. + start (int, optional): Optional start location. + stop (int, optional): Optional stop location. Returns: T: The index of the item in the sequence. @@ -59,7 +62,7 @@ class ImmutableSequenceView(Generic[T]): Raises: ValueError: If the item is not in the sequence. """ - return self._wrap.index(item) + return self._wrap.index(item, start, stop) def __reversed__(self) -> Iterator[T]: return reversed(self._wrap) From 1bd720ef0dbf9de7b4a16138c03e998375eb5bfd Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 13:55:48 +0000 Subject: [PATCH 15/47] Index to cell position --- poetry.lock | 303 +++++++++++++++++----------------- src/textual/_segment_tools.py | 41 +++++ src/textual/strip.py | 25 +-- tests/test_segment_tools.py | 12 +- tests/test_strip.py | 5 + 5 files changed, 202 insertions(+), 184 deletions(-) diff --git a/poetry.lock b/poetry.lock index 180acad62..34c7f058e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -182,7 +182,7 @@ test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] [[package]] name = "coverage" -version = "7.0.1" +version = "7.0.3" description = "Code coverage measurement for Python" category = "dev" optional = false @@ -269,7 +269,7 @@ typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\"" [[package]] name = "griffe" -version = "0.25.2" +version = "0.25.3" description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." category = "dev" optional = false @@ -313,7 +313,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "httpx" -version = "0.23.1" +version = "0.23.3" description = "The next generation HTTP client." category = "dev" optional = false @@ -333,7 +333,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "identify" -version = "2.5.11" +version = "2.5.12" description = "File identification library for Python" category = "dev" optional = false @@ -524,7 +524,7 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "0.8.2" +version = "0.8.3" description = "A Python handler for mkdocstrings." category = "dev" optional = false @@ -661,7 +661,7 @@ virtualenv = ">=20.10.0" [[package]] name = "pygments" -version = "2.13.0" +version = "2.14.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false @@ -875,7 +875,7 @@ python-versions = ">=3.7" [[package]] name = "syrupy" -version = "3.0.5" +version = "3.0.6" description = "Pytest Snapshot Test Utility" category = "dev" optional = false @@ -887,7 +887,7 @@ pytest = ">=5.1.0,<8.0.0" [[package]] name = "time-machine" -version = "2.8.2" +version = "2.9.0" description = "Travel through time in your tests." category = "dev" optional = false @@ -969,7 +969,7 @@ testing = ["coverage (>=6.2)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7 [[package]] name = "watchdog" -version = "2.2.0" +version = "2.2.1" description = "Filesystem events monitoring" category = "dev" optional = false @@ -1178,57 +1178,57 @@ commonmark = [ {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, ] coverage = [ - {file = "coverage-7.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b3695c4f4750bca943b3e1f74ad4be8d29e4aeab927d50772c41359107bd5d5c"}, - {file = "coverage-7.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fa6a5a224b7f4cfb226f4fc55a57e8537fcc096f42219128c2c74c0e7d0953e1"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74f70cd92669394eaf8d7756d1b195c8032cf7bbbdfce3bc489d4e15b3b8cf73"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b66bb21a23680dee0be66557dc6b02a3152ddb55edf9f6723fa4a93368f7158d"}, - {file = "coverage-7.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d87717959d4d0ee9db08a0f1d80d21eb585aafe30f9b0a54ecf779a69cb015f6"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:854f22fa361d1ff914c7efa347398374cc7d567bdafa48ac3aa22334650dfba2"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e414dc32ee5c3f36544ea466b6f52f28a7af788653744b8570d0bf12ff34bc0"}, - {file = "coverage-7.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6c5ad996c6fa4d8ed669cfa1e8551348729d008a2caf81489ab9ea67cfbc7498"}, - {file = "coverage-7.0.1-cp310-cp310-win32.whl", hash = "sha256:691571f31ace1837838b7e421d3a09a8c00b4aac32efacb4fc9bd0a5c647d25a"}, - {file = "coverage-7.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:89caf4425fe88889e2973a8e9a3f6f5f9bbe5dd411d7d521e86428c08a873a4a"}, - {file = "coverage-7.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:63d56165a7c76265468d7e0c5548215a5ba515fc2cba5232d17df97bffa10f6c"}, - {file = "coverage-7.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f943a3b2bc520102dd3e0bb465e1286e12c9a54f58accd71b9e65324d9c7c01"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:830525361249dc4cd013652b0efad645a385707a5ae49350c894b67d23fbb07c"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd1b9c5adc066db699ccf7fa839189a649afcdd9e02cb5dc9d24e67e7922737d"}, - {file = "coverage-7.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e00c14720b8b3b6c23b487e70bd406abafc976ddc50490f645166f111c419c39"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6d55d840e1b8c0002fce66443e124e8581f30f9ead2e54fbf6709fb593181f2c"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:66b18c3cf8bbab0cce0d7b9e4262dc830e93588986865a8c78ab2ae324b3ed56"}, - {file = "coverage-7.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:12a5aa77783d49e05439fbe6e6b427484f8a0f9f456b46a51d8aac022cfd024d"}, - {file = "coverage-7.0.1-cp311-cp311-win32.whl", hash = "sha256:b77015d1cb8fe941be1222a5a8b4e3fbca88180cfa7e2d4a4e58aeabadef0ab7"}, - {file = "coverage-7.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb992c47cb1e5bd6a01e97182400bcc2ba2077080a17fcd7be23aaa6e572e390"}, - {file = "coverage-7.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e78e9dcbf4f3853d3ae18a8f9272111242531535ec9e1009fa8ec4a2b74557dc"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e60bef2e2416f15fdc05772bf87db06c6a6f9870d1db08fdd019fbec98ae24a9"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9823e4789ab70f3ec88724bba1a203f2856331986cd893dedbe3e23a6cfc1e4e"}, - {file = "coverage-7.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9158f8fb06747ac17bd237930c4372336edc85b6e13bdc778e60f9d685c3ca37"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:486ee81fa694b4b796fc5617e376326a088f7b9729c74d9defa211813f3861e4"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:1285648428a6101b5f41a18991c84f1c3959cee359e51b8375c5882fc364a13f"}, - {file = "coverage-7.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:2c44fcfb3781b41409d0f060a4ed748537557de9362a8a9282182fafb7a76ab4"}, - {file = "coverage-7.0.1-cp37-cp37m-win32.whl", hash = "sha256:d6814854c02cbcd9c873c0f3286a02e3ac1250625cca822ca6bc1018c5b19f1c"}, - {file = "coverage-7.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f66460f17c9319ea4f91c165d46840314f0a7c004720b20be58594d162a441d8"}, - {file = "coverage-7.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b373c9345c584bb4b5f5b8840df7f4ab48c4cbb7934b58d52c57020d911b856"}, - {file = "coverage-7.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d3022c3007d3267a880b5adcf18c2a9bf1fc64469b394a804886b401959b8742"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92651580bd46519067e36493acb394ea0607b55b45bd81dd4e26379ed1871f55"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3cfc595d2af13856505631be072835c59f1acf30028d1c860b435c5fc9c15b69"}, - {file = "coverage-7.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b4b3a4d9915b2be879aff6299c0a6129f3d08a775d5a061f503cf79571f73e4"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b6f22bb64cc39bcb883e5910f99a27b200fdc14cdd79df8696fa96b0005c9444"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72d1507f152abacea81f65fee38e4ef3ac3c02ff8bc16f21d935fd3a8a4ad910"}, - {file = "coverage-7.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a79137fc99815fff6a852c233628e735ec15903cfd16da0f229d9c4d45926ab"}, - {file = "coverage-7.0.1-cp38-cp38-win32.whl", hash = "sha256:b3763e7fcade2ff6c8e62340af9277f54336920489ceb6a8cd6cc96da52fcc62"}, - {file = "coverage-7.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:09f6b5a8415b6b3e136d5fec62b552972187265cb705097bf030eb9d4ffb9b60"}, - {file = "coverage-7.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:978258fec36c154b5e250d356c59af7d4c3ba02bef4b99cda90b6029441d797d"}, - {file = "coverage-7.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:19ec666533f0f70a0993f88b8273057b96c07b9d26457b41863ccd021a043b9a"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfded268092a84605f1cc19e5c737f9ce630a8900a3589e9289622db161967e9"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07bcfb1d8ac94af886b54e18a88b393f6a73d5959bb31e46644a02453c36e475"}, - {file = "coverage-7.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:397b4a923cc7566bbc7ae2dfd0ba5a039b61d19c740f1373791f2ebd11caea59"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:aec2d1515d9d39ff270059fd3afbb3b44e6ec5758af73caf18991807138c7118"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c20cfebcc149a4c212f6491a5f9ff56f41829cd4f607b5be71bb2d530ef243b1"}, - {file = "coverage-7.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fd556ff16a57a070ce4f31c635953cc44e25244f91a0378c6e9bdfd40fdb249f"}, - {file = "coverage-7.0.1-cp39-cp39-win32.whl", hash = "sha256:b9ea158775c7c2d3e54530a92da79496fb3fb577c876eec761c23e028f1e216c"}, - {file = "coverage-7.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:d1991f1dd95eba69d2cd7708ff6c2bbd2426160ffc73c2b81f617a053ebcb1a8"}, - {file = "coverage-7.0.1-pp37.pp38.pp39-none-any.whl", hash = "sha256:3dd4ee135e08037f458425b8842d24a95a0961831a33f89685ff86b77d378f89"}, - {file = "coverage-7.0.1.tar.gz", hash = "sha256:a4a574a19eeb67575a5328a5760bbbb737faa685616586a9f9da4281f940109c"}, + {file = "coverage-7.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f7c51b6074a8a3063c341953dffe48fd6674f8e4b1d3c8aa8a91f58d6e716a8"}, + {file = "coverage-7.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:628f47eaf66727fc986d3b190d6fa32f5e6b7754a243919d28bc0fd7974c449f"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e89d5abf86c104de808108a25d171ad646c07eda96ca76c8b237b94b9c71e518"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75e43c6f4ea4d122dac389aabdf9d4f0e160770a75e63372f88005d90f5bcc80"}, + {file = "coverage-7.0.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49da0ff241827ebb52d5d6d5a36d33b455fa5e721d44689c95df99fd8db82437"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0bce4ad5bdd0b02e177a085d28d2cea5fc57bb4ba2cead395e763e34cf934eb1"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:f79691335257d60951638dd43576b9bcd6f52baa5c1c2cd07a509bb003238372"}, + {file = "coverage-7.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5722269ed05fbdb94eef431787c66b66260ff3125d1a9afcc00facff8c45adf9"}, + {file = "coverage-7.0.3-cp310-cp310-win32.whl", hash = "sha256:bdbda870e0fda7dd0fe7db7135ca226ec4c1ade8aa76e96614829b56ca491012"}, + {file = "coverage-7.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:e56fae4292e216b8deeee38ace84557b9fa85b52db005368a275427cdabb8192"}, + {file = "coverage-7.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b82343a5bc51627b9d606f0b6b6b9551db7b6311a5dd920fa52a94beae2e8959"}, + {file = "coverage-7.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fd0a8aa431f9b7ad9eb8264f55ef83cbb254962af3775092fb6e93890dea9ca2"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:112cfead1bd22eada8a8db9ed387bd3e8be5528debc42b5d3c1f7da4ffaf9fb5"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:af87e906355fa42447be5c08c5d44e6e1c005bf142f303f726ddf5ed6e0c8a4d"}, + {file = "coverage-7.0.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f30090e22a301952c5abd0e493a1c8358b4f0b368b49fa3e4568ed3ed68b8d1f"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae871d09901911eedda1981ea6fd0f62a999107293cdc4c4fd612321c5b34745"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ed7c9debf7bfc63c9b9f8b595409237774ff4b061bf29fba6f53b287a2fdeab9"}, + {file = "coverage-7.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:13121fa22dcd2c7b19c5161e3fd725692448f05377b788da4502a383573227b3"}, + {file = "coverage-7.0.3-cp311-cp311-win32.whl", hash = "sha256:037b51ee86bc600f99b3b957c20a172431c35c2ef9c1ca34bc813ab5b51fd9f5"}, + {file = "coverage-7.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:25fde928306034e8deecd5fc91a07432dcc282c8acb76749581a28963c9f4f3f"}, + {file = "coverage-7.0.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7e8b0642c38b3d3b3c01417643ccc645345b03c32a2e84ef93cdd6844d6fe530"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18b09811f849cc958d23f733a350a66b54a8de3fed1e6128ba55a5c97ffb6f65"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:349d0b545520e8516f7b4f12373afc705d17d901e1de6a37a20e4ec9332b61f7"}, + {file = "coverage-7.0.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5b38813eee5b4739f505d94247604c72eae626d5088a16dd77b08b8b1724ab3"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ba9af1218fa01b1f11c72271bc7290b701d11ad4dbc2ae97c445ecacf6858dba"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c5648c7eec5cf1ba5db1cf2d6c10036a582d7f09e172990474a122e30c841361"}, + {file = "coverage-7.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:d0df04495b76a885bfef009f45eebe8fe2fbf815ad7a83dabcf5aced62f33162"}, + {file = "coverage-7.0.3-cp37-cp37m-win32.whl", hash = "sha256:af6cef3796b8068713a48dd67d258dc9a6e2ebc3bd4645bfac03a09672fa5d20"}, + {file = "coverage-7.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:62ef3800c4058844e2e3fa35faa9dd0ccde8a8aba6c763aae50342e00d4479d4"}, + {file = "coverage-7.0.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acef7f3a3825a2d218a03dd02f5f3cc7f27aa31d882dd780191d1ad101120d74"}, + {file = "coverage-7.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a530663a361eb27375cec28aea5cd282089b5e4b022ae451c4c3493b026a68a5"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c58cd6bb46dcb922e0d5792850aab5964433d511b3a020867650f8d930dde4f4"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f918e9ef4c98f477a5458238dde2a1643aed956c7213873ab6b6b82e32b8ef61"}, + {file = "coverage-7.0.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b865aa679bee7fbd1c55960940dbd3252621dd81468268786c67122bbd15343"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:c5d9b480ebae60fc2cbc8d6865194136bc690538fa542ba58726433bed6e04cc"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:985ad2af5ec3dbb4fd75d5b0735752c527ad183455520055a08cf8d6794cabfc"}, + {file = "coverage-7.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ca15308ef722f120967af7474ba6a453e0f5b6f331251e20b8145497cf1bc14a"}, + {file = "coverage-7.0.3-cp38-cp38-win32.whl", hash = "sha256:c1cee10662c25c94415bbb987f2ec0e6ba9e8fce786334b10be7e6a7ab958f69"}, + {file = "coverage-7.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:44d6a556de4418f1f3bfd57094b8c49f0408df5a433cf0d253eeb3075261c762"}, + {file = "coverage-7.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e6dcc70a25cb95df0ae33dfc701de9b09c37f7dd9f00394d684a5b57257f8246"}, + {file = "coverage-7.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bf76d79dfaea802f0f28f50153ffbc1a74ae1ee73e480baeda410b4f3e7ab25f"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88834e5d56d01c141c29deedacba5773fe0bed900b1edc957595a8a6c0da1c3c"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef001a60e888f8741e42e5aa79ae55c91be73761e4df5e806efca1ddd62fd400"}, + {file = "coverage-7.0.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4959dc506be74e4963bd2c42f7b87d8e4b289891201e19ec551e64c6aa5441f8"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b791beb17b32ac019a78cfbe6184f992b6273fdca31145b928ad2099435e2fcb"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b07651e3b9af8f1a092861d88b4c74d913634a7f1f2280fca0ad041ad84e9e96"}, + {file = "coverage-7.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:55e46fa4168ccb7497c9be78627fcb147e06f474f846a10d55feeb5108a24ef0"}, + {file = "coverage-7.0.3-cp39-cp39-win32.whl", hash = "sha256:e3f1cd1cd65695b1540b3cf7828d05b3515974a9d7c7530f762ac40f58a18161"}, + {file = "coverage-7.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d8249666c23683f74f8f93aeaa8794ac87cc61c40ff70374a825f3352a4371dc"}, + {file = "coverage-7.0.3-pp37.pp38.pp39-none-any.whl", hash = "sha256:b1ffc8f58b81baed3f8962e28c30d99442079b82ce1ec836a1f67c0accad91c1"}, + {file = "coverage-7.0.3.tar.gz", hash = "sha256:d5be4e93acce64f516bf4fd239c0e6118fc913c93fa1a3f52d15bdcc60d97b2d"}, ] distlib = [ {file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"}, @@ -1331,8 +1331,8 @@ gitpython = [ {file = "GitPython-3.1.30.tar.gz", hash = "sha256:769c2d83e13f5d938b7688479da374c4e3d49f71549aaf462b646db9602ea6f8"}, ] griffe = [ - {file = "griffe-0.25.2-py3-none-any.whl", hash = "sha256:0868da415c5f43fe186705c041d98b69523c24a6504e841031373eacfdd7ec05"}, - {file = "griffe-0.25.2.tar.gz", hash = "sha256:555707b3417355e015d837845522cb38ee4ffcec485427868648eafacabe142e"}, + {file = "griffe-0.25.3-py3-none-any.whl", hash = "sha256:c98e8471a4fc7675a7989f45563a9f7ccbfdfb1713725526d69dec1bbdcda74a"}, + {file = "griffe-0.25.3.tar.gz", hash = "sha256:a71f156851649b3f0bdad6eb6bf7d7ac70e720a30da9f2d5a60e042480e92c03"}, ] h11 = [ {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, @@ -1343,12 +1343,12 @@ httpcore = [ {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, ] httpx = [ - {file = "httpx-0.23.1-py3-none-any.whl", hash = "sha256:0b9b1f0ee18b9978d637b0776bfd7f54e2ca278e063e3586d8f01cda89e042a8"}, - {file = "httpx-0.23.1.tar.gz", hash = "sha256:202ae15319be24efe9a8bd4ed4360e68fde7b38bcc2ce87088d416f026667d19"}, + {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, + {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, ] identify = [ - {file = "identify-2.5.11-py2.py3-none-any.whl", hash = "sha256:e7db36b772b188099616aaf2accbee122949d1c6a1bac4f38196720d6f9f06db"}, - {file = "identify-2.5.11.tar.gz", hash = "sha256:14b7076b29c99b1b0b8b08e96d448c7b877a9b07683cd8cfda2ea06af85ffa1c"}, + {file = "identify-2.5.12-py2.py3-none-any.whl", hash = "sha256:e8a400c3062d980243d27ce10455a52832205649bbcaf27ffddb3dfaaf477bad"}, + {file = "identify-2.5.12.tar.gz", hash = "sha256:0bc96b09c838310b6fcfcc61f78a981ea07f94836ef6ef553da5bb5d4745d662"}, ] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, @@ -1441,8 +1441,8 @@ mkdocstrings = [ {file = "mkdocstrings-0.19.1.tar.gz", hash = "sha256:d1037cacb4b522c1e8c164ed5d00d724a82e49dcee0af80db8fb67b384faeef9"}, ] mkdocstrings-python = [ - {file = "mkdocstrings-python-0.8.2.tar.gz", hash = "sha256:b22528b7a7a0589d007eced019d97ad14de4eba4b2b9ba6a013bb66edc74ab43"}, - {file = "mkdocstrings_python-0.8.2-py3-none-any.whl", hash = "sha256:213d9592e66e084a9bd2fa4956d6294a3487c6dc9cc45164058d6317249b7b6e"}, + {file = "mkdocstrings-python-0.8.3.tar.gz", hash = "sha256:9ae473f6dc599339b09eee17e4d2b05d6ac0ec29860f3fc9b7512d940fc61adf"}, + {file = "mkdocstrings_python-0.8.3-py3-none-any.whl", hash = "sha256:4e6e1cd6f37a785de0946ced6eb846eb2f5d891ac1cc2c7b832943d3529087a7"}, ] msgpack = [ {file = "msgpack-1.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4ab251d229d10498e9a2f3b1e68ef64cb393394ec477e3370c457f9430ce9250"}, @@ -1639,8 +1639,8 @@ pre-commit = [ {file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"}, ] pygments = [ - {file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"}, - {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, + {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, + {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, ] pymdown-extensions = [ {file = "pymdown_extensions-9.9-py3-none-any.whl", hash = "sha256:ac698c15265680db5eb13cd4342abfcde2079ac01e5486028f47a1b41547b859"}, @@ -1745,60 +1745,63 @@ sniffio = [ {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, ] syrupy = [ - {file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"}, - {file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"}, + {file = "syrupy-3.0.6-py3-none-any.whl", hash = "sha256:9c18e22264026b34239bcc87ab7cc8d893eb17236ea7dae634217ea4f22a848d"}, + {file = "syrupy-3.0.6.tar.gz", hash = "sha256:583aa5ca691305c27902c3e29a1ce9da50ff9ab5f184c54b1dc124a16e4a6cf4"}, ] time-machine = [ - {file = "time-machine-2.8.2.tar.gz", hash = "sha256:2ff3cd145c381ac87b1c35400475a8f019b15dc2267861aad0466f55b49e7813"}, - {file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:931f762053031ec76e81d5b97b276d6cbc3c9958fd281a3661a4e4dcd434ae4d"}, - {file = "time_machine-2.8.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2bec6756c46d9e7ccfaeb177fde46da01af74ac9e5862dd9528e501d367f451e"}, - {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:959e63ad6980df1c36aefd19ae746e9b01c2be2f009199ec996fde0443b84de0"}, - {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62db94b5ebe246949e6cedc57e7b96028f18ab9fb63b391d0e94d2e963702e30"}, - {file = "time_machine-2.8.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4b40d872fd025c9ee6924372d345b2788aac9df89eba5562e6464dde04cf99"}, - {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b68259837b59c3bef30c5cff24d73228c5a5821342af624c78707fe297153221"}, - {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:46b4d2763c514d0036f7f46b23836d8fba0240ac1c50df588ca43193a59ee184"}, - {file = "time_machine-2.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f416489bc8d0adb4bd63edcce5ba743b408f3c161ab0e1a65f9f904a6f9a06c0"}, - {file = "time_machine-2.8.2-cp310-cp310-win32.whl", hash = "sha256:94ab54c2062a362059a02e6df624151bfdcda79dab704ffee220bb31f8153e24"}, - {file = "time_machine-2.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:f227819cfa27793e759811dabe6187e8f36dba6ac3a404516e17a81bb0216763"}, - {file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:875eedfdf9cc59a9d119420b35c43a6d7ec08951a86581b4a4dbde47e6327256"}, - {file = "time_machine-2.8.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:01ee31fca1414d1198feff9eb7d062ca42aea9d1c01f63cdd6b2e0bb4f7479a9"}, - {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4387678c392cfb40c038016b04f5becb022bdc371ecabded751c2a116d2c0b5a"}, - {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a42739702fd8ccbf4295aa6a0e5089f0ce125974e06ab157c6e4f4eadbc167c"}, - {file = "time_machine-2.8.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1963e1b9ea4891cbdd8a8f12cfb273dc7d3b0771ffe61238d688a7c2499445ef"}, - {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7c0234c2fae05b4945b711d655af3487df34c466e184fbce7253dfc28c9980d1"}, - {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:19d01c6b6791c3ff45f8c82d149ac28292cf67242b1ace3dc1fdc0494edc111e"}, - {file = "time_machine-2.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b05a2ca1045edd343fa07d2c55d57695c40b7af1e4c7df480d8e1976eb48a22f"}, - {file = "time_machine-2.8.2-cp311-cp311-win32.whl", hash = "sha256:71607d92fd23cd5fc5bcddb3ec6b91a6a1b07f7277e7e58dce0a5c1f67d229cd"}, - {file = "time_machine-2.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9e4c58915b2136041027fb4d795e8844112683e550a9aed24ecde1de8a5a8f2"}, - {file = "time_machine-2.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4b20f55d76cacb8b6f99c4161d8bfd6fc3be8d8ae003df2a79dbda9015d6ab85"}, - {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb64b249df5c2958484706bdc095b326baf0f9c4a96c990d63a6e290680a8933"}, - {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460f3d7344b64c906030013f6ca314017d7cbeb211e6c8c0efbdb3a2f5b168e3"}, - {file = "time_machine-2.8.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ccd0e73e75f9cc624be08a2ae0305617ce7890d5b55f938ba336f086001ac66"}, - {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8856b03574bc88f506534489562dfeb9c057485052817895413d8f33e7d03d28"}, - {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3be539125dc815ff1f1ff05cd00f8839132a4b3a729809fa4a7de405f47cbd0b"}, - {file = "time_machine-2.8.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c3b356e9038abb78618169b86a2bc3488aa2faee27fa97c9cd8638972d60dfe"}, - {file = "time_machine-2.8.2-cp37-cp37m-win32.whl", hash = "sha256:bfbe53b80402ab3c93f112374d8624eb5e7f26395f01aea341bf91b4a512e36e"}, - {file = "time_machine-2.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:71917d38d2c34039a31ac0d63970f6009072a14c3a89169d165ca81130daf308"}, - {file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a3384f03776ffed86afdc2a807aa80fc656fbce6605e9b89261fc17302759290"}, - {file = "time_machine-2.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6d084ccfbf30c658c23b1340583aa64afe4c6421b4d2ab3a84769915630e0d68"}, - {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ed6c02afa3fc48af1fa256d5a3a18b63c3e36e7759fec8184e340e1b2f38f77"}, - {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c783769cc7b722e4b9df6015919a65952e58eb6fe884c198c1f56d58d883d0bc"}, - {file = "time_machine-2.8.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5da17b12c20d96b69bbe71d1e260e76c81072cded63539050d0f8aa26e9701dc"}, - {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0213c32498190d7701cf90dc8a4f87d6d8571b856a16b474072e37f9e4daf896"}, - {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c47caacc5a00656ee9e4ad4600ed46e036f233bbd93ed99c0da5f3dcec6a1a64"}, - {file = "time_machine-2.8.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0e7950776b9087ba8e44f3602e5d695eaba853518c9963f41f3cba094000d87f"}, - {file = "time_machine-2.8.2-cp38-cp38-win32.whl", hash = "sha256:8bb1e68434a6c45bf2ef5d738420399803e7aa8211d77353e416d5043f82053e"}, - {file = "time_machine-2.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:f67957dac20cca1171a7b63a8343c86f4f589e42f3c61bce687e77dd475e4d88"}, - {file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:18d60cb6eb2bb896ef442628be783d2ddf374873caefb083cbc2b2ed19361157"}, - {file = "time_machine-2.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:82055dc781c4c9f6c97f3a349473ab44f1096da61a8cf1e72c105d12a39344ea"}, - {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfaa1018ea5695a47f9536e1c7f7a112d55741162d8cdaa49801b3977f710666"}, - {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f9c6bdead992708d3f88e9e337f08f9067e259eb6a7df23f94652cee7f08459"}, - {file = "time_machine-2.8.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e6ba08062248fd9ba750ca997ed8699176d71b0d3aa525333efbd10e644f574"}, - {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7d7233bb7a01d27e93fd8f687227fb93d314fb5048127844c248d76067b36e84"}, - {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0cb22588e0c88239bad7ac5d593dc1119aacb7ac074e7aa2badc53583b92febf"}, - {file = "time_machine-2.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ba71634179448df5dc6fb85d61e3956c8e33755ad3f76549dacb9c4854e88046"}, - {file = "time_machine-2.8.2-cp39-cp39-win32.whl", hash = "sha256:70ccbd8c5c4396fe4d60b0ceacef47f95e44f84a4d1d8cd5acdf9f81880e863a"}, - {file = "time_machine-2.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:32f77a14ffbaeef8ae5e5bb86eb0e76057b56cb94f1f4990756c66047f8cac91"}, + {file = "time-machine-2.9.0.tar.gz", hash = "sha256:60222d43f6e93a926adc36ed37a54bc8e4d0d8d1c4d449096afcfe85086129c2"}, + {file = "time_machine-2.9.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fd72c0b2e7443fff6e4481991742b72c17f73735e5fdd176406ca48df187a5c9"}, + {file = "time_machine-2.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5657e0e6077cf15b37f0d8cf78e868113bbb3ecccc60064c40fe52d8166ca8b1"}, + {file = "time_machine-2.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfa82614a98ecee70272bb6038d210b2ad7b2a6b8a678b400c34bdaf776802a7"}, + {file = "time_machine-2.9.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d4380bd6697cc7db3c9e6843f24779ac0550affa9d9a8e5f9e5d5cc139cb6583"}, + {file = "time_machine-2.9.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6211beee9f5dace08b1bbbb1fb09e34a69c52d87eea676729f14c8660481dff6"}, + {file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:68ec8b83197db32c7a12da5f6b83c91271af3ed7f5dc122d2900a8de01dff9f0"}, + {file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c5dbc8b87cdc7be070a499f2bd1cd405c7f647abeb3447dfd397639df040bc64"}, + {file = "time_machine-2.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:948ca690f9770ad4a93fa183061c11346505598f5f0b721965bc85ec83bb103d"}, + {file = "time_machine-2.9.0-cp310-cp310-win32.whl", hash = "sha256:f92d5d2eb119a6518755c4c9170112094c706d1c604460f50afc1308eeb97f0e"}, + {file = "time_machine-2.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb51432652ad663b4cbd631c73c90f9e94f463382b86c0b6b854173700512a70"}, + {file = "time_machine-2.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:8976b7b1f7de13598b655d459f5640f90f3cd587283e1b914a22e45946c5485b"}, + {file = "time_machine-2.9.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6463e302c96eb8c691c4340e281bd54327a213b924fa189aea81accf7e7f78df"}, + {file = "time_machine-2.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6b632d60aa0883dc7292ac3d32050604d26ec2bbd5c4d42fb0de3b4ef17343e2"}, + {file = "time_machine-2.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d329578abe47ce95baa015ef3825acebb1b73b5fa6f818fdf2d4685a00ca457f"}, + {file = "time_machine-2.9.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9ba5fc2655749066d68986de8368984dad4082db2fbeade78f40506dc5b65672"}, + {file = "time_machine-2.9.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49df5eea2160068e5b2bf28c22fc4c5aea00862ad88ddc3b62fc0f0683e97538"}, + {file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8830510adbf0a231184da277db9de1d55ef93ed228a575d217aaee295505abf1"}, + {file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:b16a2129f9146faa080bfd1b53447761f7386ec5c72890c827a65f33ab200336"}, + {file = "time_machine-2.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a2cf80e5deaaa68c6cefb25303a4c870490b4e7591ed8e2435a65728920bc097"}, + {file = "time_machine-2.9.0-cp311-cp311-win32.whl", hash = "sha256:fe013942ab7f3241fcbe66ee43222d47f499d1e0cb69e913791c52e638ddd7f0"}, + {file = "time_machine-2.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:1d0ab46ce8a60baf9d86525694bf698fed9efefd22b8cbe1ca3e74abbb3239e1"}, + {file = "time_machine-2.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:4f3755d9342ca1f1019418db52072272dfd75eb818fa4726fa8aabe208b38c26"}, + {file = "time_machine-2.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9ee553f7732fa51e019e3329a6984593184c4e0410af1e73d91ce38a5d4b34ab"}, + {file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:359c806e5b9a7a3c73dbb808d19dca297f5504a5eefdc5d031db8d918f43e364"}, + {file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e2a90b8300812d8d774f2d2fc216fec3c7d94132ac589e062489c395061f16c"}, + {file = "time_machine-2.9.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36dde844d28549929fab171d683c28a8db1c206547bcf6b7aca77319847d2046"}, + {file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:728263611d7940fda34d21573bd2b3f1491bdb52dbf75c5fe6c226dfe4655201"}, + {file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8bcc86b5a07ea9745f26dfad958dde0a4f56748c2ae0c9a96200a334d1b55055"}, + {file = "time_machine-2.9.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b9c36240876622b7f2f9e11bf72f100857c0a1e1a59af2da3d5067efea62c37"}, + {file = "time_machine-2.9.0-cp37-cp37m-win32.whl", hash = "sha256:eaf334477bc0a9283d5150a56be8670a07295ef676e5b5a7f086952929d1a56b"}, + {file = "time_machine-2.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:8e797e5a2a99d1b237183e52251abfc1ad85c376278b39d1aca76a451a97861a"}, + {file = "time_machine-2.9.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:69898aed9b2315a90f5855343d9aa34d05fa06032e2e3bb14f2528941ec89dc1"}, + {file = "time_machine-2.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c01dbc3671d0649023daf623e952f9f0b4d904d57ab546d6d35a4aeb14915e8d"}, + {file = "time_machine-2.9.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f080f6f7ca8cfca43bc5639288aebd0a273b4b5bd0acff609c2318728b13a18"}, + {file = "time_machine-2.9.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8670cb5cfda99f483d60de6ce56ceb0ec5d359193e79e4688e1c3c9db3937383"}, + {file = "time_machine-2.9.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f97ed8bc5b517844a71030f74e9561de92f4902c306e6ccc8331a5b0c8dd0e00"}, + {file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:bdbe785e046d124f73cca603ee37d5fae0b15dc4c13702488ad19de56aae08ba"}, + {file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fcdef7687aed5c4331c9808f4a414a41987441c3e7a2ba554e4dccfa4218e788"}, + {file = "time_machine-2.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f6e79643368828d4651146a486be5a662846ac223ab5e2c73ddd519acfcc243c"}, + {file = "time_machine-2.9.0-cp38-cp38-win32.whl", hash = "sha256:bb15b2b79b00d3f6cf7d62096f5e782fa740ecedfe0540c09f1d1e4d3d7b81ba"}, + {file = "time_machine-2.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:3ff5148e2e73392db8418a1fe2f0b06f4a0e76772933502fb61e4c3000b5324e"}, + {file = "time_machine-2.9.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8367fd03f2d7349c7fc20f14de186974eaca2502c64b948212de663742c8fd11"}, + {file = "time_machine-2.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4b55654aaeaba380fcd6c004b8ada2978fdd4ece1e61e6b9717c6d4cc7fbbcd9"}, + {file = "time_machine-2.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae4e3f02ab5dabb35adca606237c7e1a515c86d69c0b7092bbe0e1cfe5cffc61"}, + {file = "time_machine-2.9.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:010a58a8de1120308befae19e6c9de2ef5ca5206635cea33cb264998725cc027"}, + {file = "time_machine-2.9.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b32addbf56639a9a8261fb62f8ea83473447671c83ca2c017ab1eabf4841157f"}, + {file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:372a97da01db89533d2f4ce50bbd908e5c56df7b8cfd6a005b177d0b14dc2938"}, + {file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b8faff03231ee55d5a216ce3e9171c5205459f866f54d4b5ee8aa1d860e4ce11"}, + {file = "time_machine-2.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:748d701228e646c224f2adfa6a11b986cd4aa90f1b8c13ef4534a3919c796bc0"}, + {file = "time_machine-2.9.0-cp39-cp39-win32.whl", hash = "sha256:d79d374e32488c76cdb06fbdd4464083aeaa715ddca3e864bac7c7760eb03729"}, + {file = "time_machine-2.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:cc6bf01211b5ea40f633d5502c5aa495b415ebaff66e041820997dae70a508e1"}, + {file = "time_machine-2.9.0-cp39-cp39-win_arm64.whl", hash = "sha256:3ce445775fcf7cb4040cfdba4b7c4888e7fd98bbcccfe1dc3fa8a798ed1f1d24"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1851,34 +1854,34 @@ virtualenv = [ {file = "virtualenv-20.17.1.tar.gz", hash = "sha256:f8b927684efc6f1cc206c9db297a570ab9ad0e51c16fa9e45487d36d1905c058"}, ] watchdog = [ - {file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ed91c3ccfc23398e7aa9715abf679d5c163394b8cad994f34f156d57a7c163dc"}, - {file = "watchdog-2.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:76a2743402b794629a955d96ea2e240bd0e903aa26e02e93cd2d57b33900962b"}, - {file = "watchdog-2.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:920a4bda7daa47545c3201a3292e99300ba81ca26b7569575bd086c865889090"}, - {file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ceaa9268d81205876bedb1069f9feab3eccddd4b90d9a45d06a0df592a04cae9"}, - {file = "watchdog-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1893d425ef4fb4f129ee8ef72226836619c2950dd0559bba022b0818c63a7b60"}, - {file = "watchdog-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9e99c1713e4436d2563f5828c8910e5ff25abd6ce999e75f15c15d81d41980b6"}, - {file = "watchdog-2.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:a5bd9e8656d07cae89ac464ee4bcb6f1b9cecbedc3bf1334683bed3d5afd39ba"}, - {file = "watchdog-2.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3a048865c828389cb06c0bebf8a883cec3ae58ad3e366bcc38c61d8455a3138f"}, - {file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e722755d995035dd32177a9c633d158f2ec604f2a358b545bba5bed53ab25bca"}, - {file = "watchdog-2.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:af4b5c7ba60206759a1d99811b5938ca666ea9562a1052b410637bb96ff97512"}, - {file = "watchdog-2.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:619d63fa5be69f89ff3a93e165e602c08ed8da402ca42b99cd59a8ec115673e1"}, - {file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f2b0665c57358ce9786f06f5475bc083fea9d81ecc0efa4733fd0c320940a37"}, - {file = "watchdog-2.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:441024df19253bb108d3a8a5de7a186003d68564084576fecf7333a441271ef7"}, - {file = "watchdog-2.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1a410dd4d0adcc86b4c71d1317ba2ea2c92babaf5b83321e4bde2514525544d5"}, - {file = "watchdog-2.2.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28704c71afdb79c3f215c90231e41c52b056ea880b6be6cee035c6149d658ed1"}, - {file = "watchdog-2.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2ac0bd7c206bb6df78ef9e8ad27cc1346f2b41b1fef610395607319cdab89bc1"}, - {file = "watchdog-2.2.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:27e49268735b3c27310883012ab3bd86ea0a96dcab90fe3feb682472e30c90f3"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:2af1a29fd14fc0a87fb6ed762d3e1ae5694dcde22372eebba50e9e5be47af03c"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:c7bd98813d34bfa9b464cf8122e7d4bec0a5a427399094d2c17dd5f70d59bc61"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_i686.whl", hash = "sha256:56fb3f40fc3deecf6e518303c7533f5e2a722e377b12507f6de891583f1b48aa"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:74535e955359d79d126885e642d3683616e6d9ab3aae0e7dcccd043bd5a3ff4f"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cf05e6ff677b9655c6e9511d02e9cc55e730c4e430b7a54af9c28912294605a4"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:d6ae890798a3560688b441ef086bb66e87af6b400a92749a18b856a134fc0318"}, - {file = "watchdog-2.2.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e5aed2a700a18c194c39c266900d41f3db0c1ebe6b8a0834b9995c835d2ca66e"}, - {file = "watchdog-2.2.0-py3-none-win32.whl", hash = "sha256:d0fb5f2b513556c2abb578c1066f5f467d729f2eb689bc2db0739daf81c6bb7e"}, - {file = "watchdog-2.2.0-py3-none-win_amd64.whl", hash = "sha256:1f8eca9d294a4f194ce9df0d97d19b5598f310950d3ac3dd6e8d25ae456d4c8a"}, - {file = "watchdog-2.2.0-py3-none-win_ia64.whl", hash = "sha256:ad0150536469fa4b693531e497ffe220d5b6cd76ad2eda474a5e641ee204bbb6"}, - {file = "watchdog-2.2.0.tar.gz", hash = "sha256:83cf8bc60d9c613b66a4c018051873d6273d9e45d040eed06d6a96241bd8ec01"}, + {file = "watchdog-2.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a09483249d25cbdb4c268e020cb861c51baab2d1affd9a6affc68ffe6a231260"}, + {file = "watchdog-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5100eae58133355d3ca6c1083a33b81355c4f452afa474c2633bd2fbbba398b3"}, + {file = "watchdog-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e618a4863726bc7a3c64f95c218437f3349fb9d909eb9ea3a1ed3b567417c661"}, + {file = "watchdog-2.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:102a60093090fc3ff76c983367b19849b7cc24ec414a43c0333680106e62aae1"}, + {file = "watchdog-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:748ca797ff59962e83cc8e4b233f87113f3cf247c23e6be58b8a2885c7337aa3"}, + {file = "watchdog-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ccd8d84b9490a82b51b230740468116b8205822ea5fdc700a553d92661253a3"}, + {file = "watchdog-2.2.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6e01d699cd260d59b84da6bda019dce0a3353e3fcc774408ae767fe88ee096b7"}, + {file = "watchdog-2.2.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8586d98c494690482c963ffb24c49bf9c8c2fe0589cec4dc2f753b78d1ec301d"}, + {file = "watchdog-2.2.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:adaf2ece15f3afa33a6b45f76b333a7da9256e1360003032524d61bdb4c422ae"}, + {file = "watchdog-2.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83a7cead445008e880dbde833cb9e5cc7b9a0958edb697a96b936621975f15b9"}, + {file = "watchdog-2.2.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f8ac23ff2c2df4471a61af6490f847633024e5aa120567e08d07af5718c9d092"}, + {file = "watchdog-2.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d0f29fd9f3f149a5277929de33b4f121a04cf84bb494634707cfa8ea8ae106a8"}, + {file = "watchdog-2.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:967636031fa4c4955f0f3f22da3c5c418aa65d50908d31b73b3b3ffd66d60640"}, + {file = "watchdog-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:96cbeb494e6cbe3ae6aacc430e678ce4b4dd3ae5125035f72b6eb4e5e9eb4f4e"}, + {file = "watchdog-2.2.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:61fdb8e9c57baf625e27e1420e7ca17f7d2023929cd0065eb79c83da1dfbeacd"}, + {file = "watchdog-2.2.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4cb5ecc332112017fbdb19ede78d92e29a8165c46b68a0b8ccbd0a154f196d5e"}, + {file = "watchdog-2.2.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a480d122740debf0afac4ddd583c6c0bb519c24f817b42ed6f850e2f6f9d64a8"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:978a1aed55de0b807913b7482d09943b23a2d634040b112bdf31811a422f6344"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:8c28c23972ec9c524967895ccb1954bc6f6d4a557d36e681a36e84368660c4ce"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_i686.whl", hash = "sha256:c27d8c1535fd4474e40a4b5e01f4ba6720bac58e6751c667895cbc5c8a7af33c"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d6b87477752bd86ac5392ecb9eeed92b416898c30bd40c7e2dd03c3146105646"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:cece1aa596027ff56369f0b50a9de209920e1df9ac6d02c7f9e5d8162eb4f02b"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:8b5cde14e5c72b2df5d074774bdff69e9b55da77e102a91f36ef26ca35f9819c"}, + {file = "watchdog-2.2.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e038be858425c4f621900b8ff1a3a1330d9edcfeaa1c0468aeb7e330fb87693e"}, + {file = "watchdog-2.2.1-py3-none-win32.whl", hash = "sha256:bc43c1b24d2f86b6e1cc15f68635a959388219426109233e606517ff7d0a5a73"}, + {file = "watchdog-2.2.1-py3-none-win_amd64.whl", hash = "sha256:17f1708f7410af92ddf591e94ae71a27a13974559e72f7e9fde3ec174b26ba2e"}, + {file = "watchdog-2.2.1-py3-none-win_ia64.whl", hash = "sha256:195ab1d9d611a4c1e5311cbf42273bc541e18ea8c32712f2fb703cfc6ff006f9"}, + {file = "watchdog-2.2.1.tar.gz", hash = "sha256:cdcc23c9528601a8a293eb4369cbd14f6b4f34f07ae8769421252e9c22718b6f"}, ] yarl = [ {file = "yarl-1.8.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bb81f753c815f6b8e2ddd2eef3c855cf7da193b82396ac013c661aaa6cc6b0a5"}, diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index c12b06cbc..47a8be0d9 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -14,6 +14,47 @@ from .css.types import AlignHorizontal, AlignVertical from .geometry import Size +def index_to_cell_position(segments: Iterable[Segment], index: int) -> int: + """Given a character index, return the cell position of that character within + an Iterable of Segments. This is the sum of the cell lengths of all the characters + *before* the character at `index`. + + Args: + segments (Iterable[Segment]): The segments to find the cell position within. + index (int): The index to convert into a cell position. + + Returns: + int: The cell position of the character at `index`. If the `index` is not + valid within the segments, returns 0. + """ + if index == 0: + return 0 + + cell_position_end = 0 + segment_length = 0 + segment_end_index = 0 + segment_cell_length = 0 + text = "" + iter_segments = iter(segments) + try: + while segment_end_index < index: + segment = next(iter_segments) + text = segment.text + segment_length = len(text) + segment_cell_length = cell_len(text) + cell_position_end += segment_cell_length + segment_end_index += segment_length + except StopIteration: + return 0 + + # Check how far into this segment the target index is + segment_index_start = segment_end_index - segment_length + index_within_segment = index - segment_index_start + segment_cell_start = cell_position_end - segment_cell_length + + return segment_cell_start + cell_len(text[:index_within_segment]) + + def line_crop( segments: list[Segment], start: int, end: int, total: int ) -> list[Segment]: diff --git a/src/textual/strip.py b/src/textual/strip.py index be9dc80b0..f7ea831df 100644 --- a/src/textual/strip.py +++ b/src/textual/strip.py @@ -10,6 +10,7 @@ from rich.style import Style from ._cache import FIFOCache from ._filter import LineFilter +from ._segment_tools import index_to_cell_position @rich.repr.auto @@ -79,29 +80,7 @@ class Strip: Returns: int: The cell position of the character at `index`. """ - if index == 0: - return 0 - - cell_position_end = 0 - segment_length = 0 - segment_end_index = 0 - segment_cell_length = 0 - text = "" - iter_segments = iter(self) - while segment_end_index < index: - segment = next(iter_segments) - text = segment.text - segment_length = len(text) - segment_cell_length = cell_len(text) - cell_position_end += segment_cell_length - segment_end_index += segment_length - - # Check how far into this segment the target index is - segment_index_start = segment_end_index - segment_length - index_within_segment = index - segment_index_start - segment_cell_start = cell_position_end - segment_cell_length - - return segment_cell_start + cell_len(text[:index_within_segment]) + return index_to_cell_position(self._segments, index) @property def cell_length(self) -> int: diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index 7483d2b15..a5dd592a1 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -1,3 +1,4 @@ +import pytest from rich.segment import Segment from rich.style import Style @@ -63,17 +64,6 @@ def test_line_crop_edge_2(): assert result == expected -def test_line_crop_highlight_reverse_bug(): - """Regression test for #818""" - segments_joined = [Segment('a1あ11bcdaef123a1a')] - segments_split = [Segment('a1あ11bcdaef'), Segment('1'), Segment('23a1a')] - - joined1 = "".join(seg.text for seg in line_crop(segments_split, start=9, end=16, total=23)) - joined2 = "".join(seg.text for seg in line_crop(segments_joined, start=9, end=16, total=23)) - - assert joined1 == joined2 - - def test_line_trim(): segments = [Segment("foo")] diff --git a/tests/test_strip.py b/tests/test_strip.py index 15d527119..473063503 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -160,3 +160,8 @@ def test_divide(): def test_index_to_cell_position(index, cell_position): strip = Strip([Segment("ab"), Segment("cd日本語ef"), Segment("gh")]) assert cell_position == strip.index_to_cell_position(index) + + +def test_index_cell_position_no_segments(): + strip = Strip([]) + assert strip.index_to_cell_position(2) == 0 From 433c86d4b9a8139ea851eb8c08ec797ca0a94c64 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 13:59:36 +0000 Subject: [PATCH 16/47] Remove unused test file --- tests/test_input.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 tests/test_input.py diff --git a/tests/test_input.py b/tests/test_input.py deleted file mode 100644 index 87971757b..000000000 --- a/tests/test_input.py +++ /dev/null @@ -1,8 +0,0 @@ -from textual.widgets._input import _InputRenderable, Input - - -def test_input_renderable(): - input_widget = Input(value="a1あ11bcdaef123a1a") - - renderable_cursor = _InputRenderable(input_widget, cursor_visible=True) - renderable_no_cursor = _InputRenderable(input_widget, cursor_visible=False) From c90e12a35bea42d18336867ba2fbe4dd4ddc6ea2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 13:59:52 +0000 Subject: [PATCH 17/47] Remove unused import in test file --- tests/test_segment_tools.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_segment_tools.py b/tests/test_segment_tools.py index a5dd592a1..630114ed9 100644 --- a/tests/test_segment_tools.py +++ b/tests/test_segment_tools.py @@ -1,4 +1,3 @@ -import pytest from rich.segment import Segment from rich.style import Style From 387a045cff816c866bd847c9339fc5364b98f3c8 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 14:32:23 +0000 Subject: [PATCH 18/47] Fix mouse01 example background transparency --- docs/examples/guide/input/mouse01.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/examples/guide/input/mouse01.css b/docs/examples/guide/input/mouse01.css index 95685d023..a37245093 100644 --- a/docs/examples/guide/input/mouse01.css +++ b/docs/examples/guide/input/mouse01.css @@ -6,17 +6,17 @@ TextLog { layer: log; } -PlayArea { - background: transparent; +PlayArea { + opacity: 0%; layer: ball; - + } Ball { layer: ball; width: auto; height: 1; background: $secondary; - border: tall $secondary; + border: tall $secondary; color: $background; box-sizing: content-box; text-style: bold; From 597a8923e9eb07cc7f00ff0e40c3fd37f6f00c18 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 6 Jan 2023 15:32:04 +0000 Subject: [PATCH 19/47] run black on tests --- .pre-commit-config.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0a091f72f..bee80413b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,5 +12,4 @@ repos: rev: 22.10.0 hooks: - id: black - exclude: ^tests/ exclude: ^tests/snapshot_tests From a133bd84dea10ffd24b55002a957453b6fdb0931 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Fri, 6 Jan 2023 16:28:34 +0000 Subject: [PATCH 20/47] Ensure pretty traceback for error in Widget compose method (#1505) * Ensure pretty traceback for error in Widget compose method * Fail fast and pretty tracebacks for Widget compose errors --- CHANGELOG.md | 1 + src/textual/widget.py | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64be93e2f..6eed95593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458 +- Fail-fast and print pretty tracebacks for Widget compose errors https://github.com/Textualize/textual/pull/1505 ### Fixed diff --git a/src/textual/widget.py b/src/textual/widget.py index 65ff0bc0d..185d18a64 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -33,6 +33,7 @@ from rich.measure import Measurement from rich.segment import Segment from rich.style import Style from rich.text import Text +from rich.traceback import Traceback from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction @@ -2333,7 +2334,10 @@ class Widget(DOMNode): raise TypeError( f"{self!r} compose() returned an invalid response; {error}" ) from None - await self.mount(*widgets) + except Exception: + self.app.panic(Traceback()) + else: + await self.mount(*widgets) def _on_mount(self, event: events.Mount) -> None: if self.styles.overflow_y == "scroll": From 3fe855296c40a59937f8399236be0aad251da26f Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 6 Jan 2023 16:34:57 +0000 Subject: [PATCH 21/47] Raise exception from index to cell position method --- src/textual/_segment_tools.py | 15 ++++++++++++--- tests/test_strip.py | 10 +++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/textual/_segment_tools.py b/src/textual/_segment_tools.py index 47a8be0d9..3ccfcf97a 100644 --- a/src/textual/_segment_tools.py +++ b/src/textual/_segment_tools.py @@ -14,6 +14,10 @@ from .css.types import AlignHorizontal, AlignVertical from .geometry import Size +class NoCellPositionForIndex(Exception): + pass + + def index_to_cell_position(segments: Iterable[Segment], index: int) -> int: """Given a character index, return the cell position of that character within an Iterable of Segments. This is the sum of the cell lengths of all the characters @@ -24,9 +28,14 @@ def index_to_cell_position(segments: Iterable[Segment], index: int) -> int: index (int): The index to convert into a cell position. Returns: - int: The cell position of the character at `index`. If the `index` is not - valid within the segments, returns 0. + int: The cell position of the character at `index`. + + Raises: + NoCellPositionForIndex: If the supplied index doesn't fall within the given segments. """ + if not segments: + raise NoCellPositionForIndex + if index == 0: return 0 @@ -45,7 +54,7 @@ def index_to_cell_position(segments: Iterable[Segment], index: int) -> int: cell_position_end += segment_cell_length segment_end_index += segment_length except StopIteration: - return 0 + raise NoCellPositionForIndex # Check how far into this segment the target index is segment_index_start = segment_end_index - segment_length diff --git a/tests/test_strip.py b/tests/test_strip.py index 473063503..7674c2fd8 100644 --- a/tests/test_strip.py +++ b/tests/test_strip.py @@ -2,6 +2,7 @@ import pytest from rich.segment import Segment from rich.style import Style +from textual._segment_tools import NoCellPositionForIndex from textual.strip import Strip from textual._filter import Monochrome @@ -164,4 +165,11 @@ def test_index_to_cell_position(index, cell_position): def test_index_cell_position_no_segments(): strip = Strip([]) - assert strip.index_to_cell_position(2) == 0 + with pytest.raises(NoCellPositionForIndex): + strip.index_to_cell_position(2) + + +def test_index_cell_position_index_too_large(): + strip = Strip([Segment("abcdef"), Segment("ghi")]) + with pytest.raises(NoCellPositionForIndex): + strip.index_to_cell_position(100) From 0db3fa6701ec1239aa4b08310f95cdcb0a52b9da 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, 6 Jan 2023 18:25:01 +0000 Subject: [PATCH 22/47] Simplify Placeholder implementation. Instead of creating a private widget that is the only child of 'Placeholder', that was inheriting from 'Container', simplify everything. 'Placeholder' now inherits directly from 'Widget' and it saves a dictionary with its renderables per variant, instead of deferring that to the child '_PlaceholderLabel'. --- src/textual/widgets/_placeholder.py | 80 ++++++++--------------------- 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index eabe762c9..137a0fd1c 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -3,7 +3,6 @@ from __future__ import annotations from itertools import cycle from .. import events -from ..containers import Container from ..css._error_tools import friendly_list from ..reactive import Reactive, reactive from ..widget import Widget, RenderResult @@ -36,19 +35,10 @@ _LOREM_IPSUM_PLACEHOLDER_TEXT = "Lorem ipsum dolor sit amet, consectetur adipisc class InvalidPlaceholderVariant(Exception): - pass + """Raised when an invalid Placeholder variant is set.""" -class _PlaceholderLabel(Widget): - def __init__(self, content, classes) -> None: - super().__init__(classes=classes) - self._content = content - - def render(self) -> RenderResult: - return self._content - - -class Placeholder(Container): +class Placeholder(Widget): """A simple placeholder widget to use before you build your custom widgets. This placeholder has a couple of variants that show different data. @@ -63,44 +53,24 @@ class Placeholder(Container): DEFAULT_CSS = """ Placeholder { - align: center middle; + content-align: center middle; overflow: hidden; + color: $text; } Placeholder.-text { padding: 1; } - - _PlaceholderLabel { - height: auto; - color: $text; - } - - Placeholder > _PlaceholderLabel { - content-align: center middle; - } - - Placeholder.-default > _PlaceholderLabel.-size, - Placeholder.-default > _PlaceholderLabel.-text, - Placeholder.-size > _PlaceholderLabel.-default, - Placeholder.-size > _PlaceholderLabel.-text, - Placeholder.-text > _PlaceholderLabel.-default, - Placeholder.-text > _PlaceholderLabel.-size { - display: none; - } - - Placeholder.-default > _PlaceholderLabel.-default, - Placeholder.-size > _PlaceholderLabel.-size, - Placeholder.-text > _PlaceholderLabel.-text { - display: block; - } """ + # Consecutive placeholders get assigned consecutive colors. _COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS) _SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]" variant: Reactive[PlaceholderVariant] = reactive("default") + _renderables: dict[PlaceholderVariant, RenderResult] + @classmethod def reset_color_cycle(cls) -> None: """Reset the placeholder background color cycle.""" @@ -128,27 +98,14 @@ class Placeholder(Container): classes (str | None, optional): A space separated string with the CSS classes of the placeholder, if any. Defaults to None. """ - # Create and cache labels for all the variants. - self._default_label = _PlaceholderLabel( - label if label else f"#{id}" if id else "Placeholder", - "-default", - ) - self._size_label = _PlaceholderLabel( - "", - "-size", - ) - self._text_label = _PlaceholderLabel( - "\n\n".join(_LOREM_IPSUM_PLACEHOLDER_TEXT for _ in range(5)), - "-text", - ) - super().__init__( - self._default_label, - self._size_label, - self._text_label, - name=name, - id=id, - classes=classes, - ) + # Create and cache renderables for all the variants. + self._renderables = { + "default": label if label else f"#{id}" if id else "Placeholder", + "size": "", + "text": "\n\n".join(_LOREM_IPSUM_PLACEHOLDER_TEXT for _ in range(5)), + } + + super().__init__(name=name, id=id, classes=classes) self.styles.background = f"{next(Placeholder._COLORS)} 50%" @@ -158,6 +115,9 @@ class Placeholder(Container): while next(self._variants_cycle) != self.variant: pass + def render(self) -> RenderResult: + return self._renderables[self.variant] + def cycle_variant(self) -> None: """Get the next variant in the cycle.""" self.variant = next(self._variants_cycle) @@ -183,6 +143,6 @@ class Placeholder(Container): def on_resize(self, event: events.Resize) -> None: """Update the placeholder "size" variant with the new placeholder size.""" - self._size_label._content = self._SIZE_RENDER_TEMPLATE.format(*self.size) + self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*self.size) if self.variant == "size": - self._size_label.refresh(layout=True) + self.refresh(layout=True) From d08699ff503551ef4a60a22a24df024245810e02 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, 6 Jan 2023 18:30:39 +0000 Subject: [PATCH 23/47] Update placeholder snapshot test. Although there was no visual difference in the output, the snapshot tool was complaining because there are now less things to draw on the screen, thus the snapshots looked different. Hence, the placeholder snapshot needed to be updated. --- .../__snapshots__/test_snapshots.ambr | 135 +++++++++--------- 1 file changed, 66 insertions(+), 69 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 4a9464a81..d920e8d87 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -7800,145 +7800,142 @@ font-weight: 700; } - .terminal-2023815619-matrix { + .terminal-1570661136-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2023815619-title { + .terminal-1570661136-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2023815619-r1 { fill: #e8e0e7 } - .terminal-2023815619-r2 { fill: #c5c8c6 } - .terminal-2023815619-r3 { fill: #eae3e5 } - .terminal-2023815619-r4 { fill: #ede6e6 } - .terminal-2023815619-r5 { fill: #efe9e4 } - .terminal-2023815619-r6 { fill: #efeedf } - .terminal-2023815619-r7 { fill: #e9eee5 } - .terminal-2023815619-r8 { fill: #e4eee8 } - .terminal-2023815619-r9 { fill: #dfebed } - .terminal-2023815619-r10 { fill: #e2edeb } - .terminal-2023815619-r11 { fill: #e4eee8;font-weight: bold } - .terminal-2023815619-r12 { fill: #dfebed;font-weight: bold } - .terminal-2023815619-r13 { fill: #e3e6eb } - .terminal-2023815619-r14 { fill: #dfe9ed } - .terminal-2023815619-r15 { fill: #e3e6eb;font-weight: bold } - .terminal-2023815619-r16 { fill: #e6e3e9 } + .terminal-1570661136-r1 { fill: #c5c8c6 } + .terminal-1570661136-r2 { fill: #eae3e5 } + .terminal-1570661136-r3 { fill: #e8e0e7 } + .terminal-1570661136-r4 { fill: #efe9e4 } + .terminal-1570661136-r5 { fill: #ede6e6 } + .terminal-1570661136-r6 { fill: #efeedf } + .terminal-1570661136-r7 { fill: #e9eee5 } + .terminal-1570661136-r8 { fill: #e2edeb } + .terminal-1570661136-r9 { fill: #e4eee8;font-weight: bold } + .terminal-1570661136-r10 { fill: #dfebed;font-weight: bold } + .terminal-1570661136-r11 { fill: #dfe9ed } + .terminal-1570661136-r12 { fill: #e3e6eb;font-weight: bold } + .terminal-1570661136-r13 { fill: #e6e3e9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - PlaceholderApp + PlaceholderApp - - - - - Placeholder p2 here! - This is a custom label for p1. - #p4 - #p3#p5Placeholde - r - - Lorem ipsum dolor sit  - 26 x 6amet, consectetur 27 x 6 - adipiscing elit. Etiam  - feugiat ac elit sit amet  - - - Lorem ipsum dolor sit amet,  - consectetur adipiscing elit. Etiam 40 x 6 - feugiat ac elit sit amet accumsan.  - Suspendisse bibendum nec libero quis  - gravida. Phasellus id eleifend ligula. - Nullam imperdiet sem tellus, sed  - vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  - Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  - lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  - sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  + + + + + Placeholder p2 here! + This is a custom label for p1. + #p4 + #p3#p5Placeholde + r + + Lorem ipsum dolor sit  + 26 x 6amet, consectetur 27 x 6 + adipiscing elit. Etiam  + feugiat ac elit sit amet  + + + Lorem ipsum dolor sit amet,  + consectetur adipiscing elit. Etiam 40 x 6 + feugiat ac elit sit amet accumsan.  + Suspendisse bibendum nec libero quis  + gravida. Phasellus id eleifend ligula. + Nullam imperdiet sem tellus, sed  + vehicula nisl faucibus sit amet. Lorem ipsum dolor sit amet,  + Praesent iaculis tempor ultricies. Sedconsectetur adipiscing elit. Etiam  + lacinia, tellus id rutrum lacinia, feugiat ac elit sit amet accumsan.  + sapien sapien congue mauris, sit amet Suspendisse bibendum nec libero quis  From 93453af00ed12acf028a3d59511723936ed42a5d 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, 6 Jan 2023 18:31:34 +0000 Subject: [PATCH 24/47] Update changelog. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eed95593..8da8e3b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - The styles `scrollbar-background-active` and `scrollbar-color-hover` are no longer ignored https://github.com/Textualize/textual/pull/1480 +- The widget `Placeholder` can now have its width set to `auto` https://github.com/Textualize/textual/pull/1508 ## [0.9.1] - 2022-12-30 From ea8252cfcc83549b1e1791288b23e052551b163d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 7 Jan 2023 09:30:01 +0000 Subject: [PATCH 25/47] Run black over recent Tree tests Now that we're running black on tests... --- tests/tree/test_tree_node_children.py | 15 ++++++++++----- tests/tree/test_tree_node_label.py | 2 ++ tests/tree/test_tree_node_parent.py | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/tree/test_tree_node_children.py b/tests/tree/test_tree_node_children.py index 5b4751745..eb5c949c0 100644 --- a/tests/tree/test_tree_node_children.py +++ b/tests/tree/test_tree_node_children.py @@ -1,6 +1,7 @@ import pytest from textual.widgets import Tree, TreeNode + def label_of(node: TreeNode[None]): """Get the label of a node as a string""" return str(node.label) @@ -8,17 +9,21 @@ def label_of(node: TreeNode[None]): def test_tree_node_children() -> None: """A node's children property should act like an immutable list.""" - CHILDREN=23 + CHILDREN = 23 tree = Tree[None]("Root") for child in range(CHILDREN): tree.root.add(str(child)) - assert len(tree.root.children)==CHILDREN + assert len(tree.root.children) == CHILDREN for child in range(CHILDREN): assert label_of(tree.root.children[child]) == str(child) assert label_of(tree.root.children[0]) == "0" - assert label_of(tree.root.children[-1]) == str(CHILDREN-1) - assert [label_of(node) for node in tree.root.children] == [str(n) for n in range(CHILDREN)] - assert [label_of(node) for node in tree.root.children[:2]] == [str(n) for n in range(2)] + assert label_of(tree.root.children[-1]) == str(CHILDREN - 1) + assert [label_of(node) for node in tree.root.children] == [ + str(n) for n in range(CHILDREN) + ] + assert [label_of(node) for node in tree.root.children[:2]] == [ + str(n) for n in range(2) + ] with pytest.raises(TypeError): tree.root.children[0] = tree.root.children[1] with pytest.raises(TypeError): diff --git a/tests/tree/test_tree_node_label.py b/tests/tree/test_tree_node_label.py index 55af8088e..e64fcf24d 100644 --- a/tests/tree/test_tree_node_label.py +++ b/tests/tree/test_tree_node_label.py @@ -1,6 +1,7 @@ from textual.widgets import Tree, TreeNode from rich.text import Text + def test_tree_node_label() -> None: """It should be possible to modify a TreeNode's label.""" node = TreeNode(Tree[None]("Xenomorph Lifecycle"), None, 0, "Facehugger") @@ -8,6 +9,7 @@ def test_tree_node_label() -> None: node.label = "Chestbuster" assert node.label == Text("Chestbuster") + def test_tree_node_label_via_tree() -> None: """It should be possible to modify a TreeNode's label when created via a Tree.""" tree = Tree[None]("Xenomorph Lifecycle") diff --git a/tests/tree/test_tree_node_parent.py b/tests/tree/test_tree_node_parent.py index 90353ea92..b9d85af43 100644 --- a/tests/tree/test_tree_node_parent.py +++ b/tests/tree/test_tree_node_parent.py @@ -1,5 +1,6 @@ from textual.widgets import TreeNode, Tree + def test_tree_node_parent() -> None: """It should be possible to access a TreeNode's parent.""" tree = Tree[None]("Anakin") From b8500de1e93102919e53012dcf7a4e81f2daaf37 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 7 Jan 2023 09:32:43 +0000 Subject: [PATCH 26/47] Run black over recent immutable sequence view tests Now that we're running black on tests... --- tests/test_immutable_sequence_view.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_immutable_sequence_view.py b/tests/test_immutable_sequence_view.py index 5af7f5133..fd691798c 100644 --- a/tests/test_immutable_sequence_view.py +++ b/tests/test_immutable_sequence_view.py @@ -3,6 +3,7 @@ import pytest from typing import Sequence from textual._immutable_sequence_view import ImmutableSequenceView + def wrap(source: Sequence[int]) -> ImmutableSequenceView[int]: """Wrap a sequence of integers inside an immutable sequence view.""" return ImmutableSequenceView[int](source) @@ -24,7 +25,7 @@ def test_non_empty_immutable_sequence() -> None: def test_no_assign_to_immutable_sequence() -> None: """It should not be possible to assign into an immutable sequence.""" - tester = wrap([1,2,3,4,5]) + tester = wrap([1, 2, 3, 4, 5]) with pytest.raises(TypeError): tester[0] = 23 with pytest.raises(TypeError): @@ -33,7 +34,7 @@ def test_no_assign_to_immutable_sequence() -> None: def test_no_del_from_iummutable_sequence() -> None: """It should not be possible delete an item from an immutable sequence.""" - tester = wrap([1,2,3,4,5]) + tester = wrap([1, 2, 3, 4, 5]) with pytest.raises(TypeError): del tester[0] @@ -46,23 +47,23 @@ def test_get_item_from_immutable_sequence() -> None: def test_get_slice_from_immutable_sequence() -> None: """It should be possible to get a slice from an immutable sequence.""" - assert list(wrap(range(10))[0:2]) == [0,1] - assert list(wrap(range(10))[0:-1]) == [0,1,2,3,4,5,6,7,8] + assert list(wrap(range(10))[0:2]) == [0, 1] + assert list(wrap(range(10))[0:-1]) == [0, 1, 2, 3, 4, 5, 6, 7, 8] def test_immutable_sequence_contains() -> None: """It should be possible to see if an immutable sequence contains a value.""" - tester = wrap([1,2,3,4,5]) + tester = wrap([1, 2, 3, 4, 5]) assert 1 in tester assert 11 not in tester def test_immutable_sequence_index() -> None: - tester = wrap([1,2,3,4,5]) + tester = wrap([1, 2, 3, 4, 5]) assert tester.index(1) == 0 with pytest.raises(ValueError): _ = tester.index(11) def test_reverse_immutable_sequence() -> None: - assert list(reversed(wrap([1,2]))) == [2,1] + assert list(reversed(wrap([1, 2]))) == [2, 1] From fd9c1de3e2254b7cc83b0ffa0d95a9eeea83f805 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Jan 2023 14:04:52 +0000 Subject: [PATCH 27/47] Call from thread method --- src/textual/app.py | 68 ++++++++++++++++++++++++++++++++++++--- tests/test_concurrency.py | 50 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/test_concurrency.py diff --git a/src/textual/app.py b/src/textual/app.py index b207bbd02..df38c6091 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +from concurrent.futures import Future +from functools import partial import inspect import io import os @@ -18,6 +20,7 @@ from time import perf_counter from typing import ( TYPE_CHECKING, Any, + Awaitable, Callable, Generic, Iterable, @@ -206,6 +209,8 @@ class _WriterThread(threading.Thread): CSSPathType = Union[str, PurePath, List[Union[str, PurePath]], None] +CallThreadReturnType = TypeVar("CallThreadReturnType") + @rich.repr.auto class App(Generic[ReturnType], DOMNode): @@ -353,6 +358,8 @@ class App(Generic[ReturnType], DOMNode): else: self.devtools = DevtoolsClient() + self._loop: asyncio.AbstractEventLoop | None = None + self._thread_id: int = 0 self._return_value: ReturnType | None = None self._exit = False @@ -604,6 +611,51 @@ class App(Generic[ReturnType], DOMNode): except Exception as error: self._handle_exception(error) + def call_from_thread( + self, + callback: Callable[..., CallThreadReturnType | Awaitable[CallThreadReturnType]], + *args, + **kwargs, + ) -> CallThreadReturnType: + """Run a callback from another thread. + + Like asyncio apps in general, Textual apps are not thread-safe. If you call methods + or set attributes on Textual objects from a thread, you may get unpredictable results. + + This method will ensure that your code is ran within the correct context. + + Args: + callback (Callable): A callable to run. + *args: Arguments to the callback. + **kwargs: Keyword arguments for the callback. + + Raises: + RuntimeError: If the app isn't running or if this method is called from the same + thread where the app is running. + """ + + if self._loop is None: + raise RuntimeError("App is not running") + + if self._thread_id == threading.get_ident(): + raise RuntimeError( + "The `call_from_thread` method must run in a different thread from the app" + ) + + callback_with_args = partial(callback, *args, **kwargs) + + async def run_callback() -> CallThreadReturnType: + """Run the callback, set the result or error on the future.""" + self._set_active() + return await invoke(callback_with_args) + + # Post the message to the main loop + future: Future[Any] = asyncio.run_coroutine_threadsafe( + run_callback(), loop=self._loop + ) + result = future.result() + return result + def action_toggle_dark(self) -> None: """Action to toggle dark mode.""" self.dark = not self.dark @@ -874,11 +926,17 @@ class App(Generic[ReturnType], DOMNode): async def run_app() -> None: """Run the app.""" - await self.run_async( - headless=headless, - size=size, - auto_pilot=auto_pilot, - ) + self._loop = asyncio.get_running_loop() + self._thread_id = threading.get_ident() + try: + await self.run_async( + headless=headless, + size=size, + auto_pilot=auto_pilot, + ) + finally: + self._loop = None + self._thread_id = 0 if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED: # N.B. This doesn't work with Python<3.10, as we end up with 2 event loops: diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py new file mode 100644 index 000000000..88a3ccf4b --- /dev/null +++ b/tests/test_concurrency.py @@ -0,0 +1,50 @@ +import pytest + +from threading import Thread +from textual.app import App, ComposeResult +from textual.widgets import TextLog + + +def test_call_from_thread_app_not_running(): + app = App() + + # Should fail if app is not running + with pytest.raises(RuntimeError): + app.call_from_thread(print) + + +def test_call_from_thread(): + class BackgroundThread(Thread): + """A background thread which will modify app in some way.""" + + def __init__(self, app: App) -> None: + self.app = app + super().__init__() + + def run(self) -> None: + def write_stuff(text: str) -> None: + """Write stuff to a widget.""" + self.app.query_one(TextLog).write(text) + + self.app.call_from_thread(write_stuff, "Hello") + # Exit the app with a code we can assert + self.app.call_from_thread(self.app.exit, 123) + + class ThreadTestApp(App): + """Trivial app with a single widget.""" + + def compose(self) -> ComposeResult: + yield TextLog() + + def on_ready(self) -> None: + """Launch a thread which will modify the app.""" + try: + self.call_from_thread(print) + except RuntimeError as error: + self._runtime_error = error + BackgroundThread(self).start() + + app = ThreadTestApp() + result = app.run(headless=True, size=(80, 24)) + assert isinstance(app._runtime_error, RuntimeError) + assert result == 123 From 249c2f319f00e1a9987fa59124048dfc6390f154 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Jan 2023 14:05:46 +0000 Subject: [PATCH 28/47] typing --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index df38c6091..89da6aaf6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -650,7 +650,7 @@ class App(Generic[ReturnType], DOMNode): return await invoke(callback_with_args) # Post the message to the main loop - future: Future[Any] = asyncio.run_coroutine_threadsafe( + future: Future[CallThreadReturnType] = asyncio.run_coroutine_threadsafe( run_callback(), loop=self._loop ) result = future.result() From e11f563123581f898df1067f3457b46048c1152f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 7 Jan 2023 14:24:17 +0000 Subject: [PATCH 29/47] docstrings --- tests/test_concurrency.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_concurrency.py b/tests/test_concurrency.py index 88a3ccf4b..c73418f2f 100644 --- a/tests/test_concurrency.py +++ b/tests/test_concurrency.py @@ -14,6 +14,8 @@ def test_call_from_thread_app_not_running(): def test_call_from_thread(): + """Test the call_from_thread method.""" + class BackgroundThread(Thread): """A background thread which will modify app in some way.""" @@ -41,6 +43,7 @@ def test_call_from_thread(): try: self.call_from_thread(print) except RuntimeError as error: + # Calling this from the same thread as the app is an error self._runtime_error = error BackgroundThread(self).start() From 90d38cd0dac9a75532594f7bba8e426397149c0e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 7 Jan 2023 22:45:38 +0000 Subject: [PATCH 30/47] WIP: devlog blog post about looking for help with Textual Work in progress. First rough draft of ideas, committing and pushing to remote as backup for the night. --- docs/blog/posts/looking-for-help.md | 262 ++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/blog/posts/looking-for-help.md diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md new file mode 100644 index 000000000..23022da2b --- /dev/null +++ b/docs/blog/posts/looking-for-help.md @@ -0,0 +1,262 @@ +--- +draft: true +date: 2023-01-07 +categories: + - DevLog +authors: + - davep +--- + +# So you're looking for a wee bit of Textual help... + +## Introduction + +!!! quote + + Patience, Highlander. You have done well. But it'll take time. You are + generations being born and dying. You are at one with all living things. + Each man's thoughts and dreams are yours to know. You have power beyond + imagination. Use it well, my friend. Don't lose your head. + + Juan Sánchez Villalobos Ramírez, Chief metallurgist to King Charles V of Spain + +As of the time of writing, I'm a handful of days off having been with +Textualize for 3 months. It's been fun, and educational, and every bit as +engaging as I'd hoped, and more. One thing I hadn't quite prepared for +though, but which I really love, is how so many other people are learning +Textual along with me! + + + +Even in those three months the library has changed and expanded quite a lot, +and it continues to do so. Meanwhile, more and more people are turning up +and using the framework; you can see this online in social media, blogs and +of course [in the ever-growing list of projects on GitHub which depend on +Textual](https://github.com/Textualize/textual/network/dependents). + +This inevitably means there's a lot of people getting to grips with a new +tool, and one that is still a bit of a moving target. This in turn means +lots of people are coming to us to get help. + +As I've watched this happen I've noticed a few patterns emerging. Some of +these good or neutral, some... let's just say not really beneficial to those +seeking the help, or to those trying to provide the help. So I wanted to +write a little bit about the different ways you can get help with Textual +and your Textual-based projects, and to also try and encourage people to +take the most helpful and positive approach to getting that help. + +Now, before I go on, I want to make something *very* clear: I'm writing this +as an individual. This is my own personal view, and my own advice from me to +anyone who wishes to take it. It's not Textual (the project) or Textualize +(the company) policy, rules or guidelines. This is just some ageing hacker's +take on how best to go about asking for help, informed by years of asking +for and also providing help in email, on Usenet, on forums, etc. + +Or, put another way, if what you read in here seems sensible to you, I +figure we'll likely have already hit it off over on GitHub or in the Discord +sever. ;-) + +## Where to go for help + +At this point this is almost a bit of an FAQ itself, so I thought I'd +address it here: where's the best place to ask for help about Textual, and +what's the difference between GitHub Issues, Discussions and our Discord +server? + +I'd suggest thinking of them like this: + +### Discord + +You have a question, or need help with something, and perhaps you could do +with a reply as soon as possible. But, and this is the **really important +part**, it doesn't matter if you don't get a response. If you're in this +situation then the Discord server is possibly a good place to start. If +you're lucky someone will be hanging about who can help out. + +I can't speak for anyone else, but keep this in mind: when I look in on +Discord I tend not to go scrolling back much to see if anything has been +missed. If something catches my eye, I'll try and reply, but if it +doesn't... well, it's mostly an instant chat thing so I don't dive too +deeply back in time. + +My own advice would be to treat Discord as an ephemeral resource. It happens +in the moment but fades away pretty quickly. It's like knocking on a +friend's door to see if they're in. If they're not in, you might leave them +a note, which is sort of like going to... + +### GitHub + +On the other hand, if you have a question or need some help or something +where you want to stand a good chance of the Textual developers (amongst +others) seeing it and responding, I'd recommend that GitHub is the place to +go. Dropping something into the discussions there, or leaving an issue, +ensures it'll get seen. It won't get lost. + +As for which you should use -- a discussion or an issue -- I'd suggest this: +if you need help with something, or you want to check your understanding of +something, or you just want to be sure something is a problem before taking +it further, a discussion might be the best thing. On the other hand, if +you've got a clear bug or feature request on your hands, an issue makes a +lot of sense. + +Don't worry if you're not sure which camp your question or whatever falls +into though; go with what you think is right. There's no harm done either +way (I may move an issue to a discussion first before replying, if it's +really just a request for help -- but that's mostly so everyone can benefit +from finding it in the right place later on down the line). + +## The dos and don'ts of getting help + +Now on to the fun part. This is where I get a bit preachy. Ish. Kinda. A +little bit. Again, please remember, this isn't a set of rules, this isn't a +set of official guidelines, this is just a bunch of *"if you want my advice, +and I know you didn't ask but you've read this far so you actually sort of +did don't say I didn't warn you!"* waffle. + +This isn't going to be an exhaustive collection, far from it. But I feel +these are some important highlights. + +### Do... + +When looking for help, in any of the locations mentioned above, I'd totally +encourage: + +#### Be clear and detailed + +Too much detail is almost always way better than not enough. *"My program +didn't run"*, often even with some of the code supplied, is so much harder +to help than *"I ran this code I'm posting here, and I expected this +particular outcome, and I expected it because I'd read this particular thing +in the docs and had comprehended it to mean this, but instead the outcome +was this exception here, and I'm a but stuck -- can someone offer some +pointers?"* + +The former approach means there often ends up having to be a back and forth +which can last a long time, and which can sometimes be frustrating for the +person asking. Manage frustration: be clear, tell us everything you can. + +#### Say what resources you've used already + +If you've read the potions of the documentation that relate to what you're +trying to do, it's going to be really helpful if you say so. If you don't, +it might be assumed you haven't and you may end up being pointed at them. + +So, please, if you've checked the documentation, looked in the FAQ, done a +search of past issues or discussions or perhaps even done a search on the +Discord server... please say so. + +#### Be polite + +This one can go a long way when looking for help. Look, I get it, +programming is bloody frustrating at times. We've all rage-quit some code at +some point, I'm sure. It's likely going to be your moment of greatest +frustration when you go looking for help. But if you turn up looking for +help acting all grumpy and stuff it's not going to come over well. Folk are +less likely to be motivated to lend a hand to someone who seems rather +annoyed. + +If you throw in a please and thank-you here and there that makes it all the +better. + +#### Fully consider the replies + +You could find yourself getting a reply that you're sure won't help at all. +That's fair. But be sure to fully consider it first. Perhaps you missed the +obvious along the way and this is 100% the course correction you'd +unknowingly come looking for in the first place. Sure, the person replying +might have totally misunderstood what was being asked, or might be giving a +wrong answer (it me! I've totally done that and will again!), but even then +a reply along the lines of *"I'm not sure that's what I'm looking for, +because..."* gets everyone to the solution faster than *"lol nah"*. + +#### Entertain what might seem like odd questions + +Aye, I get it, being asked questions when you're looking for an *answer* can +be a bit frustrating. But if you find yourself on the receiving end of a +small series of questions about your question, keep this in mind: Textual is +still rather new and still developing and it's possible that what you're +trying to do isn't the correct way to do that thing. To the person looking +to help you it may seem to them you have an [XY +problem](https://en.wikipedia.org/wiki/XY_problem). + +Entertaining those questions might just get you to the real solution to your +problem. + +#### Allow for language differences + +You don't need me to tell you that a project such as Textual has a global +audience. With that rather obvious fact comes the other fact that we don't +all share the same first language. So, please, as much as possible, try and +allow for that. If someone is trying to help you out, and they make it clear +they're struggling to follow you, keep this in mind. + +#### Acknowledge the answer + +I suppose this is a variation on "be polite" (really, a thanks can go a long +way), but there's more to this than a friendly acknowledgement. If someone +has gone to the trouble of offering some help, it's helpful to everyone who +comes after you to acknowledge if it worked or not. That way a future +help-seeker will know if the answer they're reading stands a chance of being +the right one. + +### Don't... + +Okay, now for a bot of old-hacker finger-wagging. Here's a few things I'd +personally discourage: + +#### Lack patience + +Sure, it can be annoying. You're in your flow, you've got a neat idea for a +thing you want to build, you're stuck on one particular thing and you really +need help right now! Thing is, that's unlikely to happen. Badgering +individuals, or a whole resource, to reply right now, or complaining that +it's been `$TIME_PERIOD` since you asked and nobody has replied... that's +just going to make people less likely to reply. + +#### Unnecessarily tag individuals + +This one often goes hand in hand with the "lack patience" thing: Be it +asking on Discord, or in GitHub issues, discussions or even PRs, +unnecessarily tagging individuals is a bit rude. Speaking for myself and +only myself: I *love* helping folk with Textual. If I could help everyone +all the time the moment they have a problem, I would. But it doesn't work +like that. There's any number of reasons I might not be responding to a +particular request, including but not limited to (here I'm talking +personally because I don't want to speak for anyone else, but I'm sure I'm +not alone here): + +- I have a job. Sure, my job is (in part) Textual, but there's more to it + than that particular issue. I might be doing other stuff. +- I have my own projects to work on too. I like coding for fun as well (or + writing preaching old dude blog posts like this I guess, but you get the + idea). +- I actually have other interests outside of work hours so I might actually + be out doing a 10k in the local glen, or battling headcrabs in VR, or + something. +- Housework. :-/ + +You get the idea though. So while I'm off having a well-rounded life, it's +not good to get unnecessarily intrusive alerts to something that either a) +doesn't actually directly involve me or b) could wait. + +#### Seek personal support + +Again, I'm going to speak totally for myself here, but I also feel the +general case is polite for all: there's a lot of good support resources +available already; sending DMs on Discord or Twitter or in the Fediverse, +looking for direct personal support, isn't really the best way to get help. +Using those resources is absolutely the *best* way to get that help. Why's +it a bad idea to dive into DMs? Here's some reasons I think it's not a good +idea: + +- It's a variation on "unnecessarily tagging individuals". +- You're short-changing yourself when it comes to getting help. If you ask + somewhere more public you're asking a much bigger audience, who + collectively have more time, more knowledge and more experience than a + single individual. +- Following on from that, any answers can be (politely) fact-checked or + enhanced by that audience, resulting in a better chance of getting the + best help possible. +- The next seeker-of-help gets to miss out on your question and the answer. + If asked and answered in public, it's a record that can help someone else + in the future. From a242d4b6c57322eef328dea42bc2631f9da1fffa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 7 Jan 2023 22:50:35 +0000 Subject: [PATCH 31/47] Fix blog typo The first of many, I'm sure. --- docs/blog/posts/looking-for-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index 23022da2b..2da46f021 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -201,7 +201,7 @@ the right one. ### Don't... -Okay, now for a bot of old-hacker finger-wagging. Here's a few things I'd +Okay, now for a bit of old-hacker finger-wagging. Here's a few things I'd personally discourage: #### Lack patience From a3601cf0becd6aebcee16c43be52ccb2fadc57a3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sat, 7 Jan 2023 22:52:16 +0000 Subject: [PATCH 32/47] Fix blog typo The second of many, I'm sure. --- docs/blog/posts/looking-for-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index 2da46f021..e7d759c96 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -128,7 +128,7 @@ didn't run"*, often even with some of the code supplied, is so much harder to help than *"I ran this code I'm posting here, and I expected this particular outcome, and I expected it because I'd read this particular thing in the docs and had comprehended it to mean this, but instead the outcome -was this exception here, and I'm a but stuck -- can someone offer some +was this exception here, and I'm a bit stuck -- can someone offer some pointers?"* The former approach means there often ends up having to be a back and forth From b13fc3d5b64bc4413dfc3a9854b4ebea627e106a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 16:20:15 +0000 Subject: [PATCH 33/47] snapshot --- src/textual/_compositor.py | 10 +- src/textual/widget.py | 23 ++- .../__snapshots__/test_snapshots.ambr | 162 +++++++++--------- 3 files changed, 106 insertions(+), 89 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1e7ab1603..a941aaacd 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,6 +25,7 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last +from ._profile import timer from .strip import Strip from ._typing import TypeAlias from .geometry import NULL_OFFSET, Offset, Region, Size @@ -253,7 +254,9 @@ class Compositor: # Keep a copy of the old map because we're going to compare it with the update old_map = self.map.copy() old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size) + + with timer("arrange"): + map, widgets = self._arrange_root(parent, size) new_widgets = map.keys() @@ -268,10 +271,12 @@ class Compositor: screen = size.region + changes = map.items() ^ old_map.items() + # Widgets with changed size resized_widgets = { widget - for widget, (region, *_) in map.items() + for widget, (region, *_) in changes if widget in old_widgets and old_map[widget].region.size != region.size } @@ -279,7 +284,6 @@ class Compositor: # i.e. if something is moved / deleted / added if screen not in self._dirty_regions: - changes = map.items() ^ old_map.items() regions = { region for region in ( diff --git a/src/textual/widget.py b/src/textual/widget.py index 185d18a64..735d74003 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -43,6 +43,7 @@ from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache +from ._profile import timer from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding @@ -804,13 +805,17 @@ class Widget(DOMNode): def watch_scroll_x(self, new_value: float) -> None: if self.show_horizontal_scrollbar: - self.horizontal_scrollbar.position = int(new_value) - self.refresh(layout=True, repaint=False) + new_position = int(round(new_value)) + if self.horizontal_scrollbar.position != new_position: + self.horizontal_scrollbar.position = new_position + self._refresh_scroll() def watch_scroll_y(self, new_value: float) -> None: if self.show_vertical_scrollbar: - self.vertical_scrollbar.position = int(new_value) - self.refresh(layout=True, repaint=False) + new_position = int(round(new_value)) + if self.vertical_scrollbar.position != new_position: + self.vertical_scrollbar.position = new_position + self._refresh_scroll() def validate_scroll_x(self, value: float) -> float: return clamp(value, 0, self.max_scroll_x) @@ -2155,8 +2160,16 @@ class Widget(DOMNode): event._set_forwarded() await self.post_message(event) + def _refresh_scroll(self) -> None: + """Refreshes the scroll position.""" + self._layout_required = True + self.check_idle() + def refresh( - self, *regions: Region, repaint: bool = True, layout: bool = False + self, + *regions: Region, + repaint: bool = True, + layout: bool = False, ) -> None: """Initiate a refresh of the widget. diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index d920e8d87..3368ae9c2 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4779,169 +4779,169 @@ font-weight: 700; } - .terminal-4040833233-matrix { + .terminal-2908062273-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4040833233-title { + .terminal-2908062273-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4040833233-r1 { fill: #c5c8c6 } - .terminal-4040833233-r2 { fill: #e3e3e3 } - .terminal-4040833233-r3 { fill: #e1e1e1 } - .terminal-4040833233-r4 { fill: #23568b } - .terminal-4040833233-r5 { fill: #e2e2e2 } - .terminal-4040833233-r6 { fill: #004578 } - .terminal-4040833233-r7 { fill: #14191f } - .terminal-4040833233-r8 { fill: #262626 } - .terminal-4040833233-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } - .terminal-4040833233-r10 { fill: #e2e2e2;font-weight: bold } - .terminal-4040833233-r11 { fill: #7ae998 } - .terminal-4040833233-r12 { fill: #4ebf71;font-weight: bold } - .terminal-4040833233-r13 { fill: #008139 } - .terminal-4040833233-r14 { fill: #dde8f3;font-weight: bold } - .terminal-4040833233-r15 { fill: #ddedf9 } + .terminal-2908062273-r1 { fill: #c5c8c6 } + .terminal-2908062273-r2 { fill: #e3e3e3 } + .terminal-2908062273-r3 { fill: #e1e1e1 } + .terminal-2908062273-r4 { fill: #23568b } + .terminal-2908062273-r5 { fill: #e2e2e2 } + .terminal-2908062273-r6 { fill: #14191f } + .terminal-2908062273-r7 { fill: #004578 } + .terminal-2908062273-r8 { fill: #262626 } + .terminal-2908062273-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } + .terminal-2908062273-r10 { fill: #e2e2e2;font-weight: bold } + .terminal-2908062273-r11 { fill: #7ae998 } + .terminal-2908062273-r12 { fill: #4ebf71;font-weight: bold } + .terminal-2908062273-r13 { fill: #008139 } + .terminal-2908062273-r14 { fill: #dde8f3;font-weight: bold } + .terminal-2908062273-r15 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Demo + Textual Demo - - - - Textual Demo - ▅▅ - - TOP - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃ - - Widgets - Textual Demo - - Welcome! Textual is a framework for creating sophisticated - Rich contentapplications with the terminal. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Start - CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - - -                           Widgets                            -  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  + + + + Textual Demo + ▅▅ + + TOP + + ▃▃ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + Widgets + + Textual Demo + + Rich contentWelcome! Textual is a framework for creating sophisticated + applications with the terminal. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + CSSStart + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + + + +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  From e8dc95351ebd5fcf632bfd9d2313d358b307e10a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 16:28:53 +0000 Subject: [PATCH 34/47] comment, remove debug --- CHANGELOG.md | 1 + src/textual/_compositor.py | 13 ++++--------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da8e3b9e..90bb8ddc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `MouseScrollUp` and `MouseScrollDown` now inherit from `MouseEvent` and have attached modifier keys. https://github.com/Textualize/textual/pull/1458 - Fail-fast and print pretty tracebacks for Widget compose errors https://github.com/Textualize/textual/pull/1505 +- Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524 ### Fixed diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index a941aaacd..e7c9fe214 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -255,9 +255,7 @@ class Compositor: old_map = self.map.copy() old_widgets = old_map.keys() - with timer("arrange"): - map, widgets = self._arrange_root(parent, size) - + map, widgets = self._arrange_root(parent, size) new_widgets = map.keys() # Newly visible widgets @@ -269,8 +267,7 @@ class Compositor: self.map = map self.widgets = widgets - screen = size.region - + # Contains widgets + geometry for every widget that changed (added, removed, or updated) changes = map.items() ^ old_map.items() # Widgets with changed size @@ -280,10 +277,8 @@ class Compositor: if widget in old_widgets and old_map[widget].region.size != region.size } - # Gets pairs of tuples of (Widget, MapGeometry) which have changed - # i.e. if something is moved / deleted / added - - if screen not in self._dirty_regions: + screen_region = size.region + if screen_region not in self._dirty_regions: regions = { region for region in ( From 03a159e1c83efa5a2f3e2b8fa0c5cd35d2a632b7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 16:36:27 +0000 Subject: [PATCH 35/47] Remove timer import --- src/textual/_compositor.py | 1 - src/textual/widget.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e7c9fe214..7522ddb53 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,7 +25,6 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last -from ._profile import timer from .strip import Strip from ._typing import TypeAlias from .geometry import NULL_OFFSET, Offset, Region, Size diff --git a/src/textual/widget.py b/src/textual/widget.py index 735d74003..e03d16712 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -43,7 +43,6 @@ from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout from ._segment_tools import align_lines from ._styles_cache import StylesCache -from ._profile import timer from .actions import SkipAction from .await_remove import AwaitRemove from .binding import Binding From 80a33c074bea81cefc89468636cdba57aa8427cd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 16:47:53 +0000 Subject: [PATCH 36/47] more accurate optimization --- src/textual/widget.py | 14 +- .../__snapshots__/test_snapshots.ambr | 162 +++++++++--------- 2 files changed, 87 insertions(+), 89 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index e03d16712..60d1cad9c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -802,18 +802,16 @@ class Widget(DOMNode): if self.auto_links: self.highlight_link_id = hover_style.link_id - def watch_scroll_x(self, new_value: float) -> None: + def watch_scroll_x(self, old_value: float, new_value: float) -> None: if self.show_horizontal_scrollbar: - new_position = int(round(new_value)) - if self.horizontal_scrollbar.position != new_position: - self.horizontal_scrollbar.position = new_position + self.horizontal_scrollbar.position = int(new_value) + if int(old_value) != int(new_value): self._refresh_scroll() - def watch_scroll_y(self, new_value: float) -> None: + def watch_scroll_y(self, old_value: float, new_value: float) -> None: if self.show_vertical_scrollbar: - new_position = int(round(new_value)) - if self.vertical_scrollbar.position != new_position: - self.vertical_scrollbar.position = new_position + self.vertical_scrollbar.position = int(new_value) + if int(old_value) != int(new_value): self._refresh_scroll() def validate_scroll_x(self, value: float) -> float: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 3368ae9c2..d920e8d87 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4779,169 +4779,169 @@ font-weight: 700; } - .terminal-2908062273-matrix { + .terminal-4040833233-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2908062273-title { + .terminal-4040833233-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2908062273-r1 { fill: #c5c8c6 } - .terminal-2908062273-r2 { fill: #e3e3e3 } - .terminal-2908062273-r3 { fill: #e1e1e1 } - .terminal-2908062273-r4 { fill: #23568b } - .terminal-2908062273-r5 { fill: #e2e2e2 } - .terminal-2908062273-r6 { fill: #14191f } - .terminal-2908062273-r7 { fill: #004578 } - .terminal-2908062273-r8 { fill: #262626 } - .terminal-2908062273-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } - .terminal-2908062273-r10 { fill: #e2e2e2;font-weight: bold } - .terminal-2908062273-r11 { fill: #7ae998 } - .terminal-2908062273-r12 { fill: #4ebf71;font-weight: bold } - .terminal-2908062273-r13 { fill: #008139 } - .terminal-2908062273-r14 { fill: #dde8f3;font-weight: bold } - .terminal-2908062273-r15 { fill: #ddedf9 } + .terminal-4040833233-r1 { fill: #c5c8c6 } + .terminal-4040833233-r2 { fill: #e3e3e3 } + .terminal-4040833233-r3 { fill: #e1e1e1 } + .terminal-4040833233-r4 { fill: #23568b } + .terminal-4040833233-r5 { fill: #e2e2e2 } + .terminal-4040833233-r6 { fill: #004578 } + .terminal-4040833233-r7 { fill: #14191f } + .terminal-4040833233-r8 { fill: #262626 } + .terminal-4040833233-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } + .terminal-4040833233-r10 { fill: #e2e2e2;font-weight: bold } + .terminal-4040833233-r11 { fill: #7ae998 } + .terminal-4040833233-r12 { fill: #4ebf71;font-weight: bold } + .terminal-4040833233-r13 { fill: #008139 } + .terminal-4040833233-r14 { fill: #dde8f3;font-weight: bold } + .terminal-4040833233-r15 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Demo + Textual Demo - - - - Textual Demo - ▅▅ - - TOP - - ▃▃ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - Widgets - - Textual Demo - - Rich contentWelcome! Textual is a framework for creating sophisticated - applications with the terminal. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - CSSStart - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - - -  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  + + + + Textual Demo + ▅▅ + + TOP + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃ + + Widgets + Textual Demo + + Welcome! Textual is a framework for creating sophisticated + Rich contentapplications with the terminal. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Start + CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + + + + + + + + + +                           Widgets                            +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  From 958b6d143928bdbc36a3a34ee4857e24ed7b8a8a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 16:53:06 +0000 Subject: [PATCH 37/47] smoother animation with roundering --- src/textual/widget.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 60d1cad9c..8c3f7951d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -804,14 +804,14 @@ class Widget(DOMNode): def watch_scroll_x(self, old_value: float, new_value: float) -> None: if self.show_horizontal_scrollbar: - self.horizontal_scrollbar.position = int(new_value) - if int(old_value) != int(new_value): + self.horizontal_scrollbar.position = round(new_value) + if round(old_value) != round(new_value): self._refresh_scroll() def watch_scroll_y(self, old_value: float, new_value: float) -> None: if self.show_vertical_scrollbar: - self.vertical_scrollbar.position = int(new_value) - if int(old_value) != int(new_value): + self.vertical_scrollbar.position = round(new_value) + if round(old_value) != round(new_value): self._refresh_scroll() def validate_scroll_x(self, value: float) -> float: @@ -1149,7 +1149,7 @@ class Widget(DOMNode): Returns: Offset: Offset a container has been scrolled by. """ - return Offset(int(self.scroll_x), int(self.scroll_y)) + return Offset(round(self.scroll_x), round(self.scroll_y)) @property def is_transparent(self) -> bool: From 244b1e333c4c9f498e008b46d4997ceab250d92b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 17:30:11 +0000 Subject: [PATCH 38/47] optimization --- src/textual/_compositor.py | 70 ++++++++++++++++++++------------------ src/textual/geometry.py | 5 ++- 2 files changed, 41 insertions(+), 34 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7522ddb53..3c7fda165 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -390,43 +390,47 @@ class Compositor: ) widgets.update(arranged_widgets) - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = placement_offset - widget.scroll_offset + if placements: + # An offset added to all placements + placement_offset = container_region.offset + placement_scroll_offset = ( + placement_offset - widget.scroll_offset + ) - _layers = widget.layers - layers_to_index = { - layer_name: index for index, layer_name in enumerate(_layers) - } - get_layer_index = layers_to_index.get + _layers = widget.layers + layers_to_index = { + layer_name: index + for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get - # Add all the widgets - for sub_region, margin, sub_widget, z, fixed in reversed( - placements - ): - # Combine regions with children to calculate the "virtual size" - if fixed: - widget_region = sub_region + placement_offset - else: - total_region = total_region.union( - sub_region.grow(spacing + margin) + # Add all the widgets + for sub_region, margin, sub_widget, z, fixed in reversed( + placements + ): + # Combine regions with children to calculate the "virtual size" + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union( + sub_region.grow(spacing).grow(margin) + ) + widget_region = sub_region + placement_scroll_offset + + widget_order = order + ( + (get_layer_index(sub_widget.layer, 0), z, layer_order), ) - widget_region = sub_region + placement_scroll_offset - widget_order = order + ( - (get_layer_index(sub_widget.layer, 0), z, layer_order), - ) - - add_widget( - sub_widget, - sub_region, - widget_region, - widget_order, - layer_order, - sub_clip, - visible, - ) - layer_order -= 1 + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + layer_order, + sub_clip, + visible, + ) + layer_order -= 1 if visible: # Add any scrollbars diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 9b5c09f03..17e938b1d 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -684,6 +684,7 @@ class Region(NamedTuple): ) return new_region + @lru_cache(maxsize=4096) def grow(self, margin: tuple[int, int, int, int]) -> Region: """Grow a region by adding spacing. @@ -704,6 +705,7 @@ class Region(NamedTuple): height=max(0, height + top + bottom), ) + @lru_cache(maxsize=4096) def shrink(self, margin: tuple[int, int, int, int]) -> Region: """Shrink a region by subtracting spacing. @@ -713,7 +715,8 @@ class Region(NamedTuple): Returns: Region: The new, smaller region. """ - + if not any(margin): + return self top, right, bottom, left = margin x, y, width, height = self return Region( From ba3bda2c2cb5f86b08591830994642ae27051efa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 17:45:39 +0000 Subject: [PATCH 39/47] micro optimizations --- src/textual/_compositor.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3c7fda165..bc56d019d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -344,6 +344,7 @@ class Compositor: layer_order: int, clip: Region, visible: bool, + _MapGeometry=MapGeometry, ) -> None: """Called recursively to place a widget and its children in the map. @@ -413,12 +414,15 @@ class Compositor: widget_region = sub_region + placement_offset else: total_region = total_region.union( - sub_region.grow(spacing).grow(margin) + sub_region.grow(spacing + margin) ) widget_region = sub_region + placement_scroll_offset - widget_order = order + ( - (get_layer_index(sub_widget.layer, 0), z, layer_order), + widget_order = ( + *order, + get_layer_index(sub_widget.layer, 0), + z, + layer_order, ) add_widget( @@ -437,7 +441,7 @@ class Compositor: for chrome_widget, chrome_region in widget._arrange_scrollbars( container_region ): - map[chrome_widget] = MapGeometry( + map[chrome_widget] = _MapGeometry( chrome_region + layout_offset, order, clip, @@ -446,7 +450,7 @@ class Compositor: chrome_region, ) - map[widget] = MapGeometry( + map[widget] = _MapGeometry( region + layout_offset, order, clip, @@ -457,7 +461,7 @@ class Compositor: elif visible: # Add the widget to the map - map[widget] = MapGeometry( + map[widget] = _MapGeometry( region + layout_offset, order, clip, From e12d1f8bbbce600d0244e1d45d4b29f1fea42777 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 8 Jan 2023 17:51:32 +0000 Subject: [PATCH 40/47] Fleshing out the post a wee bit more Needs whittling and rounding and stuff, perhaps, but I think I'm mostly there. A post-dinner top-to-bottom read is in now in order before I finally decide if I like it or not. --- docs/blog/posts/looking-for-help.md | 57 +++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index e7d759c96..1de449f53 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -84,6 +84,15 @@ in the moment but fades away pretty quickly. It's like knocking on a friend's door to see if they're in. If they're not in, you might leave them a note, which is sort of like going to... +!!! note + + As a slight aside here: sometimes people will pop up in Discord, ask a + question about something that turns out looking like a bug, and that's + the last we hear of it. Please, please, **please**, if this happens, the + most helpful thing you can do is go raise an issue for us. It'll help us + to keep track of problems, it'll help get your problem fixed, it'll mean + everyone benefits. + ### GitHub On the other hand, if you have a question or need some help or something @@ -199,6 +208,20 @@ comes after you to acknowledge if it worked or not. That way a future help-seeker will know if the answer they're reading stands a chance of being the right one. +#### Accept that Textual is zero-point software (right now) + +Of course the aim is to have every release of Textual be stable and useful, +but things will break. So, please, do keep in mind things like: + +- Textual likely doesn't have your feature of choice just yet. +- Might accidentally break something (perhaps pinning Textual and testing + each release is a good plan here?). +- Might deliberately break something because we've decided to take a + particular feature or way of doing things in a better direction. + +Of course it can be a bit frustrating a times, but overall the aim is to +have the best framework possible in the long run. + ### Don't... Okay, now for a bit of old-hacker finger-wagging. Here's a few things I'd @@ -260,3 +283,37 @@ idea: - The next seeker-of-help gets to miss out on your question and the answer. If asked and answered in public, it's a record that can help someone else in the future. + +#### Doubt your ability or skill level + +I suppose this should really be a do rather than a don't, as here I want to +encourage something positive. A few times I've helped people out who have +been very apologetic about their questions being "noob" questions, or about +how they're fairly new to Python, or programming in general. Really, please, +don't feel the need to apologise and don't be ashamed of where you're at. + +If you've asked something that's obviously answered in the documentation, +that's not a problem; you'll likely get pointed at the docs and it's what +happens next that's the key bit. If the attitude is *"oh, cool, that's +exactly what I needed to be reading, thanks!"* that's a really positive +thing. The only time it's a problem is when there's a real reluctance to use +the available resources. We've all seen that person somewhere at some point, +right? ;-) + +## Conclusion + +So, that's my waffle over. As I said at the start: this is my own personal +thoughts on how to get help with Textual, both as someone whose job it is to +work on Textual and help people with Textual, and also as a FOSS advocate +and supporter who can normally be found helping Textual users when he's not +"on the clock" too. + +What I've written here isn't exhaustive. Neither is it novel. Plenty has +been written on the general subject in the past, and I'm sure more will be +written on the subject in the future. I do, however, feel that these are the +most common things I notice. I'd say those dos and don'ts cover 90% of *"can +I get some help?"* interactions; perhaps closer to 99%. + +Finally, and I think this is the most important thing to remember, the next +time you are battling some issue while working with Textual: [don't lose +your head](https://www.youtube.com/watch?v=KdYvKF9O7Y8)! From 01ad0df17fdd172a76f94f7dae6b3adbbd202f17 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 8 Jan 2023 21:10:50 +0000 Subject: [PATCH 41/47] Final tweaks to the "asking for help" blog post It's not finished, it never will be, but it's good to go. --- docs/blog/posts/looking-for-help.md | 49 +++++++++++++++-------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index 1de449f53..46a9aa4eb 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -1,6 +1,6 @@ --- -draft: true -date: 2023-01-07 +draft: false +date: 2023-01-08 categories: - DevLog authors: @@ -24,14 +24,14 @@ As of the time of writing, I'm a handful of days off having been with Textualize for 3 months. It's been fun, and educational, and every bit as engaging as I'd hoped, and more. One thing I hadn't quite prepared for though, but which I really love, is how so many other people are learning -Textual along with me! +Textual along with me. Even in those three months the library has changed and expanded quite a lot, -and it continues to do so. Meanwhile, more and more people are turning up -and using the framework; you can see this online in social media, blogs and -of course [in the ever-growing list of projects on GitHub which depend on +and it continues to do so. Meanwhile, more people are turning up and using +the framework; you can see this online in social media, blogs and of course +[in the ever-growing list of projects on GitHub which depend on Textual](https://github.com/Textualize/textual/network/dependents). This inevitably means there's a lot of people getting to grips with a new @@ -52,7 +52,7 @@ anyone who wishes to take it. It's not Textual (the project) or Textualize take on how best to go about asking for help, informed by years of asking for and also providing help in email, on Usenet, on forums, etc. -Or, put another way, if what you read in here seems sensible to you, I +Or, put another way: if what you read in here seems sensible to you, I figure we'll likely have already hit it off over on GitHub or in the Discord sever. ;-) @@ -79,12 +79,7 @@ missed. If something catches my eye, I'll try and reply, but if it doesn't... well, it's mostly an instant chat thing so I don't dive too deeply back in time. -My own advice would be to treat Discord as an ephemeral resource. It happens -in the moment but fades away pretty quickly. It's like knocking on a -friend's door to see if they're in. If they're not in, you might leave them -a note, which is sort of like going to... - -!!! note +!!! tip inline end "Going from Discord to a GitHub issue" As a slight aside here: sometimes people will pop up in Discord, ask a question about something that turns out looking like a bug, and that's @@ -93,6 +88,11 @@ a note, which is sort of like going to... to keep track of problems, it'll help get your problem fixed, it'll mean everyone benefits. +My own advice would be to treat Discord as an ephemeral resource. It happens +in the moment but fades away pretty quickly. It's like knocking on a +friend's door to see if they're in. If they're not in, you might leave them +a note, which is sort of like going to... + ### GitHub On the other hand, if you have a question or need some help or something @@ -214,9 +214,9 @@ Of course the aim is to have every release of Textual be stable and useful, but things will break. So, please, do keep in mind things like: - Textual likely doesn't have your feature of choice just yet. -- Might accidentally break something (perhaps pinning Textual and testing +- We might accidentally break something (perhaps pinning Textual and testing each release is a good plan here?). -- Might deliberately break something because we've decided to take a +- We might deliberately break something because we've decided to take a particular feature or way of doing things in a better direction. Of course it can be a bit frustrating a times, but overall the aim is to @@ -268,9 +268,9 @@ Again, I'm going to speak totally for myself here, but I also feel the general case is polite for all: there's a lot of good support resources available already; sending DMs on Discord or Twitter or in the Fediverse, looking for direct personal support, isn't really the best way to get help. -Using those resources is absolutely the *best* way to get that help. Why's -it a bad idea to dive into DMs? Here's some reasons I think it's not a good -idea: +Using the public/collective resources is absolutely the *best* way to get +that help. Why's it a bad idea to dive into DMs? Here's some reasons I think +it's not a good idea: - It's a variation on "unnecessarily tagging individuals". - You're short-changing yourself when it comes to getting help. If you ask @@ -286,11 +286,12 @@ idea: #### Doubt your ability or skill level -I suppose this should really be a do rather than a don't, as here I want to -encourage something positive. A few times I've helped people out who have -been very apologetic about their questions being "noob" questions, or about -how they're fairly new to Python, or programming in general. Really, please, -don't feel the need to apologise and don't be ashamed of where you're at. +I suppose this should really be phrased as a do rather than a don't, as here +I want to encourage something positive. A few times I've helped people out +who have been very apologetic about their questions being "noob" questions, +or about how they're fairly new to Python, or programming in general. +Really, please, don't feel the need to apologise and don't be ashamed of +where you're at. If you've asked something that's obviously answered in the documentation, that's not a problem; you'll likely get pointed at the docs and it's what @@ -300,6 +301,8 @@ thing. The only time it's a problem is when there's a real reluctance to use the available resources. We've all seen that person somewhere at some point, right? ;-) +Not knowing things [is totally cool](https://xkcd.com/1053/). + ## Conclusion So, that's my waffle over. As I said at the start: this is my own personal From acb206046ac2a436de125f9327a7234fe036c0a0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 22:19:48 +0000 Subject: [PATCH 42/47] simplify resized widgets --- src/textual/_compositor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index bc56d019d..e7d436add 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -269,11 +269,14 @@ class Compositor: # Contains widgets + geometry for every widget that changed (added, removed, or updated) changes = map.items() ^ old_map.items() + # Widgets in both new and old + common_widgets = old_widgets & new_widgets + # Widgets with changed size resized_widgets = { widget for widget, (region, *_) in changes - if widget in old_widgets and old_map[widget].region.size != region.size + if widget in common_widgets and old_map[widget].region.size != region.size } screen_region = size.region From 204f3e48edda63ea214142dc84aada9be4727146 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Jan 2023 22:32:09 +0000 Subject: [PATCH 43/47] optimization --- src/textual/_compositor.py | 5 ++++- src/textual/geometry.py | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e7d436add..163ce7665 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -276,7 +276,10 @@ class Compositor: resized_widgets = { widget for widget, (region, *_) in changes - if widget in common_widgets and old_map[widget].region.size != region.size + if ( + widget in common_widgets + and not old_map[widget].region.same_size(region) + ) } screen_region = size.region diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 17e938b1d..9990a9cdb 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -541,6 +541,18 @@ class Region(NamedTuple): width2, height2 = size return Region(x, y, min(width1, width2), min(height1, height2)) + def same_size(self, region: Region) -> bool: + """Check if another region is the same size. Equivalent to `self.size == region.size`, + but a little faster. + + Args: + region (Region): A region. + + Returns: + bool: True if both regions are the same size, False if they are different sizes. + """ + return self[2:] == region[2:] + def expand(self, size: tuple[int, int]) -> Region: """Increase the size of the region by adding a border. From a1b4d53fb9465295ee1bb84a594b1541e17efba5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 08:51:00 +0000 Subject: [PATCH 44/47] Update date This hasn't been accepted/published yet so let's update the date. --- docs/blog/posts/looking-for-help.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index 46a9aa4eb..ad1c8e8fa 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -1,6 +1,6 @@ --- draft: false -date: 2023-01-08 +date: 2023-01-09 categories: - DevLog authors: @@ -20,7 +20,7 @@ authors: Juan Sánchez Villalobos Ramírez, Chief metallurgist to King Charles V of Spain -As of the time of writing, I'm a handful of days off having been with +As of the time of writing, I'm a couple or so days off having been with Textualize for 3 months. It's been fun, and educational, and every bit as engaging as I'd hoped, and more. One thing I hadn't quite prepared for though, but which I really love, is how so many other people are learning From bd9d88fa4f5943c8dac323916e236b306d4f8735 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 10:10:29 +0000 Subject: [PATCH 45/47] Fix a typo --- docs/blog/posts/looking-for-help.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index ad1c8e8fa..bfbb484e2 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -54,7 +54,7 @@ for and also providing help in email, on Usenet, on forums, etc. Or, put another way: if what you read in here seems sensible to you, I figure we'll likely have already hit it off over on GitHub or in the Discord -sever. ;-) +server. ;-) ## Where to go for help From 0a190193d763db429660416935078d8bc0db2f22 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 9 Jan 2023 10:16:52 +0000 Subject: [PATCH 46/47] Link to GitHub and Discord early on For the benefit of those who aren't there yet. --- docs/blog/posts/looking-for-help.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/blog/posts/looking-for-help.md b/docs/blog/posts/looking-for-help.md index bfbb484e2..fe142303d 100644 --- a/docs/blog/posts/looking-for-help.md +++ b/docs/blog/posts/looking-for-help.md @@ -53,8 +53,9 @@ take on how best to go about asking for help, informed by years of asking for and also providing help in email, on Usenet, on forums, etc. Or, put another way: if what you read in here seems sensible to you, I -figure we'll likely have already hit it off over on GitHub or in the Discord -server. ;-) +figure we'll likely have already hit it off [over on +GitHub](https://github.com/Textualize/textual) or in [the Discord +server](https://discord.gg/Enf6Z3qhVr). ;-) ## Where to go for help From 3ac98187f9beb0dccc3ceb95ca42a66958093b35 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Jan 2023 10:41:04 +0000 Subject: [PATCH 47/47] removed same_size --- src/textual/_compositor.py | 5 +---- src/textual/geometry.py | 12 ------------ 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 163ce7665..ca92e525e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -276,10 +276,7 @@ class Compositor: resized_widgets = { widget for widget, (region, *_) in changes - if ( - widget in common_widgets - and not old_map[widget].region.same_size(region) - ) + if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) } screen_region = size.region diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 9990a9cdb..17e938b1d 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -541,18 +541,6 @@ class Region(NamedTuple): width2, height2 = size return Region(x, y, min(width1, width2), min(height1, height2)) - def same_size(self, region: Region) -> bool: - """Check if another region is the same size. Equivalent to `self.size == region.size`, - but a little faster. - - Args: - region (Region): A region. - - Returns: - bool: True if both regions are the same size, False if they are different sizes. - """ - return self[2:] == region[2:] - def expand(self, size: tuple[int, int]) -> Region: """Increase the size of the region by adding a border.