mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
@@ -91,7 +91,7 @@ class BasicApp(App):
|
||||
|
||||
def on_load(self):
|
||||
"""Bind keys here."""
|
||||
self.bind("tab", "toggle_class('#sidebar', '-active')")
|
||||
self.bind("s", "toggle_class('#sidebar', '-active')")
|
||||
|
||||
def on_mount(self):
|
||||
"""Build layout here."""
|
||||
|
||||
@@ -5,6 +5,7 @@ from textual import layout
|
||||
|
||||
|
||||
class ButtonsApp(App[str]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield layout.Vertical(
|
||||
Button("foo", id="foo"),
|
||||
@@ -14,10 +15,11 @@ class ButtonsApp(App[str]):
|
||||
|
||||
def handle_pressed(self, event: Button.Pressed) -> None:
|
||||
self.app.bell()
|
||||
self.log("pressed", event.button.id)
|
||||
self.exit(event.button.id)
|
||||
|
||||
|
||||
app = ButtonsApp(log="textual.log")
|
||||
app = ButtonsApp(log="textual.log", log_verbosity=2)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = app.run()
|
||||
|
||||
@@ -87,8 +87,12 @@ class Timer:
|
||||
self._task = asyncio.create_task(self._run())
|
||||
return self._task
|
||||
|
||||
def stop_no_wait(self) -> None:
|
||||
"""Stop the timer."""
|
||||
self._task.cancel()
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the timer, and block until it exists."""
|
||||
"""Stop the timer, and block until it exits."""
|
||||
self._task.cancel()
|
||||
await self._task
|
||||
|
||||
|
||||
@@ -8,13 +8,23 @@ import sys
|
||||
import warnings
|
||||
from contextlib import redirect_stdout
|
||||
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.repr
|
||||
from rich.console import Console, RenderableType
|
||||
from rich.control import Control
|
||||
from rich.measure import Measurement
|
||||
from rich.protocol import is_renderable
|
||||
from rich.screen import Screen as ScreenRenderable
|
||||
from rich.segment import Segments
|
||||
from rich.traceback import Traceback
|
||||
@@ -27,6 +37,7 @@ from ._animator import Animator
|
||||
from ._callback import invoke
|
||||
from ._context import active_app
|
||||
from ._event_broker import extract_handler_actions, NoHandler
|
||||
from ._timer import Timer
|
||||
from .binding import Bindings, NoBinding
|
||||
from .css.stylesheet import Stylesheet
|
||||
from .design import ColorSystem
|
||||
@@ -87,7 +98,9 @@ ReturnType = TypeVar("ReturnType")
|
||||
class App(Generic[ReturnType], DOMNode):
|
||||
"""The base class for Textual Applications"""
|
||||
|
||||
css = ""
|
||||
CSS = """
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -96,7 +109,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
log_verbosity: int = 1,
|
||||
title: str = "Textual Application",
|
||||
css_file: str | None = None,
|
||||
css: str | None = None,
|
||||
watch_css: bool = True,
|
||||
):
|
||||
"""Textual application base class
|
||||
@@ -107,7 +119,6 @@ class App(Generic[ReturnType], DOMNode):
|
||||
log_verbosity (int, optional): Log verbosity from 0-3. Defaults to 1.
|
||||
title (str, optional): Default title of the application. Defaults to "Textual Application".
|
||||
css_file (str | None, optional): Path to CSS or ``None`` for no CSS file. Defaults to None.
|
||||
css (str | None, optional): CSS code to parse, or ``None`` for no literal CSS. Defaults to None.
|
||||
watch_css (bool, optional): Watch CSS for changes. Defaults to True.
|
||||
"""
|
||||
# N.B. This must be done *before* we call the parent constructor, because MessagePump's
|
||||
@@ -165,16 +176,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
if (watch_css and css_file)
|
||||
else None
|
||||
)
|
||||
if css is not None:
|
||||
self.css = css
|
||||
|
||||
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
|
||||
|
||||
self.registry: set[MessagePump] = set()
|
||||
|
||||
self.devtools = DevtoolsClient()
|
||||
|
||||
self._return_value: ReturnType | None = None
|
||||
self._focus_timer: Timer | None = None
|
||||
|
||||
super().__init__()
|
||||
|
||||
@@ -202,6 +209,105 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._return_value = result
|
||||
self.close_messages_no_wait()
|
||||
|
||||
@property
|
||||
def focus_chain(self) -> list[Widget]:
|
||||
"""Get widgets that may receive focus, in focus order."""
|
||||
widgets: list[Widget] = []
|
||||
add_widget = widgets.append
|
||||
root = self.screen
|
||||
stack: list[Iterator[Widget]] = [iter(root.focusable_children)]
|
||||
pop = stack.pop
|
||||
push = stack.append
|
||||
|
||||
while stack:
|
||||
node = next(stack[-1], None)
|
||||
if node is None:
|
||||
pop()
|
||||
else:
|
||||
if node.is_container and node.can_focus:
|
||||
push(iter(node.focusable_children))
|
||||
else:
|
||||
if node.can_focus:
|
||||
add_widget(node)
|
||||
|
||||
return widgets
|
||||
|
||||
def _set_active(self) -> None:
|
||||
"""Set this app to be the currently active app."""
|
||||
active_app.set(self)
|
||||
|
||||
def _move_focus(self, direction: int = 0) -> Widget | 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.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
"""
|
||||
if self._focus_timer:
|
||||
# Cancel the timer that clears the show focus class
|
||||
# We will be creating a new timer to extend the time until the focus is hidden
|
||||
self._focus_timer.stop_no_wait()
|
||||
focusable_widgets = self.focus_chain
|
||||
|
||||
if not focusable_widgets:
|
||||
# Nothing focusable, so nothing to do
|
||||
return self.focused
|
||||
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(2, self.hide_focus)
|
||||
self.add_class("-show-focus")
|
||||
return self.focused
|
||||
|
||||
def show_focus(self) -> Widget | None:
|
||||
"""Highlight the currently focused widget.
|
||||
|
||||
Returns:
|
||||
Widget | None: Focused widget, or None for no focus.
|
||||
"""
|
||||
return self._move_focus(0)
|
||||
|
||||
def focus_next(self) -> Widget | None:
|
||||
"""Focus the next widget.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
"""
|
||||
return self._move_focus(1)
|
||||
|
||||
def focus_previous(self) -> Widget | None:
|
||||
"""Focus the previous widget.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
"""
|
||||
return self._move_focus(-1)
|
||||
|
||||
def hide_focus(self) -> None:
|
||||
"""Hide the focus.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
|
||||
"""
|
||||
self.remove_class("-show-focus")
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Yield child widgets for a container."""
|
||||
return
|
||||
@@ -401,33 +507,52 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.register(self.screen, *anon_widgets, **widgets)
|
||||
self.screen.refresh()
|
||||
|
||||
async def push_screen(self, screen: Screen) -> Screen:
|
||||
self._screen_stack.append(screen)
|
||||
return screen
|
||||
def push_screen(self, screen: Screen | None = None) -> Screen:
|
||||
"""Push a new screen on the screen stack.
|
||||
|
||||
async def set_focus(self, widget: Widget | None) -> None:
|
||||
Args:
|
||||
screen (Screen | None, optional): A new Screen instance or None to create
|
||||
one internally. Defaults to None.
|
||||
|
||||
Returns:
|
||||
Screen: Newly active screen.
|
||||
"""
|
||||
new_screen = Screen() if screen is None else screen
|
||||
self._screen_stack.append(new_screen)
|
||||
return new_screen
|
||||
|
||||
def set_focus(self, widget: Widget | None) -> None:
|
||||
"""Focus (or unfocus) a widget. A focused widget will receive key events first.
|
||||
|
||||
Args:
|
||||
widget (Widget): [description]
|
||||
"""
|
||||
self.log("set_focus", widget=widget)
|
||||
if widget == self.focused:
|
||||
# Widget is already focused
|
||||
return
|
||||
|
||||
if widget is None:
|
||||
# No focus, so blur currently focused widget if it exists
|
||||
if self.focused is not None:
|
||||
focused = self.focused
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
self.focused = None
|
||||
await focused.post_message(events.Blur(self))
|
||||
elif widget.can_focus:
|
||||
if self.focused is not None:
|
||||
await self.focused.post_message(events.Blur(self))
|
||||
if widget is not None and self.focused != widget:
|
||||
if self.focused != widget:
|
||||
if self.focused is not None:
|
||||
# Blur currently focused widget
|
||||
self.focused.post_message_no_wait(events.Blur(self))
|
||||
# Change focus
|
||||
self.focused = widget
|
||||
await widget.post_message(events.Focus(self))
|
||||
# Send focus event
|
||||
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:
|
||||
"""Called when the mouse is over another widget.
|
||||
|
||||
Args:
|
||||
widget (Widget | None): Widget under mouse, or None for no widgets.
|
||||
"""
|
||||
if widget is None:
|
||||
if self.mouse_over is not None:
|
||||
try:
|
||||
@@ -467,6 +592,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
*renderables (RenderableType, optional): Rich renderables to display on exit.
|
||||
"""
|
||||
|
||||
assert all(
|
||||
is_renderable(renderable) for renderable in renderables
|
||||
), "Can only call panic with strings or Rich renderables"
|
||||
|
||||
prerendered = [
|
||||
Segments(self.console.render(renderable, self.console.options))
|
||||
for renderable in renderables
|
||||
@@ -505,7 +634,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self._exit_renderables.clear()
|
||||
|
||||
async def process_messages(self) -> None:
|
||||
active_app.set(self)
|
||||
self._set_active()
|
||||
log("---")
|
||||
log(f"driver={self.driver_class}")
|
||||
log(f"asyncio running loop={asyncio.get_running_loop()!r}")
|
||||
@@ -519,9 +648,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
try:
|
||||
if self.css_file is not None:
|
||||
self.stylesheet.read(self.css_file)
|
||||
if self.css is not None:
|
||||
if self.CSS is not None:
|
||||
self.stylesheet.add_source(
|
||||
self.css, path=f"<{self.__class__.__name__}>"
|
||||
self.CSS, path=f"<{self.__class__.__name__}>"
|
||||
)
|
||||
except Exception as error:
|
||||
self.on_exception(error)
|
||||
@@ -752,7 +881,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
if isinstance(event, events.Mount):
|
||||
screen = Screen()
|
||||
self.register(self, screen)
|
||||
await self.push_screen(screen)
|
||||
self.push_screen(screen)
|
||||
await super().on_event(event)
|
||||
|
||||
elif isinstance(event, events.InputEvent) and not event.is_forwarded:
|
||||
@@ -793,12 +922,18 @@ class App(Generic[ReturnType], DOMNode):
|
||||
action_target = default_namespace or self
|
||||
action_name = target
|
||||
|
||||
log("ACTION", action_target, action_name)
|
||||
log("action", action)
|
||||
await self.dispatch_action(action_target, action_name, params)
|
||||
|
||||
async def dispatch_action(
|
||||
self, namespace: object, action_name: str, params: Any
|
||||
) -> None:
|
||||
log(
|
||||
"dispatch_action",
|
||||
namespace=namespace,
|
||||
action_name=action_name,
|
||||
params=params,
|
||||
)
|
||||
_rich_traceback_guard = True
|
||||
method_name = f"action_{action_name}"
|
||||
method = getattr(namespace, method_name, None)
|
||||
@@ -846,7 +981,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.app.refresh()
|
||||
|
||||
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:
|
||||
log("shutdown request")
|
||||
|
||||
@@ -156,6 +156,12 @@ class Color(NamedTuple):
|
||||
r, g, b, _a = self
|
||||
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
|
||||
def hls(self) -> HLS:
|
||||
"""Get the color as HLS."""
|
||||
|
||||
@@ -558,10 +558,20 @@ class StylesBuilder:
|
||||
def process_color(self, name: str, tokens: list[Token]) -> None:
|
||||
"""Processes a simple color declaration."""
|
||||
name = name.replace("-", "_")
|
||||
|
||||
color: Color | None = None
|
||||
alpha: float | None = None
|
||||
|
||||
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:
|
||||
self.styles._rules[name] = Color.parse(token.value)
|
||||
color = Color.parse(token.value)
|
||||
except Exception:
|
||||
self.error(
|
||||
name, token, color_property_help_text(name, context="css")
|
||||
@@ -569,6 +579,12 @@ class StylesBuilder:
|
||||
else:
|
||||
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_scrollbar_color = process_color
|
||||
process_scrollbar_color_hover = process_color
|
||||
|
||||
@@ -83,7 +83,7 @@ class Selector:
|
||||
return self._checks[self.type](node)
|
||||
|
||||
def _check_universal(self, node: DOMNode) -> bool:
|
||||
return True
|
||||
return node.has_pseudo_class(*self.pseudo_classes)
|
||||
|
||||
def _check_type(self, node: DOMNode) -> bool:
|
||||
if node.css_type != self._name_lower:
|
||||
|
||||
@@ -115,6 +115,8 @@ class RulesMap(TypedDict, total=False):
|
||||
|
||||
transitions: dict[str, Transition]
|
||||
|
||||
tint: Color
|
||||
|
||||
scrollbar_color: Color
|
||||
scrollbar_color_hover: Color
|
||||
scrollbar_color_active: Color
|
||||
@@ -156,6 +158,7 @@ class StylesBase(ABC):
|
||||
"max_height",
|
||||
"color",
|
||||
"background",
|
||||
"tint",
|
||||
"scrollbar_color",
|
||||
"scrollbar_color_hover",
|
||||
"scrollbar_color_active",
|
||||
@@ -210,6 +213,7 @@ class StylesBase(ABC):
|
||||
|
||||
rich_style = StyleProperty()
|
||||
|
||||
tint = ColorProperty("transparent")
|
||||
scrollbar_color = ColorProperty("ansi_bright_magenta")
|
||||
scrollbar_color_hover = ColorProperty("ansi_yellow")
|
||||
scrollbar_color_active = ColorProperty("ansi_bright_yellow")
|
||||
|
||||
@@ -313,8 +313,15 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def displayed_children(self) -> list[DOMNode]:
|
||||
"""The children which don't have display: none set."""
|
||||
return [child for child in self.children if child.display]
|
||||
|
||||
@property
|
||||
def focusable_children(self) -> list[DOMNode]:
|
||||
"""Get the children which may be focused."""
|
||||
# TODO: This may be the place to define order, other focus related rules
|
||||
return [child for child in self.children if child.display and child.visible]
|
||||
|
||||
def get_pseudo_classes(self) -> Iterable[str]:
|
||||
"""Get any pseudo classes applicable to this Node, e.g. hover, focus.
|
||||
|
||||
@@ -439,7 +446,11 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
self._classes.update(class_names)
|
||||
self.refresh()
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
def remove_class(self, *class_names: str) -> None:
|
||||
"""Remove class names from this Node.
|
||||
@@ -449,7 +460,11 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
self._classes.difference_update(class_names)
|
||||
self.refresh()
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
def toggle_class(self, *class_names: str) -> None:
|
||||
"""Toggle class names on this Node.
|
||||
@@ -459,8 +474,11 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
self._classes.symmetric_difference_update(class_names)
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
try:
|
||||
self.app.stylesheet.update(self.app, animate=True)
|
||||
self.refresh()
|
||||
except LookupError:
|
||||
pass
|
||||
|
||||
def has_pseudo_class(self, *class_names: str) -> bool:
|
||||
"""Check for pseudo class (such as hover, focus etc)"""
|
||||
|
||||
@@ -130,9 +130,14 @@ class MessagePump:
|
||||
"""
|
||||
if self._pending_message is None:
|
||||
try:
|
||||
self._pending_message = self._message_queue.get_nowait().message
|
||||
message = self._message_queue.get_nowait().message
|
||||
except QueueEmpty:
|
||||
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:
|
||||
return self._pending_message
|
||||
@@ -219,10 +224,12 @@ class MessagePump:
|
||||
|
||||
# Combine any pending messages that may supersede this one
|
||||
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):
|
||||
break
|
||||
# self.log(message, "replaced with", pending)
|
||||
try:
|
||||
message = await self.get_message()
|
||||
except MessagePumpClosed:
|
||||
@@ -233,7 +240,7 @@ class MessagePump:
|
||||
except CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
self.app.panic(error)
|
||||
self.app.on_exception(error)
|
||||
break
|
||||
finally:
|
||||
if self._message_queue.empty():
|
||||
|
||||
47
src/textual/renderables/tint.py
Normal file
47
src/textual/renderables/tint.py
Normal 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,
|
||||
)
|
||||
@@ -160,9 +160,9 @@ class Screen(Widget):
|
||||
else:
|
||||
widget, region = self.get_widget_at(event.x, event.y)
|
||||
except errors.NoWidget:
|
||||
await self.app.set_mouse_over(None)
|
||||
await self.app._set_mouse_over(None)
|
||||
else:
|
||||
await self.app.set_mouse_over(widget)
|
||||
await self.app._set_mouse_over(widget)
|
||||
mouse_event = events.MouseMove(
|
||||
self,
|
||||
event.x - region.x,
|
||||
@@ -199,10 +199,10 @@ class Screen(Widget):
|
||||
else:
|
||||
widget, region = self.get_widget_at(event.x, event.y)
|
||||
except errors.NoWidget:
|
||||
await self.app.set_focus(None)
|
||||
self.app.set_focus(None)
|
||||
else:
|
||||
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)
|
||||
if widget is self:
|
||||
event.set_forwarded()
|
||||
|
||||
@@ -33,6 +33,7 @@ from . import messages
|
||||
from ._layout import Layout
|
||||
from .reactive import Reactive, watch
|
||||
from .renderables.opacity import Opacity
|
||||
from .renderables.tint import Tint
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -466,6 +467,8 @@ class Widget(DOMNode):
|
||||
outline=True,
|
||||
)
|
||||
|
||||
if styles.tint.a != 0:
|
||||
renderable = Tint(renderable, styles.tint)
|
||||
if styles.opacity != 1.0:
|
||||
renderable = Opacity(renderable, opacity=styles.opacity)
|
||||
|
||||
@@ -526,7 +529,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
bool: True if this widget is a container.
|
||||
"""
|
||||
return self.styles.layout is not None
|
||||
return self.styles.layout is not None or bool(self.children)
|
||||
|
||||
def watch_mouse_over(self, value: bool) -> None:
|
||||
"""Update from CSS if mouse over state changes."""
|
||||
@@ -665,9 +668,9 @@ class Widget(DOMNode):
|
||||
elif self._dirty_regions:
|
||||
self.emit_no_wait(messages.Update(self, self))
|
||||
|
||||
async def focus(self) -> None:
|
||||
def focus(self) -> None:
|
||||
"""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:
|
||||
"""Capture (or release) the mouse.
|
||||
@@ -713,6 +716,12 @@ class Widget(DOMNode):
|
||||
def on_enter(self) -> None:
|
||||
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:
|
||||
if self.is_container:
|
||||
self.scroll_down(animate=False)
|
||||
|
||||
@@ -34,6 +34,10 @@ class Button(Widget, can_focus=True):
|
||||
color: $text-primary-darken-2;
|
||||
border: tall $primary-lighten-1;
|
||||
}
|
||||
|
||||
App.-show-focus Button:focus {
|
||||
tint: $accent 20%;
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
@@ -73,3 +77,7 @@ class Button(Widget, can_focus=True):
|
||||
event.stop()
|
||||
if not self.disabled:
|
||||
await self.emit(Button.Pressed(self))
|
||||
|
||||
async def on_key(self, event: events.Key) -> None:
|
||||
if event.key == "enter" and not self.disabled:
|
||||
await self.emit(Button.Pressed(self))
|
||||
|
||||
17
tests/renderables/test_tint.py
Normal file
17
tests/renderables/test_tint.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
from rich.text import Text
|
||||
|
||||
from textual.color import Color
|
||||
from textual.renderables.tint import Tint
|
||||
|
||||
|
||||
def test_tint():
|
||||
console = Console(file=io.StringIO(), force_terminal=True, color_system="truecolor")
|
||||
renderable = Text.from_markup("[#aabbcc on #112233]foo")
|
||||
console.print(Tint(renderable, Color(0, 100, 0, 0.5)))
|
||||
output = console.file.getvalue()
|
||||
print(repr(output))
|
||||
expected = "\x1b[38;2;85;143;102;48;2;8;67;25mfoo\x1b[0m\n"
|
||||
assert output == expected
|
||||
@@ -48,6 +48,10 @@ def test_colorpair_style():
|
||||
)
|
||||
|
||||
|
||||
def test_rgb():
|
||||
assert Color(10, 20, 30, 0.55).rgb == (10, 20, 30)
|
||||
|
||||
|
||||
def test_hls():
|
||||
|
||||
red = Color(200, 20, 32)
|
||||
@@ -94,7 +98,7 @@ def test_color_blend():
|
||||
("#ffffff", Color(255, 255, 255, 1.0)),
|
||||
("#FFFFFF", Color(255, 255, 255, 1.0)),
|
||||
("#fab", Color(255, 170, 187, 1.0)), # #ffaabb
|
||||
("#fab0", Color(255, 170, 187, .0)), # #ffaabb00
|
||||
("#fab0", Color(255, 170, 187, 0.0)), # #ffaabb00
|
||||
("#020304ff", Color(2, 3, 4, 1.0)),
|
||||
("#02030400", Color(2, 3, 4, 0.0)),
|
||||
("#0203040f", Color(2, 3, 4, 0.058823529411764705)),
|
||||
|
||||
71
tests/test_focus.py
Normal file
71
tests/test_focus.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from textual.app import App
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class Focusable(Widget, can_focus=True):
|
||||
pass
|
||||
|
||||
|
||||
class NonFocusable(Widget, can_focus=False):
|
||||
pass
|
||||
|
||||
|
||||
async def test_focus_chain():
|
||||
|
||||
app = App()
|
||||
app.push_screen(Screen())
|
||||
|
||||
# Check empty focus chain
|
||||
assert not app.focus_chain
|
||||
|
||||
app.screen.add_children(
|
||||
Focusable(id="foo"),
|
||||
NonFocusable(id="bar"),
|
||||
Focusable(Focusable(id="Paul"), id="container1"),
|
||||
NonFocusable(Focusable(id="Jessica"), id="container2"),
|
||||
Focusable(id="baz"),
|
||||
)
|
||||
|
||||
focused = [widget.id for widget in app.focus_chain]
|
||||
assert focused == ["foo", "Paul", "baz"]
|
||||
|
||||
|
||||
async def test_show_focus():
|
||||
app = App()
|
||||
app.push_screen(Screen())
|
||||
app.screen.add_children(
|
||||
Focusable(id="foo"),
|
||||
NonFocusable(id="bar"),
|
||||
Focusable(Focusable(id="Paul"), id="container1"),
|
||||
NonFocusable(Focusable(id="Jessica"), id="container2"),
|
||||
Focusable(id="baz"),
|
||||
)
|
||||
|
||||
focused = [widget.id for widget in app.focus_chain]
|
||||
assert focused == ["foo", "Paul", "baz"]
|
||||
|
||||
assert app.focused is None
|
||||
assert not app.has_class("-show-focus")
|
||||
app.show_focus()
|
||||
assert app.has_class("-show-focus")
|
||||
|
||||
|
||||
async def test_focus_next_and_previous():
|
||||
|
||||
app = App()
|
||||
app.push_screen(Screen())
|
||||
app.screen.add_children(
|
||||
Focusable(id="foo"),
|
||||
NonFocusable(id="bar"),
|
||||
Focusable(Focusable(id="Paul"), id="container1"),
|
||||
NonFocusable(Focusable(id="Jessica"), id="container2"),
|
||||
Focusable(id="baz"),
|
||||
)
|
||||
|
||||
assert app.focus_next().id == "foo"
|
||||
assert app.focus_next().id == "Paul"
|
||||
assert app.focus_next().id == "baz"
|
||||
|
||||
assert app.focus_previous().id == "Paul"
|
||||
assert app.focus_previous().id == "foo"
|
||||
Reference in New Issue
Block a user