allow scrollbars to be customized

This commit is contained in:
Will McGugan
2022-04-10 15:33:35 +01:00
parent 6c51eec5c0
commit adafda3edf
13 changed files with 248 additions and 124 deletions

View File

@@ -7,6 +7,13 @@
}
*/
* {
scrollbar-background: $panel-darken-2;
scrollbar-background-hover: $panel-darken-3;
scrollbar-color: $system;
scrollbar-color-active: $accent-darken-1;
}
App > Screen {
layout: dock;
docks: side=left/1;
@@ -31,7 +38,7 @@ App > Screen {
#sidebar .title {
height: 3;
background: $primary-darken-2;
color: $text-primary-darken-2;
color: $text-primary-darken-2 ;
border-right: outer $primary-darken-3;
}
@@ -59,36 +66,42 @@ App > Screen {
color: $text-background;
background: $background;
layout: vertical;
overflow-y:scroll;
}
Tweet {
height: 16;
max-width: 80;
margin: 1 3;
background: $surface;
color: $text-surface;
background: $panel;
color: $text-panel;
layout: vertical;
border: outer $accent2;
/* border: outer $primary; */
padding: 1;
border: wide $panel-darken-2;
overflow-y: scroll
}
TweetHeader {
height:1
background: $accent1
color: $text-accent1
background: $accent
color: $text-accent
}
TweetBody {
background: $surface
color: $text-surface-fade-1
height:6;
background: $panel;
color: $text-panel;
height:20;
padding: 0 1 0 0;
}
.button {
background: $accent2;
color: $text-accent2;
background: $accent;
color: $text-accent;
width:20;
height: 3
border: tall $text-background;
@@ -100,8 +113,8 @@ TweetBody {
}
.button:hover {
background: $accent1-darken-1;
color: $text-accent1-darken-1;
background: $success-darken-1;
color: $text-success-darken-1;
width: 20;
height: 3
border: tall $text-background;
@@ -112,10 +125,10 @@ TweetBody {
}
#footer {
color: $text-accent2;
background: $accent2;
color: $text-accent;
background: $accent;
height: 1;
border-top: hkey $accent2-darken-2;
border-top: hkey $accent-darken-2;
}
@@ -127,26 +140,29 @@ OptionItem {
height: 3;
background: $primary;
transition: background 100ms linear;
border-right: outer $primary-darken-3;
border-right: outer $primary-darken-2;
border-left: hidden;
}
OptionItem:hover {
height: 3;
background: $accent1-darken-2;
color: $accent;
background: $primary-darken-1;
/* border-top: hkey $accent2-darken-3;
border-bottom: hkey $accent2-darken-3; */
text-style: bold;
border-right: outer $accent1-darken-3;
border-left: outer $accent-darken-2;
}
Error {
max-width: 78;
max-width: 80;
height:3;
background: $error;
color: $text-error;
border-top: hkey $error-darken-3;
border-bottom: hkey $error-darken-3;
border-top: hkey $error-darken-2;
border-bottom: hkey $error-darken-2;
margin: 1 3;
text-style: bold;
}
@@ -155,8 +171,8 @@ Warning {
height:3;
background: $warning;
color: $text-warning-fade-1;
border-top: hkey $warning-darken-3;
border-bottom: hkey $warning-darken-3;
border-top: hkey $warning-darken-2;
border-bottom: hkey $warning-darken-2;
margin: 1 2;
text-style: bold;
}
@@ -164,10 +180,10 @@ Warning {
Success {
max-width: 80;
height:3;
background: $success-lighten-2;
color: $text-success-lighten-2-fade-1;
border-top: hkey $success-darken-3;
border-bottom: hkey $success-darken-3;
background: $success-lighten-3;
color: $text-success-lighten-3-fade-1;
border-top: hkey $success;
border-bottom: hkey $success;
margin: 1 2;
text-style: bold;
}

View File

@@ -7,7 +7,8 @@ from textual.widget import Widget
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 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. """
)

View File

@@ -115,12 +115,13 @@ class App(DOMNode):
self.design = ColorSystem(
primary="#406e8e", # blueish
secondary="#6d9f71", # purplesis
secondary="#6d9f71", # purpleish
warning="#ffa62b", # orange
error="#ba3c5b", # error
success="#6d9f71", # green
accent1="#ffa62b",
accent2="#5a4599",
accent="#ffa62b",
system="#5a4599"
# accent2="#5a4599",
)
self.stylesheet = Stylesheet(variables=self.get_css_variables())

View File

@@ -237,7 +237,7 @@ class Color(NamedTuple):
@classmethod
@lru_cache(maxsize=1024 * 4)
def parse(cls, color_text: str) -> Color:
def parse(cls, color_text: str | Color) -> Color:
"""Parse a string containing a CSS-style color.
Args:
@@ -249,7 +249,8 @@ class Color(NamedTuple):
Returns:
Color: New color object.
"""
if isinstance(color_text, Color):
return color_text
ansi_color = ANSI_COLOR_TO_RGB.get(color_text)
if ansi_color is not None:
return cls(*ansi_color)

View File

@@ -625,7 +625,7 @@ class NameListProperty:
def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> tuple[str, ...]:
return obj.get_rule(self.name, ())
return cast(tuple[str, ...], obj.get_rule(self.name, ()))
def __set__(self, obj: StylesBase, names: str | tuple[str] | None = None):
@@ -645,13 +645,15 @@ class NameListProperty:
class ColorProperty:
"""Descriptor for getting and setting color properties."""
def __init__(self, default_color: Color) -> None:
self._default_color = default_color
def __init__(self, default_color: Color | str) -> None:
self._default_color = Color.parse(default_color)
def __set_name__(self, owner: StylesBase, name: str) -> None:
self.name = name
def __get__(self, obj: StylesBase, objtype: type[Styles] | None = None) -> Color:
def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> Color:
"""Get a ``Color``.
Args:
@@ -661,7 +663,7 @@ class ColorProperty:
Returns:
Color: The Color
"""
return obj.get_rule(self.name, self._default_color)
return cast(Color, obj.get_rule(self.name, self._default_color))
def __set__(self, obj: StylesBase, color: Color | str | None):
"""Set the Color

View File

@@ -460,10 +460,11 @@ class StylesBuilder:
)
def process_color(self, name: str, tokens: list[Token], important: bool) -> None:
name = name.replace("-", "_")
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["color"] = Color.parse(token.value)
self.styles._rules[name] = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
@@ -476,18 +477,37 @@ class StylesBuilder:
def process_background(
self, name: str, tokens: list[Token], important: bool
) -> None:
for token in tokens:
if token.name in ("color", "token"):
try:
self.styles._rules["background"] = Color.parse(token.value)
except Exception as error:
self.error(
name, token, f"failed to parse color {token.value!r}; {error}"
)
else:
self.error(
name, token, f"unexpected token {token.value!r} in declaration"
)
self.process_color(name, tokens, important)
def process_scrollbar_color(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_scrollbar_color_hover(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_scrollbar_color_active(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_scrollbar_background(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_scrollbar_background_hover(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_scrollbar_background_active(
self, name: str, tokens: list[Token], important: bool
) -> None:
self.process_color(name, tokens, important)
def process_text_style(
self, name: str, tokens: list[Token], important: bool

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from typing import Iterable, TYPE_CHECKING
from .model import CombinatorType, Selector, SelectorSet, SelectorType
from .model import CombinatorType, Selector, SelectorSet
if TYPE_CHECKING:

View File

@@ -28,14 +28,17 @@ class CombinatorType(Enum):
CHILD = 3
@dataclass
class Location:
line: tuple[int, int]
column: tuple[int, int]
@dataclass
class Selector:
"""Represents a CSS selector.
Some examples of selectors:
*
Header.title
App > Content
"""
name: str
combinator: CombinatorType = CombinatorType.DESCENDENT
type: SelectorType = SelectorType.TYPE
@@ -46,6 +49,7 @@ class Selector:
@property
def css(self) -> str:
"""Rebuilds the selector as it would appear in CSS."""
pseudo_suffix = "".join(f":{name}" for name in self.pseudo_classes)
if self.type == SelectorType.UNIVERSAL:
return "*"

View File

@@ -108,6 +108,14 @@ class RulesMap(TypedDict, total=False):
transitions: dict[str, Transition]
scrollbar_color: Color
scrollbar_color_hover: Color
scrollbar_color_active: Color
scrollbar_background: Color
scrollbar_background_hover: Color
scrollbar_background_active: Color
RULE_NAMES = list(RulesMap.__annotations__.keys())
_rule_getter = attrgetter(*RULE_NAMES)
@@ -134,6 +142,12 @@ class StylesBase(ABC):
"max_height",
"color",
"background",
"scrollbar_color",
"scrollbar_color_hover",
"scrollbar_color_active",
"scrollbar_background",
"scrollbar_background_hover",
"scrollbar_background_active",
}
display = StringEnumProperty(VALID_DISPLAY, "block")
@@ -182,6 +196,14 @@ class StylesBase(ABC):
rich_style = StyleProperty()
scrollbar_color = ColorProperty("bright_magenta")
scrollbar_color_hover = ColorProperty("yellow")
scrollbar_color_active = ColorProperty("bright_yellow")
scrollbar_background = ColorProperty("#555555")
scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black")
def __eq__(self, styles: object) -> bool:
"""Check that Styles containts the same rules."""
if not isinstance(styles, StylesBase):

View File

@@ -11,10 +11,13 @@ from .color import Color, WHITE
NUMBER_OF_SHADES = 3
# Where no content exists
DEFAULT_DARK_BACKGROUND = "#000000"
# What text usually goes on top off
DEFAULT_DARK_SURFACE = "#121212"
DEFAULT_LIGHT_BACKGROUND = "#f5f5f5"
DEFAULT_LIGHT_SURFACE = "#efefef"
DEFAULT_LIGHT_SURFACE = "#f5f5f5"
DEFAULT_LIGHT_BACKGROUND = "#efefef"
class ColorProperty:
@@ -56,8 +59,9 @@ class ColorSystem:
"warning",
"error",
"success",
"accent1",
"accent2",
"accent",
"panel",
"system",
]
def __init__(
@@ -67,20 +71,22 @@ class ColorSystem:
warning: str | None = None,
error: str | None = None,
success: str | None = None,
accent1: str | None = None,
accent2: str | None = None,
accent: str | None = None,
system: str | None = None,
background: str | None = None,
surface: str | None = None,
panel: str | None = None,
):
self._primary = primary
self._secondary = secondary
self._warning = warning
self._error = error
self._success = success
self._accent1 = accent1
self._accent2 = accent2
self._accent = accent
self._system = system
self._background = background
self._surface = surface
self._panel = panel
@property
def primary(self) -> Color:
@@ -91,10 +97,11 @@ class ColorSystem:
warning = ColorProperty()
error = ColorProperty()
success = ColorProperty()
accent1 = ColorProperty()
accent2 = ColorProperty()
accent = ColorProperty()
system = ColorProperty()
surface = ColorProperty()
background = ColorProperty()
panel = ColorProperty()
@property
def shades(self) -> Iterable[str]:
@@ -132,8 +139,8 @@ class ColorSystem:
warning = self.warning or primary
error = self.error or secondary
success = self.success or secondary
accent1 = self.accent1 or primary
accent2 = self.accent2 or secondary
accent = self.accent or primary
system = self.system or accent
background = self.background or Color.parse(
DEFAULT_DARK_BACKGROUND if dark else DEFAULT_LIGHT_BACKGROUND
@@ -143,6 +150,15 @@ class ColorSystem:
DEFAULT_DARK_SURFACE if dark else DEFAULT_LIGHT_SURFACE
)
text = background.get_contrast_text(1.0)
if self.panel is None:
if dark:
panel = background.blend(text, luminosity_spread)
else:
panel = background.blend(text, luminosity_spread / 2)
else:
panel = self.panel
colors: dict[str, str] = {}
def luminosity_range(spread) -> Iterable[tuple[str, float]]:
@@ -167,12 +183,13 @@ class ColorSystem:
("primary", primary),
("secondary", secondary),
("background", background),
("panel", panel),
("surface", surface),
("warning", warning),
("error", error),
("success", success),
("accent1", accent1),
("accent2", accent2),
("accent", accent),
("system", system),
]
# Colors names that have a dark variant
@@ -181,9 +198,11 @@ class ColorSystem:
for name, color in COLORS:
is_dark_shade = dark and name in DARK_SHADES
spread = luminosity_spread / 1.5 if is_dark_shade else luminosity_spread
if name == "panel":
spread /= 2
for shade_name, luminosity_delta in luminosity_range(spread):
if is_dark_shade:
dark_background = background.blend(color, 12 / 100)
dark_background = background.blend(color, 0.15)
shade_color = dark_background.blend(
WHITE, spread + luminosity_delta
).clamped
@@ -227,11 +246,13 @@ class ColorSystem:
if __name__ == "__main__":
color_system = ColorSystem(
primary="#005EA8",
secondary="#F59402",
warning="#ffa000",
error="#C62828",
success="#558B2F",
primary="#406e8e", # blueish
secondary="#6d9f71", # purpleish
warning="#ffa62b", # orange
error="#ba3c5b", # error
success="#6d9f71", # green
accent="#ffa62b",
system="#5a4599",
)
from rich import print

View File

@@ -311,6 +311,9 @@ class MessagePump:
async def post_priority_message(self, message: Message) -> bool:
"""Post a "priority" messages which will be processes prior to regular messages.
Note that you should rarely need this in a regular app. It exists primarily to allow
timer messages to skip the queue, so that they can be more regular.
Args:
message (Message): A message.
@@ -344,7 +347,6 @@ class MessagePump:
async def on_callback(self, event: events.Callback) -> None:
await invoke(event.callback)
# await event.callback()
def emit_no_wait(self, message: Message) -> bool:
if self._parent:

View File

@@ -206,9 +206,18 @@ class ScrollBar(Widget):
yield "position", self.position
def render(self) -> RenderableType:
styles = self.parent.styles
style = Style(
bgcolor=(Color.parse("#555555" if self.mouse_over else "#444444")),
color=Color.parse("bright_yellow" if self.grabbed else "bright_magenta"),
bgcolor=(
styles.scrollbar_background_hover.rich_color
if self.mouse_over
else styles.scrollbar_background.rich_color
),
color=(
styles.scrollbar_color_active.rich_color
if self.grabbed
else styles.scrollbar_color.rich_color
),
)
return ScrollBarRender(
virtual_size=self.window_virtual_size,

View File

@@ -220,7 +220,7 @@ class Widget(DOMNode):
y: float | None = None,
*,
animate: bool = True,
):
) -> bool:
"""Scroll to a given (absolute) coordinate, optionally animating.
Args:
@@ -229,61 +229,73 @@ class Widget(DOMNode):
animate (bool, optional): Animate to new scroll position. Defaults to False.
"""
scrolled_x = False
scrolled_y = False
if animate:
# TODO: configure animation speed
if x is not None:
self.scroll_target_x = x
self.animate(
"scroll_x", self.scroll_target_x, speed=80, easing="out_cubic"
)
if x != self.scroll_x:
self.animate(
"scroll_x", self.scroll_target_x, speed=80, easing="out_cubic"
)
scrolled_x = True
if y is not None:
self.scroll_target_y = y
self.animate(
"scroll_y", self.scroll_target_y, speed=80, easing="out_cubic"
)
if y != self.scroll_y:
self.animate(
"scroll_y", self.scroll_target_y, speed=80, easing="out_cubic"
)
scrolled_y = True
else:
if x is not None:
self.scroll_target_x = self.scroll_x = x
if x != self.scroll_x:
scrolled_x = True
if y is not None:
self.scroll_target_y = self.scroll_y = y
self.refresh(layout=True)
if y != self.scroll_y:
scrolled_y = True
self.refresh(repaint=False, layout=True)
return scrolled_x or scrolled_y
def scroll_home(self, animate: bool = True) -> None:
self.scroll_to(0, 0, animate=animate)
def scroll_home(self, animate: bool = True) -> bool:
return self.scroll_to(0, 0, animate=animate)
def scroll_end(self, animate: bool = True) -> None:
self.scroll_to(0, self.max_scroll_y, animate=animate)
def scroll_end(self, animate: bool = True) -> bool:
return self.scroll_to(0, self.max_scroll_y, animate=animate)
def scroll_left(self, animate: bool = True) -> None:
self.scroll_to(x=self.scroll_target_x - 1.5, animate=animate)
def scroll_left(self, animate: bool = True) -> bool:
return self.scroll_to(x=self.scroll_target_x - 1, animate=animate)
def scroll_right(self, animate: bool = True) -> None:
self.scroll_to(x=self.scroll_target_x + 1.5, animate=animate)
def scroll_right(self, animate: bool = True) -> bool:
return self.scroll_to(x=self.scroll_target_x + 1, animate=animate)
def scroll_up(self, animate: bool = True) -> None:
self.scroll_to(y=self.scroll_target_y + 1.5, animate=animate)
def scroll_up(self, animate: bool = True) -> bool:
return self.scroll_to(y=self.scroll_target_y + 1, animate=animate)
def scroll_down(self, animate: bool = True) -> None:
self.scroll_to(y=self.scroll_target_y - 1.5, animate=animate)
def scroll_down(self, animate: bool = True) -> bool:
return self.scroll_to(y=self.scroll_target_y - 1, animate=animate)
def scroll_page_up(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_up(self, animate: bool = True) -> bool:
return self.scroll_to(
y=self.scroll_target_y - self.container_size.height, animate=animate
)
def scroll_page_down(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_down(self, animate: bool = True) -> bool:
return self.scroll_to(
y=self.scroll_target_y + self.container_size.height, animate=animate
)
def scroll_page_left(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_left(self, animate: bool = True) -> bool:
return self.scroll_to(
x=self.scroll_target_x - self.container_size.width, animate=animate
)
def scroll_page_right(self, animate: bool = True) -> None:
self.scroll_to(
def scroll_page_right(self, animate: bool = True) -> bool:
return self.scroll_to(
x=self.scroll_target_x + self.container_size.width, animate=animate
)
@@ -525,6 +537,9 @@ class Widget(DOMNode):
"""
if self._dirty_regions:
self._render_lines()
if self.is_container:
self.horizontal_scrollbar.refresh()
self.vertical_scrollbar.refresh()
lines = self._render_cache.lines[start:end]
return lines
@@ -639,30 +654,40 @@ class Widget(DOMNode):
def on_enter(self) -> None:
self.mouse_over = True
def on_mouse_scroll_down(self) -> None:
self.scroll_down(animate=False)
def on_mouse_scroll_down(self, event) -> None:
if self.is_container:
if not self.scroll_down(animate=False):
event.stop()
def on_mouse_scroll_up(self) -> None:
self.scroll_up(animate=False)
def on_mouse_scroll_up(self, event) -> None:
if self.is_container:
if not self.scroll_up(animate=False):
event.stop()
def handle_scroll_to(self, message: ScrollTo) -> None:
self.scroll_to(message.x, message.y, animate=message.animate)
if self.is_container:
self.scroll_to(message.x, message.y, animate=message.animate)
message.stop()
def handle_scroll_up(self, event: ScrollUp) -> None:
self.scroll_page_up()
event.stop()
if self.is_container:
self.scroll_page_up()
event.stop()
def handle_scroll_down(self, event: ScrollDown) -> None:
self.scroll_page_down()
event.stop()
if self.is_container:
self.scroll_page_down()
event.stop()
def handle_scroll_left(self, event: ScrollLeft) -> None:
self.scroll_page_left()
event.stop()
if self.is_container:
self.scroll_page_left()
event.stop()
def handle_scroll_right(self, event: ScrollRight) -> None:
self.scroll_page_right()
event.stop()
if self.is_container:
self.scroll_page_right()
event.stop()
def key_home(self) -> bool:
if self.is_container: