- 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
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 checkand 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
- Day 1-2: Implement
compose()pattern - Day 3-4: Lazy loading for envelopes
- Day 5: Optimize message list navigation
- Day 6-7: Background workers for content loading
- Day 8-10: Testing and benchmarking
Week 2: Code Quality
- Day 1-2: Refactor MessageStore with caching
- Day 3-4: Consolidate EnvelopeListItem
- Day 5: Add memoization utilities
- Day 6-7: Notification compression caching
- Day 8-10: Code review and cleanup
Week 3: Advanced Optimizations
- Day 1-3: Virtual scrolling implementation
- Day 4-5: Debounce user input
- Day 6-7: Data model refactoring
- Day 8-10: Performance testing
Week 4: Memory Management
- Day 1-3: Widget pooling
- Day 4-5: Content pagination
- Day 6-7: Import cleanup
- 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
- Main Docs: https://textual.textualize.io/
- Widget Guide: https://textual.textualize.io/guide/widgets/
- Best Practices: https://textual.textualize.io/blog/
- Performance Guide: https://textual.textualize.io/blog/2024/12/12/algorithms-for-high-performance-terminal-apps/
Python Performance Guides
- Python Performance Guide: https://www.fyld.pt/blog/python-performance-guide-writing-code-2025
- Optimization Techniques: https://analyticsvidhya.com/blog/2024/01/optimize-python-code-for-high-speed-execution
Similar Projects
- Rich Console Examples: https://github.com/Textualize/rich
- Prompt Toolkit: https://github.com/prompt-toolkit/python-prompt-toolkit
- Urwid: https://github.com/urwid/urwid
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