Repository: Textualize/textual
Pull request: capture print
Fixes https://github.com/Textualize/textual/issues/2952

Implements a mechanism to capture prints (or writes to stdout / stderr).

Problem statement:
Capture  prints
At the moment we capture the output of any `print` statements, so as not to write over the Textual app. Anything printed will go to the dev tools if enabled, or dev/null if not.

This is a sensible default, but sometimes devs want to be able to capture that content and write it to a widget (for example). This can be a requirement if you working with existing code that may have printed to the terminal.

To make that possible, we should have an optional way for an app to capture data that might have otherwise been swallowed.

Please propose an API to @willmcgugan before attempting this.

Hints from issue discussion:

Commit message: docstring

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abec9fa6d4..db92053027 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Added
+
+- Added App.capture_print, App.end_capture_print, Widget.capture_print, Widget.end_capture_print https://github.com/Textualize/textual/issues/2952
+
 ## [0.30.0] - 2023-07-17
 
 ### Added
diff --git a/src/textual/app.py b/src/textual/app.py
index 378bd9bcbe..80f8c68134 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -47,7 +47,7 @@
     cast,
     overload,
 )
-from weakref import WeakSet
+from weakref import WeakKeyDictionary, WeakSet
 
 import rich
 import rich.repr
@@ -101,6 +101,8 @@
     from textual_dev.client import DevtoolsClient
     from typing_extensions import Coroutine, TypeAlias
 
+    from ._types import MessageTarget
+
     # Unused & ignored imports are needed for the docs to link to these objects:
     from .css.query import WrongType  # type: ignore  # noqa: F401
     from .message import Message
@@ -206,6 +208,37 @@ def isatty(self) -> bool:
         return True
 
 
+class _PrintCapture:
+    """A file-like which captures output."""
+
+    def __init__(self, app: App, stderr: bool = False) -> None:
+        """
+
+        Args:
+            app: App instance.
+            stderr: Write from stderr.
+        """
+        self.app = app
+        self.stderr = stderr
+
+    def write(self, text: str) -> None:
+        """Captured write.
+
+        Args:
+            text: Text that was "printed".
+        """
+        self.app._print(text, stderr=self.stderr)
+
+    def flush(self) -> None:
+        """Stdout or stderr was flushed."""
+        self.app._flush(stderr=self.stderr)
+
+    def isatty(self) -> bool:
+        """Pretend we're a terminal."""
+        # TODO: should this be configurable?
+        return True
+
+
 @rich.repr.auto
 class App(Generic[ReturnType], DOMNode):
     """The base class for Textual Applications."""
@@ -432,14 +465,17 @@ def __init__(
         self._composed: list[list[Widget]] = []
 
         self.devtools: DevtoolsClient | None = None
+        self._devtools_redirector: StdoutRedirector | None = None
         if "devtools" in self.features:
             try:
                 from textual_dev.client import DevtoolsClient
+                from textual_dev.redirect_output import StdoutRedirector
             except ImportError:
                 # Dev dependencies not installed
                 pass
             else:
                 self.devtools = DevtoolsClient()
+                self._devtools_redirector = StdoutRedirector(self.devtools)
 
         self._loop: asyncio.AbstractEventLoop | None = None
         self._return_value: ReturnType | None = None
@@ -456,9 +492,16 @@ def __init__(
         self._dom_lock = asyncio.Lock()
         self._dom_ready = False
         self._batch_count = 0
+        self._notifications = Notifications()
+
+        self._capture_print: WeakKeyDictionary[
+            MessageTarget, tuple[bool, bool]
+        ] = WeakKeyDictionary()
+        self._capture_stdout = _PrintCapture(self, stderr=False)
+        self._capture_stderr = _PrintCapture(self, stderr=True)
+
         self.set_class(self.dark, "-dark-mode")
         self.set_class(not self.dark, "-light-mode")
-        self._notifications = Notifications()
 
     def validate_title(self, title: Any) -> str:
         """Make sure the title is set to a string."""
@@ -1026,6 +1069,54 @@ async def _press_keys(self, keys: Iterable[str]) -> None:
                 await app._animator.wait_until_complete()
                 await wait_for_idle(0)
 
+    def _flush(self, stderr: bool = False) -> None:
+        """Called when stdout or stderr is flushed.
+
+        Args:
+            stderr: True if the print was to stderr, or False for stdout.
+
+        """
+        if self._devtools_redirector is not None:
+            self._devtools_redirector.flush()
+
+    def _print(self, text: str, stderr: bool = False) -> None:
+        """Called with capture print.
+
+        Args:
+            text: Text that has been printed.
+            stderr: True if the print was to stderr, or False for stdout.
+        """
+        if self._devtools_redirector is not None:
+            self._devtools_redirector.write(text)
+        for target, (_stdout, _stderr) in self._capture_print.items():
+            if (_stderr and stderr) or (_stdout and not stderr):
+                target.post_message(events.Print(text, stderr=stderr))
+
+    def capture_print(
+        self, target: MessageTarget, stdout: bool = True, stderr: bool = True
+    ) -> None:
+        """Capture content that is printed (or written to stdout / stderr).
+
+        If printing is captured, the `target` will be send an [events.Print][textual.events.Print] message.
+
+        Args:
+            target: The widget where print content will be sent.
+            stdout: Capture stdout.
+            stderr: Capture stderr.
+        """
+        if not stdout and not stderr:
+            self.end_capture_print(target)
+        else:
+            self._capture_print[target] = (stdout, stderr)
+
+    def end_capture_print(self, target: MessageTarget) -> None:
+        """End capturing of prints.
+
+        Args:
+            target: The widget that was capturing prints.
+        """
+        self._capture_print.pop(target)
+
     @asynccontextmanager
     async def run_test(
         self,
@@ -1972,24 +2063,9 @@ async def invoke_ready_callback() -> None:
             if not self._exit:
                 driver.start_application_mode()
                 try:
-                    if headless:
-                        await run_process_messages()
-                    else:
-                        if self.devtools is not None:
-                            devtools = self.devtools
-                            assert devtools is not None
-                            from textual_dev.redirect_output import StdoutRedirector
-
-                            redirector = StdoutRedirector(devtools)
-                            with redirect_stderr(redirector):
-                                with redirect_stdout(redirector):  # type: ignore
-                                    await run_process_messages()
-                        else:
-                            with open(os.devnull, "w") as null_file:
-                                with redirect_stdout(null_file), redirect_stderr(
-                                    null_file
-                                ):
-                                    await run_process_messages()
+                    with redirect_stdout(self._capture_stdout):
+                        with redirect_stderr(self._capture_stderr):
+                            await run_process_messages()
 
                 finally:
                     driver.stop_application_mode()
diff --git a/src/textual/events.py b/src/textual/events.py
index 4e2523535d..1be8f9f54e 100644
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -600,3 +600,22 @@ class ScreenSuspend(Event, bubble=False):
     - [ ] Bubbles
     - [ ] Verbose
     """
+
+
+@rich.repr.auto
+class Print(Event, bubble=False):
+    """Sent to a widget that is capturing prints.
+
+    - [ ] Bubbles
+    - [ ] Verbose
+
+    Args:
+        text: Text that was printed.
+        stderr: True if the print was to stderr, or False for stdout.
+
+    """
+
+    def __init__(self, text: str, stderr: bool = False) -> None:
+        super().__init__()
+        self.text = text
+        self.stderr = stderr
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 9f08f165b0..72b257cf37 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3128,6 +3128,23 @@ def release_mouse(self) -> None:
         """
         self.app.capture_mouse(None)
 
+    def capture_print(self, stdout: bool = True, stderr: bool = True) -> None:
+        """Capture text from print statements (or writes to stdout / stderr).
+
+        If printing is captured, the widget will be send an [events.Print][textual.events.Print] message.
+
+        Call [end_capture_print][textual.widget.Widget.end_capture_print] to disable print capture.
+
+        Args:
+            stdout: Capture stdout.
+            stderr: Capture stderr.
+        """
+        self.app.capture_print(self, stdout=stdout, stderr=stderr)
+
+    def end_capture_print(self) -> None:
+        """End print capture (set with [capture_print][textual.widget.Widget.capture_print])."""
+        self.app.end_capture_print(self)
+
     def check_message_enabled(self, message: Message) -> bool:
         """Check if a given message is enabled (allowed to be sent).
 
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 3dc7e52277..7ee20313f6 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -20618,6 +20618,161 @@
   
   '''
 # ---
+# name: test_print_capture
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-4109994713-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-4109994713-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-4109994713-r1 { fill: #e1e1e1 }
+  .terminal-4109994713-r2 { fill: #c5c8c6 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-4109994713-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-4109994713-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-4109994713-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-4109994713-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">CaptureApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-4109994713-clip-terminal)">
+      <rect fill="#1e1e1e" x="0" y="1.5" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="85.4" y="1.5" width="866.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="1.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="268.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="268.4" y="25.9" width="683.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="25.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="50.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="74.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="99.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="318.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="465.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="489.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="513.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="538.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="562.7" width="951.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="951.6" y="562.7" width="24.4" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-4109994713-matrix">
+      <text class="terminal-4109994713-r1" x="0" y="20" textLength="85.4" clip-path="url(#terminal-4109994713-line-0)">TextLog</text><text class="terminal-4109994713-r2" x="976" y="20" textLength="12.2" clip-path="url(#terminal-4109994713-line-0)">
+  </text><text class="terminal-4109994713-r1" x="0" y="44.4" textLength="268.4" clip-path="url(#terminal-4109994713-line-1)">This&#160;will&#160;be&#160;captured!</text><text class="terminal-4109994713-r2" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-4109994713-line-1)">
+  </text><text class="terminal-4109994713-r2" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-4109994713-line-2)">
+  </text><text class="terminal-4109994713-r2" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-4109994713-line-3)">
+  </text><text class="terminal-4109994713-r2" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-4109994713-line-4)">
+  </text><text class="terminal-4109994713-r2" x="976" y="142" textLength="12.2" clip-path="url(#terminal-4109994713-line-5)">
+  </text><text class="terminal-4109994713-r2" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-4109994713-line-6)">
+  </text><text class="terminal-4109994713-r2" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-4109994713-line-7)">
+  </text><text class="terminal-4109994713-r2" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-4109994713-line-8)">
+  </text><text class="terminal-4109994713-r2" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-4109994713-line-9)">
+  </text><text class="terminal-4109994713-r2" x="976" y="264" textLength="12.2" clip-path="url(#terminal-4109994713-line-10)">
+  </text><text class="terminal-4109994713-r2" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-4109994713-line-11)">
+  </text><text class="terminal-4109994713-r2" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-4109994713-line-12)">
+  </text><text class="terminal-4109994713-r2" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-4109994713-line-13)">
+  </text><text class="terminal-4109994713-r2" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-4109994713-line-14)">
+  </text><text class="terminal-4109994713-r2" x="976" y="386" textLength="12.2" clip-path="url(#terminal-4109994713-line-15)">
+  </text><text class="terminal-4109994713-r2" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-4109994713-line-16)">
+  </text><text class="terminal-4109994713-r2" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-4109994713-line-17)">
+  </text><text class="terminal-4109994713-r2" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-4109994713-line-18)">
+  </text><text class="terminal-4109994713-r2" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-4109994713-line-19)">
+  </text><text class="terminal-4109994713-r2" x="976" y="508" textLength="12.2" clip-path="url(#terminal-4109994713-line-20)">
+  </text><text class="terminal-4109994713-r2" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-4109994713-line-21)">
+  </text><text class="terminal-4109994713-r2" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-4109994713-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_programmatic_scrollbar_gutter_change
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/snapshot_apps/capture_print.py b/tests/snapshot_tests/snapshot_apps/capture_print.py
new file mode 100644
index 0000000000..dc3afc573f
--- /dev/null
+++ b/tests/snapshot_tests/snapshot_apps/capture_print.py
@@ -0,0 +1,27 @@
+from textual.app import App, ComposeResult
+from textual import events
+from textual.widgets import TextLog
+
+
+class PrintLogger(TextLog):
+    """A TextLog which captures printed text."""
+
+    def on_print(self, event: events.Print) -> None:
+        self.write(event.text)
+
+
+class CaptureApp(App):
+    def compose(self) -> ComposeResult:
+        yield PrintLogger()
+
+    def on_mount(self) -> None:
+        self.query_one(TextLog).write("TextLog")
+        self.query_one(TextLog).capture_print()
+        print("This will be captured!")
+        self.query_one(TextLog).end_capture_print()
+        print("This will *not* be captured")
+
+
+if __name__ == "__main__":
+    app = CaptureApp()
+    app.run()
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 08553015cd..cb37533d70 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -559,8 +559,10 @@ async def run_before(pilot) -> None:
 
     assert snap_compare(SNAPSHOT_APPS_DIR / "tooltips.py", run_before=run_before)
 
+
 # --- textual-dev library preview tests ---
 
+
 def test_textual_dev_border_preview(snap_compare):
     assert snap_compare(SNAPSHOT_APPS_DIR / "dev_previews_border.py", press=["enter"])
 
@@ -580,8 +582,14 @@ def test_textual_dev_keys_preview(snap_compare):
 def test_notifications_example(snap_compare) -> None:
     assert snap_compare(WIDGET_EXAMPLES_DIR / "toast.py")
 
+
 def test_notifications_through_screens(snap_compare) -> None:
     assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_screens.py")
 
+
 def test_notifications_through_modes(snap_compare) -> None:
     assert snap_compare(SNAPSHOT_APPS_DIR / "notification_through_modes.py")
+
+
+def test_print_capture(snap_compare) -> None:
+    assert snap_compare(SNAPSHOT_APPS_DIR / "capture_print.py")


Code review comments:
diff_hunk: @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## Unreleased
+
+### Added
+
+- Added App.capture_print, App.end_capture_print, Widget.capture_print, Widget.end_capture_print https://github.com/Textualize/textual/issues/2952
path: CHANGELOG.md
text: @user1:
I feel we should add the same methods to `Screen` too.

@author:
The screen is a Widget, so it will inherit those methods.

@user1:
Damn, good point. I keep forgetting that. Amusingly screen and widget both now have a notify method. Note to self: remove the wrapper on Screen.

diff_hunk: @@ -1026,6 +1069,54 @@ async def _press_keys(self, keys: Iterable[str]) -> None:
                 await app._animator.wait_until_complete()
                 await wait_for_idle(0)
 
+    def _flush(self, stderr: bool = False) -> None:
+        """Called when stdout or stderr is flushed.
+
+        Args:
+            stderr: True if the print was to stderr, or False for stdout.
+
+        """
+        if self._devtools_redirector is not None:
+            self._devtools_redirector.flush()
+
+    def _print(self, text: str, stderr: bool = False) -> None:
+        """Called with capture print.
+
+        Args:
+            text: Text that has been printed.
+            stderr: True if the print was to stderr, or False for stdout.
+        """
+        if self._devtools_redirector is not None:
+            self._devtools_redirector.write(text)
+        for target, (_stdout, _stderr) in self._capture_print.items():
+            if (_stderr and stderr) or (_stdout and not stderr):
+                target.post_message(events.Print(text, stderr=stderr))
+
+    def capture_print(
path: src/textual/app.py
text: @user1:
More to note than anything else, but I feel this might be better as `begin_capture_print`; it mirrors `end_capture_print`, makes the intent more clear, and then later allows for `capture_print` to be used as a context.

@author:
Yeah, although there is a odd inconsistency then. There is a existing `capture_mouse` which isn't a context manager.