tint renderable + ccs + tab focus

This commit is contained in:
Will McGugan
2022-05-03 17:27:01 +01:00
parent 6fba64face
commit 375a18c0b1
12 changed files with 215 additions and 19 deletions

View File

@@ -14,10 +14,11 @@ class ButtonsApp(App[str]):
def handle_pressed(self, event: Button.Pressed) -> None: def handle_pressed(self, event: Button.Pressed) -> None:
self.app.bell() self.app.bell()
self.log("pressed", event.button.id)
self.exit(event.button.id) self.exit(event.button.id)
app = ButtonsApp(log="textual.log") app = ButtonsApp(log="textual.log", log_verbosity=2)
if __name__ == "__main__": if __name__ == "__main__":
result = app.run() result = app.run()

View File

@@ -87,6 +87,10 @@ class Timer:
self._task = get_event_loop().create_task(self._run()) self._task = get_event_loop().create_task(self._run())
return self._task return self._task
def stop_no_wait(self) -> None:
"""Stop the timer, and block until it exists."""
self._task.cancel()
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the timer, and block until it exists.""" """Stop the timer, and block until it exists."""
self._task.cancel() self._task.cancel()

View File

@@ -9,7 +9,16 @@ import warnings
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from contextlib import redirect_stdout from contextlib import redirect_stdout
from time import perf_counter from time import perf_counter
from typing import Any, Generic, Iterable, TextIO, Type, TypeVar, TYPE_CHECKING from typing import (
Any,
Generic,
Iterable,
Iterator,
TextIO,
Type,
TypeVar,
TYPE_CHECKING,
)
import rich import rich
import rich.repr import rich.repr
@@ -29,6 +38,7 @@ from ._callback import invoke
from ._context import active_app from ._context import active_app
from ._event_broker import extract_handler_actions, NoHandler from ._event_broker import extract_handler_actions, NoHandler
from ._profile import timer from ._profile import timer
from ._timer import Timer
from .binding import Bindings, NoBinding from .binding import Bindings, NoBinding
from .css.stylesheet import Stylesheet, StylesheetError from .css.stylesheet import Stylesheet, StylesheetError
from .design import ColorSystem from .design import ColorSystem
@@ -163,12 +173,10 @@ class App(Generic[ReturnType], DOMNode):
self.css = css self.css = css
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", "")) self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
self.registry: set[MessagePump] = set() self.registry: set[MessagePump] = set()
self.devtools = DevtoolsClient() self.devtools = DevtoolsClient()
self._return_value: ReturnType | None = None self._return_value: ReturnType | None = None
self._focus_timer: Timer | None = None
super().__init__() super().__init__()
@@ -196,6 +204,80 @@ class App(Generic[ReturnType], DOMNode):
self._return_value = result self._return_value = result
self.close_messages_no_wait() self.close_messages_no_wait()
@property
def _focusable_widgets(self) -> list[Widget]:
"""Get widgets that may receive focus"""
widgets: list[Widget] = []
add_widget = widgets.append
root = self.screen
stack: list[Iterator[Widget]] = [iter(root.children)]
pop = stack.pop
push = stack.append
while stack:
node = next(stack[-1], None)
if node is None:
pop()
else:
if node.is_container:
push(iter(node.children))
else:
if node.can_focus:
add_widget(node)
return widgets
def show_focus(self) -> None:
"""Highlight the currently focused widget."""
self.move_focus(0)
def move_focus(self, direction: int = 0) -> None:
"""Move the focus in the given direction.
Args:
direction (int, optional): 1 to move forward, -1 to move backward, or
0 to highlight the current focus.
"""
if self._focus_timer:
self._focus_timer.stop_no_wait()
focusable_widgets = self._focusable_widgets
if not focusable_widgets:
# Nothing focusable, so nothing to do
return
if self.focused is None:
# Nothing currently focused, so focus the first one
self.set_focus(focusable_widgets[0])
else:
try:
# Find the index of the currently focused widget
current_index = focusable_widgets.index(self.focused)
except ValueError:
# Focused widget was removed in the interim, start again
self.set_focus(focusable_widgets[0])
else:
# Only move the focus if we are currently showing the focus
if direction and self.has_class("-show-focus"):
current_index = (current_index + direction) % len(focusable_widgets)
self.set_focus(focusable_widgets[current_index])
self._focus_timer = self.set_timer(3, self.hide_focus)
self.add_class("-show-focus")
self.screen.refresh_layout()
def focus_next(self) -> None:
"""Focus the next widget."""
self.move_focus(1)
def focus_previous(self) -> None:
"""Focus the previous widget."""
self.move_focus(-1)
def hide_focus(self) -> None:
"""Hide the focus."""
self.remove_class("-show-focus")
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Yield child widgets for a container.""" """Yield child widgets for a container."""
return return
@@ -405,13 +487,15 @@ class App(Generic[ReturnType], DOMNode):
self._screen_stack.append(screen) self._screen_stack.append(screen)
return screen return screen
async def set_focus(self, widget: Widget | None) -> None: def set_focus(self, widget: Widget | None) -> None:
"""Focus (or unfocus) a widget. A focused widget will receive key events first. """Focus (or unfocus) a widget. A focused widget will receive key events first.
Args: Args:
widget (Widget): [description] widget (Widget): [description]
""" """
self.log("set_focus", widget)
if widget == self.focused: if widget == self.focused:
self.log("already focused")
# Widget is already focused # Widget is already focused
return return
@@ -419,13 +503,13 @@ class App(Generic[ReturnType], DOMNode):
if self.focused is not None: if self.focused is not None:
focused = self.focused focused = self.focused
self.focused = None self.focused = None
await focused.post_message(events.Blur(self)) focused.post_message_no_wait(events.Blur(self))
elif widget.can_focus: elif widget.can_focus:
if self.focused is not None: if self.focused is not None:
await self.focused.post_message(events.Blur(self)) self.focused.post_message_no_wait(events.Blur(self))
if widget is not None and self.focused != widget: if widget is not None and self.focused != widget:
self.focused = widget self.focused = widget
await widget.post_message(events.Focus(self)) widget.post_message_no_wait(events.Focus(self))
async def set_mouse_over(self, widget: Widget | None) -> None: async def set_mouse_over(self, widget: Widget | None) -> None:
if widget is None: if widget is None:
@@ -845,7 +929,12 @@ class App(Generic[ReturnType], DOMNode):
self.app.refresh() self.app.refresh()
async def on_key(self, event: events.Key) -> None: async def on_key(self, event: events.Key) -> None:
await self.press(event.key) if event.key == "tab":
self.focus_next()
elif event.key == "shift+tab":
self.focus_previous()
else:
await self.press(event.key)
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None: async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
log("shutdown request") log("shutdown request")

View File

@@ -154,6 +154,12 @@ class Color(NamedTuple):
r, g, b, _a = self r, g, b, _a = self
return (r / 255, g / 255, b / 255) return (r / 255, g / 255, b / 255)
@property
def rgb(self) -> tuple[int, int, int]:
"""Get just the red, green, and blue components."""
r, g, b, _ = self
return (r, g, b)
@property @property
def hls(self) -> HLS: def hls(self) -> HLS:
"""Get the color as HLS.""" """Get the color as HLS."""

View File

@@ -524,10 +524,20 @@ class StylesBuilder:
def process_color(self, name: str, tokens: list[Token]) -> None: def process_color(self, name: str, tokens: list[Token]) -> None:
"""Processes a simple color declaration.""" """Processes a simple color declaration."""
name = name.replace("-", "_") name = name.replace("-", "_")
color: Color | None = None
alpha: float | None = None
for token in tokens: for token in tokens:
if token.name in ("color", "token"): if token.name == "scalar":
alpha_scalar = Scalar.parse(token.value)
if alpha_scalar.unit != Unit.PERCENT:
self.error(name, token, "alpha must be given as a percentage.")
alpha = alpha_scalar.value / 100.0
elif token.name in ("color", "token"):
try: try:
self.styles._rules[name] = Color.parse(token.value) color = Color.parse(token.value)
except Exception: except Exception:
self.error( self.error(
name, token, color_property_help_text(name, context="css") name, token, color_property_help_text(name, context="css")
@@ -535,6 +545,12 @@ class StylesBuilder:
else: else:
self.error(name, token, color_property_help_text(name, context="css")) self.error(name, token, color_property_help_text(name, context="css"))
if color is not None:
if alpha is not None:
color = color.with_alpha(alpha)
self.styles._rules[name] = color
process_tint = process_color
process_background = process_color process_background = process_color
process_scrollbar_color = process_color process_scrollbar_color = process_color
process_scrollbar_color_hover = process_color process_scrollbar_color_hover = process_color

View File

@@ -115,6 +115,8 @@ class RulesMap(TypedDict, total=False):
transitions: dict[str, Transition] transitions: dict[str, Transition]
tint: Color
scrollbar_color: Color scrollbar_color: Color
scrollbar_color_hover: Color scrollbar_color_hover: Color
scrollbar_color_active: Color scrollbar_color_active: Color
@@ -156,6 +158,7 @@ class StylesBase(ABC):
"max_height", "max_height",
"color", "color",
"background", "background",
"tint",
"scrollbar_color", "scrollbar_color",
"scrollbar_color_hover", "scrollbar_color_hover",
"scrollbar_color_active", "scrollbar_color_active",
@@ -210,6 +213,7 @@ class StylesBase(ABC):
rich_style = StyleProperty() rich_style = StyleProperty()
tint = ColorProperty("transparent")
scrollbar_color = ColorProperty("ansi_bright_magenta") scrollbar_color = ColorProperty("ansi_bright_magenta")
scrollbar_color_hover = ColorProperty("ansi_yellow") scrollbar_color_hover = ColorProperty("ansi_yellow")
scrollbar_color_active = ColorProperty("ansi_bright_yellow") scrollbar_color_active = ColorProperty("ansi_bright_yellow")

View File

@@ -439,6 +439,7 @@ class DOMNode(MessagePump):
""" """
self._classes.update(class_names) self._classes.update(class_names)
self.app.stylesheet.update(self.app, animate=True)
self.refresh() self.refresh()
def remove_class(self, *class_names: str) -> None: def remove_class(self, *class_names: str) -> None:
@@ -449,6 +450,7 @@ class DOMNode(MessagePump):
""" """
self._classes.difference_update(class_names) self._classes.difference_update(class_names)
self.app.stylesheet.update(self.app, animate=True)
self.refresh() self.refresh()
def toggle_class(self, *class_names: str) -> None: def toggle_class(self, *class_names: str) -> None:

View File

@@ -130,9 +130,14 @@ class MessagePump:
""" """
if self._pending_message is None: if self._pending_message is None:
try: try:
self._pending_message = self._message_queue.get_nowait().message message = self._message_queue.get_nowait().message
except QueueEmpty: except QueueEmpty:
pass pass
else:
if message is None:
self._closed = True
raise MessagePumpClosed("The message pump is now closed")
self._pending_message = message
if self._pending_message is not None: if self._pending_message is not None:
return self._pending_message return self._pending_message
@@ -219,10 +224,12 @@ class MessagePump:
# Combine any pending messages that may supersede this one # Combine any pending messages that may supersede this one
while not (self._closed or self._closing): while not (self._closed or self._closing):
pending = self.peek_message() try:
pending = self.peek_message()
except MessagePumpClosed:
break
if pending is None or not message.can_replace(pending): if pending is None or not message.can_replace(pending):
break break
# self.log(message, "replaced with", pending)
try: try:
message = await self.get_message() message = await self.get_message()
except MessagePumpClosed: except MessagePumpClosed:

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from rich.console import ConsoleOptions, Console, RenderResult, RenderableType
from rich.segment import Segment
from rich.style import Style
from ..color import Color
class Tint:
"""Applies a color on top of an existing renderable."""
def __init__(self, renderable: RenderableType, color: Color) -> None:
"""_summary_
Args:
renderable (RenderableType): A renderable.
color (Color): A color (presumably with alpha).
"""
self.renderable = renderable
self.color = color
def __rich_console__(
self, console: Console, options: ConsoleOptions
) -> RenderResult:
segments = console.render(self.renderable, options)
color = self.color
from_rich_color = Color.from_rich_color
style_from_color = Style.from_color
for segment in segments:
text, style, control = segment
if control or style is None:
yield segment
else:
yield Segment(
text,
(
style
+ style_from_color(
(from_rich_color(style.color) + color).rich_color,
(from_rich_color(style.bgcolor) + color).rich_color,
)
),
control,
)

View File

@@ -199,10 +199,10 @@ class Screen(Widget):
else: else:
widget, region = self.get_widget_at(event.x, event.y) widget, region = self.get_widget_at(event.x, event.y)
except errors.NoWidget: except errors.NoWidget:
await self.app.set_focus(None) self.app.set_focus(None)
else: else:
if isinstance(event, events.MouseDown) and widget.can_focus: if isinstance(event, events.MouseDown) and widget.can_focus:
await self.app.set_focus(widget) self.app.set_focus(widget)
event.style = self.get_style_at(event.screen_x, event.screen_y) event.style = self.get_style_at(event.screen_x, event.screen_y)
if widget is self: if widget is self:
event.set_forwarded() event.set_forwarded()

View File

@@ -33,6 +33,7 @@ from . import messages
from ._layout import Layout from ._layout import Layout
from .reactive import Reactive, watch from .reactive import Reactive, watch
from .renderables.opacity import Opacity from .renderables.opacity import Opacity
from .renderables.tint import Tint
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -466,6 +467,8 @@ class Widget(DOMNode):
outline=True, outline=True,
) )
if styles.tint.a != 0:
renderable = Tint(renderable, styles.tint)
if styles.opacity != 1.0: if styles.opacity != 1.0:
renderable = Opacity(renderable, opacity=styles.opacity) renderable = Opacity(renderable, opacity=styles.opacity)
@@ -665,9 +668,9 @@ class Widget(DOMNode):
elif self._dirty_regions: elif self._dirty_regions:
self.emit_no_wait(messages.Update(self, self)) self.emit_no_wait(messages.Update(self, self))
async def focus(self) -> None: def focus(self) -> None:
"""Give input focus to this widget.""" """Give input focus to this widget."""
await self.app.set_focus(self) self.app.set_focus(self)
async def capture_mouse(self, capture: bool = True) -> None: async def capture_mouse(self, capture: bool = True) -> None:
"""Capture (or release) the mouse. """Capture (or release) the mouse.
@@ -713,6 +716,12 @@ class Widget(DOMNode):
def on_enter(self) -> None: def on_enter(self) -> None:
self.mouse_over = True self.mouse_over = True
def on_focus(self, event: events.Focus) -> None:
self.has_focus = True
def on_blur(self, event: events.Blur) -> None:
self.has_focus = False
def on_mouse_scroll_down(self, event) -> None: def on_mouse_scroll_down(self, event) -> None:
if self.is_container: if self.is_container:
self.scroll_down(animate=False) self.scroll_down(animate=False)

View File

@@ -34,6 +34,11 @@ class Button(Widget, can_focus=True):
color: $text-primary-darken-2; color: $text-primary-darken-2;
border: tall $primary-lighten-1; border: tall $primary-lighten-1;
} }
App.-show-focus Button:focus {
tint: $accent 20%;
}
""" """
@@ -73,3 +78,9 @@ class Button(Widget, can_focus=True):
event.stop() event.stop()
if not self.disabled: if not self.disabled:
await self.emit(Button.Pressed(self)) await self.emit(Button.Pressed(self))
async def on_key(self, event: events.Key) -> None:
self.log("BUTTON KEY", event)
if event.key == "enter" and not self.disabled:
self.log("PRESSEd")
await self.emit(Button.Pressed(self))