interactive.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. from __future__ import annotations
  2. import numpy as np
  3. from pyglet.window import key as PygletWindowKeys
  4. from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH
  5. from manimlib.constants import DOWN, LEFT, ORIGIN, RIGHT, UP
  6. from manimlib.constants import MED_LARGE_BUFF, MED_SMALL_BUFF, SMALL_BUFF
  7. from manimlib.constants import BLACK, BLUE, GREEN, GREY_A, GREY_C, RED, WHITE
  8. from manimlib.mobject.mobject import Group
  9. from manimlib.mobject.mobject import Mobject
  10. from manimlib.mobject.geometry import Circle
  11. from manimlib.mobject.geometry import Dot
  12. from manimlib.mobject.geometry import Line
  13. from manimlib.mobject.geometry import Rectangle
  14. from manimlib.mobject.geometry import RoundedRectangle
  15. from manimlib.mobject.geometry import Square
  16. from manimlib.mobject.svg.text_mobject import Text
  17. from manimlib.mobject.types.vectorized_mobject import VGroup
  18. from manimlib.mobject.value_tracker import ValueTracker
  19. from manimlib.utils.color import rgb_to_hex
  20. from manimlib.utils.space_ops import get_closest_point_on_line
  21. from manimlib.utils.space_ops import get_norm
  22. from typing import TYPE_CHECKING
  23. if TYPE_CHECKING:
  24. from typing import Callable
  25. from manimlib.typing import ManimColor
  26. # Interactive Mobjects
  27. class MotionMobject(Mobject):
  28. """
  29. You could hold and drag this object to any position
  30. """
  31. def __init__(self, mobject: Mobject, **kwargs):
  32. super().__init__(**kwargs)
  33. assert isinstance(mobject, Mobject)
  34. self.mobject = mobject
  35. self.mobject.add_mouse_drag_listner(self.mob_on_mouse_drag)
  36. # To avoid locking it as static mobject
  37. self.mobject.add_updater(lambda mob: None)
  38. self.add(mobject)
  39. def mob_on_mouse_drag(self, mob: Mobject, event_data: dict[str, np.ndarray]) -> bool:
  40. mob.move_to(event_data["point"])
  41. return False
  42. class Button(Mobject):
  43. """
  44. Pass any mobject and register an on_click method
  45. The on_click method takes mobject as argument like updater
  46. """
  47. def __init__(self, mobject: Mobject, on_click: Callable[[Mobject]], **kwargs):
  48. super().__init__(**kwargs)
  49. assert isinstance(mobject, Mobject)
  50. self.on_click = on_click
  51. self.mobject = mobject
  52. self.mobject.add_mouse_press_listner(self.mob_on_mouse_press)
  53. self.add(self.mobject)
  54. def mob_on_mouse_press(self, mob: Mobject, event_data) -> bool:
  55. self.on_click(mob)
  56. return False
  57. # Controls
  58. class ControlMobject(ValueTracker):
  59. def __init__(self, value: float, *mobjects: Mobject, **kwargs):
  60. super().__init__(value=value, **kwargs)
  61. self.add(*mobjects)
  62. # To avoid lock_static_mobject_data while waiting in scene
  63. self.add_updater(lambda mob: None)
  64. self.fix_in_frame()
  65. def set_value(self, value: float):
  66. self.assert_value(value)
  67. self.set_value_anim(value)
  68. return ValueTracker.set_value(self, value)
  69. def assert_value(self, value):
  70. # To be implemented in subclasses
  71. pass
  72. def set_value_anim(self, value):
  73. # To be implemented in subclasses
  74. pass
  75. class EnableDisableButton(ControlMobject):
  76. def __init__(
  77. self,
  78. value: bool = True,
  79. value_type: np.dtype = np.dtype(bool),
  80. rect_kwargs: dict = {
  81. "width": 0.5,
  82. "height": 0.5,
  83. "fill_opacity": 1.0
  84. },
  85. enable_color: ManimColor = GREEN,
  86. disable_color: ManimColor = RED,
  87. **kwargs
  88. ):
  89. self.value = value
  90. self.value_type = value_type
  91. self.rect_kwargs = rect_kwargs
  92. self.enable_color = enable_color
  93. self.disable_color = disable_color
  94. self.box = Rectangle(**self.rect_kwargs)
  95. super().__init__(value, self.box, **kwargs)
  96. self.add_mouse_press_listner(self.on_mouse_press)
  97. def assert_value(self, value: bool) -> None:
  98. assert isinstance(value, bool)
  99. def set_value_anim(self, value: bool) -> None:
  100. if value:
  101. self.box.set_fill(self.enable_color)
  102. else:
  103. self.box.set_fill(self.disable_color)
  104. def toggle_value(self) -> None:
  105. super().set_value(not self.get_value())
  106. def on_mouse_press(self, mob: Mobject, event_data) -> bool:
  107. mob.toggle_value()
  108. return False
  109. class Checkbox(ControlMobject):
  110. def __init__(
  111. self,
  112. value: bool = True,
  113. value_type: np.dtype = np.dtype(bool),
  114. rect_kwargs: dict = {
  115. "width": 0.5,
  116. "height": 0.5,
  117. "fill_opacity": 0.0
  118. },
  119. checkmark_kwargs: dict = {
  120. "stroke_color": GREEN,
  121. "stroke_width": 6,
  122. },
  123. cross_kwargs: dict = {
  124. "stroke_color": RED,
  125. "stroke_width": 6,
  126. },
  127. box_content_buff: float = SMALL_BUFF,
  128. **kwargs
  129. ):
  130. self.value_type = value_type
  131. self.rect_kwargs = rect_kwargs
  132. self.checkmark_kwargs = checkmark_kwargs
  133. self.cross_kwargs = cross_kwargs
  134. self.box_content_buff = box_content_buff
  135. self.box = Rectangle(**self.rect_kwargs)
  136. self.box_content = self.get_checkmark() if value else self.get_cross()
  137. super().__init__(value, self.box, self.box_content, **kwargs)
  138. self.add_mouse_press_listner(self.on_mouse_press)
  139. def assert_value(self, value: bool) -> None:
  140. assert isinstance(value, bool)
  141. def toggle_value(self) -> None:
  142. super().set_value(not self.get_value())
  143. def set_value_anim(self, value: bool) -> None:
  144. if value:
  145. self.box_content.become(self.get_checkmark())
  146. else:
  147. self.box_content.become(self.get_cross())
  148. def on_mouse_press(self, mob: Mobject, event_data) -> None:
  149. mob.toggle_value()
  150. return False
  151. # Helper methods
  152. def get_checkmark(self) -> VGroup:
  153. checkmark = VGroup(
  154. Line(UP / 2 + 2 * LEFT, DOWN + LEFT, **self.checkmark_kwargs),
  155. Line(DOWN + LEFT, UP + RIGHT, **self.checkmark_kwargs)
  156. )
  157. checkmark.stretch_to_fit_width(self.box.get_width())
  158. checkmark.stretch_to_fit_height(self.box.get_height())
  159. checkmark.scale(0.5)
  160. checkmark.move_to(self.box)
  161. return checkmark
  162. def get_cross(self) -> VGroup:
  163. cross = VGroup(
  164. Line(UP + LEFT, DOWN + RIGHT, **self.cross_kwargs),
  165. Line(UP + RIGHT, DOWN + LEFT, **self.cross_kwargs)
  166. )
  167. cross.stretch_to_fit_width(self.box.get_width())
  168. cross.stretch_to_fit_height(self.box.get_height())
  169. cross.scale(0.5)
  170. cross.move_to(self.box)
  171. return cross
  172. class LinearNumberSlider(ControlMobject):
  173. def __init__(
  174. self,
  175. value: float = 0,
  176. value_type: type = np.float64,
  177. min_value: float = -10.0,
  178. max_value: float = 10.0,
  179. step: float = 1.0,
  180. rounded_rect_kwargs: dict = {
  181. "height": 0.075,
  182. "width": 2,
  183. "corner_radius": 0.0375
  184. },
  185. circle_kwargs: dict = {
  186. "radius": 0.1,
  187. "stroke_color": GREY_A,
  188. "fill_color": GREY_A,
  189. "fill_opacity": 1.0
  190. },
  191. **kwargs
  192. ):
  193. self.value_type = value_type
  194. self.min_value = min_value
  195. self.max_value = max_value
  196. self.step = step
  197. self.rounded_rect_kwargs = rounded_rect_kwargs
  198. self.circle_kwargs = circle_kwargs
  199. self.bar = RoundedRectangle(**self.rounded_rect_kwargs)
  200. self.slider = Circle(**self.circle_kwargs)
  201. self.slider_axis = Line(
  202. start=self.bar.get_bounding_box_point(LEFT),
  203. end=self.bar.get_bounding_box_point(RIGHT)
  204. )
  205. self.slider_axis.set_opacity(0.0)
  206. self.slider.move_to(self.slider_axis)
  207. self.slider.add_mouse_drag_listner(self.slider_on_mouse_drag)
  208. super().__init__(value, self.bar, self.slider, self.slider_axis, **kwargs)
  209. def assert_value(self, value: float) -> None:
  210. assert self.min_value <= value <= self.max_value
  211. def set_value_anim(self, value: float) -> None:
  212. prop = (value - self.min_value) / (self.max_value - self.min_value)
  213. self.slider.move_to(self.slider_axis.point_from_proportion(prop))
  214. def slider_on_mouse_drag(self, mob, event_data: dict[str, np.ndarray]) -> bool:
  215. self.set_value(self.get_value_from_point(event_data["point"]))
  216. return False
  217. # Helper Methods
  218. def get_value_from_point(self, point: np.ndarray) -> float:
  219. start, end = self.slider_axis.get_start_and_end()
  220. point_on_line = get_closest_point_on_line(start, end, point)
  221. prop = get_norm(point_on_line - start) / get_norm(end - start)
  222. value = self.min_value + prop * (self.max_value - self.min_value)
  223. no_of_steps = int((value - self.min_value) / self.step)
  224. value_nearest_to_step = self.min_value + no_of_steps * self.step
  225. return value_nearest_to_step
  226. class ColorSliders(Group):
  227. def __init__(
  228. self,
  229. sliders_kwargs: dict = {},
  230. rect_kwargs: dict = {
  231. "width": 2.0,
  232. "height": 0.5,
  233. "stroke_opacity": 1.0
  234. },
  235. background_grid_kwargs: dict = {
  236. "colors": [GREY_A, GREY_C],
  237. "single_square_len": 0.1
  238. },
  239. sliders_buff: float = MED_LARGE_BUFF,
  240. default_rgb_value: int = 255,
  241. default_a_value: int = 1,
  242. **kwargs
  243. ):
  244. self.sliders_kwargs = sliders_kwargs
  245. self.rect_kwargs = rect_kwargs
  246. self.background_grid_kwargs = background_grid_kwargs
  247. self.sliders_buff = sliders_buff
  248. self.default_rgb_value = default_rgb_value
  249. self.default_a_value = default_a_value
  250. rgb_kwargs = {"value": self.default_rgb_value, "min_value": 0, "max_value": 255, "step": 1}
  251. a_kwargs = {"value": self.default_a_value, "min_value": 0, "max_value": 1, "step": 0.04}
  252. self.r_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
  253. self.g_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
  254. self.b_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs)
  255. self.a_slider = LinearNumberSlider(**self.sliders_kwargs, **a_kwargs)
  256. self.sliders = Group(
  257. self.r_slider,
  258. self.g_slider,
  259. self.b_slider,
  260. self.a_slider
  261. )
  262. self.sliders.arrange(DOWN, buff=self.sliders_buff)
  263. self.r_slider.slider.set_color(RED)
  264. self.g_slider.slider.set_color(GREEN)
  265. self.b_slider.slider.set_color(BLUE)
  266. self.a_slider.slider.set_color_by_gradient(BLACK, WHITE)
  267. self.selected_color_box = Rectangle(**self.rect_kwargs)
  268. self.selected_color_box.add_updater(
  269. lambda mob: mob.set_fill(
  270. self.get_picked_color(), self.get_picked_opacity()
  271. )
  272. )
  273. self.background = self.get_background()
  274. super().__init__(
  275. Group(self.background, self.selected_color_box).fix_in_frame(),
  276. self.sliders,
  277. **kwargs
  278. )
  279. self.arrange(DOWN)
  280. def get_background(self) -> VGroup:
  281. single_square_len = self.background_grid_kwargs["single_square_len"]
  282. colors = self.background_grid_kwargs["colors"]
  283. width = self.rect_kwargs["width"]
  284. height = self.rect_kwargs["height"]
  285. rows = int(height / single_square_len)
  286. cols = int(width / single_square_len)
  287. cols = (cols + 1) if (cols % 2 == 0) else cols
  288. single_square = Square(single_square_len)
  289. grid = single_square.get_grid(n_rows=rows, n_cols=cols, buff=0.0)
  290. grid.stretch_to_fit_width(width)
  291. grid.stretch_to_fit_height(height)
  292. grid.move_to(self.selected_color_box)
  293. for idx, square in enumerate(grid):
  294. assert isinstance(square, Square)
  295. square.set_stroke(width=0.0, opacity=0.0)
  296. square.set_fill(colors[idx % len(colors)], 1.0)
  297. return grid
  298. def set_value(self, r: float, g: float, b: float, a: float):
  299. self.r_slider.set_value(r)
  300. self.g_slider.set_value(g)
  301. self.b_slider.set_value(b)
  302. self.a_slider.set_value(a)
  303. def get_value(self) -> np.ndarary:
  304. r = self.r_slider.get_value() / 255
  305. g = self.g_slider.get_value() / 255
  306. b = self.b_slider.get_value() / 255
  307. alpha = self.a_slider.get_value()
  308. return np.array((r, g, b, alpha))
  309. def get_picked_color(self) -> str:
  310. rgba = self.get_value()
  311. return rgb_to_hex(rgba[:3])
  312. def get_picked_opacity(self) -> float:
  313. rgba = self.get_value()
  314. return rgba[3]
  315. class Textbox(ControlMobject):
  316. def __init__(
  317. self,
  318. value: str = "",
  319. value_type: np.dtype = np.dtype(object),
  320. box_kwargs: dict = {
  321. "width": 2.0,
  322. "height": 1.0,
  323. "fill_color": WHITE,
  324. "fill_opacity": 1.0,
  325. },
  326. text_kwargs: dict = {
  327. "color": BLUE
  328. },
  329. text_buff: float = MED_SMALL_BUFF,
  330. isInitiallyActive: bool = False,
  331. active_color: ManimColor = BLUE,
  332. deactive_color: ManimColor = RED,
  333. **kwargs
  334. ):
  335. self.value_type = value_type
  336. self.box_kwargs = box_kwargs
  337. self.text_kwargs = text_kwargs
  338. self.text_buff = text_buff
  339. self.isInitiallyActive = isInitiallyActive
  340. self.active_color = active_color
  341. self.deactive_color = deactive_color
  342. self.isActive = self.isInitiallyActive
  343. self.box = Rectangle(**self.box_kwargs)
  344. self.box.add_mouse_press_listner(self.box_on_mouse_press)
  345. self.text = Text(value, **self.text_kwargs)
  346. super().__init__(value, self.box, self.text, **kwargs)
  347. self.update_text(value)
  348. self.active_anim(self.isActive)
  349. self.add_key_press_listner(self.on_key_press)
  350. def set_value_anim(self, value: str) -> None:
  351. self.update_text(value)
  352. def update_text(self, value: str) -> None:
  353. text = self.text
  354. self.remove(text)
  355. text.__init__(value, **self.text_kwargs)
  356. height = text.get_height()
  357. text.set_width(self.box.get_width() - 2 * self.text_buff)
  358. if text.get_height() > height:
  359. text.set_height(height)
  360. text.add_updater(lambda mob: mob.move_to(self.box))
  361. text.fix_in_frame()
  362. self.add(text)
  363. def active_anim(self, isActive: bool) -> None:
  364. if isActive:
  365. self.box.set_stroke(self.active_color)
  366. else:
  367. self.box.set_stroke(self.deactive_color)
  368. def box_on_mouse_press(self, mob, event_data) -> bool:
  369. self.isActive = not self.isActive
  370. self.active_anim(self.isActive)
  371. return False
  372. def on_key_press(self, mob: Mobject, event_data: dict[str, int]) -> bool | None:
  373. symbol = event_data["symbol"]
  374. modifiers = event_data["modifiers"]
  375. char = chr(symbol)
  376. if mob.isActive:
  377. old_value = mob.get_value()
  378. new_value = old_value
  379. if char.isalnum():
  380. if (modifiers & PygletWindowKeys.MOD_SHIFT) or (modifiers & PygletWindowKeys.MOD_CAPSLOCK):
  381. new_value = old_value + char.upper()
  382. else:
  383. new_value = old_value + char.lower()
  384. elif symbol in [PygletWindowKeys.SPACE]:
  385. new_value = old_value + char
  386. elif symbol == PygletWindowKeys.TAB:
  387. new_value = old_value + '\t'
  388. elif symbol == PygletWindowKeys.BACKSPACE:
  389. new_value = old_value[:-1] or ''
  390. mob.set_value(new_value)
  391. return False
  392. class ControlPanel(Group):
  393. def __init__(
  394. self,
  395. *controls: ControlMobject,
  396. panel_kwargs: dict = {
  397. "width": FRAME_WIDTH / 4,
  398. "height": MED_SMALL_BUFF + FRAME_HEIGHT,
  399. "fill_color": GREY_C,
  400. "fill_opacity": 1.0,
  401. "stroke_width": 0.0
  402. },
  403. opener_kwargs: dict = {
  404. "width": FRAME_WIDTH / 8,
  405. "height": 0.5,
  406. "fill_color": GREY_C,
  407. "fill_opacity": 1.0
  408. },
  409. opener_text_kwargs: dict = {
  410. "text": "Control Panel",
  411. "font_size": 20
  412. },
  413. **kwargs
  414. ):
  415. self.panel_kwargs = panel_kwargs
  416. self.opener_kwargs = opener_kwargs
  417. self.opener_text_kwargs = opener_text_kwargs
  418. self.panel = Rectangle(**self.panel_kwargs)
  419. self.panel.to_corner(UP + LEFT, buff=0)
  420. self.panel.shift(self.panel.get_height() * UP)
  421. self.panel.add_mouse_scroll_listner(self.panel_on_mouse_scroll)
  422. self.panel_opener_rect = Rectangle(**self.opener_kwargs)
  423. self.panel_info_text = Text(**self.opener_text_kwargs)
  424. self.panel_info_text.move_to(self.panel_opener_rect)
  425. self.panel_opener = Group(self.panel_opener_rect, self.panel_info_text)
  426. self.panel_opener.next_to(self.panel, DOWN, aligned_edge=DOWN)
  427. self.panel_opener.add_mouse_drag_listner(self.panel_opener_on_mouse_drag)
  428. self.controls = Group(*controls)
  429. self.controls.arrange(DOWN, center=False, aligned_edge=ORIGIN)
  430. self.controls.move_to(self.panel)
  431. super().__init__(
  432. self.panel, self.panel_opener,
  433. self.controls,
  434. **kwargs
  435. )
  436. self.move_panel_and_controls_to_panel_opener()
  437. self.fix_in_frame()
  438. def move_panel_and_controls_to_panel_opener(self) -> None:
  439. self.panel.next_to(
  440. self.panel_opener_rect,
  441. direction=UP,
  442. buff=0
  443. )
  444. controls_old_x = self.controls.get_x()
  445. self.controls.next_to(
  446. self.panel_opener_rect,
  447. direction=UP,
  448. buff=MED_SMALL_BUFF
  449. )
  450. self.controls.set_x(controls_old_x)
  451. def add_controls(self, *new_controls: ControlMobject) -> None:
  452. self.controls.add(*new_controls)
  453. self.move_panel_and_controls_to_panel_opener()
  454. def remove_controls(self, *controls_to_remove: ControlMobject) -> None:
  455. self.controls.remove(*controls_to_remove)
  456. self.move_panel_and_controls_to_panel_opener()
  457. def open_panel(self):
  458. panel_opener_x = self.panel_opener.get_x()
  459. self.panel_opener.to_corner(DOWN + LEFT, buff=0.0)
  460. self.panel_opener.set_x(panel_opener_x)
  461. self.move_panel_and_controls_to_panel_opener()
  462. return self
  463. def close_panel(self):
  464. panel_opener_x = self.panel_opener.get_x()
  465. self.panel_opener.to_corner(UP + LEFT, buff=0.0)
  466. self.panel_opener.set_x(panel_opener_x)
  467. self.move_panel_and_controls_to_panel_opener()
  468. return self
  469. def panel_opener_on_mouse_drag(self, mob, event_data: dict[str, np.ndarray]) -> bool:
  470. point = event_data["point"]
  471. self.panel_opener.match_y(Dot(point))
  472. self.move_panel_and_controls_to_panel_opener()
  473. return False
  474. def panel_on_mouse_scroll(self, mob, event_data: dict[str, np.ndarray]) -> bool:
  475. offset = event_data["offset"]
  476. factor = 10 * offset[1]
  477. self.controls.set_y(self.controls.get_y() + factor)
  478. return False