simplify idle events, add batching

This commit is contained in:
Will McGugan
2021-06-06 12:09:51 +01:00
parent 91b7efa4a9
commit d9fe76ac5a
5 changed files with 106 additions and 31 deletions

View File

@@ -40,6 +40,7 @@ class EventType(Enum):
DOUBLE_CLICK = auto() DOUBLE_CLICK = auto()
ENTER = auto() ENTER = auto()
LEAVE = auto() LEAVE = auto()
UPDATE = auto()
CUSTOM = 1000 CUSTOM = 1000
@@ -230,3 +231,8 @@ class Focus(Event, type=EventType.FOCUS):
class Blur(Event, type=EventType.BLUR): class Blur(Event, type=EventType.BLUR):
pass pass
class Update(Event, type=EventType.UPDATE):
def can_batch(self, event: Event) -> bool:
return isinstance(event, Update) and event.sender == self.sender

View File

@@ -12,23 +12,40 @@ class Message:
sender: MessageTarget sender: MessageTarget
bubble: ClassVar[bool] = False bubble: ClassVar[bool] = False
default_priority: ClassVar[int] = 0 default_priority: ClassVar[int] = 0
suppressed: bool = False
_no_default_action: bool = False
_stop_propagaton: bool = False
def __init__(self, sender: MessageTarget) -> None: def __init__(self, sender: MessageTarget) -> None:
self.sender = sender self.sender = sender
self.name = camel_to_snake(self.__class__.__name__) self.name = camel_to_snake(self.__class__.__name__)
self.time = monotonic() self.time = monotonic()
super().__init__()
def __init_subclass__(cls, bubble: bool = False, priority: int = 0) -> None: def __init_subclass__(cls, bubble: bool = False, priority: int = 0) -> None:
super().__init_subclass__() super().__init_subclass__()
cls.bubble = bubble cls.bubble = bubble
cls.default_priority = priority cls.default_priority = priority
def suppress_default(self, suppress: bool = True) -> None: def can_batch(self, message: "Message") -> bool:
"""Check if another message may supersede this one.
Args:
message (Message): [description]
Returns:
bool: [description]
"""
return False
def prevent_default(self, prevent: bool = True) -> None:
"""Suppress the default action. """Suppress the default action.
Args: Args:
suppress (bool, optional): True if the default action should be suppressed, prevent (bool, optional): True if the default action should be suppressed,
or False if the default actions should be performed. Defaults to True. or False if the default actions should be performed. Defaults to True.
""" """
self.suppress = suppress self._no_default_action = prevent
def stop_propagation(self, stop: bool = True) -> None:
self._stop_propagaton = stop

View File

@@ -1,6 +1,6 @@
from typing import Optional, NamedTuple, Set, Type, TYPE_CHECKING from typing import Optional, NamedTuple, Set, Type, TYPE_CHECKING
import asyncio import asyncio
from asyncio import PriorityQueue from asyncio import PriorityQueue, QueueEmpty
import logging import logging
@@ -56,6 +56,7 @@ class MessagePump:
self._closing: bool = False self._closing: bool = False
self._closed: bool = False self._closed: bool = False
self._disabled_messages: Set[Type[Message]] = set() self._disabled_messages: Set[Type[Message]] = set()
self._pending_message: Optional[MessageQueueItem] = None
def check_message_enabled(self, message: Message) -> bool: def check_message_enabled(self, message: Message) -> bool:
return type(message) not in self._disabled_messages return type(message) not in self._disabled_messages
@@ -74,6 +75,11 @@ class MessagePump:
Returns: Returns:
Optional[Event]: Event object or None. Optional[Event]: Event object or None.
""" """
if self._pending_message is not None:
try:
return self._pending_message
finally:
self._pending_message = None
if self._closed: if self._closed:
raise MessagePumpClosed("The message pump is closed") raise MessagePumpClosed("The message pump is closed")
queue_item = await self._message_queue.get() queue_item = await self._message_queue.get()
@@ -82,6 +88,20 @@ class MessagePump:
raise MessagePumpClosed("The message pump is now closed") raise MessagePumpClosed("The message pump is now closed")
return queue_item return queue_item
def peek_message(self) -> Optional[MessageQueueItem]:
"""Peek the message at the head of the queue (does not remove it from the queue),
or return None if the queue is empty.
Returns:
Optional[Message]: The message or None.
"""
if self._pending_message is None:
self._pending_message = self._message_queue.get_nowait()
if self._pending_message is not None:
return self._pending_message
return None
def set_timer( def set_timer(
self, self,
delay: float, delay: float,
@@ -121,11 +141,20 @@ class MessagePump:
except Exception: except Exception:
log.exception("error getting message") log.exception("error getting message")
break break
# Combine any pending messages that may supersede this one
while True:
pending = self.peek_message()
if pending is None or not message.can_batch(pending.message):
break
priority, message = pending
try: try:
await self.dispatch_message(message, priority) await self.dispatch_message(message, priority)
finally: finally:
if self._message_queue.empty(): if self._message_queue.empty():
await self.dispatch_message(events.Idle(self)) idle_handler = getattr(self, "on_idle", None)
if idle_handler is not None:
await idle_handler(events.Idle(self))
async def dispatch_message( async def dispatch_message(
self, message: Message, priority: int = 0 self, message: Message, priority: int = 0
@@ -141,7 +170,7 @@ class MessagePump:
dispatch_function: MessageHandler = getattr(self, method_name, None) dispatch_function: MessageHandler = getattr(self, method_name, None)
if dispatch_function is not None: if dispatch_function is not None:
await dispatch_function(event) await dispatch_function(event)
if event.bubble and self._parent: if event.bubble and self._parent and not event._stop_propagaton:
if event.sender == self._parent: if event.sender == self._parent:
log.debug("bubbled event abandoned; %r", event) log.debug("bubbled event abandoned; %r", event)
else: else:

View File

@@ -33,6 +33,10 @@ log = getLogger("rich")
T = TypeVar("T") T = TypeVar("T")
class RefreshMessage(Message):
pass
class Reactive(Generic[T]): class Reactive(Generic[T]):
def __init__(self, default: T) -> None: def __init__(self, default: T) -> None:
self._default = default self._default = default
@@ -61,7 +65,6 @@ class Widget(MessagePump):
_count: ClassVar[int] = 0 _count: ClassVar[int] = 0
can_focus: bool = False can_focus: bool = False
mouse_events: bool = False mouse_events: bool = False
idle_events: bool = False
def __init__(self, name: Optional[str] = None) -> None: def __init__(self, name: Optional[str] = None) -> None:
self.name = name or f"{self.__class__.__name__}#{self._count}" self.name = name or f"{self.__class__.__name__}#{self._count}"
@@ -79,22 +82,15 @@ class Widget(MessagePump):
events.Click, events.Click,
events.DoubleClick, events.DoubleClick,
) )
if not self.idle_events:
self.disable_messages(events.Idle)
def __init_subclass__( def __init_subclass__(
cls, cls,
can_focus: bool = False, can_focus: bool = False,
mouse_events: bool = True, mouse_events: bool = True,
idle_events: bool = False,
) -> None: ) -> None:
super().__init_subclass__() super().__init_subclass__()
cls.can_focus = can_focus cls.can_focus = can_focus
cls.mouse_events = mouse_events cls.mouse_events = mouse_events
cls.idle_events = idle_events
has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
def __rich_repr__(self) -> RichReprResult: def __rich_repr__(self) -> RichReprResult:
yield "name", self.name yield "name", self.name
@@ -127,15 +123,15 @@ class Widget(MessagePump):
def require_refresh(self) -> None: def require_refresh(self) -> None:
self._line_cache = None self._line_cache = None
async def refresh(self) -> None:
await self.emit(RefreshMessage(self))
def render_update(self, x: int, y: int) -> Iterable[Segment]: def render_update(self, x: int, y: int) -> Iterable[Segment]:
yield from self.line_cache.render(x, y) yield from self.line_cache.render(x, y)
def render(self) -> RenderableType: def render(self) -> RenderableType:
return Panel( return Panel(
Align.center(Pretty(self), vertical="middle"), Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__
title=self.__class__.__name__,
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED,
) )
async def post_message( async def post_message(
@@ -147,8 +143,11 @@ class Widget(MessagePump):
return await super().post_message(message, priority) return await super().post_message(message, priority)
async def on_event(self, event: events.Event, priority: int) -> None: async def on_event(self, event: events.Event, priority: int) -> None:
if isinstance(event, (events.Enter, events.Leave)): if isinstance(event, events.Resize):
self.mouse_over = isinstance(event, events.Enter) new_size = WidgetDimensions(event.width, event.height)
if self.size != new_size:
self.size = new_size
self.require_refresh()
await super().on_event(event, priority) await super().on_event(event, priority)
async def on_resize(self, event: events.Resize) -> None: async def on_resize(self, event: events.Resize) -> None:
@@ -156,12 +155,3 @@ class Widget(MessagePump):
if self.size != new_size: if self.size != new_size:
self.size = new_size self.size = new_size
self.require_refresh() self.require_refresh()
async def on_focus(self, event: events.Focus) -> None:
self.has_focus = True
async def on_blur(self, event: events.Focus) -> None:
self.has_focus = False
async def on_idle(self, event: events.Idle) -> None:
self.app.refresh()

View File

@@ -1,10 +1,43 @@
from ..widget import Widget from .. import events
from ..widget import Reactive, Widget
from rich import box
from rich.align import Align
from rich.console import RenderableType
from rich.panel import Panel
from rich.pretty import Pretty
from rich.repr import RichReprResult from rich.repr import RichReprResult
class Placeholder(Widget, can_focus=True, mouse_events=True): class Placeholder(Widget, can_focus=True, mouse_events=True):
has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
def __rich_repr__(self) -> RichReprResult: def __rich_repr__(self) -> RichReprResult:
yield "name", self.name yield "name", self.name
yield "has_focus", self.has_focus yield "has_focus", self.has_focus
yield "mouse_over", self.mouse_over yield "mouse_over", self.mouse_over
def render(self) -> RenderableType:
return Panel(
Align.center(Pretty(self), vertical="middle"),
title=self.__class__.__name__,
border_style="green" if self.mouse_over else "blue",
box=box.HEAVY if self.has_focus else box.ROUNDED,
)
async def on_focus(self, event: events.Focus) -> None:
self.has_focus = True
async def on_blur(self, event: events.Blur) -> None:
self.has_focus = False
async def on_enter(self, event: events.Enter) -> None:
self.mouse_over = True
async def on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
async def on_idle(self, event: events.Idle) -> None:
self.app.refresh()