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 */
/*
* {
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;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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