Skip to content

Instantly share code, notes, and snippets.

Created Aug 26, 2015
What would you like to do?
# coding: utf-8
# Pythonista 1.6
# many things just copied from omz starter code
# all the bad stuff, thats me :)
# still learning
###### VirtualView #######
# for displaying large amounts of data
# needed for 1.6 at the moment, if importing from same dir
import sys
if '.' not in sys.path:
sys.path.insert(0, '.')
# using Faker to
from faker import Faker
fake = Faker()
import time
from time import strftime
17 Aug 2015
1. color_demo, added random color when clicked.
18 Aug 2015
1. using to commit this file to gisthub.
i thought it was working, its not working though.
i want to punch it, drives me crazy.
2. **** Important *****
no need to add a button for a hit test now. when you create your cell, you can set self.action to a method/function. if the cell is clicked/hit, the self.action will be called if defined.
Changed all the demo cells to work off the new mechanism.
3. changing the click mechanism had a side effect. the cell was not being released from memory when setting self.action = some_method. In the release
function i now set self.action = None, the memory leak stopped.
4. added 'Top' and 'Bottom' menu items to the titlebar. just to take you to the top abd bottom of the list.
5. the initial cell count is 100 million. its a VirtualView. 5 or 500 million cells should not matter. if you are not comportable with that number, just change _item_count below to a smaller number!
6. changed the ui.Label width to be able to display the number correcly. did it the lazy way :(
20 Aug 2015
1. i stopped using self.bounds etc for calculations. the scrollview is used now as it should have been. this is required if you need to but anyother view inside the same containing view.
22 Aug 2015
1. added a scroll navigator. lets you move through the list in percent increments
2. adder Faker for more real world data examples
3. have a list called subview_destroy_register.if a view is appended to this list, the release method will remove the view
4. added a Stress class. this just keeps scrolling the list, using ui.delay. the nice thing about this is that no code is requied inside the virtualview class.
23 Aug 2015
1. added the concept of a table. there is a new class TableHeader that can be added to the top of the view, and i did a demo of making columns. because in the colums, the views are views inside views, there is a list subview_destroy_register you can append views to. when the release is called, any view in that list is removed from the view. we think about this more. maybe, i am just being stuip. maybe i should just walk all the subviews and remove them.
26 Aug 2015
1. added a Class ThreadedCell. Like all of this is, its just work in progress. the idea is that will try and get slow loading resouces from the web etc. i have just tried to simulate this with time.sleep at the moment. but pretty sure urs going to get more involved than that once i start using something like requests. some fun and homework coming my way :)
General Notes
1. when creating a cell/row and set a callback function, other than the ones already definded, you should explicitly set the action field to none in the release method of the VirtualCell class.
if you dont do this, your objects will not be freed from memory resulting in a memory leak.
To-do, ideas
1. to create cells on a thread. maybe the cell is trying to get a image from a url etc. but a lot for me to think about.
import ui
from random import randint
import random
import time, sys
import console
# determining if Phytonista 1.5 or 1.6xxx
__ver__ = 0
import dialogs
__ver__ = 1.6
except ImportError:
__ver__ = 1.5
# some pythonista built in image names for demo
images = ['Confounded', 'Crying_2', 'Disappointed' , 'Dizzy', 'Flushed', 'Smiling_3', 'Winking', 'Tired', 'Unamused', 'Fisted_Hand', 'Index_Finger_Up_1', 'Four_Leaf_Clover', 'Herb', 'Mushroom', 'Baby_Chick_1', 'Bactrian_Camel', 'Boar', 'Cat_Face_Grinning','Cat_Face_Heart-Shaped_Eyes', 'Cow_Face', 'Honeybee']
ani_images = ['pzl:BallBlue', 'pzl:BallGray']
btn_decorators = {'checkmark' : 'iob:ios7_checkmark_outline_24' ,'information' :'iob:ios7_information_24', 'price' : 'iob:ios7_pricetag_24'}
# for tableview style...will expand on this later
# i think will use a list of named tuples. Column should include, width, alignment, possibly font etc.
table_def = [('Name'), ('Company'), ('User Name'), ('CT')]
# some helper functions
def random_color():
# for demo purposes
return(randint(0,255) / 255., randint(0,255) / 255., randint(0,255) / 255.)
def invert_rgb(color):
return(255 - color[0], 255 - color[1], 255 - color[2], 0)
def rand_image():
# just return an image from, a list (subset)
# of pythonista built images.
# testing/demo purposes
return ui.Image.named(images[randint(0,len(images) -1)])
def inset_frame(f, left, top, w, h):
return (f[0] + left, f[1] + top, f[2] + w, f[3] + h)
# end helper functions
# for demo purposes, can use image_demo, color_demo, row_demo, row_style_1, create_table_row
_cell_type = 'row_style_1'
# for demo purposes, can set the item_size here
# item_size is width, height. if either w,h = 0
# then the width or height or both will be the
# value of the presented view
_item_size = ( 128, 44)
# just to make demo life a little easier.
# if using 'row_type' as cell, sets the width to 0
if 'row' in _cell_type:
_item_size = (0, _item_size[1])
# can provide data in 2 ways when creating the
# VirtualView. You can pass item_count, no data.
# or you can pass a list of whatever.
# passing item_count negates having to do
# something like range(100000) to pass in.
_item_count = 1000000 # 1,000,000
#should be ok. What is nice, is ui.ScrollView is
# clean in terms of memory usage. not to imply that
# it would be wrong. just nice it is not.
_buf_size = 80
# presentation style
_pres_style = 'sheet'
#to use when have alternating row colors, with rowtype cells.
_alt_row_colors = ('lightyellow', 'orange')
# whether or not to add a scroll navigation bar
_add_nav_bar = True
# whether or not to add a title header
_add_table_header =False
# if this is true, then after 2 seconds, the list
# start to scroll itself. will scroll as fast as it
# can. good for testing for memory leaks, data
# inconsistences etc...
# if True, uses the ThreadedCell Class. Just trying
# to build something at the moment...
import threading
class ThreadedCell(ui.View):
def __init__(self, caller, item_index, data_item, w, h, cell_type = None):
self.caller = caller
self.item_index = item_index
self.width, self.height = w, h
self.data_item = data_item
self.action = None
self.action_in_hilight_cell = None
self.t = None
self.event = None
def create_cell(self):
self.background_color = 'gray'
self.border_width = .5
self.border_color = 'white'
lb = ui.Label(name = 'status', frame = self.frame)
lb.text = 'loading'
lb.text_color = 'white'
lb.alignment = ui.ALIGN_CENTER
# make sure we only run the Thread once
if not self.t:
# signalling mech.
self.event = threading.Event()
def add_item_id_label(self):
# just add a label to see the item_index.
# handy for debugging
txt = '{:,}'.format(self.item_index)
w, h = 10 * len(txt), 20
f = (self.width - w, 0, w , h)
lb = ui.Label( frame = f)
lb.frame = f
lb.background_color = 'black'
lb.text_color = 'white'
lb.text = txt
lb.alignment = ui.ALIGN_RIGHT
def fetch_data(self):
# this is trying to simulate waiting for data
# from something like a web resource...
# i guess this will get more problematic when
# other threaded resources like response is
# used
# signalling for the thread
e = self.event
# first item
if e.isSet(): return
# second item
if e.isSet(): return
# third item
if e.isSet(): return
# Fourth item
if e.isSet(): return
if e.isSet(): return
self['status'].text = 'Loaded'
if e.isSet(): return
self.background_color = 'orange'
def release(self):
# if the thread is alive, signal it to exit
# i think i should be using join here. havnt
# got my head around that yet.
if self.t.isAlive():
# remove ourself from the superview
# maybe this is a stupid idea. Maybe
# smarter for the caller to remove us?
# this would not always be required. but
# i found when a button for example has a
# action attached, even internal to this
# class. Memory is not being released unless
# the subviews are removed. thats only one
# example. could be others, if using other
# callbacks such as delegates.
for sv in self.subviews:
# if i dont do this, the cell is not resleased
# from memory!
self.action = None
self.action_in_hilight_cell = None
self.t = None
class VirtualCell(ui.View):
def __init__(self, caller, item_index, data_item, w, h, cell_type = None):
A cell object to be used by the VirtualView
the instanciating object
the items index as displayed by the Virtual
the data to be drawn for the cell. can be None if the VirtualView is not using data
the width of the cell being requested
the height of the cell being requested
the idea is that the ui elements for a cell are built in their own method. Making it easier to create different cell types.
i guess this could have been done a few ways, inheritance or just a copy abd paste to make a new cell.
As i get better, i hope to improve the design, but this is what i have for now.
if cell_type is None, will call create_cell_contents method from init. otherwise you could create your own methods and call them appropriately.
i try to give an example.
# fake some data with Faker
data_item = [,, fake.user_name(), fake.country_code()]
self.caller = caller
self.item_index = item_index
self.width, self.height = w, h
self.data_item = data_item
self.action = None
self.action_in_hilight_cell = None
# just an idea to register views that are
# subviews of other views that you want to be
# sure are removed so memory is released
self.subview_destroy_register = []
# for row_demo cell_type
self.selected = False
# just for demo/testing purposes
# would normally just call create_cell_contents
if not cell_type:
elif cell_type == 'image_demo':
elif cell_type == 'color_demo':
elif cell_type == 'row_demo':
elif cell_type == 'row_style_1':
elif cell_type == 'create_table_row':
# if we dont recognise the cell_type
# call the default
if 'row' not in cell_type:
# adds a label to the cell with the
# item_index in the top right corner.
# for debugging only
def create_cell_contents(self):
# the place you would typically create your
# cells ui elements
def create_col_cell(self,col_num, w):
col_cell = ui.View(name = str(col_num), frame = (0,0,w,self.height))
f = inset_frame(col_cell.frame, 3, 3, 0,0)
lb = ui.Label(name = 'title', frame = f) = 'title'
# if the view is added to self.subview_destroy_register, it will be
col_cell.border_width = .5
return col_cell
def create_table_row(self):
cols = 4
col_width = self.width / cols
for i in range(0,cols):
col_view = self.create_col_cell(i, col_width )
col_view['title'].text = self.data_item[i]
col_view.x = (col_width * i)
self.action = self.cell_row_click
def create_color_cell_contents(self):
# demo of using random color swatchs
self.background_color = random_color()
self.action = self.set_bgcolor_to_random
def create_image_cell_contents(self):
# demo of displaying images. Just changes
# the image when the cell is clicked
img = ui.ImageView(name = 'img', frame = self.frame)
img.image = rand_image()
self.action = self.set_random_image
def row_demo_contents(self):
# demo of virtual rows. is not smart as the
# ui.TableView does this a billion times better
self.flex = 'W'
self.ignore_width = True
lb = ui.Label(frame = self.frame)
lb.text = '{:,}'.format(self.item_index)
lb.text = self.data_item[0]
lb.alignment = ui.ALIGN_LEFT
lb.font = ('Verdana', 20)
lb.x += 30
lb.width -= 30 =
self.action = self.cell_row_click
self.border_width = .5
def row_style_1_contents(self):
_margin = 5
# title text
f = (38, 0, self.width , self.height *.65)
lb = ui.Label(name = 'title_text' , frame = f )
lb.text = self.data_item[0]
lb.font = ('Melno', 36)
# subtitle
f = (38, lb.y + lb.height, lb.width, self.height - lb.height)
lb2 = ui.Label(name = 'subtitle', frame = f)
lb2.text = self.data_item[1]
lb2.font = ('DIN Condensed', 16)
# icon/image
f = (0,0, 32, 32)
img = ui.ImageView(name = 'img', frame = f)
img.image = rand_image()
img.y = (self.height - img.height) / 2
self.border_width =.3
# info button
btn = ui.Button('info', title = '')
btn.frame = self.frame
btn.width = btn.height = 32
btn.x = self.width - btn.width
btn.y = (self.height - img.height) / 2
btn.font = ('<system-bold>', 22)
btn.background_image = ui.Image.named(btn_decorators['checkmark'])
self.action = self.cell_row_click
self.action_in_hilight_cell = self.set_random_image
# action functions for when we are clicked, cell
# type to define. otherwise None
def set_bgcolor_to_random(self):
self.background_color = random_color()
def set_random_image(self):
iv = self['img']
iv.image = None
iv.image = rand_image()
def _set_row_alt_color(self):
if not self.item_index % 2:
self.background_color = _alt_row_colors[0]
self.background_color = _alt_row_colors[1]
def cell_row_click(self):
# called when clicking on a row, with row_demo cell_type
# my stupid attempt to try to simulate a row click.
# i have tried a few things here. seems like
# over kill. the current way, just using invert
# rgb. not a great method. but illustrates what
# can be done for now
if self.selected : return
self.selected = True
old_color = self.background_color
self.background_color = invert_rgb(self.background_color)
if self.action_in_hilight_cell:
self.background_color = old_color
self.selected = False
def add_item_id_label(self):
# just add a label to see the item_index.
# handy for debugging
txt = '{:,}'.format(self.item_index)
w, h = 10 * len(txt), 20
f = (self.width - w, 0, w , h)
lb = ui.Label( frame = f)
lb.frame = f
lb.background_color = 'black'
lb.text_color = 'white'
lb.text = txt
lb.alignment = ui.ALIGN_RIGHT
def layout(self):
# no need to add a btn to the view for a click
# event. this works very well.
# As long as we are ok with the whole cell
# ignoring, touch_began, touch_moved. Of course,
# other opporturnities to use these events.
# KISS at the moment :)
def touch_ended(self, touch):
if self.action:
# explicity call this method, to free up
# resources. whatever they maybe. dont rely on
# __del__
def release(self):
# remove ourself from the superview
# maybe this is a stupid idea. Maybe
# smarter for the caller to remove us?
# this would not always be required. but
# i found when a button for example has a
# action attached, even internal to this
# class. Memory is not being released unless
# the subviews are removed. thats only one
# example. could be others, if using other
# callbacks such as delegates.
for sv in self.subviews:
# hmmmm, thinking about this. without walking
# through the whole tree. can register a subview
# to be released, if its not reachable from
# from the top layer....
for sv in self.subview_destroy_register:
# if i dont do this, the cell is not resleased
# from memory!
self.action = None
self.action_in_hilight_cell = None
#def __del__(self):
# This does get called. but from what i read,
# better to have a explicit function, rather
# than rely on __del__. i am not good enough
# to understand it. So for now, i call the
# classes release method explicitly.
# Seems a shame. Would make cleaning up so
# much more natural. In my view.
#print '__del__ was called on item {}'.format(self.item_index)
class VirtualView(ui.View):
def __init__(self, vw, vh, item_size, buf_size = 0, items= None, item_count = 0):
A VirtualView for displaying large
amounts of data in scrollable area.
width of cell
heigth of cell
# set the width and height of the view
self.width = vw
self.height = vh
# buf_size gets set in the layout method.
# currently if its smaller than one screen
# of items, its resized to be able to hold
# at least one screen of items
self.buf_size = buf_size
# a list of VirtualCell objects, the number of
# buffered Cells, dedends on bufsize
self.buffer = []
# calculated in layout method
self.visible_rows = 0
self.items_per_row = 0
# store the the w,h like this for ease of reading
self.item_width = item_size[0]
self.item_height = item_size[1]
# flags to indicate that the width of height or
# both should be overwridden. if item_width or
# item height is 0, the width or height if the
# scrollview is used
self.override_width = False
self.override_height = False
if self.item_width == 0:
self.override_width = True
if self.item_height == 0:
self.override_height = True
# to make sure we are called by layout
self.flex = 'WH'
# set up a scrollview, with delagate = ui.ScrollView(flex='WH') = ui.ScrollView(flex='WH') = self
# a list of data to be used
self.items = items
# num_items is only set to non zero value if
# you dont require data.
# eg. you could pass 1,000,000 to item_count
# to have a virtual view of a 1million items.
# of course you could use a range() for items
# but for large numbers, is not efficent.
self.num_items = item_count
# just demo/debug menu title btns
btn = ui.ButtonItem(title = 'Top')
btn.action = self.goto_top
# spacer button :(
btn1 = ui.ButtonItem(title =' ' * 5)
btn2 = ui.ButtonItem(title ='Bottom')
btn2.action = self.goto_end
self.right_button_items = [btn2, btn1, btn]
# some debug vars
self.created_cells = 0
self.deleted_cells = 0
self.used_buffered_cells = 0
self.start = time.time()
# add a navigation bar
self.nav_bar = None
if _add_nav_bar:
self.nav_bar = NavClass( 32, increments = 5, callback = self.goto_percent)
#add table header
self.table_header = None
if _add_table_header:
self.table_header = TableHeader(self.width, 32, table_def)
def goto_top(self, sender): = (0,0)
def goto_end(self, sender): = (0,[1] - self.height)
def goto_percent(self, sender):
scroll_value =[1]
percent = float( / 10.
new_scroll_value = (0, scroll_value * percent) = new_scroll_value
# this method returns the data item count
def item_count(self):
# we dont access len(self.items) in the code,
# we call this method. maybe no data items,
# just a int for the count.
return self.num_items if self.num_items > 0 else len(self.items)
# ui.View callback
def layout(self):
w,h = self.bounds[2:]
# if we have added a navigation bar
if self.nav_bar:
w -= self.nav_bar.width
# if we have a header row
if self.table_header:
h -= self.table_header.height
# set the content height of the scrollview
sv =
sv.width = w
sv.height = h
if self.table_header:
sv.y = self.table_header.height
# see if we are overriding the item_size
if self.override_width and self.override_height:
self.item_width , self.item_heigth = sv.bounds[2:]
elif self.override_width:
self.item_width = w
elif self.override_height:
self.item_height = h
# number of visible rows
self.visible_rows = int(h / self.item_height)
# adding 2 addtional visible rows here. nicer
# scrolling. extra line and comment, just so
# its not just a magic + 2
self.visible_rows += 2
# items per row
self.items_per_row =int(w / self.item_width)
# maximum num of rows
max_rows = (self.item_count() / self.items_per_row)
# add an extra row if not an exact multiple
if self.item_count() % self.items_per_row <> 0:
max_rows += 1
sv.content_size =(w, max_rows * self.item_height)
# adjust the the navigation bar if we have one
if self.nav_bar:
nav_bar = self.nav_bar
nav_bar.x = self.width - nav_bar.width
#nav_bar.heigth = self.height
if self.table_header:
# the way the buffer is implemented it does
# not work correctly if it is smaller than
# the number of items on the screen.
# can be done, more a performance issue
# so the buffer is resized here if required.
min_buf_size = self.visible_rows * self.items_per_row
if self.buf_size < min_buf_size:
self.buf_size = min_buf_size
# clear the buffer, remove all the subviews
# needed when the orientation changes, is not
# called on all presentation styles. Not an
# issue, layout is called by ui when req.
for cell in self.buffer:
del cell
self.deleted_cells += 1 # debug
self.buffer = []
# ui.View callback
def draw(self):
# useful to use the draw method of ui
# on startup and orientation change we
# are called as expected.
# this method is also called explictly from
# scrollview_did_scroll
v_offset =[1]
# get the first visible row
row = int(v_offset / self.item_height)
# get the visible items to draw
visible_items = self.items_visible()
# draw each item
for item_index in visible_items:
# ui callback, ui.scrollview delegate
def scrollview_did_scroll(self, scrollview):
def draw_item(self, item_index):
# if the item_index is found in the buffer
# we know the cell is still present in the
# scrollview. so we exit, we dont need to
# recreate the cell
for cell in self.buffer:
if cell.item_index == item_index:
self.used_buffered_cells += 1 # debug
# create a cell (view), that is added to the
# subviews of the scrollview. once the buffer
# is full, the oldest cell is removed from
# the scrollview.subviews
data_item = None
if self.num_items == 0:
data_item = self.items[item_index]
cell = ThreadedCell(self, item_index, data_item, self.item_width, self.item_height, cell_type = _cell_type)
cell = VirtualCell(self, item_index, data_item, self.item_width, self.item_height, cell_type = _cell_type)
self.created_cells += 1
cell.frame = self.frame_for_item(item_index)
# maintain the buffer
if len(self.buffer) > self.buf_size:
cell = self.buffer[0]
del cell
self.deleted_cells += 1 # debug
if len( > len(self.buffer):
raise StandardError('Views are not being released from memory correctly.')
# get the frame for the given item index
def frame_for_item(self, item_index):
w, h =[2:]
items_per_row = self.items_per_row
row = item_index / items_per_row
col = item_index % items_per_row
if items_per_row == 1:
x_spacing = (w - (items_per_row * self.item_width)) / (items_per_row)
x_spacing = (w - (items_per_row * self.item_width)) / (items_per_row-1)
return (col*(self.item_width + x_spacing), row*self.item_height, self.item_width, self.item_height)
# get the visible indices as a range...
def items_visible(self):
y =[1]
w, h =[2:]
items_per_row = self.items_per_row
num_visible_rows = self.visible_rows
first_visible_row = max(0, int(y / self.item_height))
range_start = first_visible_row * items_per_row
range_end = min(self.item_count(), range_start + num_visible_rows * items_per_row)
return range(range_start, range_end)
# ui.View callback
def will_close(self):
# do some clean up, make sure nothing
# left behind in memory.
# maybe this is not need, but easy to do it
for cell in self.buffer:
del cell
self.deleted_cells += 1
self.buffer = []
# print out some debug info
print 'Created Cells = {}, Deleted Cells = {}, Used buffered Cells = {}'.format(self.created_cells, self.deleted_cells, self.used_buffered_cells)
elapased_time = time.time() - self.start
#print strftime('%I:%M:%S',elapased_time )
class NavClass(ui.View):
def __init__(self, width, increments = 10, callback = None):
self.hit_callback = callback
self.width = width
btn_font = ('<system-bold>', 12)
for i in range((100 / increments)):
inc = str((i * increments)/10.)
btn = ui.Button(name = str(inc))
btn.title = str( i * increments) + '%'
btn.action = self.hit
btn.font = btn_font
self.background_color = 'orange'
def user_layout(self, height):
# when the parent classes layout method is
# called, we manually call thus method to
# redraw our views objects
self.height = height
w = self.width
h = height / len(self.subviews)
for i, btn in enumerate(self.subviews):
btn.frame = (0, i * h, w, h )
def hit(self, sender):
# communicate with parent
if self.hit_callback:
class TableHeader(ui.View):
def __init__(self,w, h, tb_def):
self.num_cols = len(tb_def)
self.table_def = tb_def
print self.num_cols
self.width = w
self.height = h
col_width = w / self.num_cols
header_font = ('Menlo', 18)
for i, rec in enumerate(tb_def):
col_header = ui.Label(name = str(i))
col_header.border_width =.5
col_header.border_color = 'white'
col_header.alignment = ui.ALIGN_CENTER
col_header.text = rec
col_header.background_color = 'black'
col_header.text_color = 'white'
col_header.font = header_font
col_header.border_width = .5
def user_layout(self, w):
self.width = w
col_width = w / self.num_cols
for i, rec in enumerate(self.table_def):
f = (i * col_width, 0, col_width, self.height)
lb = self[str(i)]
lb.frame = f
class Stress(object):
# an auto scroller for VirtualView class
# to stress test it.
def __init__(self, obj, delay = .1):
import console, time
# trying for saftey
# no sleeping...
self.obj = obj
self.delay = delay
self.busy = False
# record the start time
self.start = time.time()
ui.delay(self.auto_scroll, 2)
def auto_scroll(self):
# this busy signal, probably not required.
# but will keep it in anyway. its a small price
# to pay for extra saftey.
#print sys.getrefcount(self)
if self.busy:
#ui.delay(self.auto_scroll, self.delay)
# thanks @JonB
# if the view is not onscreen, we delete ourself
# moments later our __del__ is called...
if not self.obj.on_screen:
print 'off screen'
#del self
self.busy = True
# the scrollview
sv =
# current offset of the scrollview
v_offset = sv.content_offset[1]
# calc new_offset, to be + one page
new_offset = v_offset + sv.height
# wrap around if we reach the bottom if the list
if new_offset > sv.content_size[1]:
new_offset = 0
sv.content_offset = (0, new_offset )
ui.delay(self.auto_scroll, self.delay)
self.busy = False
if __name__ == '__main__':
ui.cancel_delays() # just incase...the stress class sort of needs it
# moved these vars here, for testing purposes
_add_table_header = False
_item_size = (0, 60)
_cell_type = '_row_'
_add_nav_bar = False
_item_count = 100 * 1000
if __ver__ == 1.6:
console.set_font('Menlo', 22)
# as our item size is 128,128 defined at the top
# gives us a window size to view 4 rows x 4 cols
vw = 128 * 4
vh = 66 * 10
vw , vh = 540, 576
vv = VirtualView(vw, vh, _item_size, _buf_size, items = None , item_count = _item_count)
vv.background_color = 'white'
vv.present(_pres_style, hide_title_bar = False )
# this will continue to auto scroll the Virtual View until it the view is closed.
Stress(vv, delay = 1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment