Skip to content

Commit 70d18b8

Browse files
committed
smooth scrolling
1 parent e555fc0 commit 70d18b8

File tree

15 files changed

+198
-189
lines changed

15 files changed

+198
-189
lines changed

examples/simple.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from textual.app import App
55
from textual.widgets.header import Header
66
from textual.widgets.placeholder import Placeholder
7-
from textual.widgets.window import Window
7+
from textual.widgets.scroll_view import ScrollView
88

99
with open("richreadme.md", "rt") as fh:
1010
readme = Markdown(fh.read(), hyperlinks=True, code_theme="fruity")
@@ -16,7 +16,7 @@ class MyApp(App):
1616

1717
async def on_startup(self, event: events.Startup) -> None:
1818
await self.view.mount_all(
19-
header=Header(self.title), left=Placeholder(), body=Window(readme)
19+
header=Header(self.title), left=Placeholder(), body=ScrollView(readme)
2020
)
2121

2222

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "0.1.1"
3+
version = "0.1.2"
44
description = "Text User Interface using Rich"
55
authors = ["Will McGugan <[email protected]>"]
66
license = "MIT"

src/textual/_animator.py

Lines changed: 56 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,26 @@
44

55
import asyncio
66
from time import time
7+
from tracemalloc import start
78
from typing import Callable
89

910
from dataclasses import dataclass
1011

1112
from ._timer import Timer
1213
from ._types import MessageTarget
13-
from .message_pump import MessagePump
14-
15-
EasingFunction = Callable[[float], float]
1614

1715

18-
LinearEasing = lambda value: value
19-
16+
EasingFunction = Callable[[float], float]
2017

21-
def InOutCubitEasing(x: float) -> float:
22-
return 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2
18+
# https://easings.net/
19+
EASING = {
20+
"none": lambda x: 1.0,
21+
"round": lambda x: 0.0 if x < 0.5 else 1.0,
22+
"linear": lambda x: x,
23+
"in_cubic": lambda x: x * x * x,
24+
"in_out_cubic": lambda x: 4 * x * x * x if x < 0.5 else 1 - pow(-2 * x + 2, 3) / 2,
25+
"out_cubic": lambda x: 1 - pow(1 - x, 3),
26+
}
2327

2428

2529
log = logging.getLogger("rich")
@@ -35,11 +39,26 @@ class Animation:
3539
end_value: float
3640
easing_function: EasingFunction
3741

38-
def __call__(self, obj: object, time: float) -> bool:
39-
progress = min(1.0, (time - self.start_time) / self.duration)
40-
eased_progress = self.easing_function(progress)
41-
value = self.start_value + (self.end_value - self.start_value) * eased_progress
42-
setattr(obj, self.attribute, value)
42+
def __call__(self, time: float) -> bool:
43+
44+
if self.duration == 0:
45+
value = self.end_value
46+
else:
47+
progress = min(1.0, (time - self.start_time) / self.duration)
48+
if self.end_value > self.start_value:
49+
eased_progress = self.easing_function(progress)
50+
value = (
51+
self.start_value
52+
+ (self.end_value - self.start_value) * eased_progress
53+
)
54+
else:
55+
eased_progress = 1 - self.easing_function(progress)
56+
value = (
57+
self.end_value
58+
+ (self.start_value - self.end_value) * eased_progress
59+
)
60+
61+
setattr(self.obj, self.attribute, value)
4362
return value == self.end_value
4463

4564

@@ -53,22 +72,25 @@ def __call__(
5372
attribute: str,
5473
value: float,
5574
*,
56-
duration: float = 1,
57-
easing: EasingFunction = InOutCubitEasing,
75+
duration: float | None = None,
76+
speed: float | None = None,
77+
easing: EasingFunction | str = "in_out_cubic",
5878
) -> None:
79+
easing_function = EASING[easing] if isinstance(easing, str) else easing
5980
self._animator.animate(
6081
self._obj,
6182
attribute=attribute,
6283
value=value,
6384
duration=duration,
64-
easing=easing,
85+
speed=speed,
86+
easing=easing_function,
6587
)
6688

6789

6890
class Animator:
69-
def __init__(self, target: MessageTarget) -> None:
91+
def __init__(self, target: MessageTarget, frames_per_second: int = 30) -> None:
7092
self._animations: dict[tuple[object, str], Animation] = {}
71-
self._timer: Timer = Timer(target, 1 / 30, target, callback=self)
93+
self._timer = Timer(target, 1 / frames_per_second, target, callback=self)
7294

7395
async def start(self) -> None:
7496
asyncio.get_event_loop().create_task(self._timer.run())
@@ -85,22 +107,33 @@ def animate(
85107
attribute: str,
86108
value: float,
87109
*,
88-
duration: float = 1,
89-
easing: EasingFunction = InOutCubitEasing,
110+
duration: float | None = None,
111+
speed: float | None = None,
112+
easing: EasingFunction = EASING["in_out_cubic"],
90113
) -> None:
91-
start_value = getattr(obj, attribute)
114+
92115
start_time = time()
93116

117+
animation_key = (obj, attribute)
118+
if animation_key in self._animations:
119+
self._animations[animation_key](start_time)
120+
121+
start_value = getattr(obj, attribute)
122+
if duration is not None:
123+
animation_duration = duration
124+
else:
125+
animation_duration = abs(value - start_value) / (speed or 50)
126+
94127
animation = Animation(
95128
obj,
96129
attribute=attribute,
97130
start_time=start_time,
98-
duration=duration,
131+
duration=animation_duration,
99132
start_value=start_value,
100133
end_value=value,
101134
easing_function=easing,
102135
)
103-
self._animations[(obj, attribute)] = animation
136+
self._animations[animation_key] = animation
104137
self._timer.resume()
105138

106139
async def __call__(self) -> None:
@@ -111,6 +144,5 @@ async def __call__(self) -> None:
111144
animation_keys = list(self._animations.keys())
112145
for animation_key in animation_keys:
113146
animation = self._animations[animation_key]
114-
obj, _attribute = animation_key
115-
if animation(obj, animation_time):
147+
if animation(animation_time):
116148
del self._animations[animation_key]

src/textual/_timer.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def __init__(
4141
self._repeat = repeat
4242
self._stop_event = Event()
4343
self._active = Event()
44+
self._active.set()
4445

4546
def __rich_repr__(self) -> RichReprResult:
4647
yield self._interval

src/textual/app.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from __future__ import annotations
2+
from argparse import Action
23

34
import asyncio
45

@@ -13,6 +14,7 @@
1314
from rich.screen import Screen
1415
from rich import get_console
1516
from rich.console import Console, RenderableType
17+
from rich.traceback import Traceback
1618

1719
from . import events
1820
from . import actions
@@ -45,6 +47,10 @@
4547
# uvloop.install()
4648

4749

50+
class ActionError(Exception):
51+
pass
52+
53+
4854
class ShutdownError(Exception):
4955
pass
5056

@@ -163,18 +169,10 @@ async def set_mouse_over(self, widget: WidgetBase | None) -> None:
163169
self.mouse_over = widget
164170

165171
async def process_messages(self) -> None:
166-
try:
167-
await self._process_messages()
168-
except Exception:
169-
self.console.print_exception(show_locals=True)
170-
171-
async def _process_messages(self) -> None:
172172
log.debug("driver=%r", self.driver_class)
173-
loop = asyncio.get_event_loop()
174-
175-
loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt)
173+
# loop = asyncio.get_event_loop()
174+
# loop.add_signal_handler(signal.SIGINT, self.on_keyboard_interupt)
176175
driver = self._driver = self.driver_class(self.console, self)
177-
178176
active_app.set(self)
179177
self.view.set_parent(self)
180178
await self.add(self.view)
@@ -184,14 +182,21 @@ async def _process_messages(self) -> None:
184182
try:
185183
driver.start_application_mode()
186184
except Exception:
185+
self.console.print_exception()
187186
log.exception("error starting application mode")
188-
raise
189-
await self.animator.start()
190-
await super().process_messages()
191-
await self.animator.stop()
187+
else:
188+
traceback: Traceback | None = None
189+
await self.animator.start()
190+
try:
191+
await super().process_messages()
192+
except Exception:
193+
traceback = Traceback(show_locals=True)
192194

193-
await self.view.close_messages()
194-
driver.stop_application_mode()
195+
await self.animator.stop()
196+
await self.view.close_messages()
197+
driver.stop_application_mode()
198+
if traceback is not None:
199+
self.console.print(traceback)
195200

196201
async def add(self, child: MessagePump) -> None:
197202
self.children.add(child)
@@ -229,32 +234,35 @@ async def on_event(self, event: events.Event) -> None:
229234
else:
230235
await super().on_event(event)
231236

232-
async def action(self, action: str) -> None:
237+
async def action(
238+
self, action: str, default_namespace: object | None = None
239+
) -> None:
233240
"""Perform an action.
234241
235242
Args:
236243
action (str): Action encoded in a string.
237244
"""
238-
245+
default_target = default_namespace or self
239246
target, params = actions.parse(action)
240247
if "." in target:
241248
destination, action_name = target.split(".", 1)
249+
action_target = self._action_targets.get(destination, None)
250+
if action_target is None:
251+
raise ActionError("Action namespace {destination} is not known")
242252
else:
243-
destination = "app"
253+
action_target = default_namespace or self
244254
action_name = action
245255

246-
log.debug("ACTION %r %r", destination, action_name)
247-
await self.dispatch_action(destination, action_name, params)
256+
log.debug("ACTION %r %r", action_target, action_name)
257+
await self.dispatch_action(action_target, action_name, params)
248258

249259
async def dispatch_action(
250-
self, destination: str, action_name: str, params: Any
260+
self, namespace: object, action_name: str, params: Any
251261
) -> None:
252-
action_target = self._action_targets.get(destination, None)
253-
if action_target is not None:
254-
method_name = f"action_{action_name}"
255-
method = getattr(action_target, method_name, None)
256-
if method is not None:
257-
await method(*params)
262+
method_name = f"action_{action_name}"
263+
method = getattr(namespace, method_name, None)
264+
if method is not None:
265+
await method(*params)
258266

259267
async def on_shutdown_request(self, event: events.ShutdownRequest) -> None:
260268
log.debug("shutdown request")

src/textual/events.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,17 @@ class Idle(Event):
6262
"""Sent when there are no more items in the message queue."""
6363

6464

65+
class Action(Event):
66+
__slots__ = ["action"]
67+
68+
def __init__(self, sender: MessageTarget, action: str) -> None:
69+
super().__init__(sender)
70+
self.action = action
71+
72+
def __rich_repr__(self) -> RichReprResult:
73+
yield "action", self.action
74+
75+
6576
class Resize(Event):
6677
__slots__ = ["width", "height"]
6778
width: int
@@ -168,7 +179,7 @@ class MouseUp(MouseEvent):
168179
pass
169180

170181

171-
class MouseScrollDown(InputEvent):
182+
class MouseScrollDown(InputEvent, bubble=True):
172183
__slots__ = ["x", "y"]
173184

174185
def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
@@ -177,7 +188,7 @@ def __init__(self, sender: MessageTarget, x: int, y: int) -> None:
177188
self.y = y
178189

179190

180-
class MouseScrollUp(MouseScrollDown):
191+
class MouseScrollUp(MouseScrollDown, bubble=True):
181192
pass
182193

183194

src/textual/geometry.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
from __future__ import annotations
22

3-
from typing import Any, NamedTuple
3+
from typing import Any, NamedTuple, TypeVar
4+
5+
6+
T = TypeVar("T", int, float)
7+
8+
9+
def clamp(value: T, minimum: T, maximum: T) -> T:
10+
"""Clamps a value between two other values.
11+
12+
Args:
13+
value (T): A value
14+
minimum (T): Minimum value
15+
maximum (T): maximum value
16+
17+
Returns:
18+
T: New value that is not less than the minimum or greater than the maximum.
19+
"""
20+
return min(max(value, minimum), maximum)
421

522

623
class Point(NamedTuple):

src/textual/message_pump.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ async def process_messages(self) -> None:
135135
log.exception("error in get_message()")
136136
raise error from None
137137

138-
log.debug("%r -> %r", message, self)
138+
# log.debug("%r -> %r", message, self)
139139
# Combine any pending messages that may supersede this one
140140
while not (self._closed or self._closing):
141141
pending = self.peek_message()
@@ -174,7 +174,8 @@ async def on_event(self, event: events.Event) -> None:
174174
await dispatch_function(event)
175175
if event.bubble and self._parent and not event._stop_propagaton:
176176
if event.sender == self._parent:
177-
log.debug("bubbled event abandoned; %r", event)
177+
pass
178+
# log.debug("bubbled event abandoned; %r", event)
178179
elif not self._parent._closed and not self._parent._closing:
179180
await self._parent.post_message(event)
180181

@@ -204,7 +205,6 @@ async def post_message_from_child(self, message: Message) -> bool:
204205

205206
async def emit(self, message: Message) -> bool:
206207
if self._parent:
207-
log.debug("EMIT %r -> %r %r", self, self._parent, message)
208208
await self._parent.post_message_from_child(message)
209209
return True
210210
else:

0 commit comments

Comments
 (0)