mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
smooth scrolling
This commit is contained in:
@@ -4,7 +4,7 @@ from textual import events
|
|||||||
from textual.app import App
|
from textual.app import App
|
||||||
from textual.widgets.header import Header
|
from textual.widgets.header import Header
|
||||||
from textual.widgets.placeholder import Placeholder
|
from textual.widgets.placeholder import Placeholder
|
||||||
from textual.widgets.window import Window
|
from textual.widgets.scroll_view import ScrollView
|
||||||
|
|
||||||
with open("richreadme.md", "rt") as fh:
|
with open("richreadme.md", "rt") as fh:
|
||||||
readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity")
|
readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity")
|
||||||
@@ -16,7 +16,7 @@ class MyApp(App):
|
|||||||
|
|
||||||
async def on_startup(self, event: events.Startup) -> None:
|
async def on_startup(self, event: events.Startup) -> None:
|
||||||
await self.view.mount_all(
|
await self.view.mount_all(
|
||||||
header=Header(self.title), left=Placeholder(), body=Window(readme)
|
header=Header(self.title), left=Placeholder(), body=ScrollView(readme)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "Text User Interface using Rich"
|
description = "Text User Interface using Rich"
|
||||||
authors = ["Will McGugan <willmcgugan@gmail.com>"]
|
authors = ["Will McGugan <willmcgugan@gmail.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -4,22 +4,26 @@ import logging
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from time import time
|
from time import time
|
||||||
|
from tracemalloc import start
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ._timer import Timer
|
from ._timer import Timer
|
||||||
from ._types import MessageTarget
|
from ._types import MessageTarget
|
||||||
from .message_pump import MessagePump
|
|
||||||
|
|
||||||
EasingFunction = Callable[[float], float]
|
EasingFunction = Callable[[float], float]
|
||||||
|
|
||||||
|
# https://easings.net/
|
||||||
LinearEasing = lambda value: value
|
EASING = {
|
||||||
|
"none": lambda x: 1.0,
|
||||||
|
"round": lambda x: 0.0 if x < 0.5 else 1.0,
|
||||||
def InOutCubitEasing(x: float) -> float:
|
"linear": lambda x: x,
|
||||||
return 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2
|
"in_cubic": lambda x: x * x * x,
|
||||||
|
"in_out_cubic": lambda x: 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2,
|
||||||
|
"out_cubic": lambda x: 1 - pow(1 - x, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger("rich")
|
log = logging.getLogger("rich")
|
||||||
@@ -35,11 +39,26 @@ class Animation:
|
|||||||
end_value: float
|
end_value: float
|
||||||
easing_function: EasingFunction
|
easing_function: EasingFunction
|
||||||
|
|
||||||
def __call__(self, obj: object, time: float) -> bool:
|
def __call__(self, time: float) -> bool:
|
||||||
progress = min(1.0, (time - self.start_time) / self.duration)
|
|
||||||
eased_progress = self.easing_function(progress)
|
if self.duration == 0:
|
||||||
value = self.start_value + (self.end_value - self.start_value) * eased_progress
|
value = self.end_value
|
||||||
setattr(obj, self.attribute, value)
|
else:
|
||||||
|
progress = min(1.0, (time - self.start_time) / self.duration)
|
||||||
|
if self.end_value > self.start_value:
|
||||||
|
eased_progress = self.easing_function(progress)
|
||||||
|
value = (
|
||||||
|
self.start_value
|
||||||
|
+ (self.end_value - self.start_value) * eased_progress
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
eased_progress = 1 - self.easing_function(progress)
|
||||||
|
value = (
|
||||||
|
self.end_value
|
||||||
|
+ (self.start_value - self.end_value) * eased_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
setattr(self.obj, self.attribute, value)
|
||||||
return value == self.end_value
|
return value == self.end_value
|
||||||
|
|
||||||
|
|
||||||
@@ -53,22 +72,25 @@ class BoundAnimator:
|
|||||||
attribute: str,
|
attribute: str,
|
||||||
value: float,
|
value: float,
|
||||||
*,
|
*,
|
||||||
duration: float = 1,
|
duration: float | None = None,
|
||||||
easing: EasingFunction = InOutCubitEasing,
|
speed: float | None = None,
|
||||||
|
easing: EasingFunction | str = "in_out_cubic",
|
||||||
) -> None:
|
) -> None:
|
||||||
|
easing_function = EASING[easing] if isinstance(easing, str) else easing
|
||||||
self._animator.animate(
|
self._animator.animate(
|
||||||
self._obj,
|
self._obj,
|
||||||
attribute=attribute,
|
attribute=attribute,
|
||||||
value=value,
|
value=value,
|
||||||
duration=duration,
|
duration=duration,
|
||||||
easing=easing,
|
speed=speed,
|
||||||
|
easing=easing_function,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Animator:
|
class Animator:
|
||||||
def __init__(self, target: MessageTarget) -> None:
|
def __init__(self, target: MessageTarget, frames_per_second: int = 30) -> None:
|
||||||
self._animations: dict[tuple[object, str], Animation] = {}
|
self._animations: dict[tuple[object, str], Animation] = {}
|
||||||
self._timer: Timer = Timer(target, 1 / 30, target, callback=self)
|
self._timer = Timer(target, 1 / frames_per_second, target, callback=self)
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
asyncio.get_event_loop().create_task(self._timer.run())
|
asyncio.get_event_loop().create_task(self._timer.run())
|
||||||
@@ -85,22 +107,33 @@ class Animator:
|
|||||||
attribute: str,
|
attribute: str,
|
||||||
value: float,
|
value: float,
|
||||||
*,
|
*,
|
||||||
duration: float = 1,
|
duration: float | None = None,
|
||||||
easing: EasingFunction = InOutCubitEasing,
|
speed: float | None = None,
|
||||||
|
easing: EasingFunction = EASING["in_out_cubic"],
|
||||||
) -> None:
|
) -> None:
|
||||||
start_value = getattr(obj, attribute)
|
|
||||||
start_time = time()
|
start_time = time()
|
||||||
|
|
||||||
|
animation_key = (obj, attribute)
|
||||||
|
if animation_key in self._animations:
|
||||||
|
self._animations[animation_key](start_time)
|
||||||
|
|
||||||
|
start_value = getattr(obj, attribute)
|
||||||
|
if duration is not None:
|
||||||
|
animation_duration = duration
|
||||||
|
else:
|
||||||
|
animation_duration = abs(value - start_value) / (speed or 50)
|
||||||
|
|
||||||
animation = Animation(
|
animation = Animation(
|
||||||
obj,
|
obj,
|
||||||
attribute=attribute,
|
attribute=attribute,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
duration=duration,
|
duration=animation_duration,
|
||||||
start_value=start_value,
|
start_value=start_value,
|
||||||
end_value=value,
|
end_value=value,
|
||||||
easing_function=easing,
|
easing_function=easing,
|
||||||
)
|
)
|
||||||
self._animations[(obj, attribute)] = animation
|
self._animations[animation_key] = animation
|
||||||
self._timer.resume()
|
self._timer.resume()
|
||||||
|
|
||||||
async def __call__(self) -> None:
|
async def __call__(self) -> None:
|
||||||
@@ -111,6 +144,5 @@ class Animator:
|
|||||||
animation_keys = list(self._animations.keys())
|
animation_keys = list(self._animations.keys())
|
||||||
for animation_key in animation_keys:
|
for animation_key in animation_keys:
|
||||||
animation = self._animations[animation_key]
|
animation = self._animations[animation_key]
|
||||||
obj, _attribute = animation_key
|
if animation(animation_time):
|
||||||
if animation(obj, animation_time):
|
|
||||||
del self._animations[animation_key]
|
del self._animations[animation_key]
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class Timer:
|
|||||||
self._repeat = repeat
|
self._repeat = repeat
|
||||||
self._stop_event = Event()
|
self._stop_event = Event()
|
||||||
self._active = Event()
|
self._active = Event()
|
||||||
|
self._active.set()
|
||||||
|
|
||||||
def __rich_repr__(self) -> RichReprResult:
|
def __rich_repr__(self) -> RichReprResult:
|
||||||
yield self._interval
|
yield self._interval
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from argparse import Action
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import rich.repr
|
|||||||
from rich.screen import Screen
|
from rich.screen import Screen
|
||||||
from rich import get_console
|
from rich import get_console
|
||||||
from rich.console import Console, RenderableType
|
from rich.console import Console, RenderableType
|
||||||
|
from rich.traceback import Traceback
|
||||||
|
|
||||||
from . import events
|
from . import events
|
||||||
from . import actions
|
from . import actions
|
||||||
@@ -45,6 +47,10 @@ LayoutDefinition = "dict[str, Any]"
|
|||||||
# uvloop.install()
|
# uvloop.install()
|
||||||
|
|
||||||
|
|
||||||
|
class ActionError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ShutdownError(Exception):
|
class ShutdownError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -163,18 +169,10 @@ class App(MessagePump):
|
|||||||
self.mouse_over = widget
|
self.mouse_over = widget
|
||||||
|
|
||||||
async def process_messages(self) -> None:
|
async def process_messages(self) -> None:
|
||||||
try:
|
|
||||||
await self._process_messages()
|
|
||||||
except Exception:
|
|
||||||
self.console.print_exception(show_locals=True)
|
|
||||||
|
|
||||||
async def _process_messages(self) -> None:
|
|
||||||
log.debug("driver=%r", self.driver_class)
|
log.debug("driver=%r", self.driver_class)
|
||||||
loop = asyncio.get_event_loop()
|
# loop = asyncio.get_event_loop()
|
||||||
|
# loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt)
|
||||||
loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt)
|
|
||||||
driver = self._driver = self.driver_class(self.console, self)
|
driver = self._driver = self.driver_class(self.console, self)
|
||||||
|
|
||||||
active_app.set(self)
|
active_app.set(self)
|
||||||
self.view.set_parent(self)
|
self.view.set_parent(self)
|
||||||
await self.add(self.view)
|
await self.add(self.view)
|
||||||
@@ -184,14 +182,21 @@ class App(MessagePump):
|
|||||||
try:
|
try:
|
||||||
driver.start_application_mode()
|
driver.start_application_mode()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
self.console.print_exception()
|
||||||
log.exception("error starting application mode")
|
log.exception("error starting application mode")
|
||||||
raise
|
else:
|
||||||
await self.animator.start()
|
traceback: Traceback | None = None
|
||||||
await super().process_messages()
|
await self.animator.start()
|
||||||
await self.animator.stop()
|
try:
|
||||||
|
await super().process_messages()
|
||||||
|
except Exception:
|
||||||
|
traceback = Traceback(show_locals=True)
|
||||||
|
|
||||||
await self.view.close_messages()
|
await self.animator.stop()
|
||||||
driver.stop_application_mode()
|
await self.view.close_messages()
|
||||||
|
driver.stop_application_mode()
|
||||||
|
if traceback is not None:
|
||||||
|
self.console.print(traceback)
|
||||||
|
|
||||||
async def add(self, child: MessagePump) -> None:
|
async def add(self, child: MessagePump) -> None:
|
||||||
self.children.add(child)
|
self.children.add(child)
|
||||||
@@ -229,32 +234,35 @@ class App(MessagePump):
|
|||||||
else:
|
else:
|
||||||
await super().on_event(event)
|
await super().on_event(event)
|
||||||
|
|
||||||
async def action(self, action: str) -> None:
|
async def action(
|
||||||
|
self, action: str, default_namespace: object | None = None
|
||||||
|
) -> None:
|
||||||
"""Perform an action.
|
"""Perform an action.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
action (str): Action encoded in a string.
|
action (str): Action encoded in a string.
|
||||||
"""
|
"""
|
||||||
|
default_target = default_namespace or self
|
||||||
target, params = actions.parse(action)
|
target, params = actions.parse(action)
|
||||||
if "." in target:
|
if "." in target:
|
||||||
destination, action_name = target.split(".", 1)
|
destination, action_name = target.split(".", 1)
|
||||||
|
action_target = self._action_targets.get(destination, None)
|
||||||
|
if action_target is None:
|
||||||
|
raise ActionError("Action namespace {destination} is not known")
|
||||||
else:
|
else:
|
||||||
destination = "app"
|
action_target = default_namespace or self
|
||||||
action_name = action
|
action_name = action
|
||||||
|
|
||||||
log.debug("ACTION %r %r", destination, action_name)
|
log.debug("ACTION %r %r", action_target, action_name)
|
||||||
await self.dispatch_action(destination, action_name, params)
|
await self.dispatch_action(action_target, action_name, params)
|
||||||
|
|
||||||
async def dispatch_action(
|
async def dispatch_action(
|
||||||
self, destination: str, action_name: str, params: Any
|
self, namespace: object, action_name: str, params: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
action_target = self._action_targets.get(destination, None)
|
method_name = f"action_{action_name}"
|
||||||
if action_target is not None:
|
method = getattr(namespace, method_name, None)
|
||||||
method_name = f"action_{action_name}"
|
if method is not None:
|
||||||
method = getattr(action_target, method_name, None)
|
await method(*params)
|
||||||
if method is not None:
|
|
||||||
await method(*params)
|
|
||||||
|
|
||||||
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
|
||||||
log.debug("shutdown request")
|
log.debug("shutdown request")
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ class Idle(Event):
|
|||||||
"""Sent when there are no more items in the message queue."""
|
"""Sent when there are no more items in the message queue."""
|
||||||
|
|
||||||
|
|
||||||
|
class Action(Event):
|
||||||
|
__slots__ = ["action"]
|
||||||
|
|
||||||
|
def __init__(self, sender: MessageTarget, action: str) -> None:
|
||||||
|
super().__init__(sender)
|
||||||
|
self.action = action
|
||||||
|
|
||||||
|
def __rich_repr__(self) -> RichReprResult:
|
||||||
|
yield "action", self.action
|
||||||
|
|
||||||
|
|
||||||
class Resize(Event):
|
class Resize(Event):
|
||||||
__slots__ = ["width", "height"]
|
__slots__ = ["width", "height"]
|
||||||
width: int
|
width: int
|
||||||
@@ -168,7 +179,7 @@ class MouseUp(MouseEvent):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MouseScrollDown(InputEvent):
|
class MouseScrollDown(InputEvent, bubble=True):
|
||||||
__slots__ = ["x", "y"]
|
__slots__ = ["x", "y"]
|
||||||
|
|
||||||
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
|
||||||
@@ -177,7 +188,7 @@ class MouseScrollDown(InputEvent):
|
|||||||
self.y = y
|
self.y = y
|
||||||
|
|
||||||
|
|
||||||
class MouseScrollUp(MouseScrollDown):
|
class MouseScrollUp(MouseScrollDown, bubble=True):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple, TypeVar
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T", int, float)
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value: T, minimum: T, maximum: T) -> T:
|
||||||
|
"""Clamps a value between two other values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value (T): A value
|
||||||
|
minimum (T): Minimum value
|
||||||
|
maximum (T): maximum value
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
T: New value that is not less than the minimum or greater than the maximum.
|
||||||
|
"""
|
||||||
|
return min(max(value, minimum), maximum)
|
||||||
|
|
||||||
|
|
||||||
class Point(NamedTuple):
|
class Point(NamedTuple):
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class MessagePump:
|
|||||||
log.exception("error in get_message()")
|
log.exception("error in get_message()")
|
||||||
raise error from None
|
raise error from None
|
||||||
|
|
||||||
log.debug("%r -> %r", message, self)
|
# log.debug("%r -> %r", message, self)
|
||||||
# Combine any pending messages that may supersede this one
|
# Combine any pending messages that may supersede this one
|
||||||
while not (self._closed or self._closing):
|
while not (self._closed or self._closing):
|
||||||
pending = self.peek_message()
|
pending = self.peek_message()
|
||||||
@@ -174,7 +174,8 @@ class MessagePump:
|
|||||||
await dispatch_function(event)
|
await dispatch_function(event)
|
||||||
if event.bubble and self._parent and not event._stop_propagaton:
|
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)
|
pass
|
||||||
|
# log.debug("bubbled event abandoned; %r", event)
|
||||||
elif not self._parent._closed and not self._parent._closing:
|
elif not self._parent._closed and not self._parent._closing:
|
||||||
await self._parent.post_message(event)
|
await self._parent.post_message(event)
|
||||||
|
|
||||||
@@ -204,7 +205,6 @@ class MessagePump:
|
|||||||
|
|
||||||
async def emit(self, message: Message) -> bool:
|
async def emit(self, message: Message) -> bool:
|
||||||
if self._parent:
|
if self._parent:
|
||||||
log.debug("EMIT %r -> %r %r", self, self._parent, message)
|
|
||||||
await self._parent.post_message_from_child(message)
|
await self._parent.post_message_from_child(message)
|
||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ class PageRender:
|
|||||||
def move_to(self, x: int = 0, y: int = 0) -> None:
|
def move_to(self, x: int = 0, y: int = 0) -> None:
|
||||||
self.offset = Point(x, y)
|
self.offset = Point(x, y)
|
||||||
|
|
||||||
def refresh(self) -> None:
|
def clear(self) -> None:
|
||||||
self._render_width = None
|
self._render_width = None
|
||||||
self._render_height = None
|
self._render_height = None
|
||||||
del self._lines[:]
|
del self._lines[:]
|
||||||
|
|
||||||
def update(self, renderable: RenderableType) -> None:
|
def update(self, renderable: RenderableType) -> None:
|
||||||
self.renderable = renderable
|
self.renderable = renderable
|
||||||
self.refresh()
|
self.clear()
|
||||||
|
|
||||||
def render(self, console: Console, options: ConsoleOptions) -> None:
|
def render(self, console: Console, options: ConsoleOptions) -> None:
|
||||||
width = self.width or options.max_width or console.width
|
width = self.width or options.max_width or console.width
|
||||||
@@ -84,6 +84,10 @@ class Page(Widget):
|
|||||||
x: Reactive[int] = Reactive(0)
|
x: Reactive[int] = Reactive(0)
|
||||||
y: Reactive[int] = Reactive(0)
|
y: Reactive[int] = Reactive(0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def contents_size(self) -> Dimensions:
|
||||||
|
return self._page.size
|
||||||
|
|
||||||
def validate_y(self, value: int) -> int:
|
def validate_y(self, value: int) -> int:
|
||||||
return max(0, value)
|
return max(0, value)
|
||||||
|
|
||||||
@@ -91,6 +95,13 @@ class Page(Widget):
|
|||||||
x, y = self._page.offset
|
x, y = self._page.offset
|
||||||
self._page.offset = Point(x, new)
|
self._page.offset = Point(x, new)
|
||||||
|
|
||||||
|
def update(self, renderable: RenderableType | None = None) -> None:
|
||||||
|
|
||||||
|
if renderable:
|
||||||
|
self._page.update(renderable)
|
||||||
|
else:
|
||||||
|
self._page.clear()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def virtual_size(self) -> Dimensions:
|
def virtual_size(self) -> Dimensions:
|
||||||
return self._page.size
|
return self._page.size
|
||||||
|
|||||||
@@ -13,9 +13,18 @@ from rich.style import Style, StyleType
|
|||||||
log = logging.getLogger("rich")
|
log = logging.getLogger("rich")
|
||||||
|
|
||||||
from . import events
|
from . import events
|
||||||
|
from .message import Message
|
||||||
from .widget import Reactive, Widget
|
from .widget import Reactive, Widget
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollUp(Message, bubble=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollDown(Message, bubble=True):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ScrollBarRender:
|
class ScrollBarRender:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class View(ABC, WidgetBase):
|
|||||||
) -> None:
|
) -> None:
|
||||||
...
|
...
|
||||||
|
|
||||||
async def mount_all(self, **widgets: Widget) -> None:
|
async def mount_all(self, **widgets: WidgetBase) -> None:
|
||||||
for slot, widget in widgets.items():
|
for slot, widget in widgets.items():
|
||||||
await self.mount(widget, slot=slot)
|
await self.mount(widget, slot=slot)
|
||||||
self.require_repaint()
|
self.require_repaint()
|
||||||
@@ -129,9 +129,7 @@ class LayoutView(View):
|
|||||||
if not isinstance(widget, WidgetBase):
|
if not isinstance(widget, WidgetBase):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
log.debug("%r is_root %r", self, self.is_root_view)
|
|
||||||
if self.is_root_view:
|
if self.is_root_view:
|
||||||
log.debug("RENDERING %r %r %r", widget, message, region)
|
|
||||||
try:
|
try:
|
||||||
update = widget.render_update(
|
update = widget.render_update(
|
||||||
region.x + message.offset_x, region.y + message.offset_y
|
region.x + message.offset_x, region.y + message.offset_y
|
||||||
@@ -151,7 +149,8 @@ class LayoutView(View):
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
log.warning("Update widget not found")
|
pass
|
||||||
|
# log.warning("Update widget not found")
|
||||||
|
|
||||||
# async def on_create(self, event: events.Created) -> None:
|
# async def on_create(self, event: events.Created) -> None:
|
||||||
# await self.mount(Header(self.title))
|
# await self.mount(Header(self.title))
|
||||||
@@ -188,14 +187,11 @@ class LayoutView(View):
|
|||||||
async def _on_mouse_move(self, event: events.MouseMove) -> None:
|
async def _on_mouse_move(self, event: events.MouseMove) -> None:
|
||||||
try:
|
try:
|
||||||
widget, region = self.get_widget_at(event.x, event.y, deep=True)
|
widget, region = self.get_widget_at(event.x, event.y, deep=True)
|
||||||
log.debug("MOVE =%r %r", widget, region)
|
|
||||||
log.debug("mouse over %r %r", widget, region)
|
|
||||||
except NoWidget:
|
except NoWidget:
|
||||||
await self.app.set_mouse_over(None)
|
await self.app.set_mouse_over(None)
|
||||||
else:
|
else:
|
||||||
await self.app.set_mouse_over(widget)
|
await self.app.set_mouse_over(widget)
|
||||||
|
|
||||||
log.debug("posting mouse move to %r", widget)
|
|
||||||
await widget.forward_event(
|
await widget.forward_event(
|
||||||
events.MouseMove(
|
events.MouseMove(
|
||||||
self,
|
self,
|
||||||
@@ -209,7 +205,7 @@ class LayoutView(View):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def forward_event(self, event: events.Event) -> None:
|
async def forward_event(self, event: events.Event) -> None:
|
||||||
log.debug("FORWARD %r %r", self, event)
|
|
||||||
if isinstance(event, (events.Enter, events.Leave)):
|
if isinstance(event, (events.Enter, events.Leave)):
|
||||||
await self.post_message(event)
|
await self.post_message(event)
|
||||||
|
|
||||||
@@ -217,7 +213,6 @@ class LayoutView(View):
|
|||||||
await self._on_mouse_move(event)
|
await self._on_mouse_move(event)
|
||||||
|
|
||||||
elif isinstance(event, events.MouseEvent):
|
elif isinstance(event, events.MouseEvent):
|
||||||
log.debug("MOUSE %r", event)
|
|
||||||
try:
|
try:
|
||||||
widget, region = self.get_widget_at(event.x, event.y, deep=True)
|
widget, region = self.get_widget_at(event.x, event.y, deep=True)
|
||||||
except NoWidget:
|
except NoWidget:
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
|
|
||||||
def __set__(self, obj: "WidgetBase", value: ReactiveType) -> None:
|
def __set__(self, obj: "WidgetBase", value: ReactiveType) -> None:
|
||||||
if getattr(obj, self.internal_name) != value:
|
if getattr(obj, self.internal_name) != value:
|
||||||
log.debug("%s -> %s", self.internal_name, value)
|
|
||||||
|
|
||||||
current_value = getattr(obj, self.internal_name, None)
|
current_value = getattr(obj, self.internal_name, None)
|
||||||
validate_function = getattr(obj, f"validate_{self.name}", None)
|
validate_function = getattr(obj, f"validate_{self.name}", None)
|
||||||
@@ -205,10 +204,12 @@ class WidgetBase(MessagePump):
|
|||||||
Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__
|
Align.center(Pretty(self), vertical="middle"), title=self.__class__.__name__
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def action(self, action: str, *params) -> None:
|
||||||
|
await self.app.action(action, self)
|
||||||
|
|
||||||
async def post_message(self, message: Message) -> bool:
|
async def post_message(self, message: Message) -> bool:
|
||||||
if not self.check_message_enabled(message):
|
if not self.check_message_enabled(message):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return await super().post_message(message)
|
return await super().post_message(message)
|
||||||
|
|
||||||
async def on_event(self, event: events.Event) -> None:
|
async def on_event(self, event: events.Event) -> None:
|
||||||
@@ -221,7 +222,6 @@ class WidgetBase(MessagePump):
|
|||||||
|
|
||||||
async def on_idle(self, event: events.Idle) -> None:
|
async def on_idle(self, event: events.Idle) -> None:
|
||||||
if self.check_repaint():
|
if self.check_repaint():
|
||||||
log.debug("REPAINTING")
|
|
||||||
await self.repaint()
|
await self.repaint()
|
||||||
|
|
||||||
|
|
||||||
@@ -244,13 +244,15 @@ class Widget(WidgetBase):
|
|||||||
self._line_cache = LineCache.from_renderable(
|
self._line_cache = LineCache.from_renderable(
|
||||||
self.console, renderable, width, height
|
self.console, renderable, width, height
|
||||||
)
|
)
|
||||||
log.debug("%.1fms %r render elapsed", (time() - start) * 1000, self)
|
|
||||||
assert self._line_cache is not None
|
assert self._line_cache is not None
|
||||||
return self._line_cache
|
return self._line_cache
|
||||||
|
|
||||||
# def __rich__(self) -> LineCache:
|
# def __rich__(self) -> LineCache:
|
||||||
# return self.line_cache
|
# return self.line_cache
|
||||||
|
|
||||||
|
async def focus(self) -> None:
|
||||||
|
await self.app.set_focus(self)
|
||||||
|
|
||||||
def get_style_at(self, x: int, y: int) -> Style:
|
def get_style_at(self, x: int, y: int) -> Style:
|
||||||
return self.line_cache.get_style_at(x, y)
|
return self.line_cache.get_style_at(x, y)
|
||||||
|
|
||||||
@@ -279,7 +281,9 @@ class Widget(WidgetBase):
|
|||||||
yield from self.line_cache.render(x, y, width, height)
|
yield from self.line_cache.render(x, y, width, height)
|
||||||
|
|
||||||
async def on_mouse_move(self, event: events.MouseMove) -> None:
|
async def on_mouse_move(self, event: events.MouseMove) -> None:
|
||||||
log.debug("%r", self.get_style_at(event.x, event.y))
|
style_under_cursor = self.get_style_at(event.x, event.y)
|
||||||
|
if style_under_cursor:
|
||||||
|
log.debug("%r", style_under_cursor)
|
||||||
|
|
||||||
async def on_mouse_up(self, event: events.MouseUp) -> None:
|
async def on_mouse_up(self, event: events.MouseUp) -> None:
|
||||||
style = self.get_style_at(event.x, event.y)
|
style = self.get_style_at(event.x, event.y)
|
||||||
|
|||||||
@@ -51,5 +51,4 @@ class Header(Widget):
|
|||||||
return header
|
return header
|
||||||
|
|
||||||
async def on_mount(self, event: events.Mount) -> None:
|
async def on_mount(self, event: events.Mount) -> None:
|
||||||
return
|
|
||||||
self.set_interval(1.0, callback=self.refresh)
|
self.set_interval(1.0, callback=self.refresh)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from rich.text import Text
|
|||||||
|
|
||||||
from .. import events
|
from .. import events
|
||||||
from ..scrollbar import ScrollBar
|
from ..scrollbar import ScrollBar
|
||||||
|
from ..geometry import clamp
|
||||||
from ..page import Page
|
from ..page import Page
|
||||||
from ..view import LayoutView
|
from ..view import LayoutView
|
||||||
from ..widget import Reactive, StaticWidget
|
from ..widget import Reactive, StaticWidget
|
||||||
@@ -20,8 +20,13 @@ log = logging.getLogger("rich")
|
|||||||
|
|
||||||
class ScrollView(LayoutView):
|
class ScrollView(LayoutView):
|
||||||
def __init__(
|
def __init__(
|
||||||
self, renderable: RenderableType, name: str | None = None, style: StyleType = ""
|
self,
|
||||||
|
renderable: RenderableType,
|
||||||
|
name: str | None = None,
|
||||||
|
style: StyleType = "",
|
||||||
|
fluid: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.fluid = fluid
|
||||||
layout = Layout(name="outer")
|
layout = Layout(name="outer")
|
||||||
layout.split_row(
|
layout.split_row(
|
||||||
Layout(name="content", ratio=1), Layout(name="vertical_scrollbar", size=1)
|
Layout(name="content", ratio=1), Layout(name="vertical_scrollbar", size=1)
|
||||||
@@ -37,12 +42,17 @@ class ScrollView(LayoutView):
|
|||||||
x: Reactive[float] = Reactive(0)
|
x: Reactive[float] = Reactive(0)
|
||||||
y: Reactive[float] = Reactive(0)
|
y: Reactive[float] = Reactive(0)
|
||||||
|
|
||||||
def validate_y(self, value: float) -> int:
|
target_y: Reactive[float] = Reactive(0)
|
||||||
return max(0, round(value))
|
|
||||||
|
def validate_y(self, value: float) -> float:
|
||||||
|
return clamp(value, 0, self._page.contents_size.height - self.size.height)
|
||||||
|
|
||||||
|
def validate_target_y(self, value: float) -> float:
|
||||||
|
return clamp(value, 0, self._page.contents_size.height - self.size.height)
|
||||||
|
|
||||||
def update_y(self, old_value: float, new_value: float) -> None:
|
def update_y(self, old_value: float, new_value: float) -> None:
|
||||||
self._page.y = int(new_value)
|
self._page.y = round(new_value)
|
||||||
self._vertical_scrollbar.position = int(new_value)
|
self._vertical_scrollbar.position = round(new_value)
|
||||||
|
|
||||||
async def on_mount(self, event: events.Mount) -> None:
|
async def on_mount(self, event: events.Mount) -> None:
|
||||||
await self.mount_all(
|
await self.mount_all(
|
||||||
@@ -54,22 +64,32 @@ class ScrollView(LayoutView):
|
|||||||
async def on_idle(self, event: events.Idle) -> None:
|
async def on_idle(self, event: events.Idle) -> None:
|
||||||
self._vertical_scrollbar.virtual_size = self._page.virtual_size.height
|
self._vertical_scrollbar.virtual_size = self._page.virtual_size.height
|
||||||
self._vertical_scrollbar.window_size = self.size.height
|
self._vertical_scrollbar.window_size = self.size.height
|
||||||
# self._vertical_scrollbar.position = self.position_y
|
|
||||||
await super().on_idle(event)
|
await super().on_idle(event)
|
||||||
|
|
||||||
async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
||||||
self._vertical_scrollbar.position += 1
|
self.target_y += 1.5
|
||||||
|
self.animate("y", self.target_y, easing="out_cubic", speed=80)
|
||||||
|
|
||||||
async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None:
|
async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None:
|
||||||
self._vertical_scrollbar.position -= 1
|
self.target_y -= 1.5
|
||||||
|
self.animate("y", self.target_y, easing="out_cubic", speed=80)
|
||||||
|
|
||||||
async def on_key(self, event: events.Key) -> None:
|
async def on_key(self, event: events.Key) -> None:
|
||||||
key = event.key
|
key = event.key
|
||||||
if key == "down":
|
if key == "down":
|
||||||
self.y += 1
|
self.target_y += 2
|
||||||
|
self.animate("y", self.target_y, easing="linear", speed=100)
|
||||||
elif key == "up":
|
elif key == "up":
|
||||||
self.y -= 1
|
self.target_y -= 2
|
||||||
|
self.animate("y", self.target_y, easing="linear", speed=100)
|
||||||
elif key == "pagedown":
|
elif key == "pagedown":
|
||||||
self.animate("y", self.y + self.size.height)
|
self.target_y += self.size.height
|
||||||
|
self.animate("y", self.target_y, easing="out_cubic")
|
||||||
elif key == "pageup":
|
elif key == "pageup":
|
||||||
self.animate("y", self.y - self.size.height)
|
self.target_y -= self.size.height
|
||||||
|
self.animate("y", self.target_y, easing="out_cubic")
|
||||||
|
|
||||||
|
async def on_resize(self, event: events.Resize) -> None:
|
||||||
|
if self.fluid:
|
||||||
|
self._page.update()
|
||||||
|
await super().on_resize(event)
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import logging
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
if sys.version_info >= (3, 8):
|
|
||||||
from typing import Literal
|
|
||||||
else:
|
|
||||||
from typing_extensions import Literal
|
|
||||||
|
|
||||||
from rich.console import Console, ConsoleOptions, RenderableType
|
|
||||||
from rich.padding import Padding
|
|
||||||
from rich.segment import Segment
|
|
||||||
|
|
||||||
from .. import events
|
|
||||||
from ..widget import Widget, Reactive
|
|
||||||
from ..geometry import Point, Dimensions
|
|
||||||
from ..scrollbar import VerticalBar
|
|
||||||
|
|
||||||
log = logging.getLogger("rich")
|
|
||||||
|
|
||||||
ScrollMethod = Literal["always", "never", "auto", "overlay"]
|
|
||||||
|
|
||||||
|
|
||||||
class Window(Widget):
|
|
||||||
def __init__(
|
|
||||||
self, renderable: RenderableType, y_scroll: ScrollMethod = "always"
|
|
||||||
) -> None:
|
|
||||||
self.renderable = renderable
|
|
||||||
self.y_scroll = y_scroll
|
|
||||||
self._virtual_size: Dimensions = Dimensions(0, 0)
|
|
||||||
self._renderable_updated = True
|
|
||||||
self._lines: list[list[Segment]] = []
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def _validate_position(self, position: float) -> float:
|
|
||||||
_position = position
|
|
||||||
validated_pos = min(
|
|
||||||
max(0, position), self._virtual_size.height - self.size.height
|
|
||||||
)
|
|
||||||
log.debug("virtual_size=%r size=%r", self._virtual_size, self.size)
|
|
||||||
log.debug("%r %r", _position, validated_pos)
|
|
||||||
return validated_pos
|
|
||||||
|
|
||||||
position: Reactive[float] = Reactive(60, validator=_validate_position)
|
|
||||||
show_vertical_bar: Reactive[bool] = Reactive(True)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def virtual_size(self) -> Dimensions:
|
|
||||||
return self._virtual_size or self.size
|
|
||||||
|
|
||||||
def get_lines(
|
|
||||||
self, console: Console, options: ConsoleOptions
|
|
||||||
) -> list[list[Segment]]:
|
|
||||||
if not self._lines:
|
|
||||||
width = self.size.width
|
|
||||||
if self.show_vertical_bar and self.y_scroll != "overlay":
|
|
||||||
width -= 1
|
|
||||||
self._lines = console.render_lines(
|
|
||||||
Padding(self.renderable, 1), options.update_width(width)
|
|
||||||
)
|
|
||||||
self._virtual_size = Dimensions(0, len(self._lines))
|
|
||||||
return self._lines
|
|
||||||
|
|
||||||
def update(self, renderable: RenderableType) -> None:
|
|
||||||
self.renderable = renderable
|
|
||||||
del self._lines[:]
|
|
||||||
|
|
||||||
def render(self) -> RenderableType:
|
|
||||||
height = self.size.height
|
|
||||||
lines = self.get_lines(console, options)
|
|
||||||
position = int(self.position)
|
|
||||||
log.debug("%r, %r, %r", height, self._virtual_size, self.position)
|
|
||||||
return VerticalBar(
|
|
||||||
lines[position : position + height],
|
|
||||||
height,
|
|
||||||
self._virtual_size.height,
|
|
||||||
self.position,
|
|
||||||
overlay=self.y_scroll == "overlay",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_key(self, event: events.Key) -> None:
|
|
||||||
if event.key == "down":
|
|
||||||
self.position += 1
|
|
||||||
elif event.key == "up":
|
|
||||||
self.position -= 1
|
|
||||||
|
|
||||||
async def on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
||||||
self.position += 1
|
|
||||||
|
|
||||||
async def on_mouse_scroll_down(self, event: events.MouseScrollUp) -> None:
|
|
||||||
self.position -= 1
|
|
||||||
|
|
||||||
async def on_resize(self, event: events.Resize) -> None:
|
|
||||||
del self._lines[:]
|
|
||||||
self.position = self.position
|
|
||||||
self.require_repaint()
|
|
||||||
Reference in New Issue
Block a user