tabbed frametype filtering
This commit is contained in:
477
textual_state_visualizer.py
Normal file
477
textual_state_visualizer.py
Normal file
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Textual State Visualizer - Real-time state monitoring for Textual apps
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
import webbrowser
|
||||
import http.server
|
||||
import socketserver
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
class TextualStateMonitor:
|
||||
"""Monitor and capture Textual app state changes"""
|
||||
|
||||
def __init__(self, app_instance):
|
||||
self.app = app_instance
|
||||
self.state_history = []
|
||||
self.monitoring = False
|
||||
self.monitor_thread = None
|
||||
|
||||
def capture_state(self) -> Dict[str, Any]:
|
||||
"""Capture current app state"""
|
||||
state = {
|
||||
'timestamp': time.time(),
|
||||
'focused_widget': None,
|
||||
'widgets': {},
|
||||
'screen_info': {},
|
||||
'reactive_values': {}
|
||||
}
|
||||
|
||||
# Capture focused widget
|
||||
if hasattr(self.app, 'focused') and self.app.focused:
|
||||
focused = self.app.focused
|
||||
state['focused_widget'] = {
|
||||
'type': focused.__class__.__name__,
|
||||
'id': getattr(focused, 'id', None),
|
||||
'classes': list(getattr(focused, 'classes', []))
|
||||
}
|
||||
|
||||
# Capture screen info
|
||||
if hasattr(self.app, 'screen'):
|
||||
screen = self.app.screen
|
||||
state['screen_info'] = {
|
||||
'type': screen.__class__.__name__,
|
||||
'id': getattr(screen, 'id', None),
|
||||
'size': {
|
||||
'width': getattr(getattr(screen, 'size', None), 'width', 0) if hasattr(screen, 'size') else 0,
|
||||
'height': getattr(getattr(screen, 'size', None), 'height', 0) if hasattr(screen, 'size') else 0
|
||||
}
|
||||
}
|
||||
|
||||
# Capture widget states
|
||||
self._capture_widget_states(self.app.screen, state['widgets'])
|
||||
|
||||
# Capture reactive values
|
||||
self._capture_reactive_values(self.app, state['reactive_values'])
|
||||
|
||||
return state
|
||||
|
||||
def _capture_widget_states(self, widget, widget_dict: Dict[str, Any], path: str = ""):
|
||||
"""Recursively capture widget states"""
|
||||
widget_id = getattr(widget, 'id', None) or f"{widget.__class__.__name__}_{id(widget)}"
|
||||
full_path = f"{path}/{widget_id}" if path else widget_id
|
||||
|
||||
widget_state = {
|
||||
'type': widget.__class__.__name__,
|
||||
'id': widget_id,
|
||||
'path': full_path,
|
||||
'visible': getattr(widget, 'visible', True),
|
||||
'has_focus': getattr(widget, 'has_focus', False),
|
||||
'classes': list(getattr(widget, 'classes', [])),
|
||||
'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
|
||||
}
|
||||
}
|
||||
|
||||
# Add widget-specific data
|
||||
if hasattr(widget, 'label'):
|
||||
widget_state['label'] = str(widget.label)
|
||||
if hasattr(widget, 'text'):
|
||||
widget_state['text'] = str(widget.text)[:200] # Truncate long text
|
||||
if hasattr(widget, 'value'):
|
||||
widget_state['value'] = str(widget.value)
|
||||
|
||||
widget_dict[full_path] = widget_state
|
||||
|
||||
# Process children
|
||||
if hasattr(widget, 'children'):
|
||||
for child in widget.children:
|
||||
self._capture_widget_states(child, widget_dict, full_path)
|
||||
|
||||
def _capture_reactive_values(self, obj, reactive_dict: Dict[str, Any], path: str = ""):
|
||||
"""Capture reactive attribute values"""
|
||||
if hasattr(obj, '__dict__'):
|
||||
for attr_name, attr_value in obj.__dict__.items():
|
||||
if hasattr(attr_value, '__class__') and 'reactive' in str(attr_value.__class__):
|
||||
key = f"{path}.{attr_name}" if path else attr_name
|
||||
reactive_dict[key] = {
|
||||
'value': str(attr_value),
|
||||
'type': str(type(attr_value))
|
||||
}
|
||||
|
||||
def start_monitoring(self, interval: float = 1.0):
|
||||
"""Start monitoring state changes"""
|
||||
self.monitoring = True
|
||||
self.monitor_thread = threading.Thread(
|
||||
target=self._monitor_loop,
|
||||
args=(interval,),
|
||||
daemon=True
|
||||
)
|
||||
self.monitor_thread.start()
|
||||
|
||||
def stop_monitoring(self):
|
||||
"""Stop monitoring"""
|
||||
self.monitoring = False
|
||||
if self.monitor_thread:
|
||||
self.monitor_thread.join()
|
||||
|
||||
def _monitor_loop(self, interval: float):
|
||||
"""Main monitoring loop"""
|
||||
while self.monitoring:
|
||||
try:
|
||||
state = self.capture_state()
|
||||
self.state_history.append(state)
|
||||
|
||||
# Keep only last 100 states
|
||||
if len(self.state_history) > 100:
|
||||
self.state_history.pop(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error capturing state: {e}")
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
def get_state_changes(self, widget_path: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Get state changes for a specific widget or all widgets"""
|
||||
changes = []
|
||||
|
||||
for i in range(1, len(self.state_history)):
|
||||
prev_state = self.state_history[i-1]
|
||||
curr_state = self.state_history[i]
|
||||
|
||||
# Compare states
|
||||
change = {
|
||||
'timestamp': curr_state['timestamp'],
|
||||
'changes': {}
|
||||
}
|
||||
|
||||
# Compare widgets
|
||||
for widget_id, widget_state in curr_state['widgets'].items():
|
||||
if widget_path and widget_path not in widget_id:
|
||||
continue
|
||||
|
||||
prev_widget = prev_state['widgets'].get(widget_id, {})
|
||||
|
||||
widget_changes = {}
|
||||
for key, value in widget_state.items():
|
||||
if key not in prev_widget or prev_widget[key] != value:
|
||||
widget_changes[key] = {
|
||||
'old': prev_widget.get(key),
|
||||
'new': value
|
||||
}
|
||||
|
||||
if widget_changes:
|
||||
change['changes'][widget_id] = widget_changes
|
||||
|
||||
if change['changes']:
|
||||
changes.append(change)
|
||||
|
||||
return changes
|
||||
|
||||
def export_state_history(self, filename: str = "textual_state_history.json"):
|
||||
"""Export state history to JSON file"""
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(self.state_history, f, indent=2, default=str)
|
||||
print(f"📁 State history exported to {filename}")
|
||||
|
||||
class TextualStateWebServer:
|
||||
"""Web server for visualizing Textual app state"""
|
||||
|
||||
def __init__(self, monitor: TextualStateMonitor, port: int = 8080):
|
||||
self.monitor = monitor
|
||||
self.port = port
|
||||
self.httpd = None
|
||||
|
||||
def start(self):
|
||||
"""Start the web server"""
|
||||
handler = self._create_handler()
|
||||
self.httpd = socketserver.TCPServer(("", self.port), handler)
|
||||
|
||||
print(f"🌐 Starting state visualizer at http://localhost:{self.port}")
|
||||
|
||||
# Start server in background thread
|
||||
server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Open browser
|
||||
webbrowser.open(f"http://localhost:{self.port}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the web server"""
|
||||
if self.httpd:
|
||||
self.httpd.shutdown()
|
||||
|
||||
def _create_handler(self):
|
||||
"""Create HTTP request handler"""
|
||||
monitor = self.monitor
|
||||
|
||||
class StateHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/':
|
||||
self.send_html_dashboard()
|
||||
elif self.path == '/api/state':
|
||||
self.send_current_state()
|
||||
elif self.path == '/api/history':
|
||||
self.send_state_history()
|
||||
elif self.path.startswith('/api/changes'):
|
||||
self.send_state_changes()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def send_html_dashboard(self):
|
||||
"""Send HTML dashboard"""
|
||||
html = self._generate_dashboard_html()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html')
|
||||
self.end_headers()
|
||||
self.wfile.write(html.encode())
|
||||
|
||||
def send_current_state(self):
|
||||
"""Send current app state as JSON"""
|
||||
state = monitor.capture_state()
|
||||
self.send_json_response(state)
|
||||
|
||||
def send_state_history(self):
|
||||
"""Send state history as JSON"""
|
||||
self.send_json_response(monitor.state_history)
|
||||
|
||||
def send_state_changes(self):
|
||||
"""Send state changes as JSON"""
|
||||
query = parse_qs(urlparse(self.path).query)
|
||||
widget_path = query.get('widget', [None])[0]
|
||||
changes = monitor.get_state_changes(widget_path)
|
||||
self.send_json_response(changes)
|
||||
|
||||
def send_json_response(self, data):
|
||||
"""Send JSON response"""
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.end_headers()
|
||||
json_data = json.dumps(data, indent=2, default=str)
|
||||
self.wfile.write(json_data.encode())
|
||||
|
||||
def _generate_dashboard_html(self):
|
||||
"""Generate HTML dashboard"""
|
||||
return '''
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Textual State Visualizer</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.container { max-width: 1200px; margin: 0 auto; }
|
||||
.section { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 5px; }
|
||||
.widget-tree { font-family: monospace; }
|
||||
.widget-item { margin: 2px 0; padding: 2px; }
|
||||
.widget-focused { background-color: #ffffcc; }
|
||||
.widget-hidden { opacity: 0.5; }
|
||||
.changes { background-color: #f0f8ff; }
|
||||
.timestamp { color: #666; font-size: 0.9em; }
|
||||
button { margin: 5px; padding: 8px 16px; }
|
||||
#auto-refresh { color: green; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔍 Textual State Visualizer</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Controls</h2>
|
||||
<button onclick="refreshState()">Refresh State</button>
|
||||
<button onclick="toggleAutoRefresh()" id="auto-refresh-btn">Start Auto-refresh</button>
|
||||
<button onclick="exportHistory()">Export History</button>
|
||||
<span id="auto-refresh" style="display: none;">Auto-refreshing every 2s...</span>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Current State</h2>
|
||||
<div id="current-state">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Widget Tree</h2>
|
||||
<div id="widget-tree" class="widget-tree">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Changes</h2>
|
||||
<div id="recent-changes">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let autoRefreshInterval = null;
|
||||
|
||||
function refreshState() {
|
||||
fetch('/api/state')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
updateCurrentState(data);
|
||||
updateWidgetTree(data);
|
||||
});
|
||||
|
||||
fetch('/api/changes')
|
||||
.then(response => response.json())
|
||||
.then(data => updateRecentChanges(data));
|
||||
}
|
||||
|
||||
function updateCurrentState(state) {
|
||||
const div = document.getElementById('current-state');
|
||||
div.innerHTML = `
|
||||
<p><strong>Timestamp:</strong> ${new Date(state.timestamp * 1000).toLocaleString()}</p>
|
||||
<p><strong>Focused Widget:</strong> ${state.focused_widget ?
|
||||
`${state.focused_widget.type}#${state.focused_widget.id}` : 'None'}</p>
|
||||
<p><strong>Screen:</strong> ${state.screen_info.type} (${state.screen_info.size.width}x${state.screen_info.size.height})</p>
|
||||
<p><strong>Total Widgets:</strong> ${Object.keys(state.widgets).length}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateWidgetTree(state) {
|
||||
const div = document.getElementById('widget-tree');
|
||||
let html = '';
|
||||
|
||||
for (const [path, widget] of Object.entries(state.widgets)) {
|
||||
const indent = ' '.repeat((path.match(/\\//g) || []).length);
|
||||
const classes = widget.has_focus ? 'widget-focused' : (widget.visible ? '' : 'widget-hidden');
|
||||
|
||||
html += `<div class="widget-item ${classes}">`;
|
||||
html += `${indent}📦 ${widget.type}`;
|
||||
if (widget.id) html += ` #${widget.id}`;
|
||||
if (widget.label) html += ` [${widget.label}]`;
|
||||
html += ` (${widget.size.width}x${widget.size.height})`;
|
||||
if (widget.has_focus) html += ' 🎯';
|
||||
if (!widget.visible) html += ' 👻';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
div.innerHTML = html;
|
||||
}
|
||||
|
||||
function updateRecentChanges(changes) {
|
||||
const div = document.getElementById('recent-changes');
|
||||
let html = '';
|
||||
|
||||
changes.slice(-10).forEach(change => {
|
||||
html += `<div class="changes">`;
|
||||
html += `<div class="timestamp">${new Date(change.timestamp * 1000).toLocaleString()}</div>`;
|
||||
|
||||
for (const [widget, widgetChanges] of Object.entries(change.changes)) {
|
||||
html += `<strong>${widget}:</strong><br/>`;
|
||||
for (const [prop, change] of Object.entries(widgetChanges)) {
|
||||
html += ` ${prop}: ${change.old} → ${change.new}<br/>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
div.innerHTML = html || 'No recent changes';
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
const btn = document.getElementById('auto-refresh-btn');
|
||||
const indicator = document.getElementById('auto-refresh');
|
||||
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
btn.textContent = 'Start Auto-refresh';
|
||||
indicator.style.display = 'none';
|
||||
} else {
|
||||
autoRefreshInterval = setInterval(refreshState, 2000);
|
||||
btn.textContent = 'Stop Auto-refresh';
|
||||
indicator.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
function exportHistory() {
|
||||
window.open('/api/history', '_blank');
|
||||
}
|
||||
|
||||
// Initial load
|
||||
refreshState();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
|
||||
return StateHandler
|
||||
|
||||
def create_textual_debug_setup() -> str:
|
||||
"""Create a setup snippet for easy debugging"""
|
||||
return '''
|
||||
# Add this to your Textual app for easy state monitoring
|
||||
|
||||
from textual_state_visualizer import TextualStateMonitor, TextualStateWebServer
|
||||
|
||||
class DebugMixin:
|
||||
"""Mixin to add debugging capabilities to Textual apps"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._debug_monitor = None
|
||||
self._debug_server = None
|
||||
|
||||
def start_debug_monitoring(self, web_interface: bool = True, port: int = 8080):
|
||||
"""Start debug monitoring with optional web interface"""
|
||||
self._debug_monitor = TextualStateMonitor(self)
|
||||
self._debug_monitor.start_monitoring()
|
||||
|
||||
if web_interface:
|
||||
self._debug_server = TextualStateWebServer(self._debug_monitor, port)
|
||||
self._debug_server.start()
|
||||
|
||||
print(f"🔍 Debug monitoring started!")
|
||||
if web_interface:
|
||||
print(f"🌐 Web interface: http://localhost:{port}")
|
||||
|
||||
def stop_debug_monitoring(self):
|
||||
"""Stop debug monitoring"""
|
||||
if self._debug_monitor:
|
||||
self._debug_monitor.stop_monitoring()
|
||||
if self._debug_server:
|
||||
self._debug_server.stop()
|
||||
|
||||
def debug_current_state(self):
|
||||
"""Print current state to console"""
|
||||
if self._debug_monitor:
|
||||
state = self._debug_monitor.capture_state()
|
||||
print("🔍 Current Textual App State:")
|
||||
print(f" Focused: {state.get('focused_widget', 'None')}")
|
||||
print(f" Widgets: {len(state.get('widgets', {}))}")
|
||||
for path, widget in state.get('widgets', {}).items():
|
||||
status = []
|
||||
if widget.get('has_focus'): status.append('FOCUSED')
|
||||
if not widget.get('visible'): status.append('HIDDEN')
|
||||
status_str = f" [{', '.join(status)}]" if status else ""
|
||||
print(f" {path}: {widget['type']}{status_str}")
|
||||
|
||||
# Usage in your app:
|
||||
# class MyApp(App, DebugMixin):
|
||||
# def on_mount(self):
|
||||
# self.start_debug_monitoring()
|
||||
'''
|
||||
|
||||
def main():
|
||||
print("🔍 Textual State Visualizer")
|
||||
print("=" * 50)
|
||||
print("This tool provides real-time monitoring of Textual app state.")
|
||||
print("\nFeatures:")
|
||||
print(" 📊 Real-time widget tree visualization")
|
||||
print(" 🔄 State change tracking")
|
||||
print(" 🌐 Web-based dashboard")
|
||||
print(" 📁 State history export")
|
||||
print(" 🎯 Focus tracking")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("📝 Setup code for your app:")
|
||||
print(create_textual_debug_setup())
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user