Skip to content

Commit

Permalink
Merge pull request Textualize#4244 from Textualize/sort-children
Browse files Browse the repository at this point in the history
sort children method
  • Loading branch information
willmcgugan authored Mar 4, 2024
2 parents da56de9 + 4a729c6 commit 396ddba
Show file tree
Hide file tree
Showing 10 changed files with 555 additions and 180 deletions.
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",
]

0 comments on commit 396ddba

Please sign in to comment.