Repository: Textualize/textual

Issue:
Markup colors are getting reset with a newline character
### Markup colors are being reset after a newline character
An example of output showcasing the bug.
The second line should be white
<img width="199" alt="Image" src="https://github.com/user-attachments/assets/d5ef7472-2df0-4a42-9100-3dc4cce054db" />

### Example program to demonstrate bug:
```from textual.app import App, ComposeResult
from textual.widgets import Label


class LabelApp(App):
    def compose(self) -> ComposeResult:
        yield Label("[#ff0000]Hello, [#ffffff]world!\nMy work here is done.")


if __name__ == "__main__":
    app = LabelApp()
    app.run()
```
### What I expect
The second and subsequent lines should be white.

# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 3.2.0  |
| Rich    | 14.0.0 |

## Python

| Name           | Value                                            |
|----------------|--------------------------------------------------|
| Version        | 3.13.3                                           |
| Implementation | CPython                                          |
| Compiler       | Clang 16.0.0 (clang-1600.0.26.6)                 |
| Executable     | /Users/djp3/Downloads/temp/sample/bin/python3.13 |

## Operating System

| Name    | Value                                                                                                  |
|---------|--------------------------------------------------------------------------------------------------------|
| System  | Darwin                                                                                                 |
| Release | 24.5.0                                                                                                 |
| Version | Darwin Kernel Version 24.5.0: Tue Apr 22 19:53:27 PDT 2025; root:xnu-11417.121.6~2/RELEASE_ARM64_T6041 |

## Terminal

| Name                 | Value              |
|----------------------|--------------------|
| Terminal Application | iTerm.app (3.5.13) |
| TERM                 | xterm-256color     |
| COLORTERM            | truecolor          |
| FORCE_COLOR          | *Not set*          |
| NO_COLOR             | *Not set*          |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=212, height=79 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 212                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 79                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |


# Thanks for working on this framework.  It rocks!

Discussion:
@github-actions: We found the following entries in the [FAQ](https://textual.textualize.io/FAQ/) which you may find helpful:


- [How do I center a widget in a screen?](https://textual.textualize.io/FAQ/#how-do-i-center-a-widget-in-a-screen)
- [Why doesn't Textual look good on macOS?](https://textual.textualize.io/FAQ/#why-doesnt-textual-look-good-on-macos)

Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

This is an automated reply, generated by [FAQtory](https://github.com/willmcgugan/faqtory)@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1be1fb908..041c7ff433 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Fixed `VERTICAL_BREAKPOINTS` doesn't work https://github.com/Textualize/textual/pull/5785
 - Fixed `Button` allowing text selection https://github.com/Textualize/textual/pull/5770
+- Fixed running `App.run` after `asyncio.run` https://github.com/Textualize/textual/pull/5799
+- Fixed triggering a deprecation warning in py >= 3.10 https://github.com/Textualize/textual/pull/5799
 - Fixed `Input` invalid cursor position after updating the value https://github.com/Textualize/textual/issues/5811
 
 ### Added
diff --git a/src/textual/_animator.py b/src/textual/_animator.py
index 6d13c288c2..75927ec9a4 100644
--- a/src/textual/_animator.py
+++ b/src/textual/_animator.py
@@ -10,6 +10,7 @@
 
 from textual import _time
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._easing import DEFAULT_EASING, EASING
 from textual._types import AnimationLevel, CallbackType
 from textual.timer import Timer
@@ -242,11 +243,16 @@ def __init__(self, app: App, frames_per_second: int = 60) -> None:
             callback=self,
             pause=True,
         )
+
+    @cached_property
+    def _idle_event(self) -> asyncio.Event:
         """The timer that runs the animator."""
-        self._idle_event = asyncio.Event()
+        return asyncio.Event()
+
+    @cached_property
+    def _complete_event(self) -> asyncio.Event:
         """Flag if no animations are currently taking place."""
-        self._complete_event = asyncio.Event()
-        """Flag if no animations are currently taking place and none are scheduled."""
+        return asyncio.Event()
 
     async def start(self) -> None:
         """Start the animator task."""
diff --git a/src/textual/_compat.py b/src/textual/_compat.py
new file mode 100644
index 0000000000..32d7d7d136
--- /dev/null
+++ b/src/textual/_compat.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+import sys
+from typing import Any, Generic, TypeVar, overload
+
+if sys.version_info >= (3, 12):
+    from functools import cached_property
+else:
+    # based on the code from Python 3.14:
+    # https://github.com/python/cpython/blob/
+    # 5507eff19c757a908a2ff29dfe423e35595fda00/Lib/functools.py#L1089-L1138
+    # Copyright (C) 2006 Python Software Foundation.
+    # vendored under the PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 because
+    # prior to Python 3.12 cached_property used a threading.Lock, which makes
+    # it very slow.
+    _T_co = TypeVar("_T_co", covariant=True)
+    _NOT_FOUND = object()
+
+    class cached_property(Generic[_T_co]):
+        def __init__(self, func: Callable[[Any, _T_co]]) -> None:
+            self.func = func
+            self.attrname = None
+            self.__doc__ = func.__doc__
+            self.__module__ = func.__module__
+
+        def __set_name__(self, owner: type[any], name: str) -> None:
+            if self.attrname is None:
+                self.attrname = name
+            elif name != self.attrname:
+                raise TypeError(
+                    "Cannot assign the same cached_property to two different names "
+                    f"({self.attrname!r} and {name!r})."
+                )
+
+        @overload
+        def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
+
+        @overload
+        def __get__(
+            self, instance: object, owner: type[Any] | None = None
+        ) -> _T_co: ...
+
+        def __get__(
+            self, instance: object, owner: type[Any] | None = None
+        ) -> _T_co | Self:
+            if instance is None:
+                return self
+            if self.attrname is None:
+                raise TypeError(
+                    "Cannot use cached_property instance without calling __set_name__ on it."
+                )
+            try:
+                cache = instance.__dict__
+            except (
+                AttributeError
+            ):  # not all objects have __dict__ (e.g. class defines slots)
+                msg = (
+                    f"No '__dict__' attribute on {type(instance).__name__!r} "
+                    f"instance to cache {self.attrname!r} property."
+                )
+                raise TypeError(msg) from None
+            val = cache.get(self.attrname, _NOT_FOUND)
+            if val is _NOT_FOUND:
+                val = self.func(instance)
+                try:
+                    cache[self.attrname] = val
+                except TypeError:
+                    msg = (
+                        f"The '__dict__' attribute on {type(instance).__name__!r} instance "
+                        f"does not support item assignment for caching {self.attrname!r} property."
+                    )
+                    raise TypeError(msg) from None
+            return val
diff --git a/src/textual/_markup_playground.py b/src/textual/_markup_playground.py
index 720e2d68d4..5005016111 100644
--- a/src/textual/_markup_playground.py
+++ b/src/textual/_markup_playground.py
@@ -4,25 +4,21 @@
 from textual.app import App, ComposeResult
 from textual.content import Content
 from textual.reactive import reactive
-from textual.widgets import Static, TextArea
+from textual.widgets import Footer, Pretty, Static, TextArea
 
 
 class MarkupPlayground(App):
 
     TITLE = "Markup Playground"
     CSS = """
-    Screen {
-        & > * {
-            margin: 0 1;
-            height: 1fr;
-        }
+    Screen {        
         layout: vertical;
         #editor {            
-            width: 2fr;
+            width: 1fr;
             height: 1fr;
             border: tab $foreground 50%;  
             padding: 1;
-            margin: 1 1 0 0;
+            margin: 1 0 0 0;
             &:focus {
                 border: tab $primary;  
             }
@@ -48,16 +44,35 @@ class MarkupPlayground(App):
             }
             overflow-y: auto;
         }
-        #results {            
-            
+        #results {                        
             padding: 1 1;            
+            width: 1fr;
+        }
+        #spans-container {
+            border: tab $success;                
+            overflow-y: auto;
+            margin: 0 0 0 1;
+        }
+        #spans {
+            padding: 1 1;      
+            width: 1fr;                
+        }
+        HorizontalGroup {
+            height: 1fr;
         }
     }
     """
     AUTO_FOCUS = "#editor"
 
+    BINDINGS = [
+        ("f1", "toggle('show_variables')", "Variables"),
+        ("f2", "toggle('show_spans')", "Spans"),
+    ]
     variables: reactive[dict[str, object]] = reactive({})
 
+    show_variables = reactive(False)
+    show_spans = reactive(False)
+
     def compose(self) -> ComposeResult:
         with containers.HorizontalGroup():
             yield (editor := TextArea(id="editor"))
@@ -65,11 +80,21 @@ def compose(self) -> ComposeResult:
         editor.border_title = "Markup"
         variables.border_title = "Variables (JSON)"
 
-        with containers.VerticalScroll(
-            id="results-container", can_focus=False
-        ) as container:
-            yield Static(id="results")
-        container.border_title = "Output"
+        with containers.HorizontalGroup():
+            with containers.VerticalScroll(id="results-container") as container:
+                yield Static(id="results")
+                container.border_title = "Output"
+            with containers.VerticalScroll(id="spans-container") as container:
+                yield Pretty([], id="spans")
+                container.border_title = "Spans"
+
+        yield Footer()
+
+    def watch_show_variables(self, show_variables: bool) -> None:
+        self.query_one("#variables").display = show_variables
+
+    def watch_show_spans(self, show_spans: bool) -> None:
+        self.query_one("#spans-container").display = show_spans
 
     @on(TextArea.Changed, "#editor")
     def on_markup_changed(self, event: TextArea.Changed) -> None:
@@ -78,13 +103,16 @@ def on_markup_changed(self, event: TextArea.Changed) -> None:
     def update_markup(self) -> None:
         results = self.query_one("#results", Static)
         editor = self.query_one("#editor", TextArea)
+        spans = self.query_one("#spans", Pretty)
         try:
             content = Content.from_markup(editor.text, **self.variables)
             results.update(content)
-        except Exception as error:
+            spans.update(content.spans)
+        except Exception:
             from rich.traceback import Traceback
 
             results.update(Traceback())
+            spans.update([])
 
             self.query_one("#results-container").add_class("-error").scroll_end(
                 animate=False
diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py
index 3936290d21..ed4163fec6 100644
--- a/src/textual/_xterm_parser.py
+++ b/src/textual/_xterm_parser.py
@@ -260,14 +260,11 @@ def send_escape() -> None:
                     # Check cursor position report
                     cursor_position_match = _re_cursor_position.match(sequence)
                     if cursor_position_match is not None:
-                        row, column = cursor_position_match.groups()
-                        # Cursor position report conflicts with f3 key
-                        # If it is a keypress, "row" will be 1, so ignore
-                        if int(row) != 1:
-                            x = int(column) - 1
-                            y = int(row) - 1
-                            on_token(events.CursorPosition(x, y))
-                            break
+                        row, column = map(int, cursor_position_match.groups())
+                        x = int(column) - 1
+                        y = int(row) - 1
+                        on_token(events.CursorPosition(x, y))
+                        break
 
                     # Was it a pressed key event that we received?
                     key_events = list(sequence_to_key_events(sequence))
diff --git a/src/textual/app.py b/src/textual/app.py
index 9ae50e7421..ba87005aba 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -74,6 +74,7 @@
 from textual._ansi_sequences import SYNC_END, SYNC_START
 from textual._ansi_theme import ALABASTER, MONOKAI
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._compose import compose
 from textual._compositor import CompositorUpdate
 from textual._context import active_app, active_message_pump
@@ -150,6 +151,9 @@
 if constants.DEBUG:
     warnings.simplefilter("always", ResourceWarning)
 
+# `asyncio.get_event_loop()` is deprecated since Python 3.10:
+_ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED = sys.version_info >= (3, 10, 0)
+
 ComposeResult = Iterable[Widget]
 RenderResult: TypeAlias = "RenderableType | Visual | SupportsVisual"
 """Result of Widget.render()"""
@@ -645,9 +649,6 @@ def __init__(
         """The unhandled exception which is leading to the app shutting down,
         or None if the app is still running with no unhandled exceptions."""
 
-        self._exception_event: asyncio.Event = asyncio.Event()
-        """An event that will be set when the first exception is encountered."""
-
         self.title = (
             self.TITLE if self.TITLE is not None else f"{self.__class__.__name__}"
         )
@@ -841,6 +842,11 @@ def __init__(
                     )
                 )
 
+    @cached_property
+    def _exception_event(self) -> asyncio.Event:
+        """An event that will be set when the first exception is encountered."""
+        return asyncio.Event()
+
     def __init_subclass__(cls, *args, **kwargs) -> None:
         for variable_name, screen_collection in (
             ("SCREENS", cls.SCREENS),
@@ -2140,9 +2146,9 @@ def run(
             App return value.
         """
 
-        async def run_app() -> None:
+        async def run_app() -> ReturnType | None:
             """Run the app."""
-            await self.run_async(
+            return await self.run_async(
                 headless=headless,
                 inline=inline,
                 inline_no_clear=inline_no_clear,
@@ -2151,9 +2157,24 @@ async def run_app() -> None:
                 auto_pilot=auto_pilot,
             )
 
-        event_loop = asyncio.get_event_loop() if loop is None else loop
-        event_loop.run_until_complete(run_app())
-        return self.return_value
+        if loop is None:
+            if _ASYNCIO_GET_EVENT_LOOP_IS_DEPRECATED:
+                # N.B. This does work with Python<3.10, but global Locks, Events, etc
+                # eagerly bind the event loop, and result in Future bound to wrong
+                # loop errors.
+                return asyncio.run(run_app())
+            try:
+                global_loop = asyncio.get_event_loop()
+            except RuntimeError:
+                # the global event loop may have been destroyed by someone running
+                # asyncio.run(), or asyncio.set_event_loop(None), in which case
+                # we need to use asyncio.run() also. (We run this outside the
+                # context of an exception handler)
+                pass
+            else:
+                return global_loop.run_until_complete(run_app())
+            return asyncio.run(run_app())
+        return loop.run_until_complete(run_app())
 
     async def _on_css_change(self) -> None:
         """Callback for the file monitor, called when CSS files change."""
diff --git a/src/textual/markup.py b/src/textual/markup.py
index 2d92ae7353..06a0effd98 100644
--- a/src/textual/markup.py
+++ b/src/textual/markup.py
@@ -411,14 +411,13 @@ def process_text(template_text: str, /) -> str:
 
     content_text = "".join(text)
     text_length = len(content_text)
-    while style_stack:
-        position, tag_body, _ = style_stack.pop()
+    for position, tag_body, _ in style_stack:
         spans.append(Span(position, text_length, tag_body))
 
-    if style:
-        content = Content(content_text, [Span(0, len(content_text), style), *spans])
-    else:
-        content = Content(content_text, spans)
+    content = Content(
+        content_text,
+        [Span(0, len(content_text), style), *spans] if style else spans,
+    )
 
     return content
 
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 9ec49f9047..7a1e3bde7d 100644
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -11,8 +11,9 @@
 from __future__ import annotations
 
 import asyncio
+import sys
 import threading
-from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task
+from asyncio import CancelledError, QueueEmpty, Task, create_task
 from contextlib import contextmanager
 from functools import partial
 from time import perf_counter
@@ -22,15 +23,18 @@
     Awaitable,
     Callable,
     Generator,
+    Generic,
     Iterable,
     Type,
     TypeVar,
     cast,
+    overload,
 )
 from weakref import WeakSet
 
 from textual import Logger, events, log, messages
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._context import NoActiveAppError, active_app, active_message_pump
 from textual._context import message_hook as message_hook_context_var
 from textual._context import prevent_message_types_stack
@@ -114,7 +118,6 @@ class MessagePump(metaclass=_MessagePumpMeta):
     """Base class which supplies a message pump."""
 
     def __init__(self, parent: MessagePump | None = None) -> None:
-        self._message_queue: Queue[Message | None] = Queue()
         self._parent = parent
         self._running: bool = False
         self._closing: bool = False
@@ -125,7 +128,6 @@ def __init__(self, parent: MessagePump | None = None) -> None:
         self._timers: WeakSet[Timer] = WeakSet()
         self._last_idle: float = time()
         self._max_idle: float | None = None
-        self._mounted_event = asyncio.Event()
         self._is_mounted = False
         """Having this explicit Boolean is an optimization.
 
@@ -143,6 +145,14 @@ def __init__(self, parent: MessagePump | None = None) -> None:
         
         """
 
+    @cached_property
+    def _message_queue(self) -> asyncio.Queue[Message | None]:
+        return asyncio.Queue()
+
+    @cached_property
+    def _mounted_event(self) -> asyncio.Event:
+        return asyncio.Event()
+
     @property
     def _prevent_message_types_stack(self) -> list[set[type[Message]]]:
         """The stack that manages prevented messages."""
diff --git a/src/textual/timer.py b/src/textual/timer.py
index 998662a24e..f4b7b1ac1c 100644
--- a/src/textual/timer.py
+++ b/src/textual/timer.py
@@ -15,6 +15,7 @@
 
 from textual import _time, events
 from textual._callback import invoke
+from textual._compat import cached_property
 from textual._context import active_app
 from textual._time import sleep
 from textual._types import MessageTarget
@@ -62,11 +63,16 @@ def __init__(
         self._callback = callback
         self._repeat = repeat
         self._skip = skip
-        self._active = Event()
         self._task: Task | None = None
         self._reset: bool = False
-        if not pause:
-            self._active.set()
+        self._original_pause = pause
+
+    @cached_property
+    def _active(self) -> Event:
+        event = Event()
+        if not self._original_pause:
+            event.set()
+        return event
 
     def __rich_repr__(self) -> Result:
         yield self._interval


Test patch:
diff --git a/tests/test_app.py b/tests/test_app.py
index 782b99e671..3c6d54aed8 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -367,3 +367,20 @@ def on_mount(self) -> None:
     app = MyApp()
     result = await app.run_async()
     assert result == 42
+
+
+def test_app_loop_run_after_asyncio_run() -> None:
+    """Test that App.run runs after asyncio.run has run."""
+
+    class MyApp(App[int]):
+        def on_mount(self) -> None:
+            self.exit(42)
+
+    async def amain():
+        pass
+
+    asyncio.run(amain())
+
+    app = MyApp()
+    result = app.run()
+    assert result == 42
diff --git a/tests/test_markup.py b/tests/test_markup.py
index 32032bf5bd..718c61752c 100644
--- a/tests/test_markup.py
+++ b/tests/test_markup.py
@@ -77,6 +77,13 @@
                 ],
             ),
         ),
+        (
+            "[#ff0000]Hello, [#ffffff]world!\nMy work here is done.",
+            Content(
+                "Hello, world!\nMy work here is done.",
+                spans=[Span(0, 35, style="#ff0000"), Span(7, 35, style="#ffffff")],
+            ),
+        ),
     ],
 )
 def test_to_content(markup: str, content: Content):