├── .gitignore ├── logo.jpg ├── logo.png ├── happy32.gif ├── ease-funcs.jpg ├── ease-functions.png ├── images ├── anchor-with-markers.png ├── anchor-plain-portrait.png └── anchor-plain-landscape.png ├── scene_drawing ease funcs.jpg ├── anchor-readme.md ├── pyproject.toml ├── TheUnlicense.txt ├── scene-scripter-demo.py ├── anchor_demo.py ├── vector.py ├── scripter-demo.py ├── trioscripter.py ├── README.md ├── scripter ├── anchor.py └── __init__.py └── scripter 2.py /.gitignore: -------------------------------------------------------------------------------- 1 | /venv/ 2 | /dist/ 3 | /.idea/ 4 | /scripter/__pycache__/ 5 | -------------------------------------------------------------------------------- /logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/logo.jpg -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/logo.png -------------------------------------------------------------------------------- /happy32.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/happy32.gif -------------------------------------------------------------------------------- /ease-funcs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/ease-funcs.jpg -------------------------------------------------------------------------------- /ease-functions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/ease-functions.png -------------------------------------------------------------------------------- /images/anchor-with-markers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/images/anchor-with-markers.png -------------------------------------------------------------------------------- /scene_drawing ease funcs.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/scene_drawing ease funcs.jpg -------------------------------------------------------------------------------- /images/anchor-plain-portrait.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/images/anchor-plain-portrait.png -------------------------------------------------------------------------------- /images/anchor-plain-landscape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikaelho/scripter/HEAD/images/anchor-plain-landscape.png -------------------------------------------------------------------------------- /anchor-readme.md: -------------------------------------------------------------------------------- 1 | Here's my summer project, a re-imagining of the anchor module. It is now built on top of scripter and the ui.View update method. As a result, it is much more flexible and does not crash. 2 | 3 | First a couple of pictures to show the anchors in action. The second picture is the same as the first one, after rotating the phone to landscape. A sharp eye may notice some of the dynamic features enabled by the anchors. 4 | 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<3"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "scripter" 7 | author = "Mikael Honkala" 8 | author-email = "mikael.honkala@gmail.com" 9 | home-page = "https://github.com/mikaelho/scripter" 10 | dist-name = "pythonista-scripter" 11 | description-file = "README.md" 12 | license = "TheUnlicense" 13 | classifiers = [ 14 | "Operating System :: iOS" 15 | ] 16 | -------------------------------------------------------------------------------- /TheUnlicense.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /scene-scripter-demo.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | from scripter import * 3 | from scene import * 4 | 5 | class MyScene (Scene): 6 | 7 | roll = False 8 | 9 | @script # setup can be a script 10 | def setup(self): 11 | self.background_color = 'black' 12 | s = self.ship = SpriteNode('spc:PlayerShip1Orange', alpha=0, scale=2, position=self.size/2, parent=self) 13 | 14 | # Animate Scene and the Nodes 15 | yield 1.0 16 | pulse(self, 'white') 17 | s = self.ship 18 | show(s, duration=2.0) 19 | scale_to(s, 1, duration=2.0) 20 | yield 21 | wobble(s) 22 | yield 23 | move_by(s, 0, 100) 24 | yield 25 | fly_out(s, 'up') 26 | yield 27 | 28 | l = LabelNode(text='Tap anywhere', position=self.size/2, parent=self) 29 | reveal_text(l) 30 | yield 2.0 31 | hide(l) 32 | 33 | @script # touch events can be scripts 34 | def touch_began(self, touch): 35 | target = Vector(touch.location) 36 | 37 | if self.roll: 38 | roll_to(self.ship, target, end_right_side_up=False, duration=1.0) 39 | else: 40 | vector = target - self.ship.position 41 | rotate_to(self.ship, vector.degrees-90) 42 | yield 43 | move_to(self.ship, *target, duration=0.7, ease_func=sinusoidal) 44 | 45 | self.roll = self.roll == False 46 | 47 | run(MyScene()) 48 | 49 | -------------------------------------------------------------------------------- /anchor_demo.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import ui 4 | 5 | from scripter import set_scripter_view 6 | 7 | from scripter.anchor import * 8 | 9 | 10 | accent_color = '#cae8ff' 11 | 12 | def style(*views): 13 | for v in views: 14 | v.background_color = 'black' 15 | v.text_color = v.tint_color = v.border_color = 'white' 16 | v.border_width = 1 17 | v.alignment = ui.ALIGN_CENTER 18 | 19 | return v 20 | 21 | def style2(*views): 22 | for v in views: 23 | v.background_color = accent_color 24 | v.text_color = v.tint_color = 'black' 25 | v.alignment = ui.ALIGN_CENTER 26 | v.font = ('Arial Rounded MT Bold', 12) 27 | 28 | return v 29 | 30 | def style_label(v): 31 | v.background_color = 'black' 32 | v.text_color = 'white' 33 | v.alignment = ui.ALIGN_CENTER 34 | return v 35 | 36 | def create_area(title): 37 | area = style(ui.View()) 38 | label = style_label(size_to_fit(ui.Label( 39 | text=title.upper(), 40 | #number_of_lines=0, 41 | font=('Arial Rounded MT Bold', 12), 42 | ))) 43 | dock(area).top_right(label, At.TIGHT) 44 | return area 45 | 46 | root = ui.View( 47 | background_color='black', 48 | ) 49 | set_scripter_view(root) 50 | 51 | # ------ Button flow 52 | 53 | button_area = style(ui.View()) 54 | dock(root).bottom(button_area, At.TIGHT) 55 | button_label = style_label(ui.Label( 56 | text='FLOW', 57 | font=('Arial Rounded MT Bold', 12), 58 | )) 59 | button_label.size_to_fit() 60 | ghost_area = ui.View() 61 | root.add_subview(ghost_area) 62 | at(ghost_area).frame = at(button_area).frame 63 | dock(ghost_area).bottom_right(button_label) 64 | buttons = [ 65 | size_to_fit(style2(ui.Button( 66 | title=f'button {i + 1}'))) 67 | for i in range(6) 68 | ] 69 | flow(button_area).from_top_left(*buttons) 70 | at(button_area).height = at(button_area).fit_height 71 | 72 | content_area = style(ui.View()) 73 | dock(root).top(content_area, At.TIGHT) 74 | at(content_area).bottom = at(button_area).top - At.TIGHT 75 | 76 | at_area = create_area('basic at & flex') 77 | pointer_area = create_area('heading, custom, func') 78 | dock_area = create_area('dock') 79 | align_area = create_area('align') 80 | 81 | fill(content_area, 2).from_top( 82 | at_area, 83 | dock_area, 84 | pointer_area, 85 | align_area, 86 | ) 87 | 88 | def make_label(text): 89 | return size_to_fit(style2(ui.Label( 90 | text=text, 91 | number_of_lines=0))) 92 | 93 | # ----- At & flex 94 | 95 | vertical_bar = style(ui.View(width=10)) 96 | at_area.add_subview(vertical_bar) 97 | at(vertical_bar).center_x = at(at_area).width / 5 98 | align(at_area).center_y(vertical_bar) 99 | at(vertical_bar).height = at(at_area).height * 0.75 100 | 101 | fix_left = make_label('fix left') 102 | at_area.add_subview(fix_left) 103 | at(fix_left).left = at(vertical_bar).right 104 | align(vertical_bar, -30).center_y(fix_left) 105 | 106 | flex = make_label('fix left and right') 107 | at_area.add_subview(flex) 108 | at(flex).left = at(vertical_bar).right + At.TIGHT 109 | at(flex).right = at(at_area).right 110 | align(vertical_bar, +30).center_y(flex) 111 | 112 | # ------ Heading & custom 113 | 114 | def make_symbol(character): 115 | symbol = make_label(character) 116 | symbol.font = ('Arial Rounded MT Bold', 18) 117 | pointer_area.add_subview(symbol) 118 | size_to_fit(symbol) 119 | symbol.width = symbol.height 120 | symbol.objc_instance.clipsToBounds = True 121 | symbol.corner_radius = symbol.width / 2 122 | return symbol 123 | 124 | target = make_symbol('⌾') 125 | target.font = (target.font[0], 44) 126 | at(target).center_x = at(pointer_area).center_x / 1.75 127 | at(target).center_y = at(pointer_area).height - 60 128 | 129 | pointer = make_symbol('↣') 130 | pointer.text_color = accent_color 131 | pointer.background_color = 'transparent' 132 | pointer.font = (pointer.font[0], 40) 133 | align(pointer_area).center(pointer) 134 | at(pointer).heading = at(target).center 135 | 136 | heading_label = ui.Label(text='000°', 137 | font=('Arial Rounded MT Bold', 12), 138 | text_color=accent_color, 139 | alignment=ui.ALIGN_CENTER, 140 | ) 141 | heading_label.size_to_fit() 142 | pointer_area.add_subview(heading_label) 143 | at(heading_label).center_y = at(pointer).center_y - 22 144 | align(pointer).center_x(heading_label) 145 | 146 | attr(heading_label).text = at(pointer).heading + ( 147 | lambda angle: f"{int(math.degrees(angle))%360:03}°" 148 | ) 149 | 150 | # ----- Dock 151 | 152 | dock(dock_area).top_center(make_label('top\ncenter')) 153 | dock(dock_area).left(make_label('left')) 154 | dock(dock_area).bottom_right(make_label('bottom\nright')) 155 | center_label = make_label('center') 156 | dock(dock_area).center(center_label) 157 | #dock(center_label).above(make_label('below')) 158 | 159 | # ----- Align 160 | 161 | l1 = make_label('1') 162 | align_area.add_subview(l1) 163 | at(l1).center_x = at(align_area).center_x / 2 164 | l2 = make_label('2') 165 | align_area.add_subview(l2) 166 | at(l2).center_x = at(align_area).center_x 167 | l3 = make_label('3') 168 | align_area.add_subview(l3) 169 | at(l3).center_x = at(align_area).center_x / 2 * 3 170 | 171 | align(align_area).center_y(l1, l2, l3) 172 | 173 | 174 | # ------ Markers 175 | 176 | show_markers = False 177 | 178 | if show_markers: 179 | 180 | marker_counter = 0 181 | 182 | def create_marker(superview): 183 | global marker_counter 184 | marker_counter += 1 185 | marker = make_label(str(marker_counter)) 186 | superview.add_subview(marker) 187 | marker.background_color = 'white' 188 | marker.border_color = 'black' 189 | marker.border_width = 1 190 | size_to_fit(marker) 191 | marker.width = marker.height 192 | marker.objc_instance.clipsToBounds = True 193 | marker.corner_radius = marker.width / 2 194 | return marker 195 | 196 | m1 = create_marker(at_area) 197 | align(fix_left).center_y(m1) 198 | at(m1).left = at(fix_left).right 199 | 200 | m2 = create_marker(at_area) 201 | align(flex).left(m2) 202 | at(m2).center_y = at(flex).top - At.gap 203 | 204 | m3 = create_marker(at_area) 205 | align(flex).right(m3) 206 | at(m3).center_y = at(flex).top - At.gap 207 | 208 | m4 = create_marker(pointer_area) 209 | at(m4).top = at(pointer).bottom + 3*At.TIGHT 210 | at(m4).left = at(pointer).right + 3*At.TIGHT 211 | 212 | m5 = create_marker(pointer_area) 213 | align(heading_label).center_y(m5) 214 | at(m5).left = at(heading_label).right 215 | 216 | m6 = create_marker(dock_area) 217 | at(m6).center_x = at(dock_area).center_x * 1.5 218 | at(m6).center_y = at(dock_area).center_y / 2 219 | 220 | m7 = create_marker(align_area) 221 | align(align_area).center_x(m7) 222 | at(m7).top = at(l2).bottom 223 | 224 | mc = create_marker(content_area) 225 | at(mc).center = at(content_area).center 226 | 227 | mb = create_marker(button_area) 228 | last_button = buttons[-1] 229 | align(last_button).center_y(mb) 230 | at(mb).left = at(last_button).right 231 | 232 | mr = create_marker(root) 233 | align(button_area).center_x(mr) 234 | at(mr).center_y = at(button_area).top 235 | 236 | ms = create_marker(root) 237 | at(ms).center_x = at(button_area).center_x * 1.5 238 | at(ms).center_y = at(button_area).bottom 239 | 240 | root.present('fullscreen', 241 | animated=False, 242 | hide_title_bar=True, 243 | ) 244 | 245 | -------------------------------------------------------------------------------- /vector.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | import math 3 | 4 | class Vector (list): 5 | ''' Simple 2D vector class to make vector operations more convenient. If performance is a concern, you are probably better off looking at numpy. 6 | 7 | Supports the following operations: 8 | 9 | * Initialization from two arguments, two keyword arguments (`x` and `y`), tuple, list, or another Vector. 10 | * Equality and unequality comparisons to other vectors. For floating point numbers, equality tolerance is 1e-10. 11 | * `abs`, `int` and `round` 12 | * Addition and in-place addition 13 | * Subtraction 14 | * Multiplication and division by a scalar 15 | * `len`, which is the same as `magnitude`, see below. 16 | 17 | Sample usage: 18 | 19 | v = Vector(x = 1, y = 2) 20 | v2 = Vector(3, 4) 21 | v += v2 22 | assert str(v) == '[4, 6]' 23 | assert v / 2.0 == Vector(2, 3) 24 | assert v * 0.1 == Vector(0.4, 0.6) 25 | assert v.distance_to(v2) == math.sqrt(1+4) 26 | 27 | v3 = Vector(Vector(1, 2) - Vector(2, 0)) # -1.0, 2.0 28 | v3.magnitude *= 2 29 | assert v3 == [-2, 4] 30 | 31 | v3.radians = math.pi # 180 degrees 32 | v3.magnitude = 2 33 | assert v3 == [-2, 0] 34 | v3.degrees = -90 35 | assert v3 == [0, -2] 36 | 37 | assert list(Vector(1, 1).steps_to(Vector(3, 3))) == [[1.7071067811865475, 1.7071067811865475], [2.414213562373095, 2.414213562373095], [3, 3]] 38 | assert list(Vector(1, 1).steps_to(Vector(-1, 1))) == [[0, 1], [-1, 1]] 39 | assert list(Vector(1, 1).rounded_steps_to(Vector(3, 3))) == [[2, 2], [2, 2], [3, 3]] 40 | ''' 41 | 42 | abs_tol = 1e-10 43 | 44 | def __init__(self, *args, **kwargs): 45 | if len(args) + len(kwargs) == 0: 46 | self.append(0) 47 | self.append(0) 48 | else: 49 | x = kwargs.pop('x', None) 50 | y = kwargs.pop('y', None) 51 | if x and y: 52 | self.append(x) 53 | self.append(y) 54 | elif len(args) == 2: 55 | self.append(args[0]) 56 | self.append(args[1]) 57 | else: 58 | super().__init__(*args, **kwargs) 59 | 60 | @property 61 | def x(self): 62 | ''' x component of the vector. ''' 63 | return self[0] 64 | 65 | @x.setter 66 | def x(self, value): 67 | self[0] = value 68 | 69 | @property 70 | def y(self): 71 | ''' y component of the vector. ''' 72 | return self[1] 73 | 74 | @y.setter 75 | def y(self, value): 76 | self[1] = value 77 | 78 | def __eq__(self, other): 79 | return math.isclose(self[0], other[0], abs_tol=self.abs_tol) and math.isclose(self[1], other[1], abs_tol=self.abs_tol) 80 | 81 | def __ne__(self, other): 82 | return not self.__eq__(other) 83 | 84 | def __abs__(self): 85 | return type(self)(abs(self.x), abs(self.y)) 86 | 87 | def __int__(self): 88 | return type(self)(int(self.x), int(self.y)) 89 | 90 | def __add__(self, other): 91 | return type(self)(self.x + other.x, self.y + other.y) 92 | 93 | def __iadd__(self, other): 94 | self.x += other.x 95 | self.y += other.y 96 | return self 97 | 98 | def __sub__(self, other): 99 | return type(self)(self.x - other.x, self.y - other.y) 100 | 101 | def __mul__(self, other): 102 | return type(self)(self.x * other, self.y * other) 103 | 104 | def __truediv__(self, other): 105 | return type(self)(self.x / other, self.y / other) 106 | 107 | def __len__(self): 108 | return self.magnitude 109 | 110 | def __round__(self): 111 | return type(self)(round(self.x), round(self.y)) 112 | 113 | def dot_product(self, other): 114 | ''' Sum of multiplying x and y components with the x and y components of another vector. ''' 115 | return self.x * other.x + self.y * other.y 116 | 117 | def distance_to(self, other): 118 | ''' Linear distance between this vector and another. ''' 119 | return (Vector(other) - self).magnitude 120 | 121 | def projection_to(self, other): 122 | ''' Vector projection of this vector to another vector. ''' 123 | result_vector = Vector(other) 124 | scale_factor = other.dot_product(self)/(other.magnitude**2) 125 | result_vector.magnitude *= scale_factor 126 | return result_vector 127 | 128 | def rejection_from(self, other): 129 | ''' The perpendicular vector that remains when we take out the projected component. ''' 130 | return self - self.projection_to(other) 131 | 132 | @property 133 | def magnitude(self): 134 | ''' Length of the vector, or distance from (0,0) to (x,y). ''' 135 | return math.hypot(self.x, self.y) 136 | 137 | @magnitude.setter 138 | def magnitude(self, m): 139 | r = self.radians 140 | self.polar(r, m) 141 | 142 | @property 143 | def radians(self): 144 | ''' Angle between the positive x axis and this vector, in radians. ''' 145 | #return round(math.atan2(self.y, self.x), 10) 146 | return math.atan2(self.y, self.x) 147 | 148 | @radians.setter 149 | def radians(self, r): 150 | m = self.magnitude 151 | self.polar(r, m) 152 | 153 | def polar(self, r, m): 154 | ''' Set vector in polar coordinates. `r` is the angle in radians, `m` is vector magnitude or "length". ''' 155 | self.y = math.sin(r) * m 156 | self.x = math.cos(r) * m 157 | 158 | @property 159 | def degrees(self): 160 | ''' Angle between the positive x axis and this vector, in degrees. ''' 161 | return math.degrees(self.radians) 162 | 163 | @degrees.setter 164 | def degrees(self, d): 165 | self.radians = math.radians(d) 166 | 167 | def steps_to(self, other, step_magnitude=1.0): 168 | """ Generator that returns points on the line between this and the other point, with each step separated by `step_magnitude`. Does not include the starting point. """ 169 | if self == other: 170 | yield other 171 | else: 172 | step_vector = other - self 173 | steps = math.floor(step_vector.magnitude/step_magnitude) 174 | step_vector.magnitude = step_magnitude 175 | current_position = Vector(self) 176 | for _ in range(steps): 177 | current_position += step_vector 178 | yield Vector(current_position) 179 | if current_position != other: 180 | yield other 181 | 182 | def rounded_steps_to(self, other, step_magnitude=1.0): 183 | ''' As `steps_to`, but returns unique points rounded to the nearest integer. ''' 184 | previous = Vector(0,0) 185 | for step in self.steps_to(other, step_magnitude): 186 | rounded = round(step) 187 | if rounded != previous: 188 | previous = rounded 189 | yield rounded 190 | 191 | 192 | if __name__ == '__main__': 193 | v = Vector(x = 1, y = 2) 194 | v2 = Vector(3, 4) 195 | v += v2 196 | assert str(v) == '[4, 6]' 197 | assert v / 2.0 == Vector(2, 3) 198 | assert v * 0.1 == Vector(0.4, 0.6) 199 | assert v.distance_to(v2) == math.sqrt(1+4) 200 | 201 | v3 = Vector(Vector(1, 2) - Vector(2, 0)) # -1.0, 2.0 202 | v3.magnitude *= 2 203 | assert v3 == [-2, 4] 204 | 205 | v3.radians = math.pi # 180 degrees 206 | v3.magnitude = 2 207 | assert v3 == [-2, 0] 208 | v3.degrees = -90 209 | assert v3 == [0, -2] 210 | 211 | assert list(Vector(1, 1).steps_to(Vector(3, 3))) == [[1.7071067811865475, 1.7071067811865475], [2.414213562373095, 2.414213562373095], [3, 3]] 212 | assert list(Vector(1, 1).steps_to(Vector(-1, 1))) == [[0, 1], [-1, 1]] 213 | assert list(Vector(1, 1).rounded_steps_to(Vector(3, 3))) == [[2, 2], [3, 3]] 214 | 215 | v4 = Vector(0,1) 216 | v5 = Vector(5,5) 217 | assert v4.projection_to(v5) == (0.5, 0.5) 218 | assert v4.rejection_from(v5) == (-0.5, 0.5) 219 | -------------------------------------------------------------------------------- /scripter-demo.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | from functools import partial 3 | from random import random 4 | import math, inspect 5 | 6 | from ui import * 7 | import dialogs 8 | 9 | from scripter import * 10 | 11 | class DemoView(View): 12 | 13 | def __init__(self): 14 | self.start_point = (200,175) 15 | self.axes_counter = 150 16 | self.curve_point_x = None 17 | self.curve_point_y = None 18 | self.curve = [] 19 | self.start_new = False 20 | 21 | def draw(self): 22 | 23 | spx = self.start_point[0] 24 | spy = self.start_point[1] 25 | set_color('black') 26 | path = Path() 27 | path.move_to(spx, spy) 28 | path.line_to(spx+self.axes_counter, spy) 29 | path.move_to(spx, spy) 30 | path.line_to(spx, spy-self.axes_counter) 31 | 32 | end_size = 10 33 | path.move_to(spx+self.axes_counter, spy) 34 | path.line_to(spx+self.axes_counter-end_size, spy-end_size) 35 | path.move_to(spx+self.axes_counter, spy) 36 | path.line_to(spx+self.axes_counter-end_size, spy+end_size) 37 | path.move_to(spx, spy-self.axes_counter) 38 | path.line_to(spx-end_size, spy-self.axes_counter+end_size) 39 | path.move_to(spx, spy-self.axes_counter) 40 | path.line_to(spx+end_size, spy-self.axes_counter+end_size) 41 | 42 | path.stroke() 43 | 44 | if self.start_new: 45 | self.curve = [] 46 | self.start_new = False 47 | 48 | path = Path() 49 | set_color('#91cf96') 50 | path.move_to(spx, spy) 51 | 52 | if self.curve_point_x is not None: 53 | self.curve.append((spx+self.curve_point_x, spy-self.curve_point_y)) 54 | 55 | for px, py in self.curve: 56 | path.line_to(px, py) 57 | 58 | path.stroke() 59 | 60 | def trigger_refresh(self, value): 61 | self.set_needs_display() 62 | return value 63 | 64 | if __name__ == '__main__': 65 | 66 | effect_duration = 0.5 67 | ease_function = linear 68 | 69 | cleaner = None 70 | color_changer = None 71 | 72 | graph_start = 175 73 | options_start = 210 74 | buttons_start = 270 75 | 76 | v = DemoView() 77 | v.background_color = 'white' 78 | v.present('full_screen') 79 | 80 | d = View(frame=(0,0,10,10), corner_radius=5, background_color='black') 81 | d.center = (200,graph_start) 82 | v.add_subview(d) 83 | 84 | c = TextView(flex='WH', editable=False) 85 | v.add_subview(c) 86 | 87 | demo_label = Label(text='Demo', hidden=True, alignment=ALIGN_CENTER, frame=(100, -100, 75, 40), corner_radius=10) 88 | v.add_subview(demo_label) 89 | 90 | @script 91 | def demo_action(view, demo_func, animate_ease, sender): 92 | global cleaner 93 | if cleaner: 94 | find_scripter_instance(view).cancel(cleaner) 95 | demo_func(view) 96 | if animate_ease: 97 | slide_value(d, 'x', d.x+140, duration=effect_duration) 98 | slide_value(d, 'y', d.y-140, ease_func=ease_function, duration=effect_duration) 99 | code = inspect.getsource(demo_func) 100 | code = code.replace('ease_function', ease_function.__name__) 101 | code = code.replace('effect_duration', str(effect_duration)) 102 | lines = code.splitlines() 103 | code = '\n'.join(lines[2:]) 104 | c.text = code 105 | cleaner = clean_up(view, effect_duration+2) 106 | #yield 2 # sec delay before restoring state 107 | #initial_state() 108 | 109 | @script 110 | def clean_up(view, delay): 111 | global cleaner 112 | yield delay 113 | cleaner = None 114 | initial_state() 115 | 116 | def initial_state(): 117 | global graph_start, color_changer 118 | demo_label.text = 'Demo' 119 | slide_tuple(demo_label, 'center', (100+75/2, graph_start)) 120 | slide_value(demo_label, 'width', 75) 121 | slide_value(demo_label, 'height', 40) 122 | demo_label.hidden = False 123 | demo_label.alpha = 1.0 124 | demo_label.transform = Transform.rotation(0) 125 | demo_label.font = ('', 16) 126 | if not color_changer: 127 | slide_color(demo_label, 'background_color', 'transparent') 128 | slide_value(d, 'center', (200, graph_start)) 129 | 130 | @script 131 | def demo_move(view): 132 | move(view, 100, view.y-140, duration=effect_duration, ease_func=ease_function) 133 | 134 | @script 135 | def demo_move_by(view): 136 | facets = 36 137 | 138 | vct = Vector(0, facets/2) 139 | 140 | for _ in range(facets): 141 | move_by(view, vct.x, vct.y, duration=effect_duration/facets, ease_func=ease_function) 142 | yield 143 | vct.degrees -= 360/facets 144 | 145 | @script 146 | def demo_hide_and_show(view): 147 | hide(view, duration=effect_duration, ease_func=ease_function) 148 | yield 149 | show(view, duration=effect_duration, ease_func=ease_function) 150 | 151 | @script 152 | def demo_pulse(view): 153 | pulse(view, duration=effect_duration, ease_func=ease_function) 154 | 155 | @script 156 | def demo_roll_to(view): 157 | cx, cy = view.center 158 | roll_to(view, (cx, cy-140), duration=effect_duration, ease_func=ease_function) 159 | 160 | @script 161 | def demo_rotate(view): 162 | rotate(view, 360, duration=effect_duration, ease_func=ease_function) 163 | 164 | @script 165 | def demo_rotate_by(view): 166 | for _ in range(4): 167 | rotate_by(view, 90, duration=effect_duration/4, ease_func=ease_function) 168 | yield 169 | 170 | @script 171 | def demo_scale(view): 172 | scale(view, 3, duration=effect_duration, ease_func=ease_function) 173 | 174 | @script 175 | def demo_scale_by(view): 176 | for _ in range(3): 177 | scale_by(view, 2, duration=effect_duration/3, ease_func=ease_function) 178 | yield 179 | 180 | @script 181 | def demo_fly_out(view): 182 | fly_out(view, 'down', duration=effect_duration, ease_func=ease_function) 183 | 184 | @script 185 | def demo_expand(view): 186 | view.background_color = 'grey' 187 | expand(view, duration=effect_duration, ease_func=ease_function) 188 | 189 | @script 190 | def demo_count(view): 191 | set_value(view, 'text', range(1, 101), str) 192 | 193 | @script 194 | def demo_wobble(view): 195 | wobble(view) 196 | 197 | @script 198 | def demo_font_size(view): 199 | def size_but_keep_centered(): 200 | cntr = view.center 201 | view.size_to_fit() 202 | view.center = cntr 203 | 204 | slide_value(view, 'font', 128, start_value=view.font[1], map_func=lambda font_size: ('', font_size), duration=effect_duration, ease_func=ease_function, side_func=size_but_keep_centered) 205 | 206 | @script 207 | def demo_colors(view): 208 | global color_changer 209 | @script 210 | def change_color_forever(view): 211 | while True: 212 | random_color = (random(), random(), random(), 1.0) 213 | slide_color(view, 'background_color', random_color, duration=effect_duration) 214 | yield 215 | 216 | if color_changer: 217 | find_scripter_instance(view).cancel(color_changer) 218 | color_changer = None 219 | v['demo_colors'].background_color = 'black' 220 | else: 221 | color_changer = change_color_forever(view) 222 | v['demo_colors'].background_color = 'red' 223 | 224 | @script 225 | def demo_reveal_text(view): 226 | reveal_text(duration_label, duration=effect_duration, ease_func=ease_function) 227 | 228 | demos = [ 229 | ('Move', True), 230 | ('Move by', False), 231 | ('Hide and show', False), 232 | ('Pulse', False), 233 | ('Expand', True), 234 | ('Count', False), 235 | ('Roll to', True), 236 | ('Rotate', True), 237 | ('Rotate by', False), 238 | ('Scale', True), 239 | ('Scale by', False), 240 | ('Font size', True), 241 | ('Fly out', True), 242 | ('Wobble', False), 243 | ('Colors', False), 244 | ('Reveal text', True), 245 | ] 246 | 247 | @script 248 | def demo_all(view): 249 | for demo_name, animate_ease in demos: 250 | func_name = create_func_name(demo_name) 251 | v[func_name].background_color = '#c25a5a' 252 | globals()[func_name](demo_label) 253 | if animate_ease: 254 | slide_value(d, 'x', d.x+140, duration=effect_duration) 255 | slide_value(d, 'y', d.y-140, ease_func=ease_function, duration=effect_duration) 256 | yield 257 | initial_state() 258 | v[func_name].background_color = 'black' 259 | yield 260 | 261 | button_width = 90 262 | button_height = 25 263 | gap = 5 264 | width_gap = button_width + gap 265 | height_gap = button_height + gap 266 | buttons_per_line = math.floor((v.width-40)/width_gap) 267 | lines = (len(demos)+1)/buttons_per_line 268 | total_height = lines * height_gap 269 | 270 | cy = buttons_start + total_height + 20 271 | 272 | scroll_label = ScrollingBannerLabel( 273 | text='Use this tool to experiment with the various scripter animation effects as well as the impact that changing the effect duration or ease function has. All effects are applied to the "Demo" label above. Select different ease functions to see them illustrated as a graph; the basic "Move" effect is probably the best starting point to understand ease functions. Launching several effects in quick succession will lead to unpredictable results. Code for each effect launched is displayed under this text for your copy-paste convenience in case you want to use the effect. This scrolling text demonstrates the specialized ScrollingBannerLabel component. ***' 274 | ) 275 | scroll_label.frame = (20, cy, v.width-40, 30) 276 | v.add_subview(scroll_label) 277 | 278 | c.frame = (20, cy+30, v.width-40, v.height-cy-50) 279 | 280 | initial_state() 281 | 282 | preset_durations = [0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0, 5.0, 7.0, 10.0] 283 | 284 | duration_label = Label(text='0.5 seconds', frame=(20,options_start,150,20), alignment=ALIGN_LEFT) 285 | duration_slider = Slider(frame=(20,options_start+25,150,20), continuous=True, value=0.1) 286 | v.add_subview(duration_label) 287 | v.add_subview(duration_slider) 288 | 289 | def slider_action(sender): 290 | global effect_duration 291 | slot = round(sender.value*(len(preset_durations)-1)) 292 | effect_duration = preset_durations[slot] 293 | duration_label.text = str(effect_duration) + ' seconds' 294 | 295 | duration_slider.action = slider_action 296 | 297 | ease_funcs = [ linear, sinusoidal, ease_in, ease_out, ease_in_out, ease_out_in, elastic_out, elastic_in, elastic_in_out, bounce_out, bounce_in, bounce_in_out, ease_back_in, ease_back_in_alt, ease_back_out, ease_back_out_alt, ease_back_in_out, ease_back_in_out_alt ] 298 | 299 | ease_label = Label(text='linear', frame=(200, options_start, 150, 40), touch_enabled=True, corner_radius=7, border_color='black', border_width=2, alignment=ALIGN_CENTER) 300 | ease_click = Button(frame=(0, 0, 150, 40), flex='WH') 301 | ease_label.add_subview(ease_click) 302 | 303 | def ease_action(sender): 304 | global ease_function 305 | selection = dialogs.list_dialog('Select easing function', [ func.__name__ for func in ease_funcs]) 306 | if selection: 307 | ease_label.text = selection 308 | ease_function = globals()[selection] 309 | draw_ease(v) 310 | 311 | @script 312 | def draw_ease(view): 313 | v.start_new = True 314 | slide_value(v, 'curve_point_x', 140, start_value=1, duration=0.7) 315 | slide_value(v, 'curve_point_y', 140, start_value=1, ease_func=ease_function, map_func=v.trigger_refresh, duration=0.7) 316 | 317 | draw_ease(v) 318 | 319 | ease_click.action = ease_action 320 | 321 | v.add_subview(ease_label) 322 | 323 | def create_button(title, action): 324 | demo_btn = Button(title=title, corner_radius=7) 325 | demo_btn.background_color = 'black' 326 | demo_btn.tint_color = 'white' 327 | demo_btn.action = action 328 | demo_btn.font = ('', 12) 329 | demo_btn.width, demo_btn.height = (button_width, button_height) 330 | return demo_btn 331 | 332 | def create_func_name(demo_name): 333 | return ('demo_'+demo_name).replace(' ', '_').lower() 334 | 335 | all_btn = create_button('All', demo_all) 336 | all_btn.x, all_btn.y = (20, buttons_start) 337 | all_btn.background_color = '#666666' 338 | v.add_subview(all_btn) 339 | 340 | for j, (demo_name, animate_ease) in enumerate(demos): 341 | i = j+1 342 | line = math.floor(i/buttons_per_line) 343 | column = i - line*buttons_per_line 344 | demo_func_name = create_func_name(demo_name) 345 | action = partial(demo_action, demo_label, globals()[demo_func_name], animate_ease) 346 | btn = create_button(demo_name, action) 347 | btn.name = demo_func_name 348 | btn.x, btn.y = (20 + column*width_gap, buttons_start+line*height_gap) 349 | v.add_subview(btn) 350 | -------------------------------------------------------------------------------- /trioscripter.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | ''' 3 | # Installation 4 | 5 | `pip install` dependencies: 6 | * `trio` 7 | * `asks` 8 | * `contextvars` (before Python 3.7) 9 | 10 | Overlay view dependencies: 11 | * overlay 12 | * gestures 13 | ''' 14 | 15 | # Trio workarounds for Pythonista, see https://github.com/python-trio/trio/issues/684 16 | import warnings, signal 17 | with warnings.catch_warnings(): 18 | warnings.simplefilter("ignore") 19 | import trio 20 | import asks 21 | asks.init('trio') 22 | signal.signal(signal.SIGINT, signal.SIG_DFL) 23 | #trio._core._run._MAX_TIMEOUT = 1.0 24 | 25 | import functools, types, inspect, time 26 | 27 | from overlay import Overlay, AppWindows 28 | import ui, objc_util 29 | 30 | 31 | class ValueNotDefinedYet(Exception): 32 | 33 | def __init__(self): 34 | super().__init__('Value has not been returned yet. Did you forget to yield?') 35 | 36 | 37 | class GeneratorValueWrapper(): 38 | 39 | def __init__(self, gen): 40 | self.gen = gen 41 | self._value = ValueNotDefinedYet() 42 | 43 | @property 44 | def value(self): 45 | if isinstance(self._value, Exception): 46 | raise Exception(str(self._value)) from self._value 47 | else: 48 | return self._value 49 | 50 | def cancel(self): 51 | trio._scripter.cancel(self) 52 | 53 | 54 | def script(func): 55 | ''' 56 | Decorator for the async scripts. 57 | 58 | Scripts piggyback on the trio event loop. 59 | 60 | Script actions execute in parallel until the next `yield` statement. 61 | 62 | New scripts suspend the execution of the parent script until all the parallel scripts have 63 | completed, after which parent script execution is resumed. 64 | ''' 65 | @functools.wraps(func) 66 | def wrapper(*args, **kwargs): 67 | scr = trio._scripter 68 | if inspect.isgeneratorfunction(func): 69 | gen = func(*args, **kwargs) 70 | elif inspect.iscoroutinefunction(func): 71 | gen = func(*args, **kwargs) 72 | gen = scr._async_handler(gen) 73 | return gen 74 | else: 75 | def gen_wrapper(func, *args, **kwargs): 76 | func(*args, **kwargs) 77 | yield 78 | gen = gen_wrapper(func, *args, **kwargs) 79 | scr.parent_gens[gen] = scr.current_gen 80 | if scr.current_gen != 'root': 81 | scr.standby_gens.setdefault(scr.current_gen, set()) 82 | scr.standby_gens[scr.current_gen].add(gen) 83 | scr.deactivate.add(scr.current_gen) 84 | scr.activate.add(gen) 85 | 86 | scr.wake_up.set() 87 | 88 | value_wrapper = GeneratorValueWrapper(gen) 89 | scr.value_by_gen[gen] = value_wrapper 90 | 91 | return value_wrapper 92 | 93 | return wrapper 94 | 95 | ''' 96 | def extract(gen): 97 | "Extracts the returned value of a completed script. Raises an Exception if the value is not available." 98 | #loop = asyncio.get_event_loop() 99 | #scr = loop._scripter 100 | scr = trio._scripter 101 | try: 102 | value = scr.value_by_gen[gen] 103 | except KeyError: 104 | raise Exception('No value available. Remember to return a value from the script and yield before calling extract. ') 105 | if isinstance(value, Exception): 106 | raise Exception('Task raised an exception:' + str(value.args)) from value 107 | else: 108 | return value 109 | ''' 110 | 111 | 112 | class Scripter(): 113 | 114 | default_duration = 0.5 115 | 116 | def __init__(self): 117 | self.cancel_all() 118 | #loop = asyncio.get_event_loop() 119 | #self._session = aiohttp.ClientSession(loop=loop) 120 | 121 | def end_all(self): 122 | if not self._session.closed: 123 | self._session.close() 124 | 125 | async def update(self, nursery): 126 | ''' 127 | Main Scripter animation loop handler, called by the Puthonista UI loop and never by your 128 | code directly. 129 | 130 | This method: 131 | 132 | * Activates all newly called scripts and suspends their parents. 133 | * Calls all active scripts, which will run to their next `yield` or until completion. 134 | * As a convenience feature, if a `yield` returns `'wait'` or a specific duration, 135 | kicks off a child `timer` script to wait for that period of time. 136 | * Cleans out completed scripts. 137 | * Resumes parent scripts whose children have all completed. 138 | * Sets `update_interval` to 0 if all scripts have completed. 139 | ''' 140 | run_at_least_once = True 141 | while run_at_least_once or len(self.activate) > 0 or len(self.deactivate) > 0: 142 | run_at_least_once = False 143 | for gen in self.activate: 144 | self.active_gens.add(gen) 145 | for gen in self.deactivate: 146 | self.active_gens.remove(gen) 147 | self.activate = set() 148 | self.deactivate = set() 149 | gen_to_end = [] 150 | for gen in self.active_gens: 151 | self.current_gen = gen 152 | wait_time = self.should_wait.pop(gen, None) 153 | if wait_time is not None: 154 | timer(wait_time) 155 | else: 156 | yielded = None 157 | try: 158 | yielded = next(gen) 159 | except StopIteration as stopped: 160 | if gen not in self.deactivate: 161 | gen_to_end.append(gen) 162 | self.value_by_gen[gen]._value = stopped.value 163 | del self.value_by_gen[gen] 164 | if yielded is not None: 165 | if yielded == 'wait': 166 | yielded = self.default_duration 167 | if type(yielded) in [int, float]: 168 | self.should_wait[gen] = yielded 169 | elif type(yielded) is tuple: 170 | coro, queue = yielded 171 | async def async_runner(coro, queue): 172 | try: 173 | value = await coro 174 | queue.put_nowait(value) 175 | except Exception as e: 176 | queue.put_nowait(e) 177 | nursery.start_soon(async_runner, coro, queue) 178 | self.current_gen = 'root' 179 | self.time_paused = 0 180 | for gen in gen_to_end: 181 | self.active_gens.remove(gen) 182 | parent_gen = self.parent_gens[gen] 183 | del self.parent_gens[gen] 184 | if parent_gen != 'root': 185 | self.standby_gens[parent_gen].remove(gen) 186 | if len(self.standby_gens[parent_gen]) == 0: 187 | self.activate.add(parent_gen) 188 | del self.standby_gens[parent_gen] 189 | return len(self.active_gens) + len(self.standby_gens) > 0 190 | # self.update_interval = 0.0 191 | # self.running = False 192 | 193 | def cancel(self, script): 194 | ''' Cancels any ongoing animations and 195 | sub-scripts for the given script. ''' 196 | to_cancel = set() 197 | to_cancel.add(script) 198 | parent_gen = self.parent_gens[script] 199 | if parent_gen != 'root': 200 | self.standby_gens[parent_gen].remove(script) 201 | if len(self.standby_gens[parent_gen]) == 0: 202 | self.active_gens.add(parent_gen) 203 | del self.standby_gens[parent_gen] 204 | found_new = True 205 | while found_new: 206 | new_found = set() 207 | found_new = False 208 | for gen in to_cancel: 209 | if gen in self.standby_gens: 210 | for child_gen in self.standby_gens[gen]: 211 | if child_gen not in to_cancel: 212 | new_found.add(child_gen) 213 | found_new = True 214 | for gen in new_found: 215 | to_cancel.add(gen) 216 | 217 | for gen in to_cancel: 218 | if gen == self.current_gen: 219 | self.current_gen = parent_gen 220 | del self.value_by_gen[gen] 221 | del self.parent_gens[gen] 222 | self.activate.discard(gen) 223 | self.deactivate.discard(gen) 224 | self.active_gens.discard(gen) 225 | if gen in self.standby_gens: 226 | del self.standby_gens[gen] 227 | 228 | def cancel_all(self): 229 | ''' Initializes all internal structures. 230 | Used at start and to cancel all running scripts. 231 | ''' 232 | self.current_gen = 'root' 233 | self.should_wait = {} 234 | self.parent_gens = {} 235 | self.value_by_gen = {} 236 | self.active_gens = set() 237 | self.standby_gens = {} 238 | self.activate = set() 239 | self.deactivate = set() 240 | #self.running = False 241 | 242 | @script 243 | def _async_handler(self, coro): 244 | queue = trio.Queue(1) 245 | yield (coro, queue) 246 | while True: 247 | try: 248 | return queue.get_nowait() 249 | except trio.WouldBlock: 250 | pass 251 | yield 252 | 253 | async def _scripter_runner(self, nursery): 254 | while True: 255 | if not await self.update(nursery): 256 | if not self.forever: break 257 | with trio.move_on_after(1): 258 | await self.wake_up.wait() 259 | self.wake_up.clear() 260 | await trio.sleep(0) 261 | 262 | async def _runner(self): 263 | #loop = asyncio.get_event_loop() 264 | #i = 0 265 | if self.start_script: 266 | self.start_script() 267 | async with trio.open_nursery() as nursery: 268 | if self.hud: 269 | self.overlay = self.open_overlay() 270 | nursery.start_soon(self._scripter_runner, nursery) 271 | print('end') 272 | #self.end_all() 273 | 274 | def close_down(self): 275 | task = trio.hazmat.current_root_task() 276 | print(dir(task)) 277 | 278 | @objc_util.on_main_thread 279 | def open_overlay(self): 280 | view = ui.View(name='Trio') 281 | view.frame = (0, 0, 200, 1) 282 | view.flex = 'WH' 283 | view.background_color = 'white' 284 | o = Overlay(content=view, parent=AppWindows.root()) 285 | o.close_callback = self.close_down 286 | return o 287 | 288 | @classmethod 289 | def run(cls, start_script=None, forever=False, hud=False): 290 | print('starting') 291 | trio._scripter = scr = Scripter() 292 | scr.forever = forever 293 | scr.hud = hud 294 | scr.start_script = start_script 295 | scr.wake_up = trio.Event() 296 | trio.run(scr._runner) 297 | print('done') 298 | 299 | @classmethod 300 | def bootstrap(cls): 301 | scr = Scripter() 302 | loop = asyncio.get_event_loop() 303 | loop._scripter = scr 304 | print('starting') 305 | loop.call_soon(scr._runner()) 306 | print('done') 307 | 308 | @script 309 | def timer(duration=None, action=None): 310 | ''' Acts as a wait timer for the given 311 | duration in seconds. Optional action 312 | function is called every cycle. ''' 313 | duration = duration or 0.3 314 | start_time = time.time() 315 | dt = 0 316 | while dt < duration: 317 | if action: action() 318 | yield 319 | dt = time.time() - start_time 320 | 321 | @script 322 | async def get(*args, **kwargs): 323 | response = await asks.get(*args, **kwargs) 324 | return response 325 | 326 | @script 327 | async def post(*args, **kwargs): 328 | response = await asks.post(*args, **kwargs) 329 | return response 330 | 331 | 332 | if __name__ == '__main__': 333 | 334 | sites = '''youtube.com 335 | facebook.com 336 | baidu.com 337 | wikipedia.org 338 | reddit.com 339 | yahoo.com 340 | google.co.in 341 | amazon.com 342 | twitter.com 343 | sohu.com 344 | instagram.com 345 | vk.com 346 | jd.com 347 | sina.com.cn 348 | weibo.com 349 | yandex.ru 350 | google.co.jp 351 | google.co.uk 352 | list.tmall.com 353 | google.ru 354 | google.com.br 355 | netflix.com 356 | google.de 357 | google.com.hk 358 | twitch.tv 359 | google.fr 360 | linkedin.com 361 | yahoo.co.jp 362 | t.co 363 | microsoft.com 364 | bing.com 365 | office.com 366 | xvideos.com 367 | google.it 368 | google.ca 369 | mail.ru 370 | ok.ru 371 | google.es 372 | pages.tmall.com 373 | msn.com 374 | google.com.tr 375 | google.com.au 376 | whatsapp.com 377 | spotify.com 378 | google.pl 379 | google.co.id 380 | xhamster.com 381 | google.com.ar 382 | xnxx.com 383 | google.co.th 384 | Naver.com 385 | sogou.com 386 | accuweather.com 387 | goo.gl 388 | sm.cn 389 | googleweblight.com'''.splitlines() 390 | 391 | import datetime 392 | 393 | s = asks.Session(connections=100) 394 | 395 | async def grabber(site): 396 | r = await s.get('https://'+site, timeout=5) 397 | content = r.text 398 | #print(site) 399 | 400 | async def main(): 401 | async with trio.open_nursery() as n: 402 | for site in sites: 403 | n.start_soon(grabber, site) 404 | 405 | @script 406 | def retrieve_all(): 407 | for site in sites: 408 | worker(site) 409 | print(site) 410 | 411 | @script 412 | def worker(site): 413 | result = get('https://'+site, timeout=5) 414 | yield 415 | content = result.value.text 416 | 417 | 418 | def baseline_requests(): 419 | import requests 420 | for site in sites: 421 | print(site) 422 | url = 'https://'+site 423 | try: 424 | resp = requests.get(url, timeout=(5,5)) 425 | content = resp.text 426 | except Exception as e: 427 | print(str(e)) 428 | 429 | @script 430 | def simple_test(): 431 | print('hello') 432 | #yield # inserted automatically 433 | 434 | start = datetime.datetime.now() 435 | 436 | #baseline_requests() 437 | #trio.run(main) 438 | #Scripter.run(lean_retriever) 439 | Scripter.run(simple_test, forever=True, hud=True) 440 | 441 | duration = datetime.datetime.now() - start 442 | print(len(sites), 'sites in', str(duration)) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # _SCRIPTER_ - Pythonista UI and Scene animations 2 | 3 | ![Logo](https://raw.githubusercontent.com/mikaelho/scripter/master/logo.jpg) 4 | 5 | # Quick start 6 | 7 | In order to start using the animation effects, just import scripter and call 8 | the effects as functions: 9 | 10 | from scripter import * 11 | 12 | hide(my_button) 13 | 14 | Effects expect an active UI view as the first argument. Effects run for a 15 | default duration of 0.5 seconds, unless otherwise specified with a `duration` 16 | argument. 17 | 18 | If you want to create a more complex animation from the effects provided, 19 | combine them in a script: 20 | 21 | @script 22 | def my_effect(view): 23 | move(view, 50, 200) 24 | pulse(view, 'red') 25 | yield 26 | hide(view, duration=2.0) 27 | 28 | Scripts control the order of execution with `yield` statements. Here movement 29 | and a red pulsing highlight happen at the same time. After both actions are 30 | completed, view fades away slowly, in 2 seconds. 31 | 32 | As the view provided as the first argument can of course be `self` or `sender`, 33 | scripts fit naturally as custom `ui.View` methods or `action` functions. 34 | 35 | As small delays are often needed for natural-feeling animations, you can append 36 | a number after a `yield` statement, to suspend the execution of the script for 37 | that duration, or `yield 'wait'` for the default duration. 38 | 39 | Another key for good animations is the use of easing functions that modify how 40 | the value progresses from starting value to the target value. Easing functions 41 | support creating different kinds of accelerating, bouncing and springy effects. 42 | Easing functions can be added as an argument to scripts: 43 | 44 | slide_value(view, 'x', 200, ease_func=bounce_out) 45 | 46 | See this 47 | [reference](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-funcs.jpg) 48 | to pick the right function, or run `scripter-demo.py` to try out the available 49 | effects and to find the optimal duration and easing function combo for your purposes. 50 | 51 | You can change the default speed of all animations by setting 52 | `Scripter.default_duration`. 53 | 54 | Scripter can also be used to animate different kinds of Pythonista `scene` 55 | module Nodes, including the Scene itself. Scripter provides roughly the same 56 | functionality as `scene.Action`, but is maybe a bit more concise, and is 57 | available as an option if you want to use same syntax in both UI and Scene 58 | projects. 59 | 60 | See the API documentation for individual effects and how to roll your own with 61 | `set_value`, `slide_value` and `timer`. 62 | 63 | There are also convenience functions, not separately documented, corresponding 64 | to all animatable attributes of ui views. For example, you can animate the 65 | `ui.View.background_color` attribute with: 66 | 67 | background_color(view, 'black') 68 | 69 | # API 70 | 71 | * [Class: Scripter](#class-scripter) 72 | * [Methods](#methods) 73 | * [Properties](#properties) 74 | * [Class: ScrollingBannerLabel](#class-scrollingbannerlabel) 75 | * [Methods](#methods) 76 | * [Properties](#properties) 77 | * [Class: Vector](#class-vector) 78 | * [Methods](#methods) 79 | * [Properties](#properties) 80 | * [Functions](#functions) 81 | * [Script management](#script-management) 82 | * [Animation primitives](#animation-primitives) 83 | * [Animation effects](#animation-effects) 84 | * [Easing functions](#easing-functions) 85 | 86 | 87 | ## Class: Scripter 88 | 89 | Class that contains the `update` method used to run the scripts and to control their execution. 90 | 91 | Runs at default 60 fps, or not at all when there are no scripts to run. 92 | 93 | Inherits from ui.View; constructor takes all the same arguments as ui.View. 94 | 95 | ## Methods 96 | 97 | 98 | #### `update(self)` 99 | 100 | Main Scripter animation loop handler, called by the Puthonista UI loop and never by your 101 | code directly. 102 | 103 | This method: 104 | 105 | * Activates all newly called scripts and suspends their parents. 106 | * Calls all active scripts, which will run to their next `yield` or until completion. 107 | * As a convenience feature, if a `yield` returns `'wait'` or a specific duration, 108 | kicks off a child `timer` script to wait for that period of time. 109 | * Cleans out completed scripts. 110 | * Resumes parent scripts whose children have all completed. 111 | * Sets `update_interval` to 0 if all scripts have completed. 112 | 113 | #### `pause_play_all(self)` 114 | 115 | Pause or play all animations. 116 | 117 | #### `cancel(self, script)` 118 | 119 | Cancels any ongoing animations and 120 | sub-scripts for the given script. 121 | 122 | #### `cancel_all(self)` 123 | 124 | Initializes all internal structures. 125 | Used at start and to cancel all running scripts. 126 | ## Properties 127 | 128 | 129 | #### `default_update_interval (get)` 130 | 131 | The running rate for the update method. Frames per second is here considered to be just an 132 | alternative way of setting the update interval, and this property is linked to 133 | `default_fps` - change one and the other will change as well. 134 | 135 | #### `default_fps (get)` 136 | 137 | The running rate for the update method. Frames per second is here considered to be just an 138 | alternative way of setting the update interval, and this property is linked to 139 | `default_update_interval` - change one and the other will change as well. 140 | ## Class: ScrollingBannerLabel 141 | 142 | UI component that scrolls the given text indefinitely, in either direction. Will only scroll if the text is too long to fit into this component. 143 | 144 | 145 | ## Methods 146 | 147 | 148 | #### `__init__(self, **kwargs)` 149 | 150 | In addition to normal `ui.View` arguments, you can include: 151 | 152 | * `text` - To be scrolled as a marquee. 153 | * Label formatting arguments `font` and `text_color`. 154 | * `initial_delay` - How long we wait before we start scrolling, to enable reading the beginning of the text. Default is 2 seconds. 155 | * `scrolling_speed` - How fast the text moves, in points per second. Default is 100 pts/s. 156 | * `to_right` - Set to True if you would like the text to scroll from the left. Default is False. 157 | 158 | #### `stop(self)` 159 | 160 | Stops the scrolling and places the text at start. 161 | 162 | #### `restart(self)` 163 | 164 | Restarts the scrolling, including the initial delay, if any. 165 | ## Properties 166 | 167 | 168 | #### `text (get)` 169 | 170 | You can change the text displayed at 171 | any point after initialization by setting 172 | this property. 173 | 174 | # Functions 175 | 176 | 177 | #### SCRIPT MANAGEMENT 178 | #### `script(func)` 179 | 180 | _Can be used with Scene Nodes._ 181 | 182 | Decorator for the animation scripts. Scripts can be functions, methods or generators. 183 | 184 | First argument of decorated functions must always be the view to be animated. 185 | 186 | Calling a script starts the Scripter `update` loop, if not already running. 187 | 188 | New scripts suspend the execution of the parent script until all the parallel scripts have 189 | completed, after which the `update` method will resume the execution of the parent script. 190 | 191 | #### `find_scripter_instance(view)` 192 | 193 | _Can be used with Scene Nodes._ 194 | 195 | Scripts need a "controller" ui.View that runs the update method for them. This function finds 196 | or creates the controller for a view as follows: 197 | 198 | 1. Check if the view itself is a Scripter 199 | 2. Check if any of the subviews is a Scripter 200 | 3. Repeat 1 and 2 up the view hierarchy of superviews 201 | 4. If not found, create an instance of Scripter as a hidden subview of the root view 202 | 203 | In case of scene Nodes, search starts from `node.scene.view`. 204 | 205 | If you want cancel or pause scripts, and have not explicitly created a Scripter instance to 206 | run them, you need to use this method first to find the right one. 207 | 208 | #### ANIMATION PRIMITIVES 209 | #### `set_value(view, attribute, value, func=None)` 210 | `@script` 211 | 212 | Generator that sets the `attribute` to a `value` once, or several times if the value itself is a 213 | generator or an iterator. 214 | 215 | Optional keyword parameters: 216 | 217 | * `func` - called with the value, returns the actual value to be set 218 | 219 | #### `slide_value(view, attribute, end_value, target=None, start_value=None, duration=None, delta_func=None, ease_func=None, current_func=None, map_func=None, side_func=None)` 220 | `@script` 221 | 222 | Generator that "slides" the `value` of an 223 | `attribute` to an `end_value` in a given duration. 224 | 225 | Optional keyword parameters: 226 | 227 | * `start_value` - set if you want some other value than the current value of the attribute as the animation start value. 228 | * `duration` - time it takes to change to the target value. Default is 0.5 seconds. 229 | * `delta_func` - use to transform the range from start_value to end_value to something else. 230 | * `ease_func` - provide to change delta-t value to something else. Mostly used for easing; you can provide an easing function name as a string instead of an actual function. See supported easing functions [here](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-functions.png). 231 | * `current_func` - Given the start value, delta value and progress fraction (from 0 to 1), returns the current value. Intended to be used to manage more exotic values like colors. 232 | * `map_func` - Used to translate the current value to something else, e.g. an angle to a Transform.rotation. 233 | * `side_func` - Called without arguments each time after the main value has been set. Useful for side effects. 234 | 235 | #### `slide_tuple(view, *args, **kwargs)` 236 | `@script` 237 | 238 | Slide a tuple value of arbitrary length. Supports same arguments as `slide_value`. 239 | 240 | #### `slide_color(view, attribute, end_value, **kwargs)` 241 | `@script` 242 | 243 | Slide a color value. Supports the same 244 | arguments as `slide_value`. 245 | 246 | #### `timer(view, duration=None, action=None)` 247 | `@script` 248 | 249 | Acts as a wait timer for the given duration in seconds. `view` is only used to find the 250 | controlling Scripter instance. Optional action function is called every cycle. 251 | 252 | #### ANIMATION EFFECTS 253 | #### `center(view, move_center_to, **kwargs)` 254 | `@script` 255 | 256 | Move view center (anchor for Scene Nodes). 257 | 258 | #### `center_to(view, move_center_to, **kwargs)` 259 | `@script` 260 | 261 | Alias for `center`. 262 | 263 | #### `center_by(view, dx, dy, **kwargs)` 264 | `@script` 265 | 266 | Adjust view center/anchor position by dx, dy. 267 | 268 | #### `expand(view, **kwargs)` 269 | `@script` 270 | 271 | _Not applicable for Scene Nodes._ 272 | 273 | Expands the view to fill all of its superview. 274 | 275 | #### `fly_out(view, direction, **kwargs)` 276 | `@script` 277 | 278 | Moves the view out of the screen in the given direction. Direction is one of the 279 | following strings: 'up', 'down', 'left', 'right'. 280 | 281 | #### `hide(view, **kwargs)` 282 | `@script` 283 | 284 | Fade the view away. 285 | 286 | #### `move(view, x, y, **kwargs)` 287 | `@script` 288 | 289 | Move to x, y. 290 | For UI views, this positions the top-left corner. 291 | For Scene Nodes, this moves the Node `position`. 292 | 293 | #### `move_to(view, x, y, **kwargs)` 294 | `@script` 295 | 296 | Alias for `move`. 297 | 298 | #### `move_by(view, dx, dy, **kwargs)` 299 | `@script` 300 | 301 | Adjust position by dx, dy. 302 | 303 | #### `pulse(view, color='#67cf70', **kwargs)` 304 | `@script` 305 | 306 | Pulses the background of the view to the given color and back to the original color. 307 | Default color is a shade of green. 308 | 309 | #### `reveal_text(view, **kwargs)` 310 | `@script` 311 | 312 | Reveals text one letter at a time in the given duration. View must have a `text` attribute. 313 | 314 | #### `roll_to(view, to_center, end_right_side_up=True, **kwargs)` 315 | `@script` 316 | 317 | Roll the view to a target position given by the `to_center` tuple. If `end_right_side_up` is true, view starting angle is adjusted so that the view will end up with 0 rotation at the end, otherwise the view will start as-is, and end in an angle determined by the roll. 318 | View should be round for the rolling effect to make sense. Imaginary rolling surface is below the view - or to the left if rolling directly downwards. 319 | 320 | #### `rotate(view, degrees, shortest=False, **kwargs)` 321 | `@script` 322 | 323 | Rotate view to an absolute angle. Set start_value if not starting from 0. Positive number rotates clockwise. For UI views, does not mix with other transformations. 324 | 325 | Optional arguments: 326 | 327 | * `shortest` - If set to True (default), will turn in the "right" direction. For UI views, start_value must be set to a sensible value for this to work. 328 | 329 | #### `rotate_to(view, degrees, **kwargs)` 330 | 331 | Alias for `rotate`. 332 | 333 | #### `rotate_by(view, degrees, **kwargs)` 334 | `@script` 335 | 336 | Rotate view by given degrees. 337 | 338 | #### `scale(view, factor, **kwargs)` 339 | `@script` 340 | 341 | Scale view to a given factor in both x and y dimensions. For UI views, you need to explicitly set `start_value` if not starting from 1. 342 | 343 | #### `scale_to(view, factor, **kwargs)` 344 | 345 | Alias for `scale`. 346 | 347 | #### `scale_by(view, factor, **kwargs)` 348 | `@script` 349 | 350 | Scale view relative to current scale factor. 351 | 352 | #### `show(view, **kwargs)` 353 | `@script` 354 | 355 | Slide alpha from 0 to 1. 356 | 357 | #### `wobble(view)` 358 | `@script` 359 | 360 | Little wobble of a view, intended to attract attention. 361 | 362 | #### `wait_for_tap(view)` 363 | `@script` 364 | 365 | Overlays the given view with a temporary transparent view, and 366 | yields until the view is tapped. 367 | 368 | #### EASING FUNCTIONS 369 | #### `mirror(ease_func, t)` 370 | 371 | Runs the given easing function to the end in half the duration, then backwards in the second half. For example, if the function provided is `linear`, this function creates a "triangle" from 0 to 1, then back to 0; if the function is `ease_in`, the result is more of a "spike". 372 | 373 | #### `oscillate(t)` 374 | 375 | Basic sine curve that runs from 0 through 1, 0 and -1, and back to 0. 376 | -------------------------------------------------------------------------------- /scripter/anchor.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import inspect 4 | import json 5 | import math 6 | import re 7 | import textwrap 8 | 9 | from functools import partialmethod, partial 10 | from itertools import accumulate 11 | 12 | import ui 13 | 14 | from scripter import * 15 | 16 | 17 | _anchor_rules_spec = """ 18 | left: 19 | type: leading 20 | target: 21 | attribute: target.x 22 | value: value 23 | source: 24 | regular: source.x 25 | container: source.bounds.x 26 | safe: safe.origin.x 27 | right: 28 | type: trailing 29 | target: 30 | attribute: target.x 31 | value: value - target.width 32 | source: 33 | regular: source.frame.max_x 34 | container: source.bounds.max_x 35 | safe: safe.origin.x + safe.size.width 36 | top: 37 | type: leading 38 | target: 39 | attribute: target.y 40 | value: value 41 | source: 42 | regular: source.y 43 | container: source.bounds.y 44 | safe: safe.origin.y 45 | bottom: 46 | type: trailing 47 | target: 48 | attribute: target.y 49 | value: value - target.height 50 | source: 51 | regular: source.frame.max_y 52 | container: source.bounds.max_y 53 | safe: safe.origin.y + safe.size.height 54 | left_flex: 55 | type: leading 56 | target: 57 | attribute: (target.x, target.width) 58 | value: (value, target.width - (value - target.x)) 59 | right_flex: 60 | type: trailing 61 | target: 62 | attribute: target.width 63 | value: target.width + (value - (target.x + target.width)) 64 | top_flex: 65 | type: leading 66 | target: 67 | attribute: (target.y, target.height) 68 | value: (value, target.height - (value - target.y)) 69 | bottom_flex: 70 | type: trailing 71 | target: 72 | attribute: target.height 73 | value: target.height + (value - (target.y + target.height)) 74 | center_x: 75 | type: neutral 76 | target: 77 | attribute: target.center 78 | value: (value, target.center.y) 79 | source: 80 | regular: source.center.x 81 | container: source.bounds.center().x 82 | center_y: 83 | type: neutral 84 | target: 85 | attribute: target.center 86 | value: (target.center.x, value) 87 | source: 88 | regular: source.center.y 89 | container: source.bounds.center().y 90 | center: 91 | type: neutral 92 | target: 93 | attribute: target.center 94 | value: value 95 | source: 96 | regular: source.center 97 | container: source.bounds.center() 98 | width: 99 | type: neutral 100 | target: 101 | attribute: target.width 102 | value: value 103 | source: 104 | regular: source.width 105 | container: source.bounds.width - 2 * At.gap 106 | safe: safe.size.width - 2 * At.gap 107 | height: 108 | type: neutral 109 | target: 110 | attribute: target.height 111 | value: value 112 | source: 113 | regular: source.height 114 | container: source.bounds.height - 2 * At.gap 115 | safe: safe.size.height - 2 * At.gap 116 | position: 117 | type: neutral 118 | target: 119 | attribute: target.frame 120 | value: (value[0], value[1], target.width, target.height) 121 | source: 122 | regular: (source.x, source.y) 123 | container: (source.x, source.y) 124 | size: 125 | type: neutral 126 | target: 127 | attribute: target.frame 128 | value: (target.x, target.y, value[0], value[1]) 129 | source: 130 | regular: (source.width, source.height) 131 | container: (source.width, source.height) 132 | frame: 133 | type: neutral 134 | target: 135 | attribute: target.frame 136 | value: value 137 | source: 138 | regular: source.frame 139 | container: source.frame 140 | bounds: 141 | type: neutral 142 | target: 143 | attribute: target.bounds 144 | value: value 145 | source: 146 | regular: source.bounds 147 | container: source.bounds 148 | heading: 149 | type: neutral 150 | target: 151 | attribute: target._scripter_at._heading 152 | value: direction(target, source, value) 153 | source: 154 | regular: source._scripter_at._heading 155 | container: source._scripter_at._heading 156 | attr: 157 | type: neutral 158 | target: 159 | attribute: target._custom 160 | value: value 161 | source: 162 | regular: source._custom 163 | fit_size: 164 | source: 165 | regular: subview_bounds(source) 166 | fit_width: 167 | source: 168 | regular: subview_bounds(source).width 169 | fit_height: 170 | source: 171 | regular: subview_bounds(source).height 172 | """ 173 | 174 | 175 | class At: 176 | 177 | gap = 8 # Apple Standard gap 178 | safe = True # Avoid iOS UI elements 179 | TIGHT = -gap 180 | 181 | @classmethod 182 | def gaps_for(cls, count): 183 | return (count - 1) / count * At.gap 184 | 185 | class Anchor: 186 | 187 | REGULAR, CONTAINER, SAFE = 'regular', 'container', 'safe' 188 | 189 | SAME, DIFFERENT, NEUTRAL = 'same', 'different', 'neutral' 190 | 191 | TRAILING, LEADING = 'trailing', 'leading' 192 | 193 | def __init__(self, source_at, source_prop): 194 | self.source_at = source_at 195 | self.source_prop = source_prop 196 | self.modifiers = '' 197 | self.callable = None 198 | 199 | def set_target(self, target_at, target_prop): 200 | self.target_at = target_at 201 | self.target_prop = target_prop 202 | self.safe = At.safe and 'safe' in _rules[self.source_prop]['source'] 203 | 204 | if target_at.view.superview == self.source_at.view: 205 | if self.safe: 206 | self.type = self.SAFE 207 | else: 208 | self.type = self.CONTAINER 209 | else: 210 | self.type = self.REGULAR 211 | 212 | source_type = _rules.get(self.source_prop, _rules['attr']).get('type', 'neutral') 213 | target_type = _rules.get(self.target_prop, _rules['attr']).get('type', 'neutral') 214 | 215 | #print(source_type, target_type) 216 | 217 | self.same = self.SAME if any([ 218 | source_type == self.NEUTRAL, 219 | target_type == self.NEUTRAL, 220 | source_type == target_type, 221 | ]) else self.DIFFERENT 222 | 223 | #print(self.same) 224 | 225 | if (self.type in (self.CONTAINER, self.SAFE) and 226 | self.NEUTRAL not in (source_type, target_type)): 227 | self.same = self.SAME if self.same == self.DIFFERENT else self.DIFFERENT 228 | 229 | self.effective_gap = '' 230 | if self.same == self.DIFFERENT: 231 | self.effective_gap = ( 232 | f'+ {At.gap}' if target_type == self.LEADING 233 | else f'- {At.gap}') 234 | 235 | def start_script(self): 236 | self.target_at._remove_anchor(self.target_prop) 237 | 238 | if self.source_prop in _rules: 239 | source_value = _rules[self.source_prop]['source'][self.type] 240 | #source_value = source_value.replace('border_gap', str(At.gap)) 241 | else: 242 | source_value = _rules['attr']['source']['regular'] 243 | source_value = source_value.replace('_custom', self.source_prop) 244 | 245 | get_safe = ( 246 | 'safe = source.objc_instance.safeAreaLayoutGuide().layoutFrame()' 247 | if self.safe 248 | else '' 249 | ) 250 | 251 | target_attribute = self.get_target_attribute(self.target_prop) 252 | 253 | flex_get = '' 254 | flex_set = f'{target_attribute} = target_value' 255 | opposite_prop = self.get_opposite(self.target_prop) 256 | if opposite_prop: 257 | flex_prop = self.target_prop + '_flex' 258 | flex_get = f'''({self.get_target_value(flex_prop)}) if '{opposite_prop}' in scripts else ''' 259 | flex_set = f'''if '{opposite_prop}' in scripts: '''\ 260 | f'''{self.get_target_attribute(flex_prop)} = target_value'''\ 261 | f''' 262 | else: {target_attribute} = target_value 263 | ''' 264 | 265 | func = self.callable or self.target_at.callable 266 | call_callable = '' 267 | if func: 268 | call_str = 'func(target_value)' 269 | parameters = inspect.signature(func).parameters 270 | if len(parameters) == 2: 271 | call_str = 'func(target_value, target)' 272 | if len(parameters) == 3: 273 | call_str = 'func(target_value, target, source)' 274 | call_callable = f'target_value = {call_str}' 275 | 276 | script_str = (f'''\ 277 | # {self.target_prop} 278 | @script #(run_last=True) 279 | def anchor_runner(source, target, scripts, func): 280 | prev_value = None 281 | prev_bounds = None 282 | while True: 283 | {get_safe} 284 | value = ({source_value} {self.effective_gap}) {self.modifiers} 285 | target_value = ( 286 | {flex_get}{self.get_target_value(self.target_prop)} 287 | ) 288 | if (target_value != prev_value or 289 | target.superview.bounds != prev_bounds): 290 | prev_value = target_value 291 | prev_bounds = target.superview.bounds 292 | {call_callable} 293 | {flex_set} 294 | yield 295 | 296 | self.target_at.running_scripts[self.target_prop] = \ 297 | anchor_runner( 298 | self.source_at.view, 299 | self.target_at.view, 300 | self.target_at.running_scripts, 301 | self.callable or self.target_at.callable) 302 | ''' 303 | ) 304 | run_script = textwrap.dedent(script_str) 305 | #print(run_script) 306 | exec(run_script) 307 | 308 | def get_choice_code(self, code): 309 | target_prop = self.target_prop 310 | opposite_prop = self.get_opposite(target_prop) 311 | 312 | if opposite_prop: 313 | flex_prop = f'{target_prop}_flex' 314 | return f''' 315 | if '{opposite_prop}' in scripts: {self.get_code(flex_prop)} 316 | else: {self.get_code(target_prop)}''' 317 | else: 318 | return f'; {self.get_code(target_prop)}' 319 | 320 | def get_target_value(self, target_prop): 321 | if target_prop in _rules: 322 | target_value = _rules[target_prop]['target']['value'] 323 | else: 324 | target_value = _rules['attr']['target']['value'] 325 | target_value = target_value.replace('_custom', target_prop) 326 | return target_value 327 | 328 | #return f'''{target_attribute} = {target_value}''' 329 | 330 | def get_target_attribute(self, target_prop): 331 | if target_prop in _rules: 332 | target_attribute = _rules[target_prop]['target']['attribute'] 333 | else: 334 | target_attribute = _rules['attr']['target']['attribute'] 335 | target_attribute = target_attribute.replace( 336 | '_custom', target_prop) 337 | return target_attribute 338 | 339 | def get_opposite(self, prop): 340 | opposites = ( 341 | {'left', 'right'}, 342 | {'top', 'bottom'}, 343 | ) 344 | for pair in opposites: 345 | try: 346 | pair.remove(prop) 347 | return pair.pop() 348 | except KeyError: pass 349 | return None 350 | 351 | def __add__(self, other): 352 | if callable(other): 353 | self.callable = other 354 | else: 355 | self.modifiers += f'+ {other}' 356 | return self 357 | 358 | def __sub__(self, other): 359 | self.modifiers += f'- {other}' 360 | return self 361 | 362 | def __mul__(self, other): 363 | self.modifiers += f'* {other}' 364 | return self 365 | 366 | def __truediv__(self, other): 367 | self.modifiers += f'/ {other}' 368 | return self 369 | 370 | def __floordiv__(self, other): 371 | self.modifiers += f'// {other}' 372 | return self 373 | 374 | def __mod__(self, other): 375 | self.modifiers += f'% {other}' 376 | return self 377 | 378 | def __pow__ (self, other, modulo=None): 379 | self.modifiers += f'** {other}' 380 | return self 381 | 382 | 383 | def __new__(cls, view): 384 | try: 385 | return view._scripter_at 386 | except AttributeError: 387 | at = super().__new__(cls) 388 | at.view = view 389 | at.__heading = 0 390 | at.heading_adjustment = 0 391 | at.running_scripts = {} 392 | at.callable = None 393 | view._scripter_at = at 394 | return at 395 | 396 | def _prop(attribute): 397 | p = property( 398 | lambda self: 399 | partial(At._getter, self, attribute)(), 400 | lambda self, value: 401 | partial(At._setter, self, attribute, value)() 402 | ) 403 | return p 404 | 405 | def _getter(self, attr_string): 406 | return At.Anchor(self, attr_string) 407 | 408 | def _setter(self, attr_string, value): 409 | if value is None: 410 | self._remove_anchor(attr_string) 411 | else: 412 | source_anchor = value 413 | source_anchor.set_target(self, attr_string) 414 | source_anchor.start_script() 415 | 416 | def _remove_anchor(self, attr_string): 417 | anchor = self.running_scripts.pop(attr_string, None) 418 | if anchor: 419 | cancel(anchor) 420 | 421 | @property 422 | def _heading(self): 423 | return self.__heading 424 | 425 | @_heading.setter 426 | def _heading(self, value): 427 | self.__heading = value 428 | self.view.transform = ui.Transform.rotation( 429 | value + self.heading_adjustment) 430 | 431 | # PUBLIC PROPERTIES 432 | 433 | left = _prop('left') 434 | right = _prop('right') 435 | top = _prop('top') 436 | bottom = _prop('bottom') 437 | center = _prop('center') 438 | center_x = _prop('center_x') 439 | center_y = _prop('center_y') 440 | width = _prop('width') 441 | height = _prop('height') 442 | position = _prop('position') 443 | size = _prop('size') 444 | frame = _prop('frame') 445 | bounds = _prop('bounds') 446 | heading = _prop('heading') 447 | fit_size = _prop('fit_size') 448 | fit_width = _prop('fit_width') 449 | fit_height = _prop('fit_height') 450 | 451 | 452 | # Direct access functions 453 | 454 | def at(view, func=None): 455 | a = At(view) 456 | a.callable = func 457 | return a 458 | 459 | def attr(data, func=None): 460 | at = At(data) 461 | at.callable = func 462 | for attr_name in dir(data): 463 | if (not attr_name.startswith('_') and 464 | not hasattr(At, attr_name) and 465 | inspect.isdatadescriptor( 466 | inspect.getattr_static(data, attr_name) 467 | )): 468 | setattr(At, attr_name, At._prop(attr_name)) 469 | return at 470 | 471 | # Helper functions 472 | 473 | def direction(target, source, value): 474 | """ 475 | Calculate the heading if given a center 476 | """ 477 | try: 478 | if len(value) == 2: 479 | source_center = ui.convert_point(value, source.superview) 480 | target_center = ui.convert_point(target.center, target.superview) 481 | delta = source_center - target_center 482 | value = math.atan2(delta.y, delta.x) 483 | except TypeError: 484 | pass 485 | return value 486 | 487 | def subview_bounds(view): 488 | subviews_accumulated = list(accumulate( 489 | [v.frame for v in view.subviews], 490 | ui.Rect.union)) 491 | if len(subviews_accumulated): 492 | bounds = subviews_accumulated[-1] 493 | else: 494 | bounds = ui.Rect(0, 0, 0, 0) 495 | return bounds.inset(-At.gap, -At.gap) 496 | 497 | def _parse_rules(rules): 498 | rule_dict = dict() 499 | dicts = [rule_dict] 500 | spaces = re.compile(' *') 501 | for i, line in enumerate(rules.splitlines()): 502 | i += 11 # Used to match error line number to my file 503 | if line.strip() == '': continue 504 | indent = len(spaces.match(line).group()) 505 | if indent % 4 != 0: 506 | raise RuntimeError(f'Broken indent on line {i}') 507 | indent = indent // 4 + 1 508 | if indent > len(dicts): 509 | raise RuntimeError(f'Extra indentation on line {i}') 510 | dicts = dicts[:indent] 511 | line = line.strip() 512 | if line.endswith(':'): 513 | key = line[:-1].strip() 514 | new_dict = dict() 515 | dicts[-1][key] = new_dict 516 | dicts.append(new_dict) 517 | else: 518 | try: 519 | key, content = line.split(':') 520 | dicts[-1][key.strip()] = content.strip() 521 | except Exception as error: 522 | raise RuntimeError(f'Cannot parse line {i}', error) 523 | return rule_dict 524 | 525 | _rules = _parse_rules(_anchor_rules_spec) 526 | 527 | 528 | class Dock: 529 | 530 | direction_map = { 531 | 'T': ('top', +1), 532 | 'L': ('left', +1), 533 | 'B': ('bottom', -1), 534 | 'R': ('right', -1), 535 | 'X': ('center_x', 0), 536 | 'Y': ('center_y', 0), 537 | 'C': ('center', 0), 538 | } 539 | 540 | def __init__(self, superview): 541 | self.superview = superview 542 | 543 | def _dock(self, directions, view, modifier=0): 544 | self.superview.add_subview(view) 545 | v = at(view) 546 | sv = at(self.superview) 547 | for direction in directions: 548 | prop, sign = self.direction_map[direction] 549 | if prop != 'center': 550 | setattr(v, prop, getattr(sv, prop) + sign * modifier) 551 | else: 552 | setattr(v, prop, getattr(sv, prop)) 553 | 554 | all = partialmethod(_dock, 'TLBR') 555 | bottom = partialmethod(_dock, 'LBR') 556 | top = partialmethod(_dock, 'TLR') 557 | right = partialmethod(_dock, 'TBR') 558 | left = partialmethod(_dock, 'TLB') 559 | top_left = partialmethod(_dock, 'TL') 560 | top_right = partialmethod(_dock, 'TR') 561 | bottom_left = partialmethod(_dock, 'BL') 562 | bottom_right = partialmethod(_dock, 'BR') 563 | sides = partialmethod(_dock, 'LR') 564 | vertical = partialmethod(_dock, 'TB') 565 | top_center = partialmethod(_dock, 'TX') 566 | bottom_center = partialmethod(_dock, 'BX') 567 | left_center = partialmethod(_dock, 'LY') 568 | right_center = partialmethod(_dock, 'RY') 569 | center = partialmethod(_dock, 'C') 570 | 571 | def between(self, top=None, bottom=None, left=None, right=None): 572 | a_self = at(self.view) 573 | if top: 574 | a_self.top = at(top).bottom 575 | if bottom: 576 | a_self.bottom = at(bottom).top 577 | if left: 578 | a_self.left = at(left).right 579 | if right: 580 | a_self.right = at(right).left 581 | if top or bottom: 582 | a = at(top or bottom) 583 | a_self.width = a.width 584 | a_self.center_x = a.center_x 585 | if left or right: 586 | a = at(left or right) 587 | a_self.height = a.height 588 | a_self.center_y = a.center_y 589 | 590 | def above(self, other): 591 | at(self.view).bottom = at(other).top 592 | align(self.view).center_x(other) 593 | 594 | def below(self, other): 595 | at(self.view).top = at(other).bottom 596 | at(other).center_x = at(self.view).center_x 597 | at(other).width = at(self.view).width 598 | #align(self.view).center_x(other) 599 | align(self.view).width(other) 600 | 601 | def to_the_left(self, other): 602 | at(self.view).right = at(other).left 603 | align(self.view).center_y(other) 604 | 605 | def to_the_right(self, other): 606 | at(self.view).left = at(other).right 607 | align(self.view).center_y(other) 608 | 609 | 610 | def dock(superview) -> Dock: 611 | return Dock(superview) 612 | 613 | 614 | class Align: 615 | 616 | modifiable = 'left right top bottom center_x center_y width height heading' 617 | 618 | def __init__(self, view, modifier=0): 619 | self.anchor_at = at(view) 620 | self.modifier = modifier 621 | 622 | def _align(self, prop, *others): 623 | use_modifier = prop in self.modifiable.split() 624 | for other in others: 625 | if use_modifier: 626 | setattr(at(other), prop, 627 | getattr(self.anchor_at, prop) + self.modifier) 628 | else: 629 | setattr(at(other), prop, getattr(self.anchor_at, prop)) 630 | 631 | left = partialmethod(_align, 'left') 632 | right = partialmethod(_align, 'right') 633 | top = partialmethod(_align, 'top') 634 | bottom = partialmethod(_align, 'bottom') 635 | center = partialmethod(_align, 'center') 636 | center_x = partialmethod(_align, 'center_x') 637 | center_y = partialmethod(_align, 'center_y') 638 | width = partialmethod(_align, 'width') 639 | height = partialmethod(_align, 'height') 640 | position = partialmethod(_align, 'position') 641 | size = partialmethod(_align, 'size') 642 | frame = partialmethod(_align, 'frame') 643 | bounds = partialmethod(_align, 'bounds') 644 | heading = partialmethod(_align, 'heading') 645 | 646 | def align(view, modifier=0): 647 | return Align(view, modifier) 648 | 649 | 650 | class Fill: 651 | 652 | def __init__(self, superview, count=1): 653 | self.superview = superview 654 | self.super_at = at(superview) 655 | self.count = count 656 | 657 | def _fill(self, corner, attr, opposite, center, side, other_side, size, other_size, 658 | *views): 659 | assert len(views) > 0, 'Give at least one view to fill with' 660 | first = views[0] 661 | getattr(dock(self.superview), corner)(first) 662 | gaps = At.gaps_for(self.count) 663 | per_count = math.ceil(len(views)/self.count) 664 | per_gaps = At.gaps_for(per_count) 665 | for i, view in enumerate(views[1:]): 666 | self.superview.add_subview(view) 667 | if (i + 1) % per_count != 0: 668 | setattr(at(view), attr, getattr(at(views[i]), opposite)) 669 | setattr(at(view), center, getattr(at(views[i]), center)) 670 | else: 671 | setattr(at(view), attr, getattr(self.super_at, attr)) 672 | setattr(at(view), side, getattr(at(views[i]), other_side)) 673 | for view in views: 674 | setattr(at(view), size, 675 | getattr(self.super_at, size) + ( 676 | lambda v: v / per_count - per_gaps 677 | ) 678 | ) 679 | setattr(at(view), other_size, 680 | getattr(self.super_at, other_size) + ( 681 | lambda v: v / self.count - gaps 682 | ) 683 | ) 684 | 685 | from_top = partialmethod(_fill, 'top_left', 686 | 'top', 'bottom', 'center_x', 687 | 'left', 'right', 688 | 'height', 'width') 689 | from_bottom = partialmethod(_fill, 'bottom_left', 690 | 'bottom', 'top', 'center_x', 691 | 'left', 'right', 692 | 'height', 'width') 693 | from_left = partialmethod(_fill, 'top_left', 694 | 'left', 'right', 'center_y', 695 | 'top', 'bottom', 696 | 'width', 'height') 697 | from_right = partialmethod(_fill, 'top_right', 698 | 'right', 'left', 'center_y', 699 | 'top', 'bottom', 700 | 'width', 'height') 701 | 702 | 703 | def fill(superview, count=1): 704 | return Fill(superview, count) 705 | 706 | 707 | class Flow: 708 | 709 | def __init__(self, superview): 710 | self.superview = superview 711 | self.super_at = at(superview) 712 | 713 | @script 714 | def _flow(self, corner, size, func, *views): 715 | yield 716 | assert len(views) > 0, 'Give at least one view for the flow' 717 | first = views[0] 718 | getattr(dock(self.superview), corner)(first) 719 | for i, view in enumerate(views[1:]): 720 | self.superview.add_subview(view) 721 | setattr(at(view), size, 722 | getattr(at(views[i]), size)) 723 | at(view).frame = at(views[i]).frame + func 724 | 725 | def _from_left(down, value, target): 726 | if value.max_x + target.width + 2 * At.gap > target.superview.width: 727 | return (At.gap, value.y + down * (target.height + At.gap), 728 | target.width, target.height) 729 | return (value.max_x + At.gap, value.y, target.width, target.height) 730 | 731 | from_top_left = partialmethod(_flow, 732 | 'top_left', 'height', 733 | partial(_from_left, 1)) 734 | from_bottom_left = partialmethod(_flow, 735 | 'bottom_left', 'height', 736 | partial(_from_left, -1)) 737 | 738 | def _from_right(down, value, target): 739 | if value.x - target.width - At.gap < At.gap: 740 | return (target.superview.width - target.width - At.gap, 741 | value.y + down * (target.height + At.gap), 742 | target.width, target.height) 743 | return (value.x - At.gap - target.width, value.y, 744 | target.width, target.height) 745 | 746 | from_top_right = partialmethod(_flow, 747 | 'top_right', 'height', partial(_from_right, 1)) 748 | from_bottom_right = partialmethod(_flow, 749 | 'bottom_right', 'height', partial(_from_right, -1)) 750 | 751 | def _from_top(right, value, target): 752 | if value.max_y + target.height + 2 * At.gap > target.superview.height: 753 | return (value.x + right * (target.width + At.gap), At.gap, 754 | target.width, target.height) 755 | return (value.x, value.max_y + At.gap, target.width, target.height) 756 | 757 | from_left_down = partialmethod(_flow, 758 | 'top_left', 'width', partial(_from_top, 1)) 759 | from_right_down = partialmethod(_flow, 760 | 'top_right', 'width', partial(_from_top, -1)) 761 | 762 | def _from_bottom(right, value, target): 763 | if value.y - target.height - At.gap < At.gap: 764 | return (value.x + right * (target.width + At.gap), 765 | target.superview.height - target.height - At.gap, 766 | target.width, target.height) 767 | return (value.x, value.y - target.height - At.gap, 768 | target.width, target.height) 769 | 770 | from_left_up = partialmethod(_flow, 771 | 'bottom_left', 'width', partial(_from_bottom, 1)) 772 | from_right_up = partialmethod(_flow, 773 | 'bottom_right', 'width', partial(_from_bottom, -1)) 774 | 775 | def flow(superview): 776 | return Flow(superview) 777 | 778 | def size_to_fit(view): 779 | view.size_to_fit() 780 | if type(view) is ui.Label: 781 | view.frame = view.frame.inset(-At.gap, -At.gap) 782 | if type(view) is ui.Button: 783 | view.frame = view.frame.inset(0, -At.gap) 784 | return view 785 | 786 | 787 | if __name__ == '__main__': 788 | 789 | 790 | class Marker(ui.View): 791 | 792 | def __init__(self, superview, image=None, radius=15): 793 | super().__init__( 794 | background_color='black', 795 | border_color='white', 796 | border_width=1, 797 | ) 798 | self.radius = radius 799 | self.width = self.height = 2 * radius 800 | self.corner_radius = self.width/2 801 | superview.add_subview(self) 802 | 803 | if image: 804 | iv = ui.ImageView( 805 | image=ui.Image(image), 806 | content_mode=ui.CONTENT_SCALE_ASPECT_FIT, 807 | frame=self.bounds, 808 | flex='WH', 809 | ) 810 | self.add_subview(iv) 811 | 812 | 813 | class Mover(Marker): 814 | 815 | def __init__(self, superview, **kwargs): 816 | super().__init__(superview, 817 | image='iow:arrow_expand_24', 818 | **kwargs) 819 | 820 | def touch_moved(self, t): 821 | self.center += t.location - t.prev_location 822 | 823 | v = ui.View( 824 | background_color='black', 825 | ) 826 | set_scripter_view(v) 827 | 828 | sv = ui.View() 829 | dock(v).all(sv, At.TIGHT) 830 | 831 | main_view = ui.Label( 832 | text='', 833 | text_color='white', 834 | alignment=ui.ALIGN_RIGHT, 835 | border_color='white', 836 | border_width=1, 837 | frame=sv.bounds, 838 | flex='WH', 839 | touch_enabled=True, 840 | ) 841 | 842 | menu_view = ui.Label( 843 | text='MENU', 844 | text_color='white', 845 | alignment=ui.ALIGN_CENTER, 846 | border_color='white', 847 | border_width=1, 848 | background_color='grey', 849 | frame=sv.bounds, 850 | width=300, 851 | flex='H' 852 | ) 853 | 854 | sv.add_subview(menu_view) 855 | sv.add_subview(main_view) 856 | 857 | def open_and_close(sender): 858 | 859 | x( 860 | main_view, 861 | 0 if main_view.x > 0 else menu_view.width, 862 | ease_func=ease_in_out, 863 | duration=1, 864 | ) 865 | 866 | menu_button = ui.Button( 867 | image=ui.Image('iow:drag_32'), 868 | tint_color='white', 869 | action=open_and_close) 870 | dock(main_view).top_left(menu_button) 871 | 872 | At(menu_view).right = At(main_view).left 873 | mv = At(main_view) 874 | 875 | bottom_bar = ui.View( 876 | background_color='grey', 877 | ) 878 | dock(main_view).bottom(bottom_bar) 879 | 880 | pointer = ui.ImageView( 881 | image=ui.Image('iow:ios7_navigate_outline_256'), 882 | width=36, 883 | height=36, 884 | content_mode=ui.CONTENT_SCALE_ASPECT_FIT, 885 | ) 886 | 887 | dock(main_view).center(pointer) 888 | at(pointer).heading_adjustment = math.pi / 4 889 | 890 | mover = Mover(main_view) 891 | mover.center = (100,100) 892 | 893 | stretcher = ui.View( 894 | background_color='grey', 895 | height=30 896 | ) 897 | main_view.add_subview(stretcher) 898 | at(stretcher).left = at(mover).center_x 899 | at(stretcher).center_y = at(main_view).height * 4/6 900 | at(stretcher).right = at(main_view).right 901 | 902 | 903 | l = ui.Label(text='000', 904 | font=('Anonymous Pro', 12), 905 | text_color='white', 906 | alignment=ui.ALIGN_CENTER, 907 | ) 908 | l.size_to_fit() 909 | main_view.add_subview(l) 910 | at(l).center_x = at(pointer).center_x 911 | at(l).top = at(pointer).center_y + 25 912 | 913 | attr(l).text = at(pointer).heading + ( 914 | lambda angle: f"{int(math.degrees(angle))%360:03}" 915 | ) 916 | 917 | v.present('fullscreen', 918 | animated=False, 919 | hide_title_bar=True, 920 | ) 921 | 922 | at(pointer).heading = at(mover).center 923 | 924 | -------------------------------------------------------------------------------- /scripter 2.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | ''' 4 | # _SCRIPTER_ - Pythonista UI and Scene animations 5 | 6 | ![Logo](https://raw.githubusercontent.com/mikaelho/scripter/master/logo.jpg) 7 | 8 | # Quick start 9 | 10 | In order to start using the animation effects, just import scripter and call 11 | the effects as functions: 12 | 13 | from scripter import * 14 | 15 | hide(my_button) 16 | 17 | Effects expect an active UI view as the first argument. Effects run for a 18 | default duration of 0.5 seconds, unless otherwise specified with a `duration` 19 | argument. 20 | 21 | If you want to create a more complex animation from the effects provided, 22 | combine them in a script: 23 | 24 | @script 25 | def my_effect(view): 26 | move(view, 50, 200) 27 | pulse(view, 'red') 28 | yield 29 | hide(view, duration=2.0) 30 | 31 | Scripts control the order of execution with `yield` statements. Here movement 32 | and a red pulsing highlight happen at the same time. After both actions are 33 | completed, view fades away slowly, in 2 seconds. 34 | 35 | As the view provided as the first argument can of course be `self` or `sender`, 36 | scripts fit naturally as custom `ui.View` methods or `action` functions. 37 | 38 | As small delays are often needed for natural-feeling animations, you can append 39 | a number after a `yield` statement, to suspend the execution of the script for 40 | that duration, or `yield 'wait'` for the default duration. 41 | 42 | Another key for good animations is the use of easing functions that modify how 43 | the value progresses from starting value to the target value. Easing functions 44 | support creating different kinds of accelerating, bouncing and springy effects. 45 | Easing functions can be added as an argument to scripts: 46 | 47 | slide_value(view, 'x', 200, ease_func=bounce_out) 48 | 49 | See this 50 | [reference](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-funcs.jpg) 51 | to pick the right function, or run `scripter-demo.py` to try out the available 52 | effects and to find the optimal duration and easing function combo for your purposes. 53 | 54 | You can change the default speed of all animations by setting 55 | `Scripter.default_duration`. 56 | 57 | Scripter can also be used to animate different kinds of Pythonista `scene` 58 | module Nodes, including the Scene itself. Scripter provides roughly the same 59 | functionality as `scene.Action`, but is maybe a bit more concise, and is 60 | available as an option if you want to use same syntax in both UI and Scene 61 | projects. 62 | 63 | See the API documentation for individual effects and how to roll your own with 64 | `set_value`, `slide_value` and `timer`. 65 | 66 | There are also convenience functions, not separately documented, corresponding 67 | to all animatable attributes of ui views. For example, you can animate the 68 | `ui.View.background_color` attribute with: 69 | 70 | background_color(view, 'black') 71 | ''' 72 | 73 | from ui import * 74 | from scene import Node, Scene 75 | import scene_drawing 76 | import objc_util, ctypes 77 | 78 | from types import GeneratorType, SimpleNamespace 79 | from numbers import Number 80 | from functools import partial, wraps 81 | from contextlib import contextmanager 82 | import time, math 83 | 84 | #docgen: Script management 85 | 86 | def script(func): 87 | ''' 88 | _Can be used with Scene Nodes._ 89 | 90 | Decorator for the animation scripts. Scripts can be functions, methods or generators. 91 | 92 | First argument of decorated functions must always be the view to be animated. 93 | 94 | Calling a script starts the Scripter `update` loop, if not already running. 95 | 96 | New scripts suspend the execution of the parent script until all the parallel 97 | scripts have completed, after which the `update` method will resume the 98 | execution of the parent script. 99 | ''' 100 | @wraps(func) 101 | def wrapper(view, *args, **kwargs): 102 | gen = func(view, *args, **kwargs) 103 | if not isinstance(gen, GeneratorType): 104 | return gen 105 | scr = find_scripter_instance(view) 106 | scr.view_for_gen[gen] = view 107 | scr.parent_gens[gen] = scr.current_gen 108 | if scr.current_gen != 'root': 109 | scr.standby_gens.setdefault(scr.current_gen, set()) 110 | scr.standby_gens[scr.current_gen].add(gen) 111 | scr.deactivate.add(scr.current_gen) 112 | scr.activate.add(gen) 113 | scr.update_interval = scr.default_update_interval 114 | scr.running = True 115 | 116 | return gen 117 | 118 | return wrapper 119 | 120 | def isfinished(gen): 121 | ''' Returns True if the generator argument has been exhausted, i.e. 122 | script has run to the end. ''' 123 | return gen.gi_frame is None 124 | 125 | def isnode(view): 126 | ''' Returns True if argument is an instance of a subclass of scene.Node. ''' 127 | return issubclass(type(view), Node) 128 | 129 | def find_scripter_instance(view): 130 | ''' 131 | _Can be used with Scene Nodes._ 132 | 133 | Scripts need a "controller" ui.View that runs the update method for them. This 134 | function finds or creates the controller for a view as follows: 135 | 136 | 1. Check if the view itself is a Scripter 137 | 2. Check if any of the subviews is a Scripter 138 | 3. Repeat 1 and 2 up the view hierarchy of superviews 139 | 4. If not found, create an instance of Scripter as a hidden subview of the root view 140 | 141 | In case of scene Nodes, search starts from `node.scene.view`. 142 | 143 | If you want cancel or pause scripts, and have not explicitly created a 144 | Scripter instance to run them, you need to use this method first to find the 145 | right one. 146 | ''' 147 | 148 | if isnode(view): 149 | if hasattr(view, 'view'): 150 | view = view.view # Scene root node 151 | else: 152 | if view.scene is None: 153 | raise ValueError('Node must be added to a Scene before animations') 154 | view = view.scene.view 155 | 156 | while view: 157 | if isinstance(view, Scripter): 158 | return view 159 | for subview in view.subviews: 160 | if isinstance(subview, Scripter): 161 | return subview 162 | parent = view.superview 163 | if parent: 164 | view = parent 165 | else: 166 | break 167 | # If not found, create a new one as a hidden 168 | # subview of the root view 169 | scr = Scripter(hidden=True) 170 | view.add_subview(scr) 171 | return scr 172 | 173 | def find_root_view(): 174 | ''' Locates the first `present`ed view. ''' 175 | root_objc_view = objc_util.UIApplication.sharedApplication().windows()[0] 176 | for _ in range(5): 177 | root_objc_view = root_objc_view.subviews()[0] 178 | view = root_objc_view.pyObject(argtypes=[], restype=ctypes.py_object) 179 | return view 180 | 181 | class Scripter(View): 182 | 183 | ''' 184 | Class that contains the `update` method used to run the scripts and to control their execution. 185 | 186 | Runs at default 60 fps, or not at all when there are no scripts to run. 187 | 188 | Inherits from ui.View; constructor takes all the same arguments as ui.View. 189 | ''' 190 | 191 | global_default_update_interval = 1/60 192 | default_duration = 0.5 193 | 194 | def __init__(self, *args, **kwargs): 195 | super().__init__(self, *args, **kwargs) 196 | self.default_update_interval = Scripter.global_default_update_interval 197 | self.cancel_all() 198 | self.running = False 199 | self.time_paused = 0 200 | 201 | @property 202 | def default_update_interval(self): 203 | ''' 204 | The running rate for the update method. Frames per second is here considered to be just an 205 | alternative way of setting the update interval, and this property is linked to 206 | `default_fps` - change one and the other will change as well. 207 | ''' 208 | return self._default_update_interval 209 | 210 | @default_update_interval.setter 211 | def default_update_interval(self, value): 212 | self._default_update_interval = value 213 | self._default_fps = 1/value 214 | 215 | @property 216 | def default_fps(self): 217 | ''' 218 | The running rate for the update method. Frames per second is here considered to be just an 219 | alternative way of setting the update interval, and this property is linked to 220 | `default_update_interval` - change one and the other will change as well. 221 | ''' 222 | return self._default_fps 223 | 224 | @default_fps.setter 225 | def default_fps(self, value): 226 | self._default_fps = value 227 | self._default_update_interval = 1/value 228 | 229 | def update(self): 230 | ''' 231 | Main Scripter animation loop handler, called by the Puthonista UI loop and never by your 232 | code directly. 233 | 234 | This method: 235 | 236 | * Activates all newly called scripts and suspends their parents. 237 | * Calls all active scripts, which will run to their next `yield` or until completion. 238 | * As a convenience feature, if a `yield` returns `'wait'` or a specific duration, 239 | kicks off a child `timer` script to wait for that period of time. 240 | * Cleans out completed scripts. 241 | * Resumes parent scripts whose children have all completed. 242 | * Sets `update_interval` to 0 if all scripts have completed. 243 | ''' 244 | run_at_least_once = True 245 | while run_at_least_once or len(self.activate) > 0 or len(self.deactivate) > 0: 246 | run_at_least_once = False 247 | for script in self.cancel_queue: 248 | self._process_cancel(script) 249 | self.cancel_queue = set() 250 | for gen in self.activate: 251 | self.active_gens.add(gen) 252 | for gen in self.deactivate: 253 | self.active_gens.remove(gen) 254 | self.activate = set() 255 | self.deactivate = set() 256 | gen_to_end = [] 257 | for gen in self.active_gens: 258 | self.current_gen = gen 259 | wait_time = self.should_wait.pop(gen, None) 260 | if wait_time is not None: 261 | timer(self.view_for_gen[gen], wait_time) 262 | else: 263 | wait_time = None 264 | try: 265 | wait_time = next(gen) 266 | except StopIteration: 267 | if gen not in self.deactivate: 268 | gen_to_end.append(gen) 269 | if wait_time is not None: 270 | if wait_time == 'wait': 271 | wait_time = self.default_duration 272 | if isinstance(wait_time, Number): 273 | self.should_wait[gen] = wait_time 274 | self.current_gen = 'root' 275 | self.time_paused = 0 276 | for gen in gen_to_end: 277 | self.active_gens.remove(gen) 278 | parent_gen = self.parent_gens[gen] 279 | del self.parent_gens[gen] 280 | if parent_gen != 'root': 281 | self.standby_gens[parent_gen].remove(gen) 282 | if len(self.standby_gens[parent_gen]) == 0: 283 | self.activate.add(parent_gen) 284 | del self.standby_gens[parent_gen] 285 | if len(self.active_gens) == 0: 286 | self.update_interval = 0.0 287 | self.running = False 288 | 289 | def pause_play_all(self): 290 | ''' Pause or play all animations. ''' 291 | self.update_interval = 0 if self.update_interval > 0 else self.default_update_interval 292 | self.running = self.update_interval > 0 293 | if not self.running: 294 | self.pause_start_time = time.time() 295 | else: 296 | self.time_paused = time.time() - self.pause_start_time 297 | 298 | def cancel(self, script): 299 | ''' Cancels any ongoing animations and 300 | sub-scripts for the given script. ''' 301 | self.cancel_queue.add(script) 302 | 303 | def _process_cancel(self, script): 304 | to_cancel = set() 305 | to_cancel.add(script) 306 | parent_gen = self.parent_gens[script] 307 | if parent_gen != 'root': 308 | self.standby_gens[parent_gen].remove(script) 309 | if len(self.standby_gens[parent_gen]) == 0: 310 | self.active_gens.add(parent_gen) 311 | del self.standby_gens[parent_gen] 312 | found_new = True 313 | while found_new: 314 | new_found = set() 315 | found_new = False 316 | for gen in to_cancel: 317 | if gen in self.standby_gens: 318 | for child_gen in self.standby_gens[gen]: 319 | if child_gen not in to_cancel: 320 | new_found.add(child_gen) 321 | found_new = True 322 | for gen in new_found: 323 | to_cancel.add(gen) 324 | 325 | for gen in to_cancel: 326 | if gen == self.current_gen: 327 | self.currrent_gen = parent_gen 328 | del self.view_for_gen[gen] 329 | del self.parent_gens[gen] 330 | self.activate.discard(gen) 331 | self.deactivate.discard(gen) 332 | self.active_gens.discard(gen) 333 | if gen in self.standby_gens: 334 | del self.standby_gens[gen] 335 | 336 | def cancel_all(self): 337 | ''' Initializes all internal structures. 338 | Used at start and to cancel all running scripts. 339 | ''' 340 | self.current_gen = 'root' 341 | self.view_for_gen = {} 342 | self.should_wait = {} 343 | self.parent_gens = {} 344 | self.active_gens = set() 345 | self.standby_gens = {} 346 | self.activate = set() 347 | self.deactivate = set() 348 | self.running = False 349 | self.cancel_queue = set() 350 | 351 | @staticmethod 352 | def _cubic(params, t): 353 | ''' 354 | Cubic function for easing animations. 355 | 356 | Arguments: 357 | 358 | * params - either a 4-tuple of cubic parameters, one of the parameter names below (like ’easeIn') or 'linear' for a straight line 359 | * t - time running from 0 to 1 360 | ''' 361 | ease_func_params = { 362 | 'easeIn': (0, 0.05, 0.25, 1), 363 | 'easeOut': (0, 0.75, 0.95, 1), 364 | 'easeInOut': (0, 0.05, 0.95, 1), 365 | 'easeOutIn': (0, 0.75, 0.25, 1), 366 | 'easeInBounce': (0, -0.5, 0.25, 1), 367 | 'easeOutBounce': (0, 0.75, 1.5, 1), 368 | 'easeInOutBounce': (0, -0.5, 1.5, 1) 369 | } 370 | 371 | if isinstance(params, str): 372 | if params == 'linear': 373 | return t 374 | try: 375 | u = ease_func_params[params] 376 | except KeyError: 377 | raise ValueError('Easing function name must be one of the following: ' + ', '.join(list(ease_func_params))) 378 | else: 379 | u = params 380 | return u[0]*(1-t)**3 + 3*u[1]*(1-t)**2*t + 3*u[2]*(1-t)*t**2 + u[3]*t**3 381 | 382 | def cancel(view, handle): 383 | scr = find_scripter_instance(view) 384 | scr.cancel(handle) 385 | 386 | #docgen: Animation primitives 387 | 388 | @script 389 | def set_value(view, attribute, value, func=None): 390 | ''' 391 | Generator that sets the `attribute` to a `value` once, or several times if the value itself is a 392 | generator or an iterator. 393 | 394 | Optional keyword parameters: 395 | 396 | * `func` - called with the value, returns the actual value to be set 397 | ''' 398 | 399 | func = func if callable(func) else lambda val: val 400 | if isinstance(value, GeneratorType): 401 | while True: 402 | setattr(view, attribute, func(next(value))) 403 | yield 404 | elif hasattr(value, '__iter__') and not isinstance(value, str): 405 | iterator = iter(value) 406 | for value in iterator: 407 | setattr(view, attribute, func(value)) 408 | yield 409 | else: 410 | setattr(view, attribute, func(value)) 411 | 412 | @script 413 | def slide_value(view, attribute, end_value, target=None, start_value=None, duration=None, delta_func=None, ease_func=None, current_func=None, map_func=None, side_func=None): 414 | ''' 415 | Generator that "slides" the `value` of an 416 | `attribute` to an `end_value` in a given duration. 417 | 418 | Optional keyword parameters: 419 | 420 | * `start_value` - set if you want some other value than the current value of the attribute as the animation start value. 421 | * `duration` - time it takes to change to the target value. Default is 0.5 seconds. 422 | * `delta_func` - use to transform the range from start_value to end_value to something else. 423 | * `ease_func` - provide to change delta-t value to something else. Mostly used for easing; you can provide an easing function name as a string instead of an actual function. See supported easing functions [here](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-functions.png). 424 | * `current_func` - Given the start value, delta value and progress fraction (from 0 to 1), returns the current value. Intended to be used to manage more exotic values like colors. 425 | * `map_func` - Used to translate the current value to something else, e.g. an angle to a Transform.rotation. 426 | * `side_func` - Called without arguments each time after the main value has been set. Useful for side effects. 427 | ''' 428 | scr = find_scripter_instance(view) 429 | duration = duration or scr.default_duration 430 | start_value = start_value if start_value is not None else getattr(view, attribute) 431 | 432 | delta_func = delta_func if callable(delta_func) else lambda start_value, end_value: end_value - start_value 433 | map_func = map_func if callable(map_func) else lambda val: val 434 | if isinstance(ease_func, str) or isinstance(ease_func, tuple): 435 | ease_func = partial(Scripter._cubic, ease_func) 436 | else: 437 | ease_func = ease_func if callable(ease_func) else lambda val: val 438 | current_func = current_func if callable(current_func) else lambda start_value, t_fraction, delta_value: start_value + t_fraction * delta_value 439 | 440 | delta_value = delta_func(start_value, end_value) 441 | start_time = time.time() 442 | dt = 0 443 | scaling = True 444 | while scaling: 445 | if dt < duration: 446 | t_fraction = ease_func(dt/duration) 447 | #print(ease_func.__name__, t_fraction) 448 | else: 449 | t_fraction = ease_func(1) 450 | scaling = False 451 | current_value = current_func(start_value, t_fraction, delta_value) 452 | setattr(view, attribute, map_func(current_value)) 453 | if side_func: side_func() 454 | yield 455 | if scr.time_paused > 0: 456 | start_time += scr.time_paused 457 | dt = time.time() - start_time 458 | 459 | @script 460 | def slide_tuple(view, *args, **kwargs): 461 | ''' 462 | Slide a tuple value of arbitrary length. Supports same arguments as `slide_value`. ''' 463 | def delta_func_for_tuple(start_value, end_value): 464 | return tuple((end_value[i] - start_value[i] for i in range(len(start_value)))) 465 | def current_func_for_tuple(start_value, t_fraction, delta_value): 466 | return tuple((start_value[i] + t_fraction * delta_value[i] for i in range(len(start_value)))) 467 | 468 | delta_func = delta_func_for_tuple if 'delta_func' not in kwargs else kwargs['delta_func'] 469 | current_func = current_func_for_tuple if 'current_func' not in kwargs else kwargs['current_func'] 470 | 471 | return slide_value(view, *args, **kwargs, delta_func=delta_func, current_func=current_func) 472 | 473 | @script 474 | def slide_color(view, attribute, end_value, **kwargs): 475 | ''' Slide a color value. Supports the same 476 | arguments as `slide_value`. ''' 477 | start_value = kwargs.pop('start_value', None) 478 | if start_value: 479 | start_value = parse_color(start_value) 480 | end_value = parse_color(end_value) 481 | 482 | return slide_tuple(view, attribute, end_value, start_value=start_value, **kwargs) 483 | 484 | @script 485 | def timer(view, duration=None, action=None): 486 | ''' Acts as a wait timer for the given duration in seconds. `view` is only used to find the 487 | controlling Scripter instance. Optional action function is called every cycle. ''' 488 | 489 | scr = find_scripter_instance(view) 490 | duration = duration or scr.default_duration 491 | start_time = time.time() 492 | dt = 0 493 | while dt < duration: 494 | if action: action() 495 | yield 496 | if scr.time_paused > 0: 497 | start_time += scr.time_paused 498 | dt = time.time() - start_time 499 | 500 | 501 | #docgen: Animation effects 502 | 503 | @script 504 | def center(view, move_center_to, **kwargs): 505 | ''' Move view center (anchor for Scene Nodes). ''' 506 | attr = 'position' if isnode(view) else 'center' 507 | return slide_tuple(view, attr, move_center_to, **kwargs) 508 | 509 | @script 510 | def center_to(view, move_center_to, **kwargs): 511 | ''' Alias for `center`. ''' 512 | return center(view, move_center_to, **kwargs) 513 | 514 | @script 515 | def center_by(view, dx, dy, **kwargs): 516 | ''' Adjust view center/anchor position by dx, dy. ''' 517 | cx, cy = view.position if isnode(view) else view.center 518 | return center(view, (cx + dx, cy + dy), **kwargs) 519 | 520 | @script 521 | def expand(view, **kwargs): 522 | ''' _Not applicable for Scene Nodes._ 523 | 524 | Expands the view to fill all of its superview. ''' 525 | move(view, 0, 0, **kwargs) 526 | slide_value(view, 'width', view.superview.width, **kwargs) 527 | slide_value(view, 'height', view.superview.height, **kwargs) 528 | yield 529 | 530 | @script 531 | def fly_out(view, direction, **kwargs): 532 | ''' Moves the view out of the screen in the given direction. Direction is one of the 533 | following strings: 'up', 'down', 'left', 'right'. ''' 534 | 535 | (sw,sh) = get_screen_size() 536 | if isnode(view): 537 | (lx, ly, lw, lh) = view.scene.bounds 538 | attr = 'position' 539 | if direction == 'up': 540 | direction = 'down' 541 | elif direction == 'down': 542 | direction = 'up' 543 | else: 544 | (lx, ly, lw, lh) = convert_rect(rect=(0,0,sw,sh), to_view=view) 545 | attr = 'center' 546 | (x,y,w,h) = view.frame 547 | (cx, cy) = getattr(view, attr) 548 | targets = { 'up': (cx,ly-h), 'down': (cx,lh+h), 'left': (lx-w,cy), 'right': (lw+w,cy) } 549 | try: 550 | target_coord = targets[direction] 551 | except KeyError: 552 | raise ValueError('Direction must be one of ' + str(list(targets.keys()))) 553 | return slide_tuple(view, attr, target_coord, **kwargs) 554 | 555 | def future(future): 556 | ''' yields until the given future is completed. ''' 557 | @script 558 | def wait_for_done(view, future): 559 | while not future.done(): 560 | yield 561 | if future.exception() is not None: 562 | raise future.exception() 563 | return wait_for_done(find_root_view(), future) 564 | 565 | @script 566 | def hide(view, **kwargs): 567 | ''' Fade the view away. ''' 568 | return slide_value(view, 'alpha', 0.0, **kwargs) 569 | 570 | @script 571 | def move(view, x, y, **kwargs): 572 | ''' Move to x, y. 573 | For UI views, this positions the top-left corner. 574 | For Scene Nodes, this moves the Node `position`. ''' 575 | if isnode(view): 576 | slide_tuple(view, 'position', (x,y), **kwargs) 577 | else: 578 | slide_value(view, 'x', x, **kwargs) 579 | slide_value(view, 'y', y, **kwargs) 580 | yield 581 | 582 | @script 583 | def move_to(view, x, y, **kwargs): 584 | ''' Alias for `move`. ''' 585 | return move(view, x, y, **kwargs) 586 | 587 | @script 588 | def move_by(view, dx, dy, **kwargs): 589 | ''' Adjust position by dx, dy. ''' 590 | if isnode(view): 591 | slide_tuple(view, 'position', (view.position.x+dx, view.position.y+dy), **kwargs) 592 | else: 593 | slide_value(view, 'x', view.x + dx, **kwargs) 594 | slide_value(view, 'y', view.y + dy, **kwargs) 595 | yield 596 | 597 | @script 598 | def pulse(view, color='#67cf70', **kwargs): 599 | ''' Pulses the background of the view to the given color and back to the original color. 600 | Default color is a shade of green. ''' 601 | root_func = kwargs.pop('ease_func', ease_in) 602 | ease_func = partial(mirror, root_func) 603 | return slide_color(view, 'background_color', color, ease_func=ease_func, **kwargs) 604 | 605 | @script 606 | def reveal_text(view, **kwargs): 607 | ''' Reveals text one letter at a time in the given duration. View must have a `text` attribute. ''' 608 | full_text = view.text 609 | 610 | return slide_value(view, 'text', len(full_text), start_value=0, map_func=lambda value: full_text[:max(0, min(len(full_text), round(value)))], **kwargs) 611 | 612 | @script 613 | def roll_to(view, to_center, end_right_side_up=True, **kwargs): 614 | ''' Roll the view to a target position given by the `to_center` tuple. If `end_right_side_up` is true, view starting angle is adjusted so that the view will end up with 0 rotation at the end, otherwise the view will start as-is, and end in an angle determined by the roll. 615 | View should be round for the rolling effect to make sense. Imaginary rolling surface is below the view - or to the left if rolling directly downwards. ''' 616 | from_center = view.position if isnode(view) else view.center 617 | roll_vector = Vector(to_center)-Vector(from_center) 618 | roll_direction = 1 if roll_vector.x >= 0 else -1 619 | roll_distance = roll_vector.magnitude 620 | view_r = view.frame[0]/2 621 | roll_degrees = roll_direction * 360 * roll_distance/(2*math.pi*view_r) 622 | if end_right_side_up: 623 | start_degrees = roll_direction * (360 - abs(roll_degrees) % 360) 624 | start_radians = math.radians(start_degrees) 625 | if isnode(view): 626 | view.rotation = start_radians 627 | else: 628 | view.transform = Transform.rotation(start_radians) 629 | rotate_by(view, roll_degrees, **kwargs) 630 | center(view, to_center, **kwargs) 631 | yield 632 | 633 | @script 634 | def rotate(view, degrees, shortest=False, **kwargs): 635 | ''' Rotate view to an absolute angle. Set start_value if not starting from 0. Positive number rotates clockwise. For UI views, does not mix with other transformations. 636 | 637 | Optional arguments: 638 | 639 | * `shortest` - If set to True (default), will turn in the "right" direction. For UI views, start_value must be set to a sensible value for this to work. 640 | ''' 641 | start_value = kwargs.pop('start_value', math.degrees(view.rotation) if isnode(view) else 0) 642 | radians = math.radians(degrees) 643 | start_radians = math.radians(start_value) 644 | if shortest: 645 | degrees = math.degrees(math.atan2(math.sin(radians-start_radians), math.cos(radians-start_radians))) 646 | rotate_by(view, degrees) 647 | else: 648 | if isnode(view): 649 | return slide_value(view, 'rotation', radians, start_value=start_radians, **kwargs) 650 | else: 651 | return slide_value(view, 'transform', radians, start_value=start_radians, map_func=lambda r: Transform.rotation(r), **kwargs) 652 | 653 | def rotate_to(view, degrees, **kwargs): 654 | ''' Alias for `rotate`. ''' 655 | return rotate(view, degrees, **kwargs) 656 | 657 | @script 658 | def rotate_by(view, degrees, **kwargs): 659 | ''' Rotate view by given degrees. ''' 660 | radians = math.radians(degrees) 661 | if isnode(view): 662 | return slide_value(view, 'rotation', view.rotation+radians, start_value=view.rotation, **kwargs) 663 | else: 664 | starting_transform = view.transform 665 | return slide_value(view, 'transform', radians, start_value=0, map_func=lambda r: Transform.rotation(r) if not starting_transform else starting_transform.concat(Transform.rotation(r)), **kwargs) 666 | 667 | @script 668 | def scale(view, factor, **kwargs): 669 | ''' Scale view to a given factor in both x and y dimensions. For UI views, you need to explicitly set `start_value` if not starting from 1. ''' 670 | if isnode(view): 671 | start_value = kwargs.pop('start_value', view.x_scale) 672 | slide_value(view, 'x_scale', factor, start_value=start_value, **kwargs) 673 | slide_value(view, 'y_scale', factor, start_value=start_value, **kwargs) 674 | else: 675 | start_value = kwargs.pop('start_value', 1) 676 | slide_value(view, 'transform', factor, start_value=start_value, map_func=lambda r: Transform.scale(r, r), **kwargs) 677 | yield 678 | 679 | def scale_to(view, factor, **kwargs): 680 | ''' Alias for `scale`. ''' 681 | return scale(view, factor, **kwargs) 682 | 683 | @script 684 | def scale_by(view, factor, **kwargs): 685 | ''' Scale view relative to current scale factor. ''' 686 | if isnode(view): 687 | start_value = kwargs.pop('start_value', view.x_scale) 688 | end_value = start_value * factor 689 | slide_value(view, 'x_scale', end_value, start_value=start_value, **kwargs) 690 | slide_value(view, 'y_scale', end_value, start_value=start_value, **kwargs) 691 | else: 692 | start_value = kwargs.pop('start_value', 1) 693 | starting_transform = view.transform 694 | slide_value(view, 'transform', factor, start_value=start_value, map_func=lambda r: Transform.scale(r, r) if not starting_transform else starting_transform.concat(Transform.scale(r, r)), **kwargs) 695 | yield 696 | 697 | @script 698 | def show(view, **kwargs): 699 | ''' Slide alpha from 0 to 1. ''' 700 | view.alpha = 0.0 701 | return slide_value(view, 'alpha', 1.0, **kwargs) 702 | 703 | @script 704 | def wobble(view): 705 | ''' Little wobble of a view, intended to attract attention. ''' 706 | return rotate(view, 10, shortest=False, duration=0.3, ease_func=oscillate) 707 | 708 | @script 709 | def wait_for_tap(view): 710 | ''' Overlays the given view with a temporary transparent view, and 711 | yields until the view is tapped. ''' 712 | 713 | class WaitForTap(View): 714 | 715 | def __init__(self, target, **kwargs): 716 | super().__init__(**kwargs) 717 | self.tapped = False 718 | self.background_color = (0,0,0,0.0001) 719 | self.frame=target.bounds 720 | target.add_subview(self) 721 | 722 | def touch_ended(self, touch): 723 | self.tapped = True 724 | 725 | t = WaitForTap(view) 726 | while not t.tapped: 727 | yield 728 | 729 | # Generate convenience functions 730 | 731 | animation_attributes = ( 732 | (slide_value, ( 733 | 'alpha', 734 | 'corner_radius', 735 | 'height', 736 | 'row_height', 737 | 'value', 738 | 'width', 739 | 'x', 740 | 'y', 741 | )), 742 | (slide_color, ( 743 | 'background_color', 744 | 'bar_tint_color', 745 | 'border_color', 746 | 'text_color', 747 | 'tint_color', 748 | 'title_color', 749 | )), 750 | (slide_tuple, ( 751 | 'bounds', 752 | 'content_inset', 753 | 'content_offset', 754 | 'content_size', 755 | 'frame', 756 | 'scroll_indicator_insets', 757 | 'selected_range', 758 | )) 759 | ) 760 | 761 | for animation_function, keys in animation_attributes: 762 | for key in keys: 763 | def f(func, attr, view, value, **kwargs): 764 | func(view, attr, value, **kwargs) 765 | globals()[key] = partial(f, animation_function, key) 766 | 767 | def while_not_finished(gen, scr, view, *args, **kwargs): 768 | ''' While `gen` is not finished, calls `scr` repeatedly with `view` and other 769 | arguments. ''' 770 | @script 771 | def waiting(view, gen, scr, *args, **kwargs): 772 | while not isfinished(gen): 773 | scr(view, *args, **kwargs) 774 | yield 775 | waiting(view, gen, scr, *args, **kwargs) 776 | 777 | 778 | class ScrollingBannerLabel(View): 779 | ''' UI component that scrolls the given text indefinitely, in either direction. Will only scroll if the text is too long to fit into this component. 780 | ''' 781 | 782 | def __init__(self, **kwargs): 783 | ''' In addition to normal `ui.View` arguments, you can include: 784 | 785 | * `text` - To be scrolled as a marquee. 786 | * Label formatting arguments `font` and `text_color`. 787 | * `initial_delay` - How long we wait before we start scrolling, to enable reading the beginning of the text. Default is 2 seconds. 788 | * `scrolling_speed` - How fast the text moves, in points per second. Default is 100 pts/s. 789 | * `to_right` - Set to True if you would like the text to scroll from the left. Default is False. 790 | ''' 791 | 792 | self.kwargs = {} 793 | self.scrolling_speed = kwargs.pop('scrolling_speed', 100) 794 | self.initial_delay = kwargs.pop('initial_delay', 2.0) 795 | self._direction = -1 if kwargs.pop('to_right', False) else 1 796 | if 'font' in kwargs: self.kwargs['font'] = kwargs.pop('font') 797 | if 'text_color' in kwargs: self.kwargs['text_color'] = kwargs.pop('text_color') 798 | 799 | super().__init__(**kwargs) 800 | 801 | self.text = kwargs.pop('text', None) 802 | 803 | @property 804 | def text(self): 805 | ''' You can change the text displayed at 806 | any point after initialization by setting 807 | this property. ''' 808 | return self._text 809 | 810 | @text.setter 811 | def text(self, value): 812 | if value is not None and len(value) > 0: 813 | value = value.strip() + ' ' 814 | self._text = value 815 | self._create_labels() 816 | 817 | def stop(self): 818 | ''' Stops the scrolling and places the text at start. ''' 819 | scr = find_scripter_instance(self) 820 | scr.cancel(self._scroller) 821 | self.bounds = (0, 0, self.width, self.height) 822 | 823 | def restart(self): 824 | ''' Restarts the scrolling, including the initial delay, if any. ''' 825 | self.stop() 826 | self._create_labels() 827 | 828 | def _create_labels(self): 829 | for view in self.subviews: 830 | self.remove_subview(view) 831 | self._text_width = 0 832 | self._total_width = 0 833 | if self._text is None or len(self._text) == 0: return 834 | 835 | l = Label(text=self._text, **self.kwargs) 836 | l.size_to_fit() 837 | l.x, l.y = 0, 0 838 | self.add_subview(l) 839 | self._text_width = self._total_width = l.width 840 | if self._total_width < self.width: 841 | return 842 | 843 | l = Label(text=self._text, **self.kwargs) 844 | l.size_to_fit() 845 | l.x, l.y = self._direction * self._total_width, 0 846 | self._total_width += l.width 847 | self.add_subview(l) 848 | self._scroller = self._start_scrolling() 849 | 850 | @script 851 | def _start_scrolling(self): 852 | duration = self._text_width/self.scrolling_speed 853 | timer(self, self.initial_delay) 854 | yield 855 | while True: 856 | bounds(self, (self._direction * self._text_width, 0, self.width, self.height), duration=duration) 857 | yield 858 | self.bounds = (0, 0, self.width, self.height) 859 | 860 | 861 | #docgen: Easing functions 862 | 863 | def linear(t): 864 | return t 865 | def sinusoidal(t): 866 | return scene_drawing.curve_sinodial(t) 867 | def ease_in(t): 868 | return scene_drawing.curve_ease_in(t) 869 | def ease_out(t): 870 | return scene_drawing.curve_ease_out(t) 871 | def ease_in_out(t): 872 | return scene_drawing.curve_ease_in_out(t) 873 | def ease_out_in(t): 874 | return Scripter._cubic('easeOutIn', t) 875 | def elastic_out(t): 876 | return scene_drawing.curve_elastic_out(t) 877 | def elastic_in(t): 878 | return scene_drawing.curve_elastic_in(t) 879 | def elastic_in_out(t): 880 | return scene_drawing.curve_elastic_in_out(t) 881 | def bounce_out(t): 882 | return scene_drawing.curve_bounce_out(t) 883 | def bounce_in(t): 884 | return scene_drawing.curve_bounce_in(t) 885 | def bounce_in_out(t): 886 | return scene_drawing.curve_bounce_in_out(t) 887 | def ease_back_in(t): 888 | return scene_drawing.curve_ease_back_in(t) 889 | def ease_back_in_alt(t): 890 | return Scripter._cubic('easeInBounce', t) 891 | def ease_back_out(t): 892 | return scene_drawing.curve_ease_back_out(t) 893 | def ease_back_out_alt(t): 894 | return Scripter._cubic('easeOutBounce', t) 895 | def ease_back_in_out(t): 896 | return scene_drawing.curve_ease_back_in_out(t) 897 | def ease_back_in_out_alt(t): 898 | return Scripter._cubic('easeInOutBounce', t) 899 | 900 | def mirror(ease_func, t): 901 | ''' Runs the given easing function to the end in half the duration, then 902 | backwards in the second half. For example, if the function provided is 903 | `linear`, this function creates a "triangle" from 0 to 1, then back to 0; 904 | if the function is `ease_in`, the result is more of a "spike".''' 905 | ease_func = ease_func if callable(ease_func) else partial(Scripter._cubic, ease_func) 906 | if t < 0.5: 907 | t /= 0.5 908 | return ease_func(t) 909 | else: 910 | t -= 0.5 911 | t /= 0.5 912 | return ease_func(1-t) 913 | 914 | def mirror_ease_in(t): 915 | return mirror(ease_in, t) 916 | 917 | def mirror_ease_in_out(t): 918 | return mirror(ease_in_out, t) 919 | 920 | def oscillate(t): 921 | ''' Basic sine curve that runs from 0 through 1, 0 and -1, and back to 0. ''' 922 | return math.sin(t*2*math.pi) 923 | 924 | 925 | class Vector (list): 926 | ''' Simple 2D vector class to make vector operations more convenient. If 927 | performance is a concern, you are probably better off looking at numpy. 928 | 929 | Supports the following operations: 930 | 931 | * Initialization from two arguments, two keyword arguments (`x` and `y`), 932 | tuple, list, or another Vector. 933 | * Equality and unequality comparisons to other vectors. For floating point 934 | numbers, equality tolerance is 1e-10. 935 | * `abs`, `int` and `round` 936 | * Addition and in-place addition 937 | * Subtraction 938 | * Multiplication and division by a scalar 939 | * `len`, which is the same as `magnitude`, see below. 940 | 941 | Sample usage: 942 | 943 | v = Vector(x = 1, y = 2) 944 | v2 = Vector(3, 4) 945 | v += v2 946 | assert str(v) == '[4, 6]' 947 | assert v / 2.0 == Vector(2, 3) 948 | assert v * 0.1 == Vector(0.4, 0.6) 949 | assert v.distance_to(v2) == math.sqrt(1+4) 950 | 951 | v3 = Vector(Vector(1, 2) - Vector(2, 0)) # -1.0, 2.0 952 | v3.magnitude *= 2 953 | assert v3 == [-2, 4] 954 | 955 | v3.radians = math.pi # 180 degrees 956 | v3.magnitude = 2 957 | assert v3 == [-2, 0] 958 | v3.degrees = -90 959 | assert v3 == [0, -2] 960 | ''' 961 | 962 | abs_tol = 1e-10 963 | 964 | def __init__(self, *args, **kwargs): 965 | x = kwargs.pop('x', None) 966 | y = kwargs.pop('y', None) 967 | 968 | if x and y: 969 | self.append(x) 970 | self.append(y) 971 | elif len(args) == 2: 972 | self.append(args[0]) 973 | self.append(args[1]) 974 | else: 975 | super().__init__(*args, **kwargs) 976 | 977 | @property 978 | def x(self): 979 | ''' x component of the vector. ''' 980 | return self[0] 981 | 982 | @x.setter 983 | def x(self, value): 984 | self[0] = value 985 | 986 | @property 987 | def y(self): 988 | ''' y component of the vector. ''' 989 | return self[1] 990 | 991 | @y.setter 992 | def y(self, value): 993 | self[1] = value 994 | 995 | def __eq__(self, other): 996 | return math.isclose(self[0], other[0], abs_tol=self.abs_tol) and math.isclose(self[1], other[1], abs_tol=self.abs_tol) 997 | 998 | def __ne__(self, other): 999 | return not self.__eq__(other) 1000 | 1001 | def __abs__(self): 1002 | return type(self)(abs(self.x), abs(self.y)) 1003 | 1004 | def __int__(self): 1005 | return type(self)(int(self.x), int(self.y)) 1006 | 1007 | def __add__(self, other): 1008 | return type(self)(self.x + other.x, self.y + other.y) 1009 | 1010 | def __iadd__(self, other): 1011 | self.x += other.x 1012 | self.y += other.y 1013 | return self 1014 | 1015 | def __sub__(self, other): 1016 | return type(self)(self.x - other.x, self.y - other.y) 1017 | 1018 | def __mul__(self, other): 1019 | return type(self)(self.x * other, self.y * other) 1020 | 1021 | def __truediv__(self, other): 1022 | return type(self)(self.x / other, self.y / other) 1023 | 1024 | def __len__(self): 1025 | return self.magnitude 1026 | 1027 | def __round__(self): 1028 | return type(self)(round(self.x), round(self.y)) 1029 | 1030 | def dot_product(self, other): 1031 | ''' Sum of multiplying x and y components with the x and y components of another vector. ''' 1032 | return self.x * other.x + self.y * other.y 1033 | 1034 | def distance_to(self, other): 1035 | ''' Linear distance between this vector and another. ''' 1036 | return (Vector(other) - self).magnitude 1037 | 1038 | @property 1039 | def magnitude(self): 1040 | ''' Length of the vector, or distance from (0,0) to (x,y). ''' 1041 | return math.hypot(self.x, self.y) 1042 | 1043 | @magnitude.setter 1044 | def magnitude(self, m): 1045 | r = self.radians 1046 | self.polar(r, m) 1047 | 1048 | @property 1049 | def radians(self): 1050 | ''' Angle between the positive x axis and this vector, in radians. ''' 1051 | #return round(math.atan2(self.y, self.x), 10) 1052 | return math.atan2(self.y, self.x) 1053 | 1054 | @radians.setter 1055 | def radians(self, r): 1056 | m = self.magnitude 1057 | self.polar(r, m) 1058 | 1059 | def polar(self, r, m): 1060 | ''' Set vector in polar coordinates. `r` is the angle in radians, `m` is vector magnitude or "length". ''' 1061 | self.y = math.sin(r) * m 1062 | self.x = math.cos(r) * m 1063 | 1064 | @property 1065 | def degrees(self): 1066 | ''' Angle between the positive x axis and this vector, in degrees. ''' 1067 | return math.degrees(self.radians) 1068 | 1069 | @degrees.setter 1070 | def degrees(self, d): 1071 | self.radians = math.radians(d) 1072 | 1073 | def steps_to(self, other, step_magnitude=1.0): 1074 | """ Generator that returns points on the line between this and the other point, with each step separated by `step_magnitude`. Does not include the starting point. """ 1075 | if self == other: 1076 | yield other 1077 | else: 1078 | step_vector = other - self 1079 | steps = math.floor(step_vector.magnitude/step_magnitude) 1080 | step_vector.magnitude = step_magnitude 1081 | current_position = Vector(self) 1082 | for _ in range(steps): 1083 | current_position += step_vector 1084 | yield Vector(current_position) 1085 | if current_position != other: 1086 | yield other 1087 | 1088 | def rounded_steps_to(self, other, step_magnitude=1.0): 1089 | ''' As `steps_to`, but returns points rounded to the nearest integer. ''' 1090 | for step in self.steps_to(other): 1091 | yield round(step) 1092 | 1093 | 1094 | if __name__ == '__main__': 1095 | 1096 | import editor, scene_drawing 1097 | 1098 | class DemoBackground(View): 1099 | 1100 | def __init__(self): 1101 | self.start_point = (0,0) 1102 | self.axes_counter = 0 1103 | self.curve_point_x = None 1104 | self.curve_point_y = None 1105 | self.curve = [] 1106 | self.hide_curve = False 1107 | 1108 | def draw(self): 1109 | if self.axes_counter > 0: 1110 | spx = self.start_point.x 1111 | spy = self.start_point.y 1112 | set_color('black') 1113 | path = Path() 1114 | path.move_to(spx, spy) 1115 | path.line_to(spx+self.axes_counter, spy) 1116 | path.move_to(spx, spy) 1117 | path.line_to(spx, spy-self.axes_counter) 1118 | 1119 | end_size = 10 1120 | path.move_to(spx+self.axes_counter, spy) 1121 | path.line_to(spx+self.axes_counter-end_size, spy-end_size) 1122 | path.move_to(spx+self.axes_counter, spy) 1123 | path.line_to(spx+self.axes_counter-end_size, spy+end_size) 1124 | path.move_to(spx, spy-self.axes_counter) 1125 | path.line_to(spx-end_size, spy-self.axes_counter+end_size) 1126 | path.move_to(spx, spy-self.axes_counter) 1127 | path.line_to(spx+end_size, spy-self.axes_counter+end_size) 1128 | 1129 | path.stroke() 1130 | 1131 | path = Path() 1132 | set_color('#91cf96') 1133 | path.move_to(spx, spy) 1134 | 1135 | if self.curve_point_x is not None: 1136 | self.curve.append((spx+self.curve_point_x, spy-self.curve_point_y)) 1137 | 1138 | if not self.hide_curve: 1139 | for px, py in self.curve: 1140 | path.line_to(px, py) 1141 | 1142 | path.stroke() 1143 | 1144 | def trigger_refresh(self, value): 1145 | self.set_needs_display() 1146 | return value 1147 | 1148 | v = DemoBackground() 1149 | v.background_color = 'white' 1150 | v.present('full_screen') 1151 | 1152 | class Demo(View): 1153 | 1154 | def __init__(self, *args, **kwargs): 1155 | super().__init__(self, *args, *kwargs) 1156 | self.frame = (100, 100, 150, 40) 1157 | self.background_color = 'white' 1158 | self.c = c = View(frame=(0,0,10,10)) 1159 | c.corner_radius = 5 1160 | c.center = (15, 20) 1161 | c.hidden = True 1162 | c.background_color = 'black' 1163 | self.l = l = Label(text='Scripter Demo') 1164 | l.flex = 'WH' 1165 | l.text_color = 'black' 1166 | l.alignment = ALIGN_CENTER 1167 | self.add_subview(c) 1168 | self.add_subview(l) 1169 | l.frame = self.bounds 1170 | self.tv = tv = TextView(background_color='white') 1171 | tv.text = editor.get_text() 1172 | tv.text_color = '#4a4a4a' 1173 | tv.frame = (2, 2, 146, 36) 1174 | tv.flex = 'WH' 1175 | tv.alpha = 0 1176 | self.add_subview(tv) 1177 | #self.hidden = True 1178 | 1179 | @script 1180 | def demo_script(self): 1181 | show(self) 1182 | pulse(self, duration=1.0) 1183 | yield 'wait' 1184 | move(self, 200, 200) 1185 | yield 1186 | # Combine a primitive with a lambda and 1187 | # target the contained Label instead of self 1188 | set_value(self.l, 'text', range(1, 101), lambda count: f'Count: {count}') 1189 | yield 1 1190 | # Transformations 1191 | self.l.text = 'Rotating' 1192 | rotate(self, -720, ease_func=ease_back_in_out, duration=1.5) 1193 | #slide_color(self, 'background_color', 'green', duration=2.0) 1194 | background_color(self, 'green', duration=2.0) 1195 | slide_color(self.l, 'text_color', 'white', duration=2.0) 1196 | yield 'wait' 1197 | self.l.text = 'Move two' 1198 | # Create another view and control it as well 1199 | # Use another function to control animation 1200 | # "tracks" 1201 | self.other = View(background_color='red', frame=(10, 200, 150, 40)) 1202 | v.add_subview(self.other) 1203 | self.sub_script() 1204 | move(self.other, 200, 400) 1205 | yield 'wait' 1206 | 1207 | self.l.text = 'Custom' 1208 | # Driving custom View.draw animation 1209 | self.c.hidden = False 1210 | background_color(self, 'transparent') 1211 | text_color(self.l, 'black') 1212 | v.start_point = SimpleNamespace(x=self.x+15, y=self.y+20) 1213 | set_value(v, 'axes_counter', range(1, 210, 3), func=v.trigger_refresh) 1214 | yield 'wait' 1215 | slide_value(v, 'curve_point_x', 200, start_value=1, duration=2.0) 1216 | slide_value(v, 'curve_point_y', 200, start_value=1, ease_func=ease_back_in_out, map_func=v.trigger_refresh, duration=2.0) 1217 | yield 'wait' 1218 | 1219 | x(self, self.x+200, duration=2.0) 1220 | y(self, self.y-200, ease_func=ease_back_in_out, duration=2.0) 1221 | yield 'wait' 1222 | 1223 | self.l.text = 'Bounce' 1224 | self.c.hidden = True 1225 | background_color(self, 'green') 1226 | text_color(self.l, 'white') 1227 | width(self, 76) 1228 | height(self, 76) 1229 | corner_radius(self, 38) 1230 | v.hide_curve = True 1231 | v.set_needs_display() 1232 | yield 1233 | x(self, v.start_point.x, ease_func='easeOut', duration=2.0) 1234 | y(self, v.start_point.y-self.height, ease_func=scene_drawing.curve_bounce_out, duration=2.0) 1235 | yield 1.0 1236 | 1237 | self.l.text = 'Roll' 1238 | yield 'wait' 1239 | roll_to(self, (self.center[0]+170, self.center[1]), duration=2.0) 1240 | yield 1.0 1241 | roll_to(self, (self.center[0]-170, self.center[1]), duration=2.0) 1242 | yield 1.0 1243 | 1244 | v.axes_counter = 0 1245 | v.set_needs_display() 1246 | self.c.hidden = True 1247 | expand(self) 1248 | corner_radius(self, 0) 1249 | background_color(self, 'white') 1250 | show(self.tv) 1251 | content_offset(self.tv, (0,0)) 1252 | yield 1.0 1253 | 1254 | content_offset(self.tv, (0, self.tv.content_size[1]), duration=20) 1255 | self.end_fade() 1256 | 1257 | @script 1258 | def sub_script(self): 1259 | move(self, 50, 200) 1260 | yield 1261 | move(self, 50, 400) 1262 | yield 1263 | hide(self.other) 1264 | fly_out(self.other, 'down') 1265 | yield 1266 | v.remove_subview(self.other) 1267 | 1268 | @script 1269 | def end_fade(self): 1270 | yield 7.0 1271 | hide(v) 1272 | 1273 | 1274 | s = Demo() 1275 | v.add_subview(s) 1276 | 1277 | now_running = s.demo_script() 1278 | scr = find_scripter_instance(s) 1279 | 1280 | def pause_action(sender): 1281 | scr.pause_play_all() 1282 | pause.title = 'Pause' if scr.running else 'Play' 1283 | 1284 | pause = Button(title='Pause') 1285 | pause.frame = (v.width-85, 60, 75, 40) 1286 | pause.background_color = 'black' 1287 | pause.tint_color = 'white' 1288 | pause.action = pause_action 1289 | v.add_subview(pause) 1290 | 1291 | def cancel_demo(sender): 1292 | scr.cancel(now_running) 1293 | hide(pause) 1294 | hide(sender) 1295 | # or could just use 1296 | # scr.cancel_all() 1297 | # if clear that nothing else will be running 1298 | 1299 | b = Button(title='Cancel') 1300 | b.frame = (v.width-85, 10, 75, 40) 1301 | b.background_color = 'black' 1302 | b.tint_color = 'white' 1303 | b.action = cancel_demo 1304 | v.add_subview(b) 1305 | 1306 | m = ScrollingBannerLabel(text='This is a scripter test, with an info text that is too long to fit on screen at once.', font=('Futura', 24), text_color='green', frame=(40, v.height-50, v.width-80, 40)) 1307 | v.add_subview(m) 1308 | -------------------------------------------------------------------------------- /scripter/__init__.py: -------------------------------------------------------------------------------- 1 | #coding: utf-8 2 | 3 | """iOS Pythonista app UI and Scene animations and UI constraints""" 4 | 5 | __version__ = '2020.11.16' 6 | 7 | ''' 8 | # _SCRIPTER_ - Pythonista UI and Scene animations 9 | 10 | ![Logo](https://raw.githubusercontent.com/mikaelho/scripter/master/logo.jpg) 11 | 12 | # Quick start 13 | 14 | In order to start using the animation effects, just import scripter and call 15 | the effects as functions: 16 | 17 | from scripter import * 18 | 19 | hide(my_button) 20 | 21 | Effects depend on an active root UI view for the update method that runs them. 22 | Effects run for a default duration of 0.5 seconds, unless otherwise specified 23 | with a `duration` argument. You can also change the global default by changing 24 | the `scripter.default_duration` value. 25 | 26 | If you want to create a more complex animation from the effects provided, 27 | combine them in a script: 28 | 29 | @script 30 | def my_effect(view): 31 | move(view, 50, 200) 32 | background_color(view, 'red') 33 | yield 34 | hide(view, duration=2.0) 35 | 36 | Scripts control the order of execution with `yield` statements. Here the 37 | movement 38 | and background changing to red happen at the same time. After both actions are 39 | completed, view fades away slowly. 40 | 41 | As small delays are often needed for natural-feeling animations, you can append 42 | a number after a `yield` statement, to suspend the execution of the script for 43 | that duration, or `yield 'wait'` for the default duration. 44 | 45 | Another key for good animations is the use of easing functions that modify how 46 | the value progresses from starting value to the target value. Easing functions 47 | support creating different kinds of accelerating, bouncing and springy effects. 48 | Easing functions can be added as an argument to scripts: 49 | 50 | slide_value(view, 'x', 200, ease_func=bounce_out) 51 | 52 | See this 53 | [reference](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-funcs.jpg) 54 | to pick the right function, or run `scripter-demo.py` to try out the available 55 | effects and to find the optimal duration and easing function combo for your purposes. 56 | 57 | Scripter can also be used to animate different kinds of Pythonista `scene` 58 | module Nodes, including the Scene itself. Scripter provides roughly the same 59 | functionality as `scene.Action`, but is maybe a bit more concise, and is 60 | available as an option if you want to use same syntax in both UI and Scene 61 | projects. 62 | 63 | See the API documentation for individual effects and how to roll your own with 64 | `set_value`, `slide_value` and `timer`. 65 | 66 | There are also convenience functions, not separately documented, corresponding 67 | to all animatable attributes of ui views. For example, you can animate the 68 | `ui.View.background_color` attribute with: 69 | 70 | background_color(view, 'black') 71 | ''' 72 | 73 | from ui import * 74 | from scene import Node, Scene 75 | import scene_drawing 76 | import objc_util 77 | 78 | import ctypes 79 | from types import GeneratorType, SimpleNamespace 80 | import sys 81 | from numbers import Number 82 | from functools import partial, wraps, lru_cache 83 | from contextlib import contextmanager 84 | import time, math 85 | import inspect 86 | 87 | 88 | default_duration = 0.5 89 | 90 | scripter_view = None 91 | 92 | def start_scripter(view): 93 | """ 94 | This must be called with your root view 95 | before any scripts are called. 96 | """ 97 | global scripter_view 98 | scripter_view = view 99 | 100 | #docgen: Script management 101 | 102 | 103 | def _arg_wrap(func): 104 | """ Decorator to decorate decorators to support optional arguments. """ 105 | 106 | @wraps(func) 107 | def new_decorator(*args, **kwargs): 108 | if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): 109 | return func(args[0]) 110 | else: 111 | return lambda realf: func(realf, *args, **kwargs) 112 | 113 | return new_decorator 114 | 115 | 116 | @_arg_wrap 117 | def script(func, flow_control=False): 118 | ''' 119 | _Can be used with Scene Nodes._ 120 | 121 | Decorator for the animation scripts. 122 | Scripts can be functions, methods or generators. 123 | 124 | Calling a script starts the Scripter `update` loop, if not already running. 125 | 126 | New scripts suspend the execution of the parent script until all the parallel 127 | scripts have completed, after which the `update` method will resume the 128 | execution of the parent script. 129 | ''' 130 | @wraps(func) 131 | def wrapper(*args, **kwargs): 132 | 133 | def _func_wrapper(f, *args, **kwargs): 134 | return (yield f(*args, **kwargs)) 135 | 136 | if inspect.isgeneratorfunction(func): 137 | gen = func(*args, **kwargs) 138 | else: 139 | gen = _func_wrapper(func, *args, **kwargs) 140 | gen.__name__ = func.__name__ 141 | 142 | if flow_control: 143 | gen.__name__ = '_scripter_flow_controller' 144 | 145 | scr = find_scripter_instance() 146 | scr.initialize(gen) 147 | 148 | return gen 149 | 150 | return wrapper 151 | 152 | def isfinished(gen): 153 | ''' Returns True if the generator argument has been exhausted, i.e. 154 | script has run to the end. ''' 155 | return gen.gi_frame is None 156 | 157 | def ispaused(gen): 158 | scr = find_scripter_instance() 159 | to_process = [gen] 160 | while len(to_process): 161 | gen = to_process.pop() 162 | if gen in scr.paused: 163 | return True 164 | elif gen in scr.standby_gens: 165 | to_process.extend(scr.standby_gens[gen]) 166 | return False 167 | 168 | def isnode(view): 169 | ''' Returns True if argument is an instance of a subclass of scene.Node. ''' 170 | return issubclass(type(view), Node) 171 | 172 | def find_scripter_instance(): 173 | ''' 174 | _Can be used with Scene Nodes._ 175 | 176 | Scripts need a "controller" ui.View that runs the update method for them. This 177 | function finds or creates the controller for a view as follows: 178 | 179 | 1. Check if the view itself is a Scripter 180 | 2. Check if any of the subviews is a Scripter 181 | 3. Repeat 1 and 2 up the view hierarchy of superviews 182 | 4. If not found, create an instance of Scripter as a hidden subview of the root view 183 | 184 | In case of scene Nodes, search starts from `node.scene.view`. 185 | 186 | If you want cancel or pause scripts, and have not explicitly created a 187 | Scripter instance to run them, you need to use this method first to find the 188 | right one. 189 | ''' 190 | 191 | global scripter_view 192 | if scripter_view is None: 193 | raise RuntimeError( 194 | 'Call start_scripter() before calling the first script') 195 | view = scripter_view 196 | 197 | if isnode(view): 198 | if hasattr(view, 'view'): 199 | view = view.view # Scene root node 200 | else: 201 | if view.scene is None: 202 | raise ValueError('Node must be added to a Scene before animations') 203 | view = view.scene.view 204 | 205 | while True: 206 | if isinstance(view, Scripter): 207 | return view 208 | for subview in view.subviews: 209 | if isinstance(subview, Scripter): 210 | return subview 211 | if not view.superview: 212 | break 213 | view = view.superview 214 | # If not found, create a new one as a hidden 215 | # subview of the root view 216 | scr = Scripter(hidden=True) 217 | view.add_subview(scr) 218 | return scr 219 | 220 | def find_root_view(): 221 | """ 222 | Locates the first `present`ed view. 223 | You can also set the root view manually with the `set_scripter_view` 224 | function. 225 | 226 | NOT USED, turned out to be unreliable. 227 | """ 228 | #global scripter_view 229 | 230 | #if scripter_view is not None: 231 | # return scripter_view 232 | SUIView_PY3 = objc_util.ObjCClass('SUIView_PY3') 233 | candidates = [objc_util.UIApplication.sharedApplication().windows()[0]] 234 | #while len(candidates) > 0: 235 | for objc_view in candidates: 236 | #objc_view = candidates.pop() 237 | if objc_view.isKindOfClass_(SUIView_PY3.ptr): 238 | return objc_view.pyObject( 239 | argtypes=[], restype=ctypes.c_void_p) 240 | candidates.extend(objc_view.subviews()) 241 | raise Exception('Root view not found') 242 | 243 | class Scripter(View): 244 | 245 | ''' 246 | Class that contains the `update` method used to run the scripts and to control their execution. 247 | 248 | Runs at default 60 fps, or not at all when there are no scripts to run. 249 | 250 | Inherits from ui.View; constructor takes all the same arguments as ui.View. 251 | ''' 252 | 253 | global_default_update_interval = 1/60 254 | default_duration = 0.5 255 | 256 | def __init__(self, *args, **kwargs): 257 | super().__init__(self, *args, **kwargs) 258 | self.default_update_interval = Scripter.global_default_update_interval 259 | self.cancel_all() 260 | self.running = False 261 | self.time_paused = 0 262 | 263 | @property 264 | def default_update_interval(self): 265 | ''' 266 | The running rate for the update method. Frames per second is here considered to be just an 267 | alternative way of setting the update interval, and this property is linked to 268 | `default_fps` - change one and the other will change as well. 269 | ''' 270 | return self._default_update_interval 271 | 272 | @default_update_interval.setter 273 | def default_update_interval(self, value): 274 | self._default_update_interval = value 275 | self._default_fps = 1/value 276 | 277 | @property 278 | def default_fps(self): 279 | ''' 280 | The running rate for the update method. Frames per second is here considered to be just an 281 | alternative way of setting the update interval, and this property is linked to 282 | `default_update_interval` - change one and the other will change as well. 283 | ''' 284 | return self._default_fps 285 | 286 | @default_fps.setter 287 | def default_fps(self, value): 288 | self._default_fps = value 289 | self._default_update_interval = 1/value 290 | 291 | def initialize(self, gen): 292 | queued = self.queued.get(self.current_gen) 293 | if queued is not None: 294 | queued.append(gen) 295 | return 296 | self.parent_gens[gen] = self.current_gen 297 | if self.current_gen != 'root': 298 | self.standby_gens.setdefault( 299 | self.current_gen, set() 300 | ).add(gen) 301 | self.deactivate.add(self.current_gen) 302 | self.activate.add(gen) 303 | self.update_interval = self.default_update_interval 304 | self.running = True 305 | 306 | def update(self): 307 | ''' 308 | Main Scripter animation loop handler, called by the Puthonista UI loop 309 | and never by your code directly. 310 | 311 | This method: 312 | 313 | * Activates all newly called scripts and suspends their parents. 314 | * Calls all active scripts, which will run to their next `yield` or 315 | until completion. 316 | * As a convenience feature, if a `yield` returns `'wait'` or a 317 | specific duration, 318 | kicks off a child `timer` script to wait for that period of time. 319 | * Cleans out completed scripts. 320 | * Resumes parent scripts whose children have all completed. 321 | * Sets `update_interval` to 0 if all scripts have completed. 322 | ''' 323 | run_at_least_once = True 324 | 325 | while ( 326 | run_at_least_once or 327 | len(self.activate) > 0 or len(self.deactivate) > 0 328 | ): 329 | run_at_least_once = False 330 | for script in self.cancel_queue: 331 | self._process_cancel(script) 332 | self.cancel_queue = set() 333 | 334 | # scripts with flow_control=True manage their children themselves 335 | to_postpone = set() 336 | for gen in self.activate: 337 | if gen.__name__ == '_scripter_flow_controller' and gen not in to_postpone: 338 | print(gen, 'not there yet') 339 | 340 | if gen.__name__ == '_scripter_flow_controller': 341 | assert gen not in gen.gi_frame.f_locals['gens'], 'gen in gens' 342 | assert gen not in to_postpone, f'{gen} to postpone before' 343 | to_postpone.update(gen.gi_frame.f_locals['gens']) 344 | assert gen not in to_postpone, 'gen to postpone after' 345 | self.activate.difference_update(to_postpone) 346 | for gen in self.activate: 347 | self.active_gens.add(gen) 348 | self.activate = set() 349 | for gen in self.deactivate: 350 | self.active_gens.remove(gen) 351 | self.deactivate = set() 352 | for gen in self.pause_queue: 353 | to_process = [gen] 354 | while len(to_process): 355 | gen_to_pause = to_process.pop() 356 | if gen_to_pause in self.active_gens: 357 | self.active_gens.remove(gen_to_pause) 358 | self.paused.add(gen_to_pause) 359 | #print('PAUSED', gen_to_pause) 360 | elif gen_to_pause in self.standby_gens: 361 | to_process.extend(self.standby_gens[gen_to_pause]) 362 | self.pause_queue = set() 363 | for gen in self.play_queue: 364 | to_process = [gen] 365 | while len(to_process): 366 | gen_to_play = to_process.pop() 367 | if gen_to_play in self.paused: 368 | self.paused.remove(gen_to_play) 369 | self.active_gens.add(gen_to_play) 370 | elif gen_to_play in self.standby_gens: 371 | to_process.extend(self.standby_gens[gen_to_play]) 372 | self.play_queue = set() 373 | gen_to_end = [] 374 | for gen in self.active_gens: 375 | self.current_gen = gen 376 | wait_time = self.should_wait.pop(gen, None) 377 | if wait_time is not None: 378 | #timer(self.view_for_gen[gen], wait_time) 379 | timer(wait_time) 380 | else: 381 | wait_time = None 382 | try: 383 | wait_time = next(gen) 384 | except StopIteration: 385 | if gen not in self.deactivate: 386 | gen_to_end.append(gen) 387 | if wait_time is not None: 388 | if wait_time == 'wait': 389 | wait_time = self.default_duration 390 | if isinstance(wait_time, Number): 391 | self.should_wait[gen] = wait_time 392 | self.current_gen = 'root' 393 | self.time_paused = 0 394 | for gen in gen_to_end: 395 | self.active_gens.remove(gen) 396 | parent_gen = self.parent_gens[gen] 397 | del self.parent_gens[gen] 398 | if parent_gen != 'root': 399 | self.standby_gens[parent_gen].remove(gen) 400 | if len(self.standby_gens[parent_gen]) == 0: 401 | self.activate.add(parent_gen) 402 | del self.standby_gens[parent_gen] 403 | if len(self.active_gens) == 0: 404 | self.update_interval = 0.0 405 | self.running = False 406 | 407 | def _process_cancel(self, script): 408 | to_cancel = set() 409 | to_cancel.add(script) 410 | parent_gen = self.parent_gens[script] 411 | if parent_gen != 'root': 412 | self.standby_gens[parent_gen].remove(script) 413 | if len(self.standby_gens[parent_gen]) == 0: 414 | self.active_gens.add(parent_gen) 415 | del self.standby_gens[parent_gen] 416 | found_new = True 417 | while found_new: 418 | new_found = set() 419 | found_new = False 420 | for gen in to_cancel: 421 | if gen in self.standby_gens: 422 | for child_gen in self.standby_gens[gen]: 423 | if child_gen not in to_cancel: 424 | new_found.add(child_gen) 425 | found_new = True 426 | for gen in new_found: 427 | to_cancel.add(gen) 428 | 429 | for gen in to_cancel: 430 | if gen == self.current_gen: 431 | self.currrent_gen = parent_gen 432 | del self.parent_gens[gen] 433 | self.activate.discard(gen) 434 | self.deactivate.discard(gen) 435 | self.active_gens.discard(gen) 436 | if gen in self.standby_gens: 437 | del self.standby_gens[gen] 438 | self.paused.discard(gen) 439 | 440 | def cancel_all(self): 441 | ''' Initializes all internal structures. 442 | Used at start and to cancel all running scripts. 443 | ''' 444 | self.current_gen = 'root' 445 | self.should_wait = {} 446 | self.parent_gens = {} 447 | self.active_gens = set() 448 | self.standby_gens = {} 449 | self.paused = set() 450 | self.activate = set() 451 | self.deactivate = set() 452 | self.running = False 453 | self.play_queue = set() 454 | self.pause_queue = set() 455 | self.cancel_queue = set() 456 | self.queued = {} 457 | 458 | def pause_play_all(self): 459 | ''' Pause or play all animations. ''' 460 | self.update_interval = 0 if self.update_interval > 0 else self.default_update_interval 461 | self.running = self.update_interval > 0 462 | if not self.running: 463 | self.pause_start_time = time.time() 464 | else: 465 | self.time_paused = time.time() - self.pause_start_time 466 | 467 | def pause(self, script): 468 | self.pause_queue.add(script) 469 | 470 | def play(self, script): 471 | self.play_queue.add(script) 472 | 473 | def cancel(self, script): 474 | ''' Cancels any ongoing animations and 475 | sub-scripts for the given script. ''' 476 | self.cancel_queue.add(script) 477 | 478 | @staticmethod 479 | def _cubic(params, t): 480 | ''' 481 | Cubic function for easing animations. 482 | 483 | Arguments: 484 | 485 | * params - either a 4-tuple of cubic parameters, one of the parameter names below (like ’easeIn') or 'linear' for a straight line 486 | * t - time running from 0 to 1 487 | ''' 488 | ease_func_params = { 489 | 'easeIn': (0, 0.05, 0.25, 1), 490 | 'easeOut': (0, 0.75, 0.95, 1), 491 | 'easeInOut': (0, 0.05, 0.95, 1), 492 | 'easeOutIn': (0, 0.75, 0.25, 1), 493 | 'easeInBounce': (0, -0.5, 0.25, 1), 494 | 'easeOutBounce': (0, 0.75, 1.5, 1), 495 | 'easeInOutBounce': (0, -0.5, 1.5, 1) 496 | } 497 | 498 | if isinstance(params, str): 499 | if params == 'linear': 500 | return t 501 | try: 502 | u = ease_func_params[params] 503 | except KeyError: 504 | raise ValueError('Easing function name must be one of: ' + 505 | ', '.join(list(ease_func_params))) 506 | else: 507 | u = params 508 | return u[0]*(1-t)**3 + 3*u[1]*(1-t)**2*t + 3*u[2]*(1-t)*t**2 + u[3]*t**3 509 | 510 | def pause(gen): 511 | scr = find_scripter_instance() 512 | scr.pause(gen) 513 | 514 | def play(gen): 515 | scr = find_scripter_instance() 516 | scr.play(gen) 517 | 518 | def cancel(gen): 519 | scr = find_scripter_instance() 520 | scr.cancel(gen) 521 | 522 | @contextmanager 523 | def steps(): 524 | """ 525 | Scripts within a steps block are run as 526 | if they had `yield` between each script 527 | """ 528 | scr = find_scripter_instance() 529 | scr.queued[scr.current_gen] = [] 530 | 531 | yield 532 | 533 | queued = scr.queued[scr.current_gen] 534 | del scr.queued[scr.current_gen] 535 | if len(queued): 536 | @script 537 | def run_queue(gens): 538 | for gen in gens: 539 | scr.initialize(gen) 540 | yield 541 | run_queue(queued) 542 | 543 | 544 | @script(flow_control=True) 545 | def queue(*gens): 546 | """ 547 | Sometimes it is more convenient to 548 | list the scripts to be executed in order 549 | than separate them with yields. 550 | """ 551 | print(gens) 552 | scr = find_scripter_instance() 553 | if any([scr.parent_gens[gen] == 'root' for gen in gens]): 554 | raise RuntimeError('queue function used outside a script') 555 | for gen in gens: 556 | scr.initialize(gen) 557 | while not isfinished(gen): 558 | yield 559 | 560 | @script(flow_control=True) 561 | def group(*gens): 562 | """ 563 | Complement to queue, grouping scripts to be run in parallel. 564 | """ 565 | scr = find_scripter_instance() 566 | if any([scr.parent_gens[gen] == 'root' for gen in gens]): 567 | raise RuntimeError('group function used outside a script') 568 | for gen in gens: 569 | scr.initialize(gen) 570 | yield 571 | 572 | #docgen: Animation primitives 573 | 574 | @script 575 | def set_value(view, attribute, value, func=None): 576 | ''' 577 | Generator that sets the `attribute` to a `value` once, or several times 578 | if the value itself is a generator or an iterator. 579 | 580 | Optional keyword parameters: 581 | 582 | * `func` - called with the value, returns the actual value to be set 583 | ''' 584 | 585 | func = func if callable(func) else lambda val: val 586 | if isinstance(value, GeneratorType): 587 | while True: 588 | setattr(view, attribute, func(next(value))) 589 | yield 590 | elif hasattr(value, '__iter__') and not isinstance(value, str): 591 | iterator = iter(value) 592 | for value in iterator: 593 | setattr(view, attribute, func(value)) 594 | yield 595 | else: 596 | setattr(view, attribute, func(value)) 597 | 598 | @script 599 | def slide_value( 600 | view, attribute, end_value, start_value=None, 601 | duration=None, delta_func=None, ease_func=None, 602 | current_func=None, map_func=None, side_func=None): 603 | ''' 604 | Generator that "slides" the `value` of an 605 | `attribute` to an `end_value` in a given duration. 606 | 607 | Optional keyword parameters: 608 | 609 | * `start_value` - set if you want some other value than the current value of the attribute as the animation start value. 610 | * `duration` - time it takes to change to the target value. Default is 0.5 seconds. 611 | * `delta_func` - use to transform the range from start_value to end_value to something else. 612 | * `ease_func` - provide to change delta-t value to something else. Mostly used for easing; you can provide an easing function name as a string instead of an actual function. See supported easing functions [here](https://raw.githubusercontent.com/mikaelho/scripter/master/ease-functions.png). 613 | * `current_func` - Given the start value, delta value and progress fraction (from 0 to 1), returns the current value. Intended to be used to manage more exotic values like colors. 614 | * `map_func` - Used to translate the current value to something else, e.g. an angle to a Transform.rotation. 615 | * `side_func` - Called without arguments each time after the main value has been set. Useful for side effects. 616 | ''' 617 | duration = duration or default_duration 618 | start_value = start_value if start_value is not None else getattr(view, attribute) 619 | 620 | delta_func = delta_func if callable(delta_func) else lambda start_value, end_value: end_value - start_value 621 | map_func = map_func if callable(map_func) else lambda val: val 622 | if isinstance(ease_func, str) or isinstance(ease_func, tuple): 623 | ease_func = partial(Scripter._cubic, ease_func) 624 | else: 625 | ease_func = ease_func if callable(ease_func) else lambda val: val 626 | current_func = current_func if callable(current_func) else lambda start_value, t_fraction, delta_value: start_value + t_fraction * delta_value 627 | 628 | delta_value = delta_func(start_value, end_value) 629 | start_time = time.time() 630 | dt = 0 631 | 632 | scr = find_scripter_instance() 633 | scaling = True 634 | while scaling: 635 | if dt < duration: 636 | t_fraction = ease_func(dt/duration) 637 | #print(ease_func.__name__, t_fraction) 638 | else: 639 | t_fraction = ease_func(1) 640 | scaling = False 641 | current_value = current_func(start_value, t_fraction, delta_value) 642 | setattr(view, attribute, map_func(current_value)) 643 | if side_func: side_func() 644 | yield 645 | if scr.time_paused > 0: 646 | start_time += scr.time_paused 647 | dt = time.time() - start_time 648 | 649 | @script 650 | def slide_tuple(view, *args, **kwargs): 651 | ''' 652 | Slide a tuple value of arbitrary length. Supports same arguments as `slide_value`. ''' 653 | def delta_func_for_tuple(start_value, end_value): 654 | return tuple((end_value[i] - start_value[i] for i in range(len(start_value)))) 655 | def current_func_for_tuple(start_value, t_fraction, delta_value): 656 | return tuple((start_value[i] + t_fraction * delta_value[i] for i in range(len(start_value)))) 657 | 658 | delta_func = delta_func_for_tuple if 'delta_func' not in kwargs else kwargs['delta_func'] 659 | current_func = current_func_for_tuple if 'current_func' not in kwargs else kwargs['current_func'] 660 | 661 | return slide_value(view, *args, **kwargs, delta_func=delta_func, current_func=current_func) 662 | 663 | @script 664 | def slide_color(view, attribute, end_value, **kwargs): 665 | ''' Slide a color value. Supports the same 666 | arguments as `slide_value`. ''' 667 | start_value = kwargs.pop('start_value', None) 668 | if start_value: 669 | start_value = parse_color(start_value) 670 | end_value = parse_color(end_value) 671 | 672 | return slide_tuple(view, attribute, end_value, start_value=start_value, **kwargs) 673 | 674 | @script 675 | def timer(duration=None, action=None, fraction=None): 676 | ''' Acts as a wait timer for the given duration in seconds. 677 | Optional action function is called every cycle. 678 | Optional `fraction` function is called every cycle with the fraction 0-1 679 | of the total duration elapsed. ''' 680 | 681 | scr = find_scripter_instance() 682 | duration = duration or default_duration 683 | start_time = time.time() 684 | dt = 0 685 | while dt < duration: 686 | if action: action() 687 | if fraction: 688 | fraction(dt/duration) 689 | yield 690 | if scr.time_paused > 0: 691 | start_time += scr.time_paused 692 | dt = time.time() - start_time 693 | if fraction: 694 | fraction(1.0) 695 | 696 | #docgen: Animation effects 697 | 698 | @script 699 | def center(view, move_center_to, **kwargs): 700 | ''' Move view center (anchor for Scene Nodes). ''' 701 | attr = 'position' if isnode(view) else 'center' 702 | return slide_tuple(view, attr, move_center_to, **kwargs) 703 | 704 | @script 705 | def center_to(view, move_center_to, **kwargs): 706 | ''' Alias for `center`. ''' 707 | return center(view, move_center_to, **kwargs) 708 | 709 | @script 710 | def center_by(view, dx, dy, **kwargs): 711 | ''' Adjust view center/anchor position by dx, dy. ''' 712 | cx, cy = view.position if isnode(view) else view.center 713 | return center(view, (cx + dx, cy + dy), **kwargs) 714 | 715 | @script 716 | def expand(view, **kwargs): 717 | ''' _Not applicable for Scene Nodes._ 718 | 719 | Expands the view to fill all of its superview. ''' 720 | move(view, 0, 0, **kwargs) 721 | slide_value(view, 'width', view.superview.width, **kwargs) 722 | slide_value(view, 'height', view.superview.height, **kwargs) 723 | #yield 724 | 725 | @script 726 | def fly_out(view, direction, **kwargs): 727 | ''' Moves the view out of the screen in the given direction. Direction is one of the 728 | following strings: 'up', 'down', 'left', 'right'. ''' 729 | 730 | (sw,sh) = get_screen_size() 731 | if isnode(view): 732 | (lx, ly, lw, lh) = view.scene.bounds 733 | attr = 'position' 734 | if direction == 'up': 735 | direction = 'down' 736 | elif direction == 'down': 737 | direction = 'up' 738 | else: 739 | (lx, ly, lw, lh) = convert_rect(rect=(0,0,sw,sh), to_view=view) 740 | attr = 'center' 741 | (x,y,w,h) = view.frame 742 | (cx, cy) = getattr(view, attr) 743 | targets = { 'up': (cx,ly-h), 'down': (cx,lh+h), 'left': (lx-w,cy), 'right': (lw+w,cy) } 744 | try: 745 | target_coord = targets[direction] 746 | except KeyError: 747 | raise ValueError('Direction must be one of ' + str(list(targets.keys()))) 748 | return slide_tuple(view, attr, target_coord, **kwargs) 749 | 750 | def future(future): 751 | ''' yields until the given future is completed. ''' 752 | @script 753 | def wait_for_done(view, future): 754 | while not future.done(): 755 | yield 756 | if future.exception() is not None: 757 | raise future.exception() 758 | return wait_for_done(find_root_view(), future) 759 | 760 | @script 761 | def gradient(view, bg_color='black', highlight_color='#727272', mirror=False, **kwargs): 762 | 763 | CAGradientLayer = objc_util.ObjCClass('CAGradientLayer') 764 | 765 | objc_background = objc_util.UIColor.colorWithRed_green_blue_alpha_( 766 | *parse_color(bg_color) 767 | ).CGColor() 768 | objc_highlight = objc_util.UIColor.colorWithRed_green_blue_alpha_( 769 | *parse_color(highlight_color) 770 | ).CGColor() 771 | 772 | layer = view.objc_instance.layer() 773 | grlayer = CAGradientLayer.layer() 774 | grlayer.frame = layer.bounds() 775 | grlayer.setColors_([objc_background, objc_highlight, objc_background]) 776 | layer.insertSublayer_atIndex_(grlayer, 0) 777 | 778 | def set_highlight_position(fraction): 779 | scaled = -0.5 + 2 * fraction 780 | grlayer.locations = [-0.5, fraction, 1.5] 781 | 782 | duration = kwargs.pop('duration', default_duration) 783 | timer(duration, fraction=set_highlight_position) 784 | #yield 785 | 786 | @script 787 | def hide(view, **kwargs): 788 | ''' Fade the view away. ''' 789 | return slide_value(view, 'alpha', 0.0, **kwargs) 790 | 791 | @script 792 | def move(view, x, y, **kwargs): 793 | ''' Move to x, y. 794 | For UI views, this positions the top-left corner. 795 | For Scene Nodes, this moves the Node `position`. ''' 796 | if isnode(view): 797 | slide_tuple(view, 'position', (x,y), **kwargs) 798 | else: 799 | slide_value(view, 'x', x, **kwargs) 800 | slide_value(view, 'y', y, **kwargs) 801 | yield 802 | 803 | @script 804 | def move_to(view, x, y, **kwargs): 805 | ''' Alias for `move`. ''' 806 | return move(view, x, y, **kwargs) 807 | 808 | @script 809 | def move_by(view, dx, dy, **kwargs): 810 | ''' Adjust position by dx, dy. ''' 811 | if isnode(view): 812 | slide_tuple(view, 'position', (view.position.x+dx, view.position.y+dy), **kwargs) 813 | else: 814 | slide_value(view, 'x', view.x + dx, **kwargs) 815 | slide_value(view, 'y', view.y + dy, **kwargs) 816 | yield 817 | 818 | @script 819 | def pulse(view, color='#67cf70', **kwargs): 820 | ''' Pulses the background of the view to the given color and back to the original color. 821 | Default color is a shade of green. ''' 822 | root_func = kwargs.pop('ease_func', ease_in) 823 | ease_func = partial(mirror, root_func) 824 | return slide_color(view, 'background_color', color, ease_func=ease_func, **kwargs) 825 | 826 | @script 827 | def reveal_text(view, **kwargs): 828 | ''' Reveals text one letter at a time in the given duration. View must have a `text` attribute. ''' 829 | full_text = view.text 830 | 831 | return slide_value(view, 'text', len(full_text), start_value=0, map_func=lambda value: full_text[:max(0, min(len(full_text), round(value)))], **kwargs) 832 | 833 | @script 834 | def roll_to(view, to_center, end_right_side_up=True, **kwargs): 835 | ''' Roll the view to a target position given by the `to_center` tuple. If `end_right_side_up` is true, view starting angle is adjusted so that the view will end up with 0 rotation at the end, otherwise the view will start as-is, and end in an angle determined by the roll. 836 | View should be round for the rolling effect to make sense. Imaginary rolling surface is below the view - or to the left if rolling directly downwards. ''' 837 | from_center = view.position if isnode(view) else view.center 838 | roll_vector = Vector(to_center)-Vector(from_center) 839 | roll_direction = 1 if roll_vector.x >= 0 else -1 840 | roll_distance = roll_vector.magnitude 841 | view_r = view.frame[0]/2 842 | roll_degrees = roll_direction * 360 * roll_distance/(2*math.pi*view_r) 843 | if end_right_side_up: 844 | start_degrees = roll_direction * (360 - abs(roll_degrees) % 360) 845 | start_radians = math.radians(start_degrees) 846 | if isnode(view): 847 | view.rotation = start_radians 848 | else: 849 | view.transform = Transform.rotation(start_radians) 850 | rotate_by(view, roll_degrees, **kwargs) 851 | center(view, to_center, **kwargs) 852 | yield 853 | 854 | @script 855 | def rotate(view, degrees, shortest=False, **kwargs): 856 | ''' Rotate view to an absolute angle. Set start_value if not starting from 0. Positive number rotates clockwise. For UI views, does not mix with other transformations. 857 | 858 | Optional arguments: 859 | 860 | * `shortest` - If set to True (default), will turn in the "right" direction. For UI views, start_value must be set to a sensible value for this to work. 861 | ''' 862 | start_value = kwargs.pop('start_value', math.degrees(view.rotation) if isnode(view) else 0) 863 | radians = math.radians(degrees) 864 | start_radians = math.radians(start_value) 865 | if shortest: 866 | degrees = math.degrees(math.atan2(math.sin(radians-start_radians), math.cos(radians-start_radians))) 867 | rotate_by(view, degrees) 868 | else: 869 | if isnode(view): 870 | return slide_value(view, 'rotation', radians, start_value=start_radians, **kwargs) 871 | else: 872 | return slide_value(view, 'transform', radians, start_value=start_radians, map_func=lambda r: Transform.rotation(r), **kwargs) 873 | 874 | def rotate_to(view, degrees, **kwargs): 875 | ''' Alias for `rotate`. ''' 876 | return rotate(view, degrees, **kwargs) 877 | 878 | @script 879 | def rotate_by(view, degrees, **kwargs): 880 | ''' Rotate view by given degrees. ''' 881 | radians = math.radians(degrees) 882 | if isnode(view): 883 | return slide_value(view, 'rotation', view.rotation+radians, start_value=view.rotation, **kwargs) 884 | else: 885 | starting_transform = view.transform 886 | return slide_value(view, 'transform', radians, start_value=0, map_func=lambda r: Transform.rotation(r) if not starting_transform else starting_transform.concat(Transform.rotation(r)), **kwargs) 887 | 888 | @script 889 | def scale(view, factor, **kwargs): 890 | ''' Scale view to a given factor in both x and y dimensions. For UI views, you need to explicitly set `start_value` if not starting from 1. ''' 891 | if isnode(view): 892 | start_value = kwargs.pop('start_value', view.x_scale) 893 | slide_value(view, 'x_scale', factor, start_value=start_value, **kwargs) 894 | slide_value(view, 'y_scale', factor, start_value=start_value, **kwargs) 895 | else: 896 | start_value = kwargs.pop('start_value', 1) 897 | slide_value(view, 'transform', factor, start_value=start_value, map_func=lambda r: Transform.scale(r, r), **kwargs) 898 | #yield 899 | 900 | def scale_to(view, factor, **kwargs): 901 | ''' Alias for `scale`. ''' 902 | return scale(view, factor, **kwargs) 903 | 904 | @script 905 | def scale_by(view, factor, **kwargs): 906 | ''' Scale view relative to current scale factor. ''' 907 | if isnode(view): 908 | start_value = kwargs.pop('start_value', view.x_scale) 909 | end_value = start_value * factor 910 | slide_value(view, 'x_scale', end_value, start_value=start_value, **kwargs) 911 | slide_value(view, 'y_scale', end_value, start_value=start_value, **kwargs) 912 | else: 913 | start_value = kwargs.pop('start_value', 1) 914 | starting_transform = view.transform 915 | slide_value(view, 'transform', factor, start_value=start_value, map_func=lambda r: Transform.scale(r, r) if not starting_transform else starting_transform.concat(Transform.scale(r, r)), **kwargs) 916 | #yield 917 | 918 | @script 919 | def show(view, **kwargs): 920 | ''' Slide alpha from 0 to 1. ''' 921 | view.alpha = 0.0 922 | return slide_value(view, 'alpha', 1.0, **kwargs) 923 | 924 | @script 925 | def wobble(view): 926 | ''' Little wobble of a view, intended to attract attention. ''' 927 | return rotate(view, 10, shortest=False, duration=0.3, ease_func=oscillate) 928 | 929 | @script 930 | def wait_for_tap(view): 931 | ''' Overlays the given view with a temporary transparent view, and 932 | yields until the view is tapped. ''' 933 | 934 | class WaitForTap(View): 935 | 936 | def __init__(self, target, **kwargs): 937 | super().__init__(**kwargs) 938 | self.tapped = False 939 | self.background_color = (0,0,0,0.0001) 940 | self.frame=target.bounds 941 | target.add_subview(self) 942 | 943 | def touch_ended(self, touch): 944 | self.tapped = True 945 | 946 | t = WaitForTap(view) 947 | while not t.tapped: 948 | yield 949 | 950 | # Generate convenience functions 951 | 952 | animation_attributes = ( 953 | (slide_value, ( 954 | 'alpha', 955 | 'corner_radius', 956 | 'height', 957 | 'row_height', 958 | 'value', 959 | 'width', 960 | 'x', 961 | 'y', 962 | )), 963 | (slide_color, ( 964 | 'background_color', 965 | 'bar_tint_color', 966 | 'border_color', 967 | 'text_color', 968 | 'tint_color', 969 | 'title_color', 970 | )), 971 | (slide_tuple, ( 972 | 'bounds', 973 | 'content_inset', 974 | 'content_offset', 975 | 'content_size', 976 | 'frame', 977 | 'scroll_indicator_insets', 978 | 'selected_range', 979 | )) 980 | ) 981 | 982 | for animation_function, keys in animation_attributes: 983 | for key in keys: 984 | def f(func, attr, view, value, **kwargs): 985 | func(view, attr, value, **kwargs) 986 | globals()[key] = partial(f, animation_function, key) 987 | 988 | def while_not_finished(gen, scr, view, *args, **kwargs): 989 | ''' While `gen` is not finished, calls `scr` repeatedly with `view` and other 990 | arguments. ''' 991 | @script 992 | def waiting(view, gen, scr, *args, **kwargs): 993 | while not isfinished(gen): 994 | scr(view, *args, **kwargs) 995 | yield 996 | waiting(view, gen, scr, *args, **kwargs) 997 | 998 | 999 | class ScrollingBannerLabel(View): 1000 | ''' UI component that scrolls the given text indefinitely, in either 1001 | direction. Will only scroll if the text is too long to fit into this 1002 | component. 1003 | ''' 1004 | 1005 | def __init__(self, **kwargs): 1006 | ''' In addition to normal `ui.View` arguments, you can include: 1007 | 1008 | * `text` - To be scrolled as a marquee. 1009 | * Label formatting arguments `font` and `text_color`. 1010 | * `initial_delay` - How long we wait before we start scrolling, to enable reading the beginning of the text. Default is 2 seconds. 1011 | * `scrolling_speed` - How fast the text moves, in points per second. Default is 100 pts/s. 1012 | * `to_right` - Set to True if you would like the text to scroll from the left. Default is False. 1013 | ''' 1014 | 1015 | self.kwargs = {} 1016 | self.scrolling_speed = kwargs.pop('scrolling_speed', 100) 1017 | self.initial_delay = kwargs.pop('initial_delay', 2.0) 1018 | self._direction = -1 if kwargs.pop('to_right', False) else 1 1019 | if 'font' in kwargs: self.kwargs['font'] = kwargs.pop('font') 1020 | if 'text_color' in kwargs: self.kwargs['text_color'] = kwargs.pop('text_color') 1021 | text = kwargs.pop('text', None) 1022 | 1023 | super().__init__(**kwargs) 1024 | 1025 | self.text = text 1026 | 1027 | @property 1028 | def text(self): 1029 | ''' You can change the text displayed at 1030 | any point after initialization by setting 1031 | this property. ''' 1032 | return self._text 1033 | 1034 | @text.setter 1035 | def text(self, value): 1036 | if value is not None and len(value) > 0: 1037 | value = value.strip() + ' ' 1038 | self._text = value 1039 | self._create_labels() 1040 | 1041 | def stop(self): 1042 | ''' Stops the scrolling and places the text at start. ''' 1043 | cancel(self._scroller) 1044 | self.bounds = (0, 0, self.width, self.height) 1045 | 1046 | def restart(self): 1047 | ''' Restarts the scrolling, including the initial delay, if any. ''' 1048 | self.stop() 1049 | self._create_labels() 1050 | 1051 | def _create_labels(self): 1052 | for view in self.subviews: 1053 | self.remove_subview(view) 1054 | self._text_width = 0 1055 | self._total_width = 0 1056 | if self._text is None or len(self._text) == 0: return 1057 | 1058 | l = Label(text=self._text, **self.kwargs) 1059 | l.size_to_fit() 1060 | l.x, l.y = 0, 0 1061 | self.add_subview(l) 1062 | self._text_width = self._total_width = l.width 1063 | if self._total_width < self.width: 1064 | return 1065 | 1066 | l = Label(text=self._text, **self.kwargs) 1067 | l.size_to_fit() 1068 | l.x, l.y = self._direction * self._total_width, 0 1069 | self._total_width += l.width 1070 | self.add_subview(l) 1071 | self._scroller = self._start_scrolling() 1072 | 1073 | @script 1074 | def _start_scrolling(self): 1075 | duration = self._text_width/self.scrolling_speed 1076 | timer(self.initial_delay) 1077 | yield 1078 | while True: 1079 | bounds(self, (self._direction * self._text_width, 0, self.width, self.height), duration=duration) 1080 | yield 1081 | self.bounds = (0, 0, self.width, self.height) 1082 | 1083 | def touch_ended(self, t): 1084 | if ispaused(self._scroller): 1085 | play(self._scroller) 1086 | else: 1087 | pause(self._scroller) 1088 | 1089 | 1090 | #docgen: Easing functions 1091 | 1092 | def linear(t): 1093 | return t 1094 | def sinusoidal(t): 1095 | return scene_drawing.curve_sinodial(t) 1096 | def ease_in(t): 1097 | return scene_drawing.curve_ease_in(t) 1098 | def ease_out(t): 1099 | return scene_drawing.curve_ease_out(t) 1100 | def ease_in_out(t): 1101 | return scene_drawing.curve_ease_in_out(t) 1102 | def ease_out_in(t): 1103 | return Scripter._cubic('easeOutIn', t) 1104 | def elastic_out(t): 1105 | return scene_drawing.curve_elastic_out(t) 1106 | def elastic_in(t): 1107 | return scene_drawing.curve_elastic_in(t) 1108 | def elastic_in_out(t): 1109 | return scene_drawing.curve_elastic_in_out(t) 1110 | def bounce_out(t): 1111 | return scene_drawing.curve_bounce_out(t) 1112 | def bounce_in(t): 1113 | return scene_drawing.curve_bounce_in(t) 1114 | def bounce_in_out(t): 1115 | return scene_drawing.curve_bounce_in_out(t) 1116 | def ease_back_in(t): 1117 | return scene_drawing.curve_ease_back_in(t) 1118 | def ease_back_in_alt(t): 1119 | return Scripter._cubic('easeInBounce', t) 1120 | def ease_back_out(t): 1121 | return scene_drawing.curve_ease_back_out(t) 1122 | def ease_back_out_alt(t): 1123 | return Scripter._cubic('easeOutBounce', t) 1124 | def ease_back_in_out(t): 1125 | return scene_drawing.curve_ease_back_in_out(t) 1126 | def ease_back_in_out_alt(t): 1127 | return Scripter._cubic('easeInOutBounce', t) 1128 | 1129 | def mirror(ease_func, t): 1130 | ''' Runs the given easing function to the end in half the duration, then 1131 | backwards in the second half. For example, if the function provided is 1132 | `linear`, this function creates a "triangle" from 0 to 1, then back to 0; 1133 | if the function is `ease_in`, the result is more of a "spike".''' 1134 | ease_func = ease_func if callable(ease_func) else partial(Scripter._cubic, ease_func) 1135 | if t < 0.5: 1136 | t /= 0.5 1137 | return ease_func(t) 1138 | else: 1139 | t -= 0.5 1140 | t /= 0.5 1141 | return ease_func(1-t) 1142 | 1143 | def mirror_ease_in(t): 1144 | return mirror(ease_in, t) 1145 | 1146 | def mirror_ease_in_out(t): 1147 | return mirror(ease_in_out, t) 1148 | 1149 | def oscillate(t): 1150 | ''' Basic sine curve that runs from 0 through 1, 0 and -1, and back to 0. ''' 1151 | return math.sin(t*2*math.pi) 1152 | 1153 | 1154 | class Vector (list): 1155 | ''' Simple 2D vector class to make vector operations more convenient. If 1156 | performance is a concern, you are probably better off looking at numpy. 1157 | 1158 | Supports the following operations: 1159 | 1160 | * Initialization from two arguments, two keyword arguments (`x` and `y`), 1161 | tuple, list, or another Vector. 1162 | * Equality and unequality comparisons to other vectors. For floating point 1163 | numbers, equality tolerance is 1e-10. 1164 | * `abs`, `int` and `round` 1165 | * Addition and in-place addition 1166 | * Subtraction 1167 | * Multiplication and division by a scalar 1168 | * `len`, which is the same as `magnitude`, see below. 1169 | 1170 | Sample usage: 1171 | 1172 | v = Vector(x = 1, y = 2) 1173 | v2 = Vector(3, 4) 1174 | v += v2 1175 | assert str(v) == '[4, 6]' 1176 | assert v / 2.0 == Vector(2, 3) 1177 | assert v * 0.1 == Vector(0.4, 0.6) 1178 | assert v.distance_to(v2) == math.sqrt(1+4) 1179 | 1180 | v3 = Vector(Vector(1, 2) - Vector(2, 0)) # -1.0, 2.0 1181 | v3.magnitude *= 2 1182 | assert v3 == [-2, 4] 1183 | 1184 | v3.radians = math.pi # 180 degrees 1185 | v3.magnitude = 2 1186 | assert v3 == [-2, 0] 1187 | v3.degrees = -90 1188 | assert v3 == [0, -2] 1189 | ''' 1190 | 1191 | abs_tol = 1e-10 1192 | 1193 | def __init__(self, *args, **kwargs): 1194 | x = kwargs.pop('x', None) 1195 | y = kwargs.pop('y', None) 1196 | 1197 | if x and y: 1198 | self.append(x) 1199 | self.append(y) 1200 | elif len(args) == 2: 1201 | self.append(args[0]) 1202 | self.append(args[1]) 1203 | else: 1204 | super().__init__(*args, **kwargs) 1205 | 1206 | @property 1207 | def x(self): 1208 | ''' x component of the vector. ''' 1209 | return self[0] 1210 | 1211 | @x.setter 1212 | def x(self, value): 1213 | self[0] = value 1214 | 1215 | @property 1216 | def y(self): 1217 | ''' y component of the vector. ''' 1218 | return self[1] 1219 | 1220 | @y.setter 1221 | def y(self, value): 1222 | self[1] = value 1223 | 1224 | def __eq__(self, other): 1225 | return math.isclose(self[0], other[0], abs_tol=self.abs_tol) and math.isclose(self[1], other[1], abs_tol=self.abs_tol) 1226 | 1227 | def __ne__(self, other): 1228 | return not self.__eq__(other) 1229 | 1230 | def __abs__(self): 1231 | return type(self)(abs(self.x), abs(self.y)) 1232 | 1233 | def __int__(self): 1234 | return type(self)(int(self.x), int(self.y)) 1235 | 1236 | def __add__(self, other): 1237 | return type(self)(self.x + other.x, self.y + other.y) 1238 | 1239 | def __iadd__(self, other): 1240 | self.x += other.x 1241 | self.y += other.y 1242 | return self 1243 | 1244 | def __sub__(self, other): 1245 | return type(self)(self.x - other.x, self.y - other.y) 1246 | 1247 | def __mul__(self, other): 1248 | return type(self)(self.x * other, self.y * other) 1249 | 1250 | def __truediv__(self, other): 1251 | return type(self)(self.x / other, self.y / other) 1252 | 1253 | def __len__(self): 1254 | return self.magnitude 1255 | 1256 | def __round__(self): 1257 | return type(self)(round(self.x), round(self.y)) 1258 | 1259 | def dot_product(self, other): 1260 | ''' Sum of multiplying x and y components with the x and y components of another vector. ''' 1261 | return self.x * other.x + self.y * other.y 1262 | 1263 | def distance_to(self, other): 1264 | ''' Linear distance between this vector and another. ''' 1265 | return (Vector(other) - self).magnitude 1266 | 1267 | @property 1268 | def magnitude(self): 1269 | ''' Length of the vector, or distance from (0,0) to (x,y). ''' 1270 | return math.hypot(self.x, self.y) 1271 | 1272 | @magnitude.setter 1273 | def magnitude(self, m): 1274 | r = self.radians 1275 | self.polar(r, m) 1276 | 1277 | @property 1278 | def radians(self): 1279 | ''' Angle between the positive x axis and this vector, in radians. ''' 1280 | #return round(math.atan2(self.y, self.x), 10) 1281 | return math.atan2(self.y, self.x) 1282 | 1283 | @radians.setter 1284 | def radians(self, r): 1285 | m = self.magnitude 1286 | self.polar(r, m) 1287 | 1288 | def polar(self, r, m): 1289 | ''' Set vector in polar coordinates. `r` is the angle in radians, `m` is vector magnitude or "length". ''' 1290 | self.y = math.sin(r) * m 1291 | self.x = math.cos(r) * m 1292 | 1293 | @property 1294 | def degrees(self): 1295 | ''' Angle between the positive x axis and this vector, in degrees. ''' 1296 | return math.degrees(self.radians) 1297 | 1298 | @degrees.setter 1299 | def degrees(self, d): 1300 | self.radians = math.radians(d) 1301 | 1302 | def steps_to(self, other, step_magnitude=1.0): 1303 | """ Generator that returns points on the line between this and the other point, with each step separated by `step_magnitude`. Does not include the starting point. """ 1304 | if self == other: 1305 | yield other 1306 | else: 1307 | step_vector = other - self 1308 | steps = math.floor(step_vector.magnitude/step_magnitude) 1309 | step_vector.magnitude = step_magnitude 1310 | current_position = Vector(self) 1311 | for _ in range(steps): 1312 | current_position += step_vector 1313 | yield Vector(current_position) 1314 | if current_position != other: 1315 | yield other 1316 | 1317 | def rounded_steps_to(self, other, step_magnitude=1.0): 1318 | ''' As `steps_to`, but returns points rounded to the nearest integer. ''' 1319 | for step in self.steps_to(other): 1320 | yield round(step) 1321 | 1322 | 1323 | if __name__ == '__main__': 1324 | 1325 | import editor, scene_drawing 1326 | 1327 | class DemoBackground(View): 1328 | 1329 | def __init__(self): 1330 | self.start_point = (0,0) 1331 | self.axes_counter = 0 1332 | self.curve_point_x = None 1333 | self.curve_point_y = None 1334 | self.curve = [] 1335 | self.hide_curve = False 1336 | 1337 | def draw(self): 1338 | if self.axes_counter > 0: 1339 | spx = self.start_point.x 1340 | spy = self.start_point.y 1341 | set_color('black') 1342 | path = Path() 1343 | path.move_to(spx, spy) 1344 | path.line_to(spx+self.axes_counter, spy) 1345 | path.move_to(spx, spy) 1346 | path.line_to(spx, spy-self.axes_counter) 1347 | 1348 | end_size = 10 1349 | path.move_to(spx+self.axes_counter, spy) 1350 | path.line_to(spx+self.axes_counter-end_size, spy-end_size) 1351 | path.move_to(spx+self.axes_counter, spy) 1352 | path.line_to(spx+self.axes_counter-end_size, spy+end_size) 1353 | path.move_to(spx, spy-self.axes_counter) 1354 | path.line_to(spx-end_size, spy-self.axes_counter+end_size) 1355 | path.move_to(spx, spy-self.axes_counter) 1356 | path.line_to(spx+end_size, spy-self.axes_counter+end_size) 1357 | 1358 | path.stroke() 1359 | 1360 | path = Path() 1361 | set_color('#91cf96') 1362 | path.move_to(spx, spy) 1363 | 1364 | if self.curve_point_x is not None: 1365 | self.curve.append((spx+self.curve_point_x, spy-self.curve_point_y)) 1366 | 1367 | if not self.hide_curve: 1368 | for px, py in self.curve: 1369 | path.line_to(px, py) 1370 | 1371 | path.stroke() 1372 | 1373 | def trigger_refresh(self, value): 1374 | self.set_needs_display() 1375 | return value 1376 | 1377 | v = DemoBackground() 1378 | start_scripter(v) 1379 | v.background_color = 'white' 1380 | v.present('fullscreen') 1381 | 1382 | @script 1383 | def printer(count): 1384 | print(f'- Script {count}') 1385 | 1386 | @script 1387 | def steps_block_test(): 1388 | print('Steps block test:') 1389 | with steps(): 1390 | printer('A.1') 1391 | printer('A.2') 1392 | with steps(): 1393 | printer('B.1') 1394 | printer('B.2') 1395 | 1396 | steps_block_test() 1397 | 1398 | class Demo(View): 1399 | 1400 | def __init__(self, *args, **kwargs): 1401 | super().__init__(self, *args, *kwargs) 1402 | self.frame = (100, 100, 150, 40) 1403 | self.background_color = 'white' 1404 | self.c = c = View(frame=(0,0,10,10)) 1405 | c.corner_radius = 5 1406 | c.center = (15, 20) 1407 | c.hidden = True 1408 | c.background_color = 'black' 1409 | self.l = l = Label(text='Scripter Demo') 1410 | l.flex = 'WH' 1411 | l.text_color = 'black' 1412 | l.alignment = ALIGN_CENTER 1413 | self.add_subview(c) 1414 | self.add_subview(l) 1415 | l.frame = self.bounds 1416 | self.tv = tv = TextView(background_color='white') 1417 | tv.text = editor.get_text() 1418 | tv.text_color = '#4a4a4a' 1419 | tv.frame = (2, 2, 146, 36) 1420 | tv.flex = 'WH' 1421 | tv.alpha = 0 1422 | self.add_subview(tv) 1423 | #self.hidden = True 1424 | 1425 | @script 1426 | def demo_script(self): 1427 | show(self) 1428 | pulse(self, duration=1.0) 1429 | yield 'wait' 1430 | move(self, 200, 200) 1431 | yield 1432 | 1433 | # Combine a primitive with a lambda and 1434 | # target the contained Label instead of self 1435 | set_value(self.l, 'text', range(1, 101), lambda count: f'Count: {count}') 1436 | yield 1 1437 | 1438 | # Transformations 1439 | self.l.text = 'Rotating' 1440 | rotate(self, -720, ease_func=ease_back_in_out, duration=1.5) 1441 | background_color(self, 'green', duration=2.0) 1442 | slide_color(self.l, 'text_color', 'white', duration=2.0) 1443 | yield 'wait' 1444 | 1445 | # Create another view and control it as well 1446 | # Use a steps block to drive parallel animations without 1447 | # necessarily having to break the flow with a separate function 1448 | self.l.text = 'Move two' 1449 | other = View(background_color='red', frame=(10, 200, 150, 40)) 1450 | v.add_subview(other) 1451 | move(other, 200, 400, duration=1.0) 1452 | with steps(): 1453 | move(self, 50, 200) 1454 | move(self, 50, 400) 1455 | yield 1456 | hide(other) 1457 | fly_out(other, 'down') 1458 | yield 1459 | v.remove_subview(other) 1460 | yield 'wait' 1461 | 1462 | # Driving custom View.draw animation 1463 | self.l.text = 'Custom' 1464 | self.c.hidden = False 1465 | background_color(self, 'transparent') 1466 | text_color(self.l, 'black') 1467 | v.start_point = SimpleNamespace(x=self.x+15, y=self.y+20) 1468 | set_value(v, 'axes_counter', range(1, 210, 3), func=v.trigger_refresh) 1469 | yield 'wait' 1470 | slide_value(v, 'curve_point_x', 200, start_value=1, duration=2.0) 1471 | slide_value(v, 'curve_point_y', 200, start_value=1, 1472 | ease_func=ease_back_in_out, map_func=v.trigger_refresh, duration=2.0) 1473 | yield 'wait' 1474 | 1475 | x(self, self.x+200, duration=2.0) 1476 | y(self, self.y-200, ease_func=ease_back_in_out, duration=2.0) 1477 | yield 'wait' 1478 | 1479 | self.l.text = 'Bounce' 1480 | self.c.hidden = True 1481 | background_color(self, 'green') 1482 | text_color(self.l, 'white') 1483 | width(self, 76) 1484 | height(self, 76) 1485 | corner_radius(self, 38) 1486 | v.hide_curve = True 1487 | v.set_needs_display() 1488 | yield 1489 | x(self, v.start_point.x, ease_func='easeOut', duration=2.0) 1490 | y(self, v.start_point.y-self.height, 1491 | ease_func=scene_drawing.curve_bounce_out, duration=2.0) 1492 | yield 1.0 1493 | 1494 | self.l.text = 'Roll' 1495 | yield 'wait' 1496 | roll_to(self, (self.center[0]+170, self.center[1]), duration=2.0) 1497 | yield 1.0 1498 | roll_to(self, (self.center[0]-170, self.center[1]), duration=2.0) 1499 | yield 1.0 1500 | 1501 | v.axes_counter = 0 1502 | v.set_needs_display() 1503 | self.c.hidden = True 1504 | expand(self) 1505 | corner_radius(self, 0) 1506 | background_color(self, 'white') 1507 | show(self.tv) 1508 | content_offset(self.tv, (0,0)) 1509 | yield 1.0 1510 | 1511 | content_offset(self.tv, (0, self.tv.content_size[1]), duration=20) 1512 | self.end_fade() 1513 | 1514 | @script 1515 | def end_fade(self): 1516 | yield 7.0 1517 | hide(v) 1518 | 1519 | 1520 | s = Demo() 1521 | v.add_subview(s) 1522 | 1523 | now_running = s.demo_script() 1524 | scr = find_scripter_instance() 1525 | 1526 | def pause_action(sender): 1527 | scr.pause_play_all() 1528 | pause_button.title = 'Pause' if scr.running else 'Play' 1529 | 1530 | pause_button = Button(title='Pause') 1531 | pause_button.frame = (v.width-85, 60, 75, 40) 1532 | pause_button.background_color = 'black' 1533 | pause_button.tint_color = 'white' 1534 | pause_button.action = pause_action 1535 | v.add_subview(pause_button) 1536 | 1537 | def cancel_demo(sender): 1538 | scr.cancel(now_running) 1539 | hide(pause_button) 1540 | hide(sender) 1541 | # or could just use 1542 | # scr.cancel_all() 1543 | # if clear that nothing else will be running 1544 | 1545 | b = Button(title='Cancel') 1546 | b.frame = (v.width-85, 10, 75, 40) 1547 | b.background_color = 'black' 1548 | b.tint_color = 'white' 1549 | b.action = cancel_demo 1550 | v.add_subview(b) 1551 | 1552 | gradient(b, duration=2.0) 1553 | 1554 | m = ScrollingBannerLabel( 1555 | text='This is a scripter test, with an info text that is ' 1556 | 'too long to fit on screen at once.', 1557 | font=('Futura', 24), 1558 | text_color='green', 1559 | frame=(40, v.height-50, v.width-80, 40)) 1560 | v.add_subview(m) 1561 | --------------------------------------------------------------------------------