Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pyldraw3_tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ def action_show_tab(self, tab: str) -> None:
"""Activate the Catalog or Model tab."""
self.query_one("#main-tabs", TabbedContent).active = tab
if tab == "model":
self.query_one("#piece-table", PieceTable).focus()
self.query_one("#piece-table", expect_type=PieceTable).focus()

def action_toggle_theme(self) -> None:
"""Switch between the dark and light themes."""
Expand Down
53 changes: 47 additions & 6 deletions src/pyldraw3_tui/screens/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ def __init__(self, *, id: str | None = None) -> None: # noqa: A002 - Textual id
self._instruction_document: InstructionDocument | None = None
self._selected_instruction_section: str | None = None
self._selected_instruction_step = 1
self._step_occurrence_counts: tuple[int, ...] | None = None
self._step_counts_section: str | None = None

def compose(self) -> ComposeResult:
"""Lay out the top bar and the three tabs."""
Expand Down Expand Up @@ -260,9 +262,20 @@ def _instruction_step_changed(self, event: Select.Changed) -> None:

def _show_error(self, message: str) -> None:
self._model = None
self._selected_key = ROOT_KEY
self._instruction_document = None
self._selected_instruction_section = None
self._selected_instruction_step = 1
self._step_occurrence_counts = None
self._step_counts_section = None
submodel_select = self.query_one("#submodel-select", Select)
with submodel_select.prevent(Select.Changed):
submodel_select.set_options([])
self._view_mode = MODEL_MODE
mode_select = self.query_one("#view-mode-select", Select)
with mode_select.prevent(Select.Changed):
mode_select.value = MODEL_MODE
self._sync_mode_ui()
self.add_class("errored")
self.query_one("#model-error", Static).update(f"[bold red]Error:[/] {message}")
self.query_one("#model-title", Static).update("No model open")
Expand Down Expand Up @@ -306,8 +319,8 @@ def _render_whole_model(self) -> None:
occurrences,
self._parts,
)
self.query_one("#stats-panel", StatsPanel).show_model(
model,
self.query_one("#stats-panel", StatsPanel).show_occurrences(
occurrences,
self._parts,
steps=len(steps),
)
Expand All @@ -322,11 +335,20 @@ def _render_instruction_step(self) -> None:
return
section, step = selection
occurrences = step.cumulative_occurrences(expand_submodels=True)
step_numbers = tuple(
counts = self._section_step_counts(section)
labels = tuple(
source_step.number
for source_step in section.steps[: step.number]
for _ in source_step.added_occurrences(expand_submodels=True)
for source_step, count in zip(
section.steps[: step.number],
counts[: step.number],
strict=True,
)
for _ in range(count)
)
# pyldraw3 guarantees the cumulative expansion is the ordered
# concatenation of each step's added occurrences; if that ever
# drifts, blank step labels beat crashing the render.
step_numbers = labels if len(labels) == len(occurrences) else None
self.query_one("#piece-table", PieceTable).set_occurrences(
occurrences,
self._parts,
Expand All @@ -340,6 +362,8 @@ def _render_instruction_step(self) -> None:
self.query_one("#instruction-details", InstructionDetails).show_step(
step,
total_steps=len(section.steps),
added_count=counts[step.number - 1],
cumulative_count=len(occurrences),
)
self.query_one("#pli-table", BomTable).set_rows(
step.added_bill_of_materials(
Expand All @@ -362,7 +386,22 @@ def _render_instruction_step(self) -> None:
step.directives,
)

def _section_step_counts(self, section: InstructionSection) -> tuple[int, ...]:
"""Occurrence counts added per step, computed once per section."""
if (
self._step_occurrence_counts is None
or self._step_counts_section != section.name
):
self._step_occurrence_counts = tuple(
len(step.added_occurrences(expand_submodels=True))
for step in section.steps
)
self._step_counts_section = section.name
return self._step_occurrence_counts

def _build_instruction_document(self, *, reset: bool) -> None:
self._step_occurrence_counts = None
self._step_counts_section = None
if self._model is None:
self._instruction_document = None
self._clear_instruction_selectors()
Expand Down Expand Up @@ -421,7 +460,9 @@ def _instruction_selection(
for step in section.steps:
if step.number == self._selected_instruction_step:
return section, step
return section, section.steps[0]
fallback = section.steps[0]
self._selected_instruction_step = fallback.number
return section, fallback

def _clear_instruction_selectors(self) -> None:
section_select = self.query_one("#instruction-section-select", Select)
Expand Down
6 changes: 4 additions & 2 deletions src/pyldraw3_tui/widgets/directives_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ def set_directives(
self.add_row(
"—" if directive.source_line is None else str(directive.source_line),
directive.kind.value,
data,
directive.raw.to_ldraw(),
# Text keeps bracketed content (JSON arrays, user comments)
# literal — plain str cells go through Rich markup parsing.
Text(data),
Text(directive.raw.to_ldraw()),
)
self.border_title = f"Directives ({len(directives)})"
13 changes: 10 additions & 3 deletions src/pyldraw3_tui/widgets/instruction_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@
class InstructionDetails(Static):
"""Renderer-neutral rotation, camera, layout, and callout state."""

def show_step(self, step: InstructionStep, *, total_steps: int) -> None:
def show_step(
self,
step: InstructionStep,
*,
total_steps: int,
added_count: int,
cumulative_count: int,
) -> None:
"""Render the effective semantics for one section-local step."""
text = Text()

Expand All @@ -27,8 +34,8 @@ def line(label: str, value: str) -> None:

line("instruction step", f"{step.number} of {total_steps}")
line("source lines", _source_lines(step))
line("added occurrences", str(len(step.added_occurrences())))
line("cumulative occurrences", str(len(step.cumulative_occurrences())))
line("added occurrences", str(added_count))
line("cumulative occurrences", str(cumulative_count))
line("rotation", _rotation(step))
line("camera", _camera(step))
line("callouts", _callouts(step))
Expand Down
52 changes: 48 additions & 4 deletions tests/test_pilot_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,15 @@ async def test_instruction_step_renders_cumulative_geometry_and_inventories(
rows = [directives.get_row_at(row) for row in range(13)]
assert {row[1] for row in rows} >= {"highlight", "arrow"}
arrow = next(row for row in rows if row[1] == "arrow")
assert '"label": "Attach"' in arrow[2]
assert '"label": "Attach"' in arrow[2].plain
legacy_bom = next(
row
for row in rows
if row[1] == "inventory_ignore_begin" and "BOM" in row[3]
if row[1] == "inventory_ignore_begin" and "BOM" in row[3].plain
)
assert legacy_bom[3] == "0 LPUB BOM BEGIN IGN"
assert legacy_bom[3].plain == "0 LPUB BOM BEGIN IGN"
unsupported = next(row for row in rows if row[1] == "unsupported_lpub")
assert unsupported[3] == "0 !LPUB SOMETHING KEEP RAW"
assert unsupported[3].plain == "0 !LPUB SOMETHING KEEP RAW"

step_select.value = 2
await pilot.pause()
Expand Down Expand Up @@ -362,6 +362,50 @@ async def test_instruction_issues_include_orphan_section(
assert row[3] == "orphan-section"


async def test_instruction_step_keys_clamp_at_section_bounds(
make_app,
instructions_mpd,
):
app = make_app(model_path=instructions_mpd)
async with app.run_test(size=(120, 40)) as pilot:
await wait_for_catalog(app, pilot)
await pilot.press("i")
step_select = app.query_one("#instruction-step-select", Select)
assert step_select.value == 1
await pilot.press("[")
assert step_select.value == 1
for _ in range(5):
await pilot.press("]")
assert step_select.value == 4
await pilot.press("]")
assert step_select.value == 4


async def test_error_load_exits_instructions_mode(
make_app,
instructions_mpd,
broken_ldr,
):
app = make_app(model_path=instructions_mpd)
async with app.run_test(size=(120, 40)) as pilot:
await wait_for_catalog(app, pilot)
await pilot.press("i")
model_view = app.query_one("#model-view", ModelView)
assert model_view.has_class("instructions")

model_view.load_model(broken_ldr)
await pilot.pause()
assert model_view.has_class("errored")
assert not model_view.has_class("instructions")
assert app.query_one("#view-mode-select", Select).value == MODEL_MODE
submodel_select = app.query_one("#submodel-select", Select)
assert submodel_select.value is Select.NULL
assert submodel_select._options == [("", Select.NULL)] # noqa: SLF001
tabs = app.query_one("#model-tabs", TabbedContent)
assert not tabs.get_tab("tab-pli").display
assert not tabs.get_tab("tab-directives").display


async def test_opening_another_file_resets_and_clears_instruction_state(
make_app,
instructions_mpd,
Expand Down
11 changes: 8 additions & 3 deletions tests/test_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from tests.helpers import wait_for_catalog

if TYPE_CHECKING:
from collections.abc import Callable

from pyldraw3_tui.app import PyldrawTuiApp

SNAPSHOT_DIR = Path(__file__).parent / "__snapshots__"
Expand Down Expand Up @@ -83,12 +85,15 @@ async def test_snapshot_model_issues(make_app, warnings_ldr):
_check(app, "model_issues")


async def test_snapshot_model_instructions(make_app, instructions_mpd):
async def test_snapshot_model_instructions(
make_app: Callable[..., PyldrawTuiApp],
instructions_mpd: Path,
) -> None:
app = make_app(model_path=instructions_mpd)
async with app.run_test(size=SIZE) as pilot:
await wait_for_catalog(app, pilot)
await pilot.press("i")
app.query_one("#instruction-step-select", Select).value = 3
app.query_one("#model-tabs", TabbedContent).active = "tab-summary"
app.query_one("#instruction-step-select", expect_type=Select).value = 3
app.query_one("#model-tabs", expect_type=TabbedContent).active = "tab-summary"
await pilot.pause()
_check(app, "model_instructions")