fix for timer issue

This commit is contained in:
Will McGugan
2022-04-08 15:28:43 +01:00
parent 29ad56a0fb
commit 68c5d9b14c
12 changed files with 217 additions and 56 deletions

View File

@@ -1,15 +1,17 @@
/* CSS file for basic.py */ /* CSS file for basic.py */
/*
* { * {
transition: color 500ms linear, background 500ms linear; transition: color 500ms linear, background 500ms linear;
} }
*/
App > Screen { App > Screen {
layout: dock; layout: dock;
docks: side=left/1; docks: side=left/1;
background: $surface; background: $background;
color: $text-surface; color: $text-background;
} }
#sidebar { #sidebar {
@@ -27,7 +29,7 @@ App > Screen {
} }
#sidebar .title { #sidebar .title {
height: 1; height: 3;
background: $primary-darken-2; background: $primary-darken-2;
color: $text-primary-darken-2; color: $text-primary-darken-2;
border-right: outer $primary-darken-3; border-right: outer $primary-darken-3;
@@ -54,21 +56,24 @@ App > Screen {
} }
#content { #content {
color: $text-surface; color: $text-background;
background: $surface; background: $background;
layout: vertical; layout: vertical;
} }
Tweet { Tweet {
height: 10; height: 16;
max-width: 80; max-width: 80;
margin: 1 2; margin: 1 3;
background: $background; background: $surface;
color: $text-background; color: $text-surface;
layout: vertical layout: vertical;
border: outer $accent2;
padding: 1;
} }
TweetHeader { TweetHeader {
height:1 height:1
background: $accent1 background: $accent1
@@ -76,8 +81,8 @@ TweetHeader {
} }
TweetBody { TweetBody {
background: $background background: $surface
color: $text-background-fade-1 color: $text-surface-fade-1
height:6; height:6;
} }
@@ -88,7 +93,7 @@ TweetBody {
height: 3 height: 3
border: tall $text-background; 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; transition: background 200ms in_out_cubic, color 300ms in_out_cubic;
@@ -97,11 +102,11 @@ TweetBody {
.button:hover { .button:hover {
background: $accent1-darken-1; background: $accent1-darken-1;
color: $text-accent1-darken-1; color: $text-accent1-darken-1;
width:20; width: 20;
height: 3 height: 3
border: tall $text-background; border: tall $text-background;
margin: 0 1 1 1; margin: 1 1 1 1;
} }
@@ -112,3 +117,57 @@ TweetBody {
height: 1; height: 1;
border-top: hkey $accent2-darken-2; 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;
}

View File

@@ -1,3 +1,4 @@
from rich.align import Align
from rich.console import RenderableType from rich.console import RenderableType
from rich.text import Text from rich.text import Text
@@ -24,6 +25,26 @@ class Tweet(Widget):
pass 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): class BasicApp(App):
"""A basic app demonstrating CSS""" """A basic app demonstrating CSS"""
@@ -36,14 +57,20 @@ class BasicApp(App):
self.mount( self.mount(
header=Widget(), header=Widget(),
content=Widget( content=Widget(
Tweet(TweetHeader(), TweetBody(), Widget(classes={"button"})), Tweet(TweetBody(), Widget(classes={"button"})),
Tweet(TweetHeader(), TweetBody()), Error(),
Tweet(TweetHeader(), TweetBody()), Tweet(TweetBody()),
Warning(),
Tweet(TweetBody()),
Success(),
), ),
footer=Widget(), footer=Widget(),
sidebar=Widget( sidebar=Widget(
Widget(classes={"title"}), Widget(classes={"title"}),
Widget(classes={"user"}), Widget(classes={"user"}),
OptionItem(),
OptionItem(),
OptionItem(),
Widget(classes={"content"}), Widget(classes={"content"}),
), ),
) )

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import sys import sys
from time import time from time import monotonic
from typing import Any, Callable, TypeVar from typing import Any, Callable, TypeVar
from dataclasses import dataclass from dataclasses import dataclass
@@ -117,7 +117,7 @@ class Animator:
"""An object to manage updates to a given attribute over a period of time.""" """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: 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.target = target
self._timer = Timer( self._timer = Timer(
target, target,
@@ -130,7 +130,7 @@ class Animator:
def get_time(self) -> float: def get_time(self) -> float:
"""Get the current wall clock time.""" """Get the current wall clock time."""
return time() return monotonic()
async def start(self) -> None: async def start(self) -> None:
"""Start the animator task.""" """Start the animator task."""
@@ -180,9 +180,10 @@ class Animator:
final_value = value final_value = value
start_time = self.get_time() start_time = self.get_time()
animation_key = (id(obj), attribute) animation_key = (id(obj), attribute, final_value)
if animation_key in self._animations: if animation_key in self._animations:
self._animations[animation_key](start_time) return
easing_function = EASING[easing] if isinstance(easing, str) else easing easing_function = EASING[easing] if isinstance(easing, str) else easing
@@ -219,10 +220,11 @@ class Animator:
easing=easing_function, easing=easing_function,
) )
assert animation is not None, "animation expected to be non-None" assert animation is not None, "animation expected to be non-None"
self._animations[animation_key] = animation self._animations[animation_key] = animation
self._timer.resume() self._timer.resume()
def __call__(self) -> None: def __call__(self, time: float) -> None:
if not self._animations: if not self._animations:
self._timer.pause() self._timer.pause()
else: else:
@@ -236,4 +238,4 @@ class Animator:
def on_animation_frame(self) -> None: def on_animation_frame(self) -> None:
# TODO: We should be able to do animation without refreshing everything # TODO: We should be able to do animation without refreshing everything
self.target.screen.refresh(layout=True) self.target.screen.refresh_layout()

View File

@@ -28,6 +28,7 @@ from .geometry import Region, Offset, Size
from ._loop import loop_last from ._loop import loop_last
from ._profile import timer
from ._segment_tools import line_crop from ._segment_tools import line_crop
from ._types import Lines from ._types import Lines
from .widget import Widget from .widget import Widget
@@ -459,7 +460,6 @@ class Compositor:
Returns: Returns:
SegmentLines: A renderable SegmentLines: A renderable
""" """
width, height = self.size width, height = self.size
screen_region = Region(0, 0, width, height) screen_region = Region(0, 0, width, height)
@@ -495,6 +495,8 @@ class Compositor:
cut_segments = [line] cut_segments = [line]
else: else:
# More than one cut, which means we need to divide the line # More than one cut, which means we need to divide the line
if not final_cuts:
continue
render_x = render_region.x render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts] relative_cuts = [cut - render_x for cut in final_cuts]
_, *cut_segments = divide(line, relative_cuts) _, *cut_segments = divide(line, relative_cuts)

View File

@@ -8,15 +8,17 @@ from asyncio import (
sleep, sleep,
Task, Task,
) )
from functools import partial
from time import monotonic from time import monotonic
from typing import Awaitable, Callable, Union from typing import Awaitable, Callable, Union
from rich.repr import Result, rich_repr from rich.repr import Result, rich_repr
from . import events from . import events
from ._callback import invoke
from ._types import MessageTarget from ._types import MessageTarget
TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]] TimerCallback = Union[Callable[[float], Awaitable[None]], Callable[[float], None]]
class EventTargetGone(Exception): class EventTargetGone(Exception):
@@ -36,9 +38,21 @@ class Timer:
name: str | None = None, name: str | None = None,
callback: TimerCallback | None = None, callback: TimerCallback | None = None,
repeat: int | None = None, repeat: int | None = None,
skip: bool = False, skip: bool = True,
pause: bool = False, pause: bool = False,
) -> None: ) -> 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_repr = repr(event_target)
self._target = weakref.ref(event_target) self._target = weakref.ref(event_target)
self._interval = interval self._interval = interval
@@ -102,11 +116,19 @@ class Timer:
if wait_time: if wait_time:
await sleep(wait_time) await sleep(wait_time)
event = events.Timer( 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 count += 1
try: 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: except EventTargetGone:
break break
await self._active.wait() await self._active.wait()

View File

@@ -18,6 +18,9 @@ class MessageTarget(Protocol):
async def post_message(self, message: "Message") -> bool: 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: def post_message_no_wait(self, message: "Message") -> bool:
... ...

View File

@@ -35,6 +35,7 @@ from .file_monitor import FileMonitor
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
from .layouts.dock import Dock from .layouts.dock import Dock
from .message_pump import MessagePump from .message_pump import MessagePump
from ._profile import timer
from .reactive import Reactive from .reactive import Reactive
from .screen import Screen from .screen import Screen
from .widget import Widget from .widget import Widget
@@ -113,16 +114,17 @@ class App(DOMNode):
self._refresh_required = False self._refresh_required = False
self.design = ColorSystem( self.design = ColorSystem(
primary="#1b72b1", # blueish primary="#406e8e", # blueish
secondary="#471EC2", # purplesis secondary="#6d9f71", # purplesis
warning="#ffa629", # orange warning="#ffa62b", # orange
error="#db1a4a", # error error="#ba3c5b", # error
success="#38d645", # green success="#6d9f71", # green
accent1="#1b72b1", accent1="#ffa62b",
accent2="#ffa629", accent2="#5a4599",
) )
self.stylesheet = Stylesheet(variables=self.get_css_variables()) self.stylesheet = Stylesheet(variables=self.get_css_variables())
self._require_styles_update = False
self.css_file = css_file self.css_file = css_file
self.css_monitor = ( self.css_monitor = (
@@ -321,7 +323,8 @@ class App(DOMNode):
Should be called whenever CSS classes / pseudo classes change. 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: def mount(self, *anon_widgets: Widget, **widgets: Widget) -> None:
self.register(self.screen, *anon_widgets, **widgets) self.register(self.screen, *anon_widgets, **widgets)
@@ -471,6 +474,11 @@ class App(DOMNode):
if self.log_file is not None: if self.log_file is not None:
self.log_file.close() 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: def _register_child(self, parent: DOMNode, child: DOMNode) -> bool:
if child not in self.registry: if child not in self.registry:
parent.children._append(child) parent.children._append(child)
@@ -713,7 +721,7 @@ class App(DOMNode):
await self.action( await self.action(
action, default_namespace=default_namespace, modifiers=modifiers action, default_namespace=default_namespace, modifiers=modifiers
) )
elif isinstance(action, Callable): elif callable(action):
await action() await action()
else: else:
return False return False

View File

@@ -300,7 +300,7 @@ class Stylesheet:
if is_animatable(key) and new_render_value != old_render_value: if is_animatable(key) and new_render_value != old_render_value:
transition = new_styles.get_transition(key) transition = new_styles.get_transition(key)
if transition is not None: if transition is not None:
duration, easing, delay = transition duration, easing, _delay = transition
node.app.animator.animate( node.app.animator.animate(
node.styles.base, node.styles.base,
key, key,

View File

@@ -364,11 +364,13 @@ class Timer(Event, verbosity=3, bubble=False):
self, self,
sender: MessageTarget, sender: MessageTarget,
timer: "TimerClass", timer: "TimerClass",
time: float,
count: int = 0, count: int = 0,
callback: TimerCallback | None = None, callback: TimerCallback | None = None,
) -> None: ) -> None:
super().__init__(sender) super().__init__(sender)
self.timer = timer self.timer = timer
self.time = time
self.count = count self.count = count
self.callback = callback self.callback = callback

View File

@@ -2,9 +2,9 @@ from __future__ import annotations
import asyncio import asyncio
from asyncio import CancelledError from asyncio import CancelledError
from asyncio import Queue, QueueEmpty, Task from asyncio import PriorityQueue, QueueEmpty, Task
from functools import partial from functools import partial, total_ordering
from typing import TYPE_CHECKING, Awaitable, Iterable, Callable from typing import TYPE_CHECKING, Awaitable, Iterable, Callable, NamedTuple
from weakref import WeakSet from weakref import WeakSet
from . import events from . import events
@@ -32,9 +32,26 @@ class MessagePumpClosed(Exception):
pass 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: class MessagePump:
def __init__(self, parent: MessagePump | None = None) -> None: 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._parent = parent
self._running: bool = False self._running: bool = False
self._closing: bool = False self._closing: bool = False
@@ -96,7 +113,7 @@ class MessagePump:
return self._pending_message return self._pending_message
finally: finally:
self._pending_message = None self._pending_message = None
message = await self._message_queue.get() message = (await self._message_queue.get()).message
if message is None: if message is None:
self._closed = True self._closed = True
raise MessagePumpClosed("The message pump is now closed") raise MessagePumpClosed("The message pump is now closed")
@@ -111,7 +128,7 @@ class MessagePump:
""" """
if self._pending_message is None: if self._pending_message is None:
try: try:
self._pending_message = self._message_queue.get_nowait() self._pending_message = self._message_queue.get_nowait().message
except QueueEmpty: except QueueEmpty:
pass pass
@@ -155,7 +172,7 @@ class MessagePump:
) )
def close_messages_no_wait(self) -> None: 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: async def close_messages(self) -> None:
"""Close message queue, and optionally wait for queue to finish processing.""" """Close message queue, and optionally wait for queue to finish processing."""
@@ -164,7 +181,7 @@ class MessagePump:
self._closing = True self._closing = True
await self._message_queue.put(None) await self._message_queue.put(MessagePriority(None))
for task in self._child_tasks: for task in self._child_tasks:
task.cancel() task.cancel()
@@ -284,7 +301,25 @@ class MessagePump:
return False return False
if not self.check_message_enabled(message): if not self.check_message_enabled(message):
return True 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 return True
def post_message_no_wait(self, message: Message) -> bool: def post_message_no_wait(self, message: Message) -> bool:
@@ -292,7 +327,7 @@ class MessagePump:
return False return False
if not self.check_message_enabled(message): if not self.check_message_enabled(message):
return True return True
self._message_queue.put_nowait(message) self._message_queue.put_nowait(MessagePriority(message))
return True return True
async def post_message_from_child(self, message: Message) -> bool: async def post_message_from_child(self, message: Message) -> bool:

View File

@@ -91,6 +91,7 @@ class Screen(Widget):
def on_idle(self, event: events.Idle) -> None: def on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint) # Check for any widgets marked as 'dirty' (needs a repaint)
if self._dirty_widgets: if self._dirty_widgets:
self.log(dirty=len(self._dirty_widgets))
for widget in self._dirty_widgets: for widget in self._dirty_widgets:
# Repaint widgets # Repaint widgets
# TODO: Combine these in to a single update. # TODO: Combine these in to a single update.
@@ -100,7 +101,7 @@ class Screen(Widget):
# Reset dirty list # Reset dirty list
self._dirty_widgets.clear() 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).""" """Refresh the layout (can change size and positions of widgets)."""
if not self.size: if not self.size:
return return
@@ -144,11 +145,11 @@ class Screen(Widget):
async def handle_layout(self, message: messages.Layout) -> None: async def handle_layout(self, message: messages.Layout) -> None:
message.stop() message.stop()
await self.refresh_layout() self.refresh_layout()
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:
self.size_updated(event.size, event.virtual_size, event.container_size) self.size_updated(event.size, event.virtual_size, event.container_size)
await self.refresh_layout() self.refresh_layout()
event.stop() event.stop()
async def _on_mouse_move(self, event: events.MouseMove) -> None: async def _on_mouse_move(self, event: events.MouseMove) -> None:

View File

@@ -165,9 +165,9 @@ def test_lab_to_rgb(r, g, b, L_, a_, b_):
def test_rgb_lab_rgb_roundtrip(): def test_rgb_lab_rgb_roundtrip():
"""Test RGB -> CIE-L*ab -> RGB color conversion roundtripping.""" """Test RGB -> CIE-L*ab -> RGB color conversion roundtripping."""
for r in range(0, 256, 4): for r in range(0, 256, 32):
for g in range(0, 256, 4): for g in range(0, 256, 32):
for b in range(0, 256, 4): for b in range(0, 256, 32):
c_ = lab_to_rgb(rgb_to_lab(Color(r, g, b))) c_ = lab_to_rgb(rgb_to_lab(Color(r, g, b)))
assert c_.r == pytest.approx(r, abs=1) assert c_.r == pytest.approx(r, abs=1)
assert c_.g == pytest.approx(g, abs=1) assert c_.g == pytest.approx(g, abs=1)