Blog 2019/3/3
<- previous | index | next ->
Progress report for 2019/3/3:
- Basic navigation implemented.
- Inserting new nodes is about half-way done.
| ["hello", ["world", ["these", "are"]], {"a": 1, "b": [3.14,true,null]}, "words", "in", "boxes"] |
| #!/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: | |
| # look into painting to a QPixmap, and then painting that with QPainter. | |
| # perhaps that can be a higher-performance "compositing" option? | |
| # (tradeoff less cpu for more memory usage?) | |
| # 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. | |
| 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": (<w>,<h>)|None, | |
| # } | |
| # { | |
| # "type": "boolean" | |
| # "value": True, | |
| # "children": None, | |
| # "into": None, | |
| # "out": <ref>|None, | |
| # "prev": <ref>|None, | |
| # "next": <ref>|None, | |
| # "cached_size": (<w>,<h>)|None, | |
| # } | |
| # { | |
| # "type": "number" | |
| # "value": 123.456, | |
| # "children": None, | |
| # "into": None, | |
| # "out": <ref>|None, | |
| # "prev": <ref>|None, | |
| # "next": <ref>|None, | |
| # "cached_size": (<w>,<h>)|None, | |
| # } | |
| # { | |
| # "type": "string" | |
| # "value": "hello world", | |
| # "children": None, | |
| # "into": None, | |
| # "out": <ref>|None, | |
| # "prev": <ref>|None, | |
| # "next": <ref>|None, | |
| # "cached_size": (<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": (<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": (<w>,<h>)|None, | |
| # } | |
| def ast_from_json(jsn): | |
| node = { | |
| "value": jsn, | |
| "children": None, | |
| "into": None, | |
| "out": None, | |
| "next": None, | |
| "prev": None, | |
| "cached_size": 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"] = 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"] = 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"] = 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): | |
| # fill the canvas with white | |
| painter.fillRect(event.rect(), QBrush(qcolor(prefs["bg_rgb"]))) | |
| # paint the json structure | |
| (x, y) = (20, 20) | |
| paint_ast(state["ast"], painter, (x, y), prefs) | |
| 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(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"] | |
| if state["ast"] is None: | |
| d = { | |
| "d": "insert node (into)", | |
| } | |
| else: | |
| d = {} | |
| 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(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(ast, painter, (x, y), prefs, pad=5): | |
| if ast is None: | |
| return | |
| typ = ast["type"] | |
| if typ == "array": | |
| return paint_array(ast, painter, (x, y), prefs, pad) | |
| elif typ == "object": | |
| return paint_object(ast, painter, (x, y), prefs, pad) | |
| 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, 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(ast, painter, (x, y), prefs, pad=5): | |
| # first, paint the container box | |
| font = painter.font() | |
| (w, h) = size_of_ast(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(child, font, pad) | |
| centered = False | |
| if centered: | |
| (cx, cy) = (x, y + ((h-jh)/2.0)) | |
| else: | |
| (cx, cy) = (x, y) | |
| paint_ast(child, painter, (cx, cy), prefs, pad) | |
| x += jw | |
| 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(ast, painter, (x, y), prefs, pad=5): | |
| # first, paint the container box | |
| (w, h) = size_of_ast(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((k,v), painter, (x, y), prefs, pad) | |
| x += (pw + 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((k,v), painter, (x, y), prefs, pad=5): | |
| font = painter.font() | |
| (kw, kh) = size_of_ast(k, font, pad) | |
| (vw, vh) = size_of_ast(v, font, pad) | |
| paint_ast(k, painter, (x, y), prefs, pad) | |
| paint_ast(v, painter, (x + kw + pad, y), prefs, pad) | |
| # 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) | |
| painter.drawLine( | |
| x + kw, y + (kh/2.0), | |
| x + kw + pad, y + (kh/2.0) | |
| ) | |
| 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=5, 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 ast == state["focused"]: | |
| if prefs["aa"]: | |
| return 3 | |
| else: | |
| return 2 | |
| else: | |
| return 1 | |
| # calculate the bounding box size of a json structure | |
| def size_of_ast(ast, font, pad=5): | |
| cached_size = ast["cached_size"] | |
| if cached_size is not None: | |
| return cached_size | |
| (w, h) = _size_of_ast(ast, font, pad) | |
| ast["cached_size"] = (w, h) | |
| return (w, h) | |
| def _size_of_ast(ast, font, pad=5): | |
| (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(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) | |
| # 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 | |
| (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 s 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) | |
| paint_fn(painter, event) | |
| 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"] | |
| # 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): | |
| 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(ast_from_json(""), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json(""), font, pad=1) == (2, 17) | |
| assert size_of_ast(ast_from_json("Hello, world!"), font, pad=1) == (77, 17) | |
| assert size_of_ast(ast_from_json(None), font, pad=0) == (20, 15) | |
| assert size_of_ast(ast_from_json("null"), font, pad=0) == (20, 15) | |
| assert size_of_ast(ast_from_json(True), font, pad=0) == (25, 15) | |
| assert size_of_ast(ast_from_json("true"), font, pad=0) == (25, 15) | |
| assert size_of_ast(ast_from_json(False), font, pad=0) == (29, 15) | |
| assert size_of_ast(ast_from_json("false"), font, pad=0) == (29, 15) | |
| assert size_of_ast(ast_from_json(1), font, pad=0) == (5, 15) | |
| assert size_of_ast(ast_from_json("1"), font, pad=0) == (5, 15) | |
| assert size_of_ast(ast_from_json(0.5), font, pad=0) == (18, 15) | |
| assert size_of_ast(ast_from_json("0.5"), font, pad=0) == (18, 15) | |
| assert size_of_ast(ast_from_json([]), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json([1]), font, pad=0) == (5, 15) | |
| assert size_of_ast(ast_from_json([1]), font, pad=1) == (9, 19) | |
| assert size_of_ast(ast_from_json({}), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json({1:1}), font, pad=0) == (10, 15) | |
| assert size_of_ast(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(ast_from_json(""), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json(""), font, pad=1) == (2, 17) | |
| assert size_of_ast(ast_from_json("Hello, world!"), font, pad=1) == (73, 17) | |
| assert size_of_ast(ast_from_json(None), font, pad=0) == (19, 15) | |
| assert size_of_ast(ast_from_json("null"), font, pad=0) == (19, 15) | |
| assert size_of_ast(ast_from_json(True), font, pad=0) == (23, 15) | |
| assert size_of_ast(ast_from_json("true"), font, pad=0) == (23, 15) | |
| assert size_of_ast(ast_from_json(False), font, pad=0) == (27, 15) | |
| assert size_of_ast(ast_from_json("false"), font, pad=0) == (27, 15) | |
| assert size_of_ast(ast_from_json(1), font, pad=0) == (4, 15) | |
| assert size_of_ast(ast_from_json("1"), font, pad=0) == (4, 15) | |
| assert size_of_ast(ast_from_json(0.5), font, pad=0) == (17, 15) | |
| assert size_of_ast(ast_from_json("0.5"), font, pad=0) == (17, 15) | |
| assert size_of_ast(ast_from_json([]), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json([1]), font, pad=0) == (4, 15) | |
| assert size_of_ast(ast_from_json([1]), font, pad=1) == (8, 19) | |
| assert size_of_ast(ast_from_json({}), font, pad=0) == (0, 15) | |
| assert size_of_ast(ast_from_json({1:1}), font, pad=0) == (8, 15) | |
| assert size_of_ast(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) | |
| # 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 = { | |
| "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_()) |