Skip to content

Commit 41438bd

Browse files
committed
layout and animation
1 parent 8f8986c commit 41438bd

File tree

10 files changed

+217
-91
lines changed

10 files changed

+217
-91
lines changed

src/textual/_animator.py

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,32 @@
33
import logging
44

55
import asyncio
6+
import sys
67
from time import time
78
from tracemalloc import start
8-
from typing import Callable
9+
from typing import Callable, TypeVar
910

1011
from dataclasses import dataclass
1112

1213
from ._timer import Timer
1314
from ._types import MessageTarget
1415

16+
if sys.version_info >= (3, 8):
17+
from typing import Protocol
18+
else:
19+
from typing_extensions import Protocol
20+
1521

1622
EasingFunction = Callable[[float], float]
1723

24+
T = TypeVar("T")
25+
26+
27+
class Animatable(Protocol):
28+
def blend(self: T, destination: T, factor: float) -> T:
29+
...
30+
31+
1832
# https://easings.net/
1933
EASING = {
2034
"none": lambda x: 1.0,
@@ -25,6 +39,8 @@
2539
"out_cubic": lambda x: 1 - pow(1 - x, 3),
2640
}
2741

42+
DEFAULT_EASING = "in_out_cubic"
43+
2844

2945
log = logging.getLogger("rich")
3046

@@ -35,30 +51,43 @@ class Animation:
3551
attribute: str
3652
start_time: float
3753
duration: float
38-
start_value: float
39-
end_value: float
54+
start_value: float | Animatable
55+
end_value: float | Animatable
4056
easing_function: EasingFunction
4157

4258
def __call__(self, time: float) -> bool:
59+
def blend_float(start: float, end: float, factor: float) -> float:
60+
return start + (end - start) * factor
61+
62+
AnimatableT = TypeVar("AnimatableT", bound=Animatable)
63+
64+
def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT:
65+
return start.blend(end, factor)
66+
67+
blend_function = (
68+
blend_float if isinstance(self.start_value, (int, float)) else blend
69+
)
4370

4471
if self.duration == 0:
4572
value = self.end_value
4673
else:
47-
progress = min(1.0, (time - self.start_time) / self.duration)
74+
factor = min(1.0, (time - self.start_time) / self.duration)
75+
eased_factor = self.easing_function(factor)
76+
# value = blend_function(self.start_value, self.end_value, eased_factor)
77+
4878
if self.end_value > self.start_value:
49-
eased_progress = self.easing_function(progress)
79+
eased_factor = self.easing_function(factor)
5080
value = (
5181
self.start_value
52-
+ (self.end_value - self.start_value) * eased_progress
82+
+ (self.end_value - self.start_value) * eased_factor
5383
)
5484
else:
55-
eased_progress = 1 - self.easing_function(progress)
85+
eased_factor = 1 - self.easing_function(factor)
5686
value = (
57-
self.end_value
58-
+ (self.start_value - self.end_value) * eased_progress
87+
self.end_value + (self.start_value - self.end_value) * eased_factor
5988
)
60-
6189
setattr(self.obj, self.attribute, value)
90+
log.debug("ANIMATE %r %r -> %r", self.obj, self.attribute, value)
6291
return value == self.end_value
6392

6493

@@ -74,7 +103,7 @@ def __call__(
74103
*,
75104
duration: float | None = None,
76105
speed: float | None = None,
77-
easing: EasingFunction | str = "in_out_cubic",
106+
easing: EasingFunction | str = DEFAULT_EASING,
78107
) -> None:
79108
easing_function = EASING[easing] if isinstance(easing, str) else easing
80109
self._animator.animate(
@@ -88,7 +117,7 @@ def __call__(
88117

89118

90119
class Animator:
91-
def __init__(self, target: MessageTarget, frames_per_second: int = 30) -> None:
120+
def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None:
92121
self._animations: dict[tuple[object, str], Animation] = {}
93122
self._timer = Timer(target, 1 / frames_per_second, target, callback=self)
94123

@@ -109,7 +138,7 @@ def animate(
109138
*,
110139
duration: float | None = None,
111140
speed: float | None = None,
112-
easing: EasingFunction = EASING["in_out_cubic"],
141+
easing: EasingFunction | str = DEFAULT_EASING,
113142
) -> None:
114143

115144
start_time = time()
@@ -123,15 +152,15 @@ def animate(
123152
animation_duration = duration
124153
else:
125154
animation_duration = abs(value - start_value) / (speed or 50)
126-
155+
easing_function = EASING[easing] if isinstance(easing, str) else easing
127156
animation = Animation(
128157
obj,
129158
attribute=attribute,
130159
start_time=start_time,
131160
duration=animation_duration,
132161
start_value=start_value,
133162
end_value=value,
134-
easing_function=easing,
163+
easing_function=easing_function,
135164
)
136165
self._animations[animation_key] = animation
137166
self._timer.resume()

src/textual/_timer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def __init__(
3030
name: str | None = None,
3131
callback: TimerCallback | None = None,
3232
repeat: int = None,
33+
skip: bool = True,
3334
) -> None:
3435
self._target_repr = repr(event_target)
3536
self._target = weakref.ref(event_target)
@@ -39,6 +40,7 @@ def __init__(
3940
self._timer_count += 1
4041
self._callback = callback
4142
self._repeat = repeat
43+
self._skip = skip
4244
self._stop_event = Event()
4345
self._active = Event()
4446
self._active.set()
@@ -75,7 +77,11 @@ async def run(self) -> None:
7577
try:
7678
while _repeat is None or count <= _repeat:
7779
next_timer = start + (count * _interval)
80+
if self._skip and next_timer < monotonic():
81+
count += 1
82+
continue
7883
try:
84+
7985
if await wait_for(_wait(), max(0, next_timer - monotonic())):
8086
break
8187
except TimeoutError:

src/textual/app.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from . import events
1919
from . import actions
2020
from ._animator import Animator
21+
from .geometry import Point
2122
from ._context import active_app
2223
from .keys import Binding
2324
from .driver import Driver
@@ -87,8 +88,9 @@ def __init__(
8788

8889
self._bindings: dict[str, Binding] = {}
8990
self._docks: list[Dock] = []
90-
self._action_targets = {"app": self, "view": self.view}
91+
self._action_targets = {"app", "view"}
9192
self._animator = Animator(self)
93+
self.animate = self._animator.bind(self)
9294

9395
def __rich_repr__(self) -> rich.repr.RichReprResult:
9496
yield "title", self.title
@@ -240,8 +242,10 @@ def refresh(self) -> None:
240242
if not self._closed:
241243
console = self.console
242244
try:
245+
console.file.write("\x1bP=1s\x1b\\")
243246
with console:
244247
console.print(Screen(Control.home(), self.view, Control.home()))
248+
console.file.write("\x1bP=2s\x1b\\")
245249
except Exception:
246250
log.exception("refresh failed")
247251

@@ -279,9 +283,9 @@ async def action(
279283
target, params = actions.parse(action)
280284
if "." in target:
281285
destination, action_name = target.split(".", 1)
282-
action_target = self._action_targets.get(destination, None)
283-
if action_target is None:
286+
if destination not in self._action_targets:
284287
raise ActionError("Action namespace {destination} is not known")
288+
action_target = getattr(self, destination)
285289
else:
286290
action_target = default_namespace or self
287291
action_name = action
@@ -371,21 +375,35 @@ class MyApp(App):
371375
async def on_load(self, event: events.Load) -> None:
372376
await self.bind("q,ctrl+c", "quit")
373377
await self.bind("x", "bang")
374-
await self.bind("b", "view.toggle('left')")
378+
await self.bind("b", "toggle_sidebar")
379+
self.side = False
380+
381+
async def action_toggle_sidebar(self) -> None:
382+
self.side = not self.side
383+
self.animator.animate(
384+
self.bar,
385+
"layout_offset_x",
386+
20 if self.side else 0,
387+
speed=30,
388+
easing="in_out_cubic",
389+
)
375390

376391
async def on_startup(self, event: events.Startup) -> None:
377392

378393
view = await self.push_view(DockView())
379394

380395
header = Header(self.title)
396+
footer = Footer()
397+
self.bar = Placeholder(name="left")
398+
footer.add_key("b", "Toggle sidebar")
399+
footer.add_key("q", "Quit")
381400

382401
await view.dock(header, edge="top")
383-
384-
await view.dock(Placeholder(), edge="left", size=40)
402+
await view.dock(footer, edge="bottom")
403+
await view.dock(self.bar, edge="left", size=30, z=1)
385404

386405
sub_view = DockView()
387406
await sub_view.dock(Placeholder(), Placeholder(), edge="top")
388-
389407
await view.dock(sub_view, edge="left")
390408

391409
# self.refresh()

src/textual/geometry.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,18 +37,18 @@ def is_origin(self) -> bool:
3737
return self == (0, 0)
3838

3939
def __add__(self, other: object) -> Point:
40-
if isinstance(other, Point):
40+
if isinstance(other, tuple):
4141
_x, _y = self
4242
x, y = other
4343
return Point(_x + x, _y + y)
44-
raise NotImplemented
44+
return NotImplemented
4545

4646
def __sub__(self, other: object) -> Point:
47-
if isinstance(other, Point):
47+
if isinstance(other, tuple):
4848
_x, _y = self
4949
x, y = other
5050
return Point(_x - x, _y - y)
51-
raise NotImplemented
51+
return NotImplemented
5252

5353
def blend(self, destination: Point, factor: float) -> Point:
5454
"""Blend (interpolate) to a new point.

src/textual/layout.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ def __init__(self) -> None:
6767
self._cuts: list[list[int]] | None = None
6868

6969
def reset(self) -> None:
70-
self._layout_map = {}
7170
self.renders.clear()
7271
self._cuts = None
7372

@@ -80,15 +79,15 @@ def map(self) -> dict[Widget, MapRegion]:
8079
return self._layout_map
8180

8281
def __iter__(self) -> Iterable[tuple[Widget, Region]]:
83-
layers = sorted(self._layout_map.items(), key=lambda item: item[1].order)
82+
layers = sorted(
83+
self._layout_map.items(), key=lambda item: item[1].order, reverse=True
84+
)
8485
for widget, (region, _) in layers:
8586
yield widget, region
8687

8788
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
8889
"""Get the widget under the given point or None."""
89-
for widget, (region, _order) in sorted(
90-
self._layout_map.items(), key=lambda item: item[1].order
91-
):
90+
for widget, region in self:
9291
if region.contains(x, y):
9392
return widget, region
9493
raise NoWidget
@@ -175,7 +174,7 @@ def render(widget: Widget, width: int, height: int) -> Lines:
175174
new_region = region.clip(width, height)
176175
delta_x = new_region.x - region.x
177176
delta_y = new_region.y - region.y
178-
region = new_region
177+
# region = new_region
179178
lines = lines[delta_y : delta_y + region.height]
180179
lines = [
181180
list(Segment.divide(line, [delta_x, delta_x + region.width]))[1]

src/textual/layouts/dock.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ..widget import WidgetID
1212
from ..geometry import Region, Point
1313
from ..layout import Layout, MapRegion
14+
from .._types import Lines
1415

1516
if sys.version_info >= (3, 8):
1617
from typing import Literal
@@ -55,21 +56,20 @@ def reflow(self, width: int, height: int, offset: Point = Point(0, 0)) -> None:
5556
self.width = width
5657
self.height = height
5758
map: dict[Widget, MapRegion] = {}
58-
region = Region(0, 0, width, height)
5959

60-
layers: dict[int, Region] = defaultdict(lambda: Region(0, 0, width, height))
61-
62-
log.debug("%r", self.docks)
60+
layers: dict[int, Region] = defaultdict(
61+
lambda: Region(0, 0, self.width, self.height)
62+
)
6363

6464
def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
6565
region = region + offset
6666
if hasattr(widget, "layout"):
6767
widget.layout.reflow(
68-
region.width, region.height, region.origin + offset
68+
region.width, region.height, offset=region.origin + offset
6969
)
7070
map.update(widget.layout.map)
7171
else:
72-
map[widget] = MapRegion(region, order)
72+
map[widget] = MapRegion(region + widget.layout_offset, order)
7373

7474
for index, dock in enumerate(self.docks):
7575
dock_options = [
@@ -94,6 +94,8 @@ def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
9494
remaining = region.height
9595
total = 0
9696
for widget, size in zip(dock.widgets, sizes):
97+
if not widget.visible:
98+
continue
9799
size = min(remaining, size)
98100
if not size:
99101
break
@@ -112,6 +114,8 @@ def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
112114
remaining = region.height
113115
total = 0
114116
for widget, size in zip(dock.widgets, sizes):
117+
if not widget.visible:
118+
continue
115119
size = min(remaining, size)
116120
if not size:
117121
break
@@ -130,6 +134,8 @@ def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
130134
remaining = region.width
131135
total = 0
132136
for widget, size in zip(dock.widgets, sizes):
137+
if not widget.visible:
138+
continue
133139
size = min(remaining, size)
134140
if not size:
135141
break
@@ -148,6 +154,8 @@ def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
148154
remaining = region.width
149155
total = 0
150156
for widget, size in zip(dock.widgets, sizes):
157+
if not widget.visible:
158+
continue
151159
size = min(remaining, size)
152160
if not size:
153161
break
@@ -162,6 +170,13 @@ def add_widget(widget: Widget, region: Region, order: tuple[int, int]):
162170

163171
layers[dock.z] = region
164172

173+
new_renders: dict[Widget, tuple[Region, Lines]] = {}
174+
for widget, (region, order) in map.items():
175+
if widget in self.renders and self.renders[widget][0].size == region.size:
176+
new_renders[widget] = (region, self.renders[widget][1])
177+
178+
self.renders = new_renders
179+
log.debug("DOCK")
165180
self._layout_map = map
166181

167182

0 commit comments

Comments
 (0)