"""
Package screen for Syllable Walker TUI.
This screen provides a two-column workflow:
- Left: editable metadata inputs that will be saved as JSON.
- Right: packaging workflow (select run dir, include options, build ZIP).
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from pathlib import Path
from textual import on, work
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import Screen
from textual.widgets import Button, Checkbox, Input, Label, Static, TextArea
from build_tools.name_selector.name_class import get_default_policy_path, load_name_classes
from build_tools.syllable_walk_tui.controls import DirectoryBrowserScreen
from build_tools.syllable_walk_tui.services.packager import (
PackageOptions,
build_package_metadata,
collect_included_files,
package_selections,
scan_selections,
write_metadata_json,
)
[docs]
class PackageScreen(Screen):
"""
Full-screen UI for packaging selection outputs.
Users pick a run directory (``_working/<timestamp>_<extractor>``),
choose which selection artifacts to include, and generate a ZIP
archive that contains the selections plus an optional manifest.
The left column captures author-facing metadata that will be saved
as a JSON file alongside the package output.
Keybindings:
Esc/q: Close the screen
b: Browse for a run directory
p: Build package
"""
BINDINGS = [
("escape", "close_screen", "Close"),
("q", "close_screen", "Close"),
("b", "browse_run_dir", "Browse"),
("p", "build_package", "Package"),
]
DEFAULT_CSS = """
PackageScreen {
background: $surface;
padding: 1;
}
#package-main {
layout: horizontal;
width: 100%;
height: 1fr;
}
.package-column {
width: 1fr;
height: 100%;
border: solid $primary;
padding: 1;
}
.panel-title {
text-style: bold;
color: $accent;
margin-bottom: 1;
}
.section-header {
text-style: bold;
margin-top: 1;
}
.row {
width: 100%;
height: auto;
margin: 0 0 1 0;
}
.field-label {
width: 14;
text-align: right;
padding-right: 1;
color: $text-muted;
}
.input-wide {
width: 1fr;
}
.intended-use-group {
margin-bottom: 1;
}
.intended-use-option {
margin-left: 1;
}
#meta-examples {
height: 8;
border: solid $primary;
}
#meta-files {
height: 6;
border: solid $primary;
color: $text-muted;
padding: 0 1;
}
#selection-summary {
padding: 0 1;
color: $text-muted;
border: dashed $primary;
height: auto;
}
#include-row Checkbox {
margin-right: 2;
}
#button-row Button {
margin-right: 1;
}
#package-status {
padding: 0 1;
height: auto;
border: solid $primary;
color: $text;
}
.status-error {
color: $error;
}
.status-success {
color: $success;
}
"""
def __init__(self, initial_run_dir: Path | None = None) -> None:
"""
Initialize the package screen with optional initial run directory.
Args:
initial_run_dir: Pre-selected run directory, if available
"""
super().__init__()
# Track the current run directory in state for packaging actions
self.run_dir: Path | None = initial_run_dir
# Mirror include flags in state for easy access when building options
self.include_json = True
self.include_txt = True
self.include_meta = True
self.include_manifest = True
# Cache name class options for the intended-use checkboxes
self._name_class_options = self._load_name_class_options()
# Track whether the package name should be auto-derived from common name
self._auto_package_name = True
[docs]
def compose(self) -> ComposeResult:
"""Compose the package screen layout."""
with Horizontal(id="package-main"):
# -----------------------------------------------------------------
# Left column: metadata editor panel
# -----------------------------------------------------------------
with VerticalScroll(classes="package-column", id="package-editor-column"):
yield Label("PACKAGE EDITOR", classes="panel-title")
yield Static(
"Provide author metadata to save with this package.",
classes="output-placeholder",
)
yield Label("Run Directory", classes="section-header")
with Horizontal(classes="row"):
yield Label("Run Dir:", classes="field-label")
yield Input(
placeholder="_working/20260130_185007_pyphen",
id="run-dir-input",
classes="input-wide",
)
yield Button("Browse", id="browse-run-dir", variant="default")
yield Label("Metadata", classes="section-header")
# Date/time stamp (defaults to now but is editable)
with Horizontal(classes="row"):
yield Label("Date/Time:", classes="field-label")
yield Input(id="meta-created", classes="input-wide")
# Author name input
with Horizontal(classes="row"):
yield Label("Author:", classes="field-label")
yield Input(placeholder="Author name", id="meta-author", classes="input-wide")
# Version input
with Horizontal(classes="row"):
yield Label("Version:", classes="field-label")
yield Input(placeholder="1.0.0", id="meta-version", classes="input-wide")
# Human-friendly name input (defaults to run directory)
with Horizontal(classes="row"):
yield Label("Common Name:", classes="field-label")
yield Input(id="meta-common-name", classes="input-wide")
# Intended use checkboxes populated from name classes
yield Label("Intended Use", classes="section-header")
with Vertical(id="meta-intended-use", classes="intended-use-group"):
if self._name_class_options:
for index, (label, name_class) in enumerate(self._name_class_options):
yield Checkbox(
label,
value=index == 0,
id=self._intended_use_checkbox_id(name_class),
classes="intended-use-option",
)
else:
yield Static("No name classes available.", classes="output-placeholder")
# Source is derived from the run directory and kept read-only
with Horizontal(classes="row"):
yield Label("Source:", classes="field-label")
yield Input(id="meta-source", classes="input-wide", disabled=True)
# Examples area for author-provided samples
yield Label("Examples", classes="section-header")
yield TextArea(
"", # Start empty so authors can add examples
id="meta-examples",
)
# -----------------------------------------------------------------
# Right column: packaging workflow panel
# -----------------------------------------------------------------
with VerticalScroll(classes="package-column", id="package-workflow-column"):
yield Label("PACKAGE BUILDER", classes="panel-title")
# Summary of discovered selection files
yield Static("Select a run directory to view selections.", id="selection-summary")
yield Label("Include", classes="section-header")
with Horizontal(classes="row", id="include-row"):
yield Checkbox("JSON", value=True, id="include-json")
yield Checkbox("TXT", value=True, id="include-txt")
yield Checkbox("Meta", value=True, id="include-meta")
yield Checkbox("Manifest", value=True, id="include-manifest")
# Included files list (auto-generated)
yield Label("Files Included", classes="section-header")
yield Static("Select a run directory to list files.", id="meta-files")
yield Label("Output", classes="section-header")
with Horizontal(classes="row"):
yield Label("Output Dir:", classes="field-label")
yield Input(
placeholder="defaults to <run_dir>/packages",
id="output-dir-input",
classes="input-wide",
)
with Horizontal(classes="row"):
yield Label("Package Name:", classes="field-label")
yield Input(
placeholder="<run_dir>_selections.zip",
id="package-name-input",
classes="input-wide",
)
# Action buttons row
with Horizontal(classes="row", id="button-row"):
yield Button("Create Package", id="build-package", variant="primary")
yield Button("Close", id="close-package", variant="default")
# Status output
yield Static("", id="package-status")
[docs]
def on_mount(self) -> None:
"""Initialize inputs based on the current run directory state."""
# Apply the default timestamp once when the screen mounts
self._set_default_timestamp()
# Apply a default version if the field is empty
self._set_default_version()
# If an initial run directory was supplied, populate dependent fields
if self.run_dir:
self._apply_run_dir_defaults(self.run_dir)
self._refresh_selection_summary(self.run_dir)
self._refresh_included_files(self.run_dir)
self._set_source_fields(self.run_dir)
# Ensure examples are populated for the initial intended use selection
self._update_examples_from_intended_use()
[docs]
def action_close_screen(self) -> None:
"""Close this screen and return to the main view."""
self.app.pop_screen()
[docs]
def action_browse_run_dir(self) -> None:
"""Keybinding action to browse for a run directory."""
# Delegate to the async handler used by the browse button
self._browse_for_run_dir()
[docs]
def action_build_package(self) -> None:
"""Keybinding action to build a package with the current settings."""
# Delegate to the same handler used by the Create Package button
self._build_package()
[docs]
@on(Button.Pressed, "#close-package")
def on_close_pressed(self) -> None:
"""Handle Close button press."""
self.action_close_screen()
[docs]
@on(Button.Pressed, "#browse-run-dir")
def on_browse_pressed(self) -> None:
"""Handle Browse button press."""
self._browse_for_run_dir()
@work
async def _browse_for_run_dir(self) -> None:
"""
Open the directory browser to select a run directory.
This uses a validator that ensures the selection contains
a selections/ folder with at least one selection file.
"""
# Build the initial directory for browsing
initial_dir = self._get_default_working_dir()
# Open the directory browser modal and await selection
result = await self.app.push_screen_wait(
DirectoryBrowserScreen(
title="Select Run Directory",
validator=self._validate_run_dir,
initial_dir=initial_dir,
)
)
if result:
# Update the screen state and inputs with the chosen directory
self._set_run_dir(result)
[docs]
@on(Input.Submitted, "#run-dir-input")
def on_run_dir_submitted(self, event: Input.Submitted) -> None:
"""
Handle manual run directory entry.
This updates defaults and selection summary after the user
confirms input with Enter.
"""
run_dir = self._coerce_run_dir(self._normalize_path(event.value))
if run_dir:
self._set_run_dir(run_dir)
else:
self._update_status("Run directory is empty.", is_error=True)
[docs]
@on(Input.Submitted, "#output-dir-input")
def on_output_dir_submitted(self, event: Input.Submitted) -> None:
"""Handle manual output directory entry."""
output_dir = self._normalize_path(event.value)
if output_dir is None:
self._update_status("Output directory is empty.", is_error=True)
[docs]
@on(Input.Submitted, "#meta-common-name")
def on_common_name_submitted(self, event: Input.Submitted) -> None:
"""Handle common name edits to keep the package name in sync."""
self._handle_common_name_change(event.value)
[docs]
@on(Input.Changed, "#meta-common-name")
def on_common_name_changed(self, event: Input.Changed) -> None:
"""Keep package name synced as the common name is edited."""
# Update immediately so the package name stays aligned while typing
self._handle_common_name_change(event.value)
[docs]
@on(Checkbox.Changed, ".intended-use-option")
def on_intended_use_changed(self, event: Checkbox.Changed) -> None:
"""Refresh examples when intended-use selection changes."""
self._update_examples_from_intended_use()
[docs]
@on(Input.Submitted, "#package-name-input")
def on_package_name_submitted(self, event: Input.Submitted) -> None:
"""Handle manual package name entry."""
if not event.value.strip():
self._update_status("Package name is empty.", is_error=True)
return
# User has manually overridden the package name
self._auto_package_name = False
[docs]
@on(Input.Changed, "#package-name-input")
def on_package_name_changed(self, event: Input.Changed) -> None:
"""Track manual package name edits to disable auto-sync."""
# Only disable auto-sync when the user diverges from derived value
self._handle_package_name_change(event.value)
[docs]
@on(Checkbox.Changed)
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
"""Update include flags when checkboxes change."""
# Match checkbox IDs to local include flags for packaging
include_changed = False
if event.checkbox.id == "include-json":
self.include_json = event.value
include_changed = True
elif event.checkbox.id == "include-txt":
self.include_txt = event.value
include_changed = True
elif event.checkbox.id == "include-meta":
self.include_meta = event.value
include_changed = True
elif event.checkbox.id == "include-manifest":
self.include_manifest = event.value
include_changed = True
# Refresh the included files list when include options change
if include_changed and self.run_dir:
self._refresh_included_files(self.run_dir)
[docs]
@on(Button.Pressed, "#build-package")
def on_build_package_pressed(self) -> None:
"""Handle Create Package button press."""
self._build_package()
def _build_package(self) -> None:
"""
Execute packaging with the current UI settings.
This validates inputs, calls the packaging service, writes
the metadata JSON, and reports status back to the user.
"""
run_dir = self._normalize_path(self._get_input_value("run-dir-input"))
if not run_dir:
self._update_status("Select a run directory before packaging.", is_error=True)
return
# Read optional output directory and package name
output_dir = self._normalize_path(self._get_input_value("output-dir-input"))
package_name = self._get_input_value("package-name-input").strip() or None
# Default package name to the common name if none provided
if not package_name:
common_name = self._get_input_value("meta-common-name") or run_dir.name
package_name = self._derive_package_name(common_name)
# Assemble packaging options based on UI state
options = PackageOptions(
run_dir=run_dir,
output_dir=output_dir,
package_name=package_name,
include_json=self.include_json,
include_txt=self.include_txt,
include_meta=self.include_meta,
include_manifest=self.include_manifest,
)
# Run the packaging service
result = package_selections(options)
if result.error:
self._update_status(result.error, is_error=True)
return
# Build metadata payload from the editor inputs
metadata_inputs = self._collect_metadata_inputs()
include_flags = {
"json": self.include_json,
"txt": self.include_txt,
"meta": self.include_meta,
"manifest": self.include_manifest,
}
metadata_payload = build_package_metadata(
run_dir=run_dir,
metadata_inputs=metadata_inputs,
included_files=result.included_files,
include_flags=include_flags,
)
# Write metadata JSON alongside the package
metadata_path, meta_error = write_metadata_json(
result.package_path.parent,
result.package_path.name,
metadata_payload,
)
if meta_error:
self._update_status(f"Package created, metadata failed: {meta_error}", is_error=True)
return
self._update_status(
f"Package created: {result.package_path} (metadata: {metadata_path.name})",
is_error=False,
)
def _validate_run_dir(self, path: Path) -> tuple[bool, str, str]:
"""
Directory browser validator for run directories.
Args:
path: Directory path to validate
Returns:
(is_valid, type_label, message)
"""
inventory, error = scan_selections(path)
if error or inventory is None:
return (False, "", error or "Invalid run directory")
# Build a concise message showing what was found
json_count = len(inventory.selection_json)
txt_count = len(inventory.selection_txt)
meta_count = len(inventory.meta_json)
message = f"{json_count} JSON, {txt_count} TXT, {meta_count} meta files"
return (True, "run", message)
def _set_run_dir(self, run_dir: Path) -> None:
"""
Update run directory state and refresh dependent fields.
Args:
run_dir: Newly selected run directory
"""
# Normalize any accidental selections/ paths to the parent run directory
run_dir = self._coerce_run_dir(run_dir) or run_dir
self.run_dir = run_dir
# Reflect the run directory in the input field
self._set_input_value("run-dir-input", str(run_dir))
# Update default output directory and package name
self._apply_run_dir_defaults(run_dir)
# Refresh summary of available selection files
self._refresh_selection_summary(run_dir)
# Update source/common name fields and included files list
self._set_source_fields(run_dir)
self._refresh_included_files(run_dir)
# Refresh examples based on the intended use selection
self._update_examples_from_intended_use()
def _apply_run_dir_defaults(self, run_dir: Path) -> None:
"""
Apply default output directory and package name for a run directory.
Args:
run_dir: Run directory used to derive defaults
"""
default_output_dir = run_dir / "packages"
self._set_input_value("output-dir-input", str(default_output_dir))
def _refresh_selection_summary(self, run_dir: Path) -> None:
"""
Update the summary widget with discovered selection files.
Args:
run_dir: Run directory to scan
"""
summary_widget = self.query_one("#selection-summary", Static)
inventory, error = scan_selections(run_dir)
if error or inventory is None:
summary_widget.update(error or "No selections found.")
return
json_count = len(inventory.selection_json)
txt_count = len(inventory.selection_txt)
meta_count = len(inventory.meta_json)
summary_widget.update(
f"Found selections in {inventory.selections_dir} | "
f"JSON: {json_count}, TXT: {txt_count}, Meta: {meta_count}"
)
def _refresh_included_files(self, run_dir: Path) -> None:
"""
Update the included files list based on include toggles.
Args:
run_dir: Run directory to scan
"""
list_widget = self.query_one("#meta-files", Static)
included, error = collect_included_files(
run_dir=run_dir,
include_json=self.include_json,
include_txt=self.include_txt,
include_meta=self.include_meta,
)
if error:
list_widget.update(error)
return
if not included:
list_widget.update("No files match include settings.")
return
# Display each included file on its own line
list_widget.update("\n".join(f"- {path.name}" for path in included))
def _set_default_timestamp(self) -> None:
"""Set the metadata timestamp field to the current local time."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if not self._get_input_value("meta-created"):
self._set_input_value("meta-created", timestamp)
def _set_default_version(self) -> None:
"""Set the default version if the field is empty."""
if not self._get_input_value("meta-version"):
self._set_input_value("meta-version", "1.0.0")
def _set_source_fields(self, run_dir: Path) -> None:
"""
Populate source and common-name fields based on the run directory.
Args:
run_dir: Run directory selected for packaging
"""
# Source is derived from the run directory name
self._set_input_value("meta-source", run_dir.name)
# Default common name to the run directory name if empty
if not self._get_input_value("meta-common-name"):
self._set_input_value("meta-common-name", run_dir.name)
# Keep the package name aligned with the common name by default
if self._auto_package_name:
self._set_input_value(
"package-name-input",
self._derive_package_name(run_dir.name),
)
def _collect_metadata_inputs(self) -> dict:
"""
Collect metadata fields from the left-column editor.
Returns:
Dictionary of metadata values keyed for build_package_metadata
"""
return {
"created_at": self._get_input_value("meta-created"),
"author": self._get_input_value("meta-author"),
"version": self._get_input_value("meta-version"),
"common_name": self._get_input_value("meta-common-name"),
"intended_use": self._get_intended_use_values(),
"examples": self._collect_examples_payload(),
}
def _handle_common_name_change(self, raw_value: str) -> None:
"""
Update the common name input and keep package name in sync.
Args:
raw_value: Raw text entered for the common name
"""
common_name = raw_value.strip()
if not common_name and self.run_dir:
common_name = self.run_dir.name
# Update the input to the normalized common name
self._set_input_value("meta-common-name", common_name)
# Only auto-update the package name if it hasn't been manually overridden
if self._auto_package_name and common_name:
self._set_input_value(
"package-name-input",
self._derive_package_name(common_name),
)
def _handle_package_name_change(self, raw_value: str) -> None:
"""
Detect manual edits to the package name and disable auto-sync.
Args:
raw_value: Raw text entered for the package name
"""
package_name = raw_value.strip()
if not package_name:
return
common_name = self._get_input_value("meta-common-name") or ""
derived_name = self._derive_package_name(common_name) if common_name else ""
if package_name != derived_name:
self._auto_package_name = False
def _derive_package_name(self, common_name: str) -> str:
"""
Derive a filesystem-safe package name from the common name.
Args:
common_name: Human-friendly package name from the editor
Returns:
Filename ending in _selections.zip
"""
# Normalize whitespace and replace with underscores for stability
slug = re.sub(r"\s+", "_", common_name.strip().lower())
# Strip characters that are unsafe for filenames
slug = re.sub(r"[^a-z0-9_\\-]+", "", slug)
slug = slug or "package"
return f"{slug}_selections.zip"
def _load_name_class_options(self) -> list[tuple[str, str]]:
"""
Load name class options for the intended-use checkboxes.
Returns:
List of (label, value) tuples for the checkbox group
"""
try:
policies = load_name_classes(get_default_policy_path())
options: list[tuple[str, str]] = []
for name in sorted(policies.keys()):
label = name.replace("_", " ").title()
options.append((label, name))
return options
except Exception:
# Provide a minimal fallback so the checkbox group still renders
return [
("First Name", "first_name"),
("Last Name", "last_name"),
("Place Name", "place_name"),
]
def _update_examples_from_intended_use(self) -> None:
"""Initialize the examples field based on the current intended use selection."""
name_classes = self._get_intended_use_values()
self._set_examples_for_name_classes(name_classes)
def _set_examples_for_name_classes(self, name_classes: list[str]) -> None:
"""
Populate the examples field from sample JSON files for each name class.
Args:
name_classes: Selected name class identifiers
"""
text_area = self.query_one("#meta-examples", TextArea)
examples_payload = self._build_examples_payload(name_classes)
text_area.text = self._format_examples_payload(examples_payload)
def _load_examples_from_samples(self, name_class: str) -> list[str]:
"""
Load examples from <run_dir>/selections/<name_class>_sample.json.
Args:
name_class: Selected name class identifier
Returns:
List of sample names (lowercase) or empty list if unavailable
"""
if not self.run_dir:
return []
sample_path = self.run_dir / "selections" / f"{name_class}_sample.json"
if not sample_path.exists():
return []
try:
with open(sample_path, encoding="utf-8") as handle:
payload = json.load(handle)
samples = payload.get("samples", [])
if isinstance(samples, list):
return [str(item) for item in samples if str(item).strip()]
return []
except Exception:
return []
def _intended_use_checkbox_id(self, name_class: str) -> str:
"""
Build a stable checkbox id for a name class.
Args:
name_class: Name class identifier
Returns:
Checkbox id string
"""
return f"intended-use-{name_class}"
def _get_intended_use_values(self) -> list[str]:
"""
Collect selected intended-use name classes.
Returns:
List of selected name class identifiers
"""
selected: list[str] = []
for _, name_class in self._name_class_options:
checkbox_id = self._intended_use_checkbox_id(name_class)
matches = self.query(f"#{checkbox_id}")
if not matches:
continue
checkbox = matches.first()
if isinstance(checkbox, Checkbox) and checkbox.value:
selected.append(name_class)
return selected
def _build_examples_payload(self, name_classes: list[str]) -> dict[str, list[str]]:
"""
Build a class-keyed examples payload from sample JSON files.
Args:
name_classes: Selected name class identifiers
Returns:
Dictionary of name class -> list of samples
"""
payload: dict[str, list[str]] = {}
for name_class in name_classes:
payload[name_class] = self._load_examples_from_samples(name_class)
return payload
def _format_examples_payload(self, payload: dict[str, list[str]]) -> str:
"""
Format the examples payload for the editor text area.
Args:
payload: Examples dictionary keyed by name class
Returns:
Pretty JSON string for display/editing
"""
if not payload:
return ""
return json.dumps(payload, indent=2)
def _collect_examples_payload(self) -> dict[str, list[str]]:
"""
Parse examples from the text area, falling back to samples when invalid.
Returns:
Examples dictionary keyed by name class
"""
name_classes = self._get_intended_use_values()
raw_text = self._get_text_area_value("meta-examples")
if not raw_text.strip():
# No edits yet, default to the current sample files
return self._build_examples_payload(name_classes)
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
# Preserve the user's intended-use selections even on invalid JSON
return self._build_examples_payload(name_classes)
if not isinstance(parsed, dict):
# Only dict payloads are accepted for class-keyed examples
return self._build_examples_payload(name_classes)
payload: dict[str, list[str]] = {}
for name_class in name_classes:
if name_class not in parsed:
# Fill any missing classes with their sample defaults
payload[name_class] = self._load_examples_from_samples(name_class)
continue
items = parsed.get(name_class)
if isinstance(items, list):
cleaned = [str(item) for item in items if str(item).strip()]
payload[name_class] = cleaned
else:
# Fall back to sample data if the JSON entry isn't a list
payload[name_class] = self._load_examples_from_samples(name_class)
return payload
def _update_status(self, message: str, is_error: bool) -> None:
"""
Update the status message area with success or error text.
Args:
message: Message to display
is_error: True for error styling, False for normal
"""
status_widget = self.query_one("#package-status", Static)
status_widget.update(message)
status_widget.remove_class("status-error", "status-success")
status_widget.add_class("status-error" if is_error else "status-success")
def _get_default_working_dir(self) -> Path:
"""
Determine a sensible default directory for browsing.
Returns:
Path to the _working directory if available, otherwise home
"""
# Derive repository root based on the module location
repo_root = Path(__file__).resolve().parents[4]
working_dir = repo_root / "_working"
if working_dir.exists():
return working_dir
return Path.home()
def _get_input_value(self, widget_id: str) -> str:
"""
Read the current value of a Textual Input widget.
Args:
widget_id: ID of the Input widget
Returns:
Input value as a string (empty if widget not found)
"""
try:
return self.query_one(f"#{widget_id}", Input).value
except Exception:
return ""
def _set_input_value(self, widget_id: str, value: str) -> None:
"""
Set the value of a Textual Input widget, ignoring errors.
Args:
widget_id: ID of the Input widget
value: New value to set
"""
try:
widget = self.query_one(f"#{widget_id}", Input)
widget.value = value
except Exception:
# Fail silently to avoid crashing the screen on missing widgets
return
def _get_text_area_value(self, widget_id: str) -> str:
"""
Read the current content of a TextArea widget.
Args:
widget_id: ID of the TextArea widget
Returns:
Text content (empty string if unavailable)
"""
try:
text_area = self.query_one(f"#{widget_id}", TextArea)
return text_area.text
except Exception:
return ""
def _normalize_path(self, raw: str) -> Path | None:
"""
Normalize a path string into a Path object.
Args:
raw: Raw string input from a widget
Returns:
Normalized Path or None if input is empty
"""
raw = raw.strip()
if not raw:
return None
return Path(raw).expanduser()
def _coerce_run_dir(self, path: Path | None) -> Path | None:
"""
Normalize a user-supplied path to a run directory.
Users sometimes select the selections/ folder directly. In that case,
return the parent run directory so defaults and packaging work as expected.
Args:
path: Candidate path from input or browser
Returns:
Run directory path, or None if input was None
"""
if path is None:
return None
if path.name == "selections":
return path.parent
return path