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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 10 additions & 13 deletions bambu_cli/tui/screens/confirm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
155 changes: 114 additions & 41 deletions bambu_cli/tui/screens/prepare.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
65 changes: 65 additions & 0 deletions bambu_cli/tui/styles.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
37 changes: 37 additions & 0 deletions bambu_cli/tui/widgets/summary.py
Original file line number Diff line number Diff line change
@@ -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
Loading