mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
fix for timer issue
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user