Initial commit
This commit is contained in:
304
skill/assets/data_viewer.py
Normal file
304
skill/assets/data_viewer.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Data Viewer Application - Textual TUI Example
|
||||
|
||||
Demonstrates:
|
||||
- File browser and selection
|
||||
- DataTable for displaying tabular data
|
||||
- Tree for displaying hierarchical data
|
||||
- Modal screens for dialogs
|
||||
- File loading and parsing
|
||||
- Search and filter functionality
|
||||
"""
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Container, Horizontal, Vertical
|
||||
from textual.widgets import (
|
||||
Header, Footer, DirectoryTree, DataTable, Tree, Static,
|
||||
Button, Input, TabbedContent, TabPane
|
||||
)
|
||||
from textual.screen import ModalScreen
|
||||
from textual.binding import Binding
|
||||
import json
|
||||
import csv
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ErrorDialog(ModalScreen[bool]):
|
||||
"""Modal dialog for displaying errors."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ErrorDialog {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#error-dialog {
|
||||
width: 60;
|
||||
height: 11;
|
||||
border: thick $error;
|
||||
background: $surface;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#error-title {
|
||||
text-style: bold;
|
||||
color: $error;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#error-message {
|
||||
margin-bottom: 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, title: str, message: str) -> None:
|
||||
super().__init__()
|
||||
self.title = title
|
||||
self.message = message
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="error-dialog"):
|
||||
yield Static(self.title, id="error-title")
|
||||
yield Static(self.message, id="error-message")
|
||||
yield Button("OK", variant="error", id="ok")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
self.dismiss(True)
|
||||
|
||||
|
||||
class DataViewer(App):
|
||||
"""A data viewer for JSON and CSV files."""
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
background: $background;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
Header {
|
||||
background: $primary;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
width: 35;
|
||||
border-right: solid $accent;
|
||||
background: $panel;
|
||||
}
|
||||
|
||||
#file-tree {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
#main-content {
|
||||
width: 1fr;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#toolbar {
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#search-input {
|
||||
width: 1fr;
|
||||
margin-right: 1;
|
||||
}
|
||||
|
||||
#content-tabs {
|
||||
height: 1fr;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
Tree {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#info-panel {
|
||||
border: solid $primary;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+o", "open_file", "Open File"),
|
||||
Binding("ctrl+f", "focus_search", "Search"),
|
||||
("q", "quit", "Quit"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the UI."""
|
||||
yield Header(show_clock=True)
|
||||
|
||||
with Container(id="sidebar"):
|
||||
yield Static("📁 File Browser", id="sidebar-title")
|
||||
yield DirectoryTree(str(Path.home()), id="file-tree")
|
||||
|
||||
with Vertical(id="main-content"):
|
||||
with Horizontal(id="toolbar"):
|
||||
yield Input(placeholder="Search...", id="search-input")
|
||||
yield Button("Clear", id="clear-button")
|
||||
|
||||
with TabbedContent(id="content-tabs"):
|
||||
with TabPane("Table View", id="table-tab"):
|
||||
yield DataTable(id="data-table", cursor_type="row")
|
||||
|
||||
with TabPane("Tree View", id="tree-tab"):
|
||||
yield Tree("Data", id="data-tree")
|
||||
|
||||
with TabPane("Info", id="info-tab"):
|
||||
yield Static("No file loaded", id="info-panel")
|
||||
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the app."""
|
||||
table = self.query_one("#data-table", DataTable)
|
||||
table.show_header = True
|
||||
table.zebra_stripes = True
|
||||
|
||||
def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None:
|
||||
"""Handle file selection from the tree."""
|
||||
file_path = event.path
|
||||
if file_path.suffix.lower() in ['.json', '.csv']:
|
||||
self.load_file(file_path)
|
||||
else:
|
||||
self.notify("Only JSON and CSV files are supported", severity="warning")
|
||||
|
||||
def load_file(self, file_path: Path) -> None:
|
||||
"""Load and display a data file."""
|
||||
try:
|
||||
if file_path.suffix.lower() == '.json':
|
||||
self.load_json(file_path)
|
||||
elif file_path.suffix.lower() == '.csv':
|
||||
self.load_csv(file_path)
|
||||
|
||||
self.notify(f"Loaded: {file_path.name}", severity="information")
|
||||
|
||||
except Exception as e:
|
||||
self.push_screen(
|
||||
ErrorDialog("Error Loading File", f"Failed to load {file_path.name}:\n{str(e)}")
|
||||
)
|
||||
|
||||
def load_json(self, file_path: Path) -> None:
|
||||
"""Load a JSON file."""
|
||||
with open(file_path, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Update info panel
|
||||
info = self.query_one("#info-panel", Static)
|
||||
info.update(f"File: {file_path.name}\nType: JSON\nSize: {file_path.stat().st_size} bytes")
|
||||
|
||||
# Update tree view
|
||||
tree = self.query_one("#data-tree", Tree)
|
||||
tree.clear()
|
||||
tree.root.label = file_path.name
|
||||
self._build_json_tree(tree.root, data)
|
||||
|
||||
# Update table view (if data is a list of dicts)
|
||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
||||
self._populate_table_from_list(data)
|
||||
else:
|
||||
table = self.query_one("#data-table", DataTable)
|
||||
table.clear(columns=True)
|
||||
self.notify("JSON structure not suitable for table view", severity="warning")
|
||||
|
||||
def load_csv(self, file_path: Path) -> None:
|
||||
"""Load a CSV file."""
|
||||
with open(file_path, 'r') as f:
|
||||
reader = csv.DictReader(f)
|
||||
data = list(reader)
|
||||
|
||||
# Update info panel
|
||||
info = self.query_one("#info-panel", Static)
|
||||
info.update(
|
||||
f"File: {file_path.name}\n"
|
||||
f"Type: CSV\n"
|
||||
f"Rows: {len(data)}\n"
|
||||
f"Columns: {len(data[0]) if data else 0}"
|
||||
)
|
||||
|
||||
# Update table view
|
||||
self._populate_table_from_list(data)
|
||||
|
||||
# Update tree view
|
||||
tree = self.query_one("#data-tree", Tree)
|
||||
tree.clear()
|
||||
tree.root.label = file_path.name
|
||||
for i, row in enumerate(data[:100]): # Limit to 100 for performance
|
||||
row_node = tree.root.add(f"Row {i+1}")
|
||||
for key, value in row.items():
|
||||
row_node.add_leaf(f"{key}: {value}")
|
||||
|
||||
def _populate_table_from_list(self, data: list) -> None:
|
||||
"""Populate the DataTable from a list of dicts."""
|
||||
if not data:
|
||||
return
|
||||
|
||||
table = self.query_one("#data-table", DataTable)
|
||||
table.clear(columns=True)
|
||||
|
||||
# Add columns
|
||||
columns = list(data[0].keys())
|
||||
table.add_columns(*columns)
|
||||
|
||||
# Add rows
|
||||
for row in data:
|
||||
table.add_row(*[str(row.get(col, "")) for col in columns])
|
||||
|
||||
def _build_json_tree(self, node, data, max_depth: int = 10, depth: int = 0) -> None:
|
||||
"""Recursively build a tree from JSON data."""
|
||||
if depth >= max_depth:
|
||||
node.add_leaf("... (max depth reached)")
|
||||
return
|
||||
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if isinstance(value, (dict, list)):
|
||||
child = node.add(f"📂 {key}")
|
||||
self._build_json_tree(child, value, max_depth, depth + 1)
|
||||
else:
|
||||
node.add_leaf(f"{key}: {value}")
|
||||
|
||||
elif isinstance(data, list):
|
||||
for i, item in enumerate(data[:50]): # Limit to 50 items for performance
|
||||
if isinstance(item, (dict, list)):
|
||||
child = node.add(f"[{i}]")
|
||||
self._build_json_tree(child, item, max_depth, depth + 1)
|
||||
else:
|
||||
node.add_leaf(f"[{i}]: {item}")
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""Handle search input changes."""
|
||||
if event.input.id == "search-input":
|
||||
search_term = event.value.lower()
|
||||
if search_term:
|
||||
self.filter_table(search_term)
|
||||
|
||||
def filter_table(self, search_term: str) -> None:
|
||||
"""Filter table rows based on search term."""
|
||||
table = self.query_one("#data-table", DataTable)
|
||||
# Note: This is a simple example. Real implementation would need
|
||||
# to store original data and rebuild filtered rows
|
||||
pass
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle button presses."""
|
||||
if event.button.id == "clear-button":
|
||||
search_input = self.query_one("#search-input", Input)
|
||||
search_input.value = ""
|
||||
|
||||
def action_focus_search(self) -> None:
|
||||
"""Focus the search input."""
|
||||
self.query_one("#search-input", Input).focus()
|
||||
|
||||
def action_open_file(self) -> None:
|
||||
"""Focus the file tree."""
|
||||
self.query_one("#file-tree", DirectoryTree).focus()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = DataViewer()
|
||||
app.run()
|
||||
Reference in New Issue
Block a user