From c5345c3f191e9342354f2f714397800c671aadcb Mon Sep 17 00:00:00 2001 From: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:06:59 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20plate=20tui=20=E2=80=94=20a=20real=20two?= =?UTF-8?q?-column=20prepare=20screen,=20and=20a=20summary=20that=20wraps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prepare screen stacked everything in one column, so it wasted about 40% of its width and put the slice estimate — the thing you pressed Prepare to get — below the fold. The form now sits on the left and what the run produced on the right, inside a titled Result box that says what will appear in it rather than showing an empty half screen with a disabled button floating in the middle of it. Below 100 columns two halves would be too narrow for a material label, so the screen keeps the single-column layout there and scrolls a finished (or failed) run into view instead of leaving it off-screen. The summary rows were laid out with an f-string pad, so a value that wrapped restarted in the label column: "nozzle" and "Supports: no" read like field names of their own. Model/printer/material/estimate now render through one shared Rich grid (bambu_cli/tui/widgets/summary.py) used by both the prepare screen and the confirmation dialog, with every cell wrapped in Text() so a "[" in a file name renders verbatim instead of being eaten as markup. The dialog's long values now fold in full rather than ellipsizing — it is the one screen that starts a physical print, so it should not abbreviate the name of the file it is about to print. Layout only: workdir ownership, the AMS-detection guard, the settings handoff and the confirm handoff are untouched, and confirm.py remains the single confirm=True call site. Also repairs a guard that had gone vacuous: the pre-sliced-3mf test asserted "Material PETG" not in the preview, a spelling coupled to the old f-string padding, so it passed unconditionally once the grid landed and could no longer catch the TUI claiming a material was applied to a file it never sliced. It now asserts on the material name and fails when sabotaged. --- CHANGELOG.md | 21 +++ bambu_cli/tui/screens/confirm.py | 23 +-- bambu_cli/tui/screens/prepare.py | 155 +++++++++++---- bambu_cli/tui/styles.tcss | 65 +++++++ bambu_cli/tui/widgets/summary.py | 37 ++++ tests/test_tui_prepare.py | 314 ++++++++++++++++++++++++++++++- tests/test_tui_settings.py | 14 +- 7 files changed, 571 insertions(+), 58 deletions(-) create mode 100644 bambu_cli/tui/widgets/summary.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 297aec6..4d4af63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,27 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Changed +- **`plate tui` prepare screen is now two columns.** The form (source, material, + quality, supports, the Prepare/Settings buttons) sits on the left and what the + run produced — status, the time/filament estimate, and *Start print…* — on the + right, so the estimate you waited for is visible next to the form instead of a + scroll below it. The source box is one row with its label beside it, matching + the settings screen. Below 100 columns the two halves would be too narrow for + a material label, so the screen keeps the single-column layout there; on a + narrow terminal a finished prepare — or a failed one — now scrolls itself into + view instead of landing off-screen. The results side is a titled *Result* box + that says what pressing *Prepare* will put in it, rather than an empty half + screen with a disabled button floating in it, and the form's material, + quality, supports and button groups are one width, so the column has a + straight right edge instead of four different ones. Nothing about what the + screen does changed. +- **`plate tui` no longer breaks up a long value in the print summary.** Model, + printer, material and estimate are laid out as a real two-column grid on both + the prepare screen and the confirmation dialog, so a value that wraps — + "Bambu Lab P1S, 0.4mm nozzle" on a narrow column — continues under the value + instead of restarting in the label column, where "nozzle" read like a field + name of its own. A long file name is now wrapped in full rather than cut short + with an ellipsis, and square brackets in a file name still render verbatim. - **`plate tui` advanced settings are clearer to fill in.** The screen previously asked you to hand-edit a `KEY=VALUE` string, with a `filament:` prefix to remember and no help on what a setting expects. Now the named flags with a diff --git a/bambu_cli/tui/screens/confirm.py b/bambu_cli/tui/screens/confirm.py index a7d375e..f6423db 100644 --- a/bambu_cli/tui/screens/confirm.py +++ b/bambu_cli/tui/screens/confirm.py @@ -239,16 +239,13 @@ def _with_output_tail(message: str, captured: str, *, lines: int = 3, width: int def _summary_table(rows: Any) -> Any: - """Render the preview rows as a compact label/value grid (empty if none).""" - from rich.table import Table - from rich.text import Text - - table = Table.grid(padding=(0, 2)) - table.add_column(justify="right", style="bold") - table.add_column() - for label, value in rows or []: - # Text(), not str: these rows carry filenames and slicer output, and a - # str cell is parsed as Rich markup — "model [remix].stl" would render - # as "model .stl", and "a[/b]c.gcode" would raise MarkupError. - table.add_row(Text(str(label)), Text(str(value))) - return table + """Render the preview rows as a compact label/value grid (empty if none). + + The grid itself now lives in ``widgets/summary.py``: the prepare screen + renders the same rows and had its own (wrap-broken) formatter, and one grid + is the only way the two stay identical. Deferred import to keep this module + importable without touching rich until a modal is actually built. + """ + from bambu_cli.tui.widgets.summary import summary_grid + + return summary_grid(rows) diff --git a/bambu_cli/tui/screens/prepare.py b/bambu_cli/tui/screens/prepare.py index dbb539b..864ee0d 100644 --- a/bambu_cli/tui/screens/prepare.py +++ b/bambu_cli/tui/screens/prepare.py @@ -25,6 +25,7 @@ from functools import partial from typing import Any +from textual import events from textual.app import ComposeResult from textual.containers import Horizontal, VerticalScroll from textual.screen import Screen @@ -42,8 +43,20 @@ validate_source, ) from bambu_cli.tui.services import PrepareResult +from bambu_cli.tui.widgets.summary import summary_grid _SETUP_HINT = "Run 'plate setup' in a terminal, then start the TUI again." +# What the results column says before there is a result. An empty bordered box +# beside a filled-in form reads as a half-rendered widget, so the box says what +# will land in it — and is replaced by the first real status, never mixed with +# one. +_RESULTS_TITLE = "Result" +_RESULTS_PLACEHOLDER = ( + 'Nothing prepared yet.\n\nPress "Prepare" and the print time and filament estimate for this model will appear here.' +) +# Narrower than this and the two columns are too cramped for the radio labels, +# so the prepare screen stacks them instead (see PrepareScreen._apply_layout). +TWO_COLUMN_MIN_WIDTH = 100 _PRESLICED_SETTINGS_CAVEAT = "Settings unavailable — pre-sliced file, material and slice settings are not applied." @@ -125,50 +138,85 @@ def __init__(self, args: argparse.Namespace, deps: Any) -> None: def compose(self) -> ComposeResult: yield Header() - with VerticalScroll(id="prepare-body"): - yield Label("Model source — URL or local file path") - yield Input( - placeholder="https://… or ~/models/cube.stl", - id="source-input", - ) - # markup=False: these render domain strings (paths, slicer errors) - # verbatim — a "[" in a filename must not be parsed as Rich markup. - yield Static("", id="source-error", markup=False) - yield Label("Material") - yield RadioSet( - *[ - RadioButton( - _material_label(name), - value=(name == MATERIAL_CHOICES[0]), - id=f"material-{name.lower()}", - ) - for name in MATERIAL_CHOICES - ], - id="material-set", - ) - yield Label("Quality") - yield RadioSet( - *[ - RadioButton( - f"{name} — {QUALITY_GUIDANCE[name]}", - value=(name == "standard"), - id=f"quality-{name}", + with VerticalScroll(id="prepare-body"), Horizontal(id="prepare-columns"): + # Left: everything the user fills in. Right: everything the run + # produces — so the estimate and "Start print…" land beside the form + # instead of a screen below it. + with VerticalScroll(id="prepare-inputs"): + # Label beside the control (the settings-screen idiom): a stacked + # label over a bordered Input costs four rows for one field. + with Horizontal(classes="settings-row"): + yield Label("Model source", classes="settings-label") + yield Input( + placeholder="https://… or ~/models/cube.stl", + id="source-input", + classes="settings-input", ) - for name in QUALITY_CHOICES - ], - id="quality-set", - ) - yield Checkbox("Supports (big overhangs)", id="supports-check") - with Horizontal(id="prepare-actions"): - yield Button("Prepare", id="prepare-button", variant="primary") - yield Button("Settings…", id="settings-button") - yield Static("", id="settings-summary", markup=False) - yield Static("", id="prepare-status", markup=False) - yield Static("", id="preview", markup=False) - yield Button("Start print…", id="print-button", disabled=True) + # markup=False: these render domain strings (paths, slicer errors) + # verbatim — a "[" in a filename must not be parsed as Rich markup. + yield Static("", id="source-error", markup=False) + yield Label("Material", classes="prepare-group") + yield RadioSet( + *[ + RadioButton( + _material_label(name), + value=(name == MATERIAL_CHOICES[0]), + id=f"material-{name.lower()}", + ) + for name in MATERIAL_CHOICES + ], + id="material-set", + ) + yield Label("Quality", classes="prepare-group") + yield RadioSet( + *[ + RadioButton( + f"{name} — {QUALITY_GUIDANCE[name]}", + value=(name == "standard"), + id=f"quality-{name}", + ) + for name in QUALITY_CHOICES + ], + id="quality-set", + ) + yield Checkbox("Supports (big overhangs)", id="supports-check") + with Horizontal(id="prepare-actions"): + yield Button("Prepare", id="prepare-button", variant="primary") + yield Button("Settings…", id="settings-button") + # Beside the button it annotates ("Overrides: …", or why the + # settings door is shut) rather than in the results column, + # where it read as part of the preview. + yield Static("", id="settings-summary", markup=False) + with VerticalScroll(id="prepare-output"): + yield Static(_RESULTS_PLACEHOLDER, id="prepare-status", markup=False) + yield Static("", id="preview", markup=False) + yield Button("Start print…", id="print-button", disabled=True) yield Footer() + # --- responsive layout -------------------------------------------------- + + def _apply_layout(self, width: int) -> None: + """Two columns when there is room; stacked below ``TWO_COLUMN_MIN_WIDTH``. + + Two halves of an 80-column terminal are ~38 columns each, which wraps + every material radio label ("ABS — strong, needs an enclosure (detected + in AMS)"). Below the threshold the columns stack and ``#prepare-body`` + scrolls, which is exactly the pre-restructure layout. + """ + for columns in self.query("#prepare-columns"): + columns.set_class(width < TWO_COLUMN_MIN_WIDTH, "narrow") + + def on_resize(self, event: events.Resize) -> None: + self._apply_layout(event.size.width) + def on_mount(self) -> None: + # Resize normally arrives on mount, but the class must be right even for + # the first paint (and for a screen driven headlessly without one). + self._apply_layout(self.app.size.width) + # A framed box with no title is a box the reader has to identify (the + # same rule StatusPanel follows). round, not thick: a thick border + # renders as solid slabs top and bottom and reads as a broken widget. + self.query_one("#prepare-output").border_title = _RESULTS_TITLE self.query_one("#source-input", Input).focus() # The AMS read blocks on MQTT; do it off the UI thread and apply the # pre-selection when (and only when) it comes back with a known material. @@ -444,6 +492,10 @@ def _apply_result(self, result: PrepareResult) -> None: self.query_one("#print-button", Button).disabled = True status.update(result.error or "Preparing the model failed.") preview.update("") + # Stacked layout: the failure message lands below the fold exactly + # like a success does, and a run that silently appears to do nothing + # is the worse of the two. + self.call_after_refresh(partial(self._scroll_result_into_view, "#prepare-status")) return self.result = result self.query_one("#print-button", Button).disabled = False @@ -454,7 +506,28 @@ def _apply_result(self, result: PrepareResult) -> None: if presliced: self.query_one("#settings-summary", Static).update(_PRESLICED_SETTINGS_CAVEAT) status.update('Ready. Press "Start print…" to confirm.') - preview.update("\n".join(f"{label:<11}{value}" for label, value in result.rows)) + # A grid, not f"{label:<11}{value}": a wrapped value used to continue in + # column 0 — inside the label column — so "Bambu Lab P1S, 0.4mm nozzle" + # read as a "Printer" row plus a field called "nozzle". + preview.update(summary_grid(result.rows)) + # Stacked (narrow) layout only: the results sit below the form, so the + # estimate the user waited for would otherwise land off-screen. After a + # refresh, because the preview only just grew and scrolling against its + # old height stops short of the button. + self.call_after_refresh(partial(self._scroll_result_into_view, "#print-button")) + + def _scroll_result_into_view(self, selector: str) -> None: + """Bring the finished run on-screen (a no-op when nothing scrolls). + + On success the button is the last thing in the results column, so + scrolling *it* into view brings the whole preview with it; on failure + the message itself is the anchor, because it can be many lines long and + scrolling to the button below it would leave its first line above the + top of the screen. + """ + if self._left_screen: + return + self.query_one(selector).scroll_visible(animate=False) def _material_label(name: str, *, detected: bool = False) -> str: diff --git a/bambu_cli/tui/styles.tcss b/bambu_cli/tui/styles.tcss index 8079926..c2dcd26 100644 --- a/bambu_cli/tui/styles.tcss +++ b/bambu_cli/tui/styles.tcss @@ -48,6 +48,71 @@ AmsPanel { padding: 0 2; } +/* Two columns: the form on the left, what the run produced on the right, so + the estimate and "Start print…" are visible without scrolling past the form. + PrepareScreen adds .narrow below TWO_COLUMN_MIN_WIDTH columns, where two + halves are too thin for a material label and stacking reads better; there + #prepare-body does the scrolling instead of the columns. */ +#prepare-columns { + width: 100%; + height: 1fr; +} + +#prepare-inputs { + width: 60; + height: 100%; + padding: 0 2 0 0; +} + +/* A titled box, not a bare divider: before the first run this column holds only + a disabled button, and unframed that read as a half-rendered widget. round, + never thick — thick renders as solid slabs top and bottom (see 0d63378). */ +#prepare-output { + width: 1fr; + min-width: 24; + height: 100%; + border: round $primary; + border-title-color: $accent; + border-title-style: bold; + padding: 0 1; +} + +#prepare-inputs .settings-label { + width: 14; +} + +/* One form, not four boxes of unrelated size: the group controls all fill the + column, so its right edge is a straight line instead of a stair-step. */ +#prepare-inputs RadioSet, +#prepare-inputs Checkbox, +#prepare-inputs #prepare-actions { + width: 100%; +} + +#prepare-actions Button { + width: 1fr; +} + +.prepare-group { + text-style: bold; + color: $accent; + padding: 1 0 0 0; + height: 2; +} + +#prepare-columns.narrow { + layout: vertical; + height: auto; +} + +#prepare-columns.narrow > #prepare-inputs, +#prepare-columns.narrow > #prepare-output { + width: 100%; + height: auto; + padding: 0; + border: none; +} + #source-error { color: $error; height: auto; diff --git a/bambu_cli/tui/widgets/summary.py b/bambu_cli/tui/widgets/summary.py new file mode 100644 index 0000000..03eb643 --- /dev/null +++ b/bambu_cli/tui/widgets/summary.py @@ -0,0 +1,37 @@ +"""The one label/value grid used for ``preview_rows`` output. + +Two screens render the same ``PrepareResult.rows``: the prepare screen's preview +and the confirmation modal's summary. They were formatted separately, and the +prepare side used ``f"{label:<11}{value}"`` — which lays out fine until a value +wraps, at which point the continuation starts in column 0, *inside* the label +column, so "Bambu Lab P1S, 0.4mm nozzle" rendered as a "Printer" row followed by +a line that reads like a field called "nozzle". A real two-column grid wraps the +value against the value column and leaves the label column blank, which is what +this module exists to guarantee for both callers. + +View layer only — no domain logic; the rows come from ``interactive.core``. +""" + +from __future__ import annotations + +from typing import Any + +from rich.table import Table +from rich.text import Text + + +def summary_grid(rows: Any) -> Table: + """Render label/value rows as a compact grid (an empty grid if none). + + Text(), not str: these rows carry filenames and slicer output, and a str + cell is parsed as Rich markup — "model [remix].stl" would render as + "model .stl", and "a[/b]c.gcode" would raise MarkupError mid-render. + """ + table = Table.grid(padding=(0, 2)) + table.add_column(justify="right", style="bold") + # overflow="fold" so an unbreakable token (a long filename with no spaces) + # folds inside the value column instead of widening the grid past its box. + table.add_column(overflow="fold") + for label, value in rows or []: + table.add_row(Text(str(label)), Text(str(value), overflow="fold")) + return table diff --git a/tests/test_tui_prepare.py b/tests/test_tui_prepare.py index 63416ed..eeb5761 100644 --- a/tests/test_tui_prepare.py +++ b/tests/test_tui_prepare.py @@ -150,8 +150,20 @@ async def _submit_source(pilot, screen, source): def _text(widget) -> str: + """Plain text of a widget's renderable — Rich grids included. + + ``str(table)`` is a repr, not the rendered rows, so a grid-backed panel + (the preview) has to go through a Console to be asserted on at all. + """ renderable = getattr(widget, "renderable", "") - return renderable if isinstance(renderable, str) else str(renderable) + if isinstance(renderable, str): + return renderable + from rich.console import Console + + console = Console(width=200) + with console.capture() as capture: + console.print(renderable) + return capture.get() # --------------------------------------------------------------------------- @@ -229,8 +241,12 @@ async def test_presliced_3mf_shows_material_not_applied_caveat(tmp_path): preview = _text(screen.query_one("#preview", Static)) assert "pre-sliced — material settings not applied" in preview - # It must NOT claim the chosen material was applied to a file we did not slice. - assert "Material PETG" not in preview + # It must NOT claim the chosen material was applied to a file we did not + # slice. Assert on the material NAME, not on a padded "Material PETG": + # that spelling was coupled to the old f-string layout and went vacuous the + # moment the preview became a Rich grid. The name cannot appear in any other + # row here (model, printer, estimate), so its absence is the whole guard. + assert "PETG" not in preview assert slicer.calls == [] @@ -499,3 +515,295 @@ def test_message_less_prepare_failure_is_not_diagnosed_as_unreachable(tmp_path): assert "unreachable" not in (result.error or "").lower() assert result.error == "Preparing the model failed (OSError)." assert not os.path.exists(result.state.workdir) + + +# --------------------------------------------------------------------------- +# Layout: the form on the left, what the run produced on the right +# +# Geometry, not text: these assert where the widgets actually land, because a +# containment assertion alone passed happily while the preview sat below the +# fold. Sizes are the two the project supports (see PrepareScreen and +# tests/test_tui_polish.py's 80x24 cases). +# --------------------------------------------------------------------------- + +_WIDE = (100, 30) +_NARROW = (80, 24) + + +def _column_of(screen, selector): + """Which prepare column owns a widget ('prepare-inputs'/'prepare-output').""" + for ancestor in screen.query_one(selector).ancestors: + if ancestor.id in ("prepare-inputs", "prepare-output"): + return ancestor.id + return None + + +async def test_wide_terminal_puts_the_form_beside_the_results(tmp_path): + _install_ready_settings(tmp_path) + app = PlateApp(_args(), _deps()) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + + for selector in ( + "#source-input", + "#source-error", + "#material-set", + "#material-pla", + "#quality-set", + "#quality-standard", + "#supports-check", + "#prepare-actions", + "#prepare-button", + "#settings-button", + "#settings-summary", + ): + assert _column_of(screen, selector) == "prepare-inputs", selector + for selector in ("#prepare-status", "#preview", "#print-button"): + assert _column_of(screen, selector) == "prepare-output", selector + + inputs = screen.query_one("#prepare-inputs") + output = screen.query_one("#prepare-output") + # Side by side and not overlapping, on the same row. + assert output.region.x >= inputs.region.right + assert output.region.y == inputs.region.y + assert not screen.query_one("#prepare-columns").has_class("narrow") + + +async def test_wide_terminal_shows_the_estimate_without_scrolling(tmp_path): + """The point of the restructure: preview + Start print visible with the form.""" + _install_ready_settings(tmp_path) + stl = _make_stl(tmp_path) + steps = GoSteps(download=Recorder(), slice=Recorder(return_value=_sliced_3mf(tmp_path))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + await _submit_source(pilot, screen, stl) + + assert "Estimate" in _text(screen.query_one("#preview", Static)) + assert screen.query_one("#print-button").disabled is False + viewport = app.screen.region + for selector in ("#source-input", "#material-set", "#prepare-button", "#preview", "#print-button"): + assert viewport.contains_region(screen.query_one(selector).region), selector + # …and nothing had to scroll to get there. + assert screen.query_one("#prepare-body").scroll_offset.y == 0 + + +async def test_narrow_terminal_stacks_the_columns(tmp_path): + """Two 40-column halves cannot hold a material label, so 80x24 stacks.""" + _install_ready_settings(tmp_path) + app = PlateApp(_args(), _deps(ams_detector=lambda args: "ABS")) + async with app.run_test(size=_NARROW) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + + assert screen.query_one("#prepare-columns").has_class("narrow") + inputs = screen.query_one("#prepare-inputs") + output = screen.query_one("#prepare-output") + assert output.region.x == inputs.region.x + assert output.region.y >= inputs.region.bottom + # The longest label there is (material + the AMS tag) still fits on one + # line — the reason the two-column layout has a width floor at all. + material = screen.query_one("#material-set") + assert "(detected in AMS)" in str(screen.query_one("#material-abs", RadioButton).label) + assert material.outer_size.width <= 80 + assert material.outer_size.height == len(("PLA", "PETG", "ABS", "TPU")) + 2 # + border + + +async def test_narrow_terminal_scrolls_the_finished_run_into_view(tmp_path): + """Stacked, the results start below the fold; the finished run must come up.""" + _install_ready_settings(tmp_path) + stl = _make_stl(tmp_path) + steps = GoSteps(download=Recorder(), slice=Recorder(return_value=_sliced_3mf(tmp_path))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_NARROW) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + button = screen.query_one("#print-button") + preview = screen.query_one("#preview") + assert not app.screen.region.contains_region(button.region) # below the fold + + await _submit_source(pilot, screen, stl) + await pilot.pause() + + assert "Estimate" in _text(screen.query_one("#preview", Static)) + assert app.screen.region.contains_region(preview.region) + assert app.screen.region.contains_region(button.region) + assert preview.outer_size.width <= 80 + + +# --------------------------------------------------------------------------- +# The results column: a titled box that says what will land in it, and a +# label/value grid whose wrapped values stay out of the label column. +# --------------------------------------------------------------------------- + + +def _render_at(renderable, width): + """Plain text of a Rich renderable at an exact console width. + + force_terminal=False as well as no_color: the label column is styled bold, + and bold is not a colour — without it the capture carries SGR codes and the + column arithmetic below measures escape sequences instead of glyphs. + """ + from rich.console import Console + + console = Console(width=max(width, 1), no_color=True, force_terminal=False) + with console.capture() as capture: + console.print(renderable) + return capture.get() + + +def test_summary_grid_wraps_a_value_under_the_value_column(): + """The defect: f"{label:<11}{value}" continued a wrap in the label column. + + "Bambu Lab P1S, 0.4mm nozzle" then rendered as a Printer row followed by a + line reading "nozzle", which looks like a field name. + """ + from bambu_cli.tui.widgets.summary import summary_grid + + rows = [("Printer", "Bambu Lab P1S, 0.4mm nozzle"), ("Estimate", "1h 42m, ~13 g")] + lines = _render_at(summary_grid(rows), 30).splitlines() + + value_column = lines[0].index("Bambu") + assert value_column > 0 + wrapped = [line for line in lines if line.strip() == "nozzle"] + assert wrapped, lines # it did wrap at this width + for line in wrapped: + # …to the value column, with the label column left blank. + assert line.index("nozzle") == value_column, line + assert line[:value_column].strip() == "", line + + +def test_summary_grid_renders_a_bracketed_filename_verbatim(): + """A "[" in a filename is not Rich markup: str cells would eat it.""" + from bambu_cli.tui.widgets.summary import summary_grid + + out = _render_at(summary_grid([("Model", "benchy [remix].stl")]), 60) + assert "benchy [remix].stl" in out + + # And the shape that does not merely render wrong but raises MarkupError. + closing = _render_at(summary_grid([("Model", "a[/b]c.gcode")]), 60) + assert "a[/b]c.gcode" in closing + + +def test_summary_grid_tolerates_no_rows(): + from bambu_cli.tui.widgets.summary import summary_grid + + assert _render_at(summary_grid(None), 40).strip() == "" + assert _render_at(summary_grid([]), 40).strip() == "" + + +async def test_results_column_is_a_titled_box_with_a_placeholder(tmp_path): + """Before any run the column must not read as a half-rendered widget.""" + _install_ready_settings(tmp_path) + app = PlateApp(_args(), _deps()) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + + output = screen.query_one("#prepare-output") + assert str(output.border_title) == "Result" + # round, not thick: thick renders as solid slabs top and bottom (0d63378). + assert output.styles.border_top[0] == "round" + + placeholder = _text(screen.query_one("#prepare-status", Static)) + assert "Nothing prepared yet" in placeholder + assert "Prepare" in placeholder + # Never mistakable for a real result: no estimate-shaped claim in it. + assert "Estimate" not in placeholder + assert _text(screen.query_one("#preview", Static)) == "" + + +async def test_placeholder_is_replaced_by_the_real_status(tmp_path): + _install_ready_settings(tmp_path) + stl = _make_stl(tmp_path) + steps = GoSteps(download=Recorder(), slice=Recorder(return_value=_sliced_3mf(tmp_path))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + await _submit_source(pilot, screen, stl) + + status = _text(screen.query_one("#prepare-status", Static)) + assert "Nothing prepared yet" not in status + assert "Start print" in status + + +async def test_preview_never_starts_a_line_in_the_label_column(tmp_path): + """The screen, not just the helper: a wrap must not invent a field name.""" + _install_ready_settings(tmp_path) + stl = _make_stl(tmp_path) + steps = GoSteps(download=Recorder(), slice=Recorder(return_value=_sliced_3mf(tmp_path))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + await _submit_source(pilot, screen, stl) + + preview = screen.query_one("#preview", Static) + labels = {str(label) for label, _ in screen.result.rows} + rendered = _render_at(preview.renderable, preview.content_size.width) + # Values do wrap at this width — otherwise the assertion proves nothing. + assert len(rendered.splitlines()) > len(labels) + for line in rendered.splitlines(): + if line and not line.startswith(" "): + assert line.split()[0] in labels, line + + +async def test_preview_shows_a_bracketed_filename_verbatim(tmp_path): + """End to end: the preview Static must not markup-parse a filename.""" + _install_ready_settings(tmp_path) + stl = tmp_path / "benchy [remix] v2.stl" + stl.write_text("solid cube\nendsolid cube\n", encoding="utf-8") + steps = GoSteps(download=Recorder(), slice=Recorder(return_value=_sliced_3mf(tmp_path))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_WIDE) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + await _submit_source(pilot, screen, str(stl)) + + assert "benchy [remix] v2.stl" in _text(screen.query_one("#preview", Static)) + + +async def test_form_groups_are_one_width_that_fills_the_column(tmp_path): + """One form, not four boxes of unrelated size with a ragged right edge.""" + _install_ready_settings(tmp_path) + app = PlateApp(_args(), _deps()) + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + + column = screen.query_one("#prepare-inputs").content_size.width + widths = { + selector: screen.query_one(selector).outer_size.width + for selector in ("#material-set", "#quality-set", "#supports-check", "#prepare-actions") + } + assert set(widths.values()) == {column}, widths + + +async def test_narrow_terminal_scrolls_a_failure_into_view(tmp_path): + """Stacked, a failure lands below the fold too — and looks like nothing ran.""" + from bambu_cli.errors import BambuError + + _install_ready_settings(tmp_path) + stl = _make_stl(tmp_path) + steps = GoSteps(download=Recorder(), slice=Recorder(raises=BambuError("slicer failed: disk on fire", exit_code=4))) + + app = PlateApp(_args(), _deps(steps)) + async with app.run_test(size=_NARROW) as pilot: + await _settle(pilot) + screen = await _open_prepare(pilot) + status = screen.query_one("#prepare-status", Static) + assert not app.screen.region.contains_region(status.region) # below the fold + + await _submit_source(pilot, screen, stl) + await pilot.pause() + + assert "disk on fire" in _text(status) + assert app.screen.region.contains_region(status.region) diff --git a/tests/test_tui_settings.py b/tests/test_tui_settings.py index 4e235fd..ec7987f 100644 --- a/tests/test_tui_settings.py +++ b/tests/test_tui_settings.py @@ -126,8 +126,20 @@ async def _settle(pilot): def _text(widget) -> str: + """Plain text of a widget's renderable — Rich grids included. + + ``str(table)`` is a repr, not the rendered rows, so a grid-backed panel + (the preview) has to go through a Console to be asserted on at all. + """ renderable = getattr(widget, "renderable", "") - return renderable if isinstance(renderable, str) else str(renderable) + if isinstance(renderable, str): + return renderable + from rich.console import Console + + console = Console(width=200) + with console.capture() as capture: + console.print(renderable) + return capture.get() # ---------------------------------------------------------------------------