Skip to content

Instantly share code, notes, and snippets.

@cellularmitosis
Last active October 15, 2019 07:36
Show Gist options
  • Save cellularmitosis/ada541bc55b260ab924522a74780da3f to your computer and use it in GitHub Desktop.
Save cellularmitosis/ada541bc55b260ab924522a74780da3f to your computer and use it in GitHub Desktop.
Baby steps towards a structured JSON editor (part 5)

Blog 2019/3/8

<- previous | index | next ->

Baby steps towards a structured JSON editor (part 5)

<- part 4 | part 6 ->

Progress report for 2019/3/8

Implemented support for vertical layout, but must currently be a hard-coded choice (and it doubled the number of related functions).

TODO: an algorithm to intelligently choose between horizontal and vertical layout on a case-by-case basis.

Screen Shot 2019-03-08 at 11 53 53 PM copy

Screen Shot 2019-03-08 at 11 52 28 PM copy

#!/usr/bin/env python
# a pyqt script which draws a visual representation of a JSON structure.
# see https://doc.qt.io/qt-5/qpainter.html
# see http://pyqt.sourceforge.net/Docs/PyQt5/api/qpen.html
# todo:
# insertion "before"
# todo:
# better menus
# todo:
# line-wrapping
# todo:
# editing of values
# todo:
# guard against the case where a user tries to insert a key which already exists
# into an object.
# todo:
# write the edited AST to disk.
# todo:
# move pad into prefs
import sys
import time
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
# an AST node is a dict. examples:
# {
# "type": "null",
# "value": None,
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
# {
# "type": "boolean"
# "value": True,
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
# {
# "type": "number"
# "value": 123.456,
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
# {
# "type": "string"
# "value": "hello world",
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
# {
# "type": "array"
# "value": [1,2,3],
# "children": [<ref>, <ref>, <ref>...]
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
# {
# "type": "object"
# "value": {1:2},
# "children": [<ref>, <ref>, <ref>...]
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_size_hori": (<w>,<h>)|None,
# "cached_size_vert": (<w>,<h>)|None,
# }
def ast_from_json(jsn):
node = {
"value": jsn,
"children": None,
"into": None,
"out": None,
"next": None,
"prev": None,
"cached_size_hori": None,
"cached_size_vert": None,
}
if jsn is None:
node["type"] = "null"
elif isinstance(jsn, bool):
node["type"] = "boolean"
elif isinstance(jsn, int) or isinstance(jsn, float):
node["type"] = "number"
elif isinstance(jsn, str) or isinstance(jsn, unicode):
node["type"] = "string"
elif isinstance(jsn, list):
node["type"] = "array"
node["children"] = []
prev_child = None
for (i,j) in enumerate(jsn):
child = ast_from_json(j)
child["out"] = node
if i == 0:
node["into"] = child
child["prev"] = prev_child
if prev_child is not None:
prev_child["next"] = child
prev_child = child
node["children"].append(child)
elif isinstance(jsn, dict):
node["type"] = "object"
node["children"] = []
prev_child = None
for (i,k) in enumerate(sorted(jsn.keys())):
kchild = ast_from_json(k)
kchild["out"] = node
if i == 0:
node["into"] = kchild
kchild["prev"] = prev_child
if prev_child is not None:
prev_child["next"] = kchild
kchild["out"] = node
node["children"].append(kchild)
v = jsn[k]
vchild = ast_from_json(v)
vchild["out"] = node
vchild["prev"] = kchild
vchild["out"] = node
vchild["prev"] = kchild
kchild["next"] = vchild
node["children"].append(vchild)
prev_child = vchild
return node
else:
# this will never be reached.
assert False
return node
def ast_insert_into(node):
if state["ast"] is None:
state["ast"] = node
state["focused"] = node
else:
if state["focused"]["type"] == "array":
arr = state["focused"]
# stitch the new node in.
arr["value"].insert(0, node["value"])
arr["children"].insert(0, node)
arr["into"] = node
node["out"] = arr
node["prev"] = arr
if len(arr["children"]) > 1:
old_first = arr["children"][1]
old_first["prev"] = node
node["next"] = old_first
# ascend through the parent containers, busting the size caches.
i = arr
while i is not None:
i["cached_size_hori"] = None
i["cached_size_vert"] = None
i = i["out"]
if state["focused"]["type"] == "object":
obj = state["focused"]
knode = node
vnode = ast_from_json(None)
# stitch the new node-pair in.
obj["value"][knode["value"]] = vnode["value"]
obj["children"].insert(0, vnode)
obj["children"].insert(0, knode)
obj["into"] = knode
knode["out"] = obj
knode["prev"] = obj
knode["next"] = vnode
vnode["out"] = obj
vnode["prev"] = knode
if len(obj["children"]) > 2:
old_first = obj["children"][2]
old_first["prev"] = vnode
vnode["next"] = old_first
# ascend through the parent containers, busting the size caches.
i = obj
while i is not None:
i["cached_size_hori"] = None
i["cached_size_vert"] = None
i = i["out"]
state["focused"] = node
def ast_insert_after(node):
focused = state["focused"]
container = focused["out"]
if container["type"] == "array":
arr = container
# stitch the new node in.
index = 1
it = focused
while it["prev"] != None and it["prev"] != container:
index += 1
it = it["prev"]
arr["value"].insert(index, node["value"])
arr["children"].insert(index, node)
arr["into"] = arr["children"][0]
node["out"] = arr
node["prev"] = arr["children"][index-1]
node["next"] = node["prev"]["next"]
if node["next"] != None:
node["next"]["prev"] = node
node["prev"]["next"] = node
# ascend through the parent containers, busting the size caches.
i = arr
while i is not None:
i["cached_size_hori"] = None
i["cached_size_vert"] = None
i = i["out"]
if state["focused"]["type"] == "object":
assert False # TODO
state["focused"] = node
def ast_insert_before(node):
if state["ast"] is None:
ast_insert_into(node)
assert False # TODO
def paint(painter, event, bounds=None):
# fill the canvas with white
painter.fillRect(event.rect(), QBrush(qcolor(prefs["bg_rgb"])))
# paint the json structure
(x, y) = (20, 20)
# print "total size:", paint_ast_hori(state["ast"], painter, (x, y), prefs, bounds=bounds)
print "total size:", paint_ast_vert(state["ast"], painter, (x, y), prefs, bounds=bounds)
if state["mode"] == "context_menu":
paint_context_menu(painter, event)
elif state["mode"] == "node_type_menu":
paint_node_type_menu(painter, event)
def paint_context_menu(painter, event):
painter.fillRect(event.rect(), QBrush(qcolora((0,0,0,127))))
(x, y) = (20, 20)
paint_ast_hori(context_menu_ast(), painter, (x, y), prefs)
# return an AST representing the available context menu items.
def context_menu_ast():
if state["focused"] is None:
state["focused"] = state["ast"]
focused = state["focused"]
d = {}
if state["ast"] is None:
d["d"] = "insert node (into)"
else:
if focused["out"] is not None:
d["s"] = "insert node (before)"
d["f"] = "insert node (after)"
if focused["children"] is not None:
d["d"] = "insert node (into)"
return ast_from_json(d)
def paint_node_type_menu(painter, event):
painter.fillRect(event.rect(), QBrush(qcolora((0,0,0,127))))
(x, y) = (20, 20)
paint_ast_hori(node_type_menu_ast(), painter, (x, y), prefs)
def node_type_menu_ast():
return ast_from_json({
"o": "Object",
"a": "Array",
"s": "String",
"n": "Number",
"b": "Boolean",
"0": "null",
})
# recursively paint a json structure, starting at (x,y).
# returns the (width, height) used to paint the json structure.
def paint_ast_hori(ast, painter, (x, y), prefs, bounds=None):
if ast is None:
return
if bounds and (x > bounds[0] or y > bounds[1]):
# this is off-screen, no need to paint.
return (0,0)
typ = ast["type"]
if typ == "array":
return paint_array_hori(ast, painter, (x, y), prefs, bounds)
elif typ == "object":
return paint_object_hori(ast, painter, (x, y), prefs, bounds)
else:
if typ == "null":
rgb = prefs["null_rgb"]
border_rgb = prefs["null_border_rgb"]
elif typ == "boolean":
rgb = prefs["boolean_rgb"]
border_rgb = prefs["boolean_border_rgb"]
elif typ == "number":
rgb = prefs["number_rgb"]
border_rgb = prefs["number_border_rgb"]
else:
rgb = prefs["word_rgb"]
border_rgb = prefs["word_border_rgb"]
return paint_word_in_box(
atom_repr(ast["value"]), painter, (x, y), rgb, border_rgb, prefs["pad"], border_width(ast)
)
# recursively paint a json structure, starting at (x,y).
# returns the (width, height) used to paint the json structure.
def paint_ast_vert(ast, painter, (x, y), prefs, bounds=None):
if ast is None:
return
if bounds and (x > bounds[0] or y > bounds[1]):
# this is off-screen, no need to paint.
return (0,0)
typ = ast["type"]
if typ == "array":
return paint_array_vert(ast, painter, (x, y), prefs, bounds)
elif typ == "object":
return paint_object_vert(ast, painter, (x, y), prefs, bounds)
else:
if typ == "null":
rgb = prefs["null_rgb"]
border_rgb = prefs["null_border_rgb"]
elif typ == "boolean":
rgb = prefs["boolean_rgb"]
border_rgb = prefs["boolean_border_rgb"]
elif typ == "number":
rgb = prefs["number_rgb"]
border_rgb = prefs["number_border_rgb"]
else:
rgb = prefs["word_rgb"]
border_rgb = prefs["word_border_rgb"]
return paint_word_in_box(
atom_repr(ast["value"]), painter, (x, y), rgb, border_rgb, prefs["pad"], border_width(ast)
)
# paint the items of the array in a container box.
# returns the (width, height) used to paint the array.
def paint_array_hori(ast, painter, (x, y), prefs, bounds=None):
# first, paint the container box
font = painter.font()
pad = prefs["pad"]
(w, h) = size_of_ast_hori(ast, font, pad)
paint_box(painter, (x, y, w, h), prefs["array_rgb"], prefs["array_border_rgb"], border_width(ast))
# now paint the elements inside of the box
y += pad
for child in ast["children"]:
x += pad
(jw, jh) = size_of_ast_hori(child, font, pad)
centered = False
if centered:
(cx, cy) = (x, y + ((h-jh)/2.0))
else:
(cx, cy) = (x, y)
paint_ast_hori(child, painter, (cx, cy), prefs, bounds)
x += jw
return (w, h)
# paint the items of the array in a container box, stacked vertically.
# returns the (width, height) used to paint the array.
def paint_array_vert(ast, painter, (x, y), prefs, bounds=None):
# first, paint the container box
font = painter.font()
pad = prefs["pad"]
(w, h) = size_of_ast_vert(ast, font, pad)
paint_box(painter, (x, y, w, h), prefs["array_rgb"], prefs["array_border_rgb"], border_width(ast))
# now paint the elements inside of the box
x += pad
for child in ast["children"]:
y += pad
(jw, jh) = size_of_ast_vert(child, font, pad)
(cx, cy) = (x, y)
paint_ast_vert(child, painter, (cx, cy), prefs, bounds)
y += jh
return (w, h)
# paint the key-value pairs of the dictionary in a container box.
# returns the (width, height) used to paint the dictionary.
def paint_object_hori(ast, painter, (x, y), prefs, bounds=None):
# first, paint the container box
pad = prefs["pad"]
(w, h) = size_of_ast_hori(ast, painter.font(), pad)
paint_box(painter, (x, y, w, h), prefs["obj_rgb"], prefs["obj_border_rgb"], border_width(ast))
# now paint the key-value pairs inside the box
y += pad
it = iter(ast["children"])
for k in it:
v = next(it)
x += pad
(pw, _) = paint_kv_pair_hori((k,v), painter, (x, y), prefs, bounds)
x += (pw + pad)
return (w, h)
# paint the key-value pairs of the dictionary in a container box.
# returns the (width, height) used to paint the dictionary.
def paint_object_vert(ast, painter, (x, y), prefs, bounds=None):
# first, paint the container box
pad = prefs["pad"]
(w, h) = size_of_ast_vert(ast, painter.font(), pad)
paint_box(painter, (x, y, w, h), prefs["obj_rgb"], prefs["obj_border_rgb"], border_width(ast))
# now paint the key-value pairs inside the box
x += pad
it = iter(ast["children"])
for k in it:
v = next(it)
y += pad
(pw, ph) = paint_kv_pair_vert((k,v), painter, (x, y), prefs, bounds)
y += (ph + pad)
return (w, h)
# paint a linked key-value pair inside a linked pair of boxes.
# returns the (width, height) used to paint the pair.
def paint_kv_pair_hori((k,v), painter, (x, y), prefs, bounds=None):
font = painter.font()
pad = prefs["pad"]
(kw, kh) = size_of_ast_hori(k, font, pad)
(vw, vh) = size_of_ast_hori(v, font, pad)
paint_ast_hori(k, painter, (x, y), prefs, bounds)
paint_ast_hori(v, painter, (x + kw + pad, y), prefs, bounds)
# paint the link between the two boxes
# configure the box stroke
pen = QPen()
pen.setColor(qcolor(prefs["kv-link_rgb"]))
pen.setWidth(1)
painter.setPen(pen)
ly = min(y+(kh/2.0), y+(vh/2.0))
painter.drawLine(
x + kw, ly,
x + kw + pad, ly
)
return (kw+vw, kh+vh)
# paint a linked key-value pair inside a linked pair of boxes.
# returns the (width, height) used to paint the pair.
def paint_kv_pair_vert((k,v), painter, (x, y), prefs, bounds=None):
font = painter.font()
pad = prefs["pad"]
(kw, kh) = size_of_ast_vert(k, font, pad)
(vw, vh) = size_of_ast_vert(v, font, pad)
paint_ast_vert(k, painter, (x, y), prefs, bounds)
paint_ast_vert(v, painter, (x, y + kh + pad), prefs, bounds)
# paint the link between the two boxes
# configure the box stroke
pen = QPen()
pen.setColor(qcolor(prefs["kv-link_rgb"]))
pen.setWidth(1)
painter.setPen(pen)
lx = min(x+(kw/2.0), x+(vw/2.0))
painter.drawLine(
lx, y + kh,
lx, y + kh + pad
)
return (kw+vw, kh+vh)
# paint a word inside of a box, starting at (x, y).
# returns the (width, height) used to paint the word.
def paint_word_in_box(text, painter, (x, y), rgb, border_rgb, pad, border_width=1):
# calculate the size of the text
(tw, th) = size_of_word(text, painter.font())
(w, h) = (pad + tw + pad, pad + th + pad)
paint_box(painter, (x, y, w, h), rgb, border_rgb, border_width)
# configure the text stroke
pen = QPen() # defaults to black
painter.setPen(pen)
# draw the text
text_top_fudge = -2 # qt text seems to have extra padding on top.
baseline = y + th + pad + text_top_fudge
painter.drawText(
pad + x,
baseline,
text
)
return (w, h)
# paint a box (with a border stroke and rounded corners)
def paint_box(painter, (x, y, w, h), rgb, border_rgb, border_width=1):
corner_radius = 4
# configure the box stroke
pen = QPen()
pen.setColor(qcolor(border_rgb))
pen.setWidth(border_width)
painter.setPen(pen)
# configure the box fill
painter.setBrush(QBrush(qcolor(rgb)))
# draw the box
if prefs["rounded"]:
# note: the rounded corners come out a bit odd.
# see https://stackoverflow.com/questions/6507511/qt-round-rectangle-why-corners-are-different
painter.drawRoundedRect(
x, y, w, h,
corner_radius,
corner_radius
)
else:
painter.drawRect(
x, y, w, h
)
return (w,h)
# returns the border width of an AST node. when focused, it will be thicker.
def border_width(ast):
if id(ast) == id(state["focused"]):
if prefs["aa"]:
return 3
else:
return 2
else:
return 1
# calculate the bounding box size of a json structure, layed out horizontally.
def size_of_ast_hori(ast, font, pad=0):
cached_size = ast["cached_size_hori"]
if cached_size is not None:
return cached_size
(w, h) = _size_of_ast_hori(ast, font, pad)
ast["cached_size_hori"] = (w, h)
return (w, h)
# calculate the bounding box size of a json structure, layed out vertically.
def size_of_ast_vert(ast, font, pad=0):
cached_size = ast["cached_size_vert"]
if cached_size is not None:
return cached_size
(w, h) = _size_of_ast_vert(ast, font, pad)
ast["cached_size_vert"] = (w, h)
return (w, h)
def _size_of_ast_hori(ast, font, pad=0):
(total_w, total_h) = (0, 0)
if ast["children"] is not None:
if len(ast["children"]):
for child in ast["children"]:
(w, h) = size_of_ast_hori(child, font, pad)
total_w += (pad + w)
total_h = max(total_h, h)
else:
(w, h) = size_of_word("", font)
total_w += (pad + w)
total_h = h
total_w += pad
total_h = pad + total_h + pad
else:
(w, h) = size_of_word(atom_repr(ast["value"]), font)
total_w = pad + w + pad
total_h = pad + h + pad
return (total_w, total_h)
def _size_of_ast_vert(ast, font, pad=0):
(total_w, total_h) = (0, 0)
if ast["children"] is not None:
if len(ast["children"]):
for child in ast["children"]:
(w, h) = size_of_ast_vert(child, font, pad)
total_w = max(total_w, w)
total_h += (pad + h)
else:
(w, h) = size_of_word("", font)
total_w += (pad + w)
total_h = h
total_h += pad
total_w = pad + total_w + pad
else:
(w, h) = size_of_word(atom_repr(ast["value"]), font)
total_w = pad + w + pad
total_h = pad + h + pad
return (total_w, total_h)
# the string representation of the (string|number|boolean|null).
def atom_repr(value):
if value is None:
return "null"
elif value is True:
return "true"
elif value is False:
return "false"
else:
return "%s" % value
word_size_cache = {}
def size_of_word(text, font):
global word_size_cache
cached_size = word_size_cache.get(text)
if cached_size is not None:
return cached_size
else:
(w, h) = _size_of_word(text, font)
word_size_cache[text] = (w, h)
return (w, h)
# calculate the bounding box size of a word
def _size_of_word(text, font):
# note: the size of "" will be reported as (0,0). this isn't what we want.
# the size of " " will be reported as (0,15), so we'll map "" to " ".
if text == "":
text = " "
text_bounds = QFontMetrics(font).boundingRect(text)
return (text_bounds.width(), text_bounds.height())
# given an rgb triple, return a QColor
def qcolor(rgb):
(r,g,b) = rgb
return QColor(r,g,b,255)
def qcolora(rgba):
(r,g,b,a) = rgba
return QColor(r,g,b,a)
# return a lighter version of a color
def lighter(rgb):
(r,g,b) = rgb
return (
min(255, int(r + ((255-r)/1.15))),
min(255, int(g + ((255-g)/1.15))),
min(255, int(b + ((255-b)/1.15))),
)
# a widget which paints words inside of boxes, using software rendering.
class Window(QWidget):
def __init__(self, paint_fn, keypress_fn):
super(Window, self).__init__()
self.paint_fn = paint_fn
self.keypress_fn = keypress_fn
# this gets called every time the widget needs to repaint (e.g. window resize)
def paintEvent(self, event):
on_paint_event(self, event, self.paint_fn)
def keyPressEvent(self, event):
self.keypress_fn(self, event)
# a widget which paints words inside of boxes, using OpenGL.
class GLWindow(QOpenGLWidget):
def __init__(self, paint_fn, keypress_fn):
super(GLWindow, self).__init__()
self.paint_fn = paint_fn
self.keypress_fn = keypress_fn
# this gets called every time the widget needs to repaint (e.g. window resize)
def paintEvent(self, event):
on_paint_event(self, event, self.paint_fn)
def keyPressEvent(self, event):
self.keypress_fn(self, event)
# hook which gets called for every paint event (i.e. window resize, widget.update(), etc.)
def on_paint_event(widget, event, paint_fn):
then = time.time()
painter = QPainter()
painter.begin(widget)
if prefs["aa"]:
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.TextAntialiasing, True)
bounds = (widget.width(), widget.height())
paint_fn(painter, event, bounds)
painter.end()
now = time.time()
elapsed = now - then
print "fps: %s" % (1.0/elapsed)
# hook which gets called on every keypress.
def on_keypress_event(widget, event):
# see http://pyqt.sourceforge.net/Docs/PyQt4/qt.html#Key-enum
if event.key() == Qt.Key_Q and has_control_mod(event):
quit_app()
elif event.key() == Qt.Key_W and has_control_mod(event):
close_window()
elif state["mode"] == "nav":
handle_nav_keypress(widget, event)
elif state["mode"] == "context_menu":
handle_context_menu_keypress(widget, event)
elif state["mode"] == "node_type_menu":
handle_node_type_menu_keypress(widget, event)
widget.update()
def has_control_mod(event):
# see http://pyqt.sourceforge.net/Docs/PyQt4/qt.html#KeyboardModifier-enum
return event.modifiers() & Qt.ControlModifier
def has_shift_mod(event):
return event.modifiers() & Qt.ShiftModifier
def has_alt_mod(event):
return event.modifiers() & Qt.AltModifier
def handle_nav_keypress(widget, event):
if event.key() == Qt.Key_Up:
nav_up()
elif event.key() == Qt.Key_Down:
nav_down()
elif event.key() == Qt.Key_Left:
nav_prev()
elif event.key() == Qt.Key_Right:
nav_next()
elif event.key() == Qt.Key_Space:
state["mode"] = "context_menu"
elif event.key() == Qt.Key_Q:
quit_app()
def handle_context_menu_keypress(widget, event):
if event.key() == Qt.Key_F:
state["insertion_location"] = "after"
state["mode"] = "node_type_menu"
elif event.key() == Qt.Key_D:
state["insertion_location"] = "into"
state["mode"] = "node_type_menu"
elif event.key() == Qt.Key_S:
state["insertion_location"] = "before"
state["mode"] = "node_type_menu"
else:
state["mode"] = "nav"
def handle_node_type_menu_keypress(widget, event):
if event.key() == Qt.Key_O:
insert_object()
elif event.key() == Qt.Key_A:
insert_array()
elif event.key() == Qt.Key_S:
insert_string()
elif event.key() == Qt.Key_N:
insert_number()
elif event.key() == Qt.Key_B:
insert_boolean()
elif event.key() == Qt.Key_0:
insert_null()
state["insertion_location"] = None
state["mode"] = "nav"
def insert_null():
insert_node(ast_from_json(None))
def insert_boolean():
insert_node(ast_from_json(False))
def insert_number():
insert_node(ast_from_json(3.14))
def insert_string():
insert_node(ast_from_json("foo"))
def insert_array():
insert_node(ast_from_json([]))
def insert_object():
insert_node(ast_from_json({}))
def insert_node(node):
if state["insertion_location"] == "before":
ast_insert_before(node)
elif state["insertion_location"] == "into":
ast_insert_into(node)
elif state["insertion_location"] == "after":
ast_insert_after(node)
# try to ascend one level in the AST.
# else, try to navigate one node previous.
def nav_up():
if state["focused"]["out"] is not None:
state["focused"] = state["focused"]["out"]
elif state["focused"]["prev"] is not None:
state["focused"] = state["focused"]["prev"]
# try to descend one level in the AST.
# else, try to navigate to the next node if in an array / object.
def nav_down():
if state["focused"]["into"] is not None:
state["focused"] = state["focused"]["into"]
elif state["focused"]["next"] is not None:
state["focused"] = state["focused"]["next"]
# try to navigate to the previous node if in an array / object.
# else, try to ascend one level in the AST.
def nav_prev():
if state["focused"]["prev"] is not None:
state["focused"] = state["focused"]["prev"]
elif state["focused"]["out"] is not None:
state["focused"] = state["focused"]["out"]
# try to navigate to the next node if in an array / object.
# else, try to descend one level in the AST.
# else, see if you can pop up a level(s) and then go to the next node.
def nav_next():
if state["focused"]["next"] is not None:
state["focused"] = state["focused"]["next"]
elif state["focused"]["into"] is not None:
state["focused"] = state["focused"]["into"]
else:
i = state["focused"]
while i["out"] is not None:
i = i["out"]
if i["next"] is not None:
state["focused"] = i["next"]
break
# close the focused window (currently the same as quitting).
def close_window():
QCoreApplication.quit()
# quit the app.
def quit_app():
QCoreApplication.quit()
# test the sizing and painting functions.
def test_paint(painter, event, bounds=None):
if "linux" in sys.platform:
test_size_linux()
else:
test_size_mac()
(x, y) = (20, 20)
(w, h) = test_paint_box(painter, (x, y))
y += (h + 10)
(w, h) = test_paint_word_in_box(painter, (x, y))
y += (h + 10)
(w, h) = test_paint_array([], painter, (x, y))
y += (h + 10)
(w, h) = test_paint_array(["hello", 1, 1.0, False, None], painter, (x, y))
y += (h + 10)
(w, h) = test_paint_array(["arrays", ["nested", "in", ["other", "arrays", ["y'all!"]]]], painter, (x, y))
y += (h + 10)
(w, h) = test_paint_object({"key":"value", "a":1, "b":2}, painter, (x, y))
y += (h + 10)
obj = {"colors": {
"bg": [255,255,255],
"word_border": [0,0,0],
"word": [255,255,255],
"array_border": [0,0,255],
"array": list(lighter((0,0,255))),
"obj_border": [255,0,0],
"obj": list(lighter((255,0,0))),
"kv-link": [0,0,0]
}}
(w, h) = test_paint_object(obj, painter, (x, y))
# test the sizing functions (using a font available on linux).
def test_size_linux():
font = QFont('DejaVu Sans', 10)
assert size_of_word("", font) == (0, 15)
assert size_of_word(" ", font) == (0, 15) # this is a bit strange, but whatevs.
assert size_of_word("a", font) == (6, 15)
assert size_of_word("Hello, world!", font) == (75, 15)
assert size_of_ast_hori(ast_from_json(""), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json(""), font, pad=1) == (2, 17)
assert size_of_ast_hori(ast_from_json("Hello, world!"), font, pad=1) == (77, 17)
assert size_of_ast_hori(ast_from_json(None), font, pad=0) == (20, 15)
assert size_of_ast_hori(ast_from_json("null"), font, pad=0) == (20, 15)
assert size_of_ast_hori(ast_from_json(True), font, pad=0) == (25, 15)
assert size_of_ast_hori(ast_from_json("true"), font, pad=0) == (25, 15)
assert size_of_ast_hori(ast_from_json(False), font, pad=0) == (29, 15)
assert size_of_ast_hori(ast_from_json("false"), font, pad=0) == (29, 15)
assert size_of_ast_hori(ast_from_json(1), font, pad=0) == (5, 15)
assert size_of_ast_hori(ast_from_json("1"), font, pad=0) == (5, 15)
assert size_of_ast_hori(ast_from_json(0.5), font, pad=0) == (18, 15)
assert size_of_ast_hori(ast_from_json("0.5"), font, pad=0) == (18, 15)
assert size_of_ast_hori(ast_from_json([]), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json([1]), font, pad=0) == (5, 15)
assert size_of_ast_hori(ast_from_json([1]), font, pad=1) == (9, 19)
assert size_of_ast_hori(ast_from_json({}), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json({1:1}), font, pad=0) == (10, 15)
assert size_of_ast_hori(ast_from_json({1:1}), font, pad=1) == (17, 19)
# test the sizing functions (using a font available on Mac).
def test_size_mac():
font = QFont('.SF NS Text', 13)
assert size_of_word("", font) == (0, 15)
assert size_of_word(" ", font) == (0, 15) # this is a bit strange, but whatevs.
assert size_of_word("a", font) == (6, 15)
assert size_of_word("Hello, world!", font) == (71, 15)
assert size_of_ast_hori(ast_from_json(""), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json(""), font, pad=1) == (2, 17)
assert size_of_ast_hori(ast_from_json("Hello, world!"), font, pad=1) == (73, 17)
assert size_of_ast_hori(ast_from_json(None), font, pad=0) == (19, 15)
assert size_of_ast_hori(ast_from_json("null"), font, pad=0) == (19, 15)
assert size_of_ast_hori(ast_from_json(True), font, pad=0) == (23, 15)
assert size_of_ast_hori(ast_from_json("true"), font, pad=0) == (23, 15)
assert size_of_ast_hori(ast_from_json(False), font, pad=0) == (27, 15)
assert size_of_ast_hori(ast_from_json("false"), font, pad=0) == (27, 15)
assert size_of_ast_hori(ast_from_json(1), font, pad=0) == (4, 15)
assert size_of_ast_hori(ast_from_json("1"), font, pad=0) == (4, 15)
assert size_of_ast_hori(ast_from_json(0.5), font, pad=0) == (17, 15)
assert size_of_ast_hori(ast_from_json("0.5"), font, pad=0) == (17, 15)
assert size_of_ast_hori(ast_from_json([]), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json([1]), font, pad=0) == (4, 15)
assert size_of_ast_hori(ast_from_json([1]), font, pad=1) == (8, 19)
assert size_of_ast_hori(ast_from_json({}), font, pad=0) == (0, 15)
assert size_of_ast_hori(ast_from_json({1:1}), font, pad=0) == (8, 15)
assert size_of_ast_hori(ast_from_json({1:1}), font, pad=1) == (15, 19)
def test_paint_box(painter, (x, y)):
(w, h) = (20, 20)
border_rgb = (0,0,255)
rgb = lighter(border_rgb)
return paint_box(painter, (x, y, w, h), rgb, border_rgb)
def test_paint_word_in_box(painter, (x, y)):
text = "hello"
border_rgb = (255,0,255)
rgb = lighter(border_rgb)
return paint_word_in_box(text, painter, (x, y), rgb, border_rgb)
def test_paint_array(arr, painter, (x, y)):
return paint_array(ast_from_json(arr), painter, (x, y), prefs)
def test_paint_object(obj, painter, (x, y)):
return paint_object(ast_from_json(obj), painter, (x, y), prefs)
# render a box to a pixmap, then blit the pixmap into a window.
def test_pixmap_compositing(widget, event):
painter = QPainter()
pm = QPixmap(200,200)
painter.begin(pm)
test_paint_box(painter, (20, 20))
painter.end()
painter.begin(widget)
painter.fillRect(event.rect(), QBrush(qcolor((255,255,255))))
painter.setCompositionMode(QPainter.CompositionMode_SourceOver)
# hmm, I seem to be ending up with digital noise outside of the text box.
painter.drawPixmap(20,20,pm)
painter.end()
# globals
state = {
"ast": None, # the abstract syntax tree.
"focused": None, # the node in the AST which is currently focused.
"mode": "nav", # ("nav"|"context_menu"|"node_type_menu").
"insertion_location": None, # where to insert the newly created AST node.
}
prefs = {
"pad": 5,
"rounded": True, # use rounded corners on boxes.
"aa": False, # use anti-aliasing.
"bg_rgb": (255,255,255),
"word_border_rgb": (0,0,0),
"word_rgb": (255,255,255),
"null_border_rgb": (0,0,0),
"null_rgb": lighter((0,0,0)),
"boolean_border_rgb": (0,255,0),
"boolean_rgb": lighter((0,255,0)),
"number_border_rgb": (0,255,255),
"number_rgb": lighter((0,255,255)),
"array_border_rgb": (0,0,255),
"array_rgb": lighter((0,0,255)),
"obj_border_rgb": (255,0,0),
"obj_rgb": lighter((255,0,0)),
"kv-link_rgb": (0,0,0)
}
if __name__ == "__main__":
# handle command-line options.
if "--no-rounded" in sys.argv:
prefs["rounded"] = False
if "--aa" in sys.argv:
prefs["aa"] = True
if "--test" in sys.argv:
paint_fn = test_paint
else:
paint_fn = paint
# load the initial JSON structure
if len(sys.argv) > 1 and sys.argv[-1].startswith("--") == False:
import json
with open(sys.argv[-1]) as f:
state["ast"] = ast_from_json(
json.loads(f.read())
)
elif "--demo" in sys.argv:
state["ast"] = ast_from_json(
["hello", ["world", ["these", "are"]], {"a": 1, "b": [3.14,True,None]}, "words", "in", "boxes"]
)
else:
state["ast"] = None
state["focused"] = state["ast"]
# boot up Qt.
app = QApplication(sys.argv)
if "--opengl" in sys.argv:
window = GLWindow(paint_fn, on_keypress_event)
else:
window = Window(paint_fn, on_keypress_event)
window.show()
# it turns out that the python interpreter can't receive signals while
# the Qt main loop is running. So we set up a no-op timer to allow the
# python interpreter to have a chance to handle signals.
# thanks to https://stackoverflow.com/a/4939113
timer = QTimer()
timer.timeout.connect(lambda: None)
timer.start(250)
if "--profile" in sys.argv:
# run in profile mode if "--profile" given on command-line.
import cProfile
cProfile.run('app.exec_()')
else:
# otherwise, run normally.
sys.exit(app.exec_())
{"meta":{"page":1,"limit":25,"offset":-25,"pages":1,"total":7,"max_pages":25,"max_limit":1000,"source":"search","duration":0,"sites":[13],"type":"paginated","extra":[{"request":{"query":"-showAllContent","queryOptions":"{\"fields\":[\"title^20\",\"description^10\"]}","start":0,"size":25,"return":"_score,popularity,title,type,date_created","sort":"recent asc","facet":"{\"type\":{\"sort\":\"count\"},\"cat_content_type\":{\"sort\":\"count\",\"size\":30},\"cat_gender\":{\"sort\":\"count\",\"size\":30},\"cat_level\":{\"sort\":\"count\",\"size\":30},\"cat_training_type\":{\"sort\":\"count\",\"size\":30},\"people\":{\"sort\":\"count\",\"size\":30}}","filterQuery":"( and (range field=date_published {,'2019-03-04T06:29:45.000Z']) ( and type:'video' published:1 site_id:13 ) (not type:'person') (not type:'team') )"},"response":{"ids":{"video":["6310494","6293293","6288706","6288703","6050101","6050100","5187909"]},"sortSeq":{"6310494":0,"6293293":1,"6288706":2,"6288703":3,"6050101":4,"6050100":5,"5187909":6}},"facets":{"Type":[{"value":"video","count":7}],"Content Type":[{"value":"Highlight","count":2},{"value":"Feature","count":1},{"value":"Training","count":1}],"Gender":[],"Level":[],"Training Type":[{"value":"Technique","count":1}],"People":[]}},{"pre_faceted_types":[{"value":"video","count":84701},{"value":"article","count":5855},{"value":"event","count":1005},{"value":"result","count":769},{"value":"ranking","count":26}]},{"featured":{"live_events":[],"videos":[{"id":6310494,"should_show_read_more":false,"ad_set_code":null,"slug_uri":"\/video\/6310494-top-14-complete-highlight-mix-round-14","title":"Top 14 Complete Highlight Mix Round 14","short_title":null,"seo_description":"Top 14 Complete Highlight Mix Round 14","author":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":4032389,"username":"AlexPrado42","first_name":"Alexander","last_name":"Prado","gender":null,"migrated_metadata":null,"created_at":"2017-04-03T15:58:10+0000","modified_at":"2018-12-20T20:56:22+0000","deleted_at":null},"asset":{"id":6518806,"title":"Top 14 Complete Highlight Mix Round 14","description":null,"credit":null,"source":"https:\/\/damb2tknfsomm.cloudfront.net\/uploaded\/vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em\/images\/00001.jpg","site":{"domain":"www.florugby.com","id":34,"name":"FloRugby","code":"florugby","host":"http:\/\/www.florugby.com","active":true,"type":"site","version":3,"color":"#00B74F","hero_image":"https:\/\/d6fm3yzmawlcs.cloudfront.net\/mobileVerticalBackground\/mobile_bg_florugby.jpg","show_on_mobile":true,"sport_name":"Rugby","created_by":null,"modified_by":null,"deleted_by":null},"url":"https:\/\/d2hj1hh0fn56bc.cloudfront.net\/vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em.jpg","library":false,"status_code":200,"path":"https:\/\/flo30-assets-prod.s3.amazonaws.com\/uploads\/FloRugby\/vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em.jpg","copied":true,"duplicated":false,"created_at":"2019-01-08T21:16:08+0000","modified_at":"2019-01-08T21:26:09+0000","deleted_at":null},"logo":null,"slug":"top-14-complete-highlight-mix-round-14","publish_start_date":"2019-01-08T14:55:00+0000","publish_end_date":null,"status":1,"status_text":"Active - Published","status_color":"#CDEB8B","premium":true,"enable_discussion":true,"discussion_type":"comments","node":{"categories":[{"id":5,"name":"Highlight","parent":{"id":1,"name":"Content Type","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-01-22T21:12:47+0000","modified_at":"2018-03-30T19:38:31+0000","deleted_at":null},"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-01-22T21:14:00+0000","modified_at":null,"deleted_at":null}],"people":[],"teams":[],"id":6310494,"origin":null,"current_revision":null,"associations":[{"categories":[{"id":5,"name":"Highlight","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-01-22T21:14:00+0000","modified_at":null,"deleted_at":null},{"id":20,"name":"Men","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-01-22T21:18:05+0000","modified_at":null,"deleted_at":null},{"id":572,"name":"15s","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-02-02T17:28:16+0000","modified_at":null,"deleted_at":null},{"id":574,"name":"Professional","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-02-02T17:28:32+0000","modified_at":null,"deleted_at":null},{"id":582,"name":"International Tournaments","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-02-02T17:28:57+0000","modified_at":null,"deleted_at":null},{"id":585,"name":"Pool Play","parent":null,"created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-02-02T17:29:12+0000","modified_at":null,"deleted_at":null}],"people":[],"teams":[],"id":6242785,"origin":null,"current_revision":{"id":6242785,"should_show_read_more":false,"ad_set_code":null,"slug_uri":"\/events\/6242785-french-top-14-2018-19","title":"French Top 14 2018-19","short_title":"French Top 14 2018-19","seo_description":"Complete highlights, games of the week, and more.","author":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":10000001,"username":"liveteam","first_name":"API","last_name":"User","gender":"m","migrated_metadata":null,"created_at":"2017-08-31T14:39:27+0000","modified_at":null,"deleted_at":null},"asset":{"id":6416972,"title":"TOp 14 Logo.jpg","description":null,"credit":null,"source":"\/\/flo30-assets-prod.s3.amazonaws.com\/uploads\/FloRugby\/5b200eb261a2e.png","site":null,"url":"https:\/\/d2hj1hh0fn56bc.cloudfront.net\/5b200eb261a2e.png","library":true,"status_code":200,"path":"https:\/\/flo30-assets-prod.s3.amazonaws.com\/uploads\/FloRugby\/5b200eb261a2e.png","copied":true,"duplicated":false,"created_at":"2018-06-12T18:19:30+0000","modified_at":"2018-10-13T08:05:17+0000","deleted_at":null},"logo":null,"slug":"french-top-14-2018-19","publish_start_date":"2018-08-25T21:26:00+0000","publish_end_date":null,"status":1,"status_text":"Active - Published","status_color":"#CDEB8B","premium":false,"enable_discussion":true,"discussion_type":"comments","node":null,"enable_interstitial_ad":true,"enable_pre_roll_ads":true,"shareable_link":"https:\/\/www.florugby.com\/events\/6242785-french-top-14-2018-19","created_by":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":10000001,"username":"liveteam","first_name":"API","last_name":"User","gender":"m","migrated_metadata":null,"created_at":"2017-08-31T14:39:27+0000","modified_at":null,"deleted_at":null},"modified_by":null,"deleted_by":null,"created_at":"2018-08-24T15:38:39+0000","modified_at":"2019-03-01T21:29:09+0000","deleted_at":null,"preview_text":"<p>Complete highlights, games of the week, and more.<\/p>","description":"<p>Complete highlights, games of the week, and more.<\/p>","city":"Paris","region":null,"country":"FR","venue":null,"start_date":"2018-08-26","end_date":"2019-05-05","hype_video_node":{"categories":[null,null,null,null,null,null,null],"people":[],"teams":[],"id":6245346,"origin":null,"current_revision":null,"associations":[null,null,null,null,null],"primary_association":null,"banners":[null],"site":null,"view_count":478,"legacy_id":null,"search_indexed_at":null,"created_at":"2018-08-27T17:40:41+0000","modified_at":"2019-02-07T22:39:18+0000","deleted_at":null,"created_by":null,"modified_by":null,"deleted_by":null},"schedule_tab_label":"Schedule","participant_tab_label":"Entries","live_updates_tab_label":"Live Updates","info_tab_label":"Info","live_event":{"stream_list":[{"stream_id":"16684","stream_name":"Top 14 Complete Highlight Mix Round 1","stream_code":"rgp7157_top_14_complete_highlight_mix_round_1","stream_active":true,"stream_hls":"http:\/\/flocasts-hls.videocdn.scaleengine.net\/flocasts-florigins\/play","stream_type":"multiple","stream_mode":"normal"}],"event_ids":[6242785],"id":7157,"title":"Top 14 Complete Highlight Mix Round 16","short_title":"Top 14 Complete Highlight Mix Round 16","stream1":"rgp7157_top_14_complete_highlight_mix_round_1","stream2":"rgp7157_top_14_complete_highlight_mix_round_1","slug":"top-14-complete-highlight-mix-round-16","description":"<p>\r\n\t <span style=\"background-color: initial;\">Top 14 Complete Highlight Mix Round 16<\/span>\r\n<\/p>","premium":true,"ppv":false,"disable_bumping":false,"disable_login":false,"enable_ott":true,"status":"PRE-AIR","status_message":null,"type":"STREAM","start_date":"2019-02-24","end_date":"2019-02-24","start_time":"16:00:00","end_time":"23:59:59","timezone":"America\/Chicago","start_date_time":"2019-02-24T22:00:00+0000","end_date_time":"2019-02-25T05:59:59+0000","background_url":null,"live_event_url":"http:\/\/live.florugby.com#\/event\/7157-top-14-complete-highlight-mix-round-16","player_version":"auto","created_at":"2019-03-01T21:29:10+0000","modified_at":"2019-03-01T21:29:10+0000","deleted_at":null},"watchable":true,"type":"event"},"associations":[],"primary_association":null,"banners":[],"site":{},"view_count":2749,"legacy_id":null,"search_indexed_at":"2019-03-01T21:29:14+0000","created_at":"2018-08-24T15:38:39+0000","modified_at":"2019-03-04T00:19:14+0000","deleted_at":null,"created_by":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":10000001,"username":"liveteam","first_name":"API","last_name":"User","gender":"m","migrated_metadata":null,"created_at":"2017-08-31T14:39:27+0000","modified_at":null,"deleted_at":null},"modified_by":null,"deleted_by":null}],"primary_association":{"categories":[null,null,null,null,null,null],"people":[],"teams":[],"id":6242785,"origin":null,"current_revision":{"id":6242785,"should_show_read_more":false,"ad_set_code":null,"slug_uri":"\/events\/6242785-french-top-14-2018-19","title":"French Top 14 2018-19","short_title":"French Top 14 2018-19","seo_description":"Complete highlights, games of the week, and more.","author":null,"asset":null,"logo":null,"slug":"french-top-14-2018-19","publish_start_date":"2018-08-25T21:26:00+0000","publish_end_date":null,"status":1,"status_text":"Active - Published","status_color":"#CDEB8B","premium":false,"enable_discussion":true,"discussion_type":"comments","node":null,"enable_interstitial_ad":true,"enable_pre_roll_ads":true,"shareable_link":"https:\/\/www.florugby.com\/events\/6242785-french-top-14-2018-19","created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2018-08-24T15:38:39+0000","modified_at":"2019-03-01T21:29:09+0000","deleted_at":null,"preview_text":"<p>Complete highlights, games of the week, and more.<\/p>","description":"<p>Complete highlights, games of the week, and more.<\/p>","city":"Paris","region":null,"country":"FR","venue":null,"start_date":"2018-08-26","end_date":"2019-05-05","hype_video_node":null,"schedule_tab_label":"Schedule","participant_tab_label":"Entries","live_updates_tab_label":"Live Updates","info_tab_label":"Info","live_event":null,"watchable":true,"type":"event"},"associations":[],"primary_association":null,"banners":[],"site":{"domain":"www.florugby.com","id":34,"name":"FloRugby","code":"florugby","host":"http:\/\/www.florugby.com","active":true,"type":"site","version":3,"color":"#00B74F","hero_image":"https:\/\/d6fm3yzmawlcs.cloudfront.net\/mobileVerticalBackground\/mobile_bg_florugby.jpg","show_on_mobile":true,"sport_name":"Rugby","created_by":null,"modified_by":null,"deleted_by":null},"view_count":2749,"legacy_id":null,"search_indexed_at":"2019-03-01T21:29:14+0000","created_at":"2018-08-24T15:38:39+0000","modified_at":"2019-03-04T00:19:14+0000","deleted_at":null,"created_by":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":10000001,"username":"liveteam","first_name":"API","last_name":"User","gender":"m","migrated_metadata":null,"created_at":"2017-08-31T14:39:27+0000","modified_at":null,"deleted_at":null},"modified_by":null,"deleted_by":null},"banners":[],"site":{"domain":"www.florugby.com","id":34,"name":"FloRugby","code":"florugby","host":"http:\/\/www.florugby.com","active":true,"type":"site","version":3,"color":"#00B74F","hero_image":"https:\/\/d6fm3yzmawlcs.cloudfront.net\/mobileVerticalBackground\/mobile_bg_florugby.jpg","show_on_mobile":true,"sport_name":"Rugby","created_by":null,"modified_by":null,"deleted_by":null},"view_count":0,"legacy_id":null,"search_indexed_at":null,"created_at":"2019-01-08T21:16:08+0000","modified_at":"2019-01-08T21:16:16+0000","deleted_at":null,"created_by":null,"modified_by":{"profile_picture_url_small":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_100,h_100\/ntp6qsztqiio8dybdly6.png","profile_picture_url_medium":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_125,h_125\/ntp6qsztqiio8dybdly6.png","profile_picture_url_large":"https:\/\/res.cloudinary.com\/diznifoln\/image\/upload\/w_150,h_150\/ntp6qsztqiio8dybdly6.png","id":4032389,"username":"AlexPrado42","first_name":"Alexander","last_name":"Prado","gender":null,"migrated_metadata":null,"created_at":"2017-04-03T15:58:10+0000","modified_at":"2018-12-20T20:56:22+0000","deleted_at":null},"deleted_by":null},"enable_interstitial_ad":false,"enable_pre_roll_ads":true,"shareable_link":"http:\/\/www.flosports.tv\/video\/-top-14-complete-highlight-mix-round-14","created_by":null,"modified_by":null,"deleted_by":null,"created_at":"2019-01-08T20:55:39+0000","modified_at":"2019-01-08T21:16:19+0000","deleted_at":null,"source":3,"source_label":"flosports","playlist":"https:\/\/vod2.flosports.tv\/uploaded\/vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em\/playlist.m3u8","playlist_no_audio":"https:\/\/vod2.flosports.tv\/uploaded\/vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em\/playlist_video.m3u8","description":"Top 14 Complete Highlight Mix Round 14","description_plain":"Top 14 Complete Highlight Mix Round 14","video_type":0,"content_id":"vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em","embed_hash":"GmBq95zaK","remote_asset":false,"remote_asset_type":0,"account":"master","duration":2358,"offset_start":0,"comments":0,"meta_data":[],"remote_asset_secure":false,"video_key":"vbR8dlMJ6PZN3r9l0JoyE03JeGNeK8em","no_audio":false,"watermark":null,"check_geo_restriction":null,"type":"video"}]}},{"all_content_types":["Full Event Replay","Interview","Highlight","Preview","Recap","FloFilm","Training","Podcast","Show","News","Feature"]}]},"data":[{"id":6293293,"node_id":6293293,"title":"FAB 50 Show - How To Balance Technique & The Mental Game","short_title":null,"shareable_link":"http:\/\/www.flosports.tv\/video\/-fab-50-show-how-to-balance-technique-the-mental-game","publish_start_date":"2018-12-19T09:08:00+0000","publish_end_date":null,"modified_at":"2018-12-19T15:18:13+0000","type":"video","slug_uri":"\/video\/6293293-fab-50-show-how-to-balance-technique-the-mental-game","premium":false,"duration":511,"content_id":"DobpD5avXyDZvQWMXBExRkxXyVreqErM","asset":{"id":6500220,"url":"https:\/\/d2hj1hh0fn56bc.cloudfront.net\/DobpD5avXyDZvQWMXBExRkxXyVreqErM.jpg","created_at":"2018-12-19T15:18:13+0000"},"node":{"id":6293293,"view_count":5,"created_at":"2018-12-19T15:18:02+0000","site":{"id":9001,"name":"FloDogs","sport_name":"Dogs","color":"#FF8200","hero_image":"https:\/\/s3-us-west-2.amazonaws.com\/flosports30\/mobileVerticalBackground\/mobile_bg_flodogs.jpg"},"primary_event_or_series_association":null},"playlist":"https:\/\/vod2.flosports.tv\/uploaded\/DobpD5avXyDZvQWMXBExRkxXyVreqErM\/playlist.m3u8","playlist_no_audio":"https:\/\/vod2.flosports.tv\/uploaded\/DobpD5avXyDZvQWMXBExRkxXyVreqErM\/playlist_video.m3u8","source_label":"flosports","no_audio":false},{"id":6288706,"node_id":6288706,"title":"Doug The Pug - Moana","short_title":null,"shareable_link":"http:\/\/www.flosports.tv\/video\/-doug-the-pug-moana","publish_start_date":"2018-12-12T13:16:00+0000","publish_end_date":null,"modified_at":"2018-12-12T19:27:37+0000","type":"video","slug_uri":"\/video\/6288706-doug-the-pug-moana","premium":false,"duration":0,"content_id":"BQPYg0oym6Ey2Vea2MZgRGz9Obz3872N","asset":{"id":6494703,"url":"https:\/\/d2hj1hh0fn56bc.cloudfront.net\/BQPYg0oym6Ey2Vea2MZgRGz9Obz3872N.jpg","created_at":"2018-12-12T19:29:39+0000"},"node":{"id":6288706,"view_count":4,"created_at":"2018-12-12T19:29:39+0000","site":{"id":13,"name":"FloSports","sport_name":null,"color":null,"hero_image":null},"primary_event_or_series_association":null},"playlist":"https:\/\/vod2.flosports.tv\/uploaded\/BQPYg0oym6Ey2Vea2MZgRGz9Obz3872N\/playlist.m3u8","playlist_no_audio":"https:\/\/vod2.flosports.tv\/uploaded\/BQPYg0oym6Ey2Vea2MZgRGz9Obz3872N\/playlist_video.m3u8","source_label":"flosports","no_audio":false},{"id":6288703,"node_id":6288703,"title":"Dog Surprised with 100 Balls for Birthday- Cute Dog Maymo","short_title":null,"shareable_link":"http:\/\/www.flosports.tv\/video\/-dog-surprised-with-100-balls-for-birthday-cute-dog-maymo","publish_start_date":"2018-12-12T13:16:00+0000","publish_end_date":null,"modified_at":"2018-12-12T19:21:28+0000","type":"video","slug_uri":"\/video\/6288703-dog-surprised-with-100-balls-for-birthday-cute-dog-maymo","premium":false,"duration":0,"content_id":"R05Yx6dVxPNj0rM1djmwNv6EN10w7pOe","asset":{"id":6494699,"url":"https:\/\/d2hj1hh0fn56bc.cloudfront.net\/R05Yx6dVxPNj0rM1djmwNv6EN10w7pOe.jpg","created_at":"2018-12-12T19:21:28+0000"},"node":{"id":6288703,"view_count":0,"created_at":"2018-12-12T19:19:24+0000","site":{"id":13,"name":"FloSports","sport_name":null,"color":null,"hero_image":null},"primary_event_or_series_association":{"id":6082925,"node_id":6083198,"title":"2018 Niels flodogs","short_title":"Niels flodogs","shareable_link":"https:\/\/www.flodogs.com\/events\/6083198-2018-niels-flodogs","publish_start_date":"2018-01-15T14:00:00+0000","publish_end_date":null,"modified_at":"2019-03-01T18:17:37+0000","type":"event","slug_uri":"\/events\/6083198-2018-niels-flodogs","premium":false}},"playlist":"https:\/\/vod2.flosports.tv\/uploaded\/R05Yx6dVxPNj0rM1djmwNv6EN10w7pOe\/playlist.m3u8","playlist_no_audio":"https:\/\/vod2.flosports.tv\/uploaded\/R05Yx6dVxPNj0rM1djmwNv6EN10w7pOe\/playlist_video.m3u8","source_label":"flosports","no_audio":false},{"id":5187909,"node_id":5187909,"title":"The Long Green Line (full movie)","short_title":null,"shareable_link":"http:\/\/www.flosports.tv\/video\/289496-the-long-green-line-full-movie","publish_start_date":"2010-02-03T11:44:51+0000","publish_end_date":"-0001-11-30T00:00:00+0000","modified_at":"2014-09-16T20:40:10+0000","type":"video","slug_uri":"\/video\/5187909-the-long-green-line-full-movie","premium":false,"duration":0,"content_id":"","asset":null,"node":{"id":5187909,"view_count":20454,"created_at":"2010-02-03T11:44:51+0000","site":{"id":13,"name":"FloSports","sport_name":null,"color":null,"hero_image":null},"primary_event_or_series_association":null},"playlist":null,"playlist_no_audio":null,"source_label":"ooyala","no_audio":false}]}
@cellularmitosis
Copy link
Author

on a 4k :)

Screen Shot 2019-03-08 at 11 52 28 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment