mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
[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:
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()`"
|
||||
|
||||
Reference in New Issue
Block a user