├── README.md ├── anchor-demo.py ├── docs ├── anchor.md ├── gestures.md ├── menu.md ├── richlabel.md ├── safearea.md ├── scripter.md ├── sfsymbol.md └── vector.md ├── gridview-demo.py ├── pyproject.toml ├── sfsymbolbrowser-demo.py ├── sheet.py └── ui3 ├── __init__.py ├── anchor ├── __init__.py ├── core.py ├── objc_plus.py ├── observer.py └── safe_area.py ├── gestures.py ├── gridview.py ├── hierarchy.py ├── menu.py ├── pagecontrol.py ├── richlabel.py ├── safearea.py ├── sfsymbol.py ├── sfsymbol_browser.py ├── sfsymbolnames-2_1.json └── sfsymbols-restricted-2_1.json /README.md: -------------------------------------------------------------------------------- 1 | # UI utilities for Pythonista (iOS app) 2 | 3 | Repository of small UI and other utilities, install with: 4 | 5 | pip install ui3 6 | 7 | Included with separate docs: 8 | 9 | - anchor - more natural and responsive UI layout creation with anchoring and docking instead of pixel placement 10 | - controllers - wrappers around various iOS built-in dialogs 11 | - [gestures](docs/gestures.md) - Pythonic wrapper around iOS gestures (tap, long press, pinch etc.); includes drag and drop support within and between apps 12 | - gridview - places subviews in grid that is optimized to show as even rectangles as possible 13 | - [menu](docs/menu.md) - wrapper for iOS 14 button pop-up menus 14 | - [richlabel](docs/richlabel.md) - use markup to create fancier labels 15 | - [safearea](docs/safearea.md) - avoids edges (on iPhones) 16 | - scripter - generator-driven UI animations 17 | - [sfsymbol](docs/sfsymbol.md) - use iOS 14 Apple SFSymbols as icon images; includes a browser of all symbols 18 | - [vector](docs/vector.md) - vector class for easier UI calculations 19 | - wkwebview - wrapper for WKWebView, to replace legacy ui.WebView 20 | -------------------------------------------------------------------------------- /anchor-demo.py: -------------------------------------------------------------------------------- 1 | import math 2 | import inspect 3 | import random 4 | import time 5 | 6 | import ui 7 | 8 | from ui3.anchor import * 9 | from ui3.safearea import SafeAreaView 10 | 11 | accent_color = '#cae8ff' 12 | 13 | 14 | root = SafeAreaView( 15 | name='root', 16 | background_color='black', 17 | ) 18 | 19 | main_content = ui.View( 20 | frame=root.bounds, flex='WH', 21 | ) 22 | root.add_subview(main_content) 23 | 24 | def style(*views): 25 | for v in views: 26 | v.background_color = 'black' 27 | v.text_color = v.tint_color = v.border_color = 'white' 28 | v.border_width = 1 29 | v.alignment = ui.ALIGN_CENTER 30 | 31 | return v 32 | 33 | def style2(*views): 34 | for v in views: 35 | v.background_color = accent_color 36 | v.text_color = v.tint_color = 'black' 37 | v.alignment = ui.ALIGN_CENTER 38 | v.font = ('Arial Rounded MT Bold', 12) 39 | 40 | return v 41 | 42 | def style_label(v): 43 | v.background_color = 'black' 44 | v.text_color = 'white' 45 | v.alignment = ui.ALIGN_CENTER 46 | return v 47 | 48 | def create_area(title): 49 | area = style(ui.View(name=title[:4])) 50 | label = style_label(size_to_fit(ui.Label( 51 | text=title.upper(), 52 | #number_of_lines=0, 53 | font=('Arial Rounded MT Bold', 12), 54 | ))) 55 | dock(label).top_right(area, At.TIGHT) 56 | return area 57 | 58 | # ------ Button flow 59 | 60 | button_area = style(ui.View(name='button_area')) 61 | dock(button_area).bottom(main_content, At.TIGHT) 62 | button_label = style_label(ui.Label( 63 | text='FLOW', 64 | font=('Arial Rounded MT Bold', 12), 65 | )) 66 | button_label.size_to_fit() 67 | ghost_area = ui.View() 68 | main_content.add_subview(ghost_area) 69 | at(ghost_area).frame = at(button_area).frame 70 | dock(button_label).bottom_right(ghost_area) 71 | buttons = [ 72 | size_to_fit(style2(ui.Button( 73 | title=f'button {i + 1}'))) 74 | for i in range(6) 75 | ] 76 | flow(*buttons).from_top_left(button_area) 77 | at(button_area).height = at(button_area).fit_height 78 | 79 | content_area = style(ui.View(name='content_area')) 80 | dock(content_area).top(main_content, At.TIGHT) 81 | 82 | at(content_area).bottom = at(button_area).top - At.TIGHT 83 | 84 | at_area = create_area('basic at & flex') 85 | pointer_area = create_area('heading, custom, func') 86 | dock_area = create_area('dock') 87 | align_area = create_area('align') 88 | 89 | fill_with( 90 | at_area, 91 | dock_area, 92 | pointer_area, 93 | align_area, 94 | ).from_top(content_area, 2) 95 | 96 | def make_label(text): 97 | return size_to_fit(style2(ui.Label( 98 | text=text, 99 | number_of_lines=0))) 100 | 101 | # ----- Sidebar & menu button 102 | 103 | 104 | sidebar = style(ui.View(width=300)) 105 | root.add_subview(sidebar) 106 | at(sidebar).top = at(main_content).top 107 | at(sidebar).bottom = at(main_content).bottom 108 | at(sidebar).right = at(main_content).left 109 | 110 | menu_button = size_to_fit(style(ui.Button( 111 | image=ui.Image('iow:ios7_drag_32')))) 112 | menu_button.width = menu_button.height 113 | dock(menu_button).top_left(main_content, At.TIGHT) 114 | 115 | def open_and_close(sender): 116 | if main_content.x == 0: 117 | main_content.x = -sidebar.x 118 | else: 119 | main_content.x = 0 120 | 121 | menu_button.action = open_and_close 122 | 123 | # ----- At & flex 124 | 125 | vertical_bar = style(ui.View(name='vbar', 126 | width=10)) 127 | at_area.add_subview(vertical_bar) 128 | at(vertical_bar).center_x = at(at_area).width / 5 129 | 130 | align(vertical_bar).center_y(at_area) 131 | #at(vertical_bar).height = at(at_area).height * 0.75 132 | at(vertical_bar).top = 20 133 | attr(vertical_bar).border_color = lambda: (random.random(),) * 3 134 | 135 | fix_left = make_label('fix left') 136 | at_area.add_subview(fix_left) 137 | at(fix_left).left = at(vertical_bar).right 138 | align(fix_left).center_y(vertical_bar, -30) 139 | 140 | flex = make_label('fix left and right') 141 | at_area.add_subview(flex) 142 | at(flex).left = at(vertical_bar).right + At.TIGHT 143 | at(flex).right = at(at_area).right 144 | align(flex).center_y(vertical_bar, +30) 145 | 146 | # ------ Heading & custom 147 | 148 | def make_symbol(character): 149 | symbol = make_label(character) 150 | symbol.font = ('Arial Rounded MT Bold', 18) 151 | pointer_area.add_subview(symbol) 152 | size_to_fit(symbol) 153 | symbol.width = symbol.height 154 | symbol.objc_instance.clipsToBounds = True 155 | symbol.corner_radius = symbol.width / 2 156 | return symbol 157 | 158 | pointer_area.name = 'pointer_area' 159 | 160 | target = make_symbol('⌾') 161 | target.font = (target.font[0], 44) 162 | at(target).center_x = at(pointer_area).center_x / 1.75 163 | at(target).center_y = at(pointer_area).height - 60 164 | 165 | pointer = make_symbol('↣') 166 | pointer.name = 'pointer' 167 | pointer.text_color = accent_color 168 | pointer.background_color = 'transparent' 169 | pointer.font = (pointer.font[0], 40) 170 | 171 | align(pointer).center(pointer_area) 172 | at(pointer).heading = at(target).center 173 | 174 | heading_label = ui.Label(text='000°', 175 | font=('Arial Rounded MT Bold', 12), 176 | text_color=accent_color, 177 | alignment=ui.ALIGN_CENTER, 178 | ) 179 | heading_label.size_to_fit() 180 | pointer_area.add_subview(heading_label) 181 | at(heading_label).center_y = at(pointer).center_y - 22 182 | align(heading_label).center_x(pointer) 183 | 184 | attr(heading_label).text = at(pointer).heading + ( 185 | lambda angle: f"{int(math.degrees(angle))%360:03}°" 186 | ) 187 | 188 | # ----- Dock & attach 189 | 190 | top_center = make_label('top\ncenter') 191 | dock(top_center).top_center(dock_area) 192 | dock(make_label('left')).left(dock_area) 193 | dock(make_label('bottom\nright')).bottom_right(dock_area) 194 | dock(make_label('center')).center(dock_area) 195 | 196 | dock(make_label('attach')).below(top_center) 197 | 198 | # ----- Align 199 | 200 | l1 = make_label('1') 201 | align_area.add_subview(l1) 202 | at(l1).center_x = at(align_area).center_x / 2 203 | l2 = make_label('2') 204 | align_area.add_subview(l2) 205 | at(l2).center_x = at(align_area).center_x 206 | l3 = make_label('3') 207 | align_area.add_subview(l3) 208 | at(l3).center_x = at(align_area).center_x / 2 * 3 209 | 210 | align(l1, l2, l3).center_y(align_area) 211 | 212 | 213 | # ------ Markers 214 | 215 | show_markers = True 216 | 217 | if show_markers: 218 | 219 | marker_counter = 0 220 | 221 | def create_marker(superview): 222 | global marker_counter 223 | marker_counter += 1 224 | marker = make_label(str(marker_counter)) 225 | superview.add_subview(marker) 226 | marker.background_color = 'white' 227 | marker.border_color = 'black' 228 | marker.border_width = 1 229 | size_to_fit(marker) 230 | marker.width = marker.height 231 | marker.objc_instance.clipsToBounds = True 232 | marker.corner_radius = marker.width / 2 233 | return marker 234 | 235 | m1 = create_marker(at_area) 236 | align(m1).center_y(fix_left) 237 | at(m1).left = at(fix_left).right 238 | 239 | m2 = create_marker(at_area) 240 | align(m2).left(flex) 241 | at(m2).center_y = at(flex).top - At.gap 242 | 243 | m3 = create_marker(at_area) 244 | align(m3).right(flex) 245 | at(m3).center_y = at(flex).top - At.gap 246 | 247 | m4 = create_marker(pointer_area) 248 | at(m4).top = at(pointer).bottom + 3*At.TIGHT 249 | at(m4).left = at(pointer).right + 3*At.TIGHT 250 | 251 | m5 = create_marker(pointer_area) 252 | align(m5).center_y(heading_label) 253 | at(m5).left = at(heading_label).right 254 | 255 | m6 = create_marker(dock_area) 256 | at(m6).center_x = at(dock_area).center_x 257 | at(m6).center_y = at(dock_area).center_y * 1.5 258 | 259 | m7 = create_marker(align_area) 260 | align(m7).center_x(align_area) 261 | at(m7).top = at(l2).bottom 262 | 263 | mc = create_marker(content_area) 264 | at(mc).center = at(content_area).center 265 | 266 | mb = create_marker(button_area) 267 | last_button = buttons[-1] 268 | align(mb).center_y(last_button) 269 | at(mb).left = at(last_button).right 270 | 271 | mr = create_marker(content_area) 272 | at(mr).right = at(content_area).right 273 | at(mr).bottom = at(content_area).bottom - At.TIGHT 274 | 275 | ms = create_marker(main_content) 276 | at(ms).center_x = at(button_area).center_x * 1.5 277 | at(ms).bottom = at(button_area).bottom 278 | 279 | root.present('fullscreen', 280 | animated=False, 281 | hide_title_bar=True, 282 | ) 283 | 284 | -------------------------------------------------------------------------------- /docs/anchor.md: -------------------------------------------------------------------------------- 1 | Pythonista UI constraints driven by the Key Value Observing (KVO) protocol 2 | 3 | ## Installation 4 | 5 | pip install ui3 6 | 7 | ## History 8 | 9 | [First version](https://github.com/mikaelho/pythonista-uiconstraints) of UI constraints for Pythonista was created as a wrapper around Apple [NSLayoutConstraint](https://developer.apple.com/documentation/uikit/nslayoutconstraint?language=objc) class. While functional, it suffered from the same restrictions as the underlying Apple class, and was somewhat inconvenient to develop with, due to the "either frames or constraints" mindset and some mystical crashes. 10 | 11 | [Second version]() was built on top of the [scripter](https://github.com/mikaelho/scripter), utilizing the `ui.View` `update` method that gets called several times a second. Constraints could now be designed freely, and this version acted as an excellent proof of concept. There was the obvious performance concern due to the constraints being checked constantly, even if nothing was happening in the UI – easily few thousand small checks per second for a realistic UI. 12 | 13 | This version replaces the `update` method with the [KVO](https://developer.apple.com/documentation/objectivec/nsobject/nskeyvalueobserving?language=objc) (Key Value Observing) protocol, running the constraint checks only when the position or the frame of the view changes. Thus we avoid the performance overhead while retaining the freedom of custom constraints. 14 | 15 | ## Usage 16 | 17 | Examples in this section assume that you have imported anchors: 18 | 19 | ``` 20 | from ui3.anchors import * 21 | ``` 22 | 23 | To cover the main anchor features, please refer to this picture with handy numbering: 24 | 25 | ![With markers](https://raw.githubusercontent.com/mikaelho/scripter/master/images/anchor-with-markers.png) 26 | 27 | Features: 28 | 29 | 1. `at` is the basic workhorse. Applied to views, you can set layout constraints that hold when the UI otherwise changes. In the example, to fix the left edge of the blue view `fix_left` to the vertical bar: 30 | 31 | ``` 32 | at(fix_left).left = at(vertical_bar).right 33 | ``` 34 | 35 | Now, if the vertical bar moves for some reason, our fixed view goes right along. 36 | 37 | 2. The small gap between the view and the vertical bar is an Apple Standard gap of 8 points, and it is included between anchored views by default. You can add any modifiers to an anchor to change this gap. If you specifically want the views to be flush against each other, there is a constant for that, as demonstrated by our second example view, `flex`: 38 | 39 | ``` 40 | at(flex).left = at(vertical_bar).right + At.TIGHT 41 | ``` 42 | 43 | There is an option of changing the gap between views globally, to `0` if you want, by setting the `At.gap` class variable at the top of your program. 44 | 45 | 3. If we fix the other end of the view as well, the view width is adjusted as needed. The example demonstrates fixing the other end to the edge of the containing view, which looks very similar to the previous examples: 46 | 47 | ``` 48 | at(flex).right = at(containing_view).right 49 | ``` 50 | 51 | The full set of `at` attributes is: 52 | 53 | - Edges: `left, right, top, bottom` 54 | - Center: `center, center_x, center_y` 55 | - Size: `width, height, size` 56 | - Position: `position` 57 | - Position and size: `frame, bounds` 58 | - "Exotics": `heading, fit_size, fit_width, fit_height` 59 | 60 | Instead of the `at` function on the right, you can also provide a constant or a function: 61 | 62 | ``` 63 | at(vertical_bar).center_y = at(at_area).center_y 64 | at(vertical_bar).top = 30 65 | ``` 66 | 67 | With the center fixed, this effectively means that the top and the bottom of the vertical bar are always 30 pixels away from the edges of the superview. 68 | 69 | If you use just a function in your constraint, it should not expect any parameters. 70 | 71 | 4. As an experiment in what can be done beyond the previous Apple's UI constraint implementation, there is an anchor that will keep a view pointed to the center of another view: 72 | 73 | ``` 74 | at(pointer).heading = at(target).center 75 | ``` 76 | 77 | Implementation assumes that the pointer is initially pointed to 0, or right. If your graphic is actually initially pointed to, say, down, you can make the `heading` constraint work by telling it how much the initial angle needs to be adjusted (in radians): `at(pointer).heading_adjustment = -math.pi/2` would mean a 90 degree turn counterclockwise. 78 | 79 | 5. Generalizing the basic anchor idea, I included an `attr` function that can be used to "anchor" any attribute of any object. In the example, we anchor the `text` attribute of a nearby label to always show the heading of the pointer. Because the heading is a number and the label expects a string, there is an option of including a translation function like this: 80 | 81 | ``` 82 | attr(heading_label).text = at(pointer).heading + str 83 | ``` 84 | 85 | Actually, since the plain radians look a bit ugly, a little bit more complicated conversion is needed: 86 | 87 | ``` 88 | attr(heading_label).text = at(pointer).heading + ( 89 | lambda heading: f'{int(math.degrees(heading))%360:03}°' 90 | ) 91 | ``` 92 | 93 | Because Key Value Observing cannot in general be applied to random attributes, `attr()` is useful as a target only (i.e. on the left), as then it will be updated when the source (on the right) changes. 94 | 95 | 6. Docking or placing a view in some corner or some other position relative to its superview is very common. Thus there is a `dock` convenience function specifically for that purpose. For example, to attach the `top_center_view` to the top center of the `container` view: 96 | 97 | ``` 98 | dock(top_center_view).top_center(container) 99 | ``` 100 | 101 | Full set of superview docking functions is: 102 | 103 | - `all, bottom, top, right, left` 104 | - `top_left, top_right, bottom_left, bottom_right` 105 | - `sides` (left and right), `vertical` (top and bottom) 106 | - `top_center, bottom_center, left_center, right_center` 107 | - `center` 108 | 109 | For your convenience, `dock` will also add the view as a subview of the container. 110 | 111 | `dock` is also applied to docking to another view in a shared superview. This is a convenient way of placing a view beside another view, center aligned with the reference. For example, placing a view under the other view: 112 | 113 | ``` 114 | dock(second_view).below(first_view) 115 | ``` 116 | 117 | The methods available are `below`, `above`, `left_of` and `right_of`. These also add the docked view as a subview of the same parent as the reference. 118 | 119 | 7. Often, it is convenient to set the same anchor for several views at once, or just not repeat the anchor name when it is the same for both the source and the target. `align` function helps with this, in this example aligning all the labels in the `labels` array with the vertical center of the container view: 120 | 121 | ``` 122 | align(*labels).center_y(container) 123 | ``` 124 | 125 | `align` attributes match all the `at` attributes for which the concept of setting several anchors at once may make sense: 126 | `left, right, top, bottom, center, center_x, center_y, width, height, position, size, frame, bounds, heading`. 127 | 128 | 8. Filling an area with similarly-sized containers can be done with the `fill` function. In the example we create, in the `content_area` superview, 4 areas in 2 columns: 129 | 130 | ``` 131 | fill_with( 132 | at_area, 133 | dock_area, 134 | pointer_area, 135 | align_area, 136 | ).from_top(content_area, count=2) 137 | ``` 138 | 139 | Default value of `count=1` fills a single column or row. Filling can be started from any of the major directions: `from_top, from_bottom, from_left, from_right`. 140 | 141 | 9. `flow` function is another layout helper that lets you add a number of views which get placed side by side, then wrapped at the end of the row, mimicking the basic LTR text flow when you start from the top left, like in the example: 142 | 143 | ``` 144 | flow(*buttons).from_top_left(button_area) 145 | ``` 146 | 147 | The full set of flow functions support starting from different corners and flowing either horizontally or vertically: 148 | - Horizontal: `from_top_left, from_bottom_left, from_top_right, from_bottom_right` 149 | - Vertical: `from_left_down, from_right_down, from_left_up, from_right_up` 150 | 151 | 10. For sizing a superview according to its contents, you can use the `fit_size, fit_width` or `fit_height` anchor attributes. In our example we make the button container resize according to how much space the buttons need (with the content area above stretching to take up any available space): 152 | 153 | ``` 154 | at(button_area).height = at(button_area).fit_height 155 | at(content_area).bottom = at(button_area).top - At.TIGHT 156 | ``` 157 | 158 | If you use this, it is good to consider how volatile your anchors make the views within the container. For example, you cannot have a vertical `flow` in a view that automatically resizes its height. 159 | 160 | 11. With the KVO "engine", there is a millisecond timing issue that means that the safe area layout guides are not immediately available after you have `present`ed the root view. Thus the anchors cannot rely on them. 161 | 162 | Thus `anchors` provides a `SafeAreaView` that never overlaps the non-safe areas of your device display. You use it like you would a standard `ui.View`, as a part of your view hierarchy or as the root: 163 | 164 | ``` 165 | root = SafeAreaView( 166 | name='root', 167 | background_color='black', 168 | ) 169 | root.present('fullscreen', 170 | hide_title_bar=True, 171 | animated=False, 172 | ) 173 | 174 | -------------------------------------------------------------------------------- /docs/gestures.md: -------------------------------------------------------------------------------- 1 | # gestures 2 | 3 | Gestures wrapper for iOS 4 | 5 | # Gestures for the Pythonista iOS app 6 | 7 | This is a convenience class for enabling gestures, including drag and drop 8 | support, in Pythonista UI applications. Main intent here has been to make 9 | them Python friendly, hiding all the Objective-C details. 10 | 11 | Run the file on its own to see a demo of the supported gestures. 12 | 13 | ![Demo image](https://raw.githubusercontent.com/mikaelho/pythonista-gestures/master/gestures.jpg) 14 | 15 | ## Installation 16 | 17 | Copy from [GitHub](https://github.com/mikaelho/pythonista-gestures), or 18 | 19 | pip install ui3 20 | 21 | with [stash](https://github.com/ywangd/stash). 22 | 23 | ## Versions: 24 | 25 | * 1.3 - Add `first` to declare priority for the gesture, and an option to use 26 | the fine-tuning methods with ObjC gesture recognizers. 27 | * 1.2 - Add drag and drop support. 28 | * 1.1 - Add distance parameters to swipe gestures. 29 | * 1.0 - First version released to PyPi. 30 | Breaks backwards compatibility in syntax, adds multi-recognizer coordination, 31 | and removes force press support. 32 | 33 | ## Usage 34 | 35 | For example, do something when user swipes left on a Label: 36 | 37 | import ui3.gestures as g 38 | 39 | def swipe_handler(data): 40 | print(f‘I was swiped, starting from {data.location}') 41 | 42 | label = ui.Label() 43 | g.swipe(label, swipe_handler, direction=g.LEFT) 44 | 45 | Your handler method gets one `data` argument that always contains the 46 | attributes described below. Individual gestures may provide more 47 | information; see the API documentation for the methods used to add different 48 | gestures. 49 | 50 | * `recognizer` - (ObjC) recognizer object 51 | * `view` - (Pythonista) view that was gestured at 52 | * `location` - Location of the gesture as a `ui.Point` with `x` and `y` 53 | attributes 54 | * `state` - State of gesture recognition; one of 55 | `gestures.POSSIBLE/BEGAN/RECOGNIZED/CHANGED/ENDED/CANCELLED/FAILED` 56 | * `began`, `changed`, `ended`, `failed` - convenience boolean properties to 57 | check for these states 58 | * `number_of_touches` - Number of touches recognized 59 | 60 | For continuous gestures, check for `data.began` or `data.ended` in the handler 61 | if you are just interested that a pinch or a force press happened. 62 | 63 | All of the gesture-adding methods return an object that can be used 64 | to remove or disable the gesture as needed, see the API. You can also remove 65 | all gestures from a view with `remove_all_gestures(view)`. 66 | 67 | ## Fine-tuning gesture recognition 68 | 69 | By default only one gesture recognizer will be successful. 70 | 71 | If you just want to say "this recognizer goes first", the returned object 72 | contains an easy method for that: 73 | 74 | doubletap(view, handler).first() 75 | 76 | You can set priorities between recognizers 77 | more specifically by using the `before` method of the returned object. 78 | For example, the following ensures that the swipe always has a chance to happen 79 | first: 80 | 81 | swipe(view, swipe_handler, direction=RIGHT).before( 82 | pan(view, pan_handler) 83 | ) 84 | 85 | (For your convenience, there is also an inverted `after` method.) 86 | 87 | You can also allow gestures to be recognized simultaneously using the 88 | `together_with` method. For example, the following enables simultaneous panning 89 | and zooming (pinching): 90 | 91 | panner = pan(view, pan_handler) 92 | pincher = pinch(view, pinch_handler) 93 | panner.together_with(pincher) 94 | 95 | All of these methods (`before`, `after` and `together_with`) also accept an 96 | ObjCInstance of any gesture recognizer, if you need to fine-tune co-operation 97 | with the gestures of some built-in views. 98 | 99 | ## Drag and drop 100 | 101 | This module supports dragging and dropping both within a Pythonista app and 102 | between Pythonista and another app (only possible on iPads). These two cases 103 | are handled differently: 104 | 105 | * For in-app drops, Apple method of relaying objects is skipped completely, 106 | and you can refer to _any_ Python object to be dropped to the target view. 107 | * For cross-app drops, we have to conform to Apple method of managing data. 108 | Currently only plain text and image drops are supported, in either direction. 109 | * It is also good to note that `ui.TextField` and `ui.TextView` views natively 110 | act as receivers for both in-app and cross-app plain text drag and drop. 111 | 112 | View is set to be a sender for a drap and drop operation with the `drag` 113 | function. Drag starts with a long press, and can end in any view that has been 114 | set as a receiver with the `drop` function. Views show the readiness to receive 115 | data with a green "plus" sign. You can accept only specific types of data; 116 | incompatible drop targets show a grey "forbidden" sign. 117 | 118 | Following example covers setting up an in-app drag and drop operation between 119 | two labels. To repeat, in the in-app case, the simple string could replaced by 120 | any Python object of any complexity, passed by reference: 121 | 122 | drag(sender_label, "Important data") 123 | 124 | drop(receiver_label, 125 | lambda data, sender, receiver: setattr(receiver, 'text', data), 126 | accept=str) 127 | 128 | See the documentation for the two functions for details. 129 | 130 | ## Using lambdas 131 | 132 | If there in existing method that you just want to trigger with a gesture, 133 | often you do not need to create an extra handler function. 134 | This works best with the discrete `tap` and `swipe` gestures where we do not 135 | need to worry with the state of the gesture. 136 | 137 | tap(label, lambda _: setattr(label, 'text', 'Tapped')) 138 | 139 | For continuous gestures, the example below triggers some kind of a hypothetical 140 | database refresh when a long press is 141 | detected on a button. 142 | Anything more complicated than this is probably worth creating a separate 143 | function. 144 | 145 | long_press(button, lambda data: db.refresh() if data.began else None) 146 | 147 | ## Pythonista app-closing gesture 148 | 149 | When you use the `hide_title_bar=True` attribute with `present`, you close 150 | the app with the 2-finger-swipe-down gesture. This gesture can be 151 | disabled with: 152 | 153 | g.disable_swipe_to_close(view) 154 | 155 | where the `view` must be the one you `present`. 156 | 157 | You can also replace the close gesture with another, by providing the 158 | "magic" `close` string as the gesture handler. For example, 159 | if you feel that tapping with two thumbs is more convenient in two-handed 160 | phone use: 161 | 162 | g.tap(view, 'close', number_of_touches_required=2) 163 | 164 | ## Other details 165 | 166 | * Adding a gesture or a drag & drop handler to a view automatically sets 167 | `touch_enabled=True` for that 168 | view, to avoid counter-intuitive situations where adding a gesture 169 | recognizer to e.g. ui.Label produces no results. 170 | * It can be hard to add gestures to ui.ScrollView, ui.TextView and the like, 171 | because they have complex multi-view structures and gestures already in 172 | place. 173 | 174 | # API 175 | 176 | * [Functions](#functions) 177 | * [Gestures](#gestures) 178 | * [Gesture management](#gesture-management) 179 | * [Drag and drop](#drag-and-drop) 180 | 181 | 182 | # Functions 183 | 184 | 185 | #### GESTURES 186 | #### `tap(view, action,number_of_taps_required=None, number_of_touches_required=None)` 187 | 188 | Call `action` when a tap gesture is recognized for the `view`. 189 | 190 | Additional parameters: 191 | 192 | * `number_of_taps_required` - Set if more than one tap is required for 193 | the gesture to be recognized. 194 | * `number_of_touches_required` - Set if more than one finger is 195 | required for the gesture to be recognized. 196 | 197 | #### `doubletap(view, action,number_of_touches_required=None)` 198 | 199 | Convenience method that calls `tap` with a 2-tap requirement. 200 | 201 | 202 | #### `long_press(view, action,number_of_taps_required=None,number_of_touches_required=None,minimum_press_duration=None,allowable_movement=None)` 203 | 204 | Call `action` when a long press gesture is recognized for the 205 | `view`. Note that this is a continuous gesture; you might want to 206 | check for `data.changed` or `data.ended` to get the desired results. 207 | 208 | Additional parameters: 209 | 210 | * `number_of_taps_required` - Set if more than one tap is required for 211 | the gesture to be recognized. 212 | * `number_of_touches_required` - Set if more than one finger is 213 | required for the gesture to be recognized. 214 | * `minimum_press_duration` - Set to change the default 0.5-second 215 | recognition treshold. 216 | * `allowable_movement` - Set to change the default 10 point maximum 217 | distance allowed for the gesture to be recognized. 218 | 219 | #### `pan(view, action,minimum_number_of_touches=None,maximum_number_of_touches=None)` 220 | 221 | Call `action` when a pan gesture is recognized for the `view`. 222 | This is a continuous gesture. 223 | 224 | Additional parameters: 225 | 226 | * `minimum_number_of_touches` - Set to control the gesture recognition. 227 | * `maximum_number_of_touches` - Set to control the gesture recognition. 228 | 229 | Handler `action` receives the following gesture-specific attributes 230 | in the `data` argument: 231 | 232 | * `translation` - Translation from the starting point of the gesture 233 | as a `ui.Point` with `x` and `y` attributes. 234 | * `velocity` - Current velocity of the pan gesture as points per 235 | second (a `ui.Point` with `x` and `y` attributes). 236 | 237 | #### `edge_pan(view, action, edges)` 238 | 239 | Call `action` when a pan gesture starting from the edge is 240 | recognized for the `view`. This is a continuous gesture. 241 | 242 | `edges` must be set to one of 243 | `gestures.EDGE_NONE/EDGE_TOP/EDGE_LEFT/EDGE_BOTTOM/EDGE_RIGHT 244 | /EDGE_ALL`. If you want to recognize pans from different edges, 245 | you have to set up separate recognizers with separate calls to this 246 | method. 247 | 248 | Handler `action` receives the same gesture-specific attributes in 249 | the `data` argument as pan gestures, see `pan`. 250 | 251 | #### `pinch(view, action)` 252 | 253 | Call `action` when a pinch gesture is recognized for the `view`. 254 | This is a continuous gesture. 255 | 256 | Handler `action` receives the following gesture-specific attributes 257 | in the `data` argument: 258 | 259 | * `scale` - Relative to the distance of the fingers as opposed to when 260 | the touch first started. 261 | * `velocity` - Current velocity of the pinch gesture as scale 262 | per second. 263 | 264 | #### `rotation(view, action)` 265 | 266 | Call `action` when a rotation gesture is recognized for the `view`. 267 | This is a continuous gesture. 268 | 269 | Handler `action` receives the following gesture-specific attributes 270 | in the `data` argument: 271 | 272 | * `rotation` - Rotation in radians, relative to the position of the 273 | fingers when the touch first started. 274 | * `velocity` - Current velocity of the rotation gesture as radians 275 | per second. 276 | 277 | #### `swipe(view, action,direction=None,number_of_touches_required=None,min_distance=None,max_distance=None)` 278 | 279 | Call `action` when a swipe gesture is recognized for the `view`. 280 | 281 | Additional parameters: 282 | 283 | * `direction` - Direction of the swipe to be recognized. Either one of 284 | `gestures.RIGHT/LEFT/UP/DOWN`, or a list of multiple directions. 285 | * `number_of_touches_required` - Set if you need to change the minimum 286 | number of touches required. 287 | * `min_distance` - Minimum distance the swipe gesture must travel in 288 | order to be recognized. Default is 50. 289 | This uses an undocumented recognizer attribute. 290 | * `max_distance` - Maximum distance the swipe gesture can travel in 291 | order to still be recognized. Default is a very large number. 292 | This uses an undocumented recognizer attribute. 293 | 294 | If set to recognize swipes to multiple directions, the handler 295 | does not receive any indication of the direction of the swipe. Add 296 | multiple recognizers if you need to differentiate between the 297 | directions. 298 | 299 | #### GESTURE MANAGEMENT 300 | #### `disable(handler)` 301 | 302 | Disable a recognizer temporarily. 303 | 304 | #### `enable(handler)` 305 | 306 | Enable a disabled gesture recognizer. There is no error if the 307 | recognizer is already enabled. 308 | 309 | #### `remove(view, handler)` 310 | 311 | Remove the recognizer from the view permanently. 312 | 313 | #### `remove_all_gestures(view)` 314 | 315 | Remove all gesture recognizers from a view. 316 | 317 | #### `disable_swipe_to_close(view)` 318 | 319 | Utility class method that will disable the two-finger-swipe-down 320 | gesture used in Pythonista to end the program when in full screen 321 | view (`hide_title_bar` set to `True`). 322 | 323 | Returns a tuple of the actual ObjC view and dismiss target. 324 | 325 | #### `replace_close_gesture(view, recognizer_class)` 326 | 327 | 328 | #### DRAG AND DROP 329 | #### `drag(view, payload, allow_others=False)` 330 | 331 | Sets the `view` to be the sender in a drag and drop operation. Dragging 332 | starts with a long press. 333 | 334 | For within-app drag and drop, `payload` can be anything, and it is passed 335 | by reference. 336 | 337 | If the `payload` is a text string or a `ui.Image`, it can be dragged 338 | (copied) to another app (on iPad). 339 | There is also built-in support for dropping text to any `ui.TextField` or 340 | `ui.TextView`. 341 | 342 | If `payload` is a function, it is called at the time when the drag starts. 343 | The function receives one argument, the sending `view`, and must return the 344 | data to be dragged. 345 | 346 | Additional parameters: 347 | 348 | * `allow_others` - Set to True if other gestures attached to the view 349 | should be prioritized over the dragging. 350 | 351 | #### `drop(view, action, accept=None)` 352 | 353 | Sets the `view` as a drop target, calling the `action` function with 354 | dropped data. 355 | 356 | Additional parameters: 357 | 358 | * `accept` - Control which data will be accepted for dropping. Simplest 359 | option is to provide an accepted Python type like `dict` or `ui.Label`. 360 | 361 | For cross-app drops, only two types are currently supported: `str` for 362 | plain text, and `ui.Image` for images. 363 | 364 | For in-app drops, the `accept` argument can also be a function that will 365 | be called when a drag enters the view. Function gets same parameters 366 | as the main handler, and should return False if the view should not accept 367 | the drop. 368 | 369 | `action` function has to have this signature: 370 | 371 | def handle_drop(data, sender, receiver): 372 | ... 373 | 374 | Arguments of the `action` function are: 375 | 376 | * `data` - The dragged data. 377 | * `sender` - Source view of the drag and drop. This is `None` for drags 378 | between apps. 379 | * `receiver` - Same as `view`. 380 | -------------------------------------------------------------------------------- /docs/menu.md: -------------------------------------------------------------------------------- 1 | # Menu 2 | 3 | This is a wrapper around the button UIMenu introduced in iOS 14. 4 | 5 | ### Installation 6 | 7 | pip install ui3 8 | 9 | ### Usage 10 | 11 | Simplest way to set it up is to use a list defining a title and a handler function for each menu item: 12 | 13 | from ui3.menu import set_menu 14 | 15 | def handler(sender, action): 16 | print(action.title) 17 | 18 | set_menu(button, [ 19 | ('First', handler), 20 | ('Second', handler), 21 | ('Third', handler), 22 | ]) 23 | 24 | ![First menu with 3 simple items](https://raw.githubusercontent.com/mikaelho/images/master/menu1.png) 25 | 26 | Handler gets the button as sender, and the selected action. 27 | 28 | By default, the menu is displayed by a simple tap, but you can set it to be activated with a long press, which enables you to use the regular button `action` for something else: 29 | 30 | set_menu(button, [ 31 | ('First', handler), 32 | ('Second', handler), 33 | ('Third', handler), 34 | ], long_press=True) 35 | 36 | For slightly more complex menus, you can define Actions: 37 | 38 | from ui3.menu import set_menu, Action 39 | from ui3.sfsymbol import SymbolImage 40 | 41 | set_menu(button, [ 42 | Action( 43 | 'Verbose menu item gets the space it needs', placeholder, 44 | ), 45 | Action( 46 | 'Regular Pythonista icon', placeholder, 47 | image=ui.Image('iob:close_32'), 48 | ), 49 | 50 | Action( 51 | 'SFSymbol', placeholder, 52 | image=SymbolImage('photo.on.rectangle'), 53 | ), 54 | Action( 55 | 'Destructive', placeholder, 56 | image=SymbolImage('tornado'), 57 | attributes=Action.DESTRUCTIVE, 58 | ), 59 | Action( 60 | 'Disabled', placeholder, 61 | attributes=Action.DISABLED, 62 | ), 63 | ]) 64 | 65 | ![More complex menu](https://raw.githubusercontent.com/mikaelho/images/master/menu2.png) 66 | 67 | Actions have the following attributes: 68 | 69 | * title 70 | * handler - function or method 71 | * image - if you set the destructive attribute, image is tinted system red automatically 72 | * attributes - summed combination of `Action.HIDDEN`, `DESTRUCTIVE` and `DISABLED`- by default none of these are active 73 | * state - either `Action.REGULAR` (default) or `SELECTED` 74 | * discoverability_title 75 | 76 | ... and some convenience boolean properties (read/write): 77 | 78 | * `selected` 79 | * `hidden` 80 | * `disabled` 81 | * `destructive` 82 | 83 | (Note that there is nothing inherently destructive by an action marked as destructive, it's just visuals.) 84 | 85 | Changing the Action's attributes automatically updates the menu that it is included in. See this example that shows both the selection visual and updating a hidden action: 86 | 87 | expert_action = Action( 88 | "Special expert action", 89 | print, 90 | attributes=Action.HIDDEN, 91 | ) 92 | 93 | def toggle_handler(sender, action): 94 | action.selected = not action.selected 95 | expert_action.hidden = not action.selected 96 | 97 | set_menu(button2, [ 98 | ('Expert mode', toggle_handler), 99 | expert_action, 100 | ]) 101 | 102 | ![Toggling and hiding](https://github.com/mikaelho/images/blob/master/menu3.png) 103 | 104 | -------------------------------------------------------------------------------- /docs/richlabel.md: -------------------------------------------------------------------------------- 1 | # RichLabel for nicer labels 2 | 3 | ### Sample usage 4 | 5 | from ui3.richlabel import RichLabel 6 | 7 | r = RichLabel( 8 | font=('Arial', 24), 9 | background_color='white', 10 | alignment=ui.ALIGN_CENTER, 11 | number_of_lines=0, 12 | ) 13 | 14 | r.set_rich_text("\n".join([ 15 | "Plain", 16 | "Color", 17 | "Bold italic", 18 | "and just italic", 19 | ])) 20 | 21 | r.present('fullscreen') 22 | 23 | Gives you this: 24 | 25 | ![Result](https://raw.githubusercontent.com/mikaelho/images/dc78cd62cfa9d90fc3f67320fdc0ab653623fc06/rich-label.png) 26 | 27 | ### Basic tags 28 | 29 | Supported tags: 30 | - `c` or `color` tag takes any Pythonista color definition (name, hex or tuple). 31 | - `f` or `font` tag expects a font name or a size or both. Write multi-word font names with dashes (e.g. Times-New-Roman). 32 | - `o` or `outline` tag that you can use as-is or with an optional color and/or width. 33 | - `u` or `underline` for tags, `strike` for strikethrough. 34 | - Both take a suitable combination of style qualifiers: `thick`, `double`, `dot`, `dash`, `dashdot`, `dashdotdot`, `byword`. 35 | - You can also give a color for the line, default is black. 36 | - `shadow` tag, can be customized with color (default `'grey'`), offset (default `(2,2)`) and blur (default `3`). 37 | - `oblique` tag with a single float parameter. Default is 0.25, which roughly corresponds to italic on iOS. 38 | 39 | ### iOS system styles 40 | 41 | These tags to set one of the default iOS system styles: 42 | - `body`, `callout`, `caption1`, `caption2`, `footnote`, `headline`, `subheadline`, `largetitle`, `title1`, `title2`, `title3` 43 | 44 | ### HTML 45 | 46 | RichLabel has a `html` method, that you can use to show any HTML with styles in the label. 47 | 48 | ### Customizing 49 | 50 | In case you writing same tag combinations over and over gets cumbersome, you have these customization options: 51 | 52 | 1. If you use a specific complex style in the label string a lot, subclass RichLabel and define custom tags for it. For example: 53 | 54 | ``` 55 | class MyRichLabel(RichLabel): 56 | custom = { 57 | 's': '' 58 | } 59 | ``` 60 | 61 | Now, wherever you use the tag <s>, it is replaced by the above definition. 62 | 63 | 2. You can also define ready-to-use Label classes, where a certain format is applied by default: 64 | 65 | ``` 66 | class MyRichLabel(RichLabel): 67 | custom = { 68 | 's': '' 69 | } 70 | default = '' 71 | ``` 72 | 73 | The way RichLabel class is built (it is actually a ui.Label in the end), you can set any usual ui.Label attribute defaults at the same time: 74 | 75 | class MyRichLabel(RichLabel): 76 | custom = { 77 | 's': '' 78 | } 79 | default = '' 80 | font = ('Arial', 24) 81 | alignment = ui.ALIGN_CENTER 82 | number_of_lines = 0 83 | 84 | Now we can use it without extra tagging: 85 | 86 | fancy = MyRichLabel( 87 | background_color='white', 88 | ) 89 | 90 | fancy.rich_text('FANCY BLOCK') 91 | 92 | With the result: 93 | 94 | ![Fancy block image](https://raw.githubusercontent.com/mikaelho/images/master/rich-fancy.png) 95 | 96 | 3. In the best case you do not need to write any tags, if you just need one style and are happy with the defaults. Following label classes are defined in the package: 97 | 98 | * `BoldLabel`, `ItalicLabel`, `BoldItalicLabel`, `ObliqueLabel`, `BoldObliqueLabel` 99 | * `OutlineLabel` 100 | * `UnderlineLabel`, `StrikeLabel` 101 | * `ShadowLabel` 102 | * `BodyLabel`, `CalloutLabel`, `Caption1Label`, `Caption2Label`, `FootnoteLabel`, `HeadlineLabel`, `SubheadlineLabel`, `LargeTitleLabel`, `Title1Label`, `Title2Label`, `Title3Label` 103 | 104 | Due to limitations of the built-in view classes, you still need to use the `rich_text` method - good old `text` will just give you good old plain text. 105 | -------------------------------------------------------------------------------- /docs/safearea.md: -------------------------------------------------------------------------------- 1 | # Safe area for iPhones 2 | 3 | View that uses the safe area constraints to give you an area that will not overlap the rounded/notched areas of iPhones. 4 | 5 | `present` it as the root view or initialize with a superview: 6 | 7 | import ui 8 | 9 | from ui3.safearea import SafeAreaView 10 | 11 | root = ui.View() 12 | safe_area = SafeAreaView(root) 13 | 14 | root.present('fullscreen') 15 | 16 | -------------------------------------------------------------------------------- /docs/scripter.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ``` 4 | | 5 | V 6 | ------------------------- 7 | | | 8 | V | 9 | script_1 | 10 | | V 11 | | script_3 12 | | | 13 | V | 14 | script_2 | 15 | | | 16 | V V 17 | ------------------------- 18 | | 19 | V 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/sfsymbol.md: -------------------------------------------------------------------------------- 1 | # SymbolImage 2 | 3 | iOS 14 brought about a thousand more scalable system icons. This is a wrapper for using the icons like ui.Images. 4 | 5 | Install together with other `ui3` utilities: 6 | 7 | pip install ui3 8 | 9 | Basic usage example: 10 | 11 | from ui3.sfsymbol import SymbolImage 12 | 13 | my_button = ui.Button( 14 | title="Finish", 15 | image=SymbolImage('checkerboard.rectangle') 16 | ) 17 | 18 | SymbolImages have the following additional options besides the symbol name: 19 | * `point_size` - Integer font size 20 | * `weight` - Font weight, one of ULTRALIGHT, THIN, LIGHT, REGULAR, MEDIUM, SEMIBOLD, BOLD, HEAVY, BLACK 21 | * `scale` - Size relative to font size, one of SMALL, MEDIUM, LARGE 22 | 23 | # Symbol browser 24 | 25 | Thanks to [Noah Gilmore](https://noahgilmore.com/blog/sf-symbols-ios-14/), we have a convenient up-to-date list of the available symbols. You can view them with a browser: 26 | 27 | from ui3.sfsymbol_browser import SymbolBrowser 28 | 29 | SymbolBrowser().present('fullscreen') 30 | 31 | Tapping on a symbol copies the name, ready to be pasted in your code. 32 | 33 | Symbols shown in orange are specified by Apple to be used only in relation with the actual related product (e.g. use the 'airpods' symbol only if you are doing something with Airpods). 34 | -------------------------------------------------------------------------------- /docs/vector.md: -------------------------------------------------------------------------------- 1 | ## Class: Vector 2 | 3 | Simple 2D vector class to make vector operations more convenient. If performance is a concern, you are probably better off looking at numpy. 4 | 5 | Supports the following operations: 6 | 7 | * Initialization from two arguments, two keyword arguments (`x` and `y`), 8 | tuple, list, or another Vector. 9 | * Equality and unequality comparisons to other vectors. For floating point 10 | 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 | from ui3.vector import Vector 20 | 21 | v = Vector(x = 1, y = 2) 22 | v2 = Vector(3, 4) 23 | v += v2 24 | assert str(v) == '[4, 6]' 25 | assert v / 2.0 == Vector(2, 3) 26 | assert v * 0.1 == Vector(0.4, 0.6) 27 | assert v.distance_to(v2) == math.sqrt(1+4) 28 | 29 | v3 = Vector(Vector(1, 2) - Vector(2, 0)) # -1.0, 2.0 30 | v3.magnitude *= 2 31 | assert v3 == [-2, 4] 32 | 33 | v3.radians = math.pi # 180 degrees 34 | v3.magnitude = 2 35 | assert v3 == [-2, 0] 36 | v3.degrees = -90 37 | assert v3 == [0, -2] 38 | 39 | ## Methods 40 | 41 | 42 | #### `dot_product(self, other)` 43 | 44 | Sum of multiplying x and y components with the x and y components of another vector. 45 | 46 | #### `distance_to(self, other)` 47 | 48 | Linear distance between this vector and another. 49 | 50 | #### `polar(self, r, m)` 51 | 52 | Set vector in polar coordinates. `r` is the angle in radians, `m` is vector magnitude or "length". 53 | 54 | #### `steps_to(self, other, step_magnitude=1.0)` 55 | 56 | 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. 57 | 58 | #### `rounded_steps_to(self, other, step_magnitude=1.0)` 59 | 60 | As `steps_to`, but returns points rounded to the nearest integer. 61 | ## Properties 62 | 63 | 64 | #### `x (get)` 65 | 66 | x component of the vector. 67 | 68 | #### `y (get)` 69 | 70 | y component of the vector. 71 | 72 | #### `magnitude (get)` 73 | 74 | Length of the vector, or distance from (0,0) to (x,y). 75 | 76 | #### `radians (get)` 77 | 78 | Angle between the positive x axis and this vector, in radians. 79 | 80 | #### `degrees (get)` 81 | 82 | Angle between the positive x axis and this vector, in degrees. 83 | -------------------------------------------------------------------------------- /gridview-demo.py: -------------------------------------------------------------------------------- 1 | from ui import * 2 | from ui3.gridview import GridView 3 | from ui3.safearea import SafeAreaView 4 | 5 | def style(view): 6 | view.background_color='white' 7 | view.border_color = 'black' 8 | view.border_width = 1 9 | view.text_color = 'black' 10 | view.tint_color = 'black' 11 | 12 | def create_card(title, packing, count): 13 | card = View() 14 | style(card) 15 | label = Label( 16 | text=title, 17 | font=('Apple SD Gothic Neo', 12), 18 | alignment=ALIGN_CENTER, 19 | flex='W', 20 | ) 21 | label.size_to_fit() 22 | label.width = card.width 23 | card.add_subview(label) 24 | gv = GridView( 25 | pack_x=packing, 26 | frame=( 27 | 0,label.height, 28 | card.width, card.height-label.height 29 | ), 30 | flex='WH', 31 | ) 32 | style(gv) 33 | card.add_subview(gv) 34 | for _ in range(7): 35 | v = View() 36 | style(v) 37 | gv.add_subview(v) 38 | return card 39 | 40 | v = SafeAreaView() 41 | 42 | demo = GridView( 43 | background_color='white', 44 | frame=v.bounds, 45 | flex='WH' 46 | ) 47 | 48 | v.add_subview(demo) 49 | 50 | cards = ( 51 | ('CENTER', GridView.CENTER), 52 | ('FILL', GridView.FILL), 53 | ('START', GridView.START), 54 | ('END', GridView.END), 55 | ('SIDES', GridView.SIDES), 56 | ('SPREAD', GridView.SPREAD), 57 | ('START_SPREAD', GridView.START_SPREAD), 58 | ('END_SPREAD', GridView.END_SPREAD) 59 | ) 60 | 61 | for i, spec in enumerate(cards): 62 | demo.add_subview(create_card(spec[0], spec[1], i)) 63 | 64 | v.present('fullscreen') 65 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=2,<3"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [tool.flit.metadata] 6 | module = "ui3" 7 | author = "Mikael Honkala" 8 | author-email = "mikael.honkala@gmail.com" 9 | home-page = "https://github.com/mikaelho/uiutils" 10 | dist-name = "ui3" 11 | description-file = "README.md" 12 | license = "TheUnlicense" 13 | classifiers = [ 14 | "Operating System :: iOS" 15 | ] 16 | -------------------------------------------------------------------------------- /sfsymbolbrowser-demo.py: -------------------------------------------------------------------------------- 1 | from ui3.sfsymbol_browser import SymbolBrowser 2 | 3 | 4 | if __name__ == '__main__': 5 | SymbolBrowser().present('fullscreen') 6 | -------------------------------------------------------------------------------- /sheet.py: -------------------------------------------------------------------------------- 1 | 2 | import sqlite3 3 | 4 | import objc_util 5 | import ui 6 | 7 | from ui3.anchor import * 8 | 9 | 10 | class SheetView(ui.View): 11 | 12 | column_width = 100 13 | 14 | class RowNumbers: 15 | 16 | def __init__(self, main): 17 | self.main = main 18 | 19 | def tableview_number_of_rows(self, tableview, section): 20 | return len(self.main.rows) 21 | 22 | def tableview_cell_for_row(self, tableview, section, row): 23 | cell = ui.TableViewCell() 24 | cell.background_color = self.main.background_color 25 | cell.text_label.text_color = self.main.text_color 26 | cell.text_label.text = str(row + 1) 27 | cell.text_label.alignment = ui.ALIGN_RIGHT 28 | return cell 29 | 30 | @property 31 | def desired_width(self): 32 | return 75 33 | 34 | class Rows: 35 | def __init__(self, main): 36 | self.main = main 37 | 38 | def tableview_number_of_rows(self, tableview, section): 39 | return len(self.main.rows) 40 | 41 | def tableview_cell_for_row(self, tableview, section, row): 42 | cell = ui.TableViewCell() 43 | cell.background_color = self.main.background_color 44 | container = ui.View( 45 | frame=cell.content_view.bounds, 46 | flex='WH' 47 | ) 48 | cell.content_view.add_subview(container) 49 | 50 | for i, value in enumerate(self.main.rows[row]): 51 | value_label = ui.Label( 52 | text=str(value), 53 | text_color=self.main.text_color, 54 | ) 55 | value_label.size_to_fit() 56 | value_label.width = self.main.column_width 57 | if i == 0: 58 | dock(value_label).left(container) 59 | else: 60 | dock(value_label).right_of(previous) 61 | previous = value_label 62 | 63 | return cell 64 | 65 | @property 66 | def desired_width(self): 67 | column_count = len(self.main.columns) 68 | return column_count * self.main.column_width + (column_count + 1) * At.gap 69 | 70 | def __init__(self, columns, rows, **kwargs): 71 | self.columns = columns 72 | if not self.columns: 73 | raise ValueError("Must have at least one column", self.columns) 74 | self.rows = rows 75 | 76 | self.background_color = kwargs.pop('background_color', 'black') 77 | self.tint_color = kwargs.pop('tint_color', 'white') 78 | self.text_color = kwargs.pop('text_color', 'white') 79 | 80 | super().__init__(**kwargs) 81 | 82 | number_ds = SheetView.RowNumbers(self) 83 | row_ds = SheetView.Rows(self) 84 | 85 | self.h_scroll = ui.ScrollView( 86 | content_size=(row_ds.desired_width, 10), 87 | ) 88 | self.content = ui.View( 89 | width=row_ds.desired_width, 90 | height=self.h_scroll.bounds.height, 91 | flex='H', 92 | ) 93 | self.h_scroll.add_subview(self.content) 94 | dock(self.h_scroll).right(self) 95 | 96 | self.column_headings = ui.View( 97 | width=row_ds.desired_width 98 | ) 99 | for i, column_title in enumerate(columns): 100 | column_label = self.make_column_label(column_title) 101 | if i == 0: 102 | dock(column_label).top_left(self.column_headings) 103 | else: 104 | dock(column_label).right_of(previous) 105 | previous = column_label 106 | self.column_headings.height = previous.height + 2 * At.gap 107 | dock(self.column_headings).top_left(self.content) 108 | 109 | self.tv = ui.TableView( 110 | data_source=row_ds, 111 | width=row_ds.desired_width, 112 | background_color=self.background_color,) 113 | dock(self.tv).below(self.column_headings) 114 | at(self.tv).bottom = at(self.content).bottom 115 | 116 | self.row_numbers = ui.TableView( 117 | data_source=number_ds, 118 | width=number_ds.desired_width, 119 | background_color=self.background_color, 120 | ) 121 | dock(self.row_numbers).bottom_left(self) 122 | at(self.row_numbers).top = at(self.tv).top + via_screen_y 123 | at(self.h_scroll).left = at(self.row_numbers).right 124 | 125 | at(self.tv).content_y = at(self.row_numbers).content_y 126 | at(self.row_numbers).content_y = at(self.tv).content_y 127 | 128 | def make_column_label(self, column_title): 129 | column_label = ui.Label( 130 | text=column_title, 131 | text_color=self.text_color, 132 | background_color=self.background_color, 133 | ) 134 | column_label.size_to_fit() 135 | column_label.width = self.column_width 136 | return column_label 137 | 138 | 139 | class SQLTableView(ui.View): 140 | 141 | cell_width = 200 142 | 143 | def __init__( 144 | self, 145 | db_name, 146 | table_name=None, 147 | conf=None, 148 | **kwargs, 149 | ): 150 | self.background_color = kwargs.pop('background_color', 'black') 151 | self.tint_color = kwargs.pop('tint_color', 'black') 152 | self.text_color = kwargs.pop('text_color', 'black') 153 | super().__init__(**kwargs) 154 | self.db_name = db_name 155 | self.table_name = table_name 156 | self.load_table() 157 | self.columns = self.analyze_columns() 158 | 159 | self.sv = ui.ScrollView( 160 | frame=self.bounds, 161 | flex='WH', 162 | ) 163 | self.add_subview(self.sv) 164 | self.tv = ui.TableView( 165 | frame=self.bounds, 166 | flex='H', 167 | ) 168 | self.tv.width = len(self.rows[0]) * self.cell_width + (len(self.rows[0]) - 1) * 8 169 | self.sv.content_size = self.tv.width, 10 170 | self.sv.add_subview(self.tv) 171 | self.tv.data_source = self 172 | self.cursor = None 173 | 174 | def load_table(self): 175 | conn = sqlite3.connect(self.db_name) 176 | conn.row_factory = sqlite3.Row 177 | c = conn.cursor() 178 | 179 | if not self.table_name: 180 | self.table_name = self.get_table_name(c) 181 | 182 | c.execute(f'SELECT * FROM {self.table_name}') 183 | 184 | self.rows = c.fetchall() 185 | if not self.rows: 186 | raise RuntimeError(f'Table {self.table_name} is empty') 187 | 188 | @staticmethod 189 | def get_table_name(cursor): 190 | cursor.execute(''' 191 | SELECT name FROM sqlite_master 192 | WHERE type ='table' AND 193 | name NOT LIKE 'sqlite_%' 194 | ''') 195 | result = cursor.fetchone() 196 | if not result: 197 | raise ValueError('No table_name given, no table found in database.') 198 | return result[0] 199 | 200 | def analyze_columns(self): 201 | self.columns = {} 202 | self.column_names = list(self.rows[0].keys()) 203 | ''' 204 | for row in self.rows: 205 | for 206 | for column in self.rows[0]: 207 | if type(column) is str: 208 | ''' 209 | 210 | def tableview_number_of_rows(self, tableview, section): 211 | return len(self.rows) 212 | 213 | def tableview_cell_for_row(self, tableview, section, row_index): 214 | cell = ui.TableViewCell() 215 | cell.background_color = 'black' 216 | cell.selectable = False 217 | 218 | container = ui.View( 219 | frame=cell.content_view.bounds, 220 | flex='WH' 221 | ) 222 | cell.content_view.add_subview(container) 223 | 224 | row = self.rows[row_index] 225 | for i, value in enumerate(row): 226 | label = ui.Label( 227 | text_color='white', 228 | text=str(value), 229 | width=self.cell_width, 230 | ) 231 | if i == 0: 232 | dock(label).left(container) 233 | else: 234 | dock(label).right_of(prev_label) 235 | prev_label = label 236 | 237 | return cell 238 | 239 | 240 | if __name__ == '__main__': 241 | 242 | import random 243 | 244 | import faker 245 | 246 | fake = faker.Faker() 247 | 248 | columns = 'Date TX Symbol Quantity Price'.split() 249 | 250 | data = [ 251 | [ 252 | fake.date_time_this_century().isoformat()[:10], 253 | random.choice(('BUY', 'SELL')), 254 | fake.lexify(text="????").upper(), 255 | random.randint(1, 10) * 50, 256 | round(random.random() * 300, 2), 257 | ] 258 | for _ in range(100) 259 | ] 260 | 261 | sheet_view = SheetView(columns, data) 262 | sheet_view.present('fullscreen') 263 | 264 | """ 265 | 266 | db_name = 'test_sb.db' 267 | 268 | @objc_util.on_main_thread 269 | def build_database(): 270 | import random 271 | 272 | conn = sqlite3.connect(db_name) 273 | 274 | c = conn.cursor() 275 | 276 | # Set up some data 277 | c.execute('''CREATE TABLE stocks (Date text, TX text, Symbol text, Quantity real, Price real)''') 278 | 279 | for _ in range(100): 280 | c.execute( 281 | "INSERT INTO stocks VALUES (?,?,?,?,?)", 282 | (fake.date_time_this_century().isoformat()[:10], 283 | random.choice(('BUY', 'SELL')), 284 | fake.lexify(text="????").upper(), 285 | random.randint(1, 10) * 50, 286 | round(random.random() * 300, 2)), 287 | ) 288 | 289 | conn.commit() 290 | 291 | return conn 292 | 293 | #conn = build_database() 294 | 295 | sqlv = SQLTableView(db_name=db_name) 296 | 297 | sqlv.present('fullscreen') 298 | 299 | """ 300 | 301 | -------------------------------------------------------------------------------- /ui3/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | UI and other utils for Pythonista iOS app 3 | """ 4 | 5 | __version__ = '2021.01.10' 6 | 7 | 8 | # from more_itertools import collapse 9 | 10 | import ui 11 | 12 | from ui3.anchor import * 13 | from ui3.gestures import * 14 | 15 | 16 | def add_subviews(view, *subviews): 17 | ''' Helper to add several subviews at once. 18 | Subviews can be provided as comma-separated arguments: 19 | 20 | add_subviews(view, subview1, subview2) 21 | 22 | ... or in an iterable: 23 | 24 | subviews = (subview1, subview2) 25 | add_subviews(view, subviews) 26 | ''' 27 | for subview in collapse(subviews): 28 | view.add_subview(subview) 29 | 30 | def apply(view, **kwargs): 31 | ''' Applies named parameters as changes to the view's attributes. ''' 32 | for key in kwargs: 33 | setattr(view, key, kwargs[key]) 34 | 35 | def apply_down(view, include_self=True, **kwargs): 36 | ''' Applies named parameter as changes to the view's attributes, then 37 | applies them also to the hierarchy of the view's subviews. 38 | Set `include_self` to `False` to only apply the changes to subviews. ''' 39 | if include_self: 40 | apply(view, **kwargs) 41 | for subview in view.subviews: 42 | apply_down(subview, **kwargs) 43 | 44 | -------------------------------------------------------------------------------- /ui3/anchor/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pythonista UI constraints driven by the Key Value Observing (KVO) protocol 3 | """ 4 | 5 | import inspect 6 | import json 7 | import math 8 | import re 9 | import textwrap 10 | import traceback 11 | import warnings 12 | 13 | from functools import partialmethod, partial 14 | from itertools import accumulate 15 | from types import SimpleNamespace as ns 16 | 17 | import ui 18 | import objc_util 19 | 20 | from .observer import on_change, remove_on_change 21 | 22 | 23 | # TODO: lte, gte, in_range, in_range_angle, in_rect 24 | # TODO: Greater or less than? 25 | # TODO: Priorities? 26 | 27 | 28 | _constraint_rules_spec = """ 29 | left: 30 | type: leading 31 | target: 32 | attribute: target.x 33 | value: value 34 | source: 35 | regular: source.x 36 | container: source.bounds.x 37 | right: 38 | type: trailing 39 | target: 40 | attribute: target.x 41 | value: value - target.width 42 | source: 43 | regular: source.frame.max_x 44 | container: source.bounds.max_x 45 | top: 46 | type: leading 47 | target: 48 | attribute: target.y 49 | value: value 50 | source: 51 | regular: source.y 52 | container: source.bounds.y 53 | bottom: 54 | type: trailing 55 | target: 56 | attribute: target.y 57 | value: value - target.height 58 | source: 59 | regular: source.frame.max_y 60 | container: source.bounds.max_y 61 | left_flex: 62 | type: leading 63 | target: 64 | attribute: (target.x, target.width) 65 | value: (value, target.width - (value - target.x)) 66 | right_flex: 67 | type: trailing 68 | target: 69 | attribute: target.width 70 | value: target.width + (value - (target.x + target.width)) 71 | top_flex: 72 | type: leading 73 | target: 74 | attribute: (target.y, target.height) 75 | value: (value, target.height - (value - target.y)) 76 | bottom_flex: 77 | type: trailing 78 | target: 79 | attribute: target.height 80 | value: target.height + (value - (target.y + target.height)) 81 | left_flex_center: 82 | type: leading 83 | target: 84 | attribute: (target.x, target.width) 85 | value: (value, (target.center.x - value) * 2) 86 | right_flex_center: 87 | type: trailing 88 | target: 89 | attribute: (target.x, target.width) 90 | value: (target.center.x - (value - target.center.x), (value - target.center.x) * 2) 91 | top_flex_center: 92 | type: leading 93 | target: 94 | attribute: (target.y, target.height) 95 | value: (value, (target.center.y - value) * 2) 96 | bottom_flex_center: 97 | type: trailing 98 | target: 99 | attribute: (target.y, target.height) 100 | value: (target.center.y - (value - target.center.y), (value - target.center.y) * 2) 101 | center_x: 102 | target: 103 | attribute: target.x 104 | value: value - target.width / 2 105 | source: 106 | regular: source.center.x 107 | container: source.bounds.center().x 108 | center_y: 109 | target: 110 | attribute: target.y 111 | value: value - target.height / 2 112 | source: 113 | regular: source.center.y 114 | container: source.bounds.center().y 115 | center: 116 | target: 117 | attribute: target.center 118 | value: value 119 | source: 120 | regular: tuple(source.center) 121 | container: tuple(source.bounds.center()) 122 | width: 123 | target: 124 | attribute: target.width 125 | value: value 126 | source: 127 | regular: source.width 128 | container: source.bounds.width - 2 * At.gap 129 | height: 130 | target: 131 | attribute: target.height 132 | value: value 133 | source: 134 | regular: source.height 135 | container: source.bounds.height - 2 * At.gap 136 | position: 137 | target: 138 | attribute: target.frame 139 | value: (value[0], value[1], target.width, target.height) 140 | source: 141 | regular: (source.x, source.y) 142 | container: (source.x, source.y) 143 | size: 144 | target: 145 | attribute: target.frame 146 | value: (target.x, target.y, value[0], value[1]) 147 | source: 148 | regular: (source.width, source.height) 149 | container: (source.width, source.height) 150 | frame: 151 | target: 152 | attribute: target.frame 153 | value: value 154 | source: 155 | regular: source.frame 156 | container: source.frame 157 | bounds: 158 | target: 159 | attribute: target.bounds 160 | value: value 161 | source: 162 | regular: source.bounds 163 | container: source.bounds 164 | constant: 165 | source: 166 | regular: source.data 167 | function: 168 | source: 169 | regular: source.data() 170 | heading: 171 | target: 172 | attribute: target._at._heading 173 | value: direction(target, source, value) 174 | source: 175 | regular: source._at._heading 176 | container: source._at._heading 177 | attr: 178 | target: 179 | attribute: target._custom 180 | value: value 181 | source: 182 | regular: source._custom 183 | fit_size: 184 | target: 185 | attribute: target.frame 186 | value: subview_max(target) 187 | source: 188 | regular: subview_bounds(source) 189 | fit_width: 190 | target: 191 | attribute: target.width 192 | value: subview_max(target)[2] 193 | source: 194 | regular: subview_bounds(source).width 195 | fit_height: 196 | target: 197 | attribute: target.height 198 | value: subview_max(target)[3] 199 | source: 200 | regular: subview_bounds(source).height 201 | text_height: 202 | target: 203 | attribute: target.height 204 | value: get_text_height(target) 205 | text_width: 206 | target: 207 | attribute: target.width 208 | value: get_text_width(target) 209 | content_offset: 210 | target: 211 | attribute: target.content_offset 212 | value: value 213 | source: 214 | regular: source.content_offset 215 | content_x: 216 | target: 217 | attribute: target.content_offset 218 | value: (value, target.content_offset[1]) 219 | source: 220 | regular: source.content_offset[0] 221 | content_y: 222 | target: 223 | attribute: target.content_offset 224 | value: (target.content_offset[0], value) 225 | source: 226 | regular: source.content_offset[1] 227 | """ 228 | 229 | 230 | class At: 231 | 232 | #observer = NSKeyValueObserving('_at') 233 | 234 | gap = 8 # Apple Standard gap 235 | TIGHT = -gap 236 | constraint_warnings = True 237 | superview_warnings = True 238 | 239 | @classmethod 240 | def gaps_for(cls, count): 241 | return (count - 1) / count * At.gap 242 | 243 | @objc_util.on_main_thread 244 | def on_change(self, force_source=True): 245 | if self.checking: 246 | return 247 | self.checking = True 248 | changed = True 249 | counter = 0 250 | while changed and counter < 5: 251 | changed = False 252 | counter += 1 253 | #for constraint in self.target_for.values(): 254 | for constraint in self.target_for: 255 | value_changed = next(constraint.runner) 256 | changed = changed or value_changed 257 | if changed: 258 | force_source = True 259 | self.checking = False 260 | if force_source: 261 | for constraint in self.source_for: 262 | constraint.target.at.on_change(force_source=False) 263 | 264 | class Anchor: 265 | 266 | HORIZONTALS = set('left right center_x width'.split()) 267 | VERTICALS = set('top bottom center_y height'.split()) 268 | NO_CHECKS = set('fit_size fit_height fit_width text_width text_height'.split()) 269 | 270 | def __init__(self, at, prop): 271 | self.at = at 272 | self.prop = prop 273 | self.modifiers = '' 274 | self.callable = None 275 | 276 | def __add__(self, other): 277 | if callable(other): 278 | self.callable = other 279 | else: 280 | self.modifiers += f'+ {other}' 281 | return self 282 | 283 | def __sub__(self, other): 284 | self.modifiers += f'- {other}' 285 | return self 286 | 287 | def __mul__(self, other): 288 | self.modifiers += f'* {other}' 289 | return self 290 | 291 | def __truediv__(self, other): 292 | self.modifiers += f'/ {other}' 293 | return self 294 | 295 | def __floordiv__(self, other): 296 | self.modifiers += f'// {other}' 297 | return self 298 | 299 | def __mod__(self, other): 300 | self.modifiers += f'% {other}' 301 | return self 302 | 303 | def __pow__ (self, other, modulo=None): 304 | self.modifiers += f'** {other}' 305 | return self 306 | 307 | def get_edge_type(self): 308 | return At.Anchor._rules.get( 309 | self.prop, At.Anchor._rules['attr']).get( 310 | 'type', 'neutral') 311 | 312 | def get_attribute(self, prop=None): 313 | prop = prop or self.prop 314 | if prop in At.Anchor._rules: 315 | target_attribute = At.Anchor._rules[prop]['target']['attribute'] 316 | else: 317 | target_attribute = At.Anchor._rules['attr']['target']['attribute'] 318 | target_attribute = target_attribute.replace( 319 | '_custom', prop) 320 | return target_attribute 321 | 322 | def get_source_value(self, container_type): 323 | if self.prop in At.Anchor._rules: 324 | source_value = At.Anchor._rules[self.prop]['source'][container_type] 325 | else: 326 | source_value = At.Anchor._rules['attr']['source']['regular'] 327 | source_value = source_value.replace('_custom', self.prop) 328 | return source_value 329 | 330 | def get_target_value(self, prop=None): 331 | prop = prop or self.prop 332 | if prop in At.Anchor._rules: 333 | target_value = At.Anchor._rules[prop]['target']['value'] 334 | else: 335 | target_value = At.Anchor._rules['attr']['target']['value'] 336 | target_value = target_value.replace('_custom', prop) 337 | return target_value 338 | 339 | def check_for_warnings(self, source): 340 | 341 | if self.at.constraint_warnings and source.prop not in ( 342 | 'constant', 'function' 343 | ) and self.prop not in self.NO_CHECKS: 344 | source_direction, target_direction = [ 345 | 'v' if c in self.VERTICALS else '' + 346 | 'h' if c in self.HORIZONTALS else '' 347 | for c in (source.prop, self.prop) 348 | ] 349 | if source_direction != target_direction: 350 | warnings.warn( 351 | ConstraintWarning('Unusual constraint combination'), 352 | stacklevel=5, 353 | ) 354 | if self.at.superview_warnings: 355 | if not self.at.view.superview: 356 | warnings.warn( 357 | ConstraintWarning('Probably missing superview'), 358 | stacklevel=5, 359 | ) 360 | 361 | def record(self, constraint): 362 | if constraint.source == self: 363 | self.at.source_for.add(constraint) 364 | elif constraint.target == self: 365 | #self.at._remove_constraint(self.prop) 366 | #self.at.target_for[self.prop] = constraint 367 | self.at.target_for.add(constraint) 368 | else: 369 | raise ValueError('Disconnected constraint') 370 | 371 | def check_for_impossible_combos(self): 372 | """ 373 | Check for too many constraints resulting in an impossible combo 374 | """ 375 | h = set([*self.HORIZONTALS, 'center']) 376 | v = set([*self.VERTICALS, 'center']) 377 | #active = set(self.at.target_for.keys()) 378 | active = set([constraint.target.prop for constraint in self.at.target_for]) 379 | horizontals = active.intersection(h) 380 | verticals = active.intersection(v) 381 | if len(horizontals) > 2: 382 | raise ConstraintError( 383 | 'Too many horizontal constraints', horizontals) 384 | elif len(verticals) > 2: 385 | raise ConstraintError( 386 | 'Too many vertical constraints', verticals) 387 | 388 | def start_observing(self): 389 | on_change(self.at.view, self.at.on_change) 390 | 391 | def trigger_change(self): 392 | self.at.on_change() 393 | 394 | def _parse_rules(rules): 395 | rule_dict = dict() 396 | dicts = [rule_dict] 397 | spaces = re.compile(' *') 398 | for i, line in enumerate(rules.splitlines()): 399 | i += 11 # Used to match error line number to my file 400 | if line.strip() == '': continue 401 | indent = len(spaces.match(line).group()) 402 | if indent % 4 != 0: 403 | raise RuntimeError(f'Broken indent on line {i}') 404 | indent = indent // 4 + 1 405 | if indent > len(dicts): 406 | raise RuntimeError(f'Extra indentation on line {i}') 407 | dicts = dicts[:indent] 408 | line = line.strip() 409 | if line.endswith(':'): 410 | key = line[:-1].strip() 411 | new_dict = dict() 412 | dicts[-1][key] = new_dict 413 | dicts.append(new_dict) 414 | else: 415 | try: 416 | key, content = line.split(':') 417 | dicts[-1][key.strip()] = content.strip() 418 | except Exception as error: 419 | raise RuntimeError(f'Cannot parse line {i}', error) 420 | return rule_dict 421 | 422 | _rules = _parse_rules(_constraint_rules_spec) 423 | 424 | 425 | class ConstantAnchor(Anchor): 426 | 427 | def __init__(self, source_data): 428 | prop = 'function' if callable(source_data) else 'constant' 429 | super().__init__( 430 | ns(view=self), 431 | prop 432 | ) 433 | self.data = source_data 434 | 435 | def record(self, constraint): 436 | pass 437 | 438 | def start_observing(self): 439 | pass 440 | 441 | def trigger_change(self): 442 | raise NotImplementedError( 443 | 'Programming error: Constant should never trigger change' 444 | ) 445 | 446 | 447 | class Constraint: 448 | 449 | REGULAR, CONTAINER = 'regular', 'container' 450 | SAME, DIFFERENT, NEUTRAL = 'same', 'different', 'neutral' 451 | TRAILING, LEADING = 'trailing', 'leading' 452 | 453 | def __init__(self, source, target): 454 | self.source = source 455 | self.target = target 456 | 457 | target.check_for_warnings(source) 458 | 459 | self.set_constraint_gen(source, target) 460 | 461 | target.record(self) 462 | source.record(self) 463 | target.check_for_impossible_combos() 464 | 465 | target.trigger_change() 466 | target.start_observing() 467 | source.start_observing() 468 | 469 | def set_constraint_gen(self, source, target): 470 | container_type, gap = self.get_characteristics(source, target) 471 | 472 | source_value = source.get_source_value(container_type) 473 | 474 | flex_get, flex_set = self.get_flex(target) 475 | 476 | call_source_callable = '' 477 | call_callable = '' 478 | if source.callable: 479 | if source.callable in source_conversions: 480 | call_source_callable = self.get_call_str(source, 'value') 481 | else: 482 | call_callable = self.get_call_str(source, 'target_value') 483 | 484 | update_gen_str = (f'''\ 485 | # {target.prop} 486 | def constraint_runner(source, target): 487 | 488 | # scripts = target.at.target_for 489 | scripts = set([constraint.target.prop for constraint in target.at.target_for]) 490 | func = source.callable 491 | source = source.at.view 492 | target = target.at.view 493 | 494 | prev_value = None 495 | prev_bounds = None 496 | while True: 497 | value = ({source_value} {gap}) {source.modifiers} 498 | {call_source_callable} 499 | {flex_get} 500 | 501 | if (target_value != prev_value or 502 | target.superview.bounds != prev_bounds): 503 | prev_value = target_value 504 | prev_bounds = target.superview.bounds 505 | {call_callable} 506 | {flex_set} 507 | yield True 508 | else: 509 | yield False 510 | 511 | self.runner = constraint_runner(source, target) 512 | ''' 513 | ) 514 | update_gen_str = textwrap.dedent(update_gen_str) 515 | #if call_source_callable: 516 | # print(update_gen_str) 517 | exec(update_gen_str) 518 | 519 | def get_characteristics(self, source, target): 520 | if target.at.view.superview == source.at.view: 521 | container_type = self.CONTAINER 522 | else: 523 | container_type = self.REGULAR 524 | 525 | source_edge_type = source.get_edge_type() 526 | target_edge_type = target.get_edge_type() 527 | 528 | align_type = self.SAME if ( 529 | source_edge_type == self.NEUTRAL or 530 | target_edge_type == self.NEUTRAL or 531 | source_edge_type == target_edge_type 532 | ) else self.DIFFERENT 533 | 534 | if (container_type == self.CONTAINER and 535 | self.NEUTRAL not in (source_edge_type, target_edge_type)): 536 | align_type = ( 537 | self.SAME 538 | if align_type == self.DIFFERENT 539 | else self.DIFFERENT 540 | ) 541 | 542 | gap = '' 543 | if align_type == self.DIFFERENT and not self.target.at._tight: 544 | gap = ( 545 | f'+ {At.gap}' 546 | if target_edge_type == self.LEADING 547 | else f'- {At.gap}' 548 | ) 549 | 550 | return container_type, gap 551 | 552 | def get_flex(self, target): 553 | target_attribute = target.get_attribute() 554 | flex_get = f'target_value = {target.get_target_value()}' 555 | flex_set = f'{target_attribute} = target_value' 556 | opposite_prop, center_prop = self.get_opposite(target.prop) 557 | if opposite_prop: 558 | flex_prop = target.prop + '_flex' 559 | flex_center_prop = target.prop + '_flex_center' 560 | flex_get = f''' 561 | center_props = set(('center', '{center_prop}')) 562 | if '{opposite_prop}' in scripts: 563 | target_value = ({target.get_target_value(flex_prop)}) 564 | # elif len(center_props.intersection(set(scripts.keys()))): 565 | elif len(center_props.intersection(scripts)): 566 | target_value = ({target.get_target_value(flex_center_prop)}) 567 | else: 568 | target_value = {target.get_target_value()} 569 | ''' 570 | flex_set = f''' 571 | if '{opposite_prop}' in scripts: 572 | {target.get_attribute(flex_prop)} = target_value 573 | elif len(center_props.intersection(scripts)): 574 | {target.get_attribute(flex_center_prop)} = target_value 575 | else: 576 | {target_attribute} = target_value 577 | ''' 578 | return flex_get, flex_set 579 | 580 | def get_call_str(self, source, target_param_name): 581 | 582 | call_strs = { 583 | 1: f'func({target_param_name})', 584 | 2: f'func({target_param_name}, target)', 585 | 3: f'func({target_param_name}, target, source)', 586 | } 587 | parameter_count = len(inspect.signature(source.callable).parameters) 588 | return f'{target_param_name} = {call_strs[parameter_count]}' 589 | 590 | def get_opposite(self, prop): 591 | opposites = ( 592 | ({'left', 'right'}, 'center_x'), 593 | ({'top', 'bottom'}, 'center_y') 594 | ) 595 | for pair, center_prop in opposites: 596 | try: 597 | pair.remove(prop) 598 | return pair.pop(), center_prop 599 | except KeyError: pass 600 | return (None, None) 601 | 602 | 603 | def __new__(cls, view): 604 | try: 605 | return view._at 606 | except AttributeError: 607 | at = super().__new__(cls) 608 | at.view = view 609 | at.__heading = 0 610 | at.heading_adjustment = 0 611 | at.source_for = set() 612 | #at.target_for = {} 613 | at.target_for = set() 614 | at.checking = False 615 | view._at = at 616 | return at 617 | 618 | def _prop(attribute): 619 | p = property( 620 | lambda self: 621 | partial(At._getter, self, attribute)(), 622 | lambda self, value: 623 | partial(At._setter, self, attribute, value)() 624 | ) 625 | return p 626 | 627 | def _getter(self, attr_string): 628 | return At.Anchor(self, attr_string) 629 | 630 | def _setter(self, attr_string, source): 631 | target = At.Anchor(self, attr_string) 632 | if type(source) is At.Anchor: 633 | constraint = At.Constraint(source, target) 634 | #constraint.set_constraint(value) 635 | #constraint.start_observing() 636 | elif source is None: 637 | self._remove_all_constraints(attr_string) 638 | else: # Constant or function 639 | source = At.ConstantAnchor(source) 640 | constraint = At.Constraint(source, target) 641 | 642 | def _remove_all_constraints(self, attr_string): 643 | constraints_to_remove = [ 644 | constraint 645 | for constraint 646 | in self.target_for 647 | if constraint.target.prop == attr_string 648 | ] 649 | for constraint in constraints_to_remove: 650 | self._remove_constraint(constraint) 651 | 652 | #def _remove_constraint(self, attr_string): 653 | def _remove_constraint(self, constraint): 654 | target_len = len(self.target_for) 655 | was_removed = constraint in self.target_for 656 | self.target_for.discard(constraint) 657 | #constraint = self.target_for.pop(attr_string, None) 658 | if target_len and not len(self.target_for) and not len(self.source_for): 659 | #At.observer.stop_observing(self.view) 660 | remove_on_change(self.view, self.on_change) 661 | #if constraint: 662 | if was_removed: 663 | source_at = constraint.source.at 664 | source_len = len(source_at.source_for) 665 | source_at.source_for.discard(constraint) 666 | if (source_len and 667 | not len(source_at.source_for) and 668 | not len(source_at.target_for)): 669 | #At.observer.stop_observing(source_at.view) 670 | remove_on_change(source_at.view, source_at.on_change) 671 | 672 | @property 673 | def _heading(self): 674 | return self.__heading 675 | 676 | @_heading.setter 677 | def _heading(self, value): 678 | self.__heading = value 679 | self.view.transform = ui.Transform.rotation( 680 | value + self.heading_adjustment) 681 | self.on_change() 682 | 683 | # PUBLIC PROPERTIES 684 | 685 | left = _prop('left') 686 | x = _prop('left') 687 | right = _prop('right') 688 | top = _prop('top') 689 | y = _prop('top') 690 | bottom = _prop('bottom') 691 | center = _prop('center') 692 | center_x = _prop('center_x') 693 | center_y = _prop('center_y') 694 | width = _prop('width') 695 | height = _prop('height') 696 | position = _prop('position') 697 | size = _prop('size') 698 | frame = _prop('frame') 699 | bounds = _prop('bounds') 700 | heading = _prop('heading') 701 | fit_size = _prop('fit_size') 702 | fit_width = _prop('fit_width') 703 | fit_height = _prop('fit_height') 704 | text_height = _prop('text_height') 705 | text_width = _prop('text_width') 706 | content_offset = _prop('content_offset') 707 | content_x = _prop('content_x') 708 | content_y = _prop('content_y') 709 | 710 | @property 711 | def tight(self): 712 | self._tight = True 713 | return self 714 | 715 | def _remove_anchors(self): 716 | ... 717 | 718 | 719 | # Direct access functions 720 | 721 | def at(view, func=None, tight=False): 722 | a = At(view) 723 | a.callable = func 724 | a._tight = tight 725 | return a 726 | 727 | def attr(data, func=None): 728 | at = At(data) 729 | at.callable = func 730 | for attr_name in dir(data): 731 | if (not attr_name.startswith('_') and 732 | not hasattr(At, attr_name) and 733 | inspect.isdatadescriptor( 734 | inspect.getattr_static(data, attr_name) 735 | )): 736 | setattr(At, attr_name, At._prop(attr_name)) 737 | return at 738 | 739 | # Helper functions 740 | 741 | def direction(target, source, value): 742 | """ 743 | Calculate the heading if given a center 744 | """ 745 | try: 746 | if len(value) == 2: 747 | delta = value - target.center 748 | value = math.atan2(delta.y, delta.x) 749 | except TypeError: 750 | pass 751 | return value 752 | 753 | 754 | def subview_bounds(view): 755 | subviews_accumulated = list(accumulate( 756 | [v.frame for v in view.subviews], 757 | ui.Rect.union)) 758 | if len(subviews_accumulated): 759 | bounds = subviews_accumulated[-1] 760 | else: 761 | bounds = ui.Rect(0, 0, 0, 0) 762 | return bounds.inset(-At.gap, -At.gap) 763 | 764 | 765 | def subview_max(view): 766 | 767 | width = height = 0 768 | for subview in view.subviews: 769 | width = max( 770 | width, 771 | subview.frame.max_x 772 | ) 773 | height = max( 774 | height, 775 | subview.frame.max_y 776 | ) 777 | 778 | width += At.gap 779 | height += At.gap 780 | 781 | return view.x, view.y, width, height 782 | 783 | 784 | def get_text_height(view): 785 | size = view.objc_instance.sizeThatFits_(objc_util.CGSize(view.width, 0)) 786 | return size.height 787 | 788 | 789 | def get_text_width(view): 790 | size = view.objc_instance.sizeThatFits_(objc_util.CGSize(0, view.height)) 791 | return size.width 792 | 793 | 794 | def screen(source_value, target, source): 795 | if not source.superview or not target.superview: 796 | return (0, 0) 797 | return ui.convert_point( 798 | ui.convert_point(source_value, source.superview), 799 | to_view=target.superview, 800 | ) 801 | 802 | 803 | def screen_x(source_value, target, source): 804 | transformed = screen( 805 | (source_value, 0), 806 | target, 807 | source 808 | ) 809 | return transformed[0] 810 | 811 | 812 | def screen_y(source_value, target, source): 813 | transformed = screen( 814 | (0, source_value), 815 | target, 816 | source 817 | ) 818 | return transformed[1] 819 | 820 | source_conversions = ( 821 | screen, 822 | screen_x, 823 | screen_y, 824 | ) 825 | 826 | 827 | class ConstraintError(RuntimeError): 828 | """ 829 | Raised on impossible constraint combos. 830 | """ 831 | 832 | class ConstraintWarning(RuntimeWarning): 833 | """ 834 | Raised on suspicious constraint combos. 835 | """ 836 | 837 | class Dock: 838 | 839 | direction_map = { 840 | 'T': ('top', +1), 841 | 'L': ('left', +1), 842 | 'B': ('bottom', -1), 843 | 'R': ('right', -1), 844 | 'X': ('center_x', 0), 845 | 'Y': ('center_y', 0), 846 | 'C': ('center', 0), 847 | } 848 | 849 | def __init__(self, view): 850 | self.view = view 851 | self._tight = False 852 | 853 | @property 854 | def tight(self): 855 | self._tight = True 856 | return self 857 | 858 | def _dock(self, directions, superview, modifier=0): 859 | view = self.view 860 | superview.add_subview(view) 861 | v = at(view, tight=self._tight) 862 | sv = at(superview) 863 | for direction in directions: 864 | prop, sign = self.direction_map[direction] 865 | if prop != 'center': 866 | setattr(v, prop, getattr(sv, prop) + sign * modifier) 867 | else: 868 | setattr(v, prop, getattr(sv, prop)) 869 | 870 | all = partialmethod(_dock, 'TLBR') 871 | bottom = partialmethod(_dock, 'LBR') 872 | top = partialmethod(_dock, 'TLR') 873 | right = partialmethod(_dock, 'TBR') 874 | left = partialmethod(_dock, 'TLB') 875 | top_left = partialmethod(_dock, 'TL') 876 | top_right = partialmethod(_dock, 'TR') 877 | bottom_left = partialmethod(_dock, 'BL') 878 | bottom_right = partialmethod(_dock, 'BR') 879 | sides = partialmethod(_dock, 'LR') 880 | vertical = partialmethod(_dock, 'TB') 881 | top_center = partialmethod(_dock, 'TX') 882 | bottom_center = partialmethod(_dock, 'BX') 883 | left_center = partialmethod(_dock, 'LY') 884 | right_center = partialmethod(_dock, 'RY') 885 | center = partialmethod(_dock, 'C') 886 | 887 | def between(self, top=None, bottom=None, left=None, right=None): 888 | a_self = at(self.view, tight=self._tight) 889 | if top: 890 | a_self.top = at(top).bottom 891 | if bottom: 892 | a_self.bottom = at(bottom).top 893 | if left: 894 | a_self.left = at(left).right 895 | if right: 896 | a_self.right = at(right).left 897 | if top or bottom: 898 | a = at(top or bottom) 899 | a_self.width = a.width 900 | a_self.center_x = a.center_x 901 | if left or right: 902 | a = at(left or right) 903 | a_self.height = a.height 904 | a_self.center_y = a.center_y 905 | 906 | def above(self, other): 907 | other.superview.add_subview(self.view) 908 | at(self.view, tight=self._tight).bottom = at(other).top 909 | align(self.view).x(other) 910 | align(self.view).width(other) 911 | 912 | def below(self, other): 913 | other.superview.add_subview(self.view) 914 | at(self.view, tight=self._tight).top = at(other).bottom 915 | align(self.view).x(other) 916 | align(self.view).width(other) 917 | 918 | def left_of(self, other): 919 | other.superview.add_subview(self.view) 920 | at(self.view, tight=self._tight).right = at(other).left 921 | align(self.view).y(other) 922 | align(self.view).height(other) 923 | 924 | def right_of(self, other): 925 | other.superview.add_subview(self.view) 926 | at(self.view, tight=self._tight).left = at(other).right 927 | align(self.view).y(other) 928 | align(self.view).height(other) 929 | 930 | 931 | def dock(view) -> Dock: 932 | return Dock(view) 933 | 934 | 935 | class Align: 936 | 937 | modifiable = 'left right top bottom center_x center_y width height heading' 938 | 939 | def __init__(self, *others): 940 | self.others = others 941 | 942 | def _align(self, prop, view, modifier=0): 943 | anchor_at = at(view) 944 | use_modifier = prop in self.modifiable.split() 945 | for other in self.others: 946 | if use_modifier: 947 | setattr(at(other), prop, 948 | getattr(anchor_at, prop) + modifier) 949 | else: 950 | setattr(at(other), prop, getattr(anchor_at, prop)) 951 | 952 | left = partialmethod(_align, 'left') 953 | x = partialmethod(_align, 'left') 954 | right = partialmethod(_align, 'right') 955 | top = partialmethod(_align, 'top') 956 | y = partialmethod(_align, 'bottom') 957 | bottom = partialmethod(_align, 'bottom') 958 | center = partialmethod(_align, 'center') 959 | center_x = partialmethod(_align, 'center_x') 960 | center_y = partialmethod(_align, 'center_y') 961 | width = partialmethod(_align, 'width') 962 | height = partialmethod(_align, 'height') 963 | position = partialmethod(_align, 'position') 964 | size = partialmethod(_align, 'size') 965 | frame = partialmethod(_align, 'frame') 966 | bounds = partialmethod(_align, 'bounds') 967 | heading = partialmethod(_align, 'heading') 968 | 969 | def align(*others): 970 | return Align(*others) 971 | 972 | 973 | class Fill: 974 | 975 | def __init__(self, *views): 976 | self.views = views 977 | 978 | def _fill(self, 979 | corner, attr, opposite, center, side, other_side, size, other_size, 980 | superview, count=1): 981 | views = self.views 982 | assert len(views) > 0, 'Give at least one view to fill with' 983 | first = views[0] 984 | getattr(dock(first), corner)(superview) 985 | gaps = At.gaps_for(count) 986 | per_count = math.ceil(len(views)/count) 987 | per_gaps = At.gaps_for(per_count) 988 | super_at = at(superview) 989 | for i, view in enumerate(views[1:]): 990 | superview.add_subview(view) 991 | if (i + 1) % per_count != 0: 992 | setattr(at(view), attr, getattr(at(views[i]), opposite)) 993 | setattr(at(view), center, getattr(at(views[i]), center)) 994 | else: 995 | setattr(at(view), attr, getattr(super_at, attr)) 996 | setattr(at(view), side, getattr(at(views[i]), other_side)) 997 | for view in views: 998 | setattr(at(view), size, 999 | getattr(super_at, size) + ( 1000 | lambda v: v / per_count - per_gaps 1001 | ) 1002 | ) 1003 | setattr(at(view), other_size, 1004 | getattr(super_at, other_size) + ( 1005 | lambda v: v / count - gaps 1006 | ) 1007 | ) 1008 | 1009 | from_top = partialmethod(_fill, 'top_left', 1010 | 'top', 'bottom', 'center_x', 1011 | 'left', 'right', 1012 | 'height', 'width') 1013 | from_bottom = partialmethod(_fill, 'bottom_left', 1014 | 'bottom', 'top', 'center_x', 1015 | 'left', 'right', 1016 | 'height', 'width') 1017 | from_left = partialmethod(_fill, 'top_left', 1018 | 'left', 'right', 'center_y', 1019 | 'top', 'bottom', 1020 | 'width', 'height') 1021 | from_right = partialmethod(_fill, 'top_right', 1022 | 'right', 'left', 'center_y', 1023 | 'top', 'bottom', 1024 | 'width', 'height') 1025 | 1026 | 1027 | def fill_with(*views): 1028 | return Fill(*views) 1029 | 1030 | 1031 | class Flow: 1032 | 1033 | def __init__(self, *views): 1034 | self.views = views 1035 | 1036 | def _flow(self, corner, size, func, superview): 1037 | assert len(self.views) > 0, 'Give at least one view for the flow' 1038 | views = self.views 1039 | super_at = at(superview) 1040 | first = views[0] 1041 | getattr(dock(first), corner)(superview) 1042 | for i, view in enumerate(views[1:]): 1043 | superview.add_subview(view) 1044 | setattr(at(view), size, 1045 | getattr(at(views[i]), size)) 1046 | at(view).frame = at(views[i]).frame + func 1047 | 1048 | def _from_left(down, value, target): 1049 | if value.max_x + target.width + 2 * At.gap > target.superview.width: 1050 | return (At.gap, value.y + down * (target.height + At.gap), 1051 | target.width, target.height) 1052 | return (value.max_x + At.gap, value.y, target.width, target.height) 1053 | 1054 | from_top_left = partialmethod(_flow, 1055 | 'top_left', 'height', 1056 | partial(_from_left, 1)) 1057 | from_bottom_left = partialmethod(_flow, 1058 | 'bottom_left', 'height', 1059 | partial(_from_left, -1)) 1060 | 1061 | def _from_right(down, value, target): 1062 | if value.x - target.width - At.gap < At.gap: 1063 | return (target.superview.width - target.width - At.gap, 1064 | value.y + down * (target.height + At.gap), 1065 | target.width, target.height) 1066 | return (value.x - At.gap - target.width, value.y, 1067 | target.width, target.height) 1068 | 1069 | from_top_right = partialmethod(_flow, 1070 | 'top_right', 'height', partial(_from_right, 1)) 1071 | from_bottom_right = partialmethod(_flow, 1072 | 'bottom_right', 'height', partial(_from_right, -1)) 1073 | 1074 | def _from_top(right, value, target): 1075 | if value.max_y + target.height + 2 * At.gap > target.superview.height: 1076 | return (value.x + right * (target.width + At.gap), At.gap, 1077 | target.width, target.height) 1078 | return (value.x, value.max_y + At.gap, target.width, target.height) 1079 | 1080 | from_left_down = partialmethod(_flow, 1081 | 'top_left', 'width', partial(_from_top, 1)) 1082 | from_right_down = partialmethod(_flow, 1083 | 'top_right', 'width', partial(_from_top, -1)) 1084 | 1085 | def _from_bottom(right, value, target): 1086 | if value.y - target.height - At.gap < At.gap: 1087 | return (value.x + right * (target.width + At.gap), 1088 | target.superview.height - target.height - At.gap, 1089 | target.width, target.height) 1090 | return (value.x, value.y - target.height - At.gap, 1091 | target.width, target.height) 1092 | 1093 | from_left_up = partialmethod(_flow, 1094 | 'bottom_left', 'width', partial(_from_bottom, 1)) 1095 | from_right_up = partialmethod(_flow, 1096 | 'bottom_right', 'width', partial(_from_bottom, -1)) 1097 | 1098 | def flow(*views): 1099 | return Flow(*views) 1100 | 1101 | def remove_anchors(view): 1102 | at(view)._remove_anchors() 1103 | 1104 | 1105 | def size_to_fit(view): 1106 | view.size_to_fit() 1107 | if type(view) is ui.Label: 1108 | view.frame = view.frame.inset(-At.gap, -At.gap) 1109 | if type(view) is ui.Button: 1110 | view.frame = view.frame.inset(0, -At.gap) 1111 | return view 1112 | 1113 | 1114 | class FitView(ui.View): 1115 | 1116 | def __init__(self, active=True, **kwargs): 1117 | super().__init__(**kwargs) 1118 | self.active = active 1119 | 1120 | def add_subview(self, subview): 1121 | super().add_subview(subview) 1122 | if self.active: 1123 | at(self).fit_size = at(subview).frame 1124 | 1125 | 1126 | class FitScrollView(ui.View): 1127 | 1128 | def __init__(self, active=True, **kwargs): 1129 | super().__init__(**kwargs) 1130 | self.scroll_view = ui.ScrollView( 1131 | frame=self.bounds, flex='WH', 1132 | ) 1133 | self.add_subview(self.scroll_view) 1134 | 1135 | self.container = FitView(active=active) 1136 | self.scroll_view.add_subview(self.container) 1137 | 1138 | attr(self.scroll_view).content_size = at(self.container).size 1139 | 1140 | @property 1141 | def active(self): 1142 | return self.container.active 1143 | 1144 | @active.setter 1145 | def active(self, value): 1146 | self.container.active = value 1147 | 1148 | -------------------------------------------------------------------------------- /ui3/anchor/core.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import json 3 | import math 4 | import re 5 | import textwrap 6 | import traceback 7 | import warnings 8 | 9 | from functools import partialmethod, partial 10 | from itertools import accumulate 11 | from types import SimpleNamespace as ns 12 | 13 | import ui 14 | import objc_util 15 | 16 | from ui3.anchor.observer import on_change, remove_on_change 17 | 18 | 19 | # TODO: lte, gte, in_range, in_range_angle, in_rect 20 | # TODO: Greater or less than? 21 | # TODO: Priorities? 22 | 23 | 24 | _constraint_rules_spec = """ 25 | left: 26 | type: leading 27 | target: 28 | attribute: target.x 29 | value: value 30 | source: 31 | regular: source.x 32 | container: source.bounds.x 33 | right: 34 | type: trailing 35 | target: 36 | attribute: target.x 37 | value: value - target.width 38 | source: 39 | regular: source.frame.max_x 40 | container: source.bounds.max_x 41 | top: 42 | type: leading 43 | target: 44 | attribute: target.y 45 | value: value 46 | source: 47 | regular: source.y 48 | container: source.bounds.y 49 | bottom: 50 | type: trailing 51 | target: 52 | attribute: target.y 53 | value: value - target.height 54 | source: 55 | regular: source.frame.max_y 56 | container: source.bounds.max_y 57 | left_flex: 58 | type: leading 59 | target: 60 | attribute: (target.x, target.width) 61 | value: (value, target.width - (value - target.x)) 62 | right_flex: 63 | type: trailing 64 | target: 65 | attribute: target.width 66 | value: target.width + (value - (target.x + target.width)) 67 | top_flex: 68 | type: leading 69 | target: 70 | attribute: (target.y, target.height) 71 | value: (value, target.height - (value - target.y)) 72 | bottom_flex: 73 | type: trailing 74 | target: 75 | attribute: target.height 76 | value: target.height + (value - (target.y + target.height)) 77 | left_flex_center: 78 | type: leading 79 | target: 80 | attribute: (target.x, target.width) 81 | value: (value, (target.center.x - value) * 2) 82 | right_flex_center: 83 | type: trailing 84 | target: 85 | attribute: (target.x, target.width) 86 | value: (target.center.x - (value - target.center.x), (value - target.center.x) * 2) 87 | top_flex_center: 88 | type: leading 89 | target: 90 | attribute: (target.y, target.height) 91 | value: (value, (target.center.y - value) * 2) 92 | bottom_flex_center: 93 | type: trailing 94 | target: 95 | attribute: (target.y, target.height) 96 | value: (target.center.y - (value - target.center.y), (value - target.center.y) * 2) 97 | center_x: 98 | target: 99 | attribute: target.x 100 | value: value - target.width / 2 101 | source: 102 | regular: source.center.x 103 | container: source.bounds.center().x 104 | center_y: 105 | target: 106 | attribute: target.y 107 | value: value - target.height / 2 108 | source: 109 | regular: source.center.y 110 | container: source.bounds.center().y 111 | center: 112 | target: 113 | attribute: target.center 114 | value: value 115 | source: 116 | regular: tuple(source.center) 117 | container: tuple(source.bounds.center()) 118 | width: 119 | target: 120 | attribute: target.width 121 | value: value 122 | source: 123 | regular: source.width 124 | container: source.bounds.width - 2 * At.gap 125 | height: 126 | target: 127 | attribute: target.height 128 | value: value 129 | source: 130 | regular: source.height 131 | container: source.bounds.height - 2 * At.gap 132 | position: 133 | target: 134 | attribute: target.frame 135 | value: (value[0], value[1], target.width, target.height) 136 | source: 137 | regular: (source.x, source.y) 138 | container: (source.x, source.y) 139 | size: 140 | target: 141 | attribute: target.frame 142 | value: (target.x, target.y, value[0], value[1]) 143 | source: 144 | regular: (source.width, source.height) 145 | container: (source.width, source.height) 146 | frame: 147 | target: 148 | attribute: target.frame 149 | value: value 150 | source: 151 | regular: source.frame 152 | container: source.frame 153 | bounds: 154 | target: 155 | attribute: target.bounds 156 | value: value 157 | source: 158 | regular: source.bounds 159 | container: source.bounds 160 | constant: 161 | source: 162 | regular: source.data 163 | function: 164 | source: 165 | regular: source.data() 166 | heading: 167 | target: 168 | attribute: target._at._heading 169 | value: direction(target, source, value) 170 | source: 171 | regular: source._at._heading 172 | container: source._at._heading 173 | attr: 174 | target: 175 | attribute: target._custom 176 | value: value 177 | source: 178 | regular: source._custom 179 | fit_size: 180 | source: 181 | regular: subview_bounds(source) 182 | fit_width: 183 | source: 184 | regular: subview_bounds(source).width 185 | fit_height: 186 | source: 187 | regular: subview_bounds(source).height 188 | """ 189 | 190 | 191 | class At: 192 | 193 | #observer = NSKeyValueObserving('_at') 194 | 195 | gap = 8 # Apple Standard gap 196 | TIGHT = -gap 197 | constraint_warnings = True 198 | superview_warnings = True 199 | 200 | @classmethod 201 | def gaps_for(cls, count): 202 | return (count - 1) / count * At.gap 203 | 204 | @objc_util.on_main_thread 205 | def on_change(self, force_source=True): 206 | if self.checking: 207 | return 208 | self.checking = True 209 | changed = True 210 | counter = 0 211 | while changed and counter < 5: 212 | changed = False 213 | counter += 1 214 | for constraint in self.target_for.values(): 215 | value_changed = next(constraint.runner) 216 | changed = changed or value_changed 217 | if changed: 218 | force_source = True 219 | self.checking = False 220 | if force_source: 221 | for constraint in self.source_for: 222 | constraint.target.at.on_change(force_source=False) 223 | 224 | class Anchor: 225 | 226 | HORIZONTALS = set('left right center_x width fit_width'.split()) 227 | VERTICALS = set('top bottom center_y height fit_height'.split()) 228 | 229 | def __init__(self, at, prop): 230 | self.at = at 231 | self.prop = prop 232 | self.modifiers = '' 233 | self.callable = None 234 | 235 | def __add__(self, other): 236 | if callable(other): 237 | self.callable = other 238 | else: 239 | self.modifiers += f'+ {other}' 240 | return self 241 | 242 | def __sub__(self, other): 243 | self.modifiers += f'- {other}' 244 | return self 245 | 246 | def __mul__(self, other): 247 | self.modifiers += f'* {other}' 248 | return self 249 | 250 | def __truediv__(self, other): 251 | self.modifiers += f'/ {other}' 252 | return self 253 | 254 | def __floordiv__(self, other): 255 | self.modifiers += f'// {other}' 256 | return self 257 | 258 | def __mod__(self, other): 259 | self.modifiers += f'% {other}' 260 | return self 261 | 262 | def __pow__ (self, other, modulo=None): 263 | self.modifiers += f'** {other}' 264 | return self 265 | 266 | def get_edge_type(self): 267 | return At.Anchor._rules.get( 268 | self.prop, At.Anchor._rules['attr']).get( 269 | 'type', 'neutral') 270 | 271 | def get_attribute(self, prop=None): 272 | prop = prop or self.prop 273 | if prop in At.Anchor._rules: 274 | target_attribute = At.Anchor._rules[prop]['target']['attribute'] 275 | else: 276 | target_attribute = At.Anchor._rules['attr']['target']['attribute'] 277 | target_attribute = target_attribute.replace( 278 | '_custom', prop) 279 | return target_attribute 280 | 281 | def get_source_value(self, container_type): 282 | if self.prop in At.Anchor._rules: 283 | source_value = At.Anchor._rules[self.prop]['source'][container_type] 284 | else: 285 | source_value = At.Anchor._rules['attr']['source']['regular'] 286 | source_value = source_value.replace('_custom', self.prop) 287 | return source_value 288 | 289 | def get_target_value(self, prop=None): 290 | prop = prop or self.prop 291 | if prop in At.Anchor._rules: 292 | target_value = At.Anchor._rules[prop]['target']['value'] 293 | else: 294 | target_value = At.Anchor._rules['attr']['target']['value'] 295 | target_value = target_value.replace('_custom', prop) 296 | return target_value 297 | 298 | def check_for_warnings(self, source): 299 | if self.at.constraint_warnings and source.prop not in ( 300 | 'constant', 'function' 301 | ): 302 | source_direction, target_direction = [ 303 | 'v' if c in self.VERTICALS else '' + 304 | 'h' if c in self.HORIZONTALS else '' 305 | for c in (source.prop, self.prop) 306 | ] 307 | if source_direction != target_direction: 308 | warnings.warn( 309 | ConstraintWarning('Unusual constraint combination'), 310 | stacklevel=5, 311 | ) 312 | if self.at.superview_warnings: 313 | if not self.at.view.superview: 314 | warnings.warn( 315 | ConstraintWarning('Probably missing superview'), 316 | stacklevel=5, 317 | ) 318 | 319 | def record(self, constraint): 320 | if constraint.source == self: 321 | self.at.source_for.add(constraint) 322 | elif constraint.target == self: 323 | self.at._remove_constraint(self.prop) 324 | self.at.target_for[self.prop] = constraint 325 | else: 326 | raise ValueError('Disconnected constraint') 327 | 328 | def check_for_impossible_combos(self): 329 | """ 330 | Check for too many constraints resulting in an impossible combo 331 | """ 332 | h = set([*self.HORIZONTALS, 'center']) 333 | v = set([*self.VERTICALS, 'center']) 334 | active = set(self.at.target_for.keys()) 335 | horizontals = active.intersection(h) 336 | verticals = active.intersection(v) 337 | if len(horizontals) > 2: 338 | raise ConstraintError( 339 | 'Too many horizontal constraints', horizontals) 340 | elif len(verticals) > 2: 341 | raise ConstraintError( 342 | 'Too many vertical constraints', verticals) 343 | 344 | def start_observing(self): 345 | on_change(self.at.view, self.at.on_change) 346 | 347 | def trigger_change(self): 348 | self.at.on_change() 349 | 350 | def _parse_rules(rules): 351 | rule_dict = dict() 352 | dicts = [rule_dict] 353 | spaces = re.compile(' *') 354 | for i, line in enumerate(rules.splitlines()): 355 | i += 11 # Used to match error line number to my file 356 | if line.strip() == '': continue 357 | indent = len(spaces.match(line).group()) 358 | if indent % 4 != 0: 359 | raise RuntimeError(f'Broken indent on line {i}') 360 | indent = indent // 4 + 1 361 | if indent > len(dicts): 362 | raise RuntimeError(f'Extra indentation on line {i}') 363 | dicts = dicts[:indent] 364 | line = line.strip() 365 | if line.endswith(':'): 366 | key = line[:-1].strip() 367 | new_dict = dict() 368 | dicts[-1][key] = new_dict 369 | dicts.append(new_dict) 370 | else: 371 | try: 372 | key, content = line.split(':') 373 | dicts[-1][key.strip()] = content.strip() 374 | except Exception as error: 375 | raise RuntimeError(f'Cannot parse line {i}', error) 376 | return rule_dict 377 | 378 | _rules = _parse_rules(_constraint_rules_spec) 379 | 380 | 381 | class ConstantAnchor(Anchor): 382 | 383 | def __init__(self, source_data): 384 | prop = 'function' if callable(source_data) else 'constant' 385 | super().__init__( 386 | ns(view=self), 387 | prop 388 | ) 389 | self.data = source_data 390 | 391 | def record(self, constraint): 392 | pass 393 | 394 | def start_observing(self): 395 | pass 396 | 397 | def trigger_change(self): 398 | raise NotImplementedError( 399 | 'Programming error: Constant should never trigger change' 400 | ) 401 | 402 | 403 | class Constraint: 404 | 405 | REGULAR, CONTAINER = 'regular', 'container' 406 | SAME, DIFFERENT, NEUTRAL = 'same', 'different', 'neutral' 407 | TRAILING, LEADING = 'trailing', 'leading' 408 | 409 | def __init__(self, source, target): 410 | self.source = source 411 | self.target = target 412 | 413 | target.check_for_warnings(source) 414 | 415 | self.set_constraint_gen(source, target) 416 | 417 | target.record(self) 418 | source.record(self) 419 | target.check_for_impossible_combos() 420 | 421 | target.trigger_change() 422 | target.start_observing() 423 | source.start_observing() 424 | 425 | def set_constraint_gen(self, source, target): 426 | container_type, gap = self.get_characteristics(source, target) 427 | 428 | source_value = source.get_source_value(container_type) 429 | 430 | flex_get, flex_set = self.get_flex(target) 431 | 432 | call_callable = self.get_call_str(source) 433 | 434 | update_gen_str = (f'''\ 435 | # {target.prop} 436 | def constraint_runner(source, target): 437 | 438 | scripts = target.at.target_for 439 | func = source.callable 440 | source = source.at.view 441 | target = target.at.view 442 | 443 | prev_value = None 444 | prev_bounds = None 445 | while True: 446 | value = ({source_value} {gap}) {source.modifiers} 447 | 448 | {flex_get} 449 | 450 | if (target_value != prev_value or 451 | target.superview.bounds != prev_bounds): 452 | prev_value = target_value 453 | prev_bounds = target.superview.bounds 454 | {call_callable} 455 | {flex_set} 456 | yield True 457 | else: 458 | yield False 459 | 460 | self.runner = constraint_runner(source, target) 461 | ''' 462 | ) 463 | update_gen_str = textwrap.dedent(update_gen_str) 464 | #if self.target_prop == 'text': 465 | # print(update_gen_str) 466 | exec(update_gen_str) 467 | 468 | def get_characteristics(self, source, target): 469 | if target.at.view.superview == source.at.view: 470 | container_type = self.CONTAINER 471 | else: 472 | container_type = self.REGULAR 473 | 474 | source_edge_type = source.get_edge_type() 475 | target_edge_type = target.get_edge_type() 476 | 477 | align_type = self.SAME if ( 478 | source_edge_type == self.NEUTRAL or 479 | target_edge_type == self.NEUTRAL or 480 | source_edge_type == target_edge_type 481 | ) else self.DIFFERENT 482 | 483 | if (container_type == self.CONTAINER and 484 | self.NEUTRAL not in (source_edge_type, target_edge_type)): 485 | align_type = ( 486 | self.SAME 487 | if align_type == self.DIFFERENT 488 | else self.DIFFERENT 489 | ) 490 | 491 | gap = '' 492 | if align_type == self.DIFFERENT: 493 | gap = ( 494 | f'+ {At.gap}' 495 | if target_edge_type == self.LEADING 496 | else f'- {At.gap}' 497 | ) 498 | 499 | return container_type, gap 500 | 501 | def get_flex(self, target): 502 | target_attribute = target.get_attribute() 503 | flex_get = f'target_value = {target.get_target_value()}' 504 | flex_set = f'{target_attribute} = target_value' 505 | opposite_prop, center_prop = self.get_opposite(target.prop) 506 | if opposite_prop: 507 | flex_prop = target.prop + '_flex' 508 | flex_center_prop = target.prop + '_flex_center' 509 | flex_get = f''' 510 | center_props = set(('center', '{center_prop}')) 511 | if '{opposite_prop}' in scripts: 512 | target_value = ({target.get_target_value(flex_prop)}) 513 | elif len(center_props.intersection(set(scripts.keys()))): 514 | target_value = ({target.get_target_value(flex_center_prop)}) 515 | else: 516 | target_value = {target.get_target_value()} 517 | ''' 518 | flex_set = f''' 519 | if '{opposite_prop}' in scripts: 520 | {target.get_attribute(flex_prop)} = target_value 521 | elif len(center_props.intersection(set(scripts.keys()))): 522 | {target.get_attribute(flex_center_prop)} = target_value 523 | else: 524 | {target_attribute} = target_value 525 | ''' 526 | return flex_get, flex_set 527 | 528 | def get_call_str(self, source): 529 | if not source.callable: 530 | return '' 531 | 532 | call_strs = { 533 | 1: 'func(target_value)', 534 | 2: 'func(target_value, target)', 535 | 3: 'func(target_value, target, source)', 536 | } 537 | parameter_count = len(inspect.signature(source.callable).parameters) 538 | return f'target_value = {call_strs[parameter_count]}' 539 | 540 | def get_opposite(self, prop): 541 | opposites = ( 542 | ({'left', 'right'}, 'center_x'), 543 | ({'top', 'bottom'}, 'center_y') 544 | ) 545 | for pair, center_prop in opposites: 546 | try: 547 | pair.remove(prop) 548 | return pair.pop(), center_prop 549 | except KeyError: pass 550 | return (None, None) 551 | 552 | 553 | def __new__(cls, view): 554 | try: 555 | return view._at 556 | except AttributeError: 557 | at = super().__new__(cls) 558 | at.view = view 559 | at.__heading = 0 560 | at.heading_adjustment = 0 561 | at.source_for = set() 562 | at.target_for = {} 563 | at.checking = False 564 | view._at = at 565 | return at 566 | 567 | def _prop(attribute): 568 | p = property( 569 | lambda self: 570 | partial(At._getter, self, attribute)(), 571 | lambda self, value: 572 | partial(At._setter, self, attribute, value)() 573 | ) 574 | return p 575 | 576 | def _getter(self, attr_string): 577 | return At.Anchor(self, attr_string) 578 | 579 | def _setter(self, attr_string, source): 580 | target = At.Anchor(self, attr_string) 581 | if type(source) is At.Anchor: 582 | constraint = At.Constraint(source, target) 583 | #constraint.set_constraint(value) 584 | #constraint.start_observing() 585 | elif source is None: 586 | self._remove_constraint(attr_string) 587 | else: # Constant or function 588 | source = At.ConstantAnchor(source) 589 | constraint = At.Constraint(source, target) 590 | 591 | def _remove_constraint(self, attr_string): 592 | target_len = len(self.target_for) 593 | constraint = self.target_for.pop(attr_string, None) 594 | if target_len and not len(self.target_for) and not len(self.source_for): 595 | #At.observer.stop_observing(self.view) 596 | remove_on_change(self.view, self.on_change) 597 | if constraint: 598 | source_at = constraint.source.at 599 | source_len = len(source_at.source_for) 600 | source_at.source_for.discard(constraint) 601 | if (source_len and 602 | not len(source_at.source_for) and 603 | not len(source_at.target_for)): 604 | #At.observer.stop_observing(source_at.view) 605 | remove_on_change(source_at.view, source_at.on_change) 606 | 607 | @property 608 | def _heading(self): 609 | return self.__heading 610 | 611 | @_heading.setter 612 | def _heading(self, value): 613 | self.__heading = value 614 | self.view.transform = ui.Transform.rotation( 615 | value + self.heading_adjustment) 616 | self.on_change() 617 | 618 | # PUBLIC PROPERTIES 619 | 620 | left = _prop('left') 621 | right = _prop('right') 622 | top = _prop('top') 623 | bottom = _prop('bottom') 624 | center = _prop('center') 625 | center_x = _prop('center_x') 626 | center_y = _prop('center_y') 627 | width = _prop('width') 628 | height = _prop('height') 629 | position = _prop('position') 630 | size = _prop('size') 631 | frame = _prop('frame') 632 | bounds = _prop('bounds') 633 | heading = _prop('heading') 634 | fit_size = _prop('fit_size') 635 | fit_width = _prop('fit_width') 636 | fit_height = _prop('fit_height') 637 | 638 | def _remove_anchors(self): 639 | ... 640 | 641 | 642 | # Direct access functions 643 | 644 | def at(view, func=None): 645 | a = At(view) 646 | a.callable = func 647 | return a 648 | 649 | def attr(data, func=None): 650 | at = At(data) 651 | at.callable = func 652 | for attr_name in dir(data): 653 | if (not attr_name.startswith('_') and 654 | not hasattr(At, attr_name) and 655 | inspect.isdatadescriptor( 656 | inspect.getattr_static(data, attr_name) 657 | )): 658 | setattr(At, attr_name, At._prop(attr_name)) 659 | return at 660 | 661 | # Helper functions 662 | 663 | def direction(target, source, value): 664 | """ 665 | Calculate the heading if given a center 666 | """ 667 | try: 668 | if len(value) == 2: 669 | delta = value - target.center 670 | value = math.atan2(delta.y, delta.x) 671 | except TypeError: 672 | pass 673 | return value 674 | 675 | def subview_bounds(view): 676 | subviews_accumulated = list(accumulate( 677 | [v.frame for v in view.subviews], 678 | ui.Rect.union)) 679 | if len(subviews_accumulated): 680 | bounds = subviews_accumulated[-1] 681 | else: 682 | bounds = ui.Rect(0, 0, 0, 0) 683 | return bounds.inset(-At.gap, -At.gap) 684 | 685 | 686 | class ConstraintError(RuntimeError): 687 | """ 688 | Raised on impossible constraint combos. 689 | """ 690 | 691 | class ConstraintWarning(RuntimeWarning): 692 | """ 693 | Raised on suspicious constraint combos. 694 | """ 695 | 696 | class Dock: 697 | 698 | direction_map = { 699 | 'T': ('top', +1), 700 | 'L': ('left', +1), 701 | 'B': ('bottom', -1), 702 | 'R': ('right', -1), 703 | 'X': ('center_x', 0), 704 | 'Y': ('center_y', 0), 705 | 'C': ('center', 0), 706 | } 707 | 708 | def __init__(self, view): 709 | self.view = view 710 | 711 | def _dock(self, directions, superview, modifier=0): 712 | view = self.view 713 | superview.add_subview(view) 714 | v = at(view) 715 | sv = at(superview) 716 | for direction in directions: 717 | prop, sign = self.direction_map[direction] 718 | if prop != 'center': 719 | setattr(v, prop, getattr(sv, prop) + sign * modifier) 720 | else: 721 | setattr(v, prop, getattr(sv, prop)) 722 | 723 | all = partialmethod(_dock, 'TLBR') 724 | bottom = partialmethod(_dock, 'LBR') 725 | top = partialmethod(_dock, 'TLR') 726 | right = partialmethod(_dock, 'TBR') 727 | left = partialmethod(_dock, 'TLB') 728 | top_left = partialmethod(_dock, 'TL') 729 | top_right = partialmethod(_dock, 'TR') 730 | bottom_left = partialmethod(_dock, 'BL') 731 | bottom_right = partialmethod(_dock, 'BR') 732 | sides = partialmethod(_dock, 'LR') 733 | vertical = partialmethod(_dock, 'TB') 734 | top_center = partialmethod(_dock, 'TX') 735 | bottom_center = partialmethod(_dock, 'BX') 736 | left_center = partialmethod(_dock, 'LY') 737 | right_center = partialmethod(_dock, 'RY') 738 | center = partialmethod(_dock, 'C') 739 | 740 | def between(self, top=None, bottom=None, left=None, right=None): 741 | a_self = at(self.view) 742 | if top: 743 | a_self.top = at(top).bottom 744 | if bottom: 745 | a_self.bottom = at(bottom).top 746 | if left: 747 | a_self.left = at(left).right 748 | if right: 749 | a_self.right = at(right).left 750 | if top or bottom: 751 | a = at(top or bottom) 752 | a_self.width = a.width 753 | a_self.center_x = a.center_x 754 | if left or right: 755 | a = at(left or right) 756 | a_self.height = a.height 757 | a_self.center_y = a.center_y 758 | 759 | def above(self, other): 760 | other.superview.add_subview(self.view) 761 | at(self.view).bottom = at(other).top 762 | align(self.view).center_x(other) 763 | 764 | def below(self, other): 765 | other.superview.add_subview(self.view) 766 | at(self.view).top = at(other).bottom 767 | at(self.view).center_x = at(other).center_x 768 | 769 | def left_of(self, other): 770 | other.superview.add_subview(self.view) 771 | at(self.view).right = at(other).left 772 | align(self.view).center_y(other) 773 | 774 | def right_of(self, other): 775 | other.superview.add_subview(self.view) 776 | at(self.view).left = at(other).right 777 | align(self.view).center_y(other) 778 | 779 | 780 | def dock(view) -> Dock: 781 | return Dock(view) 782 | 783 | 784 | class Align: 785 | 786 | modifiable = 'left right top bottom center_x center_y width height heading' 787 | 788 | def __init__(self, *others): 789 | self.others = others 790 | 791 | def _align(self, prop, view, modifier=0): 792 | anchor_at = at(view) 793 | use_modifier = prop in self.modifiable.split() 794 | for other in self.others: 795 | if use_modifier: 796 | setattr(at(other), prop, 797 | getattr(anchor_at, prop) + modifier) 798 | else: 799 | setattr(at(other), prop, getattr(anchor_at, prop)) 800 | 801 | left = partialmethod(_align, 'left') 802 | right = partialmethod(_align, 'right') 803 | top = partialmethod(_align, 'top') 804 | bottom = partialmethod(_align, 'bottom') 805 | center = partialmethod(_align, 'center') 806 | center_x = partialmethod(_align, 'center_x') 807 | center_y = partialmethod(_align, 'center_y') 808 | width = partialmethod(_align, 'width') 809 | height = partialmethod(_align, 'height') 810 | position = partialmethod(_align, 'position') 811 | size = partialmethod(_align, 'size') 812 | frame = partialmethod(_align, 'frame') 813 | bounds = partialmethod(_align, 'bounds') 814 | heading = partialmethod(_align, 'heading') 815 | 816 | def align(*others): 817 | return Align(*others) 818 | 819 | 820 | class Fill: 821 | 822 | def __init__(self, *views): 823 | self.views = views 824 | 825 | def _fill(self, 826 | corner, attr, opposite, center, side, other_side, size, other_size, 827 | superview, count=1): 828 | views = self.views 829 | assert len(views) > 0, 'Give at least one view to fill with' 830 | first = views[0] 831 | getattr(dock(first), corner)(superview) 832 | gaps = At.gaps_for(count) 833 | per_count = math.ceil(len(views)/count) 834 | per_gaps = At.gaps_for(per_count) 835 | super_at = at(superview) 836 | for i, view in enumerate(views[1:]): 837 | superview.add_subview(view) 838 | if (i + 1) % per_count != 0: 839 | setattr(at(view), attr, getattr(at(views[i]), opposite)) 840 | setattr(at(view), center, getattr(at(views[i]), center)) 841 | else: 842 | setattr(at(view), attr, getattr(super_at, attr)) 843 | setattr(at(view), side, getattr(at(views[i]), other_side)) 844 | for view in views: 845 | setattr(at(view), size, 846 | getattr(super_at, size) + ( 847 | lambda v: v / per_count - per_gaps 848 | ) 849 | ) 850 | setattr(at(view), other_size, 851 | getattr(super_at, other_size) + ( 852 | lambda v: v / count - gaps 853 | ) 854 | ) 855 | 856 | from_top = partialmethod(_fill, 'top_left', 857 | 'top', 'bottom', 'center_x', 858 | 'left', 'right', 859 | 'height', 'width') 860 | from_bottom = partialmethod(_fill, 'bottom_left', 861 | 'bottom', 'top', 'center_x', 862 | 'left', 'right', 863 | 'height', 'width') 864 | from_left = partialmethod(_fill, 'top_left', 865 | 'left', 'right', 'center_y', 866 | 'top', 'bottom', 867 | 'width', 'height') 868 | from_right = partialmethod(_fill, 'top_right', 869 | 'right', 'left', 'center_y', 870 | 'top', 'bottom', 871 | 'width', 'height') 872 | 873 | 874 | def fill_with(*views): 875 | return Fill(*views) 876 | 877 | 878 | class Flow: 879 | 880 | def __init__(self, *views): 881 | self.views = views 882 | 883 | def _flow(self, corner, size, func, superview): 884 | assert len(self.views) > 0, 'Give at least one view for the flow' 885 | views = self.views 886 | super_at = at(superview) 887 | first = views[0] 888 | getattr(dock(first), corner)(superview) 889 | for i, view in enumerate(views[1:]): 890 | superview.add_subview(view) 891 | setattr(at(view), size, 892 | getattr(at(views[i]), size)) 893 | at(view).frame = at(views[i]).frame + func 894 | 895 | def _from_left(down, value, target): 896 | if value.max_x + target.width + 2 * At.gap > target.superview.width: 897 | return (At.gap, value.y + down * (target.height + At.gap), 898 | target.width, target.height) 899 | return (value.max_x + At.gap, value.y, target.width, target.height) 900 | 901 | from_top_left = partialmethod(_flow, 902 | 'top_left', 'height', 903 | partial(_from_left, 1)) 904 | from_bottom_left = partialmethod(_flow, 905 | 'bottom_left', 'height', 906 | partial(_from_left, -1)) 907 | 908 | def _from_right(down, value, target): 909 | if value.x - target.width - At.gap < At.gap: 910 | return (target.superview.width - target.width - At.gap, 911 | value.y + down * (target.height + At.gap), 912 | target.width, target.height) 913 | return (value.x - At.gap - target.width, value.y, 914 | target.width, target.height) 915 | 916 | from_top_right = partialmethod(_flow, 917 | 'top_right', 'height', partial(_from_right, 1)) 918 | from_bottom_right = partialmethod(_flow, 919 | 'bottom_right', 'height', partial(_from_right, -1)) 920 | 921 | def _from_top(right, value, target): 922 | if value.max_y + target.height + 2 * At.gap > target.superview.height: 923 | return (value.x + right * (target.width + At.gap), At.gap, 924 | target.width, target.height) 925 | return (value.x, value.max_y + At.gap, target.width, target.height) 926 | 927 | from_left_down = partialmethod(_flow, 928 | 'top_left', 'width', partial(_from_top, 1)) 929 | from_right_down = partialmethod(_flow, 930 | 'top_right', 'width', partial(_from_top, -1)) 931 | 932 | def _from_bottom(right, value, target): 933 | if value.y - target.height - At.gap < At.gap: 934 | return (value.x + right * (target.width + At.gap), 935 | target.superview.height - target.height - At.gap, 936 | target.width, target.height) 937 | return (value.x, value.y - target.height - At.gap, 938 | target.width, target.height) 939 | 940 | from_left_up = partialmethod(_flow, 941 | 'bottom_left', 'width', partial(_from_bottom, 1)) 942 | from_right_up = partialmethod(_flow, 943 | 'bottom_right', 'width', partial(_from_bottom, -1)) 944 | 945 | def flow(*views): 946 | return Flow(*views) 947 | 948 | def remove_anchors(view): 949 | at(view)._remove_anchors() 950 | 951 | 952 | def size_to_fit(view): 953 | view.size_to_fit() 954 | if type(view) is ui.Label: 955 | view.frame = view.frame.inset(-At.gap, -At.gap) 956 | if type(view) is ui.Button: 957 | view.frame = view.frame.inset(0, -At.gap) 958 | return view 959 | 960 | -------------------------------------------------------------------------------- /ui3/anchor/objc_plus.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import types 3 | import uuid 4 | 5 | import objc_util 6 | 7 | 8 | class ObjCPlus: 9 | 10 | def __new__(cls, *args, **kwargs): 11 | objc_class = getattr(cls, '_objc_class', None) 12 | if objc_class is None: 13 | objc_class_name = cls.__name__ + '_ObjC' 14 | objc_superclass = getattr( 15 | cls, '_objc_superclass', objc_util.NSObject) 16 | objc_debug = getattr(cls, '_objc_debug', True) 17 | 18 | #'TempClass_'+str(uuid.uuid4())[-12:] 19 | 20 | objc_methods = [] 21 | objc_classmethods = [] 22 | for key in cls.__dict__: 23 | value = getattr(cls, key) 24 | if (inspect.isfunction(value) and 25 | '_self' in inspect.signature(value).parameters 26 | ): 27 | if getattr(value, '__self__', None) == cls: 28 | objc_classmethods.append(value) 29 | else: 30 | objc_methods.append(value) 31 | ''' 32 | objc_methods = [value 33 | for value in cls.__dict__.values() 34 | if ( 35 | callable(value) and 36 | '_self' in inspect.signature(value).parameters 37 | ) 38 | ] 39 | ''' 40 | if ObjCDelegate in cls.__mro__: 41 | objc_protocols = [cls.__name__] 42 | else: 43 | objc_protocols = getattr(cls, '_objc_protocols', []) 44 | if not type(objc_protocols) is list: 45 | objc_protocols = [objc_protocols] 46 | cls._objc_class = objc_class = objc_util.create_objc_class( 47 | objc_class_name, 48 | superclass=objc_superclass, 49 | methods=objc_methods, 50 | classmethods=objc_classmethods, 51 | protocols=objc_protocols, 52 | debug=objc_debug 53 | ) 54 | 55 | instance = objc_class.alloc().init() 56 | 57 | for key in dir(cls): 58 | value = getattr(cls, key) 59 | if inspect.isfunction(value): 60 | if not '_self' in inspect.signature(value).parameters: 61 | setattr(instance, key, types.MethodType(value, instance)) 62 | if key == '__init__': 63 | value(instance, *args, **kwargs) 64 | return instance 65 | 66 | 67 | class ObjCDelegate(ObjCPlus): 68 | """ If you inherit from this class, the class name must match the delegate 69 | protocol name. """ 70 | 71 | 72 | 73 | if __name__ == '__main__': 74 | 75 | class TestClass(ObjCPlus): 76 | 77 | def __init__(self): 78 | self.test_variable = 'Instance attribute' 79 | 80 | instance = TestClass() 81 | assert instance.test_variable == 'Instance attribute' 82 | assert type(instance) is objc_util.ObjCInstance 83 | 84 | class GestureHandler(ObjCPlus): 85 | 86 | # Can be a single string or a list 87 | _objc_protocols = 'UIGestureRecognizerDelegate' 88 | 89 | # Vanilla Python __init__ 90 | def __init__(self): 91 | self.other_recognizers = [] 92 | 93 | # ObjC delegate method 94 | def gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_( 95 | _self, _sel, _gr, _other_gr): 96 | self = ObjCInstance(_self) 97 | other_gr = ObjCInstance(_other_gr) 98 | return other_gr in self.other_recognizers 99 | 100 | # Custom ObjC action target 101 | def gestureAction(_self, _cmd): 102 | self = ObjCInstance(_self) 103 | ... 104 | 105 | # Custom ObjC class method 106 | @classmethod 107 | def gestureType(_class, _cmd): 108 | ... 109 | 110 | # Vanilla Python method 111 | @objc_util.on_main_thread 112 | def before(self): 113 | return self.other_recognizers 114 | 115 | handler = GestureHandler() 116 | assert type(handler) is objc_util.ObjCInstance 117 | assert type(handler.other_recognizers) is list 118 | assert type(handler.before()) is list 119 | assert hasattr(handler, 'gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_') 120 | print(handler.__dict__) 121 | 122 | def gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_( 123 | _self, _sel, _gr, _other_gr): 124 | self = ObjCInstance(_self) 125 | other_gr = ObjCInstance(_other_gr) 126 | return other_gr in self.other_recognizers 127 | 128 | # Custom ObjC action target 129 | def gestureAction(_self, _cmd): 130 | self = ObjCInstance(_self) 131 | ... 132 | 133 | GestureHandlerObjC = objc_util.create_objc_class( 134 | 'GestureHandlerObjC', 135 | methods=[ 136 | gestureAction, 137 | gestureRecognizer_shouldRecognizeSimultaneouslyWithGestureRecognizer_, 138 | ], 139 | protocols=['UIGestureRecognizerDelegate'], 140 | ) 141 | 142 | class GestureHandler2(ObjCPlus): 143 | 144 | _objc_class = GestureHandlerObjC 145 | 146 | # Vanilla Python __init__ 147 | def __init__(self): 148 | self.other_recognizers = [] 149 | 150 | # Vanilla Python method 151 | @objc_util.on_main_thread 152 | def before(self): 153 | return self.other_recognizers 154 | 155 | handler = GestureHandler2() 156 | 157 | assert type(handler) is objc_util.ObjCInstance 158 | assert type(handler.other_recognizers) is list 159 | assert type(handler.before()) is list 160 | 161 | -------------------------------------------------------------------------------- /ui3/anchor/observer.py: -------------------------------------------------------------------------------- 1 | import objc_util 2 | 3 | from .objc_plus import ObjCDelegate 4 | 5 | 6 | class NSKeyValueObserving(ObjCDelegate): 7 | 8 | def __init__(self, observer_list='_frame_observers'): 9 | #objc_util.retain_global(self) 10 | self.targets = {} 11 | self.callbacks = {} 12 | self.observerattr = observer_list 13 | self.observeattrs = ( 14 | 'bounds', 15 | 'transform', 16 | 'position', 17 | 'anchorPoint', 18 | 'frame', 19 | 'contentOffset') 20 | 21 | def observe(self, target_view, callback_func): 22 | objc_target = target_view.objc_instance 23 | if objc_target in self.targets: return 24 | self.targets[objc_target] = target_view 25 | self.callbacks.setdefault(objc_target, []).append(callback_func) 26 | for key in self.observeattrs: 27 | objc_target.layer().addObserver_forKeyPath_options_context_( 28 | self, key, 0, None) 29 | 30 | 31 | def stop_observing(self, target_view, callback_func): 32 | objc_target = target_view.objc_instance 33 | callbacks = self.callbacks.get(objc_target, []) 34 | callbacks.remove(callback_func) 35 | if len(callbacks) == 0: 36 | target_view = self.targets.pop(objc_target, None) 37 | if target_view: 38 | for key in self.observeattrs: 39 | objc_target.layer().\ 40 | removeObserver_forKeyPath_(self, key) 41 | 42 | def stop_all(self): 43 | for target in list(self.targets.values()): 44 | for key in self.observeattrs: 45 | objc_target.layer().\ 46 | removeObserver_forKeyPath_(self, key) 47 | self.targets = {} 48 | self.callbacks = {} 49 | 50 | def observeValueForKeyPath_ofObject_change_context_( 51 | _self, _cmd, _path, _obj, _change, _ctx 52 | ): 53 | self = objc_util.ObjCInstance(_self) 54 | objc_target = objc_util.ObjCInstance(_obj).delegate() 55 | try: 56 | target_view = self.targets.get(objc_target) 57 | if target_view: 58 | for callback in self.callbacks.get(objc_target, []): 59 | callback(target_view) 60 | except Exception as e: 61 | print('observeValueForKeyPath:', self, type(e), e) 62 | 63 | 64 | observer = NSKeyValueObserving() 65 | 66 | def on_change(view, func): 67 | """ 68 | Call func when view frame (position or size) changes. 69 | Several functions can be registered per view. 70 | """ 71 | observer.observe(view, func) 72 | 73 | def remove_on_change(view, func): 74 | """ 75 | Remove func from the list of functions to be called 76 | when the frame of view changes. 77 | """ 78 | observer.stop_observing(view, func) 79 | 80 | -------------------------------------------------------------------------------- /ui3/anchor/safe_area.py: -------------------------------------------------------------------------------- 1 | import objc_util 2 | import ui 3 | 4 | NSLayoutConstraint = objc_util.ObjCClass('NSLayoutConstraint') 5 | 6 | 7 | class SafeAreaView(ui.View): 8 | 9 | def __init__(self, superview=None, **kwargs): 10 | super().__init__(**kwargs) 11 | 12 | if superview: 13 | self._set_constraints(superview) 14 | 15 | def _set_constraints(self, superview): 16 | superview.add_subview(self) 17 | selfo = self.objc_instance 18 | supero = superview.objc_instance 19 | selfo.setTranslatesAutoresizingMaskIntoConstraints_(False) 20 | safe = supero.safeAreaLayoutGuide() 21 | NSLayoutConstraint.activateConstraints_([ 22 | selfo.topAnchor().constraintEqualToAnchor_constant_( 23 | safe.topAnchor(), 0), 24 | selfo.bottomAnchor().constraintEqualToAnchor_constant_( 25 | safe.bottomAnchor(), 0), 26 | selfo.leftAnchor().constraintEqualToAnchor_constant_( 27 | safe.leftAnchor(), 0), 28 | selfo.rightAnchor().constraintEqualToAnchor_constant_( 29 | safe.rightAnchor(), 0), 30 | ]) 31 | 32 | def present(self, *args, **kwargs): 33 | real_root = ui.View(background_color=self.background_color) 34 | self._set_constraints(real_root) 35 | real_root.present(*args, **kwargs) 36 | 37 | -------------------------------------------------------------------------------- /ui3/gridview.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import ui 4 | 5 | 6 | class GridView(ui.View): 7 | 'Places subviews as squares that fill the available space.' 8 | 9 | FILL = 'III' 10 | SPREAD = '___' 11 | CENTER = '_I_' 12 | START = 'II_' 13 | END = '_II' 14 | SIDES = 'I_I' 15 | START_SPREAD = 'I__' 16 | END_SPREAD = '__I' 17 | 18 | MARGIN = 8 19 | TIGHT = 0 20 | 21 | def __init__(self, 22 | pack_x=None, 23 | pack_y=None, 24 | pack=CENTER, 25 | count_x=None, 26 | count_y=None, 27 | gap=MARGIN, 28 | **kwargs): 29 | '''By default, subviews are laid out in a grid as squares of optimal size and 30 | centered in the view. 31 | 32 | You can fix the amount of views in either dimension with the `count_x` or 33 | `count_y` parameter, or change the packing behaviour by providing 34 | the `pack` parameter with one of the following values: 35 | 36 | * `CENTER` - Clustered in the center (the default) 37 | * `SPREAD` - Distributed evenly 38 | * `FILL` - Fill the available space with only margins in between 39 | (no longer squares) 40 | * `LEADING, TRAILING` (`pack_x` only) 41 | * `TOP, BOTTOM` (`pack_y` only) 42 | ''' 43 | 44 | super().__init__(**kwargs) 45 | 46 | self.pack_x = pack_x or pack 47 | self.pack_y = pack_y or pack 48 | 49 | self.leading_free = self.pack_x[0] == '_' 50 | self.center_x_free = self.pack_x[1] == '_' 51 | self.trailing_free = self.pack_x[2] == '_' 52 | self.top_free = self.pack_y[0] == '_' 53 | self.center_y_free = self.pack_y[1] == '_' 54 | self.bottom_free = self.pack_y[2] == '_' 55 | 56 | self.count_x = count_x 57 | self.count_y = count_y 58 | 59 | self.gap = gap 60 | 61 | def dimensions(self, count): 62 | if self.height == 0: 63 | return 1, count 64 | ratio = self.width / self.height 65 | count_x = min(count, math.sqrt(count * self.width / self.height)) 66 | count_y = min(count, math.sqrt(count * self.height / self.width)) 67 | operations = ((math.floor, math.floor), (math.floor, math.ceil), 68 | (math.ceil, math.floor), (math.ceil, math.ceil)) 69 | best = None 70 | best_x = None 71 | best_y = None 72 | for oper in operations: 73 | cand_x = oper[0](count_x) 74 | cand_y = oper[1](count_y) 75 | diff = cand_x * cand_y - count 76 | if diff >= 0: 77 | if best is None or diff < best: 78 | best = diff 79 | best_x = cand_x 80 | best_y = cand_y 81 | return best_x, best_y 82 | 83 | def layout(self): 84 | count = len(self.subviews) 85 | if count == 0: return 86 | 87 | count_x, count_y = self.count_x, self.count_y 88 | if count_x is None and count_y is None: 89 | count_x, count_y = self.dimensions(count) 90 | elif count_x is None: 91 | count_x = math.ceil(count / count_y) 92 | elif count_y is None: 93 | count_y = math.ceil(count / count_x) 94 | if count > count_x * count_y: 95 | raise ValueError( 96 | f'Fixed counts (x: {count_x}, y: {count_y}) not enough to display all views' 97 | ) 98 | 99 | borders = 2 * self.border_width 100 | 101 | dim_x = (self.width - borders - (count_x + 1) * self.gap) / count_x 102 | dim_y = (self.height - borders - (count_y + 1) * self.gap) / count_y 103 | 104 | dim = min(dim_x, dim_y) 105 | 106 | px = self.pack_x 107 | exp_pack_x = px[0] + px[1] * (count_x - 1) + px[2] 108 | py = self.pack_y 109 | exp_pack_y = py[0] + py[1] * (count_y - 1) + py[2] 110 | free_count_x = exp_pack_x.count('_') 111 | free_count_y = exp_pack_y.count('_') 112 | 113 | if free_count_x > 0: 114 | per_free_x = ( 115 | self.width - borders - count_x * dim - 116 | (count_x + 1 - free_count_x) * self.gap) / free_count_x 117 | if free_count_y > 0: 118 | per_free_y = ( 119 | self.height - borders - count_y * dim - 120 | (count_y + 1 - free_count_y) * self.gap) / free_count_y 121 | 122 | real_dim_x = dim_x if free_count_x == 0 else dim 123 | real_dim_y = dim_y if free_count_y == 0 else dim 124 | 125 | subviews = iter(self.subviews) 126 | y = self.border_width + (per_free_y if self.top_free else self.gap) 127 | for row in range(count_y): 128 | x = self.border_width + (per_free_x 129 | if self.leading_free else self.gap) 130 | for col in range(count_x): 131 | try: 132 | view = next(subviews) 133 | except StopIteration: 134 | return 135 | view.frame = (x, y, real_dim_x, real_dim_y) 136 | x += real_dim_x + (per_free_x 137 | if self.center_x_free else self.gap) 138 | y += real_dim_y + (per_free_y if self.center_y_free else self.gap) 139 | 140 | -------------------------------------------------------------------------------- /ui3/hierarchy.py: -------------------------------------------------------------------------------- 1 | import ui 2 | 3 | 4 | class Views(dict): 5 | ''' A class that is used to create a hierarchy of ui views defined by 6 | a tree structure, and with the given constraints. 7 | Also stores the created views in depth-first order. 8 | Views can be accessed equivalently with dict references or as attributes: 9 | * `views['top']` 10 | * `views.top` 11 | ''' 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self._create_views() 16 | 17 | def view_hierarchy(self): 18 | ''' Sample view hierarchy dictionary: 19 | { 'root': (ui.View, { 20 | 'top': (ui.View, { 21 | 'search_text': ui.TextField, 22 | 'search_action': ui.Button, 23 | }), 24 | 'middle': ui.View, 25 | 'bottom': (ui.View, { 26 | 'accept': ui.Button, 27 | 'cancel': ui.Button, 28 | }) 29 | }) } 30 | I.e. view names as keys, view classes as values. 31 | If the value is a tuple instead, the first value must be the view class 32 | and the second value a dictionary for the next level of the view 33 | hierarchy. 34 | 35 | View names must match the requirements for identifiers, and 36 | not be any of the Python keywords or attributes of this class 37 | (inheriting `dict`). ''' 38 | 39 | return ( 'root', ui.View ) 40 | 41 | def view_defaults(self, view): 42 | ''' Views are initialized with no arguments. This method is called 43 | with the initialized view to set any defaults you want. 44 | The base implementation creates black views with 45 | white borders, tint and text. ''' 46 | bg = 'black' 47 | fg = 'white' 48 | view.background_color = bg 49 | view.border_color = fg 50 | view.border_width = 1 51 | view.tint_color = fg 52 | view.text_color = fg 53 | 54 | def set_constraints(self): 55 | ''' After all views have been initialized and included in 56 | the hierarchy, this method is called to set the constraints. 57 | Base implementation does nothing. ''' 58 | pass 59 | 60 | def present(self, *args, **kwargs): 61 | ''' Presents the root view of the hierarchy. The base implementation 62 | is a plain `present()` with no arguments. 63 | Return `self` so that you can combine the call with hierarchy init: 64 | 65 | views = Views().present() 66 | ''' 67 | next(iter(self.values())).present(*args, **kwargs) 68 | return self 69 | 70 | def __getattr__(self, key, oga=object.__getattribute__): 71 | if key in self: 72 | return self[key] 73 | else: 74 | return oga(self, key) 75 | 76 | def _create_views(self): 77 | ''' Method that creates a view hierarchy as specified by the 78 | view hierarchy spec. 79 | Each created view is stored by name in `self`. 80 | ''' 81 | 82 | def recursive_view_generation(view_spec, parent): 83 | if parent is None: 84 | assert len(view_spec) in (2, 3), 'Give exactly one root element' 85 | previous_view = None 86 | for is_subspec, group in groupby(view_spec, lambda x: type(x) is tuple): 87 | if is_subspec: 88 | recursive_view_generation(next(group), previous_view) 89 | continue 90 | for view_name, view_class in chunked(group, 2): 91 | assert ( 92 | view_name.isidentifier() 93 | ), f'{view_name} is not a valid identifier' 94 | assert ( 95 | not keyword.iskeyword(view_name) 96 | ), f'Cannot use a keyword as a view name ({view_name})' 97 | assert ( 98 | not view_name in dir(self) 99 | ), f'{view_name} is a member of Views class' 100 | 101 | previous_view = view = view_class(name=view_name) 102 | if parent: 103 | parent.add_subview(view) 104 | self.view_defaults(view) 105 | self[view_name] = view 106 | if parent is None: 107 | self.set_constraints() 108 | 109 | recursive_view_generation(self.view_hierarchy(), None) 110 | 111 | -------------------------------------------------------------------------------- /ui3/menu.py: -------------------------------------------------------------------------------- 1 | import ctypes 2 | 3 | from functools import partial 4 | 5 | import objc_util 6 | 7 | 8 | UIMenu = objc_util.ObjCClass('UIMenu') 9 | UIAction = objc_util.ObjCClass('UIAction') 10 | 11 | 12 | class Action: 13 | 14 | DISABLED = 1 15 | DESTRUCTIVE = 2 16 | HIDDEN = 4 17 | 18 | REGULAR = 0 19 | SELECTED = 1 20 | 21 | def __init__(self, 22 | title, 23 | handler, 24 | image=None, 25 | attributes=None, 26 | state=False, 27 | discoverability_title=None, 28 | ): 29 | self._menu = None 30 | self._handler = handler 31 | self._title = title 32 | self._image = image 33 | self._attributes = attributes 34 | self._state = state 35 | self._discoverability_title = discoverability_title 36 | 37 | def _action_handler(_cmd): 38 | self._handler( 39 | self._menu.button, 40 | self 41 | ) 42 | 43 | _action_handler_block = objc_util.ObjCBlock( 44 | _action_handler, 45 | restype=None, 46 | argtypes=[ctypes.c_void_p]) 47 | objc_util.retain_global(_action_handler_block) 48 | 49 | self._objc_action = UIAction.actionWithHandler_(_action_handler_block) 50 | 51 | self._update_objc_action() 52 | 53 | def _update_objc_action(self): 54 | a = self._objc_action 55 | 56 | a.setTitle_(self.title) 57 | 58 | if not self.image: 59 | a.setImage_(None) 60 | else: 61 | try: 62 | image = self.image.objc_instance 63 | except AttributeError: 64 | image = self.image 65 | if self.destructive: 66 | image = image.imageWithTintColor_(objc_util.UIColor.systemRedColor()) 67 | a.setImage_(image) 68 | 69 | if not self.attributes is None: 70 | a.setAttributes_(self.attributes) 71 | 72 | a.state = self.state 73 | 74 | if self.discoverability_title: 75 | a.setDiscoverabilityTitle_(self.discoverability_title) 76 | 77 | if self._menu: 78 | self._menu.create_or_update() 79 | 80 | def _prop(attribute): 81 | p = property( 82 | lambda self: 83 | partial(Action._getter, self, attribute)(), 84 | lambda self, value: 85 | partial(Action._setter, self, attribute, value)() 86 | ) 87 | return p 88 | 89 | def _getter(self, attr_string): 90 | return getattr(self, f'_{attr_string}') 91 | 92 | def _setter(self, attr_string, value): 93 | setattr(self, f'_{attr_string}', value) 94 | self._update_objc_action() 95 | 96 | title = _prop('title') 97 | handler = _prop('handler') 98 | image = _prop('image') 99 | discoverability_title = _prop('discoverability_title') 100 | attributes = _prop('attributes') 101 | state = _prop('state') 102 | 103 | @property 104 | def selected(self): 105 | return self.state == self.SELECTED 106 | 107 | @selected.setter 108 | def selected(self, value): 109 | self.state = self.SELECTED if value else self.REGULAR 110 | 111 | def _attr_prop(bitmask): 112 | p = property( 113 | lambda self: 114 | partial(Action._attr_getter, self, bitmask)(), 115 | lambda self, value: 116 | partial(Action._attr_setter, self, bitmask, value)() 117 | ) 118 | return p 119 | 120 | def _attr_getter(self, bitmask): 121 | return bool(self.attributes and self.attributes & bitmask) 122 | 123 | def _attr_setter(self, bitmask, value): 124 | if not self.attributes: 125 | if value: 126 | self.attributes = bitmask 127 | else: 128 | if value: 129 | self.attributes |= bitmask 130 | else: 131 | self.attributes &= ~bitmask 132 | 133 | hidden = _attr_prop(HIDDEN) 134 | destructive = _attr_prop(DESTRUCTIVE) 135 | disabled = _attr_prop(DISABLED) 136 | 137 | 138 | class Menu: 139 | 140 | def __init__(self, button, actions, long_press): 141 | self.button = button 142 | self.actions = actions 143 | self.long_press = long_press 144 | self.create_or_update() 145 | 146 | def create_or_update(self): 147 | objc_actions = [] 148 | for action in self.actions: 149 | action._menu = self 150 | objc_actions.append(action._objc_action) 151 | if not objc_actions: 152 | raise RuntimeError('No actions', self.actions) 153 | objc_menu = UIMenu.menuWithChildren_(objc_actions) 154 | objc_button = self.button.objc_instance.button() 155 | objc_button.setMenu_(objc_menu) 156 | objc_button.setShowsMenuAsPrimaryAction_(not self.long_press) 157 | 158 | 159 | def set_menu(button, items, long_press=False): 160 | actions = [] 161 | for item in items: 162 | if not isinstance(item, Action): 163 | title, handler = item 164 | item = Action(title, handler) 165 | actions.append(item) 166 | 167 | return Menu(button, actions, long_press) 168 | 169 | 170 | if __name__ == '__main__': 171 | 172 | import ui 173 | 174 | v = ui.View() 175 | 176 | # Plain button 177 | 178 | button = ui.Button( 179 | title='Plain', 180 | background_color='white', 181 | tint_color='black', 182 | flex='TBLR', 183 | ) 184 | button.frame = (0, 0, 200, 30) 185 | button.center = v.width/2, v.height/4 186 | 187 | v.add_subview(button) 188 | 189 | def handler(sender, action): 190 | print(action.title) 191 | 192 | set_menu(button, [ 193 | ('First', handler), 194 | ('Second', handler), 195 | ('Third', handler), 196 | ]) 197 | 198 | # Toggles 199 | 200 | button2 = ui.Button( 201 | title='Toggles and hidden', 202 | background_color='white', 203 | tint_color='black', 204 | flex='TBLR', 205 | ) 206 | button2.frame = (0, 0, 200, 30) 207 | button2.center = v.width/2, v.height*1.8/4 208 | 209 | v.add_subview(button2) 210 | 211 | handler_placeholder = print 212 | 213 | expert_action = Action( 214 | "Special expert action", 215 | print, 216 | ) 217 | expert_action.hidden = True 218 | 219 | def toggle_handler(sender, action): 220 | action.selected = not action.selected 221 | expert_action.hidden = not action.selected 222 | 223 | set_menu(button2, [ 224 | ('Expert mode', toggle_handler), 225 | expert_action, 226 | ]) 227 | 228 | # Styling 229 | 230 | from ui3.sfsymbol import SymbolImage 231 | 232 | button3 = ui.Button( 233 | title='Styling', 234 | background_color='white', 235 | tint_color='black', 236 | flex='TBLR', 237 | ) 238 | button3.frame = (0, 0, 200, 30) 239 | button3.center = v.width/2, v.height*3/4 240 | 241 | v.add_subview(button3) 242 | 243 | set_menu(button3, [ 244 | Action( 245 | 'Verbose menu item gets the space it needs', handler_placeholder, 246 | ), 247 | Action( 248 | 'Regular Pythonista icon', handler_placeholder, 249 | image=ui.Image('iob:close_32'), 250 | ), 251 | 252 | Action( 253 | 'SFSymbol', handler_placeholder, 254 | image=SymbolImage('photo.on.rectangle'), 255 | ), 256 | Action( 257 | 'Destructive', handler_placeholder, 258 | image=SymbolImage('tornado'), 259 | attributes=Action.DESTRUCTIVE, 260 | ), 261 | Action( 262 | 'Disabled', handler_placeholder, 263 | attributes=Action.DISABLED, 264 | ), 265 | ]) 266 | 267 | v.present('fullscreen') 268 | 269 | 270 | -------------------------------------------------------------------------------- /ui3/pagecontrol.py: -------------------------------------------------------------------------------- 1 | """ 2 | Original wrapper code by Samer in forums. 3 | Added iOS 14 features. 4 | """ 5 | 6 | import ui 7 | from objc_util import ObjCClass, CGRect, create_objc_class, ObjCInstance, UIColor 8 | 9 | UIPageControl = ObjCClass('UIPageControl') 10 | 11 | 12 | def changePage(_self, _cmd): 13 | self = ObjCInstance(_self) 14 | self.page_control.set_page(self.page_control.pageControl.currentPage()) 15 | 16 | 17 | ChangePageClass = create_objc_class("ChangePageClass", methods=[changePage]) 18 | 19 | 20 | class PageControl(ui.View): 21 | def __init__(self, **kwargs): 22 | 23 | self.scrollView = ui.ScrollView( 24 | delegate=self, 25 | paging_enabled=True, 26 | shows_horizontal_scroll_indicator=False, 27 | bounces=False, 28 | frame=self.bounds, flex='WH', 29 | ) 30 | 31 | self.pageControl = UIPageControl.alloc().init().autorelease() 32 | self._target = ChangePageClass.new().autorelease() 33 | self._target.page_control = self 34 | self.pageControl.addTarget_action_forControlEvents_(self._target, 'changePage', 1 << 12) #1<<12 = 4096 35 | self.pageControl.numberOfPages = len(self.scrollView.subviews) 36 | self.pageControl.currentPage = 0 37 | self.pageControl.hidesForSinglePage = True 38 | 39 | self._prev_page = 0 40 | 41 | super().add_subview(self.scrollView) 42 | ObjCInstance(self).addSubview_(self.pageControl) 43 | 44 | super().__init__(**kwargs) 45 | 46 | def present(self, *args, **kwargs): 47 | if 'hide_title_bar' in kwargs and kwargs['hide_title_bar']: 48 | #Temp work around for possible bug. 49 | background = ui.View(background_color=self.background_color) 50 | background.present(*args, **kwargs) 51 | self.frame = background.bounds 52 | background.add_subview(self) 53 | else: 54 | super().present(*args, **kwargs) 55 | 56 | def layout(self): 57 | self.scrollView.content_size = (self.scrollView.width * len(self.scrollView.subviews), 0) 58 | safe_bottom = self.bounds.max_y - self.objc_instance.safeAreaInsets().bottom 59 | size = self.pageControl.sizeForNumberOfPages_(self.pageControl.numberOfPages()) 60 | self.pageControl.frame = CGRect( 61 | (self.bounds.center().x - self.bounds.width / 2, safe_bottom - size.height), 62 | (self.bounds.width, size.height)) 63 | 64 | for i, v in enumerate(self.scrollView.subviews): 65 | v.x = i * self.bounds.width 66 | 67 | self.set_page(self.pageControl.currentPage()) 68 | 69 | def scrollview_did_scroll(self, scrollView): 70 | pageNumber = round(self.scrollView.content_offset[0] / (self.scrollView.content_size.width/len(self.scrollView.subviews)+1)) 71 | self.pageControl.currentPage = pageNumber 72 | self._trigger_delegate() 73 | 74 | def add_subview(self, page): 75 | self.pageControl.numberOfPages = len(self.scrollView.subviews) + 1 76 | page.frame = self.scrollView.bounds 77 | page.flex = 'WH' 78 | self.scrollView.add_subview(page) 79 | self.layout() 80 | 81 | def _objc_color(self, color): 82 | return UIColor.colorWithRed_green_blue_alpha_(*ui.parse_color(color)) 83 | 84 | def _py_color(self, objc_color): 85 | return tuple([c.floatValue() for c in objc_color.arrayFromRGBAComponents()]) if objc_color else None 86 | 87 | def _trigger_delegate(self): 88 | try: 89 | callback = self.delegate.page_changed 90 | except AttributeError: return 91 | if self.pageControl.currentPage() is not self._prev_page: 92 | callback(self, self.pageControl.currentPage()) 93 | self._prev_page = self.pageControl.currentPage() 94 | 95 | def set_page(self, page_number): 96 | if page_number < self.pageControl.numberOfPages() and page_number > -1: 97 | x = page_number * self.scrollView.width 98 | self.scrollView.content_offset = (x, 0) 99 | else: 100 | raise ValueError("Invalid Page Number. page_number is zero indexing.") 101 | 102 | @property 103 | def page_count(self): 104 | return self.pageControl.numberOfPages() 105 | 106 | @property 107 | def current_page(self): 108 | return self.pageControl.currentPage() 109 | 110 | @property 111 | def hide_on_single_page(self): 112 | return self.pageControl.hidesForSinglePage() 113 | 114 | @hide_on_single_page.setter 115 | def hide_on_single_page(self, val): 116 | self.pageControl.hidesForSinglePage = val 117 | 118 | @property 119 | def indicator_tint_color(self): 120 | """Returns un-selected tint color, returns None as default due to .pageIndicatorTintColor() returning that""" 121 | return self._py_color(self.pageControl.pageIndicatorTintColor()) 122 | 123 | @indicator_tint_color.setter 124 | def indicator_tint_color(self, val): 125 | self.pageControl.pageIndicatorTintColor = self._objc_color(val) 126 | 127 | @property 128 | def indicator_current_color(self): 129 | """Returns selected tint color, returns None as default due to .currentPageIndicatorTintColor() returning that""" 130 | return self._py_color(self.pageControl.currentPageIndicatorTintColor()) 131 | 132 | @indicator_current_color.setter 133 | def indicator_current_color(self, val): 134 | self.pageControl.currentPageIndicatorTintColor = self._objc_color(val) 135 | 136 | @property 137 | def style(self): 138 | return self.pageControl.backgroundStyle() 139 | 140 | @style.setter 141 | def style(self, value): 142 | if value < 0 or value > 2: 143 | raise ValueError(f"style property should be 0-2, got {value}") 144 | self.pageControl.setBackgroundStyle_(value) 145 | 146 | @property 147 | def image_name(self): 148 | raise NotImplementedError() 149 | 150 | @image_name.setter 151 | def image_name(self, value): 152 | self.pageControl.setPreferredIndicatorImage_( 153 | ObjCClass('UIImage').systemImageNamed_(value)) 154 | 155 | 156 | if __name__ == '__main__': 157 | 158 | image_names = ['test:Boat', 'test:Lenna', 'test:Mandrill', 'test:Peppers'] 159 | 160 | pages = PageControl( 161 | background_color='black', 162 | indicator_tint_color='grey', 163 | indicator_current_color='white', 164 | style=1, 165 | image_name='heart.fill', 166 | ) 167 | 168 | for image_name in image_names: 169 | pages.add_subview(ui.ImageView( 170 | image=ui.Image(image_name), 171 | content_mode=ui.CONTENT_SCALE_ASPECT_FIT)) 172 | 173 | pages.present('fullscreen', 174 | hide_title_bar=True 175 | ) 176 | 177 | -------------------------------------------------------------------------------- /ui3/richlabel.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import ctypes 3 | from itertools import chain 4 | import types 5 | 6 | import bs4 7 | 8 | import objc_util 9 | import ui 10 | 11 | 12 | NSMutableAttributedString = objc_util.ObjCClass('NSMutableAttributedString') 13 | UIFont = objc_util.ObjCClass('UIFont') 14 | NSShadow = objc_util.ObjCClass('NSShadow') 15 | 16 | 17 | def get_fonts(): 18 | families = [str(family) for family in UIFont.familyNames()] 19 | 20 | fonts = [str(font).lower() for fonts in [ 21 | UIFont.fontNamesForFamilyName_(family) for family in families 22 | ] 23 | for font in fonts 24 | ] 25 | 26 | return (font_name.lower() for font_name in chain(families, fonts)) 27 | 28 | 29 | class RichLabel: 30 | 31 | # No default root 32 | default = None 33 | 34 | # No custom tags 35 | custom = {} 36 | 37 | class RichText(types.SimpleNamespace): 38 | 39 | trait = 0 40 | 41 | def set(self, key, value): 42 | self.attr_str.addAttribute_value_range_( 43 | objc_util.ns(key), value, 44 | objc_util.NSRange(self.start, self.end - self.start)) 45 | 46 | @property 47 | def objc_color(self): 48 | return objc_util.UIColor.colorWithRed_green_blue_alpha_( 49 | *ui.parse_color(self.color)) 50 | 51 | class TextTrait(RichText): 52 | 53 | all_fonts = set(get_fonts()) 54 | 55 | def apply(self, attr_str): 56 | self.attr_str = attr_str 57 | if self.font_name == 'system': 58 | font = UIFont.systemFontOfSize_traits_( 59 | self.font_size, self.collected_traits) 60 | elif self.font_name.lower() in self.all_fonts: 61 | font = UIFont.fontWithName_size_traits_( 62 | self.font_name, self.font_size, self.collected_traits) 63 | else: 64 | raise ValueError('Unknown font defined', self.font_name) 65 | self.set('NSFont', font) 66 | 67 | class Bold(TextTrait): 68 | 69 | trait = 1 << 1 70 | 71 | class Italic(TextTrait): 72 | 73 | trait = 1 << 0 74 | 75 | class Font(TextTrait): 76 | def __init__(self, **kwargs): 77 | super().__init__(**kwargs) 78 | node_font = None 79 | node_size = None 80 | for key in self.node.attrs.keys(): 81 | try: 82 | node_size = int(key) 83 | except ValueError: 84 | node_font = key 85 | self.font_name = (node_font or self.font_name).replace('-', ' ') 86 | self.font_size = node_size or self.font_size 87 | 88 | class Color(RichText): 89 | def __init__(self, **kwargs): 90 | super().__init__(**kwargs) 91 | assert len( 92 | self.node.attrs) == 1, f'Give only one color: {self.node}' 93 | self.color = ui.parse_color(list(self.node.attrs.keys())[0]) 94 | 95 | def apply(self, attr_str): 96 | self.attr_str = attr_str 97 | self.set('NSColor', self.objc_color) 98 | 99 | class Outline(RichText): 100 | def __init__(self, **kwargs): 101 | super().__init__(**kwargs) 102 | assert len( 103 | self.node.attrs) <= 2, f'Give at most color and a width: {self.node}' 104 | outline_color = None 105 | outline_width = None 106 | for key in self.node.attrs.keys(): 107 | try: 108 | outline_width = float(key) 109 | except ValueError: 110 | outline_color = ui.parse_color(key) 111 | self.outline_width = outline_width or 3.0 112 | self.color = outline_color or 'black' 113 | 114 | def apply(self, attr_str): 115 | self.attr_str = attr_str 116 | self.set('NSStrokeColor', self.objc_color) 117 | self.set('NSStrokeWidth', self.outline_width) 118 | 119 | class Line(RichText): 120 | styles = { 121 | 'thick': 0x02, 122 | 'double': 0x09, 123 | 'dot': 0x0100, 124 | 'dash': 0x0200, 125 | 'dashdot': 0x0300, 126 | 'dashdotdot': 0x0400, 127 | 'byword': 0x8000 128 | } 129 | 130 | def __init__(self, **kwargs): 131 | super().__init__(**kwargs) 132 | line_style = 0 133 | line_color = None 134 | for key in self.node.attrs.keys(): 135 | if key in self.styles: 136 | line_style |= self.styles[key] 137 | else: 138 | line_color = ui.parse_color(key) 139 | self.line_style = line_style or 1 140 | self.color = line_color or 'black' 141 | 142 | def apply(self, attr_str): 143 | self.attr_str = attr_str 144 | self.set(self.style_key, self.line_style) 145 | self.set(self.color_key, self.objc_color) 146 | 147 | class Underline(Line): 148 | 149 | style_key = 'NSUnderline' 150 | color_key = 'NSUnderlineColor' 151 | 152 | class Strikethrough(Line): 153 | 154 | style_key = 'NSStrikethrough' 155 | color_key = 'NSStrikethroughColor' 156 | 157 | class Shadow(RichText): 158 | 159 | def __init__(self, **kwargs): 160 | super().__init__(**kwargs) 161 | blur = offset = color = None 162 | for key in self.node.attrs.keys(): 163 | try: 164 | blur = float(key) 165 | except ValueError: 166 | try: 167 | value = ast.literal_eval(key) 168 | if (type(value) is tuple and 169 | all(map(lambda x: x - 0 == x, value))): 170 | if len(value) == 2: 171 | offset = value 172 | else: 173 | color = ui.parse_color(value) 174 | except: 175 | color = ui.parse_color(key) 176 | self.blur = blur or 3.0 177 | self.offset = offset or (2, 2) 178 | self.color = color or 'grey' 179 | 180 | def apply(self, attr_str): 181 | self.attr_str = attr_str 182 | shadow = NSShadow.alloc().init() 183 | shadow.setShadowOffset_(objc_util.CGSize(*self.offset)) 184 | shadow.setShadowColor_(self.objc_color) 185 | shadow.setShadowBlurRadius_(self.blur) 186 | self.set('NSShadow', shadow) 187 | 188 | 189 | class Oblique(RichText): 190 | 191 | def __init__(self, **kwargs): 192 | super().__init__(**kwargs) 193 | oblique = None 194 | try: 195 | for key in self.node.attrs.keys(): 196 | oblique = float(key) 197 | except ValueError: 198 | raise ValueError('Obliqueness should be a float', self.node) 199 | self.oblique = oblique or 0.25 200 | 201 | def apply(self, attr_str): 202 | self.attr_str = attr_str 203 | self.set('NSObliqueness', self.oblique) 204 | 205 | class StandardFont(RichText): 206 | 207 | def apply(self, attr_str): 208 | self.attr_str = attr_str 209 | objc_style = objc_util.ObjCInstance( 210 | ctypes.c_void_p.in_dll( 211 | objc_util.c, self.style)) 212 | font = UIFont.preferredFontForTextStyle_(objc_style) 213 | self.set('NSFont', font) 214 | 215 | class Body(StandardFont): 216 | 217 | style = 'UIFontTextStyleBody' 218 | 219 | class Callout(StandardFont): 220 | 221 | style = 'UIFontTextStyleCallout' 222 | 223 | class Caption1(StandardFont): 224 | 225 | style = 'UIFontTextStyleCaption1' 226 | 227 | class Caption2(StandardFont): 228 | 229 | style = 'UIFontTextStyleCaption2' 230 | 231 | class Footnote(StandardFont): 232 | 233 | style = 'UIFontTextStyleFootnote' 234 | 235 | class Headline(StandardFont): 236 | 237 | style = 'UIFontTextStyleHeadline' 238 | 239 | class Subheadline(StandardFont): 240 | 241 | style = 'UIFontTextStyleSubheadline' 242 | 243 | class LargeTitle(StandardFont): 244 | 245 | style = 'UIFontTextStyleLargeTitle' 246 | 247 | class Title1(StandardFont): 248 | 249 | style = 'UIFontTextStyleTitle1' 250 | 251 | class Title2(StandardFont): 252 | 253 | style = 'UIFontTextStyleTitle2' 254 | 255 | class Title3(StandardFont): 256 | 257 | style = 'UIFontTextStyleTitle3' 258 | 259 | 260 | _tag_to_class = { 261 | 'b': Bold, 262 | 'bold': Bold, 263 | 'i': Italic, 264 | 'italic': Italic, 265 | 'c': Color, 266 | 'color': Color, 267 | 'f': Font, 268 | 'font': Font, 269 | 'o': Outline, 270 | 'outline': Outline, 271 | 'u': Underline, 272 | 'underline': Underline, 273 | 'strike': Strikethrough, 274 | 'shadow': Shadow, 275 | 'oblique': Oblique, 276 | 'body': Body, 277 | 'callout': Callout, 278 | 'caption1': Caption1, 279 | 'caption2': Caption2, 280 | 'footnote': Footnote, 281 | 'headline': Headline, 282 | 'subheadline': Subheadline, 283 | 'largetitle': LargeTitle, 284 | 'title1': Title1, 285 | 'title2': Title2, 286 | 'title3': Title3, 287 | } 288 | 289 | _font_weights = { 290 | 'ultralight': -1.0, 291 | 'thin': -0.7, 292 | 'light': -0.4, 293 | 'regular': 0.0, 294 | 'medium': 0.2, 295 | 'semibold': 0.3, 296 | 'bold': 0.4, 297 | 'heavy': 0.5, 298 | 'black': 1.0, 299 | } 300 | 301 | def __new__(cls, *args, **kwargs): 302 | target_instance = ui.Label(*args, **kwargs) 303 | for key in dir(cls): 304 | if key.startswith('__'): continue 305 | value = getattr(cls, key) 306 | if callable(value) and type(value) is not type: 307 | setattr(target_instance, key, 308 | types.MethodType(value, target_instance)) 309 | else: 310 | setattr(target_instance, key, value) 311 | return target_instance 312 | 313 | def rich_text(self, rich_text_str): 314 | #rich_text_str = self.default.format(rich_text_str) 315 | 316 | text, formats = self._parse_string(rich_text_str) 317 | attr_str = NSMutableAttributedString.alloc().initWithString_(text) 318 | for f in reversed(formats): 319 | f.apply(attr_str) 320 | self.objc_instance.setAttributedText_(attr_str) 321 | 322 | def _parse_string(self, rich_string: str): 323 | soup = bs4.BeautifulSoup(rich_string, 'html5lib') 324 | root = soup.body 325 | if self.default: 326 | self.wrap_root(soup.body, self.default) 327 | 328 | formats = [] 329 | 330 | def process(parent, end, font_name, font_size, traits): 331 | collected_text = '' 332 | for node in parent.children: 333 | if not node.name: 334 | t = node.string 335 | collected_text += t 336 | end += len(t) 337 | else: 338 | if node.name in self.custom: 339 | node = self.wrap_node(node) 340 | format_class = self._tag_to_class[node.name] 341 | collected_traits = traits | format_class.trait 342 | formatter = format_class( 343 | label=self, 344 | node=node, 345 | font_name=font_name, 346 | font_size=font_size, 347 | collected_traits=collected_traits, 348 | ) 349 | collected_font_name = formatter.font_name 350 | collected_font_size = formatter.font_size 351 | 352 | start = end 353 | end, sub_collected_text = process( 354 | node, end, 355 | collected_font_name, 356 | collected_font_size, 357 | collected_traits) 358 | collected_text += sub_collected_text 359 | 360 | formatter.start = start 361 | formatter.end = end 362 | formats.append(formatter) 363 | 364 | return end, collected_text 365 | 366 | font, font_size = self.font 367 | if self.objc_instance.font().isSystemFont(): 368 | font = 'system' 369 | end, text = process(root, 0, font, font_size, 0) 370 | 371 | return text, formats 372 | 373 | def wrap_root(self, root, wrapper_str): 374 | top_node, bottom_node = self.get_wrapper_nodes(wrapper_str) 375 | # Move children 376 | for child in list(root.children): 377 | bottom_node.insert(len(bottom_node.contents), child) 378 | # Insert new child 379 | root.insert(0, top_node) 380 | 381 | def wrap_node(self, node): 382 | top_node, bottom_node = self.get_wrapper_nodes(self.custom[node.name]) 383 | for child in list(node.children): 384 | bottom_node.append(child) 385 | node.replace_with(top_node) 386 | return top_node 387 | 388 | def get_wrapper_nodes(self, wrapper_str): 389 | wrapper_soup = bs4.BeautifulSoup(wrapper_str, 'html5lib') 390 | top_node = next(wrapper_soup.html.body.children) 391 | control_node = bottom_node = top_node 392 | while control_node: 393 | bottom_node = control_node 394 | control_node = next(bottom_node.children, None) 395 | if not control_node or not control_node.name: 396 | break 397 | return top_node, bottom_node 398 | 399 | def html(self, html): 400 | data = html.encode() 401 | attr_str = NSMutableAttributedString.alloc().\ 402 | initWithHTML_documentAttributes_( 403 | data, None) 404 | self.objc_instance.setAttributedText_(attr_str) 405 | 406 | class BoldLabel(RichLabel): 407 | default = '' 408 | 409 | class ItalicLabel(RichLabel): 410 | default = '' 411 | 412 | class BoldItalicLabel(RichLabel): 413 | default = '' 414 | 415 | class OutlineLabel(RichLabel): 416 | default = '' 417 | 418 | class UnderlineLabel(RichLabel): 419 | default = '' 420 | 421 | class StrikeLabel(RichLabel): 422 | default = '' 423 | 424 | class ShadowLabel(RichLabel): 425 | default = '' 426 | 427 | class ObliqueLabel(RichLabel): 428 | default = '' 429 | 430 | class BoldObliqueLabel(RichLabel): 431 | default = '' 432 | 433 | class BodyLabel(RichLabel): 434 | default = '' 435 | 436 | class CalloutLabel(RichLabel): 437 | default = '' 438 | 439 | class Caption1Label(RichLabel): 440 | default = '' 441 | 442 | class Caption2Label(RichLabel): 443 | default = '' 444 | 445 | class FootnoteLabel(RichLabel): 446 | default = '' 447 | 448 | class HeadlineLabel(RichLabel): 449 | default = '' 450 | 451 | class SubheadlineLabel(RichLabel): 452 | default = '' 453 | 454 | class LargeTitleLabel(RichLabel): 455 | default = '' 456 | 457 | class Title1Label(RichLabel): 458 | default = '' 459 | 460 | class Title2Label(RichLabel): 461 | default = '' 462 | 463 | class Title3Label(RichLabel): 464 | default = '' 465 | 466 | 467 | if __name__ == '__main__': 468 | 469 | v = ui.View(background_color='white') 470 | 471 | r = RichLabel( 472 | font=('Arial', 24), 473 | background_color='white', 474 | alignment=ui.ALIGN_CENTER, 475 | number_of_lines=0, 476 | ) 477 | 478 | r.rich_text("\n".join([ 479 | "Plain", 480 | "Bold italic", 481 | "and just italic", 482 | "", 483 | "Color", 484 | "Shadow", 485 | "", 486 | "Outlines:", 487 | "", 488 | "DEFAULT", 489 | "COLORED", 490 | "FILLED", 491 | "", 492 | "oblique", 493 | "really not cool" 494 | ])) 495 | 496 | v.add_subview(r) 497 | 498 | class MyRichLabel(RichLabel): 499 | custom = { 500 | 's': '' 501 | } 502 | default = '' 503 | font = ('Arial', 24) 504 | alignment = ui.ALIGN_CENTER 505 | number_of_lines = 0 506 | 507 | 508 | fancy = MyRichLabel( 509 | background_color='white', 510 | ) 511 | 512 | fancy.rich_text('FANCY BLOCK') 513 | 514 | v.add_subview(fancy) 515 | 516 | r2 = RichLabel( 517 | background_color='white', 518 | alignment=ui.ALIGN_CENTER, 519 | number_of_lines=0, 520 | ) 521 | 522 | r2.rich_text("\n".join([ 523 | "largetitle", 524 | "title1", 525 | "title2", 526 | "title3", 527 | "headline", 528 | "subheadline", 529 | "body", 530 | "callout", 531 | "caption1", 532 | "caption2", 533 | "footnote", 534 | ])) 535 | 536 | v.add_subview(r2) 537 | 538 | v.present('fullscreen') 539 | 540 | r.frame = v.bounds 541 | r.size_to_fit() 542 | r.center = v.bounds.center() 543 | r.x = 0 544 | r.width = v.width/2 545 | 546 | fancy.frame = v.bounds 547 | fancy.size_to_fit() 548 | fancy.center = v.bounds.center() 549 | fancy.y = r.y + r.height + 40 550 | 551 | r2.frame = v.bounds 552 | r2.size_to_fit() 553 | r2.width = v.width/2 554 | r2.center = v.bounds.center() 555 | r2.x = v.width/2 556 | 557 | -------------------------------------------------------------------------------- /ui3/safearea.py: -------------------------------------------------------------------------------- 1 | import objc_util 2 | import ui 3 | 4 | NSLayoutConstraint = objc_util.ObjCClass('NSLayoutConstraint') 5 | 6 | 7 | class SafeAreaView(ui.View): 8 | 9 | def __init__(self, superview=None, **kwargs): 10 | super().__init__(**kwargs) 11 | 12 | if superview: 13 | self._set_constraints(superview) 14 | 15 | def _set_constraints(self, superview): 16 | superview.add_subview(self) 17 | selfo = self.objc_instance 18 | supero = superview.objc_instance 19 | selfo.setTranslatesAutoresizingMaskIntoConstraints_(False) 20 | safe = supero.safeAreaLayoutGuide() 21 | NSLayoutConstraint.activateConstraints_([ 22 | selfo.topAnchor().constraintEqualToAnchor_constant_( 23 | safe.topAnchor(), 0), 24 | selfo.bottomAnchor().constraintEqualToAnchor_constant_( 25 | safe.bottomAnchor(), 0), 26 | selfo.leftAnchor().constraintEqualToAnchor_constant_( 27 | safe.leftAnchor(), 0), 28 | selfo.rightAnchor().constraintEqualToAnchor_constant_( 29 | safe.rightAnchor(), 0), 30 | ]) 31 | 32 | def present(self, *args, **kwargs): 33 | real_root = ui.View(background_color=self.background_color) 34 | self._set_constraints(real_root) 35 | real_root.present(*args, **kwargs) 36 | -------------------------------------------------------------------------------- /ui3/sfsymbol.py: -------------------------------------------------------------------------------- 1 | 2 | import ui, clipboard, re, dialogs 3 | from objc_util import * 4 | 5 | UIImage = ObjCClass('UIImage') 6 | UIImageSymbolConfiguration = ObjCClass('UIImageSymbolConfiguration') 7 | 8 | UIImagePNGRepresentation = c.UIImagePNGRepresentation 9 | UIImagePNGRepresentation.restype = c_void_p 10 | UIImagePNGRepresentation.argtypes = [c_void_p] 11 | 12 | #WEIGHTS 13 | ULTRALIGHT, THIN, LIGHT, REGULAR, MEDIUM, SEMIBOLD, BOLD, HEAVY, BLACK = range(1, 10) 14 | # SCALES 15 | SMALL, MEDIUM, LARGE = 1, 2, 3 16 | 17 | def SymbolImage( 18 | name, 19 | point_size=None, weight=None, scale=None, 20 | color=None, 21 | rendering_mode=ui.RENDERING_MODE_AUTOMATIC 22 | ): 23 | ''' Create a ui.Image from an SFSymbol name. Optional parameters: 24 | * `point_size` - Integer font size 25 | * `weight` - Font weight, one of ULTRALIGHT, THIN, LIGHT, REGULAR, MEDIUM, SEMIBOLD, BOLD, HEAVY, BLACK 26 | * `scale` - Size relative to font size, one of SMALL, MEDIUM, LARGE 27 | 28 | Run the file to see a symbol browser.''' 29 | objc_image = ObjCClass('UIImage').systemImageNamed_(name) 30 | conf = UIImageSymbolConfiguration.defaultConfiguration() 31 | if point_size is not None: 32 | conf = conf.configurationByApplyingConfiguration_( 33 | UIImageSymbolConfiguration.configurationWithPointSize_(point_size)) 34 | if weight is not None: 35 | conf = conf.configurationByApplyingConfiguration_( 36 | UIImageSymbolConfiguration.configurationWithWeight_(weight)) 37 | if scale is not None: 38 | conf = conf.configurationByApplyingConfiguration_( 39 | UIImageSymbolConfiguration.configurationWithScale_(scale)) 40 | objc_image = objc_image.imageByApplyingSymbolConfiguration_(conf) 41 | 42 | image = ui.Image.from_data( 43 | nsdata_to_bytes(ObjCInstance(UIImagePNGRepresentation(objc_image))) 44 | ).with_rendering_mode(rendering_mode) 45 | if color: 46 | image = image.imageWithTintColor_(UIColor.colorWithRed_green_blue_alpha_(*ui.parse_color(color))) 47 | return image 48 | 49 | 50 | if __name__ == '__main__': 51 | 52 | class SymbolSource: 53 | 54 | symbols_per_page = 20 55 | 56 | def __init__(self, root, tableview): 57 | self.tableview = tableview 58 | tableview.row_height = 50 59 | self.weight = THIN 60 | 61 | with open('sfsymbolnames.txt', 'r') as fp: 62 | all_lines = fp.read() 63 | raw = all_lines.splitlines() 64 | 65 | restricted_prefix = 'Usage restricted' 66 | 67 | self.symbol_names = [] 68 | for i, symbol_name in enumerate(raw): 69 | if raw[i].startswith(restricted_prefix): continue 70 | if i+1 == len(raw): continue 71 | value = symbol_name 72 | if raw[i+1].startswith(restricted_prefix): 73 | value = 'R ' + value 74 | self.symbol_names.append(value) 75 | 76 | self.index = 0 77 | self.update_list_to_display() 78 | 79 | self.prev_button = ui.ButtonItem( 80 | tint_color='black', 81 | image=SymbolImage('arrow.left', 8, weight=THIN), 82 | enabled=False, 83 | action=self.prev, 84 | ) 85 | self.to_start_button = ui.ButtonItem( 86 | tint_color='black', 87 | image=SymbolImage('arrow.left.to.line', 8, weight=THIN), 88 | enabled=False, 89 | action=self.to_start, 90 | ) 91 | self.next_button = ui.ButtonItem( 92 | tint_color='black', 93 | image=SymbolImage('arrow.right', 8, weight=THIN), 94 | enabled=True, 95 | action=self.next, 96 | ) 97 | self.to_end_button = ui.ButtonItem( 98 | tint_color='black', 99 | image=SymbolImage('arrow.right.to.line', 8, weight=THIN), 100 | enabled=True, 101 | action=self.to_end, 102 | ) 103 | self.weight_button = ui.ButtonItem( 104 | tint_color='black', 105 | title='Thin', 106 | enabled=True, 107 | action=self.change_weight, 108 | ) 109 | 110 | root.left_button_items = [ 111 | self.to_start_button, 112 | self.prev_button] 113 | root.right_button_items = [ 114 | self.to_end_button, 115 | self.next_button, 116 | self.weight_button] 117 | 118 | def update_list_to_display(self): 119 | self.data_list = [] 120 | for i in range(self.index, self.index+self.symbols_per_page): 121 | self.data_list.append(self.symbol_names[i]) 122 | 123 | def next(self, sender): 124 | self.index += self.symbols_per_page 125 | if self.index + self.symbols_per_page >= len(self.symbol_names): 126 | self.index = len(self.symbol_names) - self.symbols_per_page - 1 127 | self.next_button.enabled = False 128 | self.to_end_button.enabled = False 129 | self.prev_button.enabled = True 130 | self.to_start_button.enabled = True 131 | self.update_list_to_display() 132 | self.tableview.reload() 133 | 134 | def to_end(self, sender): 135 | self.index = len(self.symbol_names) - self.symbols_per_page - 1 136 | self.next_button.enabled = False 137 | self.to_end_button.enabled = False 138 | self.prev_button.enabled = True 139 | self.to_start_button.enabled = True 140 | self.update_list_to_display() 141 | self.tableview.reload() 142 | 143 | def prev(self, sender): 144 | self.index -= self.symbols_per_page 145 | if self.index <= 0: 146 | self.index = 0 147 | self.prev_button.enabled = False 148 | self.to_start_button.enabled = False 149 | self.next_button.enabled = True 150 | self.to_end_button.enabled = True 151 | self.update_list_to_display() 152 | self.tableview.reload() 153 | 154 | def to_start(self, sender): 155 | self.index = 0 156 | self.prev_button.enabled = False 157 | self.to_start_button.enabled = False 158 | self.next_button.enabled = True 159 | self.to_end_button.enabled = True 160 | self.update_list_to_display() 161 | self.tableview.reload() 162 | 163 | def change_weight(self, sender): 164 | titles = ['Ultralight', 'Thin', 'Light', 'Regular', 'Medium', 'Semibold', 'Bold', 'Heavy', 'Black'] 165 | self.weight += 1 166 | if self.weight > BLACK: 167 | self.weight = ULTRALIGHT 168 | self.weight_button.title = titles[self.weight-1] 169 | self.tableview.reload() 170 | 171 | def tableview_number_of_rows(self, tableview, section): 172 | return len(self.data_list) 173 | 174 | def tableview_cell_for_row(self, tableview, section, row): 175 | cell = ui.TableViewCell() 176 | cell.selectable = False 177 | cell.background_color='black' 178 | 179 | symbol_name = self.data_list[row] 180 | tint_color = 'white' 181 | if symbol_name.startswith('R '): 182 | symbol_name = symbol_name[2:] 183 | tint_color = 'orange' 184 | symbol_image = SymbolImage(symbol_name, 185 | point_size=14, weight=self.weight, scale=SMALL) 186 | 187 | button = ui.Button( 188 | tint_color=tint_color, 189 | title=' '+symbol_name, 190 | font=('Fira Mono', 14), 191 | image=symbol_image, 192 | frame=cell.content_view.bounds, 193 | flex='WH', 194 | action=self.copy_to_clipboard, 195 | #enabled=False, 196 | ) 197 | 198 | cell.content_view.add_subview(button) 199 | 200 | return cell 201 | 202 | def copy_to_clipboard(self, sender): 203 | clipboard.set(sender.title[3:]) 204 | dialogs.hud_alert('Copied') 205 | 206 | def textfield_did_change(self, textfield): 207 | search_text = textfield.text.strip().lower() 208 | if search_text == '': 209 | self.update_list_to_display() 210 | textfield.end_editing() 211 | else: 212 | self.data_list = list(fuzzyfinder(search_text, self.symbol_names)) 213 | self.tableview.reload() 214 | 215 | def fuzzyfinder(input, collection, accessor=lambda x: x, sort_results=True): 216 | suggestions = [] 217 | input = str(input) if not isinstance(input, str) else input 218 | pat = '.*?'.join(map(re.escape, input)) 219 | pat = '(?=({0}))'.format(pat) 220 | regex = re.compile(pat, re.IGNORECASE) 221 | for item in collection: 222 | r = list(regex.finditer(accessor(item))) 223 | if r: 224 | best = min(r, key=lambda x: len(x.group(1))) 225 | suggestions.append((len(best.group(1)), best.start(), accessor(item), item)) 226 | if sort_results: 227 | return (z[-1] for z in sorted(suggestions)) 228 | else: 229 | return (z[-1] for z in sorted(suggestions, key=lambda x: x[:2])) 230 | 231 | root = ui.View() 232 | 233 | symbol_table = ui.TableView( 234 | background_color='black', 235 | frame=root.bounds, flex='WH', 236 | ) 237 | data_source = symbol_table.data_source = SymbolSource(root, symbol_table) 238 | 239 | search_field = ui.TextField( 240 | frame=(8,8, root.width-16, 40), 241 | flex='W', 242 | clear_button_mode='always', 243 | delegate=data_source, 244 | ) 245 | symbol_table.y = search_field.height + 16 246 | symbol_table.height -= (search_field.height + 16) 247 | 248 | root.add_subview(search_field) 249 | root.add_subview(symbol_table) 250 | 251 | #symbol_table.present() 252 | root.present('fullscreen') 253 | -------------------------------------------------------------------------------- /ui3/sfsymbol_browser.py: -------------------------------------------------------------------------------- 1 | import json 2 | import math 3 | 4 | from pathlib import Path 5 | 6 | import objc_util 7 | import ui 8 | 9 | from ui3.anchor import * 10 | from ui3.sfsymbol import * 11 | 12 | 13 | NSIndexPath = objc_util.ObjCClass("NSIndexPath") 14 | 15 | 16 | class SymbolSource: 17 | 18 | symbols_per_page = 20 19 | 20 | def __init__(self, root, tableview): 21 | self.tableview = tableview 22 | tableview.row_height = 50 23 | self.weight = THIN 24 | 25 | self.symbol_names = json.loads( 26 | (Path(__file__).parent / 'sfsymbolnames-2_1.json').read_text()) 27 | 28 | self.restricted = set([ 29 | symbol['symbolName'] 30 | for symbol 31 | in json.loads( 32 | (Path(__file__).parent / 'sfsymbols-restricted-2_1.json').read_text())]) 33 | 34 | self.index = 0 35 | self.update_list_to_display() 36 | 37 | self.prev_button = ui.ButtonItem( 38 | tint_color='black', 39 | image=SymbolImage('arrow.up', 8, weight=THIN), 40 | action=self.prev, 41 | ) 42 | self.to_start_button = ui.ButtonItem( 43 | tint_color='black', 44 | image=SymbolImage('arrow.up.to.line', 8, weight=THIN), 45 | action=self.to_start, 46 | ) 47 | self.next_button = ui.ButtonItem( 48 | tint_color='black', 49 | image=SymbolImage('arrow.down', 8, weight=THIN), 50 | enabled=True, 51 | action=self.next, 52 | ) 53 | self.to_end_button = ui.ButtonItem( 54 | tint_color='black', 55 | image=SymbolImage('arrow.down.to.line', 8, weight=THIN), 56 | enabled=True, 57 | action=self.to_end, 58 | ) 59 | self.weight_button = ui.ButtonItem( 60 | tint_color='black', 61 | title='Thin', 62 | enabled=True, 63 | action=self.change_weight, 64 | ) 65 | 66 | root.left_button_items = [ 67 | self.to_start_button, 68 | self.prev_button] 69 | root.right_button_items = [ 70 | self.to_end_button, 71 | self.next_button, 72 | self.weight_button] 73 | 74 | def update_list_to_display(self): 75 | self.data_list = self.symbol_names 76 | 77 | @property 78 | def current_row(self): 79 | x, y = self.tableview.content_offset 80 | return int(y // self.tableview.row_height) 81 | 82 | def next(self, sender): 83 | total_height = len(self.data_list) * self.tableview.row_height 84 | x, y = self.tableview.content_offset 85 | w, h = self.tableview.content_size 86 | sw, sh = ui.get_screen_size() 87 | y += total_height/10 88 | if y >= h - sh : 89 | self.to_end(sender) 90 | else: 91 | self.tableview.content_offset = 0, y 92 | 93 | def to_end(self, sender): 94 | self.scroll_to_row(len(self.data_list)-1) 95 | 96 | def prev(self, sender): 97 | total_height = len(self.data_list) * self.tableview.row_height 98 | x, y = self.tableview.content_offset 99 | y -= total_height/10 100 | if y <= 0: 101 | self.to_start(sender) 102 | else: 103 | self.tableview.content_offset = 0, y 104 | 105 | 106 | def to_start(self, sender): 107 | self.scroll_to_row(0) 108 | 109 | def change_weight(self, sender): 110 | titles = ['Ultralight', 'Thin', 'Light', 'Regular', 'Medium', 'Semibold', 'Bold', 'Heavy', 'Black'] 111 | self.weight += 1 112 | if self.weight > BLACK: 113 | self.weight = ULTRALIGHT 114 | self.weight_button.title = titles[self.weight-1] 115 | self.tableview.reload() 116 | 117 | def tableview_number_of_rows(self, tableview, section): 118 | return len(self.data_list) 119 | 120 | def scroll_to_row(self, row): 121 | UITableViewScrollPositionMiddle = 2 122 | tvobjc = self.tableview.objc_instance 123 | nsindex = NSIndexPath.indexPathForRow_inSection_(row ,0) 124 | tvobjc.scrollToRowAtIndexPath_atScrollPosition_animated_( 125 | nsindex, 126 | UITableViewScrollPositionMiddle, 127 | True) 128 | 129 | def tableview_cell_for_row(self, tableview, section, row): 130 | cell = ui.TableViewCell() 131 | cell.selectable = False 132 | cell.background_color='black' 133 | 134 | symbol_name = self.data_list[row] 135 | tint_color = 'orange' if symbol_name in self.restricted else 'white' 136 | 137 | symbol_image = SymbolImage(symbol_name, 138 | point_size=14, weight=self.weight, scale=SMALL) 139 | 140 | button = ui.Button( 141 | tint_color=tint_color, 142 | title=' '+symbol_name, 143 | font=('Fira Mono', 14), 144 | image=symbol_image, 145 | frame=cell.content_view.bounds, 146 | flex='WH', 147 | action=self.copy_to_clipboard, 148 | #enabled=False, 149 | ) 150 | 151 | cell.content_view.add_subview(button) 152 | 153 | return cell 154 | 155 | def copy_to_clipboard(self, sender): 156 | clipboard.set(sender.title[3:]) 157 | dialogs.hud_alert('Copied') 158 | 159 | def textfield_did_change(self, textfield): 160 | search_text = textfield.text.strip().lower() 161 | if search_text == '': 162 | self.update_list_to_display() 163 | textfield.end_editing() 164 | else: 165 | self.data_list = list(fuzzyfinder(search_text, self.symbol_names)) 166 | self.tableview.reload() 167 | 168 | def fuzzyfinder(input, collection, accessor=lambda x: x, sort_results=True): 169 | suggestions = [] 170 | input = str(input) if not isinstance(input, str) else input 171 | pat = '.*?'.join(map(re.escape, input)) 172 | pat = '(?=({0}))'.format(pat) 173 | regex = re.compile(pat, re.IGNORECASE) 174 | for item in collection: 175 | r = list(regex.finditer(accessor(item))) 176 | if r: 177 | best = min(r, key=lambda x: len(x.group(1))) 178 | suggestions.append((len(best.group(1)), best.start(), accessor(item), item)) 179 | if sort_results: 180 | return (z[-1] for z in sorted(suggestions)) 181 | else: 182 | return (z[-1] for z in sorted(suggestions, key=lambda x: x[:2])) 183 | 184 | 185 | class SymbolBrowser(ui.View): 186 | 187 | def __init__(self, **kwargs): 188 | super().__init__(**kwargs) 189 | 190 | symbol_table = ui.TableView( 191 | background_color='black', 192 | frame=self.bounds, flex='WH', 193 | ) 194 | data_source = symbol_table.data_source = SymbolSource( 195 | self, symbol_table) 196 | 197 | search_field = ui.TextField( 198 | frame=(8,8, self.width-16, 40), 199 | flex='W', 200 | clear_button_mode='always', 201 | delegate=data_source, 202 | ) 203 | symbol_table.y = search_field.height + 16 204 | symbol_table.height -= (search_field.height + 16) 205 | 206 | self.add_subview(search_field) 207 | self.add_subview(symbol_table) 208 | 209 | 210 | class SymbolMatrix(ui.View): 211 | 212 | button_size = 40 213 | button_size_with_gap = button_size + 8 214 | 215 | def __init__(self, **kwargs): 216 | self.background_color = 'black' 217 | super().__init__(**kwargs) 218 | self.scrollview = FitScrollView( 219 | active=False, 220 | frame=self.bounds, flex='WH', 221 | ) 222 | self.add_subview(self.scrollview) 223 | 224 | self.symbol_names = json.loads( 225 | (Path(__file__).parent / 'sfsymbolnames-2_1.json').read_text()) 226 | 227 | self.restricted = set([ 228 | symbol['symbolName'] 229 | for symbol 230 | in json.loads( 231 | (Path(__file__).parent / 'sfsymbols-restricted-2_1.json').read_text())]) 232 | 233 | horizontal_item_limit = int(math.sqrt(len(self.symbol_names))) 234 | 235 | first_of_line = None 236 | for i, symbol_name in enumerate(self.symbol_names): 237 | tint_color = 'orange' if symbol_name in self.restricted else 'white' 238 | 239 | symbol_image = SymbolImage( 240 | symbol_name, 241 | point_size=14, 242 | weight=THIN, 243 | scale=SMALL, 244 | ) 245 | symbol_button = ui.Button( 246 | tint_color=tint_color, 247 | font=('Fira Mono', 14), 248 | image=symbol_image, 249 | width=self.button_size, 250 | height=self.button_size, 251 | action=self.copy_to_clipboard, 252 | #enabled=False, 253 | ) 254 | symbol_button.symbol_name = symbol_name 255 | self.scrollview.container.add_subview(symbol_button) 256 | 257 | if not first_of_line: 258 | symbol_button.x = 8 259 | symbol_button.y = 8 260 | first_of_line = previous = symbol_button 261 | elif i % horizontal_item_limit == 0: 262 | symbol_button.x = 8 263 | symbol_button.y = first_of_line.y + self.button_size_with_gap 264 | at(self.scrollview.container).fit_size = at(previous).frame 265 | first_of_line = previous = symbol_button 266 | else: 267 | symbol_button.x = previous.x + self.button_size_with_gap 268 | symbol_button.y = previous.y 269 | previous = symbol_button 270 | at(self.scrollview.container).fit_size = at(previous).frame 271 | 272 | def copy_to_clipboard(self, sender): 273 | clipboard.set(sender.symbol_name) 274 | dialogs.hud_alert(f'Copied {sender.symbol_name}') 275 | 276 | 277 | if __name__ == '__main__': 278 | SymbolMatrix().present('fullscreen') 279 | 280 | -------------------------------------------------------------------------------- /ui3/sfsymbols-restricted-2_1.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "symbolName": "airplayaudio", "feature": "AirPlay" }, 3 | { "symbolName": "airplayvideo", "feature": "AirPlay" }, 4 | { "symbolName": "airpods", "feature": "AirPods" }, 5 | { "symbolName": "airpodspro", "feature": "AirPods Pro" }, 6 | { "symbolName": "airport.express", "feature": "AirPort Express" }, 7 | { "symbolName": "airport.extreme", "feature": "AirPort Extreme" }, 8 | { "symbolName": "airport.extreme.tower", "feature": "AirPort Extreme" }, 9 | { "symbolName": "applelogo", "feature": "Sign in with Apple" }, 10 | { "symbolName": "applescript", "feature": "AppleScript language" }, 11 | { "symbolName": "applescript.fill", "feature": "AppleScript language" }, 12 | { "symbolName": "appletv", "feature": "Apple TV" }, 13 | { "symbolName": "appletv.fill", "feature": "Apple TV" }, 14 | { "symbolName": "applewatch", "feature": "Apple Watch" }, 15 | { "symbolName": "applewatch.radiowaves.left.and.right", "feature": "Apple Watch" }, 16 | { "symbolName": "applewatch.slash", "feature": "Apple Watch" }, 17 | { "symbolName": "applewatch.watchface", "feature": "Apple Watch" }, 18 | { "symbolName": "arkit", "feature": "ARKit" }, 19 | { "symbolName": "arrow.clockwise.icloud", "feature": "iCloud service" }, 20 | { "symbolName": "arrow.clockwise.icloud.fill", "feature": "iCloud service" }, 21 | { "symbolName": "arrow.counterclockwise.icloud", "feature": "iCloud service" }, 22 | { "symbolName": "arrow.counterclockwise.icloud.fill", "feature": "iCloud service" }, 23 | { "symbolName": "arrow.down.left.video", "feature": "FaceTime app" }, 24 | { "symbolName": "arrow.down.left.video.fill", "feature": "FaceTime app" }, 25 | { "symbolName": "arrow.down.left.video.fill.rtl", "feature": "FaceTime app" }, 26 | { "symbolName": "arrow.down.left.video.rtl", "feature": "FaceTime app" }, 27 | { "symbolName": "arrow.up.message", "feature": "Messages app" }, 28 | { "symbolName": "arrow.up.message.fill", "feature": "Messages app" }, 29 | { "symbolName": "arrow.up.right.video", "feature": "FaceTime app" }, 30 | { "symbolName": "arrow.up.right.video.fill", "feature": "FaceTime app" }, 31 | { "symbolName": "arrow.up.right.video.fill.rtl", "feature": "FaceTime app" }, 32 | { "symbolName": "arrow.up.right.video.rtl", "feature": "FaceTime app" }, 33 | { "symbolName": "bolt.horizontal.icloud", "feature": "iCloud service" }, 34 | { "symbolName": "bolt.horizontal.icloud.fill", "feature": "iCloud service" }, 35 | { "symbolName": "bonjour", "feature": "Bonjour networking" }, 36 | { "symbolName": "checkmark.icloud", "feature": "iCloud service" }, 37 | { "symbolName": "checkmark.icloud.fill", "feature": "iCloud service" }, 38 | { "symbolName": "earpods", "feature": "EarPods" }, 39 | { "symbolName": "exclamationmark.icloud", "feature": "iCloud service" }, 40 | { "symbolName": "exclamationmark.icloud.fill", "feature": "iCloud service" }, 41 | { "symbolName": "faceid", "feature": "Face ID" }, 42 | { "symbolName": "homekit", "feature": "HomeKit" }, 43 | { "symbolName": "homepod", "feature": "HomePod" }, 44 | { "symbolName": "homepod.fill", "feature": "HomePod" }, 45 | { "symbolName": "icloud", "feature": "iCloud service" }, 46 | { "symbolName": "icloud.and.arrow.down", "feature": "iCloud service" }, 47 | { "symbolName": "icloud.and.arrow.down.fill", "feature": "iCloud service" }, 48 | { "symbolName": "icloud.and.arrow.up", "feature": "iCloud service" }, 49 | { "symbolName": "icloud.and.arrow.up.fill", "feature": "iCloud service" }, 50 | { "symbolName": "icloud.circle", "feature": "iCloud service" }, 51 | { "symbolName": "icloud.circle.fill", "feature": "iCloud service" }, 52 | { "symbolName": "icloud.fill", "feature": "iCloud service" }, 53 | { "symbolName": "icloud.slash", "feature": "iCloud service" }, 54 | { "symbolName": "icloud.slash.fill", "feature": "iCloud service" }, 55 | { "symbolName": "ipad", "feature": "iPad" }, 56 | { "symbolName": "ipad.homebutton", "feature": "iPad" }, 57 | { "symbolName": "ipad.homebutton.landscape", "feature": "iPad" }, 58 | { "symbolName": "ipad.landscape", "feature": "iPad" }, 59 | { "symbolName": "iphone", "feature": "iPhone" }, 60 | { "symbolName": "iphone.homebutton", "feature": "iPhone" }, 61 | { "symbolName": "iphone.homebutton.radiowaves.left.and.right", "feature": "iPhone" }, 62 | { "symbolName": "iphone.homebutton.slash", "feature": "iPhone" }, 63 | { "symbolName": "iphone.radiowaves.left.and.right", "feature": "iPhone" }, 64 | { "symbolName": "iphone.slash", "feature": "iPhone" }, 65 | { "symbolName": "ipod", "feature": "iPod" }, 66 | { "symbolName": "ipodshuffle.gen1", "feature": "iPod shuffle" }, 67 | { "symbolName": "ipodshuffle.gen2", "feature": "iPod shuffle" }, 68 | { "symbolName": "ipodshuffle.gen3", "feature": "iPod shuffle" }, 69 | { "symbolName": "ipodshuffle.gen4", "feature": "iPod shuffle" }, 70 | { "symbolName": "ipodtouch", "feature": "iPod touch" }, 71 | { "symbolName": "key.icloud", "feature": "iCloud service" }, 72 | { "symbolName": "key.icloud.fill", "feature": "iCloud service" }, 73 | { "symbolName": "laptopcomputer.and.iphone", "feature": "iPhone" }, 74 | { "symbolName": "link.icloud", "feature": "iCloud service" }, 75 | { "symbolName": "link.icloud.fill", "feature": "iCloud service" }, 76 | { "symbolName": "livephoto", "feature": "Live Photos feature" }, 77 | { "symbolName": "livephoto.badge.a", "feature": "Live Photos feature" }, 78 | { "symbolName": "livephoto.play", "feature": "Live Photos feature" }, 79 | { "symbolName": "livephoto.slash", "feature": "Live Photos feature" }, 80 | { "symbolName": "lock.icloud", "feature": "iCloud service" }, 81 | { "symbolName": "lock.icloud.fill", "feature": "iCloud service" }, 82 | { "symbolName": "macmini", "feature": "Mac mini" }, 83 | { "symbolName": "macmini.fill", "feature": "Mac mini" }, 84 | { "symbolName": "macpro.gen1", "feature": "Mac Pro" }, 85 | { "symbolName": "macpro.gen2", "feature": "Mac Pro" }, 86 | { "symbolName": "macpro.gen2.fill", "feature": "Mac Pro" }, 87 | { "symbolName": "macpro.gen3", "feature": "Mac Pro" }, 88 | { "symbolName": "macpro.gen3.server", "feature": "Mac Pro" }, 89 | { "symbolName": "message", "feature": "Messages app" }, 90 | { "symbolName": "message.circle", "feature": "Messages app" }, 91 | { "symbolName": "message.circle.fill", "feature": "Messages app" }, 92 | { "symbolName": "message.fill", "feature": "Messages app" }, 93 | { "symbolName": "pencil.tip", "feature": "Markup feature" }, 94 | { "symbolName": "pencil.tip.crop.circle", "feature": "Markup feature" }, 95 | { "symbolName": "pencil.tip.crop.circle.badge.minus", "feature": "Markup feature" }, 96 | { "symbolName": "pencil.tip.crop.circle.badge.plus", "feature": "Markup feature" }, 97 | { "symbolName": "person.icloud", "feature": "iCloud service" }, 98 | { "symbolName": "person.icloud.fill", "feature": "iCloud service" }, 99 | { "symbolName": "plus.message", "feature": "Messages app" }, 100 | { "symbolName": "plus.message.fill", "feature": "Messages app" }, 101 | { "symbolName": "questionmark.video", "feature": "FaceTime app" }, 102 | { "symbolName": "questionmark.video.ar", "feature": "FaceTime app" }, 103 | { "symbolName": "questionmark.video.fill", "feature": "FaceTime app" }, 104 | { "symbolName": "questionmark.video.fill.ar", "feature": "FaceTime app" }, 105 | { "symbolName": "safari", "feature": "Safari browser" }, 106 | { "symbolName": "safari.fill", "feature": "Safari browser" }, 107 | { "symbolName": "swift", "feature": "Swift programming language" }, 108 | { "symbolName": "teletype", "feature": "Teletype feature" }, 109 | { "symbolName": "teletype.answer", "feature": "Teletype feature" }, 110 | { "symbolName": "teletype.circle", "feature": "Teletype feature" }, 111 | { "symbolName": "teletype.circle.fill", "feature": "Teletype feature" }, 112 | { "symbolName": "touchid", "feature": "Touch ID feature" }, 113 | { "symbolName": "video", "feature": "FaceTime app" }, 114 | { "symbolName": "video.badge.checkmark", "feature": "FaceTime app" }, 115 | { "symbolName": "video.badge.plus", "feature": "FaceTime app" }, 116 | { "symbolName": "video.circle", "feature": "FaceTime app" }, 117 | { "symbolName": "video.circle.fill", "feature": "FaceTime app" }, 118 | { "symbolName": "video.fill", "feature": "FaceTime app" }, 119 | { "symbolName": "video.fill.badge.checkmark", "feature": "FaceTime app" }, 120 | { "symbolName": "video.fill.badge.plus", "feature": "FaceTime app" }, 121 | { "symbolName": "video.slash", "feature": "FaceTime app" }, 122 | { "symbolName": "video.slash.fill", "feature": "FaceTime app" }, 123 | { "symbolName": "xmark.icloud", "feature": "iCloud service" }, 124 | { "symbolName": "xmark.icloud.fill", "feature": "iCloud service" }, 125 | { "symbolName": "xserve", "feature": "Xserve" } 126 | ] 127 | --------------------------------------------------------------------------------