[css][scrollbar gutter] Manage the scrollbar-gutter: stable CSS property

(only for vertical content though; we may see later on if we want to also apply that logic for horizontal scrolls?)
https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-gutter
This commit is contained in:
Olivier Philippon
2022-05-11 16:43:17 +01:00
parent 73a2dfb3c6
commit 685a2fb510
11 changed files with 149 additions and 8 deletions

View File

@@ -85,7 +85,8 @@ Tweet {
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll;
overflow-y: auto;
scrollbar-gutter: stable;
align-horizontal: center;
}
@@ -117,7 +118,7 @@ TweetBody {
width: 100%;
background: $panel;
color: $text-panel;
height:20;
height: auto;
padding: 0 1 0 0;
}

View File

@@ -4,6 +4,7 @@ from rich.syntax import Syntax
from rich.text import Text
from textual.app import App
from textual.reactive import Reactive
from textual.widget import Widget
from textual.widgets import Static
@@ -44,11 +45,15 @@ class Offset(NamedTuple):
'''
lorem = Text.from_markup(
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
"""Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
lorem_short = """Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum."""
lorem = (
lorem_short
+ """ In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus. """
)
lorem_short_text = Text.from_markup(lorem_short)
lorem_long_text = Text.from_markup(lorem * 2)
class TweetHeader(Widget):
def render(self, style: Style) -> RenderableType:
@@ -56,8 +61,10 @@ class TweetHeader(Widget):
class TweetBody(Widget):
short_lorem = Reactive[bool](False)
def render(self, style: Style) -> Text:
return lorem
return lorem_short_text if self.short_lorem else lorem_long_text
class Tweet(Widget):
@@ -135,9 +142,18 @@ class BasicApp(App):
def key_d(self):
self.dark = not self.dark
async def key_q(self):
await self.shutdown()
def key_x(self):
self.panic(self.tree)
def key_t(self):
# Pressing "t" toggles the content of the TweetBody widget, from a long "Lorem ipsum..." to a shorter one.
tweet_body = self.screen.query("TweetBody").first()
tweet_body.short_lorem = not tweet_body.short_lorem
tweet_body.refresh(layout=True)
app = BasicApp(
css_path="basic.css",

View File

@@ -114,7 +114,6 @@ class App(Generic[ReturnType], DOMNode):
driver_class: Type[Driver] | None = None,
log_path: str | PurePath = "",
log_verbosity: int = 1,
# TODO: make this Literal a proper type in Rich, so we re-use it?
log_color_system: Literal[
"auto", "standard", "256", "truecolor", "windows"
] = "auto",

View File

@@ -34,6 +34,7 @@ from .constants import (
VALID_OVERFLOW,
VALID_VISIBILITY,
VALID_STYLE_FLAGS,
VALID_SCROLLBAR_GUTTER,
)
from .errors import DeclarationError, StyleValueError
from .model import Declaration
@@ -770,6 +771,18 @@ class StylesBuilder:
process_content_align_horizontal = process_align_horizontal
process_content_align_vertical = process_align_vertical
def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER)
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(name, VALID_SCROLLBAR_GUTTER, context="css"),
)
else:
self.styles._rules[name.replace("-", "_")] = value
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
"""
Returns a valid CSS property "Python" name, or None if no close matches could be found.

View File

@@ -31,6 +31,7 @@ VALID_BOX_SIZING: Final = {"border-box", "content-box"}
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
VALID_SCROLLBAR_GUTTER: Final = {"auto", "stable"}
VALID_STYLE_FLAGS: Final = {
"none",
"not",

View File

@@ -38,6 +38,7 @@ from .constants import (
VALID_DISPLAY,
VALID_VISIBILITY,
VALID_OVERFLOW,
VALID_SCROLLBAR_GUTTER,
)
from .scalar import Scalar, ScalarOffset, Unit
from .scalar_animation import ScalarAnimation
@@ -52,6 +53,7 @@ from .types import (
Specificity4,
AlignVertical,
Visibility,
ScrollbarGutter,
)
if sys.version_info >= (3, 8):
@@ -125,6 +127,8 @@ class RulesMap(TypedDict, total=False):
scrollbar_background_hover: Color
scrollbar_background_active: Color
scrollbar_gutter: ScrollbarGutter
align_horizontal: AlignHorizontal
align_vertical: AlignVertical
@@ -222,6 +226,8 @@ class StylesBase(ABC):
scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black")
scrollbar_gutter = StringEnumProperty(VALID_SCROLLBAR_GUTTER, "auto")
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
@@ -656,6 +662,8 @@ class Styles(StylesBase):
append_declaration("overflow-x", self.overflow_x)
if has_rule("overflow-y"):
append_declaration("overflow-y", self.overflow_y)
if has_rule("scrollbar-gutter"):
append_declaration("scrollbar-gutter", self.scrollbar_gutter)
if has_rule("box-sizing"):
append_declaration("box-sizing", self.box_sizing)

View File

@@ -316,7 +316,7 @@ class Stylesheet:
styles = node.styles
base_styles = styles.base
# Styles currently used an new rules
# Styles currently used on new rules
modified_rule_keys = {*base_styles.get_rules().keys(), *rules.keys()}
# Current render rules (missing rules are filled with default)
current_render_rules = styles.get_render_rules()

View File

@@ -32,6 +32,7 @@ Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"]
AlignHorizontal = Literal["left", "center", "right"]
AlignVertical = Literal["top", "middle", "bottom"]
ScrollbarGutter = Literal["auto", "stable"]
BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[str, Color]

View File

@@ -493,6 +493,9 @@ class Widget(DOMNode):
Region: The widget region minus scrollbars.
"""
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
if self.styles.scrollbar_gutter == "stable":
# Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
show_vertical_scrollbar = True
if show_horizontal_scrollbar and show_vertical_scrollbar:
(region, _, _, _) = region.split(-1, -1)
elif show_vertical_scrollbar:

View File

@@ -1,9 +1,16 @@
import sys
from decimal import Decimal
if sys.version_info >= (3, 10):
from typing import Literal
else: # pragma: no cover
from typing_extensions import Literal
import pytest
from rich.style import Style
from textual.app import ComposeResult
from textual.color import Color
from textual.css.errors import StyleValueError
from textual.css.scalar import Scalar, Unit
@@ -11,6 +18,8 @@ from textual.css.styles import Styles, RenderStyles
from textual.dom import DOMNode
from textual.widget import Widget
from tests.utilities.test_app import AppTest
def test_styles_reset():
styles = Styles()
@@ -185,3 +194,77 @@ def test_widget_style_size_fails_if_data_type_is_not_supported(size_dimension_in
with pytest.raises(StyleValueError):
widget.styles.width = size_dimension_input
@pytest.mark.asyncio
@pytest.mark.parametrize(
"overflow_y,scrollbar_gutter,text_length,expected_text_widget_width,expects_vertical_scrollbar",
(
# ------------------------------------------------
# ----- Let's start with `overflow-y: auto`:
# short text: full width, no scrollbar
["auto", "auto", "short_text", 80, False],
# long text: reduced width, scrollbar
["auto", "auto", "long_text", 79, True],
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["auto", "stable", "short_text", 79, False],
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
["auto", "stable", "long_text", 79, True],
# ------------------------------------------------
# ----- And now let's see the behaviour with `overflow-y: scroll`:
# short text: reduced width, scrollbar
["scroll", "auto", "short_text", 79, True],
# long text: reduced width, scrollbar
["scroll", "auto", "long_text", 79, True],
# short text, `scrollbar-gutter: stable`: reduced width, scrollbar
["scroll", "stable", "short_text", 79, True],
# long text, `scrollbar-gutter: stable`: reduced width, scrollbar
["scroll", "stable", "long_text", 79, True],
# ------------------------------------------------
# ----- Finally, let's check the behaviour with `overflow-y: hidden`:
# short text: full width, no scrollbar
["hidden", "auto", "short_text", 80, False],
# long text: full width, no scrollbar
["hidden", "auto", "long_text", 80, False],
# short text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["hidden", "stable", "short_text", 79, False],
# long text, `scrollbar-gutter: stable`: reduced width, no scrollbar
["hidden", "stable", "long_text", 79, False],
),
)
async def test_scrollbar_gutter(
overflow_y: str,
scrollbar_gutter: str,
text_length: Literal["short_text", "long_text"],
expected_text_widget_width: int,
expects_vertical_scrollbar: bool,
):
from rich.text import Text
from textual.geometry import Size
class TextWidget(Widget):
def render(self, styles) -> Text:
text_multiplier = 10 if text_length == "long_text" else 2
return Text(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit liber a a a."
* text_multiplier
)
container = Widget()
container.styles.height = 3
container.styles.overflow_y = overflow_y
container.styles.scrollbar_gutter = scrollbar_gutter
text_widget = TextWidget()
text_widget.styles.height = "auto"
container.add_child(text_widget)
class MyTestApp(AppTest):
def compose(self) -> ComposeResult:
yield container
app = MyTestApp(test_name="scrollbar_gutter", size=Size(80, 10))
await app.boot_and_shutdown()
assert text_widget.size.width == expected_text_widget_width
assert container.scrollbars_enabled[0] is expects_vertical_scrollbar

View File

@@ -38,6 +38,9 @@ class AppTest(App):
log_color_system="256",
)
# Let's disable all features by default
self.features = frozenset()
# We need this so the `CLEAR_SCREEN_SEQUENCE` is always sent for a screen refresh,
# whatever the environment:
self._sync_available = True
@@ -90,6 +93,19 @@ class AppTest(App):
return get_running_state_context_manager()
async def boot_and_shutdown(
self,
*,
waiting_duration_after_initialisation: float = 0.001,
waiting_duration_before_shutdown: float = 0,
):
"""Just a commodity shortcut for `async with app.in_running_state(): pass`, for simple cases"""
async with self.in_running_state(
waiting_duration_after_initialisation=waiting_duration_after_initialisation,
waiting_duration_post_yield=waiting_duration_before_shutdown,
):
pass
def run(self):
raise NotImplementedError(
"Use `async with my_test_app.in_running_state()` rather than `my_test_app.run()`"