/*
    lists.js
    list data structure and GUI for SNAP!
    written by Jens Mönig and Brian Harvey
    jens@moenig.org, bh@cs.berkeley.edu
    Copyright (C) 2021 by Jens Mönig and Brian Harvey
    This file is part of Snap!.
    Snap! is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as
    published by the Free Software Foundation, either version 3 of
    the License, or (at your option) any later version.
    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.
    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see .
    prerequisites:
    --------------
    needs morphic.js, widgets.js and gui.js
    I. hierarchy
    -------------
    the following tree lists all constructors hierarchically,
    indentation indicating inheritance. Refer to this list to get a
    contextual overview:
    List
    BoxMorph*
        ListWatcherMorph
    * from Morphic.js
    II. toc
    -------
    the following list shows the order in which all constructors are
    defined. Use this list to locate code in this document:
    List
    ListWatcherMorph
*/
// Global settings /////////////////////////////////////////////////////
/*global modules, BoxMorph, HandleMorph, PushButtonMorph, SyntaxElementMorph,
Color, Point, WatcherMorph, StringMorph, SpriteMorph, ScrollFrameMorph, isNil,
CellMorph, ArrowMorph, MenuMorph, snapEquals, localize, isString, IDE_Morph,
MorphicPreferences, TableDialogMorph, SpriteBubbleMorph, SpeechBubbleMorph,
TableFrameMorph, TableMorph, Variable, isSnapObject, Costume, contains, detect,
ZERO, WHITE*/
modules.lists = '2021-March-15';
var List;
var ListWatcherMorph;
// List ////////////////////////////////////////////////////////////////
/*
    I am a dynamic array data structure for SNAP!
    My index starts with 1
    I am a "smart" hybrid list, because I can be used as both a linked
    list and as a dynamic array
    public interface:
    setters (linked):
    -----------------
    cons                    - answer a new list with the given item in front
    cdr                     - answer all but the first element
    setters (arrayed):
    ------------------
    add(element, index)     - insert the element before the given slot,
    put(element, index)     - overwrite the element at the given slot
    remove(index)           - remove the given slot, shortening the list
    clear()                 - remove all elements
    getters (all hybrid):
    ---------------------
    length()                - number of slots
    at(index)               - element present in specified slot
    contains(element)       - 
    isEmpty()               - 
    indexOf(element)        - index of element's first occurrence, 0 if none
    conversion:
    -----------
    asArray()               - answer me as JavaScript array, convert to arrayed
    itemsArray()            - answer a JavaScript array shallow copy of myself
    asText()                - answer my elements (recursively) concatenated
    asCSV()                 - answer a csv-formatted String of myself
    asJSON()                - answer a json-formatted String of myself
    utility:
    ---------
    map(callback)           - answer an arrayed copy applying a JS func to all
    deepMap(callback)       - same as map for all atomic elements
    matrix ops (arrayed)
    --------------------
    size()                  - count the number of all atomic elements
    rank()                  - answer the number of my dimensions
    shape()                 - answer a list of the max size for each dimension
    width()                 - ansswer the maximum length of my columns, if any
    flatten()               - answer a concatenated list of columns and atoms
    ravel()                 - answer a flat list of all atoms in all sublists
    columns()               - answer a 2D list with rows turned into columns
    transpose()             - answer the matrix transpose over all dimensions
    reversed()              - answer a reversed shallow copy of the list
    reshape()               - answer a new list formatted to the given dimensions.
    crossproduct()          - answer a new list of all possible sublist tuples
    query()                 - answer a part of a list or multidimensionel struct
    slice()                 - same as query() turning negative indices into slices
*/
// List instance creation:
function List(array) {
    this.type = null; // for UI lists, such as costumes, sounds, sprites
    this.contents = array || [];
    this.first = null;
    this.rest = null;
    this.isLinked = false;
    this.lastChanged = Date.now();
}
// List global preferences
List.prototype.enableTables = true;
// List printing
List.prototype.toString = function () {
    return 'a List [' + this.length() + ' elements]';
};
// List updating:
List.prototype.changed = function () {
    this.lastChanged = Date.now();
};
// Linked List ops:
List.prototype.cons = function (car, cdr) {
    var answer = new List();
    if (!(cdr instanceof List || isNil(cdr))) {
        throw new Error("cdr isn't a list: " + cdr);
    }
    answer.first = isNil(car) ? null : car;
    answer.rest = cdr || null;
    answer.isLinked = true;
    return answer;
};
List.prototype.cdr = function () {
    var result, i;
    if (this.isLinked) {
        return this.rest || new List();
    }
    if (this.contents.length < 2) {
        return new List();
    }
    result = new List();
    for (i = this.contents.length; i > 1; i -= 1) {
        result = this.cons(this.at(i), result);
    }
    return result;
};
// List array setters:
List.prototype.add = function (element, index) {
/*
    insert the element before the given slot index,
    if no index is specifed, append the element
*/
    var idx = Math.round(+index) || this.length() + 1,
        obj = isNil(element) ? null : element;
    this.becomeArray();
    this.contents.splice(idx - 1, 0, obj);
    this.changed();
};
List.prototype.put = function (element, index) {
    // exchange the element at the given slot for another
    var idx = Math.round(+index) || 0,
        data = element === 0 ? 0
            : element === false ? false
                    : element || null;
    this.becomeArray();
    if (idx < 1 || idx > this.contents.length) {
        return;
    }
    this.contents[idx - 1] = data;
    this.changed();
};
List.prototype.remove = function (index) {
    // remove the given slot, shortening the list
    this.becomeArray();
    this.contents.splice(Math.round(+index || 0) - 1, 1);
    this.changed();
};
List.prototype.clear = function () {
    this.contents = [];
    this.first = null;
    this.rest = null;
    this.isLinked = false;
    this.changed();
};
// List utilities
List.prototype.map = function (callback) {
    return new List(
        this.itemsArray().map(callback)
    );
};
List.prototype.deepMap = function (callback) {
    return this.map(item => item instanceof List ?
        item.deepMap(callback)
            : callback(item));
};
// List getters (all hybrid):
List.prototype.length = function () {
    if (this.isLinked) {
        var pair = this,
            result = 0;
        while (pair && pair.isLinked) {
            result += 1;
            pair = pair.rest;
        }
        return result + (pair ? pair.contents.length : 0);
    }
    return this.contents.length;
};
List.prototype.at = function (index) {
    var value,
        idx = Math.round(+index || 0),
        pair = this;
    while (pair.isLinked) {
        if (idx > 1) {
            pair = pair.rest;
            idx -= 1;
        } else {
            return pair.first;
        }
    }
    value = pair.contents[idx - 1];
    return isNil(value) ? '' : value;
};
List.prototype.contains = function (element) {
    var pair = this;
    while (pair.isLinked) {
        if (snapEquals(pair.first, element)) {
            return true;
        }
        pair = pair.rest;
    }
    // in case I'm arrayed
    return pair.contents.some(any => snapEquals(any, element));
};
List.prototype.isEmpty = function () {
    if (this.isLinked) {
        return isNil(this.first);
    }
    return !this.contents.length;
};
List.prototype.indexOf = function (element) {
    var pair = this,
        idx = 1,
        i, len;
    while (pair.isLinked) {
        if (snapEquals(pair.first, element)) {
            return idx;
        }
        pair = pair.rest;
        idx += 1;
    }
    // in case I'm arrayed
    len = pair.contents.length;
    for (i = 0; i < len; i += 1) {
        if (snapEquals(pair.contents[i], element)) {
            return idx + i;
        }
    }
    return 0;
};
// List table (2D) accessing (for table morph widget):
List.prototype.isTable = function () {
    return this.enableTables && (this.length() > 100 || this.cols() > 1);
};
List.prototype.get = function (col, row) {
    var r, len, cols;
    if (!col) {
        if (!row) {return [this.length()]; }
        if (row > this.rows()) {return null; }
        return this.rowName(row);
    } else if (!row) {
        if (this.cols() === 1) {return localize('items'); }
        return this.colName(col);
    }
    r = this.at(row);
    // encode "orphaned" as arrays and overshooting ones as Variables
    if (r instanceof List) {
        len = r.length();
        cols = this.cols();
        if (col > len) {
            return null;
        } else if (cols === 1 && len > 1) {
            return [r];
        } else if (col >= cols && len > cols) { // overshooting
            return new Variable(r.at(col));
        }
        return r.at(col);
    }
    if (col === 1 && row <= this.rows()) {
        return [r];
    }
    return null;
};
List.prototype.rows = function () {
    return this.length();
};
List.prototype.cols = function () {
    // scan the first 10 rows for the maximun width
    var len = Math.min(10, this.length()),
        count = 1,
        r, i;
    for (i = 1; i <= len; i += 1) {
        r = this.at(i);
        if (r instanceof List) {
            count = Math.max(count, r.length());
        }
    }
    return count;
};
List.prototype.colName = function (col) {
    if (col > this.cols()) {return null; }
    return String.fromCharCode(64 + ((col % 26) || 26)).repeat(
        Math.floor((col - 1) / 26) + 1
    );
};
List.prototype.rowName = function (row) {
    return row;
};
List.prototype.columnNames = function () {
    return [];
};
List.prototype.version = function (startRow, rows, startCol, cols) {
    var l = Math.min(startRow + rows, this.length()),
        v = this.lastChanged,
        r,
        i;
    for (i = startRow; i <= l; i += 1) {
        r = this.at(i);
        if (r instanceof Costume) {
            v = Math.max(v, r.version);
        } else if (r instanceof List) {
            v = Math.max(v, r.version(startCol, cols));
        } else {
            v = Math.max(v, r.lastChanged ? r.lastChanged : 0);
        }
    }
    return v;
};
// List matrix operations and utilities - very experimental
List.prototype.query = function (indices) {
    // assumes a 2D argument list where each slot represents
    // the indices to select from a dimension
    // e.g. [rows, columns, planes]
    var first, select;
    if (indices.isEmpty()) {
        return this.map(e => e);
    }
    if (indices.rank() === 1) {
        return indices.map(i => this.at(i));
    }
    first = indices.at(1);
    if (first instanceof List) {
        select = first.isEmpty() ?
            this.range(1, this.length())
                : first;
    } else {
        select = new List([first]);
    }
    return select.map(i => this.at(i)).map(
            e => e instanceof List? e.query(indices.cdr()) : e
    );
};
List.prototype.slice = function (indices) {
    // EXPERIMENTAL - NOT IN USE.
    // assumes a 2D argument list where each slot represents
    // the indices to select from a dimension
    // e.g. [rows, columns, planes]
    //
    // slicing spec:
    // positive integers represent single indices,
    // negative integes and zero represent slices starting at the
    // index following the last specified positive integer up to / down to
    // my length offset by the negative / zero integer
    //
    // Currently unused and NOT part of the ITEM OF primitivie in
    // production Snap, because negative indices are used in exercises and
    // curriculum activities relying on them returning zero / empty values
    // rather than wrapped ones, e.g. when creating a "reverb" or "echo"
    // effect from sound samples.
    //
    // to be revisited in the future, perhaps as seperate primitive.
    // -Jens
    var first, select;
    if (indices.isEmpty()) {
        return this.map(e => e);
    }
    if (indices.rank() === 1) {
        return this.rangify(indices).map(i => this.at(i));
    }
    first = indices.at(1);
    if (first instanceof List) {
        select = first.isEmpty() ?
            this.range(1, this.length())
                : this.rangify(first);
    } else {
        select = this.rangify(new List([first]));
    }
    return select.map(i => this.at(i)).map(
            e => e instanceof List? e.slice(indices.cdr()) : e
    );
};
List.prototype.rangify = function (indices) {
    // EXPERIMENTAL - NOT IN USE.
    // private - answer a list of indices with zero and negative integers
    // replaced by slices of consecutive indices ranging from the next
    // index following the last specified single index up / down to
    // my length offset by the negative / zero index.
    var result = [],
        len = this.length(),
        current = 0,
        start, end;
    indices.itemsArray().forEach(idx => {
        idx = +idx;
        if (idx > 0) {
            result.push(idx);
            current = idx;
        } else {
            end = len + idx;
            if (current !== end) {
                start = current < end ? current + 1 : current - 1;
                this.range(start, end).itemsArray().forEach(
                    num => result.push(num)
                );
            }
        }
    });
    return new List(result);
};
List.prototype.range = function (start, end) {
    // private - answer a list of integers from start to the given end
    return new List([...Array(Math.abs(end - start) + 1)].map((e, i) =>
        start < end ? start + i : start - i
    ));
};
List.prototype.items = function (indices) {
    // deprecated. Same as query() above, except in reverse order.
    // e.g. [planes, columns, rows]
    // This. This is it. The pinnacle of my programmer's life.
    // After days of roaming about my house and garden,
    // of taking showers and rummaging through the fridge,
    // of strumming the charango and the five ukuleles
    // sitting next to my laptop on my desk,
    // and of letting my mind wander far and wide,
    // to come up with this design, always thinking
    // "What would Brian do?".
    // And look, Ma, it's turned out all beautiful! -jens
    return makeSelector(
        this.rank(),
        indices.cdr(),
        makeLeafSelector(indices.at(1))
    )(this);
    function makeSelector(rank, indices, next) {
        if (rank === 1) {
            return next;
        }
        return makeSelector(
            rank - 1,
            indices.cdr(),
            makeBranch(
                indices.at(1) || new List(),
                next
            )
        );
    }
    function makeBranch(indices, next) {
        return function(data) {
            if (indices.isEmpty()) {
                return data.map(item => next(item));
            }
            return indices.map(idx => next(data.at(idx)));
        };
    }
    function makeLeafSelector(indices) {
        return function (data) {
            if (indices.isEmpty()) {
                return data.map(item => item);
            }
            return indices.map(idx => data.at(idx));
        };
    }
};
List.prototype.size = function () {
    // count the number of all atomic elements
    var count = 0;
    this.deepMap(() => count += 1);
    return count;
};
List.prototype.ravel = function () {
    // answer a flat list containing all atomic elements in all sublists
    var all = [];
    this.deepMap(atom => all.push(atom));
    return new List(all);
};
List.prototype.rank = function () {
    // answer the number of my dimensions
    // traverse the whole structure for irregularly shaped nested lists
    var rank = 1,
        len = this.length(),
        item, i;
    for (i = 1; i <= len; i += 1) {
        item = this.at(i);
        if (item instanceof List) {
            rank = Math.max(rank, 1 + item.rank());
        }
    }
    return rank;
};
List.prototype.shape = function () {
    // answer a list of the maximum size for each dimension
    var dim,
        rank = this.rank(),
        shp = new List([this.length()]),
        max, items, i, len;
    for (dim = 2; dim <= rank; dim += 1) {
        max = 0;
        items = this.getDimension(dim);
        len = items.length();
        for (i = 1; i <= len; i += 1) {
            max = Math.max(max, items.at(i).length());
        }
        shp.add(max);
    }
    return shp;
};
List.prototype.getDimension = function (rank = 0) {
    // private - answer a list of all elements of the specified rank
    if (rank < 1) {return new List(); }
    if (rank === 1) {
        return this.map(item => item);
    }
    if (rank === 2) {
        return new List(this.itemsArray().filter(item => item instanceof List));
    }
    return new List(
        this.getDimension(rank - 1).flatten().itemsArray().filter(
            value => value instanceof List
        )
    );
};
List.prototype.width = function () {
    // private - answer the maximum length of my direct sub-lists (columns),
    // if any
    var i, item,
        width = 0,
        len = this.length();
    for (i = 1; i <= len; i += 1) {
        item = this.at(i);
        width = Math.max(width, item instanceof List ? item.length() : 0);
    }
    return width;
};
List.prototype.flatten = function () {
    // answer a new list that concatenates my direct sublists (columns)
    // and atomic elements
    var flat = [];
    this.itemsArray().forEach(item => {
        if (item instanceof List) {
            item.itemsArray().forEach(value => flat.push(value));
        } else {
            flat.push(item);
        }
    });
    return new List(flat);
};
List.prototype.transpose = function () {
    if (this.rank() > 2) {
        return this.strideTranspose();
    }
    return this.columns();
};
List.prototype.columns = function () {
    // answer a 2D list where each item has turned into a row,
    // convert atomic items into lists,
    // fill ragged columns with atomic values, if any, or empty cells
    var col, src, i,
        width = Math.max(this.width(), 1),
        table = [];
    // convert atomic items into rows
    src = this.map(row =>
        row instanceof List ? row : new List(new Array(width).fill(row))
    );
    // define the mapper function
    col = (tab, c) => tab.map(row => row.at(c));
    // create the transform
    for (i = 1; i <= width; i += 1) {
        table.push(col(src, i));
    }
    return new List(table);
};
List.prototype.reshape = function (dimensions) {
    // answer a new list formatted to fit the given dimensions.
    // truncate excess elements, if any.
    // pad with (repetitions of) existing elements
    var src = this.ravel().itemsArray(),
        size = dimensions.isEmpty() ? 0
            : dimensions.itemsArray().reduce((a, b) => a * b),
        i = 0,
        trg;
    // make sure the items count matches the specified target dimensions
    if (size < src.length) {
        // truncate excess elements from the source
        trg = src.slice(0, size);
    } else {
        if (size > src.length && dimensions.length() > 2 && size > 1000000) {
            // limit usage of reshape to grow to a maximum size of 1MM rows
            // in higher dimensions to prevent accidental dimension overflow
            throw new Error('exceeding the size limit for reshape');
        }
        // pad the source by repeating its existing elements
        trg = src.slice();
        while (trg.length < size) {
            if (i >= src.length) {
                i = 0;
            }
            trg.push(src[i]);
            i += 1;
        }
    }
    // fold the doctored source into the specified dimensions
    return new List(trg).folded(dimensions);
};
List.prototype.folded = function (dimensions) {
    // private
    var len = dimensions.length(),
        trg = this,
        i;
    if (len < 2) {
        return this.map(e => e);
    }
    for (i = len; i > 1; i -= 1) {
        trg = trg.asChunksOf(dimensions.at(i));
    }
    return trg;
};
List.prototype.asChunksOf = function (size) {
    // private
    var trg = new List(),
        len = this.length(),
        sub, i;
    for (i = 0; i < len; i += 1) {
        if (i % size === 0) {
            sub = new List();
            trg.add(sub);
        }
        sub.add(this.at(i + 1));
    }
    return trg;
};
List.prototype.crossproduct = function () {
    // expects myself to be a list of lists.
    // answers a new list of all possible tuples
    // with one item from each of my sublists
    var result = new List(),
        len = this.length(),
        lengths = this.map(each => each.length()),
        size = lengths.itemsArray().reduce((a, b) => a * b),
        i, k, row, factor;
    // limit crossproduct to a maximum size of 1MM rows
    // to guard against accidental memory overflows in Chrome
    if (size > 1000000) {
        throw new Error('exceeding the size limit for cross product');
    }
    for (i = 1; i <= size; i += 1) {
        row = new List();
        factor = 1;
        for (k = 1; k <= len; k += 1) {
            row.add(
                this.at(k).at(
                    ((Math.ceil(i / ((size / lengths.at(k)) * factor)) - 1) %
                        lengths.at(k)) + 1
                )
            );
            factor /= lengths.at(k);
        }
        result.add(row);
    }
    return result;
};
List.prototype.strideTranspose = function () {
    // private - transpose a matric of rank > 2
    // thanks, Brian!
    var oldShape = this.shape(),
        newShape = oldShape.reversed(),
        oldSizes = new List([1]),
        newSizes = new List([1]),
        oldFlat = this.ravel(),
        newFlat = new List(new Array(oldFlat.length())),
        product = 1,
        i;
    function newIndex(old, os, ns) {
        var foo;
        if (os.isEmpty()) {
            return 0;
        }
        foo = Math.floor(old / ns.at(1));
        return foo * os.at(1) + newIndex(
            old % ns.at(1),
            os.cdr(),
            ns.cdr()
        );
    }
    for (i = oldShape.length(); i > 1; i -= 1) {
        product *= oldShape.at(i);
        newSizes.add(product, 1);
    }
    product = 1;
    for (i = 1; i <= oldShape.length() - 1; i += 1) {
        product *= oldShape.at(i);
        oldSizes.add(product);
    }
    for (i = 1; i <= oldFlat.length(); i += 1) {
        newFlat.put(
            oldFlat.at(i),
            newIndex(i-1, oldSizes, newSizes)+1
        );
    }
    return newFlat.reshape(newShape);
};
List.prototype.reversed = function () {
    // only for arrayed lists
    return new List(this.itemsArray().slice().reverse());
};
// List conversion:
List.prototype.asArray = function () {
    // for use in the evaluator
    this.becomeArray();
    return this.contents;
};
List.prototype.itemsArray = function () {
    // answer an array containing my elements
    // don't convert linked lists to arrays
    if (this.isLinked) {
        var next = this,
            result = [],
            i;
        while (next && next.isLinked) {
            result.push(next.first);
            next = next.rest;
        }
        if (next) {
            for (i = 1; i <= next.contents.length; i += 1) {
                result.push(next.at(i));
            }
        }
        return result;
    }
    return this.contents;
};
List.prototype.asText = function () {
    var result = '',
        length,
        element,
        pair = this,
        i;
    while (pair.isLinked) {
        element = pair.first;
        if (element instanceof List) {
            result = result.concat(element.asText());
        } else {
            element = isNil(element) ? '' : element.toString();
            result = result.concat(element);
        }
        pair = pair.rest;
    }
    length = pair.length();
    for (i = 1; i <= length; i += 1) {
        element = pair.at(i);
        if (element instanceof List) {
            result = result.concat(element.asText());
        } else {
            element = isNil(element) ? '' : element.toString();
            result = result.concat(element);
        }
    }
    return result;
};
List.prototype.becomeArray = function () {
    if (this.isLinked) {
        this.contents = this.itemsArray();
        this.isLinked = false;
        this.first = null;
        this.rest = null;
    }
};
List.prototype.becomeLinked = function () {
    var i, stop, tail = this;
    if (!this.isLinked) {
        stop = this.length();
        for (i = 0; i < stop; i += 1) {
            tail.first = this.contents[i];
            if (i < stop) {
                tail.rest = new List();
                tail.isLinked = true;
                tail = tail.rest;
            }
        }
        this.contents = [];
        this.isLinked = true;
    }
};
List.prototype.asCSV = function () {
    // RFC 4180
    // Caution, no error catching!
    // this method assumes that the list.canBeCSV()
    var items = this.itemsArray(),
        rows = [];
    
    function encodeCell(atomicValue) {
        var string = isNil(atomicValue) ? '' : atomicValue.toString(),
            cell;
        if (string.indexOf('\"') ===  -1 &&
                (string.indexOf('\n') === -1) &&
                (string.indexOf('\,') === -1)) {
            return string;
        }
        cell = ['\"'];
        string.split('').forEach(letter => {
            cell.push(letter);
            if (letter === '\"') {
                cell.push(letter);
            }
        });
        cell.push('\"');
        return cell.join('');
    }
    if (items.some(any => any instanceof List)) {
        // 2-dimensional table
        items.forEach(item => {
            if (item instanceof List) {
                rows.push(item.itemsArray().map(encodeCell).join(','));
            } else {
                rows.push(encodeCell(item));
            }
        });
        return rows.join('\n');
    }
    // single row
    return items.map(encodeCell).join(',');
};
List.prototype.asJSON = function (guessObjects) {
    // Caution, no error catching!
    // this method assumes that the list.canBeJSON()
    function objectify(list, guessObjects) {
        var items = list.itemsArray(),
            obj = {};
        if (canBeObject(items)) {
            items.forEach(pair => {
                var value = pair.length() === 2 ? pair.at(2) : undefined;
                obj[pair.at(1)] = (value instanceof List ?
                    objectify(value, guessObjects) : value);
            });
            return obj;
        }
        return items.map(element => {
            return element instanceof List ?
                objectify(element, guessObjects) : element;
        });
    }
    function canBeObject(array) {
        // try to determine whether the contents of a list
        // might be better represented as dictionary/object
        // than as array
        var keys;
        if (array.every(
            element => element instanceof List && (element.length() < 3)
        )) {
            keys = array.map(each => each.at(1));
            return keys.every(each => isString(each) && isUniqueIn(each, keys));
        }
    }
    function isUniqueIn(element, array) {
        return array.indexOf(element) === array.lastIndexOf(element);
    }
    return JSON.stringify(objectify(this, guessObjects));
};
List.prototype.canBeTXT = function () {
    return this.itemsArray().every(item =>
        isString(item) || (typeof item === 'number')
    );
};
List.prototype.asTXT = function () {
    // Caution, no error catching!
    // this method assumes that the list.canBeJSON()
    return this.itemsArray().join('\n');
};
// List testing
List.prototype.equalTo = function (other) {
    var myself = this, it = other, i, j, loopcount;
    if (!(other instanceof List)) {
        return false;
    }
    while (myself.isLinked && it.isLinked) {
        if (!snapEquals(myself.first, it.first)) {
            return false;
        }
        myself = myself.rest;
        it = it.rest;
    }
    if (it.isLinked) {
        i = it;
        it = myself;
        myself = i;
    }
    j = 0;
    while (myself.isLinked) {
        if (!snapEquals(myself.first, it.contents[j])) {
            return false;
        }
        myself = myself.rest;
        j += 1;
    }
    i = 0;
    if (myself.contents.length !== (it.contents.length - j)) {
        return false;
    }
    loopcount = myself.contents.length;
    while (loopcount > 0) {
        loopcount -= 1;
        if (!snapEquals(myself.contents[i], it.contents[j])) {
            return false;
        }
        i += 1;
        j += 1;
    }
    return true;
};
List.prototype.canBeCSV = function () {
    return this.itemsArray().every(value => {
        return (!isNaN(+value) && typeof value !== 'boolean') ||
            isString(value) ||
            (value instanceof List && value.hasOnlyAtomicData());
    });
};
List.prototype.canBeJSON = function () {
    return this.itemsArray().every(value => {
        return !isNaN(+value) ||
            isString(value) ||
            value === true ||
            value === false ||
            (value instanceof List && value.canBeJSON());
    });
};
List.prototype.hasOnlyAtomicData = function () {
    return this.itemsArray().every(value => {
        return (!isNaN(+value) && typeof value !== 'boolean') ||
            isString(value);
    });
};
// List-to-block (experimental)
List.prototype.blockify = function (limit = 500, count = [0]) {
    var block = SpriteMorph.prototype.blockForSelector('reportNewList'),
        slots = block.inputs()[0],
        len = this.length(),
        bool,
        i, value;
    block.isDraggable = true;
    slots.removeInput();
    
    // fill the slots with the data
    for (i = 0; i < len && count[0] < limit; i += 1) {
        value = this.at(i + 1);
        if (value instanceof List) {
            slots.replaceInput(
                slots.addInput(),
                value.blockify(limit, count)
            );
        } else if (typeof value === 'boolean') {
            bool = SpriteMorph.prototype.blockForSelector('reportBoolean');
            bool.inputs()[0].setContents(value);
            bool.isDraggable = true;
            slots.replaceInput(
                slots.addInput(),
                bool
            );
        } else {
            slots.addInput(value);
        }
        count[0] += 1;
    }
    slots.fixBlockColor(null, true);
    return block;
};
// ListWatcherMorph ////////////////////////////////////////////////////
/*
    I am a little window which observes a list and continuously
    updates itself accordingly
*/
// ListWatcherMorph inherits from BoxMorph:
ListWatcherMorph.prototype = new BoxMorph();
ListWatcherMorph.prototype.constructor = ListWatcherMorph;
ListWatcherMorph.uber = BoxMorph.prototype;
// ListWatcherMorph default settings
ListWatcherMorph.prototype.cellColor =
    SpriteMorph.prototype.blockColor.lists;
// ListWatcherMorph instance creation:
function ListWatcherMorph(list, parentCell) {
    this.init(list, parentCell);
}
ListWatcherMorph.prototype.init = function (list, parentCell) {
    var myself = this,
        readOnly;
    this.list = list || new List();
    this.start = 1;
    this.range = 100;
    this.lastUpdated = 0;
    this.lastCell = null;
    this.parentCell = parentCell || null; // for circularity detection
    // elements declarations
    this.label = new StringMorph(
        localize('length: ') + this.list.length(),
        SyntaxElementMorph.prototype.fontSize,
        null,
        false,
        false,
        false,
        MorphicPreferences.isFlat ? ZERO : new Point(1, 1),
        WHITE
    );
    this.label.mouseClickLeft = function () {myself.startIndexMenu(); };
    this.frame = new ScrollFrameMorph(null, 10);
    this.frame.alpha = 0;
    this.frame.acceptsDrops = false;
    this.frame.contents.acceptsDrops = false;
    this.handle = new HandleMorph(
        this,
        80,
        70,
        3,
        3
    );
    this.handle.setExtent(new Point(13, 13));
    this.arrow = new ArrowMorph(
        'down',
        SyntaxElementMorph.prototype.fontSize
    );
    this.arrow.mouseClickLeft = function () {myself.startIndexMenu(); };
    this.arrow.setRight(this.handle.right());
    this.arrow.setBottom(this.handle.top());
    this.handle.add(this.arrow);
    readOnly = this.list.type && !contains(['text', 'number'], this.list.type);
    if (readOnly) {
        this.plusButton = null;
    } else {
        this.plusButton = new PushButtonMorph(
            this.list,
            'add',
            '+'
        );
        this.plusButton.padding = 0;
        this.plusButton.edge = 0;
        this.plusButton.outlineColor = this.color;
        this.plusButton.fixLayout();
    }
    ListWatcherMorph.uber.init.call(
        this,
        SyntaxElementMorph.prototype.rounding,
        1,
        new Color(120, 120, 120)
    );
    this.color = new Color(220, 220, 220);
    this.isDraggable = false;
    this.setExtent(new Point(80, 70).multiplyBy(
        SyntaxElementMorph.prototype.scale
    ));
    this.add(this.label);
    this.add(this.frame);
    if (!readOnly) {
        this.add(this.plusButton);
    }
    this.add(this.handle);
    this.handle.fixLayout();
    this.update();
    this.fixLayout();
};
// ListWatcherMorph updating:
ListWatcherMorph.prototype.update = function (anyway) {
    var i, idx, ceil, morphs, cell, cnts, label, button, max,
        starttime, maxtime = 1000;
    this.frame.contents.children.forEach(m => {
        if (m instanceof CellMorph) {
            if (m.contentsMorph instanceof ListWatcherMorph) {
                m.contentsMorph.update();
            } else if (isSnapObject(m.contents) ||
                    (m.contents instanceof Costume)) {
                m.update();
            }
        }
    });
    if (this.lastUpdated === this.list.lastChanged && !anyway) {
        return null;
    }
    this.updateLength(true);
    // adjust start index to current list length
    this.start = Math.max(
        Math.min(
            this.start,
            Math.floor((this.list.length() - 1) / this.range)
                * this.range + 1
        ),
        1
    );
    // refresh existing cells
    // highest index shown:
    max = Math.min(
        this.start + this.range - 1,
        this.list.length()
    );
    // number of morphs available for refreshing
    ceil = Math.min(
        (max - this.start + 1) * 3,
        this.frame.contents.children.length
    );
    for (i = 0; i < ceil; i += 3) {
        idx = this.start + (i / 3);
        cell = this.frame.contents.children[i];
        label = this.frame.contents.children[i + 1];
        button = this.frame.contents.children[i + 2];
        cnts = this.list.at(idx);
        if (cell.contents !== cnts) {
            cell.contents = cnts;
            cell.fixLayout();
            if (this.lastCell) {
                cell.setLeft(this.lastCell.left());
            }
        }
        this.lastCell = cell;
        if (label.text !== idx.toString()) {
            label.text = idx.toString();
            label.fixLayout();
        }
        button.action = idx;
    }
    // remove excess cells
    // number of morphs to be shown
    morphs = (max - this.start + 1) * 3;
    while (this.frame.contents.children.length > morphs) {
        this.frame.contents.children[morphs].destroy();
    }
    // add additional cells
    ceil = morphs; //max * 3;
    i = this.frame.contents.children.length;
    starttime = Date.now();
    if (ceil > i + 1) {
        for (i; i < ceil; i += 3) {
            if (Date.now() - starttime > maxtime) {
                this.fixLayout();
                this.frame.contents.adjustBounds();
                this.frame.contents.setLeft(this.frame.left());
                return null;
            }
            idx = this.start + (i / 3);
            label = new StringMorph(
                idx.toString(),
                SyntaxElementMorph.prototype.fontSize,
                null,
                false,
                false,
                false,
                MorphicPreferences.isFlat ? ZERO : new Point(1, 1),
                WHITE
            );
            cell = new CellMorph(
                this.list.at(idx),
                this.cellColor,
                idx,
                this.parentCell
            );
            button = new PushButtonMorph(
                this.list.remove,
                idx,
                '-',
                this.list
            );
            button.padding = 1;
            button.edge = 0;
            button.corner = 1;
            button.outlineColor = this.color.darker();
            button.fixLayout();
            this.frame.contents.add(cell);
            if (this.lastCell) {
                cell.setPosition(this.lastCell.bottomLeft());
            } else {
                cell.setTop(this.frame.contents.top());
            }
            this.lastCell = cell;
            label.setCenter(cell.center());
            label.setRight(cell.left() - 2);
            this.frame.contents.add(label);
            this.frame.contents.add(button);
        }
    }
    this.lastCell = null;
    this.fixLayout();
    this.frame.contents.adjustBounds();
    this.frame.contents.setLeft(this.frame.left());
    this.updateLength();
    this.lastUpdated = this.list.lastChanged;
};
ListWatcherMorph.prototype.updateLength = function (notDone) {
    this.label.text = localize('length: ') + this.list.length();
    if (notDone) {
        this.label.color = new Color(0, 0, 100);
    } else {
        this.label.color = new Color(0, 0, 0);
    }
    this.label.fixLayout();
    this.label.setCenter(this.center());
    this.label.setBottom(this.bottom() - 3);
};
ListWatcherMorph.prototype.startIndexMenu = function () {
    var i,
        range,
        items = Math.ceil(this.list.length() / this.range),
        menu = new MenuMorph(
            idx => this.setStartIndex(idx),
            null,
            this
        );
    menu.addItem('1...', 1);
    for (i = 1; i < items; i += 1) {
        range = i * 100 + 1;
        menu.addItem(range + '...', range);
    }
    menu.popUpAtHand(this.world());
};
ListWatcherMorph.prototype.setStartIndex = function (index) {
    this.start = index;
    this.list.changed();
    this.update();
};
ListWatcherMorph.prototype.fixLayout = function () {
    if (!this.label) {return; }
    if (this.frame) {
        this.arrangeCells();
        this.frame.setPosition(this.position().add(3));
        this.frame.bounds.corner = this.bounds.corner.subtract(new Point(
            3,
            17
        ));
        this.frame.fixLayout();
        this.frame.contents.adjustBounds();
    }
    this.label.setCenter(this.center());
    this.label.setBottom(this.bottom() - 3);
    if (this.plusButton) {
        this.plusButton.setLeft(this.left() + 3);
        this.plusButton.setBottom(this.bottom() - 3);
    }
    if (this.parent) {
        this.parent.changed();
        this.parent.fixLayout();
        this.parent.rerender();
    }
};
ListWatcherMorph.prototype.arrangeCells = function () {
    var i, cell, label, button, lastCell,
        end = this.frame.contents.children.length;
    for (i = 0; i < end; i += 3) {
        cell = this.frame.contents.children[i];
        label = this.frame.contents.children[i + 1];
        button = this.frame.contents.children[i + 2];
        if (lastCell) {
            cell.setTop(lastCell.bottom());
        }
        if (label) {
            label.setTop(cell.center().y - label.height() / 2);
            label.setRight(cell.left() - 2);
        }
        if (button) {
            button.setCenter(cell.center());
            button.setLeft(cell.right() + 2);
        }
        lastCell = cell;
    }
    this.frame.contents.adjustBounds();
};
ListWatcherMorph.prototype.expand = function (maxExtent) {
    // make sure to show all (first 100) cells
    var fe = this.frame.contents.extent(),
        ext = new Point(fe.x + 6, fe.y + this.label.height() + 6);
    if (maxExtent) {
        ext = ext.min(maxExtent);
    }
    this.setExtent(ext);
    this.handle.setRight(this.right() - 3);
    this.handle.setBottom(this.bottom() - 3);
};
// ListWatcherMorph context menu
ListWatcherMorph.prototype.userMenu = function () {
    if (!List.prototype.enableTables) {
        return this.escalateEvent('userMenu');
    }
    var menu = new MenuMorph(this);
    menu.addItem('table view...', 'showTableView');
    if (this.list.canBeJSON()) {
        menu.addItem(
            'blockify',
            () => {
                var world = this.world(),
                    ide = detect(world.children, m => m instanceof IDE_Morph);
                this.list.blockify().pickUp(world);
                world.hand.grabOrigin = {
                    origin: ide.palette,
                    position: ide.palette.center()
                };
            }
        );
    }
    menu.addLine();
    menu.addItem(
        'open in dialog...',
        () => new TableDialogMorph(this.list).popUp(this.world())
    );
    return menu;
};
ListWatcherMorph.prototype.showTableView = function () {
    var view = this.parentThatIsA(
        SpriteBubbleMorph,
        SpeechBubbleMorph,
        CellMorph
    );
    if (!view) {return; }
    if (view instanceof SpriteBubbleMorph) {
        view.contentsMorph.destroy();
        view.contentsMorph = new TableFrameMorph(new TableMorph(this.list, 10));
        view.contentsMorph.expand(this.extent());
        view.parent.positionTalkBubble();
    } else if (view instanceof SpeechBubbleMorph) {
        view.contents = new TableFrameMorph(new TableMorph(this.list, 10));
        view.contents.expand(this.extent());
    } else { // watcher cell
        view.changed();
        view.contentsMorph.destroy();
        view.contentsMorph = new TableFrameMorph(new TableMorph(this.list, 10));
        view.add(view.contentsMorph);
        view.contentsMorph.setPosition(this.position());
        view.contentsMorph.expand(this.extent());
    }
    view.fixLayout();
    view.rerender();
};
// ListWatcherMorph events:
ListWatcherMorph.prototype.mouseDoubleClick = function (pos) {
    if (List.prototype.enableTables) {
        new TableDialogMorph(this.list).popUp(this.world());
    } else {
        this.escalateEvent('mouseDoubleClick', pos);
    }
};
// ListWatcherMorph hiding/showing:
ListWatcherMorph.prototype.show = function () {
    ListWatcherMorph.uber.show.call(this);
    this.frame.contents.adjustBounds();
};
// ListWatcherMorph rendering:
ListWatcherMorph.prototype.render = WatcherMorph.prototype.render;