diff --git a/docs/guide/widgets.md b/docs/guide/widgets.md index 66bcfea23..8f990930b 100644 --- a/docs/guide/widgets.md +++ b/docs/guide/widgets.md @@ -94,7 +94,7 @@ This app will "play" fizz buzz by displaying a table of the first 15 numbers and === "fizzbuzz.css" - ```sass title="hello03.css" hl_lines="32-35" + ```sass title="fizzbuzz.css" hl_lines="32-35" --8<-- "docs/examples/guide/widgets/fizzbuzz.css" ``` @@ -103,18 +103,49 @@ This app will "play" fizz buzz by displaying a table of the first 15 numbers and ```{.textual path="docs/examples/guide/widgets/fizzbuzz.py"} ``` + +## Default CSS + +When building an app it is best to keep all your CSS in an external file. This allows you to see all your CSS in one place, and to enable live editing. However if you are building Textual widgets in an external library it can be convenient to bundle code and CSS within the widget itself. You can do this by adding a `DEFAULT_CSS` class variable inside your widget class. + +Textual's builtin widgets bundle CSS in this way, which is why you can see nicely styled widgets without having to cut and paste code in to your CSS file. + +Here's the Hello example again, this time the widget has embedded default CSS: + +=== "hello04.py" + + ```python title="hello04.py" hl_lines="8-18" + --8<-- "docs/examples/guide/widgets/hello04.py" + ``` + +=== "hello04.css" + + ```sass title="hello04.css" + --8<-- "docs/examples/guide/widgets/hello04.css" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/widgets/hello04.py"} + ``` + + + +### Default specificity + +CSS defined within `DEFAULT_CSS` has an automatically lower [specificity](./CSS.md#specificity) than CSS read from either the App's `CSS` class variable or an external stylesheet. In practice this means that your app's CSS will take precedence over any CSS bundled with widgets. + ## Content size If you use a rich renderable as content, Textual can auto-detect the dimensions of the output which will become the content area of the widget. +## Compound widgets +## Line API + TODO: Widgets docs -- What is a widget -- Defining a basic widget - - Base classes Widget or Static - - Text widgets - - Rich renderable widgets -- Complete widget -- Render line widget API +- Content size +- Compound widgets +- Line API diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index fbbbbba61..51db0cbc4 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -6,6 +6,9 @@ transition: color 300ms linear, background 300ms linear; } +Tweet.tall { + height: 24; +} *:hover { /* tint: 30% red; @@ -47,8 +50,8 @@ DataTable { /*border:heavy red;*/ /* tint: 10% green; */ /* text-opacity: 50%; */ - background: $surface; - padding: 1 2; + + margin: 1 2; height: 24; } @@ -118,6 +121,7 @@ Tweet { .scrollable { overflow-x: auto; overflow-y: scroll; + padding: 0 2; margin: 1 2; height: 24; align-horizontal: center; @@ -125,8 +129,7 @@ Tweet { } .code { - height: auto; - + height: auto; } diff --git a/sandbox/will/basic.py b/sandbox/will/basic.py index 0b89151fa..7545a4d47 100644 --- a/sandbox/will/basic.py +++ b/sandbox/will/basic.py @@ -124,17 +124,18 @@ class BasicApp(App): yield Vertical( Tweet(TweetBody()), - Container( + Tweet( Static( Syntax( CODE, "python", + theme="ansi_dark", line_numbers=True, indent_guides=True, ), classes="code", ), - classes="scrollable", + classes="tall", ), Container(table, id="table-container"), Container(DirectoryTree("~/"), id="tree-container"), diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 074154056..9a5cd1f86 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -14,7 +14,8 @@ without having to render the entire screen. from __future__ import annotations from itertools import chain -from operator import itemgetter +from functools import reduce +from operator import itemgetter, __or__ import sys from typing import Callable, cast, Iterator, Iterable, NamedTuple, TYPE_CHECKING @@ -71,13 +72,33 @@ class MapGeometry(NamedTuple): CompositorMap: TypeAlias = "dict[Widget, MapGeometry]" +def style_links( + segments: Iterable[Segment], link_map: dict[str, Style] +) -> Iterable[Segment]: + return segments + if not link_map: + return segments + + _Segment = Segment + link_map_get = link_map.get + + segments = [ + _Segment(text, link_map_get(style.link_id, style) if style else None, control) + for text, style, control in segments + ] + return segments + + @rich.repr.auto(angular=True) class LayoutUpdate: """A renderable containing the result of a render for a given region.""" - def __init__(self, lines: Lines, region: Region) -> None: + def __init__( + self, lines: Lines, region: Region, link_map: dict[str, Style] + ) -> None: self.lines = lines self.region = region + self.link_map = link_map def __rich_console__( self, console: Console, options: ConsoleOptions @@ -87,7 +108,7 @@ class LayoutUpdate: move_to = Control.move_to for last, (y, line) in loop_last(enumerate(self.lines, self.region.y)): yield move_to(x, y) - yield from line + yield from style_links(line, self.link_map) if not last: yield new_line @@ -104,6 +125,7 @@ class ChopsUpdate: chops: list[dict[int, list[Segment] | None]], spans: list[tuple[int, int, int]], chop_ends: list[list[int]], + link_map: dict[str, Style], ) -> None: """A renderable which updates chops (fragments of lines). @@ -115,6 +137,7 @@ class ChopsUpdate: self.chops = chops self.spans = spans self.chop_ends = chop_ends + self.link_map = link_map def __rich_console__( self, console: Console, options: ConsoleOptions @@ -126,6 +149,7 @@ class ChopsUpdate: last_y = self.spans[-1][0] _cell_len = cell_len + link_map = self.link_map for y, x1, x2 in self.spans: line = chops[y] @@ -135,6 +159,8 @@ class ChopsUpdate: if segments is None: continue + segments = style_links(segments, link_map) + if x > x2 or end <= x1: continue @@ -203,6 +229,23 @@ class Compositor: # Regions that require an update self._dirty_regions: set[Region] = set() + self._link_map: dict[str, Style] | None = None + + @property + def link_map(self) -> dict[str, Style]: + """A mapping of link ids on to styles.""" + if self._link_map is None: + self._link_map = cast( + "dict[str,Style]", + reduce( + __or__, + (widget._link_styles for widget in self.map.keys()), + {}, + ), + ) + + return self._link_map + @classmethod def _regions_to_spans( cls, regions: Iterable[Region] @@ -257,6 +300,7 @@ class Compositor: """ self._cuts = None self._layers = None + self._link_map = None self.root = parent self.size = size @@ -744,10 +788,10 @@ class Compositor: if full: render_lines = self._assemble_chops(chops) - return LayoutUpdate(render_lines, screen_region) + return LayoutUpdate(render_lines, screen_region, self.link_map) else: chop_ends = [cut_set[1:] for cut_set in cuts] - return ChopsUpdate(chops, spans, chop_ends) + return ChopsUpdate(chops, spans, chop_ends, self.link_map) def __rich_console__( self, console: Console, options: ConsoleOptions @@ -775,3 +819,4 @@ class Compositor: add_region(update_region) self._dirty_regions.update(regions) + self._link_map = None diff --git a/src/textual/app.py b/src/textual/app.py index 0baa862de..328c9cf3f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1314,7 +1314,8 @@ class App(Generic[ReturnType], DOMNode): def bell(self) -> None: """Play the console 'bell'.""" - self.console.bell() + if not self.is_headless: + self.console.bell() async def press(self, key: str) -> bool: """Handle a key press. diff --git a/src/textual/color.py b/src/textual/color.py index e58eea8f7..ea2c9c768 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -184,6 +184,12 @@ class Color(NamedTuple): ), ) + @property + def inverse(self) -> Color: + """The inverse of this color.""" + r, g, b, a = self + return Color(255 - r, 255 - g, 255 - b, a) + @property def is_transparent(self) -> bool: """Check if the color is transparent, i.e. has 0 alpha. diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index b379e85d4..1d9cbb3a5 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -616,6 +616,11 @@ class StylesBuilder: process_scrollbar_background_hover = process_color process_scrollbar_background_active = process_color + process_link_color = process_color + process_link_background = process_color + process_hover_color = process_color + process_hover_background = process_color + def process_text_style(self, name: str, tokens: list[Token]) -> None: for token in tokens: value = token.value @@ -627,7 +632,10 @@ class StylesBuilder: ) style_definition = " ".join(token.value for token in tokens) - self.styles.text_style = style_definition + self.styles._rules[name.replace("-", "_")] = style_definition + + process_link_style = process_text_style + process_hover_style = process_text_style def process_text_align(self, name: str, tokens: list[Token]) -> None: """Process a text-align declaration""" diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 0c94e445c..be24a3d37 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -160,6 +160,14 @@ class RulesMap(TypedDict, total=False): text_align: TextAlign + link_color: Color + link_background: Color + link_style: Style + + hover_color: Color + hover_background: Color + hover_style: Style + RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) @@ -197,6 +205,10 @@ class StylesBase(ABC): "scrollbar_background", "scrollbar_background_hover", "scrollbar_background_active", + "link_color", + "link_background", + "hover_color", + "hover_background", } node: DOMNode | None = None @@ -284,6 +296,14 @@ class StylesBase(ABC): text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start") + link_color = ColorProperty("transparent") + link_background = ColorProperty("transparent") + link_style = StyleFlagsProperty() + + hover_color = ColorProperty("transparent") + hover_background = ColorProperty("transparent") + hover_style = StyleFlagsProperty() + def __eq__(self, styles: object) -> bool: """Check that Styles contains the same rules.""" if not isinstance(styles, StylesBase): diff --git a/src/textual/design.py b/src/textual/design.py index 4d24ceddf..f8463d5e5 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -115,7 +115,6 @@ class ColorSystem: dark = self._dark luminosity_spread = self._luminosity_spread - text_alpha = self._text_alpha if dark: background = self.background or Color.parse(DEFAULT_DARK_BACKGROUND) @@ -124,6 +123,8 @@ class ColorSystem: background = self.background or Color.parse(DEFAULT_LIGHT_BACKGROUND) surface = self.surface or Color.parse(DEFAULT_LIGHT_SURFACE) + foreground = background.inverse + boost = self.boost or background.get_contrast_text(1.0).with_alpha(0.07) if self.panel is None: @@ -159,6 +160,7 @@ class ColorSystem: ("primary-background", primary), ("secondary-background", secondary), ("background", background), + ("foregroud", foreground), ("panel", panel), ("boost", boost), ("surface", surface), diff --git a/src/textual/screen.py b/src/textual/screen.py index c1a1e66e5..355769a49 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -280,6 +280,7 @@ class Screen(Widget): screen_y=event.screen_y, style=event.style, ) + widget.hover_style = event.style mouse_event._set_forwarded() await widget._forward_event(mouse_event) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index d5b989d32..988a7794b 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -6,7 +6,7 @@ import rich.repr from rich.color import Color from rich.console import ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment, Segments -from rich.style import Style, StyleType +from rich.style import NULL_STYLE, Style, StyleType from . import events from ._types import MessageTarget @@ -118,8 +118,8 @@ class ScrollBarRender: start_index, start_bar = divmod(max(0, start), len_bars) end_index, end_bar = divmod(max(0, end), len_bars) - upper = {"@click": "scroll_up"} - lower = {"@click": "scroll_down"} + upper = {"@mouse.up": "scroll_up"} + lower = {"@mouse.up": "scroll_down"} upper_back_segment = Segment(blank, _Style(bgcolor=back, meta=upper)) lower_back_segment = Segment(blank, _Style(bgcolor=back, meta=lower)) @@ -189,6 +189,17 @@ class ScrollBarRender: @rich.repr.auto class ScrollBar(Widget): + + DEFAULT_CSS = """ + ScrollBar { + hover-color: ; + hover-background:; + hover-style: ; + link-color: transparent; + link-background: transparent; + } + """ + def __init__( self, vertical: bool = True, name: str | None = None, *, thickness: int = 1 ) -> None: @@ -211,6 +222,17 @@ class ScrollBar(Widget): if self.thickness > 1: yield "thickness", self.thickness + @property + def link_style(self) -> Style: + return NULL_STYLE + + @property + def link_hover_style(self) -> Style: + return NULL_STYLE + + def watch_hover_style(self, old_style: Style, new_style: Style) -> None: + pass + def render(self) -> RenderableType: styles = self.parent.styles background = ( diff --git a/src/textual/widget.py b/src/textual/widget.py index 7ec579ca0..dabe765b9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -10,13 +10,14 @@ import rich.repr from rich.console import ( Console, ConsoleRenderable, + ConsoleOptions, RichCast, JustifyMethod, RenderableType, + RenderResult, ) from rich.segment import Segment -from rich.style import Style -from rich.styled import Styled +from rich.style import Style, StyleType from rich.text import Text from . import errors, events, messages @@ -57,6 +58,49 @@ _JUSTIFY_MAP: dict[str, JustifyMethod] = { } +class _Styled: + """Apply a style to a renderable. + + Args: + renderable (RenderableType): Any renderable. + style (StyleType): A style to apply across the entire renderable. + """ + + def __init__( + self, renderable: "RenderableType", style: Style, link_style: Style + ) -> None: + self.renderable = renderable + self.style = style + self.link_style = link_style + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> "RenderResult": + style = console.get_style(self.style) + result_segments = console.render(self.renderable, options) + + _Segment = Segment + if style: + apply = style.__add__ + result_segments = ( + _Segment(text, apply(_style), control) + for text, _style, control in result_segments + ) + link_style = self.link_style + if link_style: + result_segments = ( + _Segment( + text, + style + if style._meta is None + else (link_style + style if "click" in style.meta else style), + control, + ) + for text, style, control in result_segments + ) + return result_segments + + class RenderCache(NamedTuple): """Stores results of a previous render.""" @@ -82,6 +126,9 @@ class Widget(DOMNode): scrollbar-corner-color: $panel-darken-1; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; + link-style: underline; + hover-background: $boost; + hover-style: not underline; } """ COMPONENT_CLASSES: ClassVar[set[str]] = set() @@ -95,6 +142,8 @@ class Widget(DOMNode): shrink = Reactive(True) """Rich renderable may shrink.""" + hover_style: Reactive[Style] = Reactive(Style) + def __init__( self, *children: Widget, @@ -131,7 +180,7 @@ class Widget(DOMNode): self._styles_cache = StylesCache() self._rich_style_cache: dict[str, Style] = {} - + self._link_styles: dict[str, Style] = {} self._lock = Lock() super().__init__( @@ -383,6 +432,13 @@ class Widget(DOMNode): return height + def watch_hover_style(self, old_style: Style, new_style: Style) -> None: + self._link_styles.pop(old_style.link_id, None) + if new_style.link_id: + meta = new_style.meta + if "@click" in meta: + self._link_styles[new_style.link_id] = self.link_hover_style + def watch_scroll_x(self, new_value: float) -> None: self.horizontal_scrollbar.position = int(new_value) self.refresh(layout=True) @@ -798,6 +854,26 @@ class Widget(DOMNode): return node.styles.layers return ("default",) + @property + def link_style(self) -> Style: + styles = self.styles + base, _, background, color = self.colors + style = styles.link_style + Style.from_color( + (color + styles.link_color).rich_color, + (background + styles.link_background).rich_color, + ) + return style + + @property + def link_hover_style(self) -> Style: + styles = self.styles + base, _, background, color = self.colors + style = styles.hover_style + Style.from_color( + (color + styles.hover_color).rich_color, + (background + styles.hover_background).rich_color, + ) + return style + def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). @@ -1413,7 +1489,7 @@ class Widget(DOMNode): ): renderable.justify = text_justify - renderable = Styled(renderable, self.rich_style) + renderable = _Styled(renderable, self.rich_style, self.link_style) return renderable diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 8c8141727..2a865d8bc 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -107,8 +107,11 @@ class Coord(NamedTuple): class DataTable(ScrollView, Generic[CellType], can_focus=True): DEFAULT_CSS = """ + App.-dark DataTable { + background:; + } DataTable { - + background: $surface ; color: $text; } DataTable > .datatable--header { diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index 34c99629d..d9ff421c9 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -79,20 +79,20 @@ class Header(Widget): color: $text; height: 1; } - Header.tall { + Header.-tall { height: 3; } """ tall = Reactive(True) - DEFAULT_CLASSES = "tall" + DEFAULT_CLASSES = "-tall" def watch_tall(self, tall: bool) -> None: - self.set_class(tall, "tall") + self.set_class(tall, "-tall") async def on_click(self, event): - self.toggle_class("tall") + self.toggle_class("-tall") def on_mount(self) -> None: def set_title(title: str) -> None: diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index c51858ec4..3e75adad8 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -86,11 +86,11 @@ class Static(Widget): """ return self._renderable - def update(self, renderable: RenderableType) -> None: + def update(self, renderable: RenderableType = "") -> None: """Update the widget's content area with new text or Rich renderable. Args: - renderable (RenderableType, optional): A new rich renderable. + renderable (RenderableType, optional): A new rich renderable. Defaults to empty renderable; """ _check_renderable(renderable) self.renderable = renderable diff --git a/src/textual/widgets/_tree_control.py b/src/textual/widgets/_tree_control.py index a6d7e477b..2a46f1a1c 100644 --- a/src/textual/widgets/_tree_control.py +++ b/src/textual/widgets/_tree_control.py @@ -164,10 +164,10 @@ class TreeNode(Generic[NodeDataType]): class TreeControl(Generic[NodeDataType], Static, can_focus=True): DEFAULT_CSS = """ TreeControl { - color: $text; height: auto; width: 100%; + link-style: not underline; } TreeControl > .tree--guides { @@ -324,8 +324,8 @@ class TreeControl(Generic[NodeDataType], Static, can_focus=True): if isinstance(node.label, str) else node.label ) - if node.id == self.hover_node: - label.stylize("underline") + # if node.id == self.hover_node: + # label.stylize("underline") label.apply_meta({"@click": f"click_label({node.id})", "tree_node": node.id}) return label