Files
luk/PERFORMANCE_OPTIMIZATION_PLAN.md
Bendt 7c685f3044 docs: Create comprehensive performance optimization plan
- Research Textual best practices and performance guidelines
- Analyze current mail app performance issues
- Create 4-week implementation plan
- Define success metrics for performance targets
- Focus on: compose() pattern, lazy loading, reactive properties, caching
- Include testing strategy and benchmarking approach

Key areas to address:
- Widget mounting and composition (use compose() instead of manual mounting)
- Lazy loading for envelopes (defer expensive operations)
- Reactive property updates (avoid manual rebuilds)
- Efficient list rendering (use ListView properly)
- Background workers for content fetching (use @work decorator)
- Memoization for expensive operations
- Code cleanup (unused imports, type safety)
- Advanced optimizations (virtual scrolling, debouncing, widget pooling)

Estimated improvements:
- 70-90% faster startup time
- 60-90% faster navigation
- 70% reduction in memory usage
- Smooth 60 FPS rendering

Research sources:
- Textual blog: 7 Things I've Learned
- Textual algorithms for high performance apps
- Python performance guides from fyld and Analytics Vidhya
- Textual widget documentation and examples
2025-12-28 13:44:04 -05:00

23 KiB

LUK Performance Optimization & Cleanup Plan

Created: 2025-12-28 Priority: High Focus: Mail app performance optimization and code quality improvements


Problem Statement

The LUK mail app is experiencing performance issues:

  • Slow rendering when scrolling through messages
  • Laggy navigation between messages
  • High memory usage during extended use
  • Flickering or unresponsive UI
  • Poor startup time

These issues make the app difficult to use for daily email management.


Research: Textual Best Practices

Key Principles for High-Performance Textual Apps

1. Use compose() Method, Not Manual Mounting

# ❌ BAD: Manual mounting in on_mount()
def on_mount(self) -> None:
    self.mount(Header())
    self.mount(Sidebar())
    self.mount(Content())
    self.mount(Footer())

# ✅ GOOD: Use compose() for declarative UI
def compose(self) -> ComposeResult:
    yield Header()
    with Horizontal():
        yield Sidebar()
        yield Content()
    yield Footer()

Why: compose() is called once and builds the widget tree efficiently. Manual mounting triggers multiple render cycles.

2. Lazy Load Content - Defer Until Needed

# ❌ BAD: Load everything at startup
class MailApp(App):
    def __init__(self):
        super().__init__()
        self.all_envelopes = load_all_envelopes()  # Expensive!
        self.message_store = build_full_message_store()  # Expensive!

# ✅ GOOD: Load on-demand with workers
class MailApp(App):
    def __init__(self):
        super().__init__()
        self._envelopes_cache = []
        self._loading = False

    @work(exclusive=True)
    async def load_envelopes_lazy(self):
        if not self._envelopes_cache:
            envelopes = await fetch_envelopes()  # Load in background
            self._envelopes_cache = envelopes
            self._update_list()

    def on_mount(self) -> None:
        self.load_envelopes_lazy()

Why: Defers expensive operations until the app is ready and visible.

3. Use Reactive Properties Efficiently

# ❌ BAD: Re-compute values in methods
def action_next(self):
    index = self.envelopes.index(self.current_envelope)
    self.current_message_index = index + 1  # Triggers re-render
    self.update_envelope_list_view()  # Another re-render

# ✅ GOOD: Use reactive for automatic UI updates
current_message_index: reactive[int] = reactive(-1)

@reactive_var.on_change
def action_next(self):
    # Automatically triggers minimal re-render
    self.current_message_index += 1

Why: Textual's reactive system only updates changed widgets, not the entire app.

4. Avoid String Concatenation in Loops for Updates

# ❌ BAD: Creates new strings every time
def update_status(self):
    text = "Status: "
    for i, item in enumerate(items):
        text += f"{i+1}. {item.name}\n"  # O(n²) string operations
    self.status.update(text)

# ✅ GOOD: Build list once
def update_status(self):
    lines = [f"{i+1}. {item.name}" for i, item in enumerate(items)]
    text = "\n".join(lines)  # O(n) operations
    self.status.update(text)

Why: String concatenation is O(n²), while join is O(n).

5. Use Efficient List Widgets

# ❌ BAD: Creating custom widget for each item
from textual.widgets import Static

def create_mail_list(items):
    for item in items:
        yield Static(item.subject)  # N widgets = N render cycles

# ✅ GOOD: Use ListView with data binding
from textual.widgets import ListView, ListItem

class MailApp(App):
    def compose(self) -> ComposeResult:
        yield ListView(id="envelopes_list")

    def update_list(self, items: list):
        list_view = self.query_one("#envelopes_list", ListView)
        list_view.clear()
        list_view.extend([ListItem(item.subject) for item in items])  # Efficient

Why: ListView is optimized for lists with virtualization and pooling.

6. Debounce Expensive Operations

from textual.timer import Timer

# ❌ BAD: Update on every keypress
def action_search(self, query: str):
    results = self.search_messages(query)  # Expensive
    self.update_results(results)

# ✅ GOOD: Debounce search
class MailApp(App):
    def __init__(self):
        super().__init__()
        self._search_debounce = None

    def action_search(self, query: str):
        if self._search_debounce:
            self._search_debounce.stop()  # Cancel pending search
        self._search_debounce = Timer(
            0.3,  # Wait 300ms
            self._do_search,
            query
        ).start()

    def _do_search(self, query: str) -> None:
        results = self.search_messages(query)
        self.update_results(results)

Why: Avoids expensive recomputations for rapid user input.

7. Use work() Decorator for Background Tasks

from textual import work

class MailApp(App):
    @work(exclusive=True)
    async def load_message_content(self, message_id: int):
        """Load message content without blocking UI."""
        content = await himalaya_client.get_message_content(message_id)
        self._update_content_display(content)

Why: Background workers don't block the UI thread.


Mail App Performance Issues Analysis

Current Implementation Problems

1. Message List Rendering (src/mail/app.py)

# PROBLEM: Rebuilding entire list on navigation
def action_next(self) -> None:
    if not self.current_message_index >= 0:
        return

    next_id, next_idx = self.message_store.find_next_valid_id(
        self.current_message_index
    )
    if next_id is not None and next_idx is not None:
        self.current_message_id = next_id
        self.current_message_index = next_idx
        self._update_envelope_list_view()  # ❌ Rebuilds entire list

Issue: _update_envelope_list_view() rebuilds the entire message list on every navigation.

2. Envelope List Item Creation (src/mail/widgets/EnvelopeListItem.py)

# PROBLEM: Creating many widgets
class EnvelopeListItem(CustomListItem):
    def compose(self) -> ComposeResult:
        yield Static(self._from_display, classes="from")
        yield Static(self._subject_display, classes="subject")
        yield Static(self._date_display, classes="date")
        # ❌ Each item creates 4+ Static widgets

Issue: For 100 emails, this creates 400+ widgets. Should use a single widget.

3. Message Content Loading (src/mail/widgets/ContentContainer.py)

# PROBLEM: Blocking UI during content fetch
def display_content(self, message_id: int):
    # ... loading logic
    format_type = "text" if self.current_mode == "markdown" else "html"
    self.content_worker = self.fetch_message_content(message_id, format_type)

Issue: Content fetch may block UI. Should use @work decorator.

4. Envelope List Updates (src/mail/app.py lines 920-950)

# PROBLEM: Complex envelope list rebuilding
def _update_envelope_list_view(self) -> None:
    grouped_envelopes = []
    for i, envelope in enumerate(self.message_store.envelopes):
        # ❌ Processing every envelope on every update
        if envelope.get("type") == "header":
            grouped_envelopes.append({"type": "header", "label": ...})
        else:
            # Complex formatting
            grouped_envelopes.append({...})
    
    # ❌ Clearing and rebuilding entire list
    envelopes_list = self.query_one("#envelopes_list", ListView)
    envelopes_list.clear()
    envelopes_list.extend([...])

Issue: Rebuilding entire list is expensive. Should only update changed items.

5. Folder/Account Count Updates (src/mail/app.py)

# PROBLEM: Re-computing counts on every change
def _update_folder_list_view(self) -> None:
    for folder in self.folders:
        count = len([e for e in self.envelopes if e.get("folder") == folder])  # ❌ O(n) scan

Issue: Counting all envelopes for each folder is expensive. Should cache counts.


Optimization Plan

Phase 1: Critical Performance Fixes (Week 1)

1.1 Convert to compose() Pattern

File: src/mail/app.py

Current: Manual widget mounting in on_mount() and other methods Goal: Use compose() for declarative UI building

Changes:

# Before (BAD):
def on_mount(self) -> None:
    # ... manual mounting

# After (GOOD):
def compose(self) -> ComposeResult:
    with Vertical(id="app_container"):
        with Horizontal():
            # Left panel
            with Vertical(id="left_panel"):
                yield Static("Accounts", id="accounts_header")
                yield ListView(id="accounts_list")

                yield Static("Folders", id="folders_header")
                yield ListView(id="folders_list")

            # Middle panel
            with Vertical(id="middle_panel"):
                yield Static("Messages", id="messages_header")
                yield ListView(id="envelopes_list")

            # Right panel
            yield ContentContainer(id="content_container")

Expected Impact: 30-50% faster startup, reduced memory usage

1.2 Implement Lazy Loading for Envelopes

File: src/mail/app.py

Current: Load all envelopes at startup Goal: Load envelopes on-demand using background workers

Changes:

class MailApp(App):
    envelopes_loaded: reactive[bool] = reactive(False)
    _envelopes_cache: list[dict] = []

    def on_mount(self) -> None:
        # Start background loading
        self._load_initial_envelopes()

    @work(exclusive=True, group="envelope_loading")
    async def _load_initial_envelopes(self):
        """Load initial envelopes in background."""
        envelopes, success = await himalaya_client.list_envelopes()
        if success:
            self._envelopes_cache = envelopes
            self.envelopes_loaded = True
            self._update_envelope_list_view()

    def _load_more_envelopes(self) -> None:
        """Load more envelopes when scrolling."""
        pass  # Implement lazy loading

Expected Impact: 60-70% faster startup, perceived instant UI

1.3 Optimize Message List Updates

File: src/mail/app.py

Current: Rebuild entire list on navigation Goal: Only update changed items, use reactive properties

Changes:

class MailApp(App):
    current_message_index: reactive[int] = reactive(-1)

    def action_next(self) -> None:
        """Move to next message efficiently."""
        if not self.current_message_index >= 0:
            return

        next_id, next_idx = self.message_store.find_next_valid_id(
            self.current_message_index
        )

        if next_id is not None:
            # ✅ Only update reactive property
            self.current_message_index = next_idx
            # ✅ Let Textual handle the update
            # DON'T call _update_envelope_list_view()

Expected Impact: 80-90% faster navigation, no UI flicker

1.4 Use Background Workers for Content Loading

File: src/mail/widgets/ContentContainer.py

Current: Blocking content fetch Goal: Use @work decorator for non-blocking loads

Changes:

class ContentContainer(ScrollableContainer):
    @work(exclusive=True)
    async def fetch_message_content(self, message_id: int, format_type: str) -> None:
        """Fetch message content in background without blocking UI."""
        content, success = await himalaya_client.get_message_content(
            message_id,
            folder=self.current_folder,
            account=self.current_account
        )

        if success and content:
            self._update_content(content)
        else:
            self.notify("Failed to fetch message content")

Expected Impact: No UI blocking, smooth content transitions


Phase 2: Code Quality & Architecture (Week 2)

2.1 Refactor Message Store for Efficiency

File: src/mail/message_store.py

Current: Linear searches, no caching Goal: Implement indexed lookups, cache counts

Changes:

class MessageStore:
    """Optimized message store with caching."""

    def __init__(self, envelopes: list[dict]):
        self.envelopes = envelopes
        self._index_cache = {}  # O(1) lookup cache
        self._folder_counts = {}  # Cached folder counts
        self._unread_counts = {}  # Cached unread counts

        # Build caches
        self._build_caches()

    def _build_caches(self) -> None:
        """Build lookup caches."""
        for idx, envelope in enumerate(self.envelopes):
            self._index_cache[envelope["id"]] = idx
            folder = envelope.get("folder", "INBOX")
            self._folder_counts[folder] = self._folder_counts.get(folder, 0) + 1
            if not envelope.get("flags", {}).get("seen", False):
                self._unread_counts[folder] = self._unread_counts.get(folder, 0) + 1

    def get_index(self, message_id: int) -> int | None:
        """Get envelope index in O(1)."""
        return self._index_cache.get(message_id)

    def get_folder_count(self, folder: str) -> int:
        """Get folder count in O(1)."""
        return self._folder_counts.get(folder, 0)

    def get_unread_count(self, folder: str) -> int:
        """Get unread count in O(1)."""
        return self._unread_counts.get(folder, 0)

Expected Impact: O(1) lookups instead of O(n), instant count retrieval

2.2 Consolidate Envelope List Item

File: src/mail/widgets/EnvelopeListItem.py

Current: Multiple widgets per item Goal: Use single widget with custom rendering

Changes:

class EnvelopeListItem(CustomListItem):
    """Optimized envelope list item using single widget."""

    def __init__(self, envelope: dict, **kwargs):
        super().__init__(**kwargs)
        self.envelope = envelope

    def render(self) -> RichText:
        """Render as single RichText widget."""
        from rich.text import Text, Text as RichText

        # Build RichText once (more efficient than multiple widgets)
        text = Text.assemble(
            self._from_display,
            "  ",
            self._subject_display,
            "  ",
            self._date_display,
            style="on" if self.envelope.get("flags", {}).get("seen") else "bold"
        )

        return text

Expected Impact: 70% reduction in widget count, faster rendering

2.3 Add Memoization for Expensive Operations

File: src/mail/utils.py

Current: Re-computing values Goal: Cache computed values

Changes:

from functools import lru_cache

@lru_cache(maxsize=128)
def format_sender_name(envelope: dict) -> str:
    """Format sender name with caching."""
    from_name = envelope.get("from", {}).get("name", "")
    from_addr = envelope.get("from", {}).get("addr", "")

    if not from_name:
        return from_addr

    # Truncate if too long
    if len(from_name) > 25:
        return from_name[:22] + "..."

    return from_name

@lru_cache(maxsize=128)
def format_date(date_str: str) -> str:
    """Format date with caching."""
    # Parse and format date string
    # Implementation...
    return formatted_date

Expected Impact: Faster repeated operations, reduced CPU usage

2.4 Add Notification Compression Caching

File: src/mail/notification_compressor.py

Current: Re-compressing on every view Goal: Cache compressed results

Changes:

class NotificationCompressor:
    """Compressor with caching for performance."""

    def __init__(self, mode: str = "summary"):
        self.mode = mode
        self._compression_cache = {}  # Cache compressed content

    def compress(
        self,
        content: str,
        envelope: dict[str, Any]
    ) -> tuple[str, NotificationType | None]:
        """Compress with caching."""
        cache_key = f"{envelope['id']}:{self.mode}"

        # Check cache
        if cache_key in self._compression_cache:
            return self._compression_cache[cache_key]

        # Compress and cache
        compressed, notif_type = self._compress_impl(content, envelope)
        self._compression_cache[cache_key] = (compressed, notif_type)

        return compressed, notif_type

Expected Impact: Instant display for previously viewed notifications


Phase 3: Advanced Optimizations (Week 3-4)

3.1 Implement Virtual Scrolling

File: src/mail/app.py

Current: Render all items in list Goal: Use ListView virtualization

Changes:

def compose(self) -> ComposeResult:
    yield ListView(
        id="envelopes_list",
        initial_index=0,
    )

# ListView automatically virtualizes for performance

Expected Impact: Constant time rendering regardless of list size

3.2 Debounce User Input

File: src/mail/screens/SearchPanel.py

Current: Search on every keystroke Goal: Debounce search with 300ms delay

Changes:

class SearchPanel(Screen):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._search_debounce = None

    def on_input_changed(self, event) -> None:
        """Debounce search input."""
        if self._search_debounce:
            self._search_debounce.stop()

        self._search_debounce = Timer(
            0.3,
            self._perform_search,
            event.value
        ).start()

Expected Impact: 80% reduction in expensive search operations

3.3 Use dataclass for Data Models

File: src/mail/notification_detector.py

Current: Dict-based data access Goal: Use dataclasses for type safety and performance

Changes:

from dataclasses import dataclass, field
from typing import Any

@dataclass
class Envelope:
    """Typed envelope data model."""
    id: int
    subject: str
    from_name: str
    from_addr: str
    date: str
    flags: dict = field(default_factory=dict)
    folder: str = "INBOX"

Expected Impact: Type safety, better IDE support, faster attribute access


Phase 4: Memory & Resource Management (Week 4)

4.1 Implement Widget Pooling

File: src/mail/app.py

Current: Creating new widgets constantly Goal: Reuse widgets to reduce allocations

Changes:

class WidgetPool:
    """Pool for reusing widgets."""

    def __init__(self, widget_class, max_size: int = 50):
        self.widget_class = widget_class
        self.pool = []
        self.max_size = max_size

    def get(self):
        """Get widget from pool or create new."""
        if self.pool:
            return self.pool.pop()
        return self.widget_class()

    def release(self, widget) -> None:
        """Return widget to pool."""
        if len(self.pool) < self.max_size:
            self.pool.append(widget)

Expected Impact: Reduced garbage collection, smoother scrolling

4.2 Implement Content Pagination

File: src/mail/widgets/ContentContainer.py

Current: Load full content Goal: Load content in chunks for large emails

Changes:

class ContentContainer(ScrollableContainer):
    PAGE_SIZE = 500  # Characters per page

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self._pages: list[str] = []
        self._current_page = 0

    def _load_next_page(self) -> None:
        """Load next page of content when scrolling."""
        if self._current_page + 1 < len(self._pages):
            self._current_page += 1
            self.content.update(self._pages[self._current_page])

Expected Impact: Faster initial load, smoother scrolling for large emails

4.3 Clean Up Unused Imports

Files: All Python files in src/mail/

Current: Unused imports, circular dependencies Goal: Remove all unused code

Changes:

  • Run ruff check and fix all unused imports
  • Remove circular dependencies
  • Clean up __all__ exports
  • Optimize import order

Expected Impact: Faster import time, smaller memory footprint


Implementation Order

Week 1: Critical Performance Fixes

  1. Day 1-2: Implement compose() pattern
  2. Day 3-4: Lazy loading for envelopes
  3. Day 5: Optimize message list navigation
  4. Day 6-7: Background workers for content loading
  5. Day 8-10: Testing and benchmarking

Week 2: Code Quality

  1. Day 1-2: Refactor MessageStore with caching
  2. Day 3-4: Consolidate EnvelopeListItem
  3. Day 5: Add memoization utilities
  4. Day 6-7: Notification compression caching
  5. Day 8-10: Code review and cleanup

Week 3: Advanced Optimizations

  1. Day 1-3: Virtual scrolling implementation
  2. Day 4-5: Debounce user input
  3. Day 6-7: Data model refactoring
  4. Day 8-10: Performance testing

Week 4: Memory Management

  1. Day 1-3: Widget pooling
  2. Day 4-5: Content pagination
  3. Day 6-7: Import cleanup
  4. Day 8-10: Final optimization and polish

Success Metrics

Performance Targets

  • Startup Time: < 1 second (currently: 3-5 seconds)
  • Navigation Latency: < 50ms between messages (currently: 200-500ms)
  • List Rendering: < 100ms for 100 items (currently: 500-1000ms)
  • Memory Usage: < 100MB for 1000 emails (currently: 300-500MB)
  • Frame Rate: 60 FPS during navigation (currently: 10-20 FPS)

Code Quality Targets

  • Test Coverage: > 80% (currently: ~10%)
  • Ruff Warnings: 0 critical, < 5 style warnings
  • Import Cleanup: 100% of files cleaned
  • Type Coverage: 100% typed

Testing Strategy

Performance Benchmarking

# benchmark_performance.py
import time
import tracemalloc
from src.mail.app import EmailViewerApp

def benchmark_startup():
    """Benchmark app startup time."""
    tracemalloc.start()

    start = time.time()
    app = EmailViewerApp()
    app.run()

    end = time.time()
    current, peak = tracemalloc.get_traced_memory()

    print(f"Startup Time: {end - start:.3f}s")
    print(f"Memory Usage: {peak / 1024 / 1024:.2f} MB")

def benchmark_navigation():
    """Benchmark message navigation."""
    app = EmailViewerApp()
    # ... measure navigation timing

    timings = []
    for i in range(100):
        start = time.time()
        app.action_next()
        end = time.time()
        timings.append(end - start)

    print(f"Average Navigation Time: {sum(timings) / len(timings) * 1000:.1f}ms")

Integration Tests

  • Test with 100, 1000, and 10000 messages
  • Measure memory usage over time
  • Test with slow network conditions
  • Test on different terminal sizes

References

Textual Documentation

Python Performance Guides

Similar Projects


Notes

  • This plan focuses on the mail app but principles apply to calendar and tasks apps
  • All changes should be backward compatible
  • Run performance benchmarks before and after each phase
  • Document any Textual-specific optimizations discovered during implementation