diff --git a/example_scenes.py b/example_scenes.py index 4ea2f987..50a2b5fc 100644 --- a/example_scenes.py +++ b/example_scenes.py @@ -396,4 +396,42 @@ class SurfaceExample(Scene): self.wait() +class ControlsExample(Scene): + def setup(self): + self.textbox = Textbox() + self.checkbox = Checkbox() + self.color_picker = ColorSliders() + self.panel = ControlPanel( + Text("Text", size=0.5), self.textbox, Line(), + Text("Show/Hide Text", size=0.5), self.checkbox, Line(), + Text("Color of Text", size=0.5), self.color_picker + ) + self.add(self.panel) + + def construct(self): + text = Text("", size=2) + + def text_updater(old_text): + assert(isinstance(old_text, Text)) + new_text = Text(self.textbox.get_value(), size=old_text.size) + new_text.align_data_and_family(old_text) + new_text.move_to(old_text) + if self.checkbox.get_value(): + new_text.set_fill( + color=self.color_picker.get_picked_color(), + opacity=self.color_picker.get_picked_opacity() + ) + else: + new_text.set_opacity(0) + old_text.become(new_text) + + text.add_updater(text_updater) + + self.add(MotionMobject(text)) + + self.textbox.set_value("Manim") + self.wait(60) + self.embed() + + # See https://github.com/3b1b/videos for many, many more diff --git a/manimlib/imports.py b/manimlib/imports.py index cc01279d..084aa0ac 100644 --- a/manimlib/imports.py +++ b/manimlib/imports.py @@ -43,6 +43,7 @@ from manimlib.mobject.number_line import * from manimlib.mobject.numbers import * from manimlib.mobject.probability import * from manimlib.mobject.shape_matchers import * +from manimlib.mobject.interactive import * from manimlib.mobject.svg.brace import * from manimlib.mobject.svg.drawings import * from manimlib.mobject.svg.svg_mobject import * diff --git a/manimlib/mobject/interactive.py b/manimlib/mobject/interactive.py new file mode 100644 index 00000000..69f4c698 --- /dev/null +++ b/manimlib/mobject/interactive.py @@ -0,0 +1,517 @@ +import numpy as np +from pyglet.window import key as PygletWindowKeys + +from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH +from manimlib.constants import LEFT, RIGHT, UP, DOWN, ORIGIN +from manimlib.constants import SMALL_BUFF, MED_SMALL_BUFF, MED_LARGE_BUFF +from manimlib.constants import BLACK, GREY_A, GREY_C, RED, GREEN, BLUE, WHITE +from manimlib.mobject.mobject import Mobject, Group +from manimlib.mobject.types.vectorized_mobject import VGroup +from manimlib.mobject.geometry import Dot, Line, Square, Rectangle, RoundedRectangle, Circle +from manimlib.mobject.svg.text_mobject import Text +from manimlib.mobject.value_tracker import ValueTracker +from manimlib.utils.config_ops import digest_config +from manimlib.utils.space_ops import get_norm, get_closest_point_on_line +from manimlib.utils.color import rgb_to_color, color_to_rgba, rgb_to_hex + + +# Interactive Mobjects + +class MotionMobject(Mobject): + """ + You could hold and drag this object to any position + """ + + CONFIG = { + "listen_to_events": True + } + + def __init__(self, mobject, **kwargs): + super().__init__(**kwargs) + self.mobject = mobject + self.mobject.add_updater(lambda mob: None) # To avoid locking it as static mobject + self.add(mobject) + + def on_mouse_drag(self, point, d_point, buttons, modifiers): + if self.mobject.is_point_touching(point): + self.mobject.move_to(point) + return False + + +class Button(Mobject): + """ + Pass any mobject and register an on_click method + """ + + CONFIG = { + "listen_to_events": True + } + + def __init__(self, mobject, on_click, **kwargs): + super().__init__(**kwargs) + self.mobject, self.on_click = mobject, on_click + self.add(self.mobject) + + def on_mouse_press(self, point, button, mods): + if self.mobject.is_point_touching(point): + self.on_click() + return False + + +# Controls + +class ContolMobject(ValueTracker): + CONFIG = { + "listen_to_events": True + } + + def __init__(self, value, *mobjects, **kwargs): + super().__init__(value=value, **kwargs) + self.add(*mobjects) + + self.add_updater(lambda mob: None) # To avoid lock_static_mobject_data while waiting in scene + self.fix_in_frame() + + def set_value(self, value): + self.assert_value(value) + self.set_value_anim(value) + return ValueTracker.set_value(self, value) + + def assert_value(self, value): + # To be implemented in subclasses + pass + + def set_value_anim(self, value): + # To be implemented in subclasses + pass + +class EnableDisableButton(ContolMobject): + CONFIG = { + "value_type": np.dtype(bool), + "rect_kwargs": { + "width": 0.5, + "height": 0.5, + "fill_opacity": 1.0 + }, + "enable_color": GREEN, + "disable_color": RED + } + + def __init__(self, value=True, **kwargs): + digest_config(self, kwargs) + self.box = Rectangle(**self.rect_kwargs) + super().__init__(value, self.box, **kwargs) + + def assert_value(self, value): + assert(value == True or value == False) + + def set_value_anim(self, value): + if value == True: + self.box.set_fill(self.enable_color) + elif value == False: + self.box.set_fill(self.disable_color) + + def toggle_value(self): + super().set_value(not self.get_value()) + + def on_mouse_press(self, point, button, mods): + self.toggle_value() + return False + + +class Checkbox(ContolMobject): + CONFIG = { + "value_type": np.dtype(bool), + "rect_kwargs": { + "width": 0.5, + "height": 0.5, + "fill_opacity": 0.0 + }, + + "checkmark_kwargs": { + "stroke_color": GREEN, + "stroke_width": 6, + }, + "cross_kwargs": { + "stroke_color": RED, + "stroke_width": 6, + }, + "box_content_buff": SMALL_BUFF + } + + def __init__(self, value=True, **kwargs): + digest_config(self, kwargs) + self.box = Rectangle(**self.rect_kwargs) + self.box_content = self.get_checkmark() if value else self.get_cross() + super().__init__(value, self.box, self.box_content, **kwargs) + + def assert_value(self, value): + assert(value == True or value == False) + + def toggle_value(self): + super().set_value(not self.get_value()) + + def set_value_anim(self, value): + if value == True: + self.box_content.become(self.get_checkmark()) + elif value == False: + self.box_content.become(self.get_cross()) + + def on_mouse_press(self, point, button, mods): + self.toggle_value() + return False + + # Helper methods + + def get_checkmark(self): + checkmark = VGroup( + Line(UP / 2 + 2 * LEFT, DOWN + LEFT, **self.checkmark_kwargs), + Line(DOWN + LEFT, UP + RIGHT, **self.checkmark_kwargs) + ) + + checkmark.stretch_to_fit_width(self.box.get_width()) + checkmark.stretch_to_fit_height(self.box.get_height()) + checkmark.scale(0.5) + checkmark.move_to(self.box) + return checkmark + + def get_cross(self): + cross = VGroup( + Line(UP + LEFT, DOWN + RIGHT, **self.cross_kwargs), + Line(UP + RIGHT, DOWN + LEFT, **self.cross_kwargs) + ) + + cross.stretch_to_fit_width(self.box.get_width()) + cross.stretch_to_fit_height(self.box.get_height()) + cross.scale(0.5) + cross.move_to(self.box) + return cross + + +class LinearNumberSlider(ContolMobject): + CONFIG = { + "value_type": np.float64, + + "min_value": -10.0, + "max_value": 10.0, + "step": 1.0, + + "rounded_rect_kwargs": { + "height": 0.075, + "width": 2, + "corner_radius": 0.0375 + }, + "circle_kwargs": { + "radius": 0.1, + "stroke_color": GREY_A, + "fill_color": GREY_A, + "fill_opacity": 1.0 + } + } + + def __init__(self, value=0, **kwargs): + digest_config(self, kwargs) + self.bar = RoundedRectangle(**self.rounded_rect_kwargs) + self.slider = Circle(**self.circle_kwargs) + self.slider_axis = Line( + start=self.bar.get_bounding_box_point(LEFT), + end=self.bar.get_bounding_box_point(RIGHT) + ) + self.slider_axis.set_opacity(0.0) + self.slider.move_to(self.slider_axis) + + super().__init__(value, self.bar, self.slider, self.slider_axis, ** kwargs) + + def assert_value(self, value): + assert(self.min_value <= value <= self.max_value) + + def set_value_anim(self, value): + prop = (value - self.min_value) / (self.max_value - self.min_value) + self.slider.move_to(self.slider_axis.point_from_proportion(prop)) + + def on_mouse_drag(self, point, d_point, buttons, modifiers): + if self.slider.is_point_touching(point): + self.set_value(self.get_value_from_point(point)) + return False + + # Helper Methods + + def get_value_from_point(self, point): + start, end = self.slider_axis.get_start_and_end() + point_on_line = get_closest_point_on_line(start, end, point) + prop = get_norm(point_on_line - start) / get_norm(end - start) + value = self.min_value + prop * (self.max_value - self.min_value) + no_of_steps = int((value - self.min_value) / self.step) + value_nearest_to_step = self.min_value + no_of_steps * self.step + return value_nearest_to_step + +class ColorSliders(Group): + CONFIG = { + "sliders_kwargs": {}, + "rect_kwargs": { + "width": 2.0, + "height": 0.5, + "stroke_opacity": 1.0 + }, + "background_grid_kwargs": { + "colors": [GREY_A, GREY_C], + "single_square_len": 0.1 + }, + "sliders_buff": MED_LARGE_BUFF, + "default_rgb_value": 255, + "default_a_value": 1, + } + + def __init__(self, **kwargs): + digest_config(self, kwargs) + + rgb_kwargs = {"value": self.default_rgb_value,"min_value": 0, "max_value": 255, "step": 1} + a_kwargs = {"value": self.default_a_value, "min_value": 0, "max_value": 1, "step": 0.04} + + self.r_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs) + self.g_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs) + self.b_slider = LinearNumberSlider(**self.sliders_kwargs, **rgb_kwargs) + self.a_slider = LinearNumberSlider(**self.sliders_kwargs, **a_kwargs) + self.sliders = Group( + self.r_slider, + self.g_slider, + self.b_slider, + self.a_slider + ) + self.sliders.arrange(DOWN, buff=self.sliders_buff) + + self.r_slider.slider.set_color(RED) + self.g_slider.slider.set_color(GREEN) + self.b_slider.slider.set_color(BLUE) + self.a_slider.slider.set_color_by_gradient([BLACK, WHITE]) + + self.selected_color_box = Rectangle(**self.rect_kwargs) + self.selected_color_box.add_updater( + lambda mob: mob.set_fill( + self.get_picked_color(), self.get_picked_opacity() + ) + ) + self.background = self.get_background() + + super().__init__( + Group(self.background, self.selected_color_box).fix_in_frame(), + self.sliders, + **kwargs + ) + + self.arrange(DOWN) + + def get_background(self): + single_square_len = self.background_grid_kwargs["single_square_len"] + colors = self.background_grid_kwargs["colors"] + width = self.rect_kwargs["width"] + height = self.rect_kwargs["height"] + rows = int(height / single_square_len) + cols = int(width / single_square_len) + cols = (cols + 1) if (cols % 2 == 0) else cols + + single_square = Square(single_square_len) + grid = single_square.get_grid(n_rows=rows, n_cols=cols, buff=0.0) + grid.stretch_to_fit_width(width) + grid.stretch_to_fit_height(height) + grid.move_to(self.selected_color_box) + + for idx, square in enumerate(grid): + assert(isinstance(square, Square)) + square.set_stroke(width=0.0, opacity=0.0) + square.set_fill(colors[idx % len(colors)], 1.0) + + return grid + + def set_value(self, r, g, b, a): + self.r_slider.set_value(r) + self.g_slider.set_value(g) + self.b_slider.set_value(b) + self.a_slider.set_value(a) + + def get_value(self): + r = self.r_slider.get_value() / 255 + g = self.g_slider.get_value() / 255 + b = self.b_slider.get_value() / 255 + alpha = self.a_slider.get_value() + return color_to_rgba(rgb_to_color((r, g, b)), alpha=alpha) + + def get_picked_color(self): + rgba = self.get_value() + return rgb_to_hex(rgba[:3]) + + def get_picked_opacity(self): + rgba = self.get_value() + return rgba[3] + + +class Textbox(ContolMobject): + CONFIG = { + "value_type": np.dtype(object), + + "box_kwargs": { + "width": 2.0, + "height": 1.0, + "fill_color": WHITE, + "fill_opacity": 1.0, + }, + "text_kwargs": { + "color": BLUE + }, + "text_buff": MED_SMALL_BUFF, + "isInitiallyActive": False, + "active_color": BLUE, + "deactive_color": RED, + } + + def __init__(self, value="", **kwargs): + digest_config(self, kwargs) + self.isActive = self.isInitiallyActive + self.box = Rectangle(**self.box_kwargs) + self.text = Text(value, **self.text_kwargs) + super().__init__(value, self.box, self.text, **kwargs) + self.update_text(value) + self.active_anim(self.isActive) + + def set_value_anim(self, value): + self.update_text(value) + + def update_text(self, value): + self.remove(self.text) + self.text.__init__(value, **self.text_kwargs) + self.text.set_width(self.box.get_width() - 2*self.text_buff, stretch=True) + self.text.add_updater(lambda mob: mob.move_to(self.box)) + self.text.fix_in_frame() + self.add(self.text) + + def active_anim(self, isActive): + if isActive: + self.box.set_stroke(self.active_color) + else: + self.box.set_stroke(self.deactive_color) + + def on_mouse_press(self, point, button, mods): + if self.box.is_point_touching(point): + self.isActive = not self.isActive + self.active_anim(self.isActive) + return False + + def on_key_press(self, symbol, modifiers): + char = chr(symbol) + if self.isActive: + old_value = self.get_value() + new_value = old_value + if char.isalnum(): + if (modifiers & PygletWindowKeys.MOD_SHIFT) or (modifiers & PygletWindowKeys.MOD_CAPSLOCK): + new_value = old_value + char.upper() + else: + new_value = old_value + char.lower() + elif symbol in [PygletWindowKeys.SPACE]: + new_value = old_value + char + elif symbol == PygletWindowKeys.TAB: + new_value = old_value + '\t' + elif symbol == PygletWindowKeys.BACKSPACE: + new_value = old_value[:-1] or '' + self.set_value(new_value) + return False + +class ControlPanel(Group): + CONFIG = { + "listen_to_events": True, + + "panel_kwargs": { + "width": FRAME_WIDTH / 4, + "height": MED_SMALL_BUFF + FRAME_HEIGHT, + "fill_color": GREY_C, + "fill_opacity": 1.0, + "stroke_width": 0.0 + }, + "opener_kwargs": { + "width": FRAME_WIDTH / 8, + "height": 0.5, + "fill_color": GREY_C, + "fill_opacity": 1.0 + }, + "opener_text_kwargs": { + "text": "Control Panel", + "size": 0.4 + } + } + + def __init__(self, *controls, **kwargs): + digest_config(self, kwargs) + + self.panel = Rectangle(**self.panel_kwargs) + self.panel.to_corner(UP + LEFT, buff=0) + self.panel.shift(self.panel.get_height() * UP) + + self.panel_opener_rect = Rectangle(**self.opener_kwargs) + self.panel_info_text = Text(**self.opener_text_kwargs) + self.panel_info_text.move_to(self.panel_opener_rect) + + self.panel_opener = Group(self.panel_opener_rect, self.panel_info_text) + self.panel_opener.next_to(self.panel, DOWN, aligned_edge=DOWN) + + self.controls = Group(*controls) + self.controls.arrange(DOWN, center=False, aligned_edge=ORIGIN) + self.controls.move_to(self.panel) + + super().__init__( + self.panel, self.panel_opener, + self.controls, + **kwargs + ) + + self.move_panel_and_controls_to_panel_opener() + self.fix_in_frame() + + def move_panel_and_controls_to_panel_opener(self): + self.panel.next_to( + self.panel_opener_rect, + direction=UP, + buff=0 + ) + + controls_old_x = self.controls.get_x() + self.controls.next_to( + self.panel_opener_rect, + direction=UP, + buff=MED_SMALL_BUFF + ) + + self.controls.set_x(controls_old_x) + + def add_controls(self, *new_controls): + self.controls.add(*new_controls) + self.move_panel_and_controls_to_panel_opener() + + def remove_controls(self, *controls_to_remove): + self.controls.remove(*controls_to_remove) + self.move_panel_and_controls_to_panel_opener() + + def open_panel(self): + panel_opener_x = self.panel_opener.get_x() + self.panel_opener.to_corner(DOWN + LEFT, buff=0.0) + self.panel_opener.set_x(panel_opener_x) + self.move_panel_and_controls_to_panel_opener() + return self + + def close_panel(self): + panel_opener_x = self.panel_opener.get_x() + self.panel_opener.to_corner(UP + LEFT, buff=0.0) + self.panel_opener.set_x(panel_opener_x) + self.move_panel_and_controls_to_panel_opener() + return self + + def on_mouse_drag(self, point, d_point, buttons, modifiers): + if self.panel_opener.is_point_touching(point): + self.panel_opener.match_y(Dot(point)) + self.move_panel_and_controls_to_panel_opener() + return False + + def on_mouse_scroll(self, point, offset): + if self.panel.is_point_touching(point): + factor = 10 * offset[1] + self.controls.set_y(self.controls.get_y() + factor) + return False + diff --git a/manimlib/mobject/mobject.py b/manimlib/mobject/mobject.py index 1a0bd33b..5f31a841 100644 --- a/manimlib/mobject/mobject.py +++ b/manimlib/mobject/mobject.py @@ -52,7 +52,9 @@ class Mobject(object): # Must match in attributes of vert shader "shader_dtype": [ ('point', np.float32, (3,)), - ] + ], + # Event listener + "listen_to_events": False } def __init__(self, **kwargs): @@ -215,6 +217,14 @@ class Mobject(object): parent.refresh_bounding_box() return self + def is_point_touching(self, point, buff=MED_SMALL_BUFF): + self.refresh_bounding_box() + bb = self.get_bounding_box() + if np.all(point >= (bb[0] - buff)) and np.all(point <= (bb[2] + buff)): + return True + else: + return False + # Family matters def __getitem__(self, value): @@ -1434,6 +1444,41 @@ class Mobject(object): def get_shader_vert_indices(self): return self.shader_indices + # Event Handlers + """ + Event handling follows the Event Bubbling model of DOM in javascript. + Return false to stop the event bubbling. + To learn more visit https://www.quirksmode.org/js/events_order.html + """ + + def on_mouse_motion(self, point, d_point): + # To be implemented in subclasses + pass + + def on_mouse_drag(self, point, d_point, buttons, modifiers): + # To be implemented in subclasses + pass + + def on_mouse_press(self, point, button, mods): + # To be implemented in subclasses + pass + + def on_mouse_release(self, point, button, mods): + # To be implemented in subclasses + pass + + def on_mouse_scroll(self, point, offset): + # To be implemented in subclasses + pass + + def on_key_release(self, symbol, modifiers): + # To be implemented in subclasses + pass + + def on_key_press(self, symbol, modifiers): + # To be implemented in subclasses + pass + # Errors def throw_error_if_no_points(self): diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 5946f585..4e9c946e 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -513,8 +513,26 @@ class Scene(object): self.mobjects = mobjects # Event handling + def get_event_listeners_mobjects(self): + """ + This method returns all the mobjects that listen to events + in reversed order. So the top most mobject's event is called first. + This helps in event bubbling. + """ + return filter( + lambda mob: mob.listen_to_events, + reversed(self.get_mobject_family_members()) + ) + def on_mouse_motion(self, point, d_point): self.mouse_point.move_to(point) + + for mob_listener in self.get_event_listeners_mobjects(): + if mob_listener.is_point_touching(point): + propagate_event = mob_listener.on_mouse_motion(point, d_point) + if propagate_event is not None and propagate_event is False: + return + frame = self.camera.frame if self.window.is_key_pressed(ord("d")): frame.increment_theta(-d_point[0]) @@ -530,13 +548,33 @@ class Scene(object): def on_mouse_drag(self, point, d_point, buttons, modifiers): self.mouse_drag_point.move_to(point) + for mob_listener in self.get_event_listeners_mobjects(): + if mob_listener.is_point_touching(point): + propagate_event = mob_listener.on_mouse_drag(point, d_point, buttons, modifiers) + if propagate_event is not None and propagate_event is False: + return + def on_mouse_press(self, point, button, mods): - pass + for mob_listener in self.get_event_listeners_mobjects(): + if mob_listener.is_point_touching(point): + propagate_event = mob_listener.on_mouse_press(point, button, mods) + if propagate_event is not None and propagate_event is False: + return def on_mouse_release(self, point, button, mods): - pass + for mob_listener in self.get_event_listeners_mobjects(): + if mob_listener.is_point_touching(point): + propagate_event = mob_listener.on_mouse_release(point, button, mods) + if propagate_event is not None and propagate_event is False: + return def on_mouse_scroll(self, point, offset): + for mob_listener in self.get_event_listeners_mobjects(): + if mob_listener.is_point_touching(point): + propagate_event = mob_listener.on_mouse_scroll(point, offset) + if propagate_event is not None and propagate_event is False: + return + frame = self.camera.frame if self.window.is_key_pressed(ord("z")): factor = 1 + np.arctan(10 * offset[1]) @@ -547,7 +585,10 @@ class Scene(object): frame.shift(-20.0 * shift) def on_key_release(self, symbol, modifiers): - pass + for mob_listener in self.get_event_listeners_mobjects(): + propagate_event = mob_listener.on_key_release(symbol, modifiers) + if propagate_event is not None and propagate_event is False: + return def on_key_press(self, symbol, modifiers): try: @@ -555,6 +596,12 @@ class Scene(object): except OverflowError: print(" Warning: The value of the pressed key is too large.") return + + for mob_listener in self.get_event_listeners_mobjects(): + propagate_event = mob_listener.on_key_press(symbol, modifiers) + if propagate_event is not None and propagate_event is False: + return + if char == "r": self.camera.frame.to_default_state() elif char == "q": diff --git a/manimlib/utils/space_ops.py b/manimlib/utils/space_ops.py index 3ac27c9e..2a1de082 100644 --- a/manimlib/utils/space_ops.py +++ b/manimlib/utils/space_ops.py @@ -290,6 +290,21 @@ def find_intersection(p0, v0, p1, v1, threshold=1e-5): return p0 + ratio * v0 +def get_closest_point_on_line(a, b, p): + """ + It returns point x such that + x is on line ab and xp is perpendicular to ab. + If x lies beyond ab line, then it returns nearest edge(a or b). + """ + # x = b + t*(a-b) = t*a + (1-t)*b + t = np.dot(p - b, a - b) / np.dot(a - b, a - b) + if t < 0: + t = 0 + if t > 1: + t = 1 + return ((t * a) + ((1 - t) * b)) + + def get_winding_number(points): total_angle = 0 for p1, p2 in adjacent_pairs(points):