Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sort children method #4244

Merged
merged 10 commits into from
Mar 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Mapping of ANSI colors to hex codes configurable via `App.ansi_theme_dark` and `App.ansi_theme_light` https://github.com/Textualize/textual/pull/4192
- `Pilot.resize_terminal` to resize the terminal in testing https://github.com/Textualize/textual/issues/4212
- Added `sort_children` method https://github.com/Textualize/textual/pull/4244

### Fixed

Expand Down
6 changes: 6 additions & 0 deletions docs/blog/posts/toolong-retrospective.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ You register a file with a `Selector` object, then call `select()` which returns

See [watcher.py](https://github.com/Textualize/toolong/blob/main/src/toolong/watcher.py) in Toolong, which runs a thread to monitors files for changes with a selector.

!!! warning "Addendum"

So it turns out that watching regular files for changes with selectors only works with `KqueueSelector` which is the default on macOS.
Disappointingly, the Python docs aren't clear on this.
Toolong will use a polling approach where this selector is unavailable.

## Textual learnings

This project was a chance for me to "dogfood" Textual.
Expand Down
341 changes: 166 additions & 175 deletions poetry.lock

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/textual/_node_list.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
from __future__ import annotations

import sys
from typing import TYPE_CHECKING, Any, Iterator, Sequence, overload
from operator import attrgetter
from typing import TYPE_CHECKING, Any, Callable, Iterator, Sequence, overload

import rich.repr

if TYPE_CHECKING:
from _typeshed import SupportsRichComparison

from .widget import Widget


Expand Down Expand Up @@ -49,6 +52,25 @@ def __len__(self) -> int:
def __contains__(self, widget: object) -> bool:
return widget in self._nodes

def _sort(
self,
*,
key: Callable[[Widget], SupportsRichComparison] | None = None,
reverse: bool = False,
):
"""Sort nodes.

Args:
key: A key function which accepts a widget, or `None` for no key function.
reverse: Sort in descending order.
"""
if key is None:
self._nodes.sort(key=attrgetter("sort_order"), reverse=reverse)
else:
self._nodes.sort(key=key, reverse=reverse)

self._updates += 1

def index(self, widget: Any, start: int = 0, stop: int = sys.maxsize) -> int:
"""Return the index of the given widget.

Expand Down
28 changes: 27 additions & 1 deletion src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
from .walk import walk_breadth_first, walk_depth_first

if TYPE_CHECKING:
from typing_extensions import Self, TypeAlias
from _typeshed import SupportsRichComparison

from rich.console import RenderableType
from .app import App
from .css.query import DOMQuery, QueryType
Expand All @@ -54,7 +57,6 @@
from .screen import Screen
from .widget import Widget
from .worker import Worker, WorkType, ResultType
from typing_extensions import Self, TypeAlias

# Unused & ignored imports are needed for the docs to link to these objects:
from .css.query import NoMatches, TooManyMatches, WrongType # type: ignore # noqa: F401
Expand Down Expand Up @@ -338,6 +340,30 @@ def children(self) -> Sequence["Widget"]:
"""
return self._nodes

def sort_children(
self,
*,
key: Callable[[Widget], SupportsRichComparison] | None = None,
reverse: bool = False,
) -> None:
"""Sort child widgets with an optional key function.

If `key` is not provided then widgets will be sorted in the order they are constructed.

Example:
```python
# Sort widgets by name
screen.sort_children(key=lambda widget: widget.name or "")
```

Args:
key: A callable which accepts a widget and returns something that can be sorted,
or `None` to sort without a key function.
reverse: Sort in descending order.
"""
self._nodes._sort(key=key, reverse=reverse)
self.refresh(layout=True)

@property
def auto_refresh(self) -> float | None:
"""Number of seconds between automatic refresh, or `None` for no automatic refresh."""
Expand Down
6 changes: 5 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,9 @@ class Widget(DOMNode):
loading: Reactive[bool] = Reactive(False)
"""If set to `True` this widget will temporarily be replaced with a loading indicator."""

# Default sort order, incremented by constructor
_sort_order: ClassVar[int] = 0

def __init__(
self,
*children: Widget,
Expand All @@ -324,6 +327,8 @@ def __init__(
self._recompose_required = False
self._default_layout = VerticalLayout()
self._animate: BoundAnimator | None = None
Widget._sort_order += 1
self.sort_order = Widget._sort_order
self.highlight_style: Style | None = None

self._vertical_scrollbar: ScrollBar | None = None
Expand All @@ -340,7 +345,6 @@ def __init__(
self._repaint_regions: set[Region] = set()

# Cache the auto content dimensions
# TODO: add mechanism to explicitly clear this
self._content_width_cache: tuple[object, int] = (None, 0)
self._content_height_cache: tuple[object, int] = (None, 0)

Expand Down
160 changes: 160 additions & 0 deletions tests/snapshot_tests/__snapshots__/test_snapshots.ambr

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions tests/snapshot_tests/snapshot_apps/sort_children.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from operator import attrgetter

from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Label


class Number(Label):
DEFAULT_CSS = """
Number {
width: 1fr;
}
"""

def __init__(self, number: int) -> None:
self.number = number
super().__init__(classes=f"number{number}")

def render(self) -> str:
return str(self.number)


class NumberList(Vertical):

DEFAULT_CSS = """
NumberList {
width: 1fr;
Number {
border: green;
box-sizing: border-box;
&.number1 {
height: 3;
}
&.number2 {
height: 4;
}
&.number3 {
height: 5;
}
&.number4 {
height: 6;
}
&.number5 {
height: 7;
}
}
}

"""

def compose(self) -> ComposeResult:
yield Number(5)
yield Number(1)
yield Number(3)
yield Number(2)
yield Number(4)


class SortApp(App):

def compose(self) -> ComposeResult:
with Horizontal():
yield NumberList(id="unsorted")
yield NumberList(id="ascending")
yield NumberList(id="descending")

def on_mount(self) -> None:
self.query_one("#ascending").sort_children(
key=attrgetter("number"),
)
self.query_one("#descending").sort_children(
key=attrgetter("number"),
reverse=True,
)


if __name__ == "__main__":
app = SortApp()
app.run()
13 changes: 11 additions & 2 deletions tests/snapshot_tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):

# --- Other ---


def test_key_display(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py")

Expand Down Expand Up @@ -564,8 +565,10 @@ def test_richlog_width(snap_compare):
"""Check that min_width applies in RichLog and that we can write
to the RichLog when it's not visible, and it still renders as expected
when made visible again."""

async def setup(pilot):
from rich.text import Text

rich_log: RichLog = pilot.app.query_one(RichLog)
rich_log.write(Text("hello1", style="on red", justify="right"), expand=True)
rich_log.visible = False
Expand All @@ -576,8 +579,7 @@ async def setup(pilot):
rich_log.write(Text("world4", style="on yellow", justify="right"), expand=True)
rich_log.display = True

assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_width.py",
run_before=setup)
assert snap_compare(SNAPSHOT_APPS_DIR / "richlog_width.py", run_before=setup)


def test_tabs_invalidate(snap_compare):
Expand Down Expand Up @@ -981,6 +983,7 @@ def test_text_area_alternate_screen(snap_compare):
SNAPSHOT_APPS_DIR / "text_area_alternate_screen.py", terminal_size=(48, 10)
)


@pytest.mark.syntax
def test_text_area_wrapping_and_folding(snap_compare):
assert snap_compare(
Expand Down Expand Up @@ -1103,11 +1106,13 @@ def test_input_percentage_width(snap_compare):
# https://github.com/Textualize/textual/issues/3721
assert snap_compare(SNAPSHOT_APPS_DIR / "input_percentage_width.py")


def test_recompose(snap_compare):
"""Check recompose works."""
# https://github.com/Textualize/textual/pull/4206
assert snap_compare(SNAPSHOT_APPS_DIR / "recompose.py")


@pytest.mark.parametrize("dark", [True, False])
def test_ansi_color_mapping(snap_compare, dark):
"""Test how ANSI colors in Rich renderables are mapped to hex colors."""
Expand All @@ -1124,3 +1129,7 @@ def test_pretty_grid_gutter_interaction(snap_compare):
SNAPSHOT_APPS_DIR / "pretty_grid_gutter_interaction.py", terminal_size=(81, 7)
)


def test_sort_children(snap_compare):
"""Test sort_children method."""
assert snap_compare(SNAPSHOT_APPS_DIR / "sort_children.py", terminal_size=(80, 25))
77 changes: 77 additions & 0 deletions tests/test_widget.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from operator import attrgetter

import pytest
from rich.text import Text

Expand Down Expand Up @@ -436,3 +438,78 @@ def render(self) -> str:
render_result = widget._render()
assert isinstance(render_result, Text)
assert render_result.plain == "Hello World!"


async def test_sort_children() -> None:
"""Test the sort_children method."""

class SortApp(App):

def compose(self) -> ComposeResult:
with Container(id="container"):
yield Label("three", id="l3")
yield Label("one", id="l1")
yield Label("four", id="l4")
yield Label("two", id="l2")

app = SortApp()
async with app.run_test():
container = app.query_one("#container", Container)
assert [label.id for label in container.query(Label)] == [
"l3",
"l1",
"l4",
"l2",
]
container.sort_children(key=attrgetter("id"))
assert [label.id for label in container.query(Label)] == [
"l1",
"l2",
"l3",
"l4",
]
container.sort_children(key=attrgetter("id"), reverse=True)
assert [label.id for label in container.query(Label)] == [
"l4",
"l3",
"l2",
"l1",
]


async def test_sort_children_no_key() -> None:
"""Test sorting with no key."""

class SortApp(App):

def compose(self) -> ComposeResult:
with Container(id="container"):
yield Label("three", id="l3")
yield Label("one", id="l1")
yield Label("four", id="l4")
yield Label("two", id="l2")

app = SortApp()
async with app.run_test():
container = app.query_one("#container", Container)
assert [label.id for label in container.query(Label)] == [
"l3",
"l1",
"l4",
"l2",
]
# Without a key, the sort order is the order children were instantiated
container.sort_children()
assert [label.id for label in container.query(Label)] == [
"l3",
"l1",
"l4",
"l2",
]
container.sort_children(reverse=True)
assert [label.id for label in container.query(Label)] == [
"l2",
"l4",
"l1",
"l3",
]
Loading