diff --git a/sandbox/basic.css b/sandbox/basic.css index d73df4ea0..c62a9b3bd 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -1,15 +1,17 @@ /* CSS file for basic.py */ +/* * { transition: color 500ms linear, background 500ms linear; } +*/ App > Screen { layout: dock; docks: side=left/1; - background: $surface; - color: $text-surface; + background: $background; + color: $text-background; } #sidebar { @@ -27,7 +29,7 @@ App > Screen { } #sidebar .title { - height: 1; + height: 3; background: $primary-darken-2; color: $text-primary-darken-2; border-right: outer $primary-darken-3; @@ -54,21 +56,24 @@ App > Screen { } #content { - color: $text-surface; - background: $surface; + color: $text-background; + background: $background; layout: vertical; } Tweet { - height: 10; + height: 16; max-width: 80; - margin: 1 2; - background: $background; - color: $text-background; - layout: vertical + margin: 1 3; + background: $surface; + color: $text-surface; + layout: vertical; + border: outer $accent2; + padding: 1; } + TweetHeader { height:1 background: $accent1 @@ -76,8 +81,8 @@ TweetHeader { } TweetBody { - background: $background - color: $text-background-fade-1 + background: $surface + color: $text-surface-fade-1 height:6; } @@ -88,7 +93,7 @@ TweetBody { height: 3 border: tall $text-background; - margin: 0 1 1 1; + margin: 1 1 1 1; transition: background 200ms in_out_cubic, color 300ms in_out_cubic; @@ -97,11 +102,11 @@ TweetBody { .button:hover { background: $accent1-darken-1; color: $text-accent1-darken-1; - width:20; + width: 20; height: 3 border: tall $text-background; - margin: 0 1 1 1; + margin: 1 1 1 1; } @@ -112,3 +117,57 @@ TweetBody { height: 1; border-top: hkey $accent2-darken-2; } + + +#sidebar .content { + layout: vertical +} + +OptionItem { + height: 3; + background: $primary; + transition: background 100ms linear; + border-right: outer $primary-darken-3; +} + +OptionItem:hover { + height: 3; + background: $accent1-darken-2; + /* border-top: hkey $accent2-darken-3; + border-bottom: hkey $accent2-darken-3; */ + text-style: bold; + border-right: outer $accent1-darken-3; +} + +Error { + max-width: 78; + height:3; + background: $error; + color: $text-error; + border-top: hkey $error-darken-3; + border-bottom: hkey $error-darken-3; + margin: 1 3; + text-style: bold; +} + +Warning { + max-width: 80; + height:3; + background: $warning; + color: $text-warning-fade-1; + border-top: hkey $warning-darken-3; + border-bottom: hkey $warning-darken-3; + margin: 1 2; + text-style: bold; +} + +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; + margin: 1 2; + text-style: bold; +} diff --git a/sandbox/basic.py b/sandbox/basic.py index d267724c0..cdef37ffd 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -1,3 +1,4 @@ +from rich.align import Align from rich.console import RenderableType from rich.text import Text @@ -24,6 +25,26 @@ class Tweet(Widget): pass +class OptionItem(Widget): + def render(self) -> Text: + return Align.center(Text("Option", justify="center"), vertical="middle") + + +class Error(Widget): + def render(self) -> Text: + return Text("This is an error message", justify="center") + + +class Warning(Widget): + def render(self) -> Text: + return Text("This is a warning message", justify="center") + + +class Success(Widget): + def render(self) -> Text: + return Text("This is a success message", justify="center") + + class BasicApp(App): """A basic app demonstrating CSS""" @@ -36,14 +57,20 @@ class BasicApp(App): self.mount( header=Widget(), content=Widget( - Tweet(TweetHeader(), TweetBody(), Widget(classes={"button"})), - Tweet(TweetHeader(), TweetBody()), - Tweet(TweetHeader(), TweetBody()), + Tweet(TweetBody(), Widget(classes={"button"})), + Error(), + Tweet(TweetBody()), + Warning(), + Tweet(TweetBody()), + Success(), ), footer=Widget(), sidebar=Widget( Widget(classes={"title"}), Widget(classes={"user"}), + OptionItem(), + OptionItem(), + OptionItem(), Widget(classes={"content"}), ), ) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 62aba4894..9a4eb1c37 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio import sys -from time import time +from time import monotonic from typing import Any, Callable, TypeVar from dataclasses import dataclass @@ -117,7 +117,7 @@ class Animator: """An object to manage updates to a given attribute over a period of time.""" def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: - self._animations: dict[tuple[object, str], Animation] = {} + self._animations: dict[tuple[object, str, object], Animation] = {} self.target = target self._timer = Timer( target, @@ -130,7 +130,7 @@ class Animator: def get_time(self) -> float: """Get the current wall clock time.""" - return time() + return monotonic() async def start(self) -> None: """Start the animator task.""" @@ -180,9 +180,10 @@ class Animator: final_value = value start_time = self.get_time() - animation_key = (id(obj), attribute) + animation_key = (id(obj), attribute, final_value) + if animation_key in self._animations: - self._animations[animation_key](start_time) + return easing_function = EASING[easing] if isinstance(easing, str) else easing @@ -219,10 +220,11 @@ class Animator: easing=easing_function, ) assert animation is not None, "animation expected to be non-None" + self._animations[animation_key] = animation self._timer.resume() - def __call__(self) -> None: + def __call__(self, time: float) -> None: if not self._animations: self._timer.pause() else: @@ -236,4 +238,4 @@ class Animator: def on_animation_frame(self) -> None: # TODO: We should be able to do animation without refreshing everything - self.target.screen.refresh(layout=True) + self.target.screen.refresh_layout() diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1b950acc5..da78a39fa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -28,6 +28,7 @@ from .geometry import Region, Offset, Size from ._loop import loop_last +from ._profile import timer from ._segment_tools import line_crop from ._types import Lines from .widget import Widget @@ -459,7 +460,6 @@ class Compositor: Returns: SegmentLines: A renderable """ - width, height = self.size screen_region = Region(0, 0, width, height) @@ -495,6 +495,8 @@ class Compositor: cut_segments = [line] else: # More than one cut, which means we need to divide the line + if not final_cuts: + continue render_x = render_region.x relative_cuts = [cut - render_x for cut in final_cuts] _, *cut_segments = divide(line, relative_cuts) diff --git a/src/textual/_timer.py b/src/textual/_timer.py index 44d3bb098..13f70f948 100644 --- a/src/textual/_timer.py +++ b/src/textual/_timer.py @@ -8,15 +8,17 @@ from asyncio import ( sleep, Task, ) +from functools import partial from time import monotonic from typing import Awaitable, Callable, Union from rich.repr import Result, rich_repr from . import events +from ._callback import invoke from ._types import MessageTarget -TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] +TimerCallback = Union[Callable[[float], Awaitable[None]], Callable[[float], None]] class EventTargetGone(Exception): @@ -36,9 +38,21 @@ class Timer: name: str | None = None, callback: TimerCallback | None = None, repeat: int | None = None, - skip: bool = False, + skip: bool = True, pause: bool = False, ) -> None: + """A class to send timer-based events. + + Args: + event_target (MessageTarget): The object which will receive the timer events. + interval (float): The time between timer events. + sender (MessageTarget): The sender of the event.s + name (str | None, optional): A name to assign the event (for debugging). Defaults to None. + callback (TimerCallback | None, optional): A optional callback to invoke when the event is handled. Defaults to None. + repeat (int | None, optional): The number of times to repeat the timer, or None for no repeat. Defaults to None. + skip (bool, optional): Enable skipping of scheduled events that couldn't be sent in time. Defaults to True. + pause (bool, optional): Start the timer paused. Defaults to False. + """ self._target_repr = repr(event_target) self._target = weakref.ref(event_target) self._interval = interval @@ -102,11 +116,19 @@ class Timer: if wait_time: await sleep(wait_time) event = events.Timer( - self.sender, timer=self, count=count, callback=self._callback + self.sender, + timer=self, + time=next_timer, + count=count, + callback=self._callback, ) count += 1 try: - await self.target.post_message(event) + if self._callback is not None: + await invoke(self._callback, next_timer) + else: + await self.target.post_priority_message(event) + except EventTargetGone: break await self._active.wait() diff --git a/src/textual/_types.py b/src/textual/_types.py index 71c228949..180d02be4 100644 --- a/src/textual/_types.py +++ b/src/textual/_types.py @@ -18,6 +18,9 @@ class MessageTarget(Protocol): async def post_message(self, message: "Message") -> bool: ... + async def post_priority_message(self, message: "Message") -> bool: + ... + def post_message_no_wait(self, message: "Message") -> bool: ... diff --git a/src/textual/app.py b/src/textual/app.py index 84dda3138..896774809 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -35,6 +35,7 @@ from .file_monitor import FileMonitor from .geometry import Offset, Region, Size from .layouts.dock import Dock from .message_pump import MessagePump +from ._profile import timer from .reactive import Reactive from .screen import Screen from .widget import Widget @@ -113,16 +114,17 @@ class App(DOMNode): self._refresh_required = False self.design = ColorSystem( - primary="#1b72b1", # blueish - secondary="#471EC2", # purplesis - warning="#ffa629", # orange - error="#db1a4a", # error - success="#38d645", # green - accent1="#1b72b1", - accent2="#ffa629", + primary="#406e8e", # blueish + secondary="#6d9f71", # purplesis + warning="#ffa62b", # orange + error="#ba3c5b", # error + success="#6d9f71", # green + accent1="#ffa62b", + accent2="#5a4599", ) self.stylesheet = Stylesheet(variables=self.get_css_variables()) + self._require_styles_update = False self.css_file = css_file self.css_monitor = ( @@ -321,7 +323,8 @@ class App(DOMNode): Should be called whenever CSS classes / pseudo classes change. """ - self.post_message_no_wait(messages.StylesUpdated(self)) + self._require_styles_update = True + self.check_idle() def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None: self.register(self.screen, *anon_widgets, **widgets) @@ -471,6 +474,11 @@ class App(DOMNode): if self.log_file is not None: self.log_file.close() + async def on_idle(self) -> None: + if self._require_styles_update: + await self.post_message(messages.StylesUpdated(self)) + self._require_styles_update = False + def _register_child(self, parent: DOMNode, child: DOMNode) -> bool: if child not in self.registry: parent.children._append(child) @@ -713,7 +721,7 @@ class App(DOMNode): await self.action( action, default_namespace=default_namespace, modifiers=modifiers ) - elif isinstance(action, Callable): + elif callable(action): await action() else: return False diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index e1c90bf06..04b21dabe 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -300,7 +300,7 @@ class Stylesheet: if is_animatable(key) and new_render_value != old_render_value: transition = new_styles.get_transition(key) if transition is not None: - duration, easing, delay = transition + duration, easing, _delay = transition node.app.animator.animate( node.styles.base, key, diff --git a/src/textual/events.py b/src/textual/events.py index 3201be6ef..6f9ef9fa9 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -364,11 +364,13 @@ class Timer(Event, verbosity=3, bubble=False): self, sender: MessageTarget, timer: "TimerClass", + time: float, count: int = 0, callback: TimerCallback | None = None, ) -> None: super().__init__(sender) self.timer = timer + self.time = time self.count = count self.callback = callback diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 65cbc2cec..201a90e09 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -2,9 +2,9 @@ from __future__ import annotations import asyncio from asyncio import CancelledError -from asyncio import Queue, QueueEmpty, Task -from functools import partial -from typing import TYPE_CHECKING, Awaitable, Iterable, Callable +from asyncio import PriorityQueue, QueueEmpty, Task +from functools import partial, total_ordering +from typing import TYPE_CHECKING, Awaitable, Iterable, Callable, NamedTuple from weakref import WeakSet from . import events @@ -32,9 +32,26 @@ class MessagePumpClosed(Exception): pass +@total_ordering +class MessagePriority: + __slots__ = ["message", "priority"] + + def __init__(self, message: Message | None = None, priority: int = 0): + self.message = message + self.priority = priority + + def __eq__(self, other: object) -> bool: + assert isinstance(other, MessagePriority) + return self.priority == other.priority + + def __gt__(self, other: object) -> bool: + assert isinstance(other, MessagePriority) + return self.priority > other.priority + + class MessagePump: def __init__(self, parent: MessagePump | None = None) -> None: - self._message_queue: Queue[Message | None] = Queue() + self._message_queue: PriorityQueue[MessagePriority] = PriorityQueue() self._parent = parent self._running: bool = False self._closing: bool = False @@ -96,7 +113,7 @@ class MessagePump: return self._pending_message finally: self._pending_message = None - message = await self._message_queue.get() + message = (await self._message_queue.get()).message if message is None: self._closed = True raise MessagePumpClosed("The message pump is now closed") @@ -111,7 +128,7 @@ class MessagePump: """ if self._pending_message is None: try: - self._pending_message = self._message_queue.get_nowait() + self._pending_message = self._message_queue.get_nowait().message except QueueEmpty: pass @@ -155,7 +172,7 @@ class MessagePump: ) def close_messages_no_wait(self) -> None: - self._message_queue.put_nowait(None) + self._message_queue.put_nowait(MessagePriority(None)) async def close_messages(self) -> None: """Close message queue, and optionally wait for queue to finish processing.""" @@ -164,7 +181,7 @@ class MessagePump: self._closing = True - await self._message_queue.put(None) + await self._message_queue.put(MessagePriority(None)) for task in self._child_tasks: task.cancel() @@ -284,7 +301,25 @@ class MessagePump: return False if not self.check_message_enabled(message): return True - await self._message_queue.put(message) + await self._message_queue.put(MessagePriority(message)) + return True + + # TODO: This may not be needed, or may only be needed by the timer + # Consider removing or making private + async def post_priority_message(self, message: Message) -> bool: + """Post a "priority" messages which will be processes prior to regular messages. + + Args: + message (Message): A message. + + Returns: + bool: True if the messages was processed. + """ + if self._closing or self._closed: + return False + if not self.check_message_enabled(message): + return True + await self._message_queue.put(MessagePriority(message, -1)) return True def post_message_no_wait(self, message: Message) -> bool: @@ -292,7 +327,7 @@ class MessagePump: return False if not self.check_message_enabled(message): return True - self._message_queue.put_nowait(message) + self._message_queue.put_nowait(MessagePriority(message)) return True async def post_message_from_child(self, message: Message) -> bool: diff --git a/src/textual/screen.py b/src/textual/screen.py index 0d1043bb8..8e457c0fe 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -91,6 +91,7 @@ class Screen(Widget): def on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) if self._dirty_widgets: + self.log(dirty=len(self._dirty_widgets)) for widget in self._dirty_widgets: # Repaint widgets # TODO: Combine these in to a single update. @@ -100,7 +101,7 @@ class Screen(Widget): # Reset dirty list self._dirty_widgets.clear() - async def refresh_layout(self) -> None: + def refresh_layout(self) -> None: """Refresh the layout (can change size and positions of widgets).""" if not self.size: return @@ -144,11 +145,11 @@ class Screen(Widget): async def handle_layout(self, message: messages.Layout) -> None: message.stop() - await self.refresh_layout() + self.refresh_layout() async def on_resize(self, event: events.Resize) -> None: self.size_updated(event.size, event.virtual_size, event.container_size) - await self.refresh_layout() + self.refresh_layout() event.stop() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/tests/test_color.py b/tests/test_color.py index 55007cc02..35430391a 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -165,9 +165,9 @@ def test_lab_to_rgb(r, g, b, L_, a_, b_): def test_rgb_lab_rgb_roundtrip(): """Test RGB -> CIE-L*ab -> RGB color conversion roundtripping.""" - for r in range(0, 256, 4): - for g in range(0, 256, 4): - for b in range(0, 256, 4): + for r in range(0, 256, 32): + for g in range(0, 256, 32): + for b in range(0, 256, 32): c_ = lab_to_rgb(rgb_to_lab(Color(r, g, b))) assert c_.r == pytest.approx(r, abs=1) assert c_.g == pytest.approx(g, abs=1)