"""
Database viewer screen modal component.
This module provides the DatabaseScreen modal for browsing corpus SQLite
database contents with pagination and sorting.
"""
from __future__ import annotations
from pathlib import Path
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen, Screen
from textual.widgets import DataTable, Label
from build_tools.corpus_db_viewer import queries
[docs]
class SyllableDetailModal(ModalScreen):
"""Modal showing detailed feature breakdown for a syllable."""
BINDINGS = [
("escape", "close", "Close"),
("enter", "close", "Close"),
]
DEFAULT_CSS = """
SyllableDetailModal {
align: center middle;
}
SyllableDetailModal > Vertical {
width: 50;
height: auto;
max-height: 80%;
background: $surface;
border: solid $primary;
padding: 1 2;
}
SyllableDetailModal .detail-header {
text-style: bold;
color: $accent;
text-align: center;
margin-bottom: 1;
}
SyllableDetailModal .detail-freq {
text-align: center;
margin-bottom: 1;
}
SyllableDetailModal .detail-section {
text-style: bold;
color: $secondary;
margin-top: 1;
}
SyllableDetailModal .detail-feature {
padding-left: 2;
}
SyllableDetailModal .detail-help {
text-align: center;
color: $text-muted;
margin-top: 1;
}
"""
def __init__(self, syllable_data: dict, feature_details: dict, *args, **kwargs):
"""Initialize with syllable data and feature mapping."""
super().__init__(*args, **kwargs)
self.syllable_data = syllable_data
self.feature_details = feature_details
[docs]
def compose(self) -> ComposeResult:
"""Create detail modal layout."""
with Vertical():
yield Label(f'"{self.syllable_data["syllable"]}"', classes="detail-header")
yield Label(f'Frequency: {self.syllable_data["frequency"]:,}', classes="detail-freq")
# Group features by category
current_category = None
for feature_key, (category, feature_name) in self.feature_details.items():
if feature_key in self.syllable_data:
if category != current_category:
yield Label(f"{category}:", classes="detail-section")
current_category = category
value = self.syllable_data[feature_key]
indicator = "●" if value else "○"
yes_no = "Yes" if value else "No"
yield Label(f"{indicator} {feature_name}: {yes_no}", classes="detail-feature")
yield Label("Press Esc or Enter to close", classes="detail-help")
[docs]
def action_close(self) -> None:
"""Close the modal."""
self.app.pop_screen()
[docs]
class DatabaseScreen(Screen):
"""
Modal screen for viewing corpus database contents.
Displays the syllables table from the corpus.db SQLite database
with pagination and sorting by frequency.
Args:
db_path: Path to the corpus.db SQLite database
patch_name: Name of the patch (A or B) for display
Keybindings:
Esc: Close screen and return to main view
j/k: Navigate rows down/up
Enter: Show row details
n/l/Right: Next page
p/h/Left: Previous page
[/]: Cycle sort column (prev/next)
f: Toggle sort order (asc/desc)
Home: Go to first page
End: Go to last page
"""
# Feature names for detail view (maps db column to readable name)
FEATURE_DETAILS = {
"starts_with_vowel": ("Onset", "Starts with vowel"),
"starts_with_cluster": ("Onset", "Starts with cluster"),
"starts_with_heavy_cluster": ("Onset", "Heavy cluster"),
"contains_plosive": ("Body", "Contains plosive"),
"contains_fricative": ("Body", "Contains fricative"),
"contains_liquid": ("Body", "Contains liquid"),
"contains_nasal": ("Body", "Contains nasal"),
"short_vowel": ("Body", "Short vowel"),
"long_vowel": ("Body", "Long vowel"),
"ends_with_vowel": ("Coda", "Ends with vowel"),
"ends_with_nasal": ("Coda", "Ends with nasal"),
"ends_with_stop": ("Coda", "Ends with stop"),
}
# Sortable columns with display names
SORTABLE_COLUMNS = [
("syllable", "Syllable"),
("frequency", "Freq"),
("starts_with_vowel", "V→"),
("starts_with_cluster", "Cl→"),
("contains_plosive", "Pls"),
("contains_fricative", "Frc"),
("contains_liquid", "Liq"),
("contains_nasal", "Nas"),
("ends_with_vowel", "→V"),
("ends_with_nasal", "→N"),
("ends_with_stop", "→St"),
]
BINDINGS = [
("escape", "close_screen", "Close"),
("j", "cursor_down", "Down"),
("k", "cursor_up", "Up"),
("enter", "show_details", "Details"),
("n", "next_page", "Next"),
("l", "next_page", "Next"),
("right", "next_page", "Next"),
("p", "prev_page", "Prev"),
("h", "prev_page", "Prev"),
("left", "prev_page", "Prev"),
("left_square_bracket", "prev_column", "Prev Col"),
("right_square_bracket", "next_column", "Next Col"),
("f", "toggle_sort", "Sort"),
("home", "first_page", "First"),
("end", "last_page", "Last"),
]
DEFAULT_CSS = """
DatabaseScreen {
background: $surface;
padding: 1;
}
DatabaseScreen .db-header {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
DatabaseScreen .db-meta {
color: $text-muted;
margin-bottom: 1;
}
DatabaseScreen .db-status {
dock: bottom;
height: 1;
background: $boost;
padding: 0 1;
}
DatabaseScreen .db-help {
dock: bottom;
height: 1;
color: $text-muted;
padding: 0 1;
}
DatabaseScreen DataTable {
height: 1fr;
}
"""
def __init__(
self,
db_path: Path | None = None,
patch_name: str = "",
*args,
**kwargs,
):
"""
Initialize database screen.
Args:
db_path: Path to corpus.db file
patch_name: Patch identifier for display (A or B)
"""
super().__init__(*args, **kwargs)
self.db_path = db_path
self.patch_name = patch_name
self.current_page = 1
self.total_pages = 1
self.total_rows = 0
self.page_size = 50
self.sort_column_index = 1 # Default to frequency (index 1)
self.sort_by = self.SORTABLE_COLUMNS[self.sort_column_index][0]
self.sort_direction: str = "DESC"
self.metadata: dict[str, str] = {}
self.current_rows: list[dict] = [] # Store current page data for detail view
[docs]
def compose(self) -> ComposeResult:
"""Create database screen layout."""
title = (
f"CORPUS DATABASE - PATCH {self.patch_name}" if self.patch_name else "CORPUS DATABASE"
)
yield Label(title, classes="db-header")
yield Label("", id="db-meta", classes="db-meta")
yield DataTable(id="db-table")
yield Label("", id="db-status", classes="db-status")
yield Label(
r"j/k:Row Enter:Details \[/]:Col f:Order h/l:Page Esc:Close",
classes="db-help",
)
[docs]
def on_mount(self) -> None:
"""Load data when screen is mounted."""
table = self.query_one("#db-table", DataTable)
table.cursor_type = "row"
table.zebra_stripes = True
if self.db_path and self.db_path.exists():
self._load_metadata()
self._setup_columns()
self._load_data()
else:
self._show_no_database()
def _load_metadata(self) -> None:
"""Load and display database metadata."""
if not self.db_path:
return
try:
# Get metadata from database
import sqlite3
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT key, value FROM metadata")
self.metadata = dict(cursor.fetchall())
conn.close()
# Get total syllable count
self.total_rows = queries.get_row_count(self.db_path, "syllables")
self.total_pages = max(1, (self.total_rows + self.page_size - 1) // self.page_size)
# Update meta display
meta_label = self.query_one("#db-meta", Label)
source_tool = self.metadata.get("source_tool", "unknown")
generated_at = self.metadata.get("generated_at", "unknown")[:19] # Trim to datetime
meta_label.update(
f"Source: {source_tool} | Generated: {generated_at} | "
f"Syllables: {self.total_rows:,}"
)
except Exception as e:
meta_label = self.query_one("#db-meta", Label)
meta_label.update(f"Error loading metadata: {e}")
def _setup_columns(self) -> None:
"""Set up DataTable columns with sort indicator on active column."""
table = self.query_one("#db-table", DataTable)
table.clear(columns=True)
# Add columns with sort indicator on the active sort column
sort_indicator = "↓" if self.sort_direction == "DESC" else "↑"
for i, (col_key, col_name) in enumerate(self.SORTABLE_COLUMNS):
# Add sort indicator to the active column
if i == self.sort_column_index:
label = f"{col_name}{sort_indicator}"
else:
label = col_name
# First two columns are wider
width = 12 if col_key == "syllable" else (8 if col_key == "frequency" else 4)
table.add_column(label, key=col_key, width=width)
def _load_data(self) -> None:
"""Load current page of data into the table."""
if not self.db_path:
return
table = self.query_one("#db-table", DataTable)
table.clear()
try:
data = queries.get_table_data(
self.db_path,
"syllables",
page=self.current_page,
limit=self.page_size,
sort_by=self.sort_by,
sort_order=self.sort_direction,
)
# Store rows for detail view lookup
self.current_rows = data["rows"]
for row in data["rows"]:
# Convert binary features to visual indicators
table.add_row(
row["syllable"],
str(row["frequency"]),
"●" if row["starts_with_vowel"] else "○",
"●" if row["starts_with_cluster"] else "○",
"●" if row["contains_plosive"] else "○",
"●" if row["contains_fricative"] else "○",
"●" if row["contains_liquid"] else "○",
"●" if row["contains_nasal"] else "○",
"●" if row["ends_with_vowel"] else "○",
"●" if row["ends_with_nasal"] else "○",
"●" if row["ends_with_stop"] else "○",
)
self._update_status()
except Exception as e:
status = self.query_one("#db-status", Label)
status.update(f"Error: {e}")
def _update_status(self) -> None:
"""Update the status bar with pagination info."""
status = self.query_one("#db-status", Label)
sort_indicator = "↓" if self.sort_direction == "DESC" else "↑"
col_name = self.SORTABLE_COLUMNS[self.sort_column_index][1]
# Escape brackets to avoid Textual markup interpretation
status.update(
f"Page {self.current_page}/{self.total_pages} | "
f"Sort: {col_name} {sort_indicator} | "
r"\[]/f:change"
)
def _show_no_database(self) -> None:
"""Display message when no database is available."""
meta_label = self.query_one("#db-meta", Label)
meta_label.update("No corpus database found. Select a corpus directory first.")
status = self.query_one("#db-status", Label)
status.update("Press Esc to close")
[docs]
def action_close_screen(self) -> None:
"""Close this screen and return to main view."""
self.app.pop_screen()
[docs]
def action_cursor_down(self) -> None:
"""Move cursor down one row."""
table = self.query_one("#db-table", DataTable)
table.action_cursor_down()
[docs]
def action_cursor_up(self) -> None:
"""Move cursor up one row."""
table = self.query_one("#db-table", DataTable)
table.action_cursor_up()
[docs]
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection (Enter key) to show details."""
# Get row index from the event
row_index = event.cursor_row
if 0 <= row_index < len(self.current_rows):
row_data = self.current_rows[row_index]
self.app.push_screen(SyllableDetailModal(row_data, self.FEATURE_DETAILS))
[docs]
def action_show_details(self) -> None:
"""Show detailed feature breakdown for the selected row (fallback)."""
table = self.query_one("#db-table", DataTable)
# Get current cursor row
row_index = table.cursor_row
if row_index is not None and 0 <= row_index < len(self.current_rows):
row_data = self.current_rows[row_index]
self.app.push_screen(SyllableDetailModal(row_data, self.FEATURE_DETAILS))
[docs]
def action_next_page(self) -> None:
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_data()
[docs]
def action_prev_page(self) -> None:
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_data()
[docs]
def action_first_page(self) -> None:
"""Go to first page."""
if self.current_page != 1:
self.current_page = 1
self._load_data()
[docs]
def action_last_page(self) -> None:
"""Go to last page."""
if self.current_page != self.total_pages:
self.current_page = self.total_pages
self._load_data()
[docs]
def action_prev_column(self) -> None:
"""Cycle to previous sortable column."""
self.sort_column_index = (self.sort_column_index - 1) % len(self.SORTABLE_COLUMNS)
self.sort_by = self.SORTABLE_COLUMNS[self.sort_column_index][0]
self.current_page = 1 # Reset to first page when column changes
self._setup_columns() # Rebuild columns to update sort indicator
self._load_data()
[docs]
def action_next_column(self) -> None:
"""Cycle to next sortable column."""
self.sort_column_index = (self.sort_column_index + 1) % len(self.SORTABLE_COLUMNS)
self.sort_by = self.SORTABLE_COLUMNS[self.sort_column_index][0]
self.current_page = 1 # Reset to first page when column changes
self._setup_columns() # Rebuild columns to update sort indicator
self._load_data()
[docs]
def action_toggle_sort(self) -> None:
"""Toggle sort order (ascending/descending)."""
if self.sort_direction == "DESC":
self.sort_direction = "ASC"
else:
self.sort_direction = "DESC"
self.current_page = 1 # Reset to first page when sorting changes
self._setup_columns() # Rebuild columns to update sort indicator
self._load_data()