Repository: Textualize/textual

Issue:
Add "blank" border type, and change "none" and "hidden" borders
Textual has a "none" border which reserves space with no characters, which conflicts with Browser CSS.

We should have "none" to mean the same as no border. And deprecate "hidden".

If we want to toggle a border on and off we can set the border-color to transparent.

Discussion:

Patch:
diff --git a/sandbox/borders.py b/sandbox/borders.py
new file mode 100644
index 000000000..9a2b7e2d6
--- /dev/null
+++ b/sandbox/borders.py
@@ -0,0 +1,67 @@
+from rich.console import RenderableType
+from rich.text import Text
+
+from textual.app import App, ComposeResult
+from textual.css.types import EdgeType
+from textual.widget import Widget
+from textual.widgets import Placeholder
+
+
+class VerticalContainer(Widget):
+    CSS = """
+    VerticalContainer {
+        layout: vertical;
+        overflow: hidden auto;
+        background: darkblue;
+    }
+
+    VerticalContainer Placeholder {
+        margin: 1 0;
+        height: auto;
+        align: center top;
+    }
+    """
+
+
+class Introduction(Widget):
+    CSS = """
+    Introduction {
+        background: indigo;
+        color: white;
+        height: 3;
+        padding: 1 0;
+    }
+    """
+
+    def render(self, styles) -> RenderableType:
+        return Text("Here are the color edge types we support.", justify="center")
+
+
+class BorderDemo(Widget):
+    def __init__(self, name: str):
+        super().__init__(name=name)
+
+    def render(self, style) -> RenderableType:
+        return Text(self.name, style="black on yellow", justify="center")
+
+
+class MyTestApp(App):
+    def compose(self) -> ComposeResult:
+        border_demo_widgets = []
+        for border_edge_type in EdgeType.__args__:
+            border_demo = BorderDemo(f'"border: {border_edge_type} white"')
+            border_demo.styles.height = "auto"
+            border_demo.styles.margin = (1, 0)
+            border_demo.styles.border = (border_edge_type, "white")
+            border_demo_widgets.append(border_demo)
+
+        yield VerticalContainer(Introduction(), *border_demo_widgets, id="root")
+
+    def on_mount(self):
+        self.bind("q", "quit")
+
+
+app = MyTestApp()
+
+if __name__ == "__main__":
+    app.run()
diff --git a/src/textual/_border.py b/src/textual/_border.py
index a7c8a72e8..49f52139a 100644
--- a/src/textual/_border.py
+++ b/src/textual/_border.py
@@ -1,24 +1,33 @@
 from __future__ import annotations
 
+import sys
 from functools import lru_cache
+from typing import cast, Tuple, Union
 
 from rich.console import Console, ConsoleOptions, RenderResult, RenderableType
 import rich.repr
 from rich.segment import Segment, SegmentLines
-from rich.style import Style, StyleType
+from rich.style import Style, Color
 
-from .color import Color
 from .css.types import EdgeStyle, EdgeType
 
+if sys.version_info >= (3, 10):
+    from typing import TypeAlias
+else:  # pragma: no cover
+    from typing_extensions import TypeAlias
 
 INNER = 1
 OUTER = 2
 
 BORDER_CHARS: dict[EdgeType, tuple[str, str, str]] = {
-    # TODO: in "browsers' CSS" `none` and `hidden` both set the border width to zero. Should we do the same?
+    # Each string of the tuple represents a sub-tuple itself:
+    #  - 1st string represents `(top1, top2, top3)`
+    #  - 2nd string represents (mid1, mid2, mid3)
+    #  - 3rd string represents (bottom1, bottom2, bottom3)
     "": ("   ", "   ", "   "),
     "none": ("   ", "   ", "   "),
     "hidden": ("   ", "   ", "   "),
+    "blank": ("   ", "   ", "   "),
     "round": ("╭─╮", "│ │", "╰─╯"),
     "solid": ("┌─┐", "│ │", "└─┘"),
     "double": ("╔═╗", "║ ║", "╚═╝"),
@@ -40,6 +49,7 @@ BORDER_LOCATIONS: dict[
     "": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
     "none": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
     "hidden": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
+    "blank": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
     "round": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
     "solid": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
     "double": ((0, 0, 0), (0, 0, 0), (0, 0, 0)),
@@ -53,6 +63,10 @@ BORDER_LOCATIONS: dict[
     "wide": ((1, 1, 1), (0, 1, 0), (1, 1, 1)),
 }
 
+INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
+
+BorderValue: TypeAlias = Tuple[EdgeType, Union[str, Color, Style]]
+
 
 @lru_cache(maxsize=1024)
 def get_box(
@@ -135,7 +149,12 @@ class Border:
             (bottom, bottom_color),
             (left, left_color),
         ) = edge_styles
-        self._sides = (top or "none", right or "none", bottom or "none", left or "none")
+        self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] = (
+            top or "none",
+            right or "none",
+            bottom or "none",
+            left or "none",
+        )
         from_color = Style.from_color
 
         self._styles = (
@@ -159,10 +178,11 @@ class Border:
             width (int): Desired width.
         """
         top, right, bottom, left = self._sides
-        has_left = left != "none"
-        has_right = right != "none"
-        has_top = top != "none"
-        has_bottom = bottom != "none"
+        # the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string
+        has_left = bool(left)
+        has_right = bool(right)
+        has_top = bool(top)
+        has_bottom = bool(bottom)
 
         if has_top:
             lines.pop(0)
@@ -188,10 +208,11 @@ class Border:
         outer_style = console.get_style(self.outer_style)
         top_style, right_style, bottom_style, left_style = self._styles
 
-        has_left = left != "none"
-        has_right = right != "none"
-        has_top = top != "none"
-        has_bottom = bottom != "none"
+        # ditto than in `_crop_renderable` ☝
+        has_left = bool(left)
+        has_right = bool(right)
+        has_top = bool(top)
+        has_bottom = bool(bottom)
 
         width = options.max_width - has_left - has_right
 
@@ -262,6 +283,18 @@ class Border:
             yield new_line
 
 
+_edge_type_normalization_table: dict[EdgeType, EdgeType] = {
+    # i.e. we normalize "border: none;" to "border: ;".
+    # As a result our layout-related calculations that include borders are simpler (and have better performance)
+    "none": "",
+    "hidden": "",
+}
+
+
+def normalize_border_value(value: BorderValue) -> BorderValue:
+    return _edge_type_normalization_table.get(value[0], value[0]), value[1]
+
+
 if __name__ == "__main__":
     from rich import print
     from rich.text import Text
diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py
index b7a161c71..9ed7d5a8a 100644
--- a/src/textual/css/_style_properties.py
+++ b/src/textual/css/_style_properties.py
@@ -27,6 +27,7 @@ from ._help_text import (
     string_enum_help_text,
     color_property_help_text,
 )
+from .._border import INVISIBLE_EDGE_TYPES, normalize_border_value
 from ..color import Color, ColorPair, ColorParseError
 from ._error_tools import friendly_list
 from .constants import NULL_SPACING, VALID_STYLE_FLAGS
@@ -321,28 +322,37 @@ class BorderProperty:
             clear_rule(bottom)
             clear_rule(left)
             return
-        if isinstance(border, tuple):
-            setattr(obj, top, border)
-            setattr(obj, right, border)
-            setattr(obj, bottom, border)
-            setattr(obj, left, border)
+        if isinstance(border, tuple) and len(border) == 2:
+            _border = normalize_border_value(border)
+            setattr(obj, top, _border)
+            setattr(obj, right, _border)
+            setattr(obj, bottom, _border)
+            setattr(obj, left, _border)
             return
 
         count = len(border)
         if count == 1:
-            _border = border[0]
+            _border = normalize_border_value(border[0])
             setattr(obj, top, _border)
             setattr(obj, right, _border)
             setattr(obj, bottom, _border)
             setattr(obj, left, _border)
         elif count == 2:
-            _border1, _border2 = border
+            _border1, _border2 = (
+                normalize_border_value(border[0]),
+                normalize_border_value(border[1]),
+            )
             setattr(obj, top, _border1)
             setattr(obj, bottom, _border1)
             setattr(obj, right, _border2)
             setattr(obj, left, _border2)
         elif count == 4:
-            _border1, _border2, _border3, _border4 = border
+            _border1, _border2, _border3, _border4 = (
+                normalize_border_value(border[0]),
+                normalize_border_value(border[1]),
+                normalize_border_value(border[3]),
+                normalize_border_value(border[4]),
+            )
             setattr(obj, top, _border1)
             setattr(obj, right, _border2)
             setattr(obj, bottom, _border3)
diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py
index a81df3d81..9f8b959b9 100644
--- a/src/textual/css/_styles_builder.py
+++ b/src/textual/css/_styles_builder.py
@@ -42,7 +42,8 @@ from .scalar import Scalar, ScalarOffset, Unit, ScalarError, ScalarParseError
 from .styles import DockGroup, Styles
 from .tokenize import Token
 from .transition import Transition
-from .types import BoxSizing, Edge, Display, Overflow, Visibility
+from .types import BoxSizing, Edge, Display, Overflow, Visibility, EdgeType
+from .._border import normalize_border_value, BorderValue
 from ..color import Color, ColorParseError
 from .._duration import _duration_as_seconds
 from .._easing import EASING
@@ -417,8 +418,8 @@ class StylesBuilder:
     process_padding_bottom = _process_space_partial
     process_padding_left = _process_space_partial
 
-    def _parse_border(self, name: str, tokens: list[Token]) -> tuple[str, Color]:
-        border_type = "solid"
+    def _parse_border(self, name: str, tokens: list[Token]) -> BorderValue:
+        border_type: EdgeType = "solid"
         border_color = Color(0, 255, 0)
 
         def border_value_error():
@@ -444,7 +445,7 @@ class StylesBuilder:
             else:
                 border_value_error()
 
-        return (border_type, border_color)
+        return normalize_border_value((border_type, border_color))
 
     def _process_border_edge(self, edge: str, name: str, tokens: list[Token]) -> None:
         border = self._parse_border(name, tokens)
diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py
index 51f8cf7ae..3fa4d3a30 100644
--- a/src/textual/css/constants.py
+++ b/src/textual/css/constants.py
@@ -1,4 +1,6 @@
+from __future__ import annotations
 import sys
+import typing
 
 if sys.version_info >= (3, 8):
     from typing import Final
@@ -7,12 +9,16 @@ else:
 
 from ..geometry import Spacing
 
+if typing.TYPE_CHECKING:
+    from .types import EdgeType
+
 VALID_VISIBILITY: Final = {"visible", "hidden"}
 VALID_DISPLAY: Final = {"block", "none"}
-VALID_BORDER: Final = {
+VALID_BORDER: Final[set[EdgeType]] = {
     "none",
     "hidden",
     "round",
+    "blank",
     "solid",
     "double",
     "dashed",
diff --git a/src/textual/css/types.py b/src/textual/css/types.py
index ac2183607..e7fd749b2 100644
--- a/src/textual/css/types.py
+++ b/src/textual/css/types.py
@@ -16,6 +16,7 @@ EdgeType = Literal[
     "",
     "none",
     "hidden",
+    "blank",
     "round",
     "solid",
     "double",
@@ -35,6 +36,6 @@ AlignVertical = Literal["top", "middle", "bottom"]
 ScrollbarGutter = Literal["auto", "stable"]
 BoxSizing = Literal["border-box", "content-box"]
 Overflow = Literal["scroll", "hidden", "auto"]
-EdgeStyle = Tuple[str, Color]
+EdgeStyle = Tuple[EdgeType, Color]
 Specificity3 = Tuple[int, int, int]
 Specificity4 = Tuple[int, int, int, int]
diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py
index e5d49e755..5979ee884 100644
--- a/src/textual/widgets/_placeholder.py
+++ b/src/textual/widgets/_placeholder.py
@@ -19,6 +19,19 @@ class Placeholder(Widget, can_focus=True):
     has_focus: Reactive[bool] = Reactive(False)
     mouse_over: Reactive[bool] = Reactive(False)
 
+    def __init__(
+        # parent class constructor signature:
+        self,
+        *children: Widget,
+        name: str | None = None,
+        id: str | None = None,
+        classes: str | None = None,
+        # ...and now for our own class specific params:
+        title: str | None = None,
+    ) -> None:
+        super().__init__(*children, name=name, id=id, classes=classes)
+        self.title = title
+
     def __rich_repr__(self) -> rich.repr.Result:
         yield from super().__rich_repr__()
         yield "has_focus", self.has_focus, False
@@ -32,7 +45,7 @@ class Placeholder(Widget, can_focus=True):
                 Pretty(self, no_wrap=True, overflow="ellipsis"),
                 vertical="middle",
             ),
-            title=self.__class__.__name__,
+            title=self.title or self.__class__.__name__,
             border_style="green" if self.mouse_over else "blue",
             box=box.HEAVY if self.has_focus else box.ROUNDED,
         )


Test patch:
diff --git a/tests/test_integration_layout.py b/tests/test_integration_layout.py
index c41b22cb6..4dc5d01c0 100644
--- a/tests/test_integration_layout.py
+++ b/tests/test_integration_layout.py
@@ -3,9 +3,12 @@ import asyncio
 from typing import cast, List
 
 import pytest
+from rich.console import RenderableType
+from rich.text import Text
 
 from tests.utilities.test_app import AppTest
 from textual.app import ComposeResult
+from textual.css.types import EdgeType
 from textual.geometry import Size
 from textual.widget import Widget
 from textual.widgets import Placeholder
@@ -31,30 +34,20 @@ PLACEHOLDERS_DEFAULT_H = 3  # the default height for our Placeholder widgets
         "expected_placeholders_offset_x",
     ),
     (
-        [
-            SCREEN_SIZE,
-            1,
-            "border: ;",  # #root has no border
-            "",  # no specific placeholder style
-            # #root's virtual size=screen size
-            (SCREEN_W, SCREEN_H),
-            # placeholders width=same than screen :: height=default height
-            (SCREEN_W, PLACEHOLDERS_DEFAULT_H),
-            # placeholders should be at offset 0
-            0,
-        ],
-        [
-            # "none" borders still allocate a space for the (invisible) border
-            SCREEN_SIZE,
-            1,
-            "border: none;",  # #root has an invisible border
-            "",  # no specific placeholder style
-            # #root's virtual size is smaller because of its borders
-            (SCREEN_W - 2, SCREEN_H - 2),
-            # placeholders width=same than screen, minus 2 borders :: height=default height minus 2 borders
-            (SCREEN_W - 2, PLACEHOLDERS_DEFAULT_H),
-            # placeholders should be at offset 1 because of #root's border
-            1,
+        *[
+            [
+                SCREEN_SIZE,
+                1,
+                f"border: {invisible_border_edge};",  # #root has no visible border
+                "",  # no specific placeholder style
+                # #root's virtual size=screen size
+                (SCREEN_W, SCREEN_H),
+                # placeholders width=same than screen :: height=default height
+                (SCREEN_W, PLACEHOLDERS_DEFAULT_H),
+                # placeholders should be at offset 0
+                0,
+            ]
+            for invisible_border_edge in ("", "none", "hidden")
         ],
         [
             SCREEN_SIZE,
@@ -169,3 +162,75 @@ async def test_composition_of_vertical_container_with_children(
         assert placeholder.size == expected_placeholders_size
         assert placeholder.styles.offset.x.value == 0.0
         assert app.screen.get_offset(placeholder).x == expected_placeholders_offset_x
+
+
+@pytest.mark.asyncio
+@pytest.mark.integration_test
+@pytest.mark.parametrize(
+    "edge_type,expected_box_inner_size,expected_box_size,expected_top_left_edge_color,expects_visible_char_at_top_left_edge",
+    (
+        # These first 3 types of border edge types are synonyms, and display no borders:
+        ["", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
+        ["none", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
+        ["hidden", Size(SCREEN_W, 1), Size(SCREEN_W, 1), "black", False],
+        # Let's transition to "blank": we still see no visible border, but the size is increased
+        # as the gutter space is reserved the same way it would be with a border:
+        ["blank", Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", False],
+        # And now for the "normally visible" border edge types:
+        # --> we see a visible border, and the size is increased:
+        *[
+            [edge_style, Size(SCREEN_W - 2, 1), Size(SCREEN_W, 3), "#ffffff", True]
+            for edge_style in [
+                "round",
+                "solid",
+                "double",
+                "dashed",
+                "heavy",
+                "inner",
+                "outer",
+                "hkey",
+                "vkey",
+                "tall",
+                "wide",
+            ]
+        ],
+    ),
+)
+async def test_border_edge_types_impact_on_widget_size(
+    edge_type: EdgeType,
+    expected_box_inner_size: Size,
+    expected_box_size: Size,
+    expected_top_left_edge_color: str,
+    expects_visible_char_at_top_left_edge: bool,
+):
+    class BorderTarget(Widget):
+        def render(self, style) -> RenderableType:
+            return Text("border target", style="black on yellow", justify="center")
+
+    border_target = BorderTarget()
+    border_target.styles.height = "auto"
+    border_target.styles.border = (edge_type, "white")
+
+    class MyTestApp(AppTest):
+        def compose(self) -> ComposeResult:
+            yield border_target
+
+    app = MyTestApp(size=SCREEN_SIZE, test_name="border_edge_types")
+
+    await app.boot_and_shutdown()
+
+    box_inner_size = Size(
+        border_target.content_region.width,
+        border_target.content_region.height,
+    )
+    assert box_inner_size == expected_box_inner_size
+
+    assert border_target.size == expected_box_size
+
+    top_left_edge_style = app.screen.get_style_at(0, 0)
+    top_left_edge_color = top_left_edge_style.color.name
+    assert top_left_edge_color == expected_top_left_edge_color
+
+    top_left_edge_char = app.get_char_at(0, 0)
+    top_left_edge_char_is_a_visible_one = top_left_edge_char != " "
+    assert top_left_edge_char_is_a_visible_one == expects_visible_char_at_top_left_edge
diff --git a/tests/utilities/test_app.py b/tests/utilities/test_app.py
index c73a1e882..802578842 100644
--- a/tests/utilities/test_app.py
+++ b/tests/utilities/test_app.py
@@ -8,7 +8,7 @@ from typing import AsyncContextManager, cast
 
 from rich.console import Console
 
-from textual import events
+from textual import events, errors
 from textual.app import App, ReturnType, ComposeResult
 from textual.driver import Driver
 from textual.geometry import Size
@@ -123,6 +123,36 @@ class AppTest(App):
         last_display_start_index = total_capture.rindex(CLEAR_SCREEN_SEQUENCE)
         return total_capture[last_display_start_index:]
 
+    def get_char_at(self, x: int, y: int) -> str:
+        """Get the character at the given cell or empty string
+
+        Args:
+            x (int): X position within the Layout
+            y (int): Y position within the Layout
+
+        Returns:
+            str: The character at the cell (x, y) within the Layout
+        """
+        # N.B. Basically a copy-paste-and-slightly-adapt of `Compositor.get_style_at()`
+        try:
+            widget, region = self.get_widget_at(x, y)
+        except errors.NoWidget:
+            return ""
+        if widget not in self.screen._compositor.regions:
+            return ""
+
+        x -= region.x
+        y -= region.y
+        lines = widget.get_render_lines(y, y + 1)
+        if not lines:
+            return ""
+        end = 0
+        for segment in lines[0]:
+            end += segment.cell_length
+            if x < end:
+                return segment.text[0]
+        return ""
+
     @property
     def console(self) -> ConsoleTest:
         return self._console