diff --git a/MANIFEST.in b/MANIFEST.in
index 2916540..343826d 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
recursive-include example *
-recursive-include pynetworktables2js/js *
\ No newline at end of file
+recursive-include pynetworktables2js/js *
+recursive-include pynetworktables2js/css *
\ No newline at end of file
diff --git a/example/www/tableviewer.html b/example/www/tableviewer.html
new file mode 100644
index 0000000..fd2908f
--- /dev/null
+++ b/example/www/tableviewer.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pynetworktables2js/css/tableviewer.css b/pynetworktables2js/css/tableviewer.css
new file mode 100644
index 0000000..32aa441
--- /dev/null
+++ b/pynetworktables2js/css/tableviewer.css
@@ -0,0 +1,212 @@
+
+.tableviewer {
+ position: relative;
+}
+
+.tableviewer ul, .tableviewer li {
+ list-style: none;
+}
+
+.tableviewer ul {
+ padding-left: 0;
+}
+
+.tableviewer li.boolean, .tableviewer li.number, .tableviewer li.string {
+ padding-left: 25px;
+}
+
+.tableviewer li.table, .tableviewer li.array {
+ padding-left: 19px;
+}
+
+.tableviewer > ul {
+ padding-left: 0;
+}
+
+.tableviewer button.expanded, .tableviewer button.collapsed {
+ background: none;
+ border: none;
+ outline: none;
+}
+
+.tableviewer button.expanded::before {
+ content: "▼";
+}
+
+.tableviewer button.collapsed::before {
+ content: "▶";
+}
+
+.tableviewer button.collapsed + ul, .tableviewer button.collapsed + span + ul {
+ display: none;
+}
+
+
+.tableviewer .key::after {
+ content: ":";
+ display: inline-block;
+ padding: 0 10px;
+}
+
+.tableviewer span.type {
+ text-transform: capitalize;
+ color: #aaa;
+ font-size: x-small;
+ margin-left: 13px;
+ display: inline-block;
+ bottom: 2px;
+ position: relative;
+}
+
+.tableviewer .number span.type {
+ margin-left: 0;
+}
+
+
+.tableviewer input[type=number] {
+ position: relative;
+ border: none;
+ outline: none;
+ padding-left: 10px;
+ width: 100%;
+ text-overflow: ellipsis;
+}
+
+.tableviewer li.string, .tableviewer li.number {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+}
+
+.tableviewer li.string .key, .tableviewer li.number .key {
+ display: flex;
+}
+
+.tableviewer input[type="number"]::-webkit-outer-spin-button,
+.tableviewer input[type="number"]::-webkit-inner-spin-button {
+ position: absolute;
+ left: -5px;
+}
+
+
+.tableviewer input[type=text] {
+ width: 100%;
+ border: none;
+ outline: none;
+ text-overflow: ellipsis;
+ margin: 0 3px;
+}
+
+.tableviewer .phantom-input {
+ position: absolute;
+ visibility: hidden;
+ -webkit-user-select: none;
+}
+
+.tableviewer input, .tableviewer .phantom-input {
+ font-family: "Times New Roman", Times, serif;
+ font-size: 12px;
+}
+
+.tableviewer .array ul .type {
+ display: none;
+}
+
+
+
+.tableviewer-modal {
+ position: fixed;
+ left: 0;
+ right: 0;
+ top: 100px;
+ width: 300px;
+ z-index: 200;
+ background-color: white;
+ border: 2px solid #aaa;
+ margin: 0 auto;
+ padding: 10px;
+}
+
+.tableviewer-modal .close {
+ position: absolute;
+ top: 0;
+ right: 0;
+ padding: 7px;
+ cursor: pointer;
+ line-height: 8px;
+}
+
+.tableviewer-modal .title {
+ font-weight: bold;
+ margin-bottom: 12px;
+ font-size: larger;
+}
+
+.tableviewer-modal .input-row {
+ display: flex;
+ margin-bottom: 8px;
+}
+
+.tableviewer-modal .input-row label {
+ display: inline-block;
+ margin-right: 8px;
+}
+
+.tableviewer-modal .input-row input {
+ flex: 1;
+}
+
+.tableviewer-modal-overlay {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(255,255,255,.8);
+ left: 0;
+ right: 0;
+ top: 0;
+ z-index: 100;
+}
+
+.tableviewer-modal .buttons {
+ text-align: right;
+}
+
+.tableviewer-modal .buttons button {
+ text-transform: uppercase;
+ padding: 5px 10px;
+ margin: 0 3px;
+}
+
+.tableviewer-modal .body:not([data-type=string]) .add-string {
+ display: none;
+}
+
+.tableviewer-modal .body:not([data-type=number]) .add-number {
+ display: none;
+}
+
+.tableviewer-modal .body:not([data-type=boolean]) .add-boolean {
+ display: none;
+}
+
+.tableviewer-modal .title .type-label {
+ text-transform: capitalize;
+}
+
+.tableviewer .contextmenu {
+ position: absolute;
+ border: 1px solid black;
+}
+
+.tableviewer .contextmenu span {
+ display: block;
+ width: 120px;
+ padding: 5px 10px;
+ text-align: left;
+ background: white;
+ cursor: pointer;
+}
+
+.tableviewer .contextmenu span:hover {
+ background: #aaa;
+}
\ No newline at end of file
diff --git a/pynetworktables2js/js/tableviewer.js b/pynetworktables2js/js/tableviewer.js
new file mode 100644
index 0000000..5373dc4
--- /dev/null
+++ b/pynetworktables2js/js/tableviewer.js
@@ -0,0 +1,491 @@
+"use strict";
+
+(function() {
+
+ if ($ === undefined) {
+ alert("jQuery must be downloaded+included to use tableviewer.js!");
+ return;
+ }
+
+ function Tableviewer($el) {
+ this.$el = $el.addClass('tableviewer');
+ var $root = $('')
+ .appendTo($el);
+ this.ntRoot = {
+ '' : {
+ type : 'table',
+ $el : $root.find('ul')
+ }
+ };
+
+ this._createModal();
+
+ // Expand/Collabse tables
+ $el.on('click', '.expanded, .collapsed', function(e) {
+ $(this).toggleClass('expanded collapsed');
+ $(this).siblings().find('input[type=number], input[type=text]').trigger('tableExpanded');
+ });
+
+ // Resize number input based on length of input
+ $el.on('change keyup ntUpdate tableExpanded', '[type=number]', function(e) {
+ var text = $(this).val();
+ var length = text.length;
+ var $phantomInput = $(this).next();
+ $phantomInput.text(text);
+ $(this).css('max-width', 13 + $phantomInput.width());
+ });
+
+ $el.on('change keyup ntUpdate tableExpanded', '[type=text]', function(e) {
+ var text = $(this).val();
+ var length = text.length;
+ var $phantomInput = $(this).next();
+ $phantomInput.text(text);
+ $(this).css('max-width', 10 + $phantomInput.width());
+ });
+
+ // Update NetworkTables
+ $el.on('change', '[type=checkbox]', function(e) {
+ var key = $(this).parents('[data-path]').data('path');
+ var value = $(this).prop('checked');
+ NetworkTables.putValue(key, value);
+ });
+
+ $el.on('change', '[type=text]', function(e) {
+ var key = $(this).parents('[data-path]').data('path');
+ var value = $(this).val();
+ NetworkTables.putValue(key, value);
+ });
+
+ $el.on('change', '[type=number]', function(e) {
+ var key = $(this).parents('[data-path]').data('path');
+ var value = parseFloat($(this).val());
+ NetworkTables.putValue(key, value);
+ });
+
+ var that = this;
+
+ // Show context menu for inserting values
+ this._createContextMenu();
+
+
+
+ NetworkTables.addGlobalListener(function(key, value, isNew) {
+ that._putValue(key, value, 0);
+ }, true);
+ }
+
+ Tableviewer.prototype._createContextMenu = function() {
+ var $contextMenu = $('').appendTo(this.$el);
+
+ this.contextMenu = {
+ $el : $contextMenu,
+ $addString : $contextMenu.find('.add-string'),
+ $addNumber : $contextMenu.find('.add-number'),
+ $addBoolean : $contextMenu.find('.add-boolean')
+ };
+
+ var that = this;
+
+ var key = '';
+
+ this.$el.on('contextmenu', '.table, .table > *', function(e) {
+ var $target = $(e.target);
+ var $table = $target.hasClass('table') ? $target : $target.parent();
+ if($table.hasClass('table')) {
+ e.preventDefault();
+ key = $table.attr('data-path');
+ that._openContextMenu(e.pageX - that.$el.offset().left, e.pageY - that.$el.offset().top);
+ } else {
+ that._closeContextMenu();
+ }
+ });
+
+ this.contextMenu.$addString.on('click', function(e) {
+ that._showModal(key + '/', 'string');
+ });
+
+ this.contextMenu.$addNumber.on('click', function(e) {
+ that._showModal(key + '/', 'number');
+ });
+
+ this.contextMenu.$addBoolean.on('click', function(e) {
+ that._showModal(key + '/', 'boolean');
+ });
+
+ this.$el.on('click', function(e) {
+ that._closeContextMenu();
+ });
+ };
+
+ Tableviewer.prototype._openContextMenu = function(x, y) {
+ this.contextMenu.$el.css({
+ 'display' : 'block',
+ 'left' : x,
+ 'top' : y
+ });
+
+ };
+
+ Tableviewer.prototype._closeContextMenu = function() {
+ this.contextMenu.$el.css('display', 'none');
+ };
+
+
+ Tableviewer.prototype._createModal = function() {
+
+ var $modal = $('' +
+ '
×
' +
+ '
Put Value
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
').appendTo('body');
+
+ var $modalOverlay = $('').appendTo('body');
+
+ this.modal = {
+ $el : $modal,
+ $body : $modal.find('.body'),
+ $typeLabel : $modal.find('.title .type-label'),
+ $close : $modal.find('.close'),
+ $key : $modal.find('.key input'),
+ $parentPath : $modal.find('.parent-path'),
+ $addString : $modal.find('.add-string'),
+ $addStringInput : $modal.find('.add-string input'),
+ $addNumber : $modal.find('.add-number'),
+ $addNumberInput : $modal.find('.add-number input'),
+ $addBoolean : $modal.find('.add-boolean'),
+ $addBooleanInput : $modal.find('.add-boolean input'),
+ $okButton : $modal.find('.buttons .ok'),
+ $cancelButton : $modal.find('.buttons .cancel'),
+ $overlay : $modalOverlay
+ };
+
+
+ // Add events
+ var that = this;
+
+ var closeSelectors = [
+ '.tableviewer-modal .close',
+ '.tableviewer-modal .buttons .cancel',
+ '.tableviewer-modal-overlay'
+ ];
+
+ $('body').on('click', closeSelectors.join(','), function(e) {
+ that._hideModal();
+ });
+
+ // Put element into NetworkTables
+ this.modal.$okButton.on('click', function(e) {
+ var type = that.modal.$body.attr('data-type');
+ var key = that.modal.$parentPath.val() + that.modal.$key.val();
+ var value;
+
+ if(type === 'string') {
+ value = that.modal.$addStringInput.val();
+ } else if(type ==='number') {
+ value = parseFloat(that.modal.$addNumberInput.val());
+ } else if(type === 'boolean') {
+ value = that.modal.$addBooleanInput.prop('checked');
+ }
+
+ NetworkTables.putValue(key, value);
+
+ that._hideModal();
+ });
+
+ };
+
+ Tableviewer.prototype._showModal = function(parentPath, type) {
+
+ type = ['string', 'number', 'boolean', 'array'].indexOf(type) > -1 ? type : 'string';
+
+ this.modal.$parentPath.val(parentPath ? parentPath : '');
+ this.modal.$key.val('');
+ this.modal.$body.attr('data-type', type);
+ this.modal.$typeLabel.text(type);
+
+ this.modal.$el.css('display', 'block');
+ this.modal.$overlay.css('display', 'block');
+ };
+
+ Tableviewer.prototype._hideModal = function() {
+ this.modal.$el.css('display', 'none');
+ this.modal.$overlay.css('display', 'none');
+ };
+
+ Tableviewer.prototype.printTable = function() {
+ console.log(this.ntRoot);
+ };
+
+ Tableviewer.prototype._putValue = function(key, value) {
+ var steps = key.split('/').filter(function(s) { return s.length > 0; });
+
+ var parentPath = '';
+
+ for(var i = 0 ; i < steps.length - 1; i++) {
+ if(!this._createTableNode(parentPath, steps[i])) {
+ return;
+ }
+ parentPath += '/' + steps[i];
+ }
+
+ var type = typeof(value);
+ var step = steps[steps.length - 1];
+ var path = parentPath + '/' + step;
+
+ // Create node if it doesn't exist
+ if(!this.ntRoot[path]) {
+ if(type === 'object') {
+ this._createArrayNode(parentPath, step, value);
+ } else if(type === 'boolean') {
+ this._createBooleanNode(parentPath, step, value);
+ } else if(type === 'number') {
+ this._createNumberNode(parentPath, step, value);
+ } else {
+ this._createStringNode(parentPath, step, value);
+ }
+ }
+
+ // Update the value
+ if(type === 'object') {
+ this._updateArray(path, value);
+ } else if(type === 'boolean') {
+ this._updateBoolean(path, value);
+ } else if(type === 'number') {
+ this._updateNumber(path, value);
+ } else {
+ this._updateString(path, value);
+ }
+ };
+
+ Tableviewer.prototype._updateArray = function(path, values) {
+ // this.ntRoot[pathTraveled].$type.text(value.type + '[' + value.length + ']');
+ var type = 'array';
+
+ for(var i = 0; i < values.length; i++) {
+ var value = values[i];
+ type = typeof(value);
+
+ // Create array elements
+ if(!this.ntRoot[path + '/' + i]) {
+ if(type === 'boolean') {
+ this._createBooleanNode(path, i, value);
+ } else if(type === 'number') {
+ this._createNumberNode(path, i, value);
+ } else {
+ this._createStringNode(path, i, value);
+ }
+
+ // Disable the input
+ this.ntRoot[path + '/' + i].$value.prop('disabled', true);
+ }
+
+ // Update the value
+ if(type === 'boolean') {
+ this._updateBoolean(path + '/' + i, value);
+ } else if(type === 'number') {
+ this._updateNumber(path + '/' + i, value);
+ } else {
+ this._updateString(path + '/' + i, value);
+ }
+
+ }
+
+ // Remove array elements
+ var $items = this.ntRoot[path].$el.find('li');
+ for(var i = values.length; i < $items.length; i++) {
+ delete this.ntRoot[path + '/' + i];
+ $items[i].remove();
+ }
+
+ // Set the type label
+ this.ntRoot[path].$type.text(type + '[' + values.length + ']');
+ };
+
+ Tableviewer.prototype._updateBoolean = function(path, value) {
+ this.ntRoot[path].$value.prop('checked', value);
+ };
+
+ Tableviewer.prototype._updateNumber = function(path, value) {
+ this.ntRoot[path].$value.val(value);
+ this.ntRoot[path].$value.trigger('ntUpdate');
+ };
+
+ Tableviewer.prototype._updateString = function(path, value) {
+ this.ntRoot[path].$value.val(value);
+ this.ntRoot[path].$value.trigger('ntUpdate');
+ };
+
+ Tableviewer.prototype._createTableNode = function(parentPath, step) {
+
+ var path = parentPath + '/' + step;
+
+ // If path exists and is not a table then it's a value. The value being added/updated is invalid
+ if(this.ntRoot[path] && this.ntRoot[path].type !== 'table') {
+ return false;
+ }
+
+ if(!this.ntRoot[path]) {
+ // Otherwise the path doesn't exist so add
+ var $el = $('' + step + '');
+
+ this.ntRoot[path] = {
+ type : 'table',
+ $el : $el.find('ul'),
+ $root : $el,
+ parentPath : parentPath
+ }
+
+ this._addNodeToDom(path);
+ }
+
+ return true;
+ };
+
+ Tableviewer.prototype._createArrayNode = function(parentPath, step, value) {
+ var path = parentPath + '/' + step;
+ //var typeLabel = value.type + '[' + value.length + ']';
+ var $el = $('' +
+ '' +
+ step +
+ 'Array[' + value.length + ']' +
+ '' +
+ '');
+
+ this.ntRoot[path] = {
+ type : 'array',
+ $el : $el.find('ul'),
+ $root : $el,
+ $type : $el.find('.type'),
+ parentPath : parentPath
+ };
+
+ this._addNodeToDom(path);
+ };
+
+ Tableviewer.prototype._createBooleanNode = function(parentPath, step, value) {
+ var path = parentPath + '/' + step;
+ var $el = $('')
+ .append('' + step + '')
+ .append('')
+ .append('boolean');
+
+ this.ntRoot[path] = {
+ type : 'boolean',
+ $el : $el,
+ $root : $el,
+ $key : $el.find('.key'),
+ $value : $el.find('.value'),
+ $type : $el.find('.type'),
+ parentPath : parentPath
+ };
+
+ this._addNodeToDom(path);
+ };
+
+ Tableviewer.prototype._createNumberNode = function(parentPath, step, value) {
+ var path = parentPath + '/' + step;
+ var $el = $('')
+ .append('' + step + '')
+ .append('')
+ .append('')
+ .append('number');
+
+ this.ntRoot[path] = {
+ type : 'number',
+ $el : $el,
+ $root: $el,
+ $key : $el.find('.key'),
+ $value : $el.find('.value'),
+ $phantomInput : $el.find('.phantom-input'),
+ $type : $el.find('.type'),
+ parentPath : parentPath
+ };
+
+ this._addNodeToDom(path);
+ };
+
+ Tableviewer.prototype._createStringNode = function(parentPath, step, value) {
+ var path = parentPath + '/' + step;
+ var $el = $('')
+ .append('' + step + '')
+ .append('“”')
+ .append('')
+ .append('string');
+
+ this.ntRoot[path] = {
+ type : 'string',
+ $el : $el,
+ $root : $el,
+ $key : $el.find('.key'),
+ $value : $el.find('.value'),
+ $phantomInput : $el.find('.phantom-input'),
+ $type : $el.find('.type'),
+ parentPath : parentPath
+ };
+
+ this._addNodeToDom(path);
+ };
+
+ Tableviewer.prototype._getChildPaths = function(parentPath) {
+ var childPaths = [];
+ for(var path in this.ntRoot) {
+ if(this.ntRoot[path].parentPath === parentPath) {
+ childPaths.push(path);
+ }
+ }
+ return childPaths.sort();
+ };
+
+ Tableviewer.prototype._getSiblingPaths = function(path) {
+ return this._getChildPaths(this.ntRoot[path].parentPath);
+ };
+
+ Tableviewer.prototype._addNodeToDom = function(path) {
+ var siblingPaths = this._getSiblingPaths(path);
+ var nodeIndex = siblingPaths.indexOf(path);
+ var $el = this.ntRoot[path].$root;
+ var parentPath = this.ntRoot[path].parentPath;
+ //console.log(siblingPaths);
+ if(nodeIndex <= 0) {
+ $el.prependTo(this.ntRoot[parentPath].$el);
+ } else {
+ var beforePath = siblingPaths[nodeIndex - 1];
+ var $before = this.ntRoot[beforePath].$root;
+ $before.after($el);
+ }
+ };
+
+ // jQuery plugin
+ $.fn.extend({
+ tableviewer: function() {
+ var args = Array.prototype.slice.call(arguments);
+ var method = args.shift();
+ var methodArgs = args;
+
+ return this.each(function() {
+ var $el = $(this);
+ var tableviewer = $el.data('tableviewer');
+
+ // Initialize tableviewer
+ if(typeof(tableviewer) === 'undefined') {
+ $el.data('tableviewer', new Tableviewer($el));
+ // Call tableviewer method
+ } else {
+ Tableviewer.prototype[method].apply(tableviewer, methodArgs);
+ }
+ });
+ }
+ });
+
+})();
\ No newline at end of file
diff --git a/pynetworktables2js/tornado_handlers.py b/pynetworktables2js/tornado_handlers.py
index 2014a02..c1eca3d 100644
--- a/pynetworktables2js/tornado_handlers.py
+++ b/pynetworktables2js/tornado_handlers.py
@@ -77,10 +77,12 @@ def get_handlers():
])
'''
+ css_path_opts = {'path': abspath(join(dirname(__file__), 'css'))}
js_path_opts = {'path': abspath(join(dirname(__file__), 'js'))}
return [
('/networktables/ws', NetworkTablesWebSocket),
- ('/networktables/(.*)', NonCachingStaticFileHandler, js_path_opts),
+ ('/networktables/css/(.*)', NonCachingStaticFileHandler, css_path_opts),
+ ('/networktables/(.*)', NonCachingStaticFileHandler, js_path_opts)
]