Files
StreamLens/textual_inspector.py

187 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""
Textual DOM Inspector - Analyze and debug Textual app widget trees
"""
import json
import sys
from pathlib import Path
from typing import Dict, Any, List
def inspect_textual_app(app_instance) -> Dict[str, Any]:
"""
Inspect a running Textual app and return detailed DOM information
"""
def widget_to_dict(widget) -> Dict[str, Any]:
"""Convert a widget to a dictionary representation"""
widget_info = {
'type': widget.__class__.__name__,
'id': getattr(widget, 'id', None),
'classes': list(getattr(widget, 'classes', [])),
'styles': {},
'size': {
'width': getattr(getattr(widget, 'size', None), 'width', 0) if hasattr(widget, 'size') else 0,
'height': getattr(getattr(widget, 'size', None), 'height', 0) if hasattr(widget, 'size') else 0
},
'position': {
'x': getattr(getattr(widget, 'offset', None), 'x', 0) if hasattr(widget, 'offset') else 0,
'y': getattr(getattr(widget, 'offset', None), 'y', 0) if hasattr(widget, 'offset') else 0
},
'visible': getattr(widget, 'visible', True),
'has_focus': getattr(widget, 'has_focus', False),
'children': []
}
# Extract key styles
if hasattr(widget, 'styles'):
styles = widget.styles
widget_info['styles'] = {
'display': str(getattr(styles, 'display', 'block')),
'height': str(getattr(styles, 'height', 'auto')),
'width': str(getattr(styles, 'width', 'auto')),
'margin': str(getattr(styles, 'margin', '0')),
'padding': str(getattr(styles, 'padding', '0')),
'background': str(getattr(styles, 'background', 'transparent')),
}
# Add widget-specific properties
if hasattr(widget, 'label'):
widget_info['label'] = str(widget.label)
if hasattr(widget, 'text'):
widget_info['text'] = str(widget.text)[:100] # Truncate long text
if hasattr(widget, 'rows'):
widget_info['row_count'] = len(widget.rows) if widget.rows else 0
if hasattr(widget, 'columns'):
widget_info['column_count'] = len(widget.columns) if widget.columns else 0
# Recursively process children
if hasattr(widget, 'children'):
for child in widget.children:
widget_info['children'].append(widget_to_dict(child))
return widget_info
# Start with the app screen
if hasattr(app_instance, 'screen'):
return {
'app_title': getattr(app_instance, 'title', 'Unknown App'),
'screen_stack_size': len(getattr(app_instance, 'screen_stack', [])),
'current_screen': widget_to_dict(app_instance.screen)
}
else:
return {'error': 'App instance does not have accessible screen'}
def print_widget_tree(widget_data: Dict[str, Any], indent: int = 0) -> None:
"""Print a formatted widget tree"""
prefix = " " * indent
widget_type = widget_data.get('type', 'Unknown')
widget_id = widget_data.get('id', '')
widget_classes = widget_data.get('classes', [])
# Format the line
line = f"{prefix}📦 {widget_type}"
if widget_id:
line += f" #{widget_id}"
if widget_classes:
line += f" .{'.'.join(widget_classes)}"
# Add key properties
if 'label' in widget_data:
line += f" [label: {widget_data['label']}]"
if 'row_count' in widget_data:
line += f" [rows: {widget_data['row_count']}]"
if 'column_count' in widget_data:
line += f" [cols: {widget_data['column_count']}]"
size = widget_data.get('size', {})
if size.get('width') or size.get('height'):
line += f" [{size.get('width', 0)}x{size.get('height', 0)}]"
if not widget_data.get('visible', True):
line += " [HIDDEN]"
if widget_data.get('has_focus', False):
line += " [FOCUSED]"
print(line)
# Print children
for child in widget_data.get('children', []):
print_widget_tree(child, indent + 1)
def analyze_layout_issues(widget_data: Dict[str, Any]) -> List[str]:
"""Analyze potential layout issues"""
issues = []
def check_widget(widget, path=""):
current_path = f"{path}/{widget.get('type', 'Unknown')}"
if widget.get('id'):
current_path += f"#{widget['id']}"
# Check for zero-sized widgets that should have content
size = widget.get('size', {})
if size.get('width') == 0 or size.get('height') == 0:
if widget.get('type') in ['Button', 'DataTable', 'Static'] and 'label' in widget:
issues.append(f"Zero-sized widget with content: {current_path}")
# Check for invisible widgets with focus
if not widget.get('visible', True) and widget.get('has_focus', False):
issues.append(f"Invisible widget has focus: {current_path}")
# Check for overlapping positioning (basic check)
styles = widget.get('styles', {})
if 'absolute' in str(styles.get('position', '')):
# Could add position conflict detection here
pass
# Recursively check children
for child in widget.get('children', []):
check_widget(child, current_path)
check_widget(widget_data.get('current_screen', {}))
return issues
def create_textual_debug_snippet() -> str:
"""Create a code snippet to add to Textual apps for debugging"""
return '''
# Add this to your Textual app for debugging
def debug_widget_tree(self):
"""Debug method to inspect widget tree"""
from textual_inspector import inspect_textual_app, print_widget_tree
data = inspect_textual_app(self)
print("🔍 TEXTUAL APP INSPECTION")
print("=" * 50)
print_widget_tree(data.get('current_screen', {}))
# You can call this from anywhere in your app:
# self.debug_widget_tree()
def debug_focused_widget(self):
"""Debug method to find focused widget"""
focused = self.focused
if focused:
print(f"🎯 Focused widget: {focused.__class__.__name__}")
if hasattr(focused, 'id'):
print(f" ID: {focused.id}")
if hasattr(focused, 'classes'):
print(f" Classes: {list(focused.classes)}")
else:
print("🎯 No widget has focus")
'''
def main():
print("🔍 Textual DOM Inspector")
print("=" * 50)
print("This tool helps debug Textual applications.")
print("\nTo use with your app, add this import:")
print("from textual_inspector import inspect_textual_app, print_widget_tree")
print("\nThen in your app:")
print("data = inspect_textual_app(self)")
print("print_widget_tree(data.get('current_screen', {}))")
print("\n" + "=" * 50)
print("\n📝 Debug snippet:")
print(create_textual_debug_snippet())
if __name__ == "__main__":
main()