Repository: pdm-project/pdm
Pull request #3100 (2024-08-13 09:11:13)

feat: polish the ui looking of locking packages
Signed-off-by: Frost Ming <me@frostming.com>

## Pull Request Checklist

- [ ] A news fragment is added in `news/` describing what is new.
- [ ] Test cases added for changed code.

## Describe what you have changed in this PR.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) Report
Attention: Patch coverage is `78.66667%` with `16 lines` in your changes missing coverage. Please review.
> Project coverage is 85.67%. Comparing base [(`096fe03`)](https://app.codecov.io/gh/pdm-project/pdm/commit/096fe03f76b60b8676e202a568672eefccd793f7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) to head [(`75fbc57`)](https://app.codecov.io/gh/pdm-project/pdm/commit/75fbc57de243914605f9090a9ad23d957c057b9d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Patch % | Lines |
|---|---|---|
| [src/pdm/cli/actions.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&filepath=src%2Fpdm%2Fcli%2Factions.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9jbGkvYWN0aW9ucy5weQ==) | 57.89% | [8 Missing :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/resolver/reporters.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&filepath=src%2Fpdm%2Fresolver%2Freporters.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9yZXNvbHZlci9yZXBvcnRlcnMucHk=) | 84.21% | [5 Missing and 1 partial :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |
| [src/pdm/installers/synchronizers.py](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&filepath=src%2Fpdm%2Finstallers%2Fsynchronizers.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#diff-c3JjL3BkbS9pbnN0YWxsZXJzL3N5bmNocm9uaXplcnMucHk=) | 33.33% | [0 Missing and 2 partials :warning: ](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3100      +/-   ##
==========================================
- Coverage   85.71%   85.67%   -0.04%     
==========================================
  Files         109      109              
  Lines       10975    11002      +27     
  Branches     2399     2400       +1     
==========================================
+ Hits         9407     9426      +19     
- Misses       1079     1086       +7     
- Partials      489      490       +1     
```

| [Flag](https://app.codecov.io/gh/pdm-project/pdm/pull/3100/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | Coverage Δ | |
|---|---|---|
| [unittests](https://app.codecov.io/gh/pdm-project/pdm/pull/3100/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project) | `85.47% <78.66%> (-0.04%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pdm-project/pdm/pull/3100?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pdm-project).


Patch:
diff --git a/news/3100.feature.md b/news/3100.feature.md
new file mode 100644
index 00000000..66a4e325
--- /dev/null
+++ b/news/3100.feature.md
@@ -0,0 +1,1 @@
+Polish the UI looking of locking packages to display the progress.
diff --git a/src/pdm/cli/actions.py b/src/pdm/cli/actions.py
index f0d6a617..6380108c 100644
--- a/src/pdm/cli/actions.py
+++ b/src/pdm/cli/actions.py
@@ -34,13 +34,13 @@ from pdm.models.specifiers import PySpecSet
 from pdm.project import Project
 from pdm.project.lockfile import FLAG_CROSS_PLATFORM, FLAG_DIRECT_MINIMAL_VERSIONS, FLAG_INHERIT_METADATA
 from pdm.resolver import resolve
+from pdm.resolver.reporters import RichLockReporter
 from pdm.termui import logger
 from pdm.utils import deprecation_warning
 
 if TYPE_CHECKING:
     from pdm.models.requirements import Requirement
     from pdm.resolver.providers import BaseProvider
-    from pdm.resolver.reporters import SpinnerReporter
 
 
 def do_lock(
@@ -123,9 +123,8 @@ def do_lock(
     with ui.logging("lock"):
         # The context managers are nested to ensure the spinner is stopped before
         # any message is thrown to the output.
-        try:
-            with ui.open_spinner(title="Resolving dependencies") as spin:
-                reporter = cast("SpinnerReporter", project.get_reporter(requirements, tracked_names, spin))
+        with RichLockReporter(requirements, ui) as reporter:
+            try:
                 for target in targets:
                     if supports_env_spec:
                         provider = project.get_provider(
@@ -142,34 +141,35 @@ def do_lock(
                             direct_minimal_versions=FLAG_DIRECT_MINIMAL_VERSIONS in lock_strategy,
                             ignore_compatibility=target.is_allow_all(),
                         )
+                    provider.repository.reporter = reporter
                     mapping = _lock_for_env(
                         project, target, provider, reporter, requirements, lock_strategy, resolve_max_rounds
                     )
                     locked_repo.merge_result(target, mapping.values(), provider.fetched_dependencies)
                     if result_repo is not locked_repo:
                         result_repo.merge_result(target, mapping.values(), provider.fetched_dependencies)
-        except ResolutionTooDeep:
-            ui.echo(f"{termui.Emoji.LOCK} Lock failed.", err=True)
-            ui.echo(
-                "The dependency resolution exceeds the maximum loop depth of "
-                f"{resolve_max_rounds}, there may be some circular dependencies "
-                "in your project. Try to solve them or increase the "
-                f"[success]`strategy.resolve_max_rounds`[/] config.",
-                err=True,
-            )
-            raise
-        except ResolutionImpossible as err:
-            ui.echo(f"{termui.Emoji.LOCK} Lock failed.", err=True)
-            ui.error(format_resolution_impossible(err))
-            raise ResolutionImpossible("Unable to find a resolution") from None
-        else:
-            groups = list(set(groups) | provider.repository.collected_groups)
-            provider.repository.collected_groups.clear()
-            data = result_repo.format_lockfile(groups=groups, strategy=lock_strategy)
-            if project.enable_write_lockfile:
-                ui.echo(f"{termui.Emoji.LOCK} Lock successful.")
-            project.write_lockfile(data, write=not dry_run)
-            hooks.try_emit("post_lock", resolution=result_repo.all_candidates, dry_run=dry_run)
+            except ResolutionTooDeep:
+                reporter.update(f"{termui.Emoji.LOCK} Lock failed.", info="", completed=1)
+                ui.echo(
+                    "The dependency resolution exceeds the maximum loop depth of "
+                    f"{resolve_max_rounds}, there may be some circular dependencies "
+                    "in your project. Try to solve them or increase the "
+                    f"[success]`strategy.resolve_max_rounds`[/] config.",
+                    err=True,
+                )
+                raise
+            except ResolutionImpossible as err:
+                reporter.update(f"{termui.Emoji.LOCK} Lock failed.", info="", completed=1)
+                ui.error(format_resolution_impossible(err))
+                raise ResolutionImpossible("Unable to find a resolution") from None
+            else:
+                groups = list(set(groups) | provider.repository.collected_groups)
+                provider.repository.collected_groups.clear()
+                data = result_repo.format_lockfile(groups=groups, strategy=lock_strategy)
+                if project.enable_write_lockfile:
+                    reporter.update(f"{termui.Emoji.LOCK} Lock successful.", info="", completed=1)
+                project.write_lockfile(data, write=not dry_run)
+                hooks.try_emit("post_lock", resolution=result_repo.all_candidates, dry_run=dry_run)
 
     return result_repo.all_candidates
 
@@ -178,12 +178,12 @@ def _lock_for_env(
     project: Project,
     env_spec: EnvSpec,
     provider: BaseProvider,
-    reporter: SpinnerReporter,
+    reporter: RichLockReporter,
     requirements: list[Requirement],
     lock_strategy: set[str],
     max_rounds: int,
 ) -> dict[str, Candidate]:
-    reporter.spinner.update(f"Resolve for environment {env_spec}")
+    reporter.update(f"Resolve for environment {env_spec}")
     requirements = [req for req in requirements if not req.marker or req.marker.matches(env_spec)]
     resolver: Resolver = project.core.resolver_class(provider, reporter)
     mapping, *_ = resolve(
@@ -193,6 +193,7 @@ def _lock_for_env(
         inherit_metadata=FLAG_INHERIT_METADATA in lock_strategy,
     )
     if project.enable_write_lockfile:
+        reporter.update(info="Fetching hashes for resolved packages")
         fetch_hashes(provider.repository, mapping.values())
     if not (env_python := PySpecSet(env_spec.requires_python)).is_superset(project.environment.python_requires):
         python_marker = get_marker(env_python.as_marker_string())
diff --git a/src/pdm/installers/core.py b/src/pdm/installers/core.py
index b5e7d786..a4bc3d4d 100644
--- a/src/pdm/installers/core.py
+++ b/src/pdm/installers/core.py
@@ -6,6 +6,7 @@ from pdm.environments import BaseEnvironment
 from pdm.installers.synchronizers import BaseSynchronizer
 from pdm.models.requirements import Requirement
 from pdm.resolver.core import resolve
+from pdm.resolver.reporters import LockReporter
 
 
 def install_requirements(
@@ -21,7 +22,7 @@ def install_requirements(
     # Disable this so installing self will not skip including dependencies
     provider.repository.find_dependencies_from_local = False
     reqs = [req for req in reqs if not req.marker or req.marker.matches(provider.repository.env_spec)]
-    reporter = project.get_reporter(reqs)
+    reporter = LockReporter()
     resolver = project.core.resolver_class(provider, reporter)
     resolve_max_rounds = int(project.config["strategy.resolve_max_rounds"])
     backend = project.backend
diff --git a/src/pdm/installers/synchronizers.py b/src/pdm/installers/synchronizers.py
index cc85b43e..d8f00535 100644
--- a/src/pdm/installers/synchronizers.py
+++ b/src/pdm/installers/synchronizers.py
@@ -13,7 +13,7 @@ from pdm.environments import BaseEnvironment
 from pdm.exceptions import BuildError, InstallationError
 from pdm.installers.manager import InstallManager
 from pdm.models.candidates import Candidate
-from pdm.models.reporter import BaseReporter, InstallationStatus, RichProgressReporter
+from pdm.models.reporter import CandidateReporter, InstallationStatus, RichProgressReporter
 from pdm.models.requirements import FileRequirement, Requirement, parse_requirement, strip_extras
 from pdm.utils import is_editable, normalize_name
 
@@ -233,7 +233,7 @@ class Synchronizer(BaseSynchronizer):
             progress.console.print(f"  [success]{termui.Emoji.SUCC}[/] Install {can.format()} successful")
         finally:
             progress.update(job, visible=False)
-            can.prepare(self.environment, BaseReporter())
+            can.prepare(self.environment, CandidateReporter())
         return can
 
     def update_candidate(self, key: str, progress: Progress) -> tuple[Distribution, Candidate]:
@@ -262,7 +262,7 @@ class Synchronizer(BaseSynchronizer):
             )
         finally:
             progress.update(job, visible=False)
-            can.prepare(self.environment, BaseReporter())
+            can.prepare(self.environment, CandidateReporter())
 
         return dist, can
 
diff --git a/src/pdm/models/candidates.py b/src/pdm/models/candidates.py
index b1be5129..78e79fba 100644
--- a/src/pdm/models/candidates.py
+++ b/src/pdm/models/candidates.py
@@ -18,7 +18,7 @@ from pdm.builders import EditableBuilder, WheelBuilder
 from pdm.compat import importlib_metadata as im
 from pdm.exceptions import BuildError, CandidateNotFound, InvalidPyVersion, PDMWarning, RequirementError
 from pdm.models.backends import get_backend, get_backend_by_spec
-from pdm.models.reporter import BaseReporter
+from pdm.models.reporter import CandidateReporter
 from pdm.models.requirements import (
     FileRequirement,
     Requirement,
@@ -280,10 +280,10 @@ class Candidate:
         """Format for output."""
         return f"[req]{self.name}[/] [warning]{self.version}[/]"
 
-    def prepare(self, environment: BaseEnvironment, reporter: BaseReporter | None = None) -> PreparedCandidate:
+    def prepare(self, environment: BaseEnvironment, reporter: CandidateReporter | None = None) -> PreparedCandidate:
         """Prepare the candidate for installation."""
         if self._prepared is None:
-            self._prepared = PreparedCandidate(self, environment, reporter=reporter or BaseReporter())
+            self._prepared = PreparedCandidate(self, environment, reporter=reporter or CandidateReporter())
         else:
             self._prepared.environment = environment
             if reporter is not None:
@@ -299,7 +299,7 @@ class PreparedCandidate:
 
     candidate: Candidate
     environment: BaseEnvironment
-    reporter: BaseReporter = dataclasses.field(default_factory=BaseReporter)
+    reporter: CandidateReporter = dataclasses.field(default_factory=CandidateReporter)
 
     def __post_init__(self) -> None:
         self.req = self.candidate.req
diff --git a/src/pdm/models/reporter.py b/src/pdm/models/reporter.py
index cc5165d8..a173f6f4 100644
--- a/src/pdm/models/reporter.py
+++ b/src/pdm/models/reporter.py
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
     from rich.progress import Progress, TaskID
 
 
-class BaseReporter:
+class CandidateReporter:
     def report_download(self, link: Any, completed: int, total: int | None) -> None:
         pass
 
@@ -30,7 +30,7 @@ class BaseReporter:
 
 
 @dataclass
-class RichProgressReporter(BaseReporter):
+class RichProgressReporter(CandidateReporter):
     progress: Progress
     task_id: TaskID
 
@@ -56,9 +56,9 @@ class InstallationStatus:
         self.console = get_console()
         self._spinner = Progress(
             SpinnerColumn(termui.SPINNER),
+            TimeElapsedColumn(),
             "{task.description}",
             MofNCompleteColumn(),
-            TimeElapsedColumn(),
             console=self.console,
         )
         self._spinner_task = self._spinner.add_task(text, total=None)
@@ -90,6 +90,7 @@ class InstallationStatus:
         self._spinner.update(
             self._spinner_task, total=total, completed=completed, advance=advance, description=description
         )
+        self.live.refresh()
 
     def start(self) -> None:
         """Start the progress display."""
diff --git a/src/pdm/models/repositories/base.py b/src/pdm/models/repositories/base.py
index 8c54da51..3c75390d 100644
--- a/src/pdm/models/repositories/base.py
+++ b/src/pdm/models/repositories/base.py
@@ -65,6 +65,8 @@ class BaseRepository:
             the current environment.
         :param env_spec: the environment specifier to filter the candidates.
         """
+        from pdm.resolver.reporters import LockReporter
+
         self.sources = sources
         self.environment = environment
         self._candidate_info_cache = environment.project.make_candidate_info_cache()
@@ -86,6 +88,7 @@ class BaseRepository:
             else:
                 env_spec = environment.spec
         self.env_spec = env_spec
+        self.reporter = LockReporter()
 
     def get_filtered_sources(self, req: Requirement) -> list[RepositoryConfig]:
         """Get matching sources based on the index attribute."""
@@ -138,7 +141,8 @@ class BaseRepository:
         project = self.environment.project
         link = Link.from_path(project.root)
         candidate = Candidate(requirement, project.name, link=link)
-        candidate.prepare(self.environment).metadata
+        with self.reporter.make_candidate_reporter(candidate) as reporter:
+            candidate.prepare(self.environment, reporter=reporter).metadata
         return candidate
 
     def _should_ignore_package_warning(self, requirement: Requirement) -> bool:
@@ -272,10 +276,11 @@ class BaseRepository:
 
     @cache_result
     def _get_dependencies_from_metadata(self, candidate: Candidate) -> CandidateMetadata:
-        prepared = candidate.prepare(self.environment)
-        deps = prepared.get_dependencies_from_metadata()
-        requires_python = candidate.requires_python
-        summary = prepared.metadata.metadata.get("Summary", "")
+        with self.reporter.make_candidate_reporter(candidate) as reporter:
+            prepared = candidate.prepare(self.environment, reporter=reporter)
+            deps = prepared.get_dependencies_from_metadata()
+            requires_python = candidate.requires_python
+            summary = prepared.metadata.metadata.get("Summary", "")
         return CandidateMetadata(deps, requires_python, summary)
 
     def _get_dependencies_from_local_package(self, candidate: Candidate) -> CandidateMetadata:
diff --git a/src/pdm/project/core.py b/src/pdm/project/core.py
index 08205bb6..823ab638 100644
--- a/src/pdm/project/core.py
+++ b/src/pdm/project/core.py
@@ -15,7 +15,6 @@ import tomlkit
 from pbs_installer import PythonVersion
 from tomlkit.items import Array
 
-from pdm import termui
 from pdm._types import NotSet, NotSetType, RepositoryConfig
 from pdm.compat import CompatibleSequence
 from pdm.exceptions import NoPythonVersion, PdmUsageError, ProjectError
@@ -45,14 +44,13 @@ from pdm.utils import (
 
 if TYPE_CHECKING:
     from findpython import Finder
-    from resolvelib.reporters import BaseReporter
 
-    from pdm._types import Spinner
     from pdm.core import Core
     from pdm.environments import BaseEnvironment
     from pdm.models.caches import CandidateInfoCache, HashCache, WheelCache
     from pdm.models.candidates import Candidate
     from pdm.resolver.providers import BaseProvider
+    from pdm.resolver.reporters import RichLockReporter
 
 
 PYENV_ROOT = os.path.expanduser(os.getenv("PYENV_ROOT", "~/.pyenv"))
@@ -500,11 +498,8 @@ class Project:
         return provider_class(repository=repository, direct_minimal_versions=direct_minimal_versions, **params)
 
     def get_reporter(
-        self,
-        requirements: list[Requirement],
-        tracked_names: Iterable[str] | None = None,
-        spinner: Spinner | None = None,
-    ) -> BaseReporter:
+        self, requirements: list[Requirement], tracked_names: Iterable[str] | None = None
+    ) -> RichLockReporter:  # pragma: no cover
         """Return the reporter object to construct a resolver.
 
         :param requirements: requirements to resolve
@@ -512,12 +507,9 @@ class Project:
         :param spinner: optional spinner object
         :returns: a reporter
         """
-        from pdm.resolver.reporters import SpinnerReporter
-
-        if spinner is None:
-            spinner = termui.SilentSpinner("")
+        from pdm.resolver.reporters import RichLockReporter
 
-        return SpinnerReporter(spinner, requirements)
+        return RichLockReporter(requirements, self.core.ui)
 
     def get_lock_metadata(self) -> dict[str, Any]:
         content_hash = "sha256:" + self.pyproject.content_hash("sha256")
diff --git a/src/pdm/resolver/reporters.py b/src/pdm/resolver/reporters.py
index a696bbfb..5f6acdf1 100644
--- a/src/pdm/resolver/reporters.py
+++ b/src/pdm/resolver/reporters.py
@@ -1,53 +1,42 @@
 from __future__ import annotations
 
-import logging
-from typing import TYPE_CHECKING
+from contextlib import contextmanager
+from typing import TYPE_CHECKING, Any, Generator
 
 from resolvelib import BaseReporter
+from rich import get_console
+from rich.live import Live
+from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TimeElapsedColumn
 
-from pdm import termui
+from pdm.models.reporter import CandidateReporter, RichProgressReporter
+from pdm.termui import SPINNER, UI, Verbosity, logger
 
 if TYPE_CHECKING:
     from resolvelib.resolvers import Criterion, RequirementInformation, State
+    from rich.console import Console, ConsoleOptions, RenderResult
 
     from pdm.models.candidates import Candidate
     from pdm.models.requirements import Requirement
 
 
-logger = logging.getLogger("pdm.termui")
-
-
 def log_title(title: str) -> None:
     logger.info("=" * 8 + " " + title + " " + "=" * 8)
 
 
-class SpinnerReporter(BaseReporter):
-    def __init__(self, spinner: termui.Spinner, requirements: list[Requirement]) -> None:
-        self.spinner = spinner
-        self.requirements = requirements
-        self._previous: dict[str, Candidate] | None = None
-
-    def starting_round(self, index: int) -> None:
-        log_title(f"Starting round {index}")
+class LockReporter(BaseReporter):
+    @contextmanager
+    def make_candidate_reporter(self, candidate: Candidate) -> Generator[CandidateReporter]:
+        yield CandidateReporter()
 
-    def starting(self) -> None:
-        """Called before the resolution actually starts."""
+    def starting(self) -> Any:
         log_title("Start resolving requirements")
-        for req in self.requirements:
-            logger.info("  " + req.as_line())
 
-    def ending_round(self, index: int, state: State) -> None:
-        """Called before each round of resolution ends.
-
-        This is NOT called if the resolution ends at this round. Use `ending`
-        if you want to report finalization. The index is zero-based.
-        """
-        log_title(f"Ending round {index}")
+    def adding_requirement(self, requirement: Requirement, parent: Candidate) -> None:
+        parent_line = f"(from {parent.name} {parent.version})" if parent else ""
+        logger.info("  Adding requirement %s%s", requirement.as_line(), parent_line)
 
-    def ending(self, state: State) -> None:
-        """Called before the resolution ends successfully."""
+    def ending(self, state: State) -> Any:
         log_title("Resolution Result")
-        logger.info("Stable pins:")
         if state.mapping:
             column_width = max(map(len, state.mapping.keys()))
             for k, can in state.mapping.items():
@@ -59,17 +48,83 @@ class SpinnerReporter(BaseReporter):
                     can_info = can.version
                 logger.info(f"  {k.rjust(column_width)} {can_info}")
 
-    def adding_requirement(self, requirement: Requirement, parent: Candidate) -> None:
-        """Called when adding a new requirement into the resolve criteria.
 
-        :param requirement: The additional requirement to be applied to filter
-            the available candidates.
-        :param parent: The candidate that requires ``requirement`` as a
-            dependency, or None if ``requirement`` is one of the root
-            requirements passed in from ``Resolver.resolve()``.
+class RichLockReporter(LockReporter):
+    def __init__(self, requirements: list[Requirement], ui: UI) -> None:
+        self.ui = ui
+        self.console = get_console()
+        self.requirements = requirements
+        self.progress = Progress(
+            "[progress.description]{task.description}",
+            "[info]{task.fields[text]}",
+            BarColumn(),
+            TaskProgressColumn(),
+            console=self.console,
+        )
+        self._spinner = Progress(
+            SpinnerColumn(SPINNER, style="primary"),
+            TimeElapsedColumn(),
+            "[bold]{task.description}",
+            "{task.fields[info]}",
+            console=self.console,
+        )
+        self._spinner_task = self._spinner.add_task("Resolving dependencies", info="", total=1)
+        self.live = Live(self)
+
+    @contextmanager
+    def make_candidate_reporter(self, candidate: Candidate) -> Generator[CandidateReporter]:
+        task_id = self.progress.add_task(f"Resolving {candidate.format()}", text="")
+        try:
+            yield RichProgressReporter(self.progress, task_id)
+        finally:
+            self.progress.update(task_id, visible=False)
+            if candidate._prepared:
+                candidate._prepared.reporter = CandidateReporter()
+
+    def update(self, description: str | None = None, info: str | None = None, completed: float | None = None) -> None:
+        self._spinner.update(self._spinner_task, description=description, info=info, completed=completed)
+        self.live.refresh()
+
+    def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:  # pragma: no cover
+        yield self._spinner
+        yield self.progress
+
+    def start(self) -> None:
+        """Start the progress display."""
+        if self.ui.verbosity < Verbosity.DETAIL:
+            self.live.start(refresh=True)
+
+    def stop(self) -> None:
+        """Stop the progress display."""
+        self.live.stop()
+        if not self.console.is_interactive:  # pragma: no cover
+            self.console.print()
+
+    def __enter__(self) -> RichLockReporter:
+        self.start()
+        return self
+
+    def __exit__(self, *args: Any) -> None:
+        self.stop()
+
+    def starting_round(self, index: int) -> None:
+        log_title(f"Starting round {index}")
+
+    def starting(self) -> None:
+        """Called before the resolution actually starts."""
+        log_title("Start resolving requirements")
+        for req in self.requirements:
+            logger.info("  " + req.as_line())
+
+    def ending_round(self, index: int, state: State) -> None:
+        """Called before each round of resolution ends.
+
+        This is NOT called if the resolution ends at this round. Use `ending`
+        if you want to report finalization. The index is zero-based.
         """
-        parent_line = f"(from {parent.name} {parent.version})" if parent else ""
-        logger.info("  Adding requirement %s%s", requirement.as_line(), parent_line)
+        resolved = len(state.mapping)
+        to_resolve = len(state.criteria) - resolved
+        self.update(info=f"[info]{resolved}[/] resolved, [info]{to_resolve}[/] to resolve")
 
     def rejecting_candidate(self, criterion: Criterion, candidate: Candidate) -> None:
         if not criterion.information:
@@ -88,8 +143,7 @@ class SpinnerReporter(BaseReporter):
 
     def pinning(self, candidate: Candidate) -> None:
         """Called when adding a candidate to the potential solution."""
-        self.spinner.update(f"Resolving: new pin {candidate.format()}")
-        logger.info("Pinning: %s %s", candidate.name, candidate.version)
+        logger.info("Adding new pin: %s %s", candidate.name, candidate.version)
 
     def resolving_conflicts(self, causes: list[RequirementInformation]) -> None:
         conflicts = sorted({f"  {req.as_line()} (from {parent if parent else 'project'})" for req, parent in causes})


Test patch:
diff --git a/tests/resolver/test_resolve.py b/tests/resolver/test_resolve.py
index 94fec143..31d69933 100644
--- a/tests/resolver/test_resolve.py
+++ b/tests/resolver/test_resolve.py
@@ -7,7 +7,7 @@ from pdm.models.markers import EnvSpec
 from pdm.models.requirements import parse_requirement
 from pdm.models.specifiers import PySpecSet
 from pdm.resolver import resolve as _resolve
-from pdm.resolver.reporters import SpinnerReporter
+from pdm.resolver.reporters import LockReporter
 from tests import FIXTURES
 
 
@@ -43,9 +43,8 @@ def resolve(project, repository):
         )
 
         ui = project.core.ui
-        with ui.open_spinner("Resolving dependencies") as spin, ui.logging("lock"):
-            reporter = SpinnerReporter(spin, requirements)
-            resolver = Resolver(provider, reporter)
+        with ui.logging("lock"):
+            resolver = Resolver(provider, LockReporter())
             mapping, *_ = _resolve(resolver, requirements, inherit_metadata=inherit_metadata)
             return mapping