From 2903b8825da7bbf6352bf5966f44f3196fca83f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=B0=D0=BD=D0=B8=D0=B8=D0=BB=20=D0=93=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=B0=D1=80=D1=8C?= Date: Thu, 21 May 2026 11:05:08 +1000 Subject: [PATCH] Enhance progress reporting in setup and deletion processes across multiple scripts --- cobot/commands/delete.py | 29 ++++-- cobot/commands/doc_setup.py | 61 +++++++++---- cobot/commands/docker_setup.py | 109 +++++++++++++++++------ cobot/commands/local_setup.py | 155 +++++++++++++++++++++++++++++---- cobot/commands/setup.py | 62 +++++++++---- cobot/commands/update.py | 15 +++- cobot/tui.py | 27 +++++- 7 files changed, 361 insertions(+), 97 deletions(-) diff --git a/cobot/commands/delete.py b/cobot/commands/delete.py index 42e380f..6bf9bce 100644 --- a/cobot/commands/delete.py +++ b/cobot/commands/delete.py @@ -94,15 +94,30 @@ def _remove_project_dir(write) -> None: def _task_delete(screen: LogScreen, remove_ros: bool) -> None: try: - _stop_docker_containers(screen.write) - _remove_docker_images(screen.write) - if remove_ros: + # stop(0-20) images(20-45) ros2(45-70) cobot(70-85) dir(85-100) + screen.set_progress(0, "Stopping containers...") + _stop_docker_containers(screen.write) + screen.set_progress(20, "Removing Docker images...") + _remove_docker_images(screen.write) + screen.set_progress(45, "Removing ROS2 Jazzy...") _remove_ros2(screen.write) + screen.set_progress(70, "Uninstalling cobot CLI...") + _uninstall_cobot(screen.write) + screen.set_progress(85, "Removing project directory...") + _remove_project_dir(screen.write) + else: + # stop(0-25) images(25-60) cobot(60-85) dir(85-100) + screen.set_progress(0, "Stopping containers...") + _stop_docker_containers(screen.write) + screen.set_progress(25, "Removing Docker images...") + _remove_docker_images(screen.write) + screen.set_progress(60, "Uninstalling cobot CLI...") + _uninstall_cobot(screen.write) + screen.set_progress(85, "Removing project directory...") + _remove_project_dir(screen.write) - _uninstall_cobot(screen.write) - _remove_project_dir(screen.write) - + screen.set_progress(100, "Done") screen.write("\n[green]Project fully removed.[/green]") screen.finish(True) @@ -143,7 +158,7 @@ class _DeleteApp(App[None]): def _on_ros_choice(self, choice: Optional[str]) -> None: remove_ros = choice is not None and choice.startswith("Yes") self.push_screen( - LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros)), + LogScreen("Deleting project", lambda s: _task_delete(s, remove_ros), show_progress=True), lambda _: self.exit(), ) diff --git a/cobot/commands/doc_setup.py b/cobot/commands/doc_setup.py index 201b117..bfdaf4c 100644 --- a/cobot/commands/doc_setup.py +++ b/cobot/commands/doc_setup.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import os +import re import shutil import subprocess import sys @@ -21,7 +22,6 @@ _DEFAULT_PORT = "8000" Write = Callable[[str], None] - def _docker(*args: str, capture: bool = False) -> subprocess.CompletedProcess: return subprocess.run(["docker", *args], capture_output=capture, text=True) @@ -35,22 +35,31 @@ def _image_exists() -> bool: return bool(_docker("images", "-q", _IMAGE_NAME, capture=True).stdout.strip()) -def _build_docs_image(write: Write) -> bool: +def _build_docs_image( + write: Write, + on_progress: Optional[Callable[[float], None]] = None, +) -> bool: write("[cyan][*][/cyan] Building documentation image (runs once)...") env = {**os.environ, "DOCKER_BUILDKIT": "0"} - result = subprocess.run( + proc = subprocess.Popen( ["docker", "build", "-t", _IMAGE_NAME, str(_DOC_DIR)], - capture_output=True, text=True, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, ) - if result.returncode != 0: - for line in (result.stdout + result.stderr).splitlines(): - if line.strip(): - write(line) - write("[red]Image build failed.[/red]") - return False - write("[green][ok][/green] Documentation image ready") - return True - + for line in proc.stdout: + s = line.rstrip() + if s: + write(s) + if on_progress: + m = re.match(r"Step (\d+)/(\d+) :", line) + if m: + step, total = int(m.group(1)), int(m.group(2)) + on_progress(step / total * 100) + proc.wait() + if proc.returncode == 0: + write("[green][ok][/green] Documentation image ready") + return True + write("[red]Image build failed.[/red]") + return False def _task_up(screen: LogScreen, port: str) -> None: @@ -67,12 +76,17 @@ def _task_up(screen: LogScreen, port: str) -> None: return if not _image_exists(): - if not _build_docs_image(screen.write): + screen.set_progress(0, "Building documentation image...") + if not _build_docs_image( + screen.write, + on_progress=lambda p: screen.set_progress(p * 0.85, "Building documentation image..."), + ): screen.finish(False) return else: screen.write("[dim]Documentation image already built, skipping.[/dim]") + screen.set_progress(88, "Starting MkDocs server...") screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") result = _docker( "run", "-d", "--name", _CONTAINER_NAME, "--rm", @@ -86,6 +100,7 @@ def _task_up(screen: LogScreen, port: str) -> None: screen.finish(False) return + screen.set_progress(100, "Server running") screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") screen.write(" Edit files in [bold]doc/lwc-doc/docs/[/bold] — reloads automatically.") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") @@ -102,8 +117,10 @@ def _task_down(screen: LogScreen) -> None: screen.write("[yellow]Docs container is not running.[/yellow]") screen.finish(True) return + screen.set_progress(30, "Stopping container...") screen.write("[cyan][*][/cyan] Stopping documentation server...") _docker("stop", _CONTAINER_NAME) + screen.set_progress(100, "Done") screen.write("[green][ok][/green] Container stopped.") screen.finish(True) except Exception as exc: @@ -114,19 +131,26 @@ def _task_down(screen: LogScreen) -> None: def _task_rebuild(screen: LogScreen, port: str) -> None: try: if _is_running(): + screen.set_progress(5, "Stopping container...") screen.write("[cyan][*][/cyan] Stopping existing container...") _docker("stop", _CONTAINER_NAME) screen.write("[green][ok][/green] Stopped.") if _image_exists(): + screen.set_progress(15, "Removing old image...") screen.write("[cyan][*][/cyan] Removing old image...") _docker("rmi", "-f", _IMAGE_NAME) screen.write("[green][ok][/green] Image removed.") - if not _build_docs_image(screen.write): + screen.set_progress(20, "Building documentation image...") + if not _build_docs_image( + screen.write, + on_progress=lambda p: screen.set_progress(20 + p * 0.68, "Building documentation image..."), + ): screen.finish(False) return + screen.set_progress(90, "Starting MkDocs server...") screen.write("\n[cyan][*][/cyan] Starting MkDocs server...") result = _docker( "run", "-d", "--name", _CONTAINER_NAME, "--rm", @@ -140,6 +164,7 @@ def _task_rebuild(screen: LogScreen, port: str) -> None: screen.finish(False) return + screen.set_progress(100, "Server running") screen.write(f"\n[green]Docs running at:[/green] http://localhost:{port}") screen.write(" Stop with: [bold]cobot doc-setup down[/bold]") screen.finish(True) @@ -159,7 +184,7 @@ class _DocApp(App[None]): def on_mount(self) -> None: if self._action == "down": self.push_screen( - LogScreen("Documentation server", _task_down), + LogScreen("Documentation server", _task_down, show_progress=True), lambda _: self.exit(), ) elif self._action == "rebuild": @@ -179,7 +204,7 @@ class _DocApp(App[None]): return p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT self.push_screen( - LogScreen("Documentation server", lambda s: _task_up(s, p)), + LogScreen("Documentation server", lambda s: _task_up(s, p), show_progress=True), lambda _: self.exit(), ) @@ -189,7 +214,7 @@ class _DocApp(App[None]): return p = (port.strip() or _DEFAULT_PORT) if port.isdigit() or not port.strip() else _DEFAULT_PORT self.push_screen( - LogScreen("Documentation server — rebuild", lambda s: _task_rebuild(s, p)), + LogScreen("Documentation server — rebuild", lambda s: _task_rebuild(s, p), show_progress=True), lambda _: self.exit(), ) diff --git a/cobot/commands/docker_setup.py b/cobot/commands/docker_setup.py index 88454f4..412d7db 100644 --- a/cobot/commands/docker_setup.py +++ b/cobot/commands/docker_setup.py @@ -44,21 +44,13 @@ class _Config: hub_repo: str -def _run_quiet(cmd: List[str], write: Write, env=None) -> bool: - result = subprocess.run( - cmd, capture_output=True, text=True, - env=env or os.environ, - ) - if result.returncode != 0: - for line in (result.stdout + result.stderr).splitlines(): - if line.strip(): - write(line) - return result.returncode == 0 - - def _build_image( - name: str, tag: str, dockerfile: Path, ctx: Path, + name: str, + tag: str, + dockerfile: Path, + ctx: Path, write: Write, + on_progress: Optional[Callable[[float], None]] = None, parent_tag: Optional[str] = None, build_type: str = "release", ) -> bool: @@ -71,34 +63,78 @@ def _build_image( if parent_tag: cmd += ["--build-arg", f"IMAGE={parent_tag}"] cmd.append(str(ctx)) - ok = _run_quiet(cmd, write, env) - if ok: + + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env, + ) + for line in proc.stdout: + s = line.rstrip() + if s: + write(s) + if on_progress: + m = re.match(r"Step (\d+)/(\d+) :", line) + if m: + step, total = int(m.group(1)), int(m.group(2)) + on_progress(step / total * 100) + proc.wait() + if proc.returncode == 0: write(f"[green][ok][/green] {name}") - else: - write(f"[red]Build failed:[/red] {tag}") - return ok + return True + write(f"[red]Build failed:[/red] {tag}") + return False -def _pull_image(name: str, tag: str, write: Write) -> bool: +def _pull_image( + name: str, + tag: str, + write: Write, + on_progress: Optional[Callable[[float], None]] = None, +) -> bool: write(f"[cyan][*][/cyan] Pulling [bold]{name}[/bold] ({tag})...") - ok = _run_quiet(["docker", "pull", tag], write) - if ok: + if on_progress: + on_progress(5) + + proc = subprocess.Popen( + ["docker", "pull", tag], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + layers_total = 0 + layers_done = 0 + for line in proc.stdout: + s = line.rstrip() + if s: + write(s) + if "Pulling fs layer" in line or "Waiting" in line: + layers_total += 1 + elif "Pull complete" in line or "Already exists" in line: + layers_done += 1 + if on_progress and layers_total > 0: + on_progress(5 + layers_done / layers_total * 90) + proc.wait() + if proc.returncode == 0: write(f"[green][ok][/green] {name}") - else: - write(f"[red]Pull failed:[/red] {tag}") - return ok + if on_progress: + on_progress(100) + return True + write(f"[red]Pull failed:[/red] {tag}") + return False def _task_execute(screen: LogScreen, cfg: _Config) -> None: try: chain = _WEBOTS_CHAIN if cfg.variant == "webots" else _CONTROLLER_CHAIN + n = len(chain) if cfg.source == "build": screen.write( - f"[bold]Building {len(chain)} image(s) — " + f"[bold]Building {n} image(s) — " f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n" ) - for name in chain: + for i, name in enumerate(chain): + lo = i / n * 100 + hi = (i + 1) / n * 100 + screen.set_progress(lo, f"Image {i + 1}/{n}: building {name}...") + tag = f"{cfg.image_prefix}:{name}-{cfg.ros_version}" dockerfile = _DOCKER_DIR / cfg.ros_version / name / "Dockerfile" if not dockerfile.exists(): @@ -111,9 +147,19 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: f"{cfg.image_prefix}:{parent_name}-{cfg.ros_version}" if parent_name else None ) - if not _build_image(name, tag, dockerfile, ctx, screen.write, parent_tag, cfg.build_type): + if not _build_image( + name, tag, dockerfile, ctx, screen.write, + on_progress=lambda p, lo=lo, hi=hi: screen.set_progress( + lo + p * (hi - lo) / 100, f"Image {i + 1}/{n}: building {name}..." + ), + parent_tag=parent_tag, + build_type=cfg.build_type, + ): screen.finish(False) return + screen.set_progress(hi) + + screen.set_progress(100, "All images built") screen.write( f"\n[green]Done.[/green] " f"Images tagged [bold]{cfg.image_prefix}:-{cfg.ros_version}[/bold]." @@ -127,9 +173,14 @@ def _task_execute(screen: LogScreen, cfg: _Config) -> None: f"[bold]Pulling from {cfg.hub_repo} — " f"ROS {cfg.ros_version} — {cfg.build_type}[/bold]\n" ) - if not _pull_image(short, full_ref, screen.write): + screen.set_progress(0, f"Pulling {full_ref}...") + if not _pull_image( + short, full_ref, screen.write, + on_progress=lambda p: screen.set_progress(p, f"Pulling {full_ref}..."), + ): screen.finish(False) return + screen.set_progress(100, "Pull complete") screen.write(f"\n[green]Done.[/green] Image ready: [bold]{full_ref}[/bold].") screen.finish(True) @@ -254,7 +305,7 @@ class _Wizard(App[None]): else f"Pulling Docker image — ROS {cfg.ros_version}" ) self.push_screen( - LogScreen(title, lambda screen: _task_execute(screen, cfg)), + LogScreen(title, lambda screen: _task_execute(screen, cfg), show_progress=True), lambda _: self.exit(), ) diff --git a/cobot/commands/local_setup.py b/cobot/commands/local_setup.py index 536bad3..ddeca4f 100644 --- a/cobot/commands/local_setup.py +++ b/cobot/commands/local_setup.py @@ -5,6 +5,7 @@ import os import shutil import subprocess import tempfile +import threading import urllib.request from pathlib import Path from typing import Callable, List, Optional @@ -72,6 +73,53 @@ def _run_logged(cmd: List[str], write: Write, env: dict | None = None, cwd=None) raise RuntimeError(f"Command failed: {cmd[0]}") +def _run_apt_with_progress( + cmd: List[str], + write: Write, + on_progress: Callable[[float], None], + env: dict | None = None, +) -> None: + """Run an apt command and feed real percentage from APT::Status-Fd to on_progress(0-100).""" + r_fd, w_fd = os.pipe() + try: + proc = subprocess.Popen( + cmd + [f"-o", f"APT::Status-Fd={w_fd}"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + env=env or os.environ, + pass_fds=(w_fd,), + ) + finally: + os.close(w_fd) + + def _read_status() -> None: + with os.fdopen(r_fd, "r") as f: + for line in f: + # Format: dlstatus:N:PCT:MSG or pmstatus:NAME:PCT:MSG + parts = line.strip().split(":", 3) + if len(parts) >= 3: + try: + on_progress(float(parts[2])) + except ValueError: + pass + + t = threading.Thread(target=_read_status, daemon=True) + t.start() + for line in proc.stdout: + s = line.rstrip() + if s: + write(s) + proc.wait() + t.join() + if proc.returncode != 0: + raise RuntimeError(f"Command failed: {cmd[0]}") + + +# --------------------------------------------------------------------------- +# Installation steps +# --------------------------------------------------------------------------- + def _setup_locale(write: Write) -> None: write("[cyan][*][/cyan] Checking locale...") if "UTF-8" in subprocess.run(["locale"], capture_output=True, text=True).stdout: @@ -85,29 +133,37 @@ def _setup_locale(write: Write) -> None: write("[green][ok][/green] Locale configured") -def _add_ros2_repo(write: Write) -> None: +def _add_ros2_repo(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: + def _prog(p: float) -> None: + if on_progress: + on_progress(p) + write("[cyan][*][/cyan] Adding ROS2 apt repository...") - # Best-effort update before installing prereqs (ignore errors from broken repos) + # Best-effort update before installing prereqs subprocess.run(["sudo", "apt-get", "update", "-qq"], capture_output=True) + _prog(15) _run_quiet( ["sudo", "apt-get", "install", "-y", "--no-install-recommends", "software-properties-common", "curl", "gnupg"], write, _APT_ENV, ) + _prog(35) _run_quiet(["sudo", "add-apt-repository", "-y", "universe"], write) + _prog(45) - # Always re-download and dearmor the key to fix any previous bad install write("[cyan][*][/cyan] Downloading ROS2 signing key...") with tempfile.NamedTemporaryFile(delete=False, suffix=".key") as tmp: tmp_path = tmp.name try: urllib.request.urlretrieve(_ROS_KEY_URL, tmp_path) + _prog(60) _run_quiet(["sudo", "gpg", "--dearmor", "--yes", "-o", str(_ROS_KEYRING), tmp_path]) finally: os.unlink(tmp_path) write("[green][ok][/green] Signing key installed") + _prog(65) arch = subprocess.check_output(["dpkg", "--print-architecture"], text=True).strip() codename = subprocess.check_output( @@ -123,18 +179,26 @@ def _add_ros2_repo(write: Write) -> None: ) if proc.returncode != 0: raise RuntimeError(f"Failed to write {_ROS_SOURCES}") + _prog(70) write("[cyan][*][/cyan] Updating apt cache...") - _run_quiet(["sudo", "apt-get", "update", "-q"], write, _APT_ENV) + _run_apt_with_progress( + ["sudo", "apt-get", "update", "-q"], + write, + lambda p: _prog(70 + p * 0.30), + _APT_ENV, + ) write("[green][ok][/green] ROS2 repository ready") + _prog(100) -def _install_ros2_jazzy(write: Write) -> None: +def _install_ros2_jazzy(write: Write, on_progress: Optional[Callable[[float], None]] = None) -> None: write("[cyan][*][/cyan] Installing ros-jazzy-desktop and ros-dev-tools...") - _run_quiet( - ["sudo", "apt-get", "install", "-y", - "ros-jazzy-desktop", "ros-dev-tools"], - write, _APT_ENV, + _run_apt_with_progress( + ["sudo", "apt-get", "install", "-y", "ros-jazzy-desktop", "ros-dev-tools"], + write, + on_progress or (lambda _: None), + _APT_ENV, ) write("[green][ok][/green] ROS2 Jazzy Desktop installed") @@ -164,14 +228,44 @@ def _setup_shell_rc(write: Write) -> None: write(f"[green][ok][/green] Added ROS2 setup to ~/{rc.name}") +# --------------------------------------------------------------------------- +# Background tasks (run inside LogScreen worker) +# --------------------------------------------------------------------------- + def _task_install_jazzy(screen: LogScreen) -> None: try: - screen.write("[bold]Installing ROS2 Jazzy[/bold]\n") + # Step 1 — locale (0 → 5 %) + screen.set_progress(0, "Setting up locale...") + screen.write("[bold]Step 1 / 5 — Locale[/bold]") _setup_locale(screen.write) - _add_ros2_repo(screen.write) - _install_ros2_jazzy(screen.write) + + # Step 2 — ROS2 repo (5 → 20 %) + screen.set_progress(5, "Adding ROS2 repository...") + screen.write("\n[bold]Step 2 / 5 — ROS2 repository[/bold]") + _add_ros2_repo( + screen.write, + on_progress=lambda p: screen.set_progress(5 + p * 0.15), + ) + + # Step 3 — ROS2 Jazzy (20 → 85 %) + screen.set_progress(20, "Installing ROS2 Jazzy Desktop...") + screen.write("\n[bold]Step 3 / 5 — ROS2 Jazzy Desktop[/bold]") + _install_ros2_jazzy( + screen.write, + on_progress=lambda p: screen.set_progress(20 + p * 0.65), + ) + + # Step 4 — colcon (85 → 92 %) + screen.set_progress(85, "Installing colcon...") + screen.write("\n[bold]Step 4 / 5 — colcon[/bold]") _install_colcon(screen.write) + + # Step 5 — shell rc (92 → 100 %) + screen.set_progress(92, "Configuring shell...") + screen.write("\n[bold]Step 5 / 5 — Shell configuration[/bold]") _setup_shell_rc(screen.write) + screen.set_progress(100, "Done") + screen.write( "\nRestart the terminal, then run [bold]cobot local-setup[/bold] again to build." ) @@ -188,12 +282,27 @@ def _task_build(screen: LogScreen) -> None: screen.write("Source ROS2 first: [bold]source /opt/ros/jazzy/setup.bash[/bold]") screen.finish(False) return - screen.write("[bold]Building project with colcon[/bold]\n") - _run_logged( - ["colcon", "build", "--symlink-install"], - screen.write, - cwd=_PROJECT_DIR, + + # Count packages so we can show X/total progress + list_result = subprocess.run( + ["colcon", "list"], capture_output=True, text=True, cwd=_PROJECT_DIR, ) + total = max(len([l for l in list_result.stdout.splitlines() if l.strip()]), 1) + + screen.write(f"[bold]Building {total} package(s) with colcon[/bold]\n") + screen.set_progress(0, f"0 / {total} packages done") + built = 0 + + def _track(line: str) -> None: + nonlocal built + screen.write(line) + if "Finished <<<" in line or "Failed <<<" in line: + built += 1 + screen.set_progress(built / total * 100, f"{built} / {total} packages done") + + _run_logged(["colcon", "build", "--symlink-install"], _track, cwd=_PROJECT_DIR) + + screen.set_progress(100, "Build complete") screen.write("\nActivate workspace: [bold]source install/setup.bash[/bold]") screen.finish(True) except Exception as exc: @@ -201,6 +310,10 @@ def _task_build(screen: LogScreen) -> None: screen.finish(False) +# --------------------------------------------------------------------------- +# Textual apps +# --------------------------------------------------------------------------- + class _InstallJazzyApp(App[None]): CSS = SCREEN_CSS @@ -220,7 +333,7 @@ class _InstallJazzyApp(App[None]): self.exit() return self.push_screen( - LogScreen("Installing ROS2 Jazzy", _task_install_jazzy), + LogScreen("Installing ROS2 Jazzy", _task_install_jazzy, show_progress=True), lambda _: self.exit(), ) @@ -230,7 +343,7 @@ class _BuildApp(App[None]): def on_mount(self) -> None: self.push_screen( - LogScreen("Building project", _task_build), + LogScreen("Building project", _task_build, show_progress=True), lambda _: self.exit(), ) @@ -250,6 +363,10 @@ class _DockerPromptApp(App[bool]): ) +# --------------------------------------------------------------------------- +# CLI registration +# --------------------------------------------------------------------------- + def register(subparsers: argparse._SubParsersAction) -> None: p = subparsers.add_parser( "local-setup", diff --git a/cobot/commands/setup.py b/cobot/commands/setup.py index c21013b..1f82ea3 100644 --- a/cobot/commands/setup.py +++ b/cobot/commands/setup.py @@ -1,5 +1,5 @@ import argparse -from typing import Optional +from typing import List, Optional from textual.app import App @@ -10,40 +10,66 @@ from cobot.commands.robot_setup import run as _robot_setup from cobot.tui import SCREEN_CSS, PickScreen -class _AskSetupMode(App[Optional[str]]): +class _Ask(App[Optional[str]]): + """Single-question picker that exits immediately with the chosen value.""" CSS = SCREEN_CSS + def __init__(self, step: str, question: str, options: List[str], default: str): + super().__init__() + self._step = step + self._question = question + self._options = options + self._default = default + def on_mount(self) -> None: self.push_screen( - PickScreen( - "Step 1 of 1", - "How do you want to set up the project?", - [ - "local-setup — install ROS2 Jazzy on this machine and build with colcon", - "docker-setup — build a Docker image with ROS2 Jazzy pre-installed", - ], - "local-setup — install ROS2 Jazzy on this machine and build with colcon", - ), + PickScreen(self._step, self._question, self._options, self._default), self.exit, ) +def _ask(step: str, question: str, options: List[str], default: str) -> Optional[str]: + return _Ask(step, question, options, default).run() + + def register(subparsers): p = subparsers.add_parser("setup", help="First-time project setup") p.set_defaults(func=run) def run(args: argparse.Namespace) -> None: - - _doc_setup(args) - - choice = _AskSetupMode().run() - if choice is None: + # Step 1 — documentation + v = _ask("Step 1 of 3", "Set up the documentation server?", ["Yes", "No"], "Yes") + if v is None: return + if v == "Yes": + _doc_setup(args) - if choice.startswith("local"): + # Step 2 — build environment + v = _ask( + "Step 2 of 3", + "How do you want to set up the build environment?", + [ + "local-setup — install ROS2 Jazzy on this machine and build with colcon", + "docker-setup — build a Docker image with ROS2 Jazzy pre-installed", + ], + "local-setup — install ROS2 Jazzy on this machine and build with colcon", + ) + if v is None: + return + if v.startswith("local"): _local_setup(args) else: _docker_setup(args) - _robot_setup(args) + # Step 3 — robot parameters + v = _ask( + "Step 3 of 3", + "Configure robot parameters (cobot-setting.yaml)?", + ["Yes", "No"], + "Yes", + ) + if v is None: + return + if v == "Yes": + _robot_setup(args) diff --git a/cobot/commands/update.py b/cobot/commands/update.py index c0fee25..edcd646 100644 --- a/cobot/commands/update.py +++ b/cobot/commands/update.py @@ -20,7 +20,8 @@ def _task_update(screen: LogScreen) -> None: ).strip() screen.write(f"[cyan][*][/cyan] Branch: [bold]{branch}[/bold]") - # Fetch + # Fetch (0 → 30 %) + screen.set_progress(0, "Fetching from remote...") screen.write("[cyan][*][/cyan] Fetching from remote...") fetch = subprocess.run( ["git", "fetch", "origin"], @@ -30,6 +31,7 @@ def _task_update(screen: LogScreen) -> None: screen.write(f"[red]Fetch failed:[/red] {fetch.stderr.strip()}") screen.finish(False) return + screen.set_progress(30) # Check how many commits behind behind = subprocess.check_output( @@ -38,6 +40,7 @@ def _task_update(screen: LogScreen) -> None: ).strip() if behind == "0": + screen.set_progress(100, "Already up to date") screen.write("[green][ok][/green] Already up to date.") screen.finish(True) return @@ -51,7 +54,8 @@ def _task_update(screen: LogScreen) -> None: for line in log_lines: screen.write(f" [dim]{line}[/dim]") - # Pull + # Pull (30 → 80 %) + screen.set_progress(30, "Pulling changes...") screen.write("\n[cyan][*][/cyan] Pulling changes...") pull = subprocess.run( ["git", "pull", "origin", branch], @@ -64,8 +68,10 @@ def _task_update(screen: LogScreen) -> None: screen.write("[red]Pull failed.[/red]") screen.finish(False) return + screen.set_progress(80) - # Reinstall in case dependencies changed + # Reinstall (80 → 100 %) + screen.set_progress(80, "Reinstalling cobot CLI...") screen.write("\n[cyan][*][/cyan] Reinstalling cobot CLI...") reinstall = subprocess.run( ["uv", "tool", "install", "--editable", str(_PROJECT_DIR)], @@ -76,6 +82,7 @@ def _task_update(screen: LogScreen) -> None: else: screen.write(f"[yellow]Warning:[/yellow] reinstall failed — {reinstall.stderr.strip()}") + screen.set_progress(100, "Done") screen.write("\n[green]Project updated successfully.[/green]") screen.finish(True) @@ -89,7 +96,7 @@ class _UpdateApp(App[None]): def on_mount(self) -> None: self.push_screen( - LogScreen("Updating project", _task_update), + LogScreen("Updating project", _task_update, show_progress=True), lambda _: self.exit(), ) diff --git a/cobot/tui.py b/cobot/tui.py index e5c43a3..a621713 100644 --- a/cobot/tui.py +++ b/cobot/tui.py @@ -6,7 +6,7 @@ from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.screen import Screen -from textual.widgets import Footer, Input, LoadingIndicator, RadioButton, RadioSet, RichLog, Static +from textual.widgets import Footer, Input, LoadingIndicator, ProgressBar, RadioButton, RadioSet, RichLog, Static SCREEN_CSS = """ Screen { @@ -36,6 +36,15 @@ RadioSet { Input { margin-bottom: 1; } +LogScreen #progress { + margin-top: 1; + height: 1; +} +LogScreen #step-label { + color: $text-muted; + text-style: dim; + margin-bottom: 1; +} LogScreen #log { height: 1fr; border: none; @@ -135,14 +144,18 @@ class LogScreen(Screen[bool]): BINDINGS = [Binding("enter,escape", "close", "Close", show=False)] - def __init__(self, title: str, task: Callable[[LogScreen], None]): + def __init__(self, title: str, task: Callable[[LogScreen], None], show_progress: bool = False): super().__init__() self._title = title self._run_fn = task self._finished = False + self._show_progress = show_progress def compose(self) -> ComposeResult: yield Static(self._title, id="step") + if self._show_progress: + yield ProgressBar(id="progress", total=100, show_eta=False) + yield Static("", id="step-label") yield RichLog(id="log", highlight=True, markup=True, wrap=True) yield LoadingIndicator(id="loading") yield Static("", id="hint") @@ -152,6 +165,16 @@ class LogScreen(Screen[bool]): self.query_one(RichLog).focus() self.app.run_worker(lambda: self._run_fn(self), thread=True) + def set_progress(self, pct: float, label: str = "") -> None: + """Thread-safe: update the progress bar and optional step label.""" + if self._show_progress: + self.app.call_from_thread(self._do_set_progress, pct, label) + + def _do_set_progress(self, pct: float, label: str) -> None: + self.query_one("#progress", ProgressBar).progress = pct + if label: + self.query_one("#step-label", Static).update(label) + def write(self, line: str) -> None: """Thread-safe: append a line to the log.""" self.app.call_from_thread(self._append, line)