This commit is contained in:
Will McGugan
2022-09-22 16:19:45 +01:00
parent 9ab01d3c54
commit e4dd71655f
16 changed files with 258 additions and 39 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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"),

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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"""

View File

@@ -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):

View File

@@ -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),

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -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

View File

@@ -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 {

View File

@@ -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:

View File

@@ -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

View File

@@ -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