├── 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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 = '
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 |
--------------------------------------------------------------------------------