Repository: Textualize/textual

Issue:
Implement bracketed paste mode
We need to implement "bracketed paste" mode to allow pasting in to text input.

I think they way it works is that you get a sequence that say that bracket paste mode has started, then a bunch of keys, then something to say it has ended. This should be translated in to a `events.Paste` containing the string of characters just pasted.


Discussion:

Patch:
diff --git a/sandbox/basic.css b/sandbox/basic.css
index c4c632736..ec6fcf886 100644
--- a/sandbox/basic.css
+++ b/sandbox/basic.css
@@ -42,7 +42,7 @@ App > Screen {
   background: $primary-background-darken-2;
   color: $text-primary-darken-2 ;
   border-right: outer $primary-darken-3;
-  content-align: center middle;  
+  content-align: center middle;
 }
 
 #sidebar .user {
@@ -71,14 +71,13 @@ App > Screen {
   color: $text-background;
   background: $background;
   layout: vertical;
-  overflow-y: scroll; 
+  overflow-y: scroll;
 }
 
 
 Tweet {
   height: 12;
   width: 80;
- 
   margin: 1 3;
   background: $panel;
   color: $text-panel;
@@ -106,9 +105,8 @@ Tweet {
 
   height: 34;
   width: 100%;
-  
-}
 
+}
 
 TweetHeader {
   height:1;
@@ -136,12 +134,12 @@ Tweet.scroll-horizontal TweetBody {
   /* border-top: hidden  $accent-darken-3;  */
   border: tall $accent-darken-2;
   /* border-left: tall $accent-darken-1; */
- 
+
 
   /* padding: 1 0 0 0 ; */
 
   transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
- 
+
 }
 
 .button:hover {
@@ -151,10 +149,10 @@ Tweet.scroll-horizontal TweetBody {
   height: 3;
   border: tall $accent-darken-1;
   /* border-left: tall $accent-darken-3; */
- 
 
 
- 
+
+
 }
 
 #footer {
@@ -194,11 +192,11 @@ Error {
   background: $error;
   color: $text-error;
   border-top: hkey $error-darken-2;
-  border-bottom: hkey $error-darken-2; 
+  border-bottom: hkey $error-darken-2;
   margin: 1 3;
-  
+
   text-style: bold;
-  align-horizontal: center;   
+  align-horizontal: center;
 }
 
 Warning {
@@ -207,7 +205,7 @@ Warning {
   background: $warning;
   color: $text-warning-fade-1;
   border-top: hkey $warning-darken-2;
-  border-bottom: hkey $warning-darken-2; 
+  border-bottom: hkey $warning-darken-2;
   margin: 1 2;
   text-style: bold;
   align-horizontal: center;
@@ -215,12 +213,12 @@ Warning {
 
 Success {
   width: 80;
-  height:3;  
+  height:3;
   box-sizing: border-box;
   background: $success-lighten-3;
   color: $text-success-lighten-3-fade-1;
   border-top: hkey $success;
-  border-bottom: hkey $success; 
+  border-bottom: hkey $success;
   margin: 1 2;
   text-style: bold;
   align-horizontal: center;
diff --git a/sandbox/basic.py b/sandbox/basic.py
index ea97b8619..3629e886a 100644
--- a/sandbox/basic.py
+++ b/sandbox/basic.py
@@ -100,6 +100,8 @@ class BasicApp(App, css_path="basic.css"):
 
     def on_mount(self):
         """Build layout here."""
+
+        self.scroll_to_target = Tweet(TweetBody())
         self.mount(
             header=Static(
                 Text.from_markup(
@@ -110,6 +112,7 @@ class BasicApp(App, css_path="basic.css"):
                 Tweet(TweetBody()),
                 Widget(
                     Static(Syntax(CODE, "python"), classes="code"),
+                    self.scroll_to_target,
                     classes="scrollable",
                 ),
                 Error(),
@@ -150,6 +153,9 @@ class BasicApp(App, css_path="basic.css"):
         tweet_body.short_lorem = not tweet_body.short_lorem
         tweet_body.refresh(layout=True)
 
+    def key_v(self):
+        self.get_child(id="content").scroll_to_widget(self.scroll_to_target)
+
 
 app = BasicApp()
 
diff --git a/sandbox/hsl.py b/sandbox/hsl.py
new file mode 100644
index 000000000..17c0d4f49
--- /dev/null
+++ b/sandbox/hsl.py
@@ -0,0 +1,11 @@
+from textual.app import App, ComposeResult
+from textual.widgets import Static
+
+
+class HSLApp(App):
+    def compose(self) -> ComposeResult:
+        yield Static(classes="box")
+
+
+app = HSLApp(css_path="hsl.scss", watch_css=True)
+app.run()
diff --git a/sandbox/hsl.scss b/sandbox/hsl.scss
new file mode 100644
index 000000000..78c0a495f
--- /dev/null
+++ b/sandbox/hsl.scss
@@ -0,0 +1,5 @@
+.box {
+  height: 1fr;
+  /*background: rgb(180,50, 50);*/
+  background: hsl(180,50%, 50%);
+}
diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py
index 8681115e5..1465ccf13 100644
--- a/src/textual/_ansi_sequences.py
+++ b/src/textual/_ansi_sequences.py
@@ -134,7 +134,6 @@ ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = {
     # Tmux (Win32 subsystem) sends the following scroll events.
     "\x1b[62~": (Keys.ScrollUp,),
     "\x1b[63~": (Keys.ScrollDown,),
-    "\x1b[200~": (Keys.BracketedPaste,),  # Start of bracketed paste.
     # --
     # Sequences generated by numpad 5. Not sure what it means. (It doesn't
     # appear in 'infocmp'. Just ignore.
diff --git a/src/textual/_text_backend.py b/src/textual/_text_backend.py
index 8865ca7b1..59e204671 100644
--- a/src/textual/_text_backend.py
+++ b/src/textual/_text_backend.py
@@ -124,7 +124,7 @@ class TextEditorBackend:
         self.cursor_index = text_length
         return True
 
-    def insert_at_cursor(self, text: str) -> bool:
+    def insert(self, text: str) -> bool:
         """Insert some text at the cursor position, and move the cursor
         to the end of the newly inserted text.
 
diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py
index 5eaa45106..009fb6fe5 100644
--- a/src/textual/_xterm_parser.py
+++ b/src/textual/_xterm_parser.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 import re
 from typing import Any, Callable, Generator, Iterable
 
-from . import log, messages
+from . import messages
 from . import events
 from ._types import MessageTarget
 from ._parser import Awaitable, Parser, TokenCallback
@@ -14,6 +14,8 @@ _re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z"
 _re_terminal_mode_response = re.compile(
     "^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
 )
+_re_bracketed_paste_start = re.compile(r"^\x1b\[200~$")
+_re_bracketed_paste_end = re.compile(r"^\x1b\[201~$")
 
 
 class XTermParser(Parser[events.Event]):
@@ -85,59 +87,102 @@ class XTermParser(Parser[events.Event]):
         read1 = self.read1
         get_key_ansi_sequence = ANSI_SEQUENCES_KEYS.get
         more_data = self.more_data
+        paste_buffer: list[str] = []
+        bracketed_paste = False
 
         while not self.is_eof:
+            if not bracketed_paste and paste_buffer:
+                # We're at the end of the bracketed paste.
+                # The paste buffer has content, but the bracketed paste has finished,
+                # so we flush the paste buffer. We have to remove the final character
+                # since if bracketed paste has come to an end, we'll have added the
+                # ESC from the closing bracket, since at that point we didn't know what
+                # the full escape code was.
+                pasted_text = "".join(paste_buffer[:-1])
+                self.debug_log(f"pasted_text={pasted_text!r}")
+                on_token(events.Paste(self.sender, text=pasted_text))
+                paste_buffer.clear()
+
             character = yield read1()
+
+            # If we're currently performing a bracketed paste,
+            if bracketed_paste:
+                paste_buffer.append(character)
+                self.debug_log(f"paste_buffer={paste_buffer!r}")
+
             self.debug_log(f"character={character!r}")
             if character == ESC:
                 # Could be the escape key was pressed OR the start of an escape sequence
                 sequence: str = character
-                peek_buffer = yield self.peek_buffer()
-                if not peek_buffer:
-                    # An escape arrived without any following characters
-                    on_token(events.Key(self.sender, key="escape"))
-                    continue
-                if peek_buffer and peek_buffer[0] == ESC:
-                    # There is an escape in the buffer, so ESC ESC has arrived
-                    yield read1()
-                    on_token(events.Key(self.sender, key="escape"))
-                    # If there is no further data, it is not part of a sequence,
-                    # So we don't need to go in to the loop
-                    if len(peek_buffer) == 1 and not more_data():
+                if not bracketed_paste:
+                    peek_buffer = yield self.peek_buffer()
+                    if not peek_buffer:
+                        # An escape arrived without any following characters
+                        on_token(events.Key(self.sender, key="escape"))
                         continue
+                    if peek_buffer and peek_buffer[0] == ESC:
+                        # There is an escape in the buffer, so ESC ESC has arrived
+                        yield read1()
+                        on_token(events.Key(self.sender, key="escape"))
+                        # If there is no further data, it is not part of a sequence,
+                        # So we don't need to go in to the loop
+                        if len(peek_buffer) == 1 and not more_data():
+                            continue
 
                 while True:
                     sequence += yield read1()
                     self.debug_log(f"sequence={sequence!r}")
-                    # Was it a pressed key event that we received?
-                    keys = get_key_ansi_sequence(sequence, None)
-                    if keys is not None:
-                        for key in keys:
-                            on_token(events.Key(self.sender, key=key))
-                        break
-                    # Or a mouse event?
-                    mouse_match = _re_mouse_event.match(sequence)
-                    if mouse_match is not None:
-                        mouse_code = mouse_match.group(0)
-                        event = self.parse_mouse_code(mouse_code, self.sender)
-                        if event:
-                            on_token(event)
+
+                    # Firstly, check if it's a bracketed paste escape code
+                    bracketed_paste_start_match = _re_bracketed_paste_start.match(
+                        sequence
+                    )
+                    self.debug_log(f"sequence = {repr(sequence)}")
+                    self.debug_log(f"match = {repr(bracketed_paste_start_match)}")
+                    if bracketed_paste_start_match is not None:
+                        bracketed_paste = True
+                        self.debug_log("BRACKETED PASTE START DETECTED")
                         break
-                    # Or a mode report? (i.e. the terminal telling us if it supports a mode we requested)
-                    mode_report_match = _re_terminal_mode_response.match(sequence)
-                    if mode_report_match is not None:
-                        if (
-                            mode_report_match["mode_id"] == "2026"
-                            and int(mode_report_match["setting_parameter"]) > 0
-                        ):
-                            on_token(
-                                messages.TerminalSupportsSynchronizedOutput(self.sender)
-                            )
+
+                    bracketed_paste_end_match = _re_bracketed_paste_end.match(sequence)
+                    if bracketed_paste_end_match is not None:
+                        bracketed_paste = False
+                        self.debug_log("BRACKETED PASTE ENDED")
                         break
+
+                    if not bracketed_paste:
+                        # Was it a pressed key event that we received?
+                        keys = get_key_ansi_sequence(sequence, None)
+                        if keys is not None:
+                            for key in keys:
+                                on_token(events.Key(self.sender, key=key))
+                            break
+                        # Or a mouse event?
+                        mouse_match = _re_mouse_event.match(sequence)
+                        if mouse_match is not None:
+                            mouse_code = mouse_match.group(0)
+                            event = self.parse_mouse_code(mouse_code, self.sender)
+                            if event:
+                                on_token(event)
+                            break
+                        # Or a mode report? (i.e. the terminal telling us if it supports a mode we requested)
+                        mode_report_match = _re_terminal_mode_response.match(sequence)
+                        if mode_report_match is not None:
+                            if (
+                                mode_report_match["mode_id"] == "2026"
+                                and int(mode_report_match["setting_parameter"]) > 0
+                            ):
+                                on_token(
+                                    messages.TerminalSupportsSynchronizedOutput(
+                                        self.sender
+                                    )
+                                )
+                            break
             else:
-                keys = get_key_ansi_sequence(character, None)
-                if keys is not None:
-                    for key in keys:
-                        on_token(events.Key(self.sender, key=key))
-                else:
-                    on_token(events.Key(self.sender, key=character))
+                if not bracketed_paste:
+                    keys = get_key_ansi_sequence(character, None)
+                    if keys is not None:
+                        for key in keys:
+                            on_token(events.Key(self.sender, key=key))
+                    else:
+                        on_token(events.Key(self.sender, key=character))
diff --git a/src/textual/app.py b/src/textual/app.py
index ee96289e0..398f38324 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -111,9 +111,9 @@ class App(Generic[ReturnType], DOMNode):
     """The base class for Textual Applications"""
 
     CSS = """
-    App {     
+    App {
         background: $surface;
-        color: $text-surface;        
+        color: $text-surface;
     }
     """
 
@@ -758,6 +758,7 @@ class App(Generic[ReturnType], DOMNode):
 
             driver = self._driver = self.driver_class(self.console, self)
             driver.start_application_mode()
+            driver.enable_bracketed_paste()
             try:
                 with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)):  # type: ignore
                     mount_event = events.Mount(sender=self)
@@ -772,6 +773,7 @@ class App(Generic[ReturnType], DOMNode):
                     await self.animator.stop()
                     await self.close_all()
             finally:
+                driver.disable_bracketed_paste()
                 driver.stop_application_mode()
         except Exception as error:
             self.on_exception(error)
@@ -1002,7 +1004,8 @@ class App(Generic[ReturnType], DOMNode):
             else:
                 # Forward the event to the view
                 await self.screen.forward_event(event)
-
+        elif isinstance(event, events.Paste):
+            await self.focused.forward_event(event)
         else:
             await super().on_event(event)
 
diff --git a/src/textual/color.py b/src/textual/color.py
index fd7fd0bd8..67b5bbe44 100644
--- a/src/textual/color.py
+++ b/src/textual/color.py
@@ -23,6 +23,8 @@ from rich.color import Color as RichColor
 from rich.style import Style
 from rich.text import Text
 
+from textual.css.scalar import percentage_string_to_float
+from textual.css.tokenize import COMMA, OPEN_BRACE, CLOSE_BRACE, DECIMAL, PERCENT
 from textual.suggestions import get_suggestion
 from ._color_constants import COLOR_NAME_TO_RGB
 from .geometry import clamp
@@ -53,13 +55,15 @@ class Lab(NamedTuple):
 
 
 RE_COLOR = re.compile(
-    r"""^
-\#([0-9a-fA-F]{3})$|
-\#([0-9a-fA-F]{4})$|
-\#([0-9a-fA-F]{6})$|
-\#([0-9a-fA-F]{8})$|
-rgb\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$|
-rgba\((\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*)\)$
+    rf"""^
+\#([0-9a-fA-F]{{3}})$|
+\#([0-9a-fA-F]{{4}})$|
+\#([0-9a-fA-F]{{6}})$|
+\#([0-9a-fA-F]{{8}})$|
+rgb{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$|
+rgba{OPEN_BRACE}({DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}){CLOSE_BRACE}$|
+hsl{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}){CLOSE_BRACE}$|
+hsla{OPEN_BRACE}({DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}){CLOSE_BRACE}$
 """,
     re.VERBOSE,
 )
@@ -284,7 +288,7 @@ class Color(NamedTuple):
         if color_match is None:
             error_message = f"failed to parse {color_text!r} as a color"
             suggested_color = None
-            if not color_text.startswith("#") and not color_text.startswith("rgb"):
+            if not color_text.startswith(("#", "rgb", "hsl")):
                 # Seems like we tried to use a color name: let's try to find one that is close enough:
                 suggested_color = get_suggestion(color_text, COLOR_NAME_TO_RGB.keys())
                 if suggested_color:
@@ -297,6 +301,8 @@ class Color(NamedTuple):
             rgba_hex,
             rgb,
             rgba,
+            hsl,
+            hsla,
         ) = color_match.groups()
 
         if rgb_hex_triple is not None:
@@ -329,6 +335,19 @@ class Color(NamedTuple):
                 clamp(int(float_b), 0, 255),
                 clamp(float_a, 0.0, 1.0),
             )
+        elif hsl is not None:
+            h, s, l = hsl.split(",")
+            h = float(h) % 360 / 360
+            s = percentage_string_to_float(s)
+            l = percentage_string_to_float(l)
+            color = Color.from_hls(h, l, s)
+        elif hsla is not None:
+            h, s, l, a = hsla.split(",")
+            h = float(h) % 360 / 360
+            s = percentage_string_to_float(s)
+            l = percentage_string_to_float(l)
+            a = clamp(float(a), 0.0, 1.0)
+            color = Color.from_hls(h, l, s).with_alpha(a)
         else:
             raise AssertionError("Can't get here if RE_COLOR matches")
         return color
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index c9d4e40cb..b0d390573 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -40,7 +40,14 @@ from .constants import (
 )
 from .errors import DeclarationError, StyleValueError
 from .model import Declaration
-from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError
+from .scalar import (
+    Scalar,
+    ScalarOffset,
+    Unit,
+    ScalarError,
+    ScalarParseError,
+    percentage_string_to_float,
+)
 from .styles import DockGroup, Styles
 from .tokenize import Token
 from .transition import Transition
@@ -333,9 +340,8 @@ class StylesBuilder:
             token_name = token.name
             value = token.value
             if token_name == "scalar" and value.endswith("%"):
-                percentage = value[:-1]
                 try:
-                    opacity = clamp(float(percentage) / 100, 0, 1)
+                    opacity = percentage_string_to_float(value)
                     self.styles.set_rule(name, opacity)
                 except ValueError:
                     error = True
diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py
index 7e349e785..dd612d09d 100644
--- a/src/textual/css/scalar.py
+++ b/src/textual/css/scalar.py
@@ -8,7 +8,7 @@ from typing import Iterable, NamedTuple
 
 import rich.repr
 
-from ..geometry import Offset, Size
+from ..geometry import Offset, Size, clamp
 
 
 class ScalarError(Exception):
@@ -326,6 +326,22 @@ class ScalarOffset(NamedTuple):
 
 NULL_SCALAR = ScalarOffset(Scalar.from_number(0), Scalar.from_number(0))
 
+
+def percentage_string_to_float(string: str) -> float:
+    """Convert a string percentage e.g. '20%' to a float e.g. 20.0.
+
+    Args:
+        string (str): The percentage string to convert.
+    """
+    string = string.strip()
+    if string.endswith("%"):
+        percentage = string[:-1]
+        float_percentage = clamp(float(percentage) / 100, 0, 1)
+    else:
+        float_percentage = float(string)
+    return float_percentage
+
+
 if __name__ == "__main__":
     print(Scalar.parse("3.14fr"))
     s = Scalar.parse("23")
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index dff909c4b..7cee24ce2 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -6,11 +6,21 @@ from typing import Iterable
 
 from textual.css.tokenizer import Expect, Tokenizer, Token
 
+PERCENT = r"-?\d+\.?\d*%"
+DECIMAL = r"-?\d+\.?\d*"
+COMMA = r"\s*,\s*"
+OPEN_BRACE = r"\(\s*"
+CLOSE_BRACE = r"\s*\)"
+
+HEX_COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}"
+RGB_COLOR = rf"rgb{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}|rgba{OPEN_BRACE}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{COMMA}{DECIMAL}{CLOSE_BRACE}"
+HSL_COLOR = rf"hsl{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{CLOSE_BRACE}|hsla{OPEN_BRACE}{DECIMAL}{COMMA}{PERCENT}{COMMA}{PERCENT}{COMMA}{DECIMAL}{CLOSE_BRACE}"
+
 COMMENT_START = r"\/\*"
-SCALAR = r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)"
+SCALAR = rf"{DECIMAL}(?:fr|%|w|h|vw|vh)"
 DURATION = r"\d+\.?\d*(?:ms|s)"
 NUMBER = r"\-?\d+\.?\d*"
-COLOR = r"\#[0-9a-fA-F]{8}|\#[0-9a-fA-F]{6}|\#[0-9a-fA-F]{4}|\#[0-9a-fA-F]{3}|rgb\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)|rgba\(\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*,\-?\d+\.?\d*\)"
+COLOR = rf"{HEX_COLOR}|{RGB_COLOR}|{HSL_COLOR}"
 KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+"
 TOKEN = "[a-zA-Z][a-zA-Z0-9_-]*"
 STRING = r"\".*?\""
diff --git a/src/textual/driver.py b/src/textual/driver.py
index a349540f3..93e1fdd69 100644
--- a/src/textual/driver.py
+++ b/src/textual/driver.py
@@ -41,6 +41,18 @@ class Driver(ABC):
             click_event = events.Click.from_event(event)
             self.send_event(click_event)
 
+    def enable_bracketed_paste(self) -> None:
+        """Write the ANSI escape code `ESC[?2004h`, which
+        enables bracketed paste mode."""
+        self.console.file.write("\x1b[?2004h")
+        self.console.file.flush()
+
+    def disable_bracketed_paste(self) -> None:
+        """Write the ANSI escape code `ESC[?2004l`, which
+        disables bracketed paste mode."""
+        self.console.file.write("\x1b[?2004l")
+        self.console.file.flush()
+
     @abstractmethod
     def start_application_mode(self) -> None:
         ...
diff --git a/src/textual/events.py b/src/textual/events.py
index d47c1a9a8..c5b96dacb 100644
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -413,3 +413,24 @@ class DescendantFocus(Event, verbosity=2, bubble=True):
 
 class DescendantBlur(Event, verbosity=2, bubble=True):
     pass
+
+
+@rich.repr.auto
+class Paste(Event, bubble=False):
+    """Event containing text that was pasted into the Textual application.
+    This event will only appear when running in a terminal emulator that supports
+    bracketed paste mode. Textual will enable bracketed pastes when an app starts,
+    and disable it when the app shuts down.
+    """
+
+    def __init__(self, sender: MessageTarget, text: str) -> None:
+        """
+        Args:
+            sender (MessageTarget): The sender of the event, (in this case the app).
+            text: The text that has been pasted
+        """
+        super().__init__(sender)
+        self.text = text
+
+    def __rich_repr__(self) -> rich.repr.Result:
+        yield "text", self.text
diff --git a/src/textual/keys.py b/src/textual/keys.py
index b22816d1f..14092a749 100644
--- a/src/textual/keys.py
+++ b/src/textual/keys.py
@@ -180,7 +180,6 @@ class Keys(str, Enum):
     CPRResponse = "<cursor-position-response>"
     Vt100MouseEvent = "<vt100-mouse-event>"
     WindowsMouseEvent = "<windowshift+mouse-event>"
-    BracketedPaste = "<bracketed-paste>"
 
     # For internal use: key which is ignored.
     # (The key binding for this key should not do anything.)
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 8318b1f99..0d886adda 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -543,48 +543,65 @@ class Widget(DOMNode):
         )
 
     def scroll_to_widget(self, widget: Widget, *, animate: bool = True) -> bool:
-        """Scroll so that a child widget is in the visible area.
+        """Starting from `widget`, travel up the DOM to this node, scrolling all containers such that
+        every widget is visible within its parent container. This will, in the majority of cases,
+        bring the target widget into
 
         Args:
-            widget (Widget): A Widget in the children.
+            widget (Widget): A descendant widget.
             animate (bool, optional): True to animate, or False to jump. Defaults to True.
 
         Returns:
-            bool: True if the scroll position changed, otherwise False.
+            bool: True if any scrolling has occurred in any descendant, otherwise False.
         """
 
-        try:
-            widget_region = widget.content_region
-            container_region = self.content_region
-        except errors.NoWidget:
-            return False
-
-        if widget_region in container_region:
-            # Widget is visible, nothing to do
-            return False
+        scrolls = set()
+
+        node = widget.parent
+        child = widget
+        while node:
+            try:
+                widget_region = child.region
+                container_region = node.region
+            except (errors.NoWidget, AttributeError):
+                return False
+
+            if widget_region in container_region:
+                # Widget is visible, nothing to do
+                child = node
+                node = node.parent
+                continue
+
+            # We can either scroll so the widget is at the top of the container, or so that
+            # it is at the bottom. We want to pick which has the shortest distance
+            top_delta = widget_region.origin - container_region.origin
+
+            bottom_delta = widget_region.origin - (
+                container_region.origin
+                + Offset(0, container_region.height - widget_region.height)
+            )
 
-        # We can either scroll so the widget is at the top of the container, or so that
-        # it is at the bottom. We want to pick which has the shortest distance
-        top_delta = widget_region.origin - container_region.origin
+            if widget_region.width > container_region.width:
+                delta_x = top_delta.x
+            else:
+                delta_x = min(top_delta.x, bottom_delta.x, key=abs)
 
-        bottom_delta = widget_region.origin - (
-            container_region.origin
-            + Offset(0, container_region.height - widget_region.height)
-        )
+            if widget_region.height > container_region.height:
+                delta_y = top_delta.y
+            else:
+                delta_y = min(top_delta.y, bottom_delta.y, key=abs)
 
-        if widget_region.width > container_region.width:
-            delta_x = top_delta.x
-        else:
-            delta_x = min(top_delta.x, bottom_delta.x, key=abs)
+            scrolled = node.scroll_relative(
+                delta_x or None, delta_y or None, animate=animate, duration=0.2
+            )
+            scrolls.add(scrolled)
 
-        if widget_region.height > container_region.height:
-            delta_y = top_delta.y
-        else:
-            delta_y = min(top_delta.y, bottom_delta.y, key=abs)
+            if node == self:
+                break
+            child = node
+            node = node.parent
 
-        return self.scroll_relative(
-            delta_x or None, delta_y or None, animate=animate, duration=0.2
-        )
+        return any(scrolls)
 
     def __init_subclass__(
         cls,
diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py
index bed64adcd..5e094dc62 100644
--- a/src/textual/widgets/text_input.py
+++ b/src/textual/widgets/text_input.py
@@ -5,7 +5,6 @@ from typing import Callable
 from rich.cells import cell_len
 from rich.console import RenderableType
 from rich.padding import Padding
-from rich.style import Style
 from rich.text import Text
 
 from textual import events, _clock
@@ -44,7 +43,7 @@ class TextWidgetBase(Widget):
 
         changed = False
         if event.is_printable:
-            changed = self._editor.insert_at_cursor(key)
+            changed = self._editor.insert(key)
         elif key == "ctrl+h":
             changed = self._editor.delete_back()
         elif key == "ctrl+d":
@@ -197,6 +196,30 @@ class TextInput(TextWidgetBase, can_focus=True):
         self._editor.cursor_index = new_cursor_index
         self.refresh()
 
+    def on_paste(self, event: events.Paste) -> None:
+        """Handle Paste event by stripping newlines from the text, and inserting
+        the text at the cursor position, sliding the visible window if required."""
+        text = "".join(event.text.splitlines())
+        width_behind_cursor = self._visible_content_to_cursor_cell_len
+        self._editor.insert(text)
+        paste_cell_len = cell_len(text)
+        available_width = self.content_region.width
+
+        # By inserting the pasted text, how far beyond the bounds of the text input
+        # will we move the cursor? We need to slide the visible window along by that.
+        scroll_amount = paste_cell_len + width_behind_cursor - available_width + 1
+
+        if scroll_amount > 0:
+            self._slide_window(scroll_amount)
+
+        self.refresh()
+
+    def _slide_window(self, amount: int) -> None:
+        """Slide the visible window left or right by `amount`. Negative integers move
+        the window left, and positive integers move the window right."""
+        start, end = self.visible_range
+        self.visible_range = start + amount, end + amount
+
     def _reset_visible_range(self):
         """Reset our window into the editor content. Used when the widget is resized."""
         available_width = self.content_region.width
@@ -249,6 +272,20 @@ class TextInput(TextWidgetBase, can_focus=True):
                 display_text = self._apply_cursor_to_text(display_text, 0)
             return display_text
 
+    @property
+    def _visible_content_to_cursor_cell_len(self) -> int:
+        """The cell width of the visible content up to the cursor cell"""
+        start, _ = self.visible_range
+        visible_content_to_cursor = self._editor.get_range(
+            start, self._editor.cursor_index + 1
+        )
+        return cell_len(visible_content_to_cursor)
+
+    @property
+    def _cursor_at_right_edge(self) -> bool:
+        """True if the cursor is at the right edge of the content area"""
+        return self._visible_content_to_cursor_cell_len == self.content_region.width
+
     def on_key(self, event: events.Key) -> None:
         key = event.key
         if key in self.STOP_PROPAGATE:
@@ -267,19 +304,14 @@ class TextInput(TextWidgetBase, can_focus=True):
         scrollable = cell_len(self._editor.content) >= available_width
 
         # Check what content is visible from the editor, and how wide that content is
-        visible_content = self._editor.get_range(start, end)
-        visible_content_cell_len = cell_len(visible_content)
-        visible_content_to_cursor = self._editor.get_range(
-            start, self._editor.cursor_index + 1
-        )
-        visible_content_to_cursor_cell_len = cell_len(visible_content_to_cursor)
+        visible_content_to_cursor_cell_len = self._visible_content_to_cursor_cell_len
 
-        cursor_at_end = visible_content_to_cursor_cell_len == available_width
+        cursor_at_end = self._cursor_at_right_edge
         key_cell_len = cell_len(key)
         if event.is_printable:
             # Check if we'll need to scroll to accommodate the new cell width after insertion.
             if visible_content_to_cursor_cell_len + key_cell_len >= available_width:
-                self.visible_range = start + key_cell_len, end + key_cell_len
+                self._slide_window(key_cell_len)
             self._update_suggestion(event)
         elif key == "enter" and self._editor.content:
             self.post_message_no_wait(TextInput.Submitted(self, self._editor.content))
@@ -296,12 +328,9 @@ class TextInput(TextWidgetBase, can_focus=True):
                         window_shift_amount = cell_width_character_to_right
                     else:
                         window_shift_amount = 1
-                    self.visible_range = (
-                        start + window_shift_amount,
-                        end + window_shift_amount,
-                    )
+                    self._slide_window(window_shift_amount)
             if self._suggestion_suffix and self._editor.cursor_at_end:
-                self._editor.insert_at_cursor(self._suggestion_suffix)
+                self._editor.insert(self._suggestion_suffix)
                 self._suggestion_suffix = ""
                 self._reset_visible_range()
         elif key == "left":
@@ -315,7 +344,7 @@ class TextInput(TextWidgetBase, can_focus=True):
                     self.app.bell()
         elif key == "ctrl+h":
             if cursor_index == start and self._editor.query_cursor_left():
-                self.visible_range = start - 1, end - 1
+                self._slide_window(-1)
             self._update_suggestion(event)
         elif key == "ctrl+d":
             self._update_suggestion(event)
@@ -420,9 +449,9 @@ class TextAreaChild(TextWidgetBase, can_focus=True):
             event.stop()
 
         if event.key == "enter":
-            self._editor.insert_at_cursor("\n")
+            self._editor.insert("\n")
         elif event.key == "tab":
-            self._editor.insert_at_cursor("\t")
+            self._editor.insert("\t")
         elif event.key == "escape":
             self.app.focused = None
 


Test patch:
diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py
index dd1e73809..b9b03f3bf 100644
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -904,6 +904,32 @@ class TestParseText:
         assert styles.background == Color.parse("red")
 
 
+class TestParseColor:
+    """More in-depth tests around parsing of CSS colors"""
+
+    @pytest.mark.parametrize("value,result", [
+        ("rgb(1,255,50)", Color(1, 255, 50)),
+        ("rgb( 1, 255,50 )", Color(1, 255, 50)),
+        ("rgba( 1, 255,50,0.3 )", Color(1, 255, 50, 0.3)),
+        ("rgba( 1, 255,50, 1.3 )", Color(1, 255, 50, 1.0)),
+        ("hsl( 180, 50%, 50% )", Color(64, 191, 191)),
+        ("hsl(180,50%,50%)", Color(64, 191, 191)),
+        ("hsla(180,50%,50%,0.25)", Color(64, 191, 191, 0.25)),
+        ("hsla( 180, 50% ,50%,0.25 )", Color(64, 191, 191, 0.25)),
+        ("hsla( 180, 50% , 50% , 1.5 )", Color(64, 191, 191)),
+    ])
+    def test_rgb_and_hsl(self, value, result):
+        css = f""".box {{
+          color: {value};
+        }}
+        """
+        stylesheet = Stylesheet()
+        stylesheet.add_source(css)
+
+        styles = stylesheet.rules[0].styles
+        assert styles.color == result
+
+
 class TestParseOffset:
     @pytest.mark.parametrize(
         "offset_x, parsed_x, offset_y, parsed_y",
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
index 9ef433a29..7dca722cc 100644
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -32,9 +32,6 @@ from textual.css.tokenizer import TokenizeError
         ["red 4", pytest.raises(StylesheetParseError), None],  # space in it
         ["1", pytest.raises(StylesheetParseError), None],  # invalid value
         ["()", pytest.raises(TokenizeError), None],  # invalid tokens
-        # TODO: allow spaces in rgb/rgba expressions?
-        ["rgb(200, 90, 30)", pytest.raises(TokenizeError), None],
-        ["rgba(200,90,30, 0.4)", pytest.raises(TokenizeError), None],
     ],
 )
 def test_color_property_parsing(css_value, expectation, expected_color):
diff --git a/tests/test_color.py b/tests/test_color.py
index 97ffddd39..3f9719665 100644
--- a/tests/test_color.py
+++ b/tests/test_color.py
@@ -1,5 +1,4 @@
 import pytest
-
 from rich.color import Color as RichColor
 from rich.text import Text
 
@@ -108,12 +107,43 @@ def test_color_blend():
         ("rgb(2,3,4)", Color(2, 3, 4, 1.0)),
         ("rgba(2,3,4,1.0)", Color(2, 3, 4, 1.0)),
         ("rgba(2,3,4,0.058823529411764705)", Color(2, 3, 4, 0.058823529411764705)),
+        ("hsl(45,25%,25%)", Color(80, 72, 48)),
+        ("hsla(45,25%,25%,0.35)", Color(80, 72, 48, 0.35)),
     ],
 )
 def test_color_parse(text, expected):
     assert Color.parse(text) == expected
 
 
+@pytest.mark.parametrize("input,output", [
+    ("rgb( 300, 300 , 300 )", Color(255, 255, 255)),
+    ("rgba( 2 , 3 , 4, 1.0 )", Color(2, 3, 4, 1.0)),
+    ("hsl( 45, 25% , 25% )", Color(80, 72, 48)),
+    ("hsla( 45, 25% , 25%, 0.35 )", Color(80, 72, 48, 0.35)),
+])
+def test_color_parse_input_has_spaces(input, output):
+    assert Color.parse(input) == output
+
+
+@pytest.mark.parametrize("input,output", [
+    ("rgb(300, 300, 300)", Color(255, 255, 255)),
+    ("rgba(300, 300, 300, 300)", Color(255, 255, 255, 1.0)),
+    ("hsl(400, 200%, 250%)", Color(255, 255, 255, 1.0)),
+    ("hsla(400, 200%, 250%, 1.9)", Color(255, 255, 255, 1.0)),
+])
+def test_color_parse_clamp(input, output):
+    assert Color.parse(input) == output
+
+
+def test_color_parse_hsl_negative_degrees():
+    assert Color.parse("hsl(-90, 50%, 50%)") == Color.parse("hsl(270, 50%, 50%)")
+
+
+def test_color_parse_hsla_negative_degrees():
+    assert Color.parse("hsla(-45, 50%, 50%, 0.2)") == Color.parse(
+        "hsla(315, 50%, 50%, 0.2)")
+
+
 def test_color_parse_color():
     # as a convenience, if Color.parse is passed a color object, it will return it
     color = Color(20, 30, 40, 0.5)
diff --git a/tests/test_text_backend.py b/tests/test_text_backend.py
index b4b98a711..bf9296720 100644
--- a/tests/test_text_backend.py
+++ b/tests/test_text_backend.py
@@ -136,21 +136,21 @@ def test_cursor_text_end_cursor_in_middle():
 
 def test_insert_at_cursor_cursor_at_start():
     editor = TextEditorBackend(CONTENT)
-    assert editor.insert_at_cursor("ABC")
+    assert editor.insert("ABC")
     assert editor.content == "ABC" + CONTENT
     assert editor.cursor_index == len("ABC")
 
 def test_insert_at_cursor_cursor_in_middle():
     start_cursor_index = 6
     editor = TextEditorBackend(CONTENT, start_cursor_index)
-    assert editor.insert_at_cursor("ABC")
+    assert editor.insert("ABC")
     assert editor.content == "Hello,ABC world!"
     assert editor.cursor_index == start_cursor_index + len("ABC")
 
 
 def test_insert_at_cursor_cursor_at_end():
     editor = TextEditorBackend(CONTENT, len(CONTENT))
-    assert editor.insert_at_cursor("ABC")
+    assert editor.insert("ABC")
     assert editor.content == CONTENT + "ABC"
     assert editor.cursor_index == len(editor.content)