Source code for build_tools.syllable_walk_tui.modules.renderer.screen

"""
Render Screen for Syllable Walker TUI.

Modal screen for viewing selected names with proper rendering.
Displays Patch A and Patch B selections side-by-side with styling.

This screen consumes the name_renderer module to transform raw
lowercase names into properly styled, human-readable formats.

Design Philosophy:
    - Presentation only - does not modify underlying data
    - Shows names in context for human evaluation
    - "orma" in a list is data; "Orma" in context is a name
"""

from pathlib import Path

from textual import on
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import Screen
from textual.widgets import Button, Label, Static

from build_tools.name_renderer import (
    get_available_styles,
    get_style_description,
    render,
    render_full_name,
)


[docs] class RenderScreen(Screen): """ Modal screen for rendered name display. Shows selected names from both patches with: - Title case rendering (default) - Full name combination (A first + B last) - Style toggle (title/upper/lower) Keybindings: Esc/q: Close screen and return to main view s: Cycle through rendering styles c: Toggle combined name view """ BINDINGS = [ ("escape", "close_screen", "Close"), ("q", "close_screen", "Close"), ("s", "cycle_style", "Style"), ("c", "toggle_combine", "Combine"), ] DEFAULT_CSS = """ RenderScreen { background: $surface; padding: 1; } /* Header styling */ .render-title { text-style: bold; color: $accent; text-align: center; padding: 1; border-bottom: solid $primary; } .render-subtitle { color: $text-muted; text-align: center; padding-bottom: 1; } /* Main layout - side by side columns */ .render-columns { width: 100%; height: 1fr; } .render-column { width: 1fr; height: 100%; padding: 1; border: solid $primary; margin: 0 1; } .column-header { text-style: bold; color: $secondary; padding-bottom: 1; border-bottom: dashed $primary; } .name-class-label { color: $text-muted; padding-bottom: 1; } /* Name list styling */ .names-scroll { height: 1fr; } .rendered-name { padding: 0 1; } .no-names { color: $text-muted; text-style: italic; } /* Combined names panel */ .combined-panel { width: 100%; height: auto; max-height: 30%; padding: 1; border: solid $accent; margin-top: 1; } .combined-header { text-style: bold; color: $accent; padding-bottom: 1; } .combined-scroll { height: 1fr; max-height: 20; } .combined-name { padding: 0 1; color: $success; } /* Footer */ .render-footer { text-align: center; color: $text-muted; padding-top: 1; border-top: solid $primary; } """ def __init__( self, names_a: list[str], names_b: list[str], name_class_a: str, name_class_b: str, selections_dir_a: Path | None = None, selections_dir_b: Path | None = None, ) -> None: """ Initialize with selected names from both patches. Args: names_a: Selected names from Patch A (SelectorState.outputs) names_b: Selected names from Patch B (SelectorState.outputs) name_class_a: Name class used for Patch A selection name_class_b: Name class used for Patch B selection selections_dir_a: Selections directory for Patch A (for exports) selections_dir_b: Selections directory for Patch B (for exports) """ super().__init__() # Store input data self.names_a = names_a self.names_b = names_b self.name_class_a = name_class_a self.name_class_b = name_class_b self.selections_dir_a = selections_dir_a self.selections_dir_b = selections_dir_b # Display state self.available_styles = get_available_styles() self.current_style_index = 0 # Start with "title" self.show_combined = False @property def current_style(self) -> str: """Get the current rendering style.""" return self.available_styles[self.current_style_index]
[docs] def compose(self) -> ComposeResult: """Create the render screen layout.""" # Header yield Label("NAME RENDERER", classes="render-title") yield Label( f"Style: {get_style_description(self.current_style)} | " f"Press 's' to change, 'c' to combine", id="style-label", classes="render-subtitle", ) # Main content - two columns with Horizontal(classes="render-columns"): # Patch A column with Vertical(classes="render-column"): yield Label("PATCH A", classes="column-header") yield Label( f"Name Class: {self.name_class_a}", classes="name-class-label", ) yield Button( "Export Sample", id="export-sample-a", variant="primary", ) with VerticalScroll(classes="names-scroll"): yield Static( self._render_names_list(self.names_a, self.name_class_a), id="names-a", ) # Patch B column with Vertical(classes="render-column"): yield Label("PATCH B", classes="column-header") yield Label( f"Name Class: {self.name_class_b}", classes="name-class-label", ) yield Button( "Export Sample", id="export-sample-b", variant="primary", ) with VerticalScroll(classes="names-scroll"): yield Static( self._render_names_list(self.names_b, self.name_class_b), id="names-b", ) # Combined names panel (hidden by default) with Vertical(id="combined-panel", classes="combined-panel"): yield Label("COMBINED NAMES (A + B)", classes="combined-header") with VerticalScroll(classes="combined-scroll"): yield Static( self._render_combined_names(), id="combined-names", ) # Footer yield Label( "Esc/q: Close | s: Cycle Style | c: Toggle Combine", classes="render-footer", )
[docs] def on_mount(self) -> None: """Handle screen mount - hide combined panel initially.""" self._update_combined_visibility()
def _render_names_list(self, names: list[str], name_class: str) -> str: """ Render a list of names with current style. Args: names: List of raw names name_class: Name class for rendering Returns: Rendered names as newline-separated string """ if not names: return "(no names selected)" rendered = [render(name, name_class, style=self.current_style) for name in names] return "\n".join(rendered) def _render_combined_names(self) -> str: """ Render combined full names (Patch A first + Patch B last). Pairs names from both patches. If lists are different lengths, only pairs up to the shorter length. Returns: Combined names as newline-separated string """ if not self.names_a or not self.names_b: return "(need names in both patches to combine)" # Pair names from both patches combined = [] pair_count = min(len(self.names_a), len(self.names_b)) for i in range(pair_count): full_name = render_full_name( self.names_a[i], self.names_b[i], style=self.current_style, ) combined.append(full_name) return "\n".join(combined) def _refresh_names_display(self) -> None: """Refresh all name displays with current style.""" try: # Update Patch A names names_a_widget = self.query_one("#names-a", Static) names_a_widget.update(self._render_names_list(self.names_a, self.name_class_a)) # Update Patch B names names_b_widget = self.query_one("#names-b", Static) names_b_widget.update(self._render_names_list(self.names_b, self.name_class_b)) # Update combined names combined_widget = self.query_one("#combined-names", Static) combined_widget.update(self._render_combined_names()) # Update style label style_label = self.query_one("#style-label", Label) style_label.update( f"Style: {get_style_description(self.current_style)} | " f"Press 's' to change, 'c' to combine" ) except Exception: # nosec B110 - Widget query may fail during transitions pass def _update_combined_visibility(self) -> None: """Show or hide the combined names panel.""" try: combined_panel = self.query_one("#combined-panel") if self.show_combined: combined_panel.display = True else: combined_panel.display = False except Exception: # nosec B110 - Widget may not be mounted yet pass
[docs] def action_close_screen(self) -> None: """Close this screen and return to main view.""" self.app.pop_screen()
[docs] def action_cycle_style(self) -> None: """Cycle through available rendering styles.""" # Move to next style (wrapping around) self.current_style_index = (self.current_style_index + 1) % len(self.available_styles) # Refresh display with new style self._refresh_names_display() # Notify user self.notify(f"Style: {get_style_description(self.current_style)}")
[docs] def action_toggle_combine(self) -> None: """Toggle the combined names panel visibility.""" self.show_combined = not self.show_combined self._update_combined_visibility() # Notify user if self.show_combined: self.notify("Combined names: ON") else: self.notify("Combined names: OFF")
[docs] @on(Button.Pressed, "#export-sample-a") def on_export_sample_a(self) -> None: """Export a sample JSON for Patch A.""" self._export_sample( names=self.names_a, name_class=self.name_class_a, selections_dir=self.selections_dir_a, patch_label="A", )
[docs] @on(Button.Pressed, "#export-sample-b") def on_export_sample_b(self) -> None: """Export a sample JSON for Patch B.""" self._export_sample( names=self.names_b, name_class=self.name_class_b, selections_dir=self.selections_dir_b, patch_label="B", )
def _export_sample( self, names: list[str], name_class: str, selections_dir: Path | None, patch_label: str, ) -> None: """ Export a random sample JSON file for the specified patch. Args: names: Selected names for the patch name_class: Name class for file naming selections_dir: Directory to write the sample JSON into patch_label: Patch label for user messaging """ from build_tools.syllable_walk_tui.services.exporter import export_sample_json # Validate there are names to sample if not names: self.notify(f"Patch {patch_label}: No names to sample.", severity="warning") return # Ensure the selections directory is available if selections_dir is None: self.notify( f"Patch {patch_label}: No selections directory available.", severity="warning", ) return # Write the sample JSON file output_path, error = export_sample_json( names=names, name_class=name_class, selections_dir=selections_dir, ) if error: self.notify(f"Patch {patch_label}: {error}", severity="error") return # Tell the user where the sample landed for packaging workflows self.notify( f"Patch {patch_label}: Sample exported → {output_path.name}", severity="information", )