305 lines
9.1 KiB
Python
305 lines
9.1 KiB
Python
"""
|
|
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()
|