Skip to content
Draft
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ my_list.events.removed.connect(lambda i, val: print(f"Removed {val} at index {i}

my_list.append(6) # Output: Inserted 6 at index 5
my_list.pop() # Output: Removed 6 at index 5

# the `items_*` signals bracket a contiguous batch with a single (start, stop) range,
# so e.g. `extend` emits `items_inserting`/`items_inserted` once
# while `inserted` still fires once per item.
my_list.events.items_inserting.connect(
lambda start, stop: print(f"Inserting rows [{start}:{stop}]")
)
my_list.extend([7, 8, 9]) # Output: Inserting rows [2:5] (+ 3 per-item Inserted lines)
```

See the
Expand Down
5 changes: 4 additions & 1 deletion docs/guides/dataclasses.md
Original file line number Diff line number Diff line change
Expand Up @@ -522,10 +522,13 @@ class Project:
project = Project()
project.events.connect(lambda info: print(f"{info.signal.name}: {info.args} {info.path}"))

# Add a person to the list - EventedList emits two events here:
# Add a person to the list - EventedList emits the per-item events, bracketed by the
# contiguous-range items_* events (useful for handling a batch as a single update):
project.team_members.append(Person(name="Bob"))
# items_inserting: (0, 1) (.team_members, [0])
# inserting: (0,) (.team_members, [0])
# inserted: (0, Person(name='Bob', age=0)) (.team_members, [0])
# items_inserted: (0, 1, [Person(name='Bob', age=0)]) (.team_members, [0])

# Change a person in the list - this also bubbles up
project.team_members[0].age = 25
Expand Down
100 changes: 94 additions & 6 deletions src/psygnal/containers/_evented_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@
Index: TypeAlias = int | slice


def _contiguous_runs(indices: Iterable[int]) -> Iterable[tuple[int, int]]:
"""Yield `(start, stop)` half-open runs of contiguous integers, highest first.

e.g. `[1, 2, 3, 5, 6]` -> `(5, 7)`, `(1, 4)`. Runs are yielded in descending
order so that deleting later (higher) blocks leaves earlier indices valid.
"""
ordered = sorted(set(indices), reverse=True)
if not ordered:
return
stop = ordered[0] + 1
prev = ordered[0]
for idx in ordered[1:]:
if idx != prev - 1:
yield prev, stop
stop = idx + 1
prev = idx
yield prev, stop


class ListSignalInstance(SignalInstance):
def _psygnal_relocate_info_(self, emission_info: EmissionInfo) -> EmissionInfo:
"""Relocate the emission info to the index being modified.
Expand All @@ -74,6 +93,27 @@ class ListEvents(SignalGroup):
"""`(index)` emitted before an item is removed at `index`"""
removed = ListSignal(int, object)
"""`(index, value)` emitted after `value` is removed at `index`"""
items_inserting = ListSignal(int, int)
"""`(start, stop)` emitted once before a contiguous block of items is inserted
into the half-open range `[start, stop)`.

This brackets the per-item `inserting` events (which still fire for each item), so
that a batch insert (e.g. `extend`/`+=`) can be handled with a single update. A
single insert is just a length-1 range."""
items_inserted = ListSignal(int, int, object)
"""`(start, stop, values)` emitted once after a contiguous block of items has been
inserted into the half-open range `[start, stop)` (`values` is the inserted
`list`)."""
items_removing = ListSignal(int, int)
"""`(start, stop)` emitted once before a contiguous block of items is removed from
the half-open range `[start, stop)`.

Brackets the per-item `removing` events. Non-contiguous removals (e.g. a strided
slice) emit this once per contiguous block, highest block first."""
items_removed = ListSignal(int, int, object)
"""`(start, stop, values)` emitted once after a contiguous block of items has been
removed from the half-open range `[start, stop)` (`values` is the removed `list`).
"""
moving = ListSignal(int, int)
"""`(index, new_index)` emitted before an item is moved from `index` to
`new_index`"""
Expand Down Expand Up @@ -142,12 +182,48 @@ def __init__(

def insert(self, index: int, value: _T) -> None:
"""Insert `value` before index."""
# normalize for the (range-aware) items_* signals; the per-item events
# keep emitting the raw `index` exactly as before.
norm = max(0, len(self) + index) if index < 0 else min(index, len(self))
self.events.items_inserting.emit(norm, norm + 1)
self._insert_one(index, value)
self.events.items_inserted.emit(norm, norm + 1, [value])

def extend(self, values: Iterable[_T]) -> None:
"""Extend list by appending all items from `values`."""
values = list(values)
if not values:
return
start, stop = len(self), len(self) + len(values)
self.events.items_inserting.emit(start, stop)
for i, value in enumerate(values):
self._insert_one(start + i, value)
self.events.items_inserted.emit(start, stop, values)

def _insert_one(self, index: int, value: _T) -> None:
"""Insert a single `value`, emitting the per-item `inserting`/`inserted`.

This is the per-item primitive that `insert`/`extend` funnel through; the
contiguous-range `items_inserting`/`items_inserted` events are emitted by the
callers, bracketing one or more `_insert_one` calls.
"""
_value = self._pre_insert(value)
self.events.inserting.emit(index)
self._data.insert(index, _value)
self.events.inserted.emit(index, value)
self._post_insert(value)

def clear(self) -> None:
"""Remove all items from the list.

Overrides `MutableSequence.clear` (which pops one at a time) so the whole
list is removed as a single contiguous block (one `items_removing`/
`items_removed` pair). The per-item `removing`/`removed` events still fire for
each item, highest index first, exactly as before.
"""
if self._data:
del self[:]

@overload
def __getitem__(self, key: int) -> _T: ...

Expand Down Expand Up @@ -185,12 +261,24 @@ def __setitem__(self, key: Index, value: _T | Iterable[_T]) -> None:

def __delitem__(self, key: Index) -> None:
"""Delete self[key]."""
# delete from the end
for parent, index in sorted(self._delitem_indices(key), reverse=True):
parent.events.removing.emit(index)
parent._pre_remove(index)
item = parent._data.pop(index)
self.events.removed.emit(index, item)
# group indices by their (possibly nested) parent list
by_parent: dict[int, tuple[EventedList[_T], list[int]]] = {}
for parent, index in self._delitem_indices(key):
by_parent.setdefault(id(parent), (parent, []))[1].append(index)

for parent, indices in by_parent.values():
# bracket each contiguous block with items_removing/items_removed, while
# still emitting the per-item removing/removed events (highest index
# first, so lower indices stay valid as we go).
for start, stop in _contiguous_runs(indices):
parent.events.items_removing.emit(start, stop)
items: list[_T] = []
for index in range(stop - 1, start - 1, -1):
parent.events.removing.emit(index)
parent._pre_remove(index)
items.insert(0, parent._data.pop(index))
parent.events.removed.emit(index, items[0])
parent.events.items_removed.emit(start, stop, items)

def _delitem_indices(self, key: Index) -> Iterable[tuple[EventedList[_T], int]]:
# returning (self, int) allows subclasses to pass nested members
Expand Down
6 changes: 3 additions & 3 deletions src/psygnal/containers/_selectable_evented_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ def __init__(
def _on_item_removed(self, idx: int, obj: Any) -> None:
self.selection.discard(obj)

def insert(self, index: int, value: _T) -> None:
"""Insert item(s) into the list and update the selection."""
super().insert(index, value)
def _insert_one(self, index: int, value: _T) -> None:
"""Insert an item into the list and update the selection."""
super()._insert_one(index, value)
if self._activate_on_insert:
self.selection.active = value

Expand Down
Loading
Loading