Source code for build_tools.pipeline_tui.screens.configure
"""
Configure tab panel for Pipeline TUI.
This module provides the ConfigurePanel widget which displays all configuration
options for the syllable extraction pipeline. It allows users to:
- Select source and output directories
- Choose extractor type (pyphen or NLTK)
- Configure language (for pyphen)
- Set syllable length constraints
- Toggle pipeline stages (normalize, annotate)
**Design Principles:**
- Uses shared tui_common controls for consistent UX
- Posts messages for state changes (handled by parent app)
- Clear visual grouping of related options
- Keyboard-navigable (Tab between controls, Enter/Space to activate)
**Message Flow:**
The panel posts custom messages when configuration changes. The parent app
handles these messages and updates the central PipelineState.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from textual import on
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.widgets import Button, Input, Label
from build_tools.pipeline_tui.core.state import ExtractorType
from build_tools.tui_common.controls import IntSpinner, RadioOption
if TYPE_CHECKING:
from textual.app import ComposeResult
# =============================================================================
# Common Languages for Quick Selection
# =============================================================================
# Most commonly used languages for the language selector
# "auto" is the default - uses langdetect for automatic language detection
# Full list available in pyphen_syllable_extractor.languages
COMMON_LANGUAGES = [
("auto", "Auto-detect"),
("en_US", "English (US)"),
("en_GB", "English (UK)"),
("de_DE", "German"),
("fr", "French"),
("es", "Spanish"),
("it_IT", "Italian"),
("pt_BR", "Portuguese (Brazil)"),
("nl_NL", "Dutch"),
]
# =============================================================================
# ConfigurePanel Widget
# =============================================================================
[docs]
class ConfigurePanel(VerticalScroll):
"""
Configuration panel for pipeline settings.
This widget contains all the configuration controls for setting up
a syllable extraction pipeline run. It is designed to be composed
within a TabPane in the main application.
**Layout Structure:**
.. code-block:: text
┌─────────────────────────────────────────────────────────┐
│ DIRECTORIES │
│ Source: /path/to/source [Browse] │
│ Output: /path/to/output [Browse] │
├─────────────────────────────────────────────────────────┤
│ EXTRACTOR │
│ [x] Pyphen: Multi-language typographic hyphenation │
│ [ ] NLTK: English-only phonetic splitting │
├─────────────────────────────────────────────────────────┤
│ LANGUAGE (Pyphen only) │
│ [x] English (US) [ ] German [ ] French [ ] Other │
│ Custom: [________] │
├─────────────────────────────────────────────────────────┤
│ CONSTRAINTS │
│ Min Length: [2] │
│ Max Length: [8] │
│ File Pattern: [*.txt] │
├─────────────────────────────────────────────────────────┤
│ PIPELINE STAGES │
│ [x] Run Normalization: Clean and deduplicate syllables │
│ [x] Run Annotation: Add phonetic features │
└─────────────────────────────────────────────────────────┘
Attributes:
source_path: Currently selected source directory
output_dir: Currently selected output directory
extractor_type: Selected extractor (PYPHEN or NLTK)
language: Language code for pyphen (e.g., "en_US")
min_syllable_length: Minimum syllable length filter
max_syllable_length: Maximum syllable length filter
file_pattern: Glob pattern for input files
run_normalize: Whether to run normalization step
run_annotate: Whether to run annotation step
Messages:
- :class:`SourceSelected`: Posted when source directory changes
- :class:`OutputSelected`: Posted when output directory changes
- :class:`ExtractorChanged`: Posted when extractor type changes
- :class:`LanguageChanged`: Posted when language changes
- :class:`ConstraintsChanged`: Posted when length constraints change
- :class:`PipelineStagesChanged`: Posted when stage toggles change
"""
# -------------------------------------------------------------------------
# Custom Messages for Configuration Changes
# -------------------------------------------------------------------------
[docs]
class SourceSelected(Message):
"""Posted when the source directory is selected via browse button."""
def __init__(self) -> None:
"""Initialize the SourceSelected message."""
super().__init__()
[docs]
class FilesSelected(Message):
"""Posted when the user wants to select specific files."""
def __init__(self) -> None:
"""Initialize the FilesSelected message."""
super().__init__()
[docs]
class OutputSelected(Message):
"""Posted when the output directory is selected via browse button."""
def __init__(self) -> None:
"""Initialize the OutputSelected message."""
super().__init__()
[docs]
class ExtractorChanged(Message):
"""
Posted when the extractor type changes.
Attributes:
extractor_type: The newly selected extractor type
"""
def __init__(self, extractor_type: ExtractorType) -> None:
"""
Initialize the ExtractorChanged message.
Args:
extractor_type: The newly selected extractor type
"""
super().__init__()
self.extractor_type = extractor_type
[docs]
class LanguageChanged(Message):
"""
Posted when the language selection changes.
Attributes:
language: The newly selected language code (e.g., "en_US")
"""
def __init__(self, language: str) -> None:
"""
Initialize the LanguageChanged message.
Args:
language: The language code
"""
super().__init__()
self.language = language
[docs]
class ConstraintsChanged(Message):
"""
Posted when syllable length constraints change.
Attributes:
min_length: The new minimum syllable length
max_length: The new maximum syllable length
file_pattern: The file glob pattern
"""
def __init__(self, min_length: int, max_length: int, file_pattern: str) -> None:
"""
Initialize the ConstraintsChanged message.
Args:
min_length: Minimum syllable length
max_length: Maximum syllable length
file_pattern: File glob pattern
"""
super().__init__()
self.min_length = min_length
self.max_length = max_length
self.file_pattern = file_pattern
[docs]
class PipelineStagesChanged(Message):
"""
Posted when pipeline stage toggles change.
Attributes:
run_normalize: Whether to run normalization
run_annotate: Whether to run annotation
"""
def __init__(self, run_normalize: bool, run_annotate: bool) -> None:
"""
Initialize the PipelineStagesChanged message.
Args:
run_normalize: Whether normalization is enabled
run_annotate: Whether annotation is enabled
"""
super().__init__()
self.run_normalize = run_normalize
self.run_annotate = run_annotate
# -------------------------------------------------------------------------
# Default CSS Styling
# -------------------------------------------------------------------------
DEFAULT_CSS = """
ConfigurePanel {
width: 1fr;
height: 1fr;
padding: 1 2;
}
/* Section containers */
.config-section {
width: 100%;
height: auto;
margin-bottom: 1;
padding: 0 1;
border: solid $primary-darken-2;
}
.section-header {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
/* Directory selection row */
.dir-row {
height: 3;
width: 100%;
margin-bottom: 0;
}
.dir-label {
width: 10;
height: 3;
content-align: center middle;
text-align: right;
padding-right: 1;
}
.dir-path {
width: 1fr;
height: 3;
background: $boost;
padding: 1;
content-align: left middle;
color: $text;
}
.dir-path-empty {
color: $text-muted;
text-style: italic;
}
.dir-button {
width: 12;
height: 3;
margin-left: 1;
}
/* Extractor options */
.extractor-option {
height: auto;
padding: 0 1;
}
/* Language selector */
.language-row {
height: auto;
width: 100%;
}
.language-grid {
width: 100%;
height: auto;
}
.language-option {
width: auto;
margin-right: 2;
}
.custom-language-row {
height: 3;
margin-top: 1;
}
.custom-language-label {
width: 10;
height: 3;
content-align: center middle;
text-align: right;
padding-right: 1;
}
.custom-language-input {
width: 15;
height: 3;
}
/* Language section disabled state */
.language-section-disabled {
opacity: 0.5;
}
.language-section-disabled .language-option {
color: $text-muted;
}
/* Constraints section */
.constraints-row {
height: auto;
width: 100%;
}
.pattern-row {
height: 3;
margin-top: 1;
}
.pattern-label {
width: 15;
height: 3;
content-align: center middle;
text-align: right;
padding-right: 1;
}
.pattern-input {
width: 15;
height: 3;
}
/* Pipeline stages */
.stage-option {
height: auto;
padding: 0 1;
}
"""
def __init__(
self,
source_path: Path | None = None,
selected_files: list[Path] | None = None,
output_dir: Path | None = None,
extractor_type: ExtractorType = ExtractorType.PYPHEN,
language: str = "auto",
min_syllable_length: int = 2,
max_syllable_length: int = 8,
file_pattern: str = "*.txt",
run_normalize: bool = True,
run_annotate: bool = True,
*args,
**kwargs,
) -> None:
"""
Initialize the ConfigurePanel with current configuration.
Args:
source_path: Current source directory path
selected_files: List of specific files to process (empty = use directory)
output_dir: Current output directory path
extractor_type: Current extractor type selection
language: Current language code for pyphen
min_syllable_length: Current minimum syllable length
max_syllable_length: Current maximum syllable length
file_pattern: Current file glob pattern
run_normalize: Whether normalization is enabled
run_annotate: Whether annotation is enabled
*args: Additional positional arguments passed to Static
**kwargs: Additional keyword arguments passed to Static
"""
super().__init__(*args, **kwargs)
# Store initial configuration values
self.source_path = source_path
self.selected_files = selected_files or []
self.output_dir = output_dir
self.extractor_type = extractor_type
self.language = language
self.min_syllable_length = min_syllable_length
self.max_syllable_length = max_syllable_length
self.file_pattern = file_pattern
self.run_normalize = run_normalize
self.run_annotate = run_annotate
[docs]
def compose(self) -> ComposeResult:
"""
Compose the configuration panel layout.
Creates a vertically scrollable panel with grouped configuration
sections for directories, extractor, language, constraints, and
pipeline stages.
Yields:
Configuration section widgets
"""
# -----------------------------------------------------------------
# DIRECTORIES Section
# -----------------------------------------------------------------
with Container(classes="config-section", id="directories-section"):
yield Label("DIRECTORIES", classes="section-header")
# Source directory row
with Horizontal(classes="dir-row"):
yield Label("Source:", classes="dir-label")
source_text = self._get_source_display_text()
source_classes = (
"dir-path"
if (self.source_path or self.selected_files)
else "dir-path dir-path-empty"
)
yield Label(source_text, classes=source_classes, id="source-path-display")
yield Button("Directory", classes="dir-button", id="source-browse-btn")
yield Button("Select...", classes="dir-button", id="select-files-btn")
# Output directory row
with Horizontal(classes="dir-row"):
yield Label("Output:", classes="dir-label")
output_text = str(self.output_dir) if self.output_dir else "Not selected"
output_classes = "dir-path" if self.output_dir else "dir-path dir-path-empty"
yield Label(output_text, classes=output_classes, id="output-path-display")
yield Button("Browse", classes="dir-button", id="output-browse-btn")
# -----------------------------------------------------------------
# EXTRACTOR Section
# -----------------------------------------------------------------
with Container(classes="config-section", id="extractor-section"):
yield Label("EXTRACTOR", classes="section-header")
# Extractor type options using RadioOption
yield RadioOption(
option_name="pyphen",
description="Multi-language typographic hyphenation (40+ languages)",
is_selected=(self.extractor_type == ExtractorType.PYPHEN),
classes="extractor-option",
id="extractor-pyphen",
)
yield RadioOption(
option_name="nltk",
description="English-only phonetic splitting (CMUDict)",
is_selected=(self.extractor_type == ExtractorType.NLTK),
classes="extractor-option",
id="extractor-nltk",
)
# -----------------------------------------------------------------
# LANGUAGE Section (Pyphen only)
# -----------------------------------------------------------------
# Determine if language section should be disabled (NLTK selected)
lang_disabled = self.extractor_type == ExtractorType.NLTK
lang_section_classes = (
"config-section language-section-disabled" if lang_disabled else "config-section"
)
with Container(classes=lang_section_classes, id="language-section"):
yield Label("LANGUAGE (Pyphen only)", classes="section-header")
# Common language quick-select options
with Horizontal(classes="language-grid"):
# Create radio options for common languages
for code, name in COMMON_LANGUAGES[:4]: # First 4 common languages
yield RadioOption(
option_name=code,
description=name,
is_selected=(self.language == code),
classes="language-option",
id=f"lang-{code.replace('_', '-').lower()}",
)
# Custom language input for languages not in quick-select
with Horizontal(classes="custom-language-row"):
yield Label("Custom:", classes="custom-language-label")
# Show current language if not in common list
initial_custom = ""
if self.language not in [code for code, _ in COMMON_LANGUAGES[:4]]:
initial_custom = self.language
yield Input(
placeholder="e.g., pt_BR",
value=initial_custom,
classes="custom-language-input",
id="custom-language-input",
)
# -----------------------------------------------------------------
# CONSTRAINTS Section
# -----------------------------------------------------------------
with Container(classes="config-section", id="constraints-section"):
yield Label("CONSTRAINTS", classes="section-header")
# Min/Max syllable length spinners
with Vertical(classes="constraints-row"):
yield IntSpinner(
label="Min Length",
value=self.min_syllable_length,
min_val=1,
max_val=10,
id="min-length-spinner",
)
yield IntSpinner(
label="Max Length",
value=self.max_syllable_length,
min_val=1,
max_val=20,
id="max-length-spinner",
)
# File pattern input
with Horizontal(classes="pattern-row"):
yield Label("File Pattern:", classes="pattern-label")
yield Input(
value=self.file_pattern,
placeholder="*.txt",
classes="pattern-input",
id="file-pattern-input",
)
# -----------------------------------------------------------------
# PIPELINE STAGES Section
# -----------------------------------------------------------------
with Container(classes="config-section", id="stages-section"):
yield Label("PIPELINE STAGES", classes="section-header")
yield RadioOption(
option_name="normalize",
description="Clean and deduplicate syllables after extraction",
is_selected=self.run_normalize,
classes="stage-option",
id="stage-normalize",
)
yield RadioOption(
option_name="annotate",
description="Add phonetic feature annotations",
is_selected=self.run_annotate,
classes="stage-option",
id="stage-annotate",
)
# -------------------------------------------------------------------------
# Helper Methods
# -------------------------------------------------------------------------
def _get_source_display_text(self) -> str:
"""
Get the display text for the source path/files.
Returns:
Display text showing directory name or selected file count
"""
if self.selected_files:
count = len(self.selected_files)
if count == 1:
return f"1 file: {self.selected_files[0].name}"
else:
return f"{count} files selected"
elif self.source_path:
return str(self.source_path)
else:
return "Not selected"
# -------------------------------------------------------------------------
# Event Handlers
# -------------------------------------------------------------------------
[docs]
@on(Button.Pressed, "#source-browse-btn")
def on_source_browse_pressed(self, event: Button.Pressed) -> None:
"""
Handle source browse button press.
Posts SourceSelected message to trigger directory browser in parent app.
The parent app handles the actual directory selection modal.
Args:
event: Button press event
"""
event.stop() # Prevent event from bubbling
self.post_message(self.SourceSelected())
[docs]
@on(Button.Pressed, "#output-browse-btn")
def on_output_browse_pressed(self, event: Button.Pressed) -> None:
"""
Handle output browse button press.
Posts OutputSelected message to trigger directory browser in parent app.
Args:
event: Button press event
"""
event.stop()
self.post_message(self.OutputSelected())
[docs]
@on(Button.Pressed, "#select-files-btn")
def on_select_files_pressed(self, event: Button.Pressed) -> None:
"""
Handle select files button press.
Posts FilesSelected message to trigger file selector in parent app.
Args:
event: Button press event
"""
event.stop()
self.post_message(self.FilesSelected())
[docs]
@on(RadioOption.Selected)
def on_radio_option_selected(self, event: RadioOption.Selected) -> None:
"""
Handle radio option selection events.
Routes the selection to appropriate handler based on widget ID:
- extractor-*: Update extractor type
- lang-*: Update language selection
- stage-*: Toggle pipeline stage
Args:
event: Radio option selected event
"""
widget_id = event.widget_id or ""
# Handle extractor type selection
if widget_id.startswith("extractor-"):
self._handle_extractor_selection(event.option_name)
# Handle language selection
elif widget_id.startswith("lang-"):
self._handle_language_selection(event.option_name)
# Handle pipeline stage toggles
elif widget_id.startswith("stage-"):
self._handle_stage_toggle(event.option_name)
def _handle_extractor_selection(self, option_name: str) -> None:
"""
Handle extractor type selection change.
Updates internal state, toggles radio button display, and
enables/disables the language section based on extractor type.
Args:
option_name: Selected extractor name ("pyphen" or "nltk")
"""
# Determine new extractor type
new_type = ExtractorType.PYPHEN if option_name == "pyphen" else ExtractorType.NLTK
# Update radio button states
try:
pyphen_opt = self.query_one("#extractor-pyphen", RadioOption)
nltk_opt = self.query_one("#extractor-nltk", RadioOption)
pyphen_opt.set_selected(new_type == ExtractorType.PYPHEN)
nltk_opt.set_selected(new_type == ExtractorType.NLTK)
except Exception: # nosec B110 - Widget may not exist
pass
# Update language section enabled state
try:
lang_section = self.query_one("#language-section", Container)
if new_type == ExtractorType.NLTK:
lang_section.add_class("language-section-disabled")
else:
lang_section.remove_class("language-section-disabled")
except Exception: # nosec B110 - Widget may not exist
pass
# Update internal state and post message
self.extractor_type = new_type
self.post_message(self.ExtractorChanged(new_type))
def _handle_language_selection(self, language_code: str) -> None:
"""
Handle language selection change from quick-select options.
Updates radio button states and posts language change message.
Args:
language_code: Selected language code (e.g., "en_US")
"""
# Update radio button states for all language options
for code, _ in COMMON_LANGUAGES[:4]:
try:
opt_id = f"#lang-{code.replace('_', '-').lower()}"
opt = self.query_one(opt_id, RadioOption)
opt.set_selected(code == language_code)
except Exception: # nosec B110 - Widget may not exist
pass
# Clear custom input when selecting a quick-select option
try:
custom_input = self.query_one("#custom-language-input", Input)
custom_input.value = ""
except Exception: # nosec B110 - Widget may not exist
pass
# Update internal state and post message
self.language = language_code
self.post_message(self.LanguageChanged(language_code))
def _handle_stage_toggle(self, stage_name: str) -> None:
"""
Handle pipeline stage toggle.
Unlike extractor/language, stages are independent toggles (checkboxes),
not mutually exclusive radio buttons. Clicking toggles the state.
Args:
stage_name: Stage name ("normalize" or "annotate")
"""
# Toggle the appropriate stage
if stage_name == "normalize":
self.run_normalize = not self.run_normalize
try:
opt = self.query_one("#stage-normalize", RadioOption)
opt.set_selected(self.run_normalize)
except Exception: # nosec B110 - Widget may not exist
pass
elif stage_name == "annotate":
self.run_annotate = not self.run_annotate
try:
opt = self.query_one("#stage-annotate", RadioOption)
opt.set_selected(self.run_annotate)
except Exception: # nosec B110 - Widget may not exist
pass
# Post stage change message
self.post_message(self.PipelineStagesChanged(self.run_normalize, self.run_annotate))
[docs]
@on(Input.Changed)
def on_input_changed(self, event: Input.Changed) -> None:
"""
Handle input field changes.
Routes input changes to appropriate handler based on widget ID:
- custom-language-input: Update language setting
- file-pattern-input: Update file pattern
Args:
event: Input change event
"""
# Get the input widget ID from the event
input_id = event.input.id if event.input else None
if input_id == "custom-language-input":
self._handle_custom_language_input(event.value)
elif input_id == "file-pattern-input":
self._handle_file_pattern_input(event.value)
def _handle_custom_language_input(self, value: str) -> None:
"""
Handle custom language input changes.
When user types in custom language field, deselects quick-select
options and updates the language setting.
Args:
value: New input value
"""
custom_lang = value.strip()
if custom_lang:
# Deselect all quick-select language options
for code, _ in COMMON_LANGUAGES[:4]:
try:
opt_id = f"#lang-{code.replace('_', '-').lower()}"
opt = self.query_one(opt_id, RadioOption)
opt.set_selected(False)
except Exception: # nosec B110 - Widget may not exist
pass
# Update language and post message
self.language = custom_lang
self.post_message(self.LanguageChanged(custom_lang))
def _handle_file_pattern_input(self, value: str) -> None:
"""
Handle file pattern input changes.
Posts constraints changed message with updated pattern.
Args:
value: New input value
"""
self.file_pattern = value.strip() or "*.txt"
self.post_message(
self.ConstraintsChanged(
self.min_syllable_length,
self.max_syllable_length,
self.file_pattern,
)
)
[docs]
@on(IntSpinner.Changed)
def on_spinner_changed(self, event: IntSpinner.Changed) -> None:
"""
Handle IntSpinner value changes.
Routes spinner changes to update constraints based on widget_id.
Args:
event: Spinner change event
"""
widget_id = event.widget_id
if widget_id == "min-length-spinner":
self.min_syllable_length = event.value
self.post_message(
self.ConstraintsChanged(
self.min_syllable_length,
self.max_syllable_length,
self.file_pattern,
)
)
elif widget_id == "max-length-spinner":
self.max_syllable_length = event.value
self.post_message(
self.ConstraintsChanged(
self.min_syllable_length,
self.max_syllable_length,
self.file_pattern,
)
)
# -------------------------------------------------------------------------
# Public Methods for External Updates
# -------------------------------------------------------------------------
[docs]
def update_source_path(self, path: Path | None) -> None:
"""
Update the displayed source path.
Called by parent app after directory selection.
Clears any selected files since we're now using directory mode.
Args:
path: New source path, or None if cleared
"""
self.source_path = path
self.selected_files = [] # Clear file selection when directory is set
try:
display = self.query_one("#source-path-display", Label)
if path:
display.update(str(path))
display.remove_class("dir-path-empty")
else:
display.update("Not selected")
display.add_class("dir-path-empty")
except Exception: # nosec B110 - Widget may not exist
pass
[docs]
def update_output_path(self, path: Path | None) -> None:
"""
Update the displayed output path.
Called by parent app after directory selection.
Args:
path: New output path, or None if cleared
"""
self.output_dir = path
try:
display = self.query_one("#output-path-display", Label)
if path:
display.update(str(path))
display.remove_class("dir-path-empty")
else:
display.update("Not selected")
display.add_class("dir-path-empty")
except Exception: # nosec B110 - Widget may not exist
pass
[docs]
def update_selected_files(self, files: list[Path]) -> None:
"""
Update the selected files list.
Called by parent app after file selection.
When files are selected, the source_path is used only as the
initial browse location, not for processing.
Args:
files: List of selected file paths
"""
self.selected_files = files
try:
display = self.query_one("#source-path-display", Label)
if files:
display.update(self._get_source_display_text())
display.remove_class("dir-path-empty")
else:
# Revert to showing source_path if no files selected
if self.source_path:
display.update(str(self.source_path))
display.remove_class("dir-path-empty")
else:
display.update("Not selected")
display.add_class("dir-path-empty")
except Exception: # nosec B110 - Widget may not exist
pass