|
#!/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_()) |