Date: 2025-11-07 Goal: Add column-based sorting functionality to smart_list library
Add comprehensive sorting support to smart_list, allowing users to click column headers to sort the list by that column. Support both ascending and descending order with visual indicators.
- Clean API - Simple, intuitive interface for defining sortable columns
- Backwards Compatible - Existing code continues to work without changes
- Platform Consistent - Works on both ListView (Windows/Linux) and DataView (macOS)
- Visual Feedback - Show sort direction indicators (▲▼) in column headers
- Performance - Efficient sorting even with large lists
- Flexible - Support custom sort keys and multi-type columns
Current:
class Column:
def __init__(self, title=None, width=-1, model_field=None):
self.title = title
self.model_field = model_field
self.width = widthProposed:
class Column:
def __init__(self, title=None, width=-1, model_field=None,
sortable=True, sort_key=None, reverse_sort=False):
self.title = title
self.model_field = model_field
self.width = width
self.sortable = sortable # Whether this column can be sorted
self.sort_key = sort_key # Custom sort key function (optional)
self.reverse_sort = reverse_sort # Default sort directionParameters:
sortable(bool): If False, clicking header does nothing. Default: Truesort_key(callable or None): Custom function for extracting sort value from model- If None, uses the same logic as get_model_value()
- Signature:
sort_key(model) -> comparable_value - Example:
sort_key=lambda book: book.get("title", "").lower()for case-insensitive
reverse_sort(bool): Default sort direction. True = descending, False = ascending
Examples:
# Simple sortable column (uses model_field value)
Column(title="Name", model_field="name")
# Non-sortable column
Column(title="Actions", model_field=get_actions, sortable=False)
# Custom sort key for case-insensitive sorting
Column(title="Title", model_field="title",
sort_key=lambda x: x.get("title", "").lower())
# Sort by length of author list, default descending
Column(title="Authors", model_field=format_authors,
sort_key=lambda x: len(x.get("author", [])),
reverse_sort=True)New Methods:
def sort_by_column(self, column_index, reverse=False):
"""Sort the list by specified column.
Args:
column_index (int): Index of column to sort by (0-based)
reverse (bool): Sort in descending order if True
Raises:
ValueError: If column_index is out of range
RuntimeError: If column is not sortable
"""
def get_sort_state(self):
"""Get current sort state.
Returns:
tuple: (column_index, reverse) or (None, None) if unsorted
"""
def toggle_sort(self, column_index):
"""Toggle sort direction or sort by new column.
If currently sorted by this column, reverses direction.
If sorted by different column, sorts by this column ascending.
Args:
column_index (int): Index of column to sort by
"""New Instance Variables:
self.sort_column = None # Index of currently sorted column (None = unsorted)
self.sort_reverse = False # Current sort direction
self.sort_enabled = True # Master switch to enable/disable sortingModified Methods:
def set_columns(self, columns):
"""Configure columns and bind sort event handler."""
# Existing logic...
# NEW: Bind EVT_LIST_COL_CLICK if any columns are sortable
def add_items(self, items):
"""Add items and re-sort if currently sorted."""
# Existing logic...
# NEW: If self.sort_column is set, re-apply sort after adding
def delete_items(self, items):
"""Delete items (sorting state maintained)."""
# Existing logic (no change needed)
def update_item(self, item, original=None):
"""Update item and re-sort if currently sorted."""
# Existing logic...
# NEW: If self.sort_column is set, may need to re-sortLocation: Lines 393-446
Changes:
class Column(object):
"""Column definition for SmartList display.
Defines how to extract and display a value from model objects.
Supports three field resolution strategies:
- Attribute access: model_field="name" -> obj.name
- Dict key access: model_field="name" -> obj["name"]
- Callable: model_field=lambda x: x.first + x.last
Args:
title: Column header text
width: Column width in pixels (-1 for auto)
model_field: Field name or callable for extracting values
sortable: Whether column can be sorted (default True)
sort_key: Custom callable for extracting sort value (optional)
reverse_sort: Default to descending sort (default False)
"""
def __init__(self, title=None, width=-1, model_field=None,
sortable=True, sort_key=None, reverse_sort=False):
self.title = title
self.model_field = model_field
self.width = width
self.sortable = sortable
self.sort_key = sort_key
self.reverse_sort = reverse_sort
def get_sort_value(self, model):
"""Extract sort value from model object.
Uses sort_key if provided, otherwise uses get_model_value logic.
Args:
model: Object to extract value from
Returns:
Comparable value for sorting
Raises:
RuntimeError: If field not found and sort_key not provided
"""
if self.sort_key is not None:
return self.sort_key(model)
# Fall back to get_model_value logic
if self.model_field is None:
return ""
if is_callable(self.model_field):
return self.model_field(model)
try:
value = getattr(model, self.model_field)
except (AttributeError, TypeError):
try:
value = model[self.model_field]
except (KeyError, TypeError):
raise RuntimeError(
"Unable to find a %r attribute or key on model %r"
% (self.model_field, model)
)
if is_callable(value):
value = value()
return value
# Existing get_model_value method unchangedLocation: Lines 85-285
Changes:
a) init modification:
def __init__(self, parent=None, id=-1, *args, **kwargs):
choices = kwargs.pop("choices", [])
self.control = UnifiedList(
parent_obj=self, parent=parent, id=id, *args, **kwargs
)
self.models = []
self.list_items = []
self.index_map = {}
self.columns = []
# NEW: Sorting state
self.sort_column = None # Index of column being sorted by
self.sort_reverse = False # Sort direction
self.sort_enabled = True # Master switch for sorting
self.add_items(choices)b) set_columns modification:
def set_columns(self, columns):
"""Configure column definitions for the list.
Args:
columns: List of Column objects defining display fields
"""
self.columns = columns
for column in columns:
self.control.AppendColumn(column.title, column.width)
# NEW: Bind column click event if any columns are sortable
if any(col.sortable for col in columns):
self.control.BindColumnClick(self._handle_column_click)c) New sorting methods:
def _handle_column_click(self, event):
"""Handle column header click for sorting.
Args:
event: wx.ListEvent with column index
"""
if not self.sort_enabled:
return
column_index = event.GetColumn()
# Check if column is sortable
if column_index < 0 or column_index >= len(self.columns):
return
if not self.columns[column_index].sortable:
return
self.toggle_sort(column_index)
def toggle_sort(self, column_index):
"""Toggle sort direction or sort by new column.
If currently sorted by this column, reverses direction.
If sorted by different column, sorts by this column (using column's default direction).
Args:
column_index (int): Index of column to sort by
"""
if column_index == self.sort_column:
# Toggle direction on same column
self.sort_reverse = not self.sort_reverse
else:
# Sort by new column using its default direction
self.sort_column = column_index
self.sort_reverse = self.columns[column_index].reverse_sort
self.sort_by_column(self.sort_column, self.sort_reverse)
def sort_by_column(self, column_index, reverse=False):
"""Sort the list by specified column.
Args:
column_index (int): Index of column to sort by (0-based)
reverse (bool): Sort in descending order if True
Raises:
ValueError: If column_index is out of range
RuntimeError: If column is not sortable
"""
if column_index < 0 or column_index >= len(self.columns):
raise ValueError(f"Column index {column_index} out of range")
column = self.columns[column_index]
if not column.sortable:
raise RuntimeError(f"Column {column_index} ({column.title}) is not sortable")
# Store sort state
self.sort_column = column_index
self.sort_reverse = reverse
# Sort models using column's get_sort_value
try:
self.models.sort(
key=lambda model: column.get_sort_value(model),
reverse=reverse
)
except Exception as e:
logger.exception(f"Error sorting by column {column_index}: {e}")
raise
# Rebuild display
self._rebuild_display()
# Update visual sort indicator
self.control.SetSortIndicator(column_index, reverse)
def _rebuild_display(self):
"""Rebuild the list display from sorted models."""
self.control.Freeze()
try:
# Clear display but keep models
self.control.Clear()
# Re-add all items in new order
for model in self.models:
columns = self.get_columns_for(model)
self.control.Append(columns)
# Rebuild index map
self._rebuild_index_map()
finally:
self.control.Thaw()
def get_sort_state(self):
"""Get current sort state.
Returns:
tuple: (column_index, reverse) or (None, None) if unsorted
"""
return (self.sort_column, self.sort_reverse)
def clear_sort(self):
"""Clear sorting and return to original order.
Note: This doesn't restore insertion order, just clears the sort state.
To restore original order, you'd need to track it separately.
"""
self.sort_column = None
self.sort_reverse = False
self.control.ClearSortIndicator()d) Modify add_items to maintain sort:
@freeze_and_thaw
def add_items(self, items):
"""Add multiple items to the list.
If list is currently sorted, items are added and list is re-sorted.
Args:
items: Iterable of model objects to display
"""
if self.index_map is None:
self._rebuild_index_map()
for item in items:
self.models.append(item)
if isinstance(item, MutableMapping):
item = freeze_dict(item)
# Don't add to display yet if we need to re-sort
# If sorted, re-sort and rebuild. Otherwise add to display.
if self.sort_column is not None:
self.sort_by_column(self.sort_column, self.sort_reverse)
else:
# Add items to display in order
for item in items:
columns = self.get_columns_for(item)
self.control.Append(columns)
if isinstance(item, MutableMapping):
item = freeze_dict(item)
self.index_map[item] = len(self.models) - 1Location: Lines 22-189
New Methods:
def BindColumnClick(self, handler):
"""Bind column header click event.
Args:
handler: Callback function(event)
"""
if self.use_dataview:
# DataView uses EVT_DATAVIEW_COLUMN_HEADER_CLICK
self.control.Bind(wx.dataview.EVT_DATAVIEW_COLUMN_HEADER_CLICK, handler)
else:
# ListView uses EVT_LIST_COL_CLICK
self.control.Bind(wx.EVT_LIST_COL_CLICK, handler)
def SetSortIndicator(self, column_index, reverse=False):
"""Show sort indicator on column header.
Args:
column_index (int): Column to show indicator on
reverse (bool): True for descending (▼), False for ascending (▲)
"""
if self.use_dataview:
# DataView supports sort indicators
col = self.control.GetColumn(column_index)
if col:
if reverse:
col.SetSortOrder(False) # Descending
else:
col.SetSortOrder(True) # Ascending
# Note: May need to set col as sort column explicitly
# self.control.SetSortingColumn(column_index)
else:
# ListView: Use SortItems or manually draw indicator
# wx.ListCtrl doesn't have built-in sort indicators on all platforms
# May need to prepend ▲/▼ to column title as workaround
self._update_column_title_with_indicator(column_index, reverse)
def ClearSortIndicator(self):
"""Clear all sort indicators from column headers."""
if self.use_dataview:
# Clear DataView sort indicator
# self.control.ClearSortingColumn() # If this method exists
pass
else:
# Clear ListView title indicators
self._clear_all_column_indicators()
def _update_column_title_with_indicator(self, column_index, reverse):
"""Add ▲/▼ to column title (ListView workaround).
Args:
column_index (int): Column to update
reverse (bool): Direction indicator
"""
# First clear all existing indicators
self._clear_all_column_indicators()
# Get column info
col_info = self.control.GetColumn(column_index)
title = col_info.GetText()
# Remove any existing indicators
title = title.rstrip(" ▲▼↑↓")
# Add new indicator
indicator = " ▼" if reverse else " ▲"
col_info.SetText(title + indicator)
self.control.SetColumn(column_index, col_info)
def _clear_all_column_indicators(self):
"""Remove ▲/▼ from all column titles (ListView workaround)."""
for i in range(self.control.GetColumnCount()):
col_info = self.control.GetColumn(i)
title = col_info.GetText()
title = title.rstrip(" ▲▼↑↓")
col_info.SetText(title)
self.control.SetColumn(i, col_info)Challenge: Virtual lists don't store models in memory, so sorting requires special handling.
Options:
Option A: Disable sorting for virtual lists (simplest)
class VirtualSmartList(SmartList):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.sort_enabled = False # Disable sorting for virtual listsOption B: Support sorting with callback (advanced)
class VirtualSmartList(SmartList):
def __init__(self, get_virtual_item=None, update_cache=None,
sort_callback=None, *args, **kwargs):
# ...existing init...
self.sort_callback = sort_callback # Callable(column_index, reverse)
def sort_by_column(self, column_index, reverse=False):
"""Delegate sorting to callback if provided."""
if self.sort_callback:
self.sort_callback(column_index, reverse)
self.sort_column = column_index
self.sort_reverse = reverse
self.control.SetSortIndicator(column_index, reverse)
self.refresh()
else:
raise RuntimeError("Virtual lists require sort_callback for sorting")Recommendation: Start with Option A (disable for virtual lists), add Option B later if needed.
import unittest
import wx
from smart_list import SmartList, Column
class TestSorting(unittest.TestCase):
def setUp(self):
self.app = wx.App()
self.frame = wx.Frame(None)
def tearDown(self):
self.frame.Destroy()
self.app.Destroy()
def test_sort_by_string_column(self):
"""Test sorting by string attribute."""
lst = SmartList(parent=self.frame)
lst.set_columns([
Column(title="Name", model_field="name"),
Column(title="Age", model_field="age")
])
items = [
{"name": "Charlie", "age": 30},
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 35},
]
lst.add_items(items)
# Sort by name ascending
lst.sort_by_column(0, reverse=False)
self.assertEqual(lst.models[0]["name"], "Alice")
self.assertEqual(lst.models[1]["name"], "Bob")
self.assertEqual(lst.models[2]["name"], "Charlie")
# Sort by name descending
lst.sort_by_column(0, reverse=True)
self.assertEqual(lst.models[0]["name"], "Charlie")
self.assertEqual(lst.models[2]["name"], "Alice")
def test_sort_by_numeric_column(self):
"""Test sorting by numeric attribute."""
lst = SmartList(parent=self.frame)
lst.set_columns([
Column(title="Name", model_field="name"),
Column(title="Age", model_field="age")
])
items = [
{"name": "Charlie", "age": 30},
{"name": "Alice", "age": 25},
{"name": "Bob", "age": 35},
]
lst.add_items(items)
lst.sort_by_column(1, reverse=False)
self.assertEqual(lst.models[0]["age"], 25)
self.assertEqual(lst.models[1]["age"], 30)
self.assertEqual(lst.models[2]["age"], 35)
def test_toggle_sort(self):
"""Test toggle_sort switches direction."""
lst = SmartList(parent=self.frame)
lst.set_columns([Column(title="Name", model_field="name")])
items = [{"name": "B"}, {"name": "A"}, {"name": "C"}]
lst.add_items(items)
# First toggle: sort ascending
lst.toggle_sort(0)
self.assertEqual(lst.sort_column, 0)
self.assertEqual(lst.sort_reverse, False)
self.assertEqual(lst.models[0]["name"], "A")
# Second toggle: reverse to descending
lst.toggle_sort(0)
self.assertEqual(lst.sort_reverse, True)
self.assertEqual(lst.models[0]["name"], "C")
def test_custom_sort_key(self):
"""Test sorting with custom sort_key function."""
lst = SmartList(parent=self.frame)
lst.set_columns([
Column(
title="Name",
model_field="name",
sort_key=lambda x: x["name"].lower() # Case-insensitive
)
])
items = [
{"name": "charlie"},
{"name": "Alice"},
{"name": "BOB"},
]
lst.add_items(items)
lst.sort_by_column(0, reverse=False)
self.assertEqual(lst.models[0]["name"], "Alice")
self.assertEqual(lst.models[1]["name"], "BOB")
self.assertEqual(lst.models[2]["name"], "charlie")
def test_non_sortable_column(self):
"""Test that non-sortable columns raise error."""
lst = SmartList(parent=self.frame)
lst.set_columns([
Column(title="Name", model_field="name", sortable=False)
])
items = [{"name": "A"}, {"name": "B"}]
lst.add_items(items)
with self.assertRaises(RuntimeError):
lst.sort_by_column(0)
def test_sort_state_persists_on_add(self):
"""Test that adding items maintains sort order."""
lst = SmartList(parent=self.frame)
lst.set_columns([Column(title="Name", model_field="name")])
lst.add_items([{"name": "C"}, {"name": "A"}])
lst.sort_by_column(0, reverse=False)
# Add new item
lst.add_items([{"name": "B"}])
# Should still be sorted
self.assertEqual(lst.models[0]["name"], "A")
self.assertEqual(lst.models[1]["name"], "B")
self.assertEqual(lst.models[2]["name"], "C")- Click column header sorts ascending
- Click same header again sorts descending
- Click different header sorts by new column ascending
- Sort indicator (▲▼) appears on correct column
- Only one column shows indicator at a time
- Non-sortable columns don't respond to clicks
- Adding items maintains sort order
- Deleting items maintains sort order
- Works with dict models
- Works with object models
- Works with callable model_field
- Custom sort_key functions work correctly
- Sort persists across Freeze/Thaw operations
- Visual indicator clears when sort is cleared
Files:
smart_list/smart_list.py(Column, SmartList)smart_list/unified_list.py(UnifiedList)
Tasks:
- Add sortable, sort_key, reverse_sort to Column.init
- Add get_sort_value() to Column class
- Add sort state variables to SmartList.init
- Add sort_by_column() method to SmartList
- Add toggle_sort() method to SmartList
- Add _rebuild_display() method to SmartList
- Modify set_columns() to bind column click event
- Add _handle_column_click() to SmartList
- Add BindColumnClick() to UnifiedList
Deliverable: Can programmatically sort list via lst.sort_by_column(0)
Files:
smart_list/unified_list.py
Tasks:
- Add SetSortIndicator() to UnifiedList
- Add ClearSortIndicator() to UnifiedList
- Add _update_column_title_with_indicator() for ListView
- Add _clear_all_column_indicators() for ListView
- Call SetSortIndicator() from SmartList.sort_by_column()
Deliverable: Sort direction shown in column header (▲▼)
Files:
smart_list/smart_list.py
Tasks:
- Modify add_items() to re-sort after adding
- Add get_sort_state() method
- Add clear_sort() method
- Test that delete_items maintains sort order (should work automatically)
Deliverable: Sort order maintained when items added/removed
Files:
smart_list/smart_list.py(VirtualSmartList)
Tasks:
- Decide on approach (disable or callback)
- Implement chosen approach
- Document virtual list sorting limitations/requirements
Deliverable: Virtual lists either disable sorting gracefully or support via callback
Files:
smart_list/tests/test_sorting.py(new)smart_list/README.md- Example scripts
Tasks:
- Write comprehensive unit tests
- Update README with sorting examples
- Create example script demonstrating sorting features
- Manual testing on Windows, Linux, macOS
Deliverable: Fully tested and documented feature
All changes are backwards compatible:
- Column class accepts new optional parameters (all default to backwards-compatible values)
- SmartList behavior unchanged unless user clicks column headers
- Existing code continues to work without modification
Before (still works):
lst.set_columns([
Column(title="Name", model_field="name", width=100),
Column(title="Age", model_field="age", width=50)
])After (with sorting features):
lst.set_columns([
Column(title="Name", model_field="name", width=100,
sort_key=lambda x: x.name.lower()), # Case-insensitive
Column(title="Age", model_field="age", width=50),
Column(title="Actions", model_field=format_actions,
sortable=False) # Disable sorting on this column
])- Clicking header on empty list should be no-op
- get_sort_state() returns (None, None) for unsorted list
- sort_key should handle None gracefully
- Consider:
sort_key=lambda x: (x.get("name") or "", x.get("id", 0)) - Or:
sort_key=lambda x: x.get("name") or ""
- If column has mixed types (e.g., some ints, some strings), sort will fail
- Provide helpful error message
- Document that columns should have consistent types for sorting
- Freeze/Thaw prevents flickering during rebuild
- For very large lists (>10k items), consider profiling
- May need to batch updates or use virtual list
- Ensure sort_key works with custom objects
- Document that objects must be comparable if using default sort
- Test sort indicators on all platforms
- ListView (Windows/Linux) may need title-based indicators
- DataView (macOS) has native sort indicator support
- Python's
list.sort()is Timsort (O(n log n)) - Very efficient for partially sorted data
- Stable sort preserves relative order of equal elements
- Freeze/Thaw prevents multiple redraws
- Clear + re-add all items may be slow for very large lists
- Alternative: Update each row in place (more complex)
- _rebuild_index_map() is O(n)
- Called after every sort
- Using frozendict for dict keys adds overhead
- Lazy Sorting: Only sort when visible (defer until Thaw)
- Incremental Updates: Update changed rows instead of full rebuild
- Sort Cache: Cache sort keys to avoid recomputing
- Virtual Sorting: For virtual lists, sort indices not items
lst.sort_by_columns([
(0, False), # Primary: column 0 ascending
(1, True) # Secondary: column 1 descending
])- Allow custom sort indicator characters/icons
- Support themes (dark mode, high contrast)
- Define column groups that sort together
- E.g., "First Name" + "Last Name" as single sortable unit
- Callback to request sorted data from server
- Useful for paginated/virtual lists with backend
- Remember last N sort operations
- Allow undo/redo of sorts
- Announce sort state to screen readers
- Keyboard shortcuts for sorting (e.g., Alt+Click for multi-column)
-
Should we support multi-column sorting in Phase 1?
- Recommendation: No, add later if needed
-
What should happen when clicking a non-sortable column header?
- Recommendation: No-op (no visual feedback)
- Alternative: Show tooltip "This column cannot be sorted"
-
Should sort state be exposed in API for persistence?
- Use case: Save/restore sort preference across sessions
- Recommendation: Add get_sort_state() in Phase 3
-
How should we handle sort on columns with None values?
- Recommendation: None values sort to end (ascending) or beginning (descending)
- Implementation: Use custom key like
key=lambda x: (x is None, x)
-
Should we add sort animation/transition?
- Recommendation: No, keep it simple for MVP
- May cause issues with accessibility
-
Do we need column click modifiers (Shift for secondary sort)?
- Recommendation: Not for MVP, consider for future
Feature is complete when:
- ✅ User can click any sortable column header to sort
- ✅ Clicking again reverses sort direction
- ✅ Visual indicator shows which column is sorted and direction
- ✅ Sort persists when adding new items
- ✅ Non-sortable columns cannot be sorted
- ✅ Custom sort_key functions work correctly
- ✅ Works on Windows/Linux (ListView) and macOS (DataView)
- ✅ All unit tests pass
- ✅ No regressions in existing functionality
- ✅ Documentation complete with examples
- ✅ Backwards compatible with existing code
- Phase 1 (Core): 4-6 hours
- Phase 2 (Visual): 2-3 hours
- Phase 3 (Persistence): 2-3 hours
- Phase 4 (Virtual - optional): 2-4 hours
- Phase 5 (Testing/Docs): 3-4 hours
Total: 13-20 hours (excluding virtual list support)
- Keep changes minimal and focused
- Prioritize code clarity over cleverness
- Test incrementally after each phase
- Consider impact on other projects using smart_list
- Document any platform-specific behavior