Skip to content

Instantly share code, notes, and snippets.

@cellularmitosis
Last active October 15, 2019 07:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save cellularmitosis/7203b449eeda5f78b2a86793f9de088c to your computer and use it in GitHub Desktop.
Save cellularmitosis/7203b449eeda5f78b2a86793f9de088c to your computer and use it in GitHub Desktop.
Baby steps towards a structured JSON editor (part 6)

Blog 2019/3/14

<- previous | index | next ->

Baby steps towards a structured JSON editor (part 6)

<- part 5

Progress report for 2019/3/14

An initial stab at "smart" layout (a basic box-filling algorithm, rather than strict horizontal or vertical layout).

Also, the code structure has been refactored to be a bit more functional (rather than every AST node resulting in an imperative paint function being called, each AST node results in a "render command", and the list of render commands is evaluated at the end of the render cycle). Representing these imperative calls as data may open up some caching opportunities.

There are still a few edge-cases where one of the inner structures will try to squeeze itself onto the same line by laying itself out vertically, which ends up looking a bit odd. Also, child nodes which come later on the line by end up being taller than their predecessors end up looking a bit odd as well.

Also, I've temporarily broken the menus and editing capabilities during the refactor.

Screen Shot 2019-03-15 at 9 39 35 AM

Screen Shot 2019-03-15 at 9 37 50 AM

Screen Shot 2019-03-15 at 9 38 37 AM

Screen Shot 2019-03-15 at 9 39 51 AM

Screen Shot 2019-03-15 at 9 40 36 AM

#!/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:
# 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.
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_render_cmds": [cmd,cmd,...]|None,
# }
# {
# "type": "boolean"
# "value": True,
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_render_cmds": [cmd,cmd,...]|None,
# }
# {
# "type": "number"
# "value": 123.456,
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_render_cmds": [cmd,cmd,...]|None,
# }
# {
# "type": "string"
# "value": "hello world",
# "children": None,
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_render_cmds": [cmd,cmd,...]|None,
# }
# {
# "type": "array"
# "value": [1,2,3],
# "children": [<ref>, <ref>, <ref>...]
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_render_cmds": [cmd,cmd,...]|None,
# }
# {
# "type": "object"
# "value": {1:2},
# "children": [<ref>, <ref>, <ref>...]
# "into": None,
# "out": <ref>|None,
# "prev": <ref>|None,
# "next": <ref>|None,
# "cached_render_cmds": [cmd,cmd,...]|None,
# }
# construct an AST from JSON.
def ast_from_json(jsn):
node = {
"value": jsn,
"children": None,
"into": None,
"out": None,
"next": None,
"prev": None,
"cached_render_cmds": 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
# insert a node into the focused AST container as a child.
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
ast_bust_cache(arr)
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
ast_bust_cache(obj)
state["focused"] = node
# bust any cached sizes of this AST node and ancestor nodes.
def ast_bust_cache(ast):
i = ast
while i is not None:
i["cached_render_cmds"] = None
i = i["out"]
# insert a node as a sibling after the focused AST 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
ast_bust_cache(arr)
if state["focused"]["type"] == "object":
assert False # TODO
state["focused"] = node
# insert a node as a sibling before the focused AST node.
def ast_insert_before(node):
if state["ast"] is None:
ast_insert_into(node)
assert False # TODO
# paint the UI.
def paint(painter, event, bounds):
# fill the canvas with white
painter.fillRect(event.rect(), QBrush(qcolor(prefs["bg_rgb"])))
# paint the json structure
(x, y) = (20, 20)
ast = state["ast"]
font = painter.font()
(size, cmds) = render_ast(ast, font, (x,y), prefs, bounds)
eval_render_cmds(cmds, painter)
print "total size:", size
# paint any context menus
# TODO get this working again
# 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",
})
# returns the size of and list of instructions to paint the AST.
def render_ast(ast, font, (x,y), prefs, bounds):
# TODO: implement cache invalidation and then enable this.
# Note: the cache invalidation might make this not worthwhile.
# if ast["cached_render_cmds"] is not None:
# return ast["cached_render_cmds"]
# else:
# cmds = _render_ast(ast, font, (x,y), prefs, bounds)
# ast["cached_render_cmds"] = cmds
# return cmds
return _render_ast(ast, font, (x,y), prefs, bounds)
# returns the size of and list of instructions to paint the AST.
def _render_ast(ast, font, (x,y), prefs, bounds):
if ast is None:
assert False
(bw, bh) = bounds
if (x > bw or y > bh):
# this is off-screen, no need to render.
size = (0,0)
cmds = []
return (size, cmds)
typ = ast["type"]
if typ == "array":
return render_array(ast, font, (x,y), prefs, bounds)
elif typ == "object":
return render_object(ast, font, (x,y), prefs, bounds)
else:
word = atom_repr(ast["value"])
(rgb, border_rgb) = rgbs_of_atom(ast, prefs)
pad = prefs["pad"]
bwidth = border_width(ast)
return render_word_in_box(word, font, (x,y), rgb, border_rgb, pad, bwidth)
# returns the size of and list of instructions to paint an array.
def render_array(ast, font, (x,y), prefs, bounds):
pad = prefs["pad"]
(bw,bh) = bounds
(_, single_line_height) = size_of_word("", font)
# render all of the child nodes.
cmds = []
(ix, iy) = (x+pad, y+pad)
lineh = 0
(maxw, maxh) = (pad+0+pad, pad+single_line_height+pad)
for iast in ast["children"]:
if (ix+pad) > bw:
# ix has run off the right edge of the screen.
# wrap to the next line.
ix = x + pad
iy += (lineh + pad)
maxh = iy-y
lineh = 0
if iy > bh:
# we have run off the bottom edge of the screen.
# no need to continue rendering.
break
((iw,ih), icmds) = render_ast(iast, font, (ix,iy), prefs, bounds)
ix += iw
if (ix+pad) > bw: # this child extends past the edge of the screen
if lineh != 0:
# this isn't the first child on this line, so try wrapping
# to the next line and re-rendering.
ix = x + pad
iy += (lineh + pad)
lineh = 0
((iw,ih), icmds) = render_ast(iast, font, (ix,iy), prefs, bounds)
ix += iw
cmds += icmds
ix += pad
lineh = max(lineh, ih)
maxw = max(maxw, ix-x)
maxh = max(maxh, (iy-y)+lineh+pad)
# render the container box.
dimensions = (x, y, maxw, maxh)
rgb = prefs["array_rgb"]
border_rgb = prefs["array_border_rgb"]
((bsize), bcmds) = render_box(dimensions, rgb, border_rgb, border_width(ast))
cmds = bcmds + cmds
return (bsize, cmds)
# returns the size of and list of instructions to paint a (JSON) object.
def render_object(ast, font, (x,y), prefs, bounds):
pad = prefs["pad"]
(bw,bh) = bounds
(_, single_line_height) = size_of_word("", font)
# render all of the key-value pairs.
cmds = []
(ix, iy) = (x+pad, y+pad)
lineh = 0
(maxw, maxh) = (pad+0+pad, pad+single_line_height+pad)
it = iter(ast["children"])
first_of_line = True
for k in it:
v = next(it)
if (ix+pad) > bw:
# ix has run off the right edge of the screen.
# wrap to the next line.
ix = x + pad
iy += (lineh + pad)
lineh = 0
first_of_line = True
maxh = max(maxh, (iy-y)+pad)
if iy > bh:
# we have run off the bottom edge of the screen.
# no need to continue rendering.
break
# TODO: DRY this out a bit.
# try rendering both the key and value on the current line.
((kw,kh), kcmds) = render_ast(k, font, (ix,iy), prefs, bounds)
((vw,vh), vcmds) = render_ast(v, font, (ix+kw+pad,iy), prefs, bounds)
ly = iy + min(kh/2.0, vh/2.0)
lx = ix+kw
(_, lcmds) = render_line((lx,ly), (lx+pad,ly), prefs["kv-link_rgb"])
# render the line joining the two boxes.
if (ix+kw+pad+vw+pad) < bw:
# we were able to fit the k-v pair on the current line.
# continue on to the next iteration of the loop.
ix += (kw+pad+vw+pad)
lineh = max(lineh, max(kh,vh))
cmds += lcmds
cmds += kcmds
cmds += vcmds
maxw = max(maxw, ix-x)
maxh = max(maxh, (iy-y)+lineh+pad)
first_of_line = False
continue
if first_of_line == False:
# this k-v pair extends past the edge of the screen and it
# isn't the first child on this line, so try wrapping to the
# next line and re-rendering the k-v pair on a single-line.
ix = x + pad
iy += (lineh + pad)
lineh = 0
first_of_line = True
((kw,kh), kcmds) = render_ast(k, font, (ix,iy), prefs, bounds)
((vw,vh), vcmds) = render_ast(v, font, (ix+kw+pad,iy), prefs, bounds)
ly = iy + min(kh/2.0, vh/2.0)
lx = ix+kw
(_, lcmds) = render_line((lx,ly), (lx+pad,ly), prefs["kv-link_rgb"])
if (ix+kw+pad+vw+pad) < bw:
# we were able to fit the k-v pair on the current line.
# continue on to the next iteration of the loop.
ix += (kw+pad+vw+pad)
lineh = max(lineh, max(kh,vh))
cmds += lcmds
cmds += kcmds
cmds += vcmds
maxw = max(maxw, ix-x)
maxh = max(maxh, (iy-y)+lineh+pad)
first_of_line = False
continue
# this didn't fit on a single-line despite being first-of-line.
# try re-rendering with the key and value arranged vertically.
ix = x + pad
lineh = 0
first_of_line = True
((kw,kh), kcmds) = render_ast(k, font, (ix,iy), prefs, bounds)
((vw,vh), vcmds) = render_ast(v, font, (ix+pad,iy+pad+kh), prefs, bounds)
lx = ix + min(kw/2.0, pad+(vw/2.0))
ly = iy+kh
(_, lcmds) = render_line((lx,ly), (lx,ly+pad), prefs["kv-link_rgb"])
ix += max(kw, pad+vw+pad)
lineh = max(lineh, kh+pad+vh)
cmds += lcmds
cmds += kcmds
cmds += vcmds
maxw = max(maxw, ix-x)
maxh = max(maxh, (iy-y)+lineh+pad)
first_of_line = False
continue
# render the container box.
dimensions = (x, y, maxw, maxh)
rgb = prefs["obj_rgb"]
border_rgb = prefs["obj_border_rgb"]
((bsize), bcmds) = render_box(dimensions, rgb, border_rgb, border_width(ast))
cmds = bcmds + cmds
return (bsize, cmds)
# returns the size of and list of instructions to paint a word in a box.
def render_word_in_box(text, font, (x, y), rgb, border_rgb, pad, border_width):
((ww,wh), wcmds) = render_word((pad+x,pad+y), text, font)
dimensions = (x, y, pad+ww+pad, pad+wh+pad)
(bsize, bcmds) = render_box(dimensions, rgb, border_rgb, border_width)
cmds = bcmds + wcmds
return (bsize, cmds)
# returns the size of and a list of instructions to paint a word.
def render_word((x,y), text, font):
(w,h) = size_of_word(text, font)
cmd = ["paint_word", (x,y,w,h), text]
return ((w,h), [cmd])
# returns the size of and a list of instructions to paint a box.
def render_box((x,y,w,h), rgb, border_rgb, border_width):
cmd = ["paint_box", (x, y, w, h), rgb, border_rgb, border_width]
return ((w,h), [cmd])
# returns the size of and a list of instructions to paint a line.
def render_line((x1,y1), (x2,y2), rgb):
size = (abs(x2-x1), abs(y2-y1))
cmd = ["paint_line", (x1,y1), (x2,y2), rgb]
return (size, [cmd])
# evaluate a list of render commands.
def eval_render_cmds(cmds, painter):
for cmd in cmds:
if cmd[0] == "paint_box":
(xywh, rgb, border_rgb, border_width) = cmd[1:]
qt_paint_box(painter, xywh, rgb, border_rgb, border_width)
elif cmd[0] == "paint_word":
(xywh, text) = cmd[1:]
qt_paint_word(painter, xywh, text)
elif cmd[0] == "paint_line":
((x1,y1), (x2,y2), rgb) = cmd[1:]
qt_paint_line(painter, (x1,y1), (x2,y2), rgb)
else:
assert False
# paint a word.
def qt_paint_word(painter, (x,y,w,h), text):
# 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 + h + text_top_fudge
painter.drawText(
x,
baseline,
text
)
# paint a box (with a border stroke and rounded corners)
def qt_paint_box(painter, (x,y,w,h), rgb, border_rgb, border_width):
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
)
# paint a line.
def qt_paint_line(painter, (x1,y1), (x2,y2), rgb):
pen = QPen()
pen.setColor(qcolor(rgb))
pen.setWidth(1)
painter.setPen(pen)
painter.drawLine(x1, y1, x2, y2)
# 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
# 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 the (rgb, border_rgb) of an atom.
def rgbs_of_atom(ast, prefs):
typ = ast["type"]
assert typ != "array"
assert typ != "object"
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 (rgb, border_rgb)
# 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):
# if "linux" in sys.platform:
# test_size_linux()
# else:
# test_size_mac()
(x, y) = (20, 20)
(w, h) = test_box(painter, (x, y))
y += (h + 10)
(w, h) = test_word_in_box(painter, (x, y))
y += (h + 10)
(w, h) = test_array([], painter, (x, y), bounds)
y += (h + 10)
(w, h) = test_array(["hello", 1, 1.0, False, None], painter, (x, y), bounds)
y += (h + 10)
(w, h) = test_array(["line", "wrap"]*20, painter, (x, y), bounds)
y += (h + 10)
(w, h) = test_array(["arrays", ["nested", "in", ["other", "arrays", ["y'all!"]]]], painter, (x, y), bounds)
y += (h + 10)
(w, h) = test_object({"key":"value"}, painter, (x, y), bounds)
y += (h + 10)
(w, h) = test_object({"key":"value", "a":1, "b":2}, painter, (x, y), bounds)
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_object(obj, painter, (x, y), bounds)
# test the sizing functions (using a font available on linux).
def fixme_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 fixme_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_box(painter, (x,y)):
(w, h) = (20, 20)
border_rgb = (0,0,255)
rgb = lighter(border_rgb)
border_width = 1
(size, cmds) = render_box((x,y,w,h), rgb, border_rgb, border_width)
eval_render_cmds(cmds, painter)
return size
def test_word_in_box(painter, (x,y)):
text = "hello"
border_rgb = (255,0,255)
rgb = lighter(border_rgb)
pad = 5
border_width = 1
font = painter.font()
(size, cmds) = render_word_in_box(text, font, (x,y), rgb, border_rgb, pad, border_width)
eval_render_cmds(cmds, painter)
return size
def test_array(arr, painter, (x,y), bounds):
ast = ast_from_json(arr)
font = painter.font()
(size, cmds) = render_array(ast, font, (x,y), prefs, bounds)
eval_render_cmds(cmds, painter)
return size
def test_object(obj, painter, (x, y), bounds):
ast = ast_from_json(obj)
font = painter.font()
(size, cmds) = render_object(ast, font, (x,y), prefs, bounds)
eval_render_cmds(cmds, painter)
return size
# 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", "lots", "and", "lots", "of"]], {"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_())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment