Back to Index Page generated: May 8, 2024, 6:16:03 AM

Expansion Interface Reordering

Content

Warnings

  1. http://wiki.alioth.net/index.php/Interface%20Reordering -> 404 Not Found
  2. Low hanging fuit: Information URL exists...

Manifest

from Expansion Manager's OXP list from Expansion Manifest
Description Change interface order on F4 page (\"Ship and system interfaces\") Change interface order on F4 page (\"Ship and system interfaces\")
Identifier oolite.oxp.Alnivel.InterfaceReordering oolite.oxp.Alnivel.InterfaceReordering
Title Interface Reordering Interface Reordering
Category Misc Misc
Author Alnivel Alnivel
Version 0.2 0.2
Tags
Required Oolite Version
Maximum Oolite Version
Required Expansions
Optional Expansions
Conflict Expansions
Information URL https://wiki.alioth.net/index.php/Interface_Reordering_OXP n/a
Download URL https://www.dl.dropboxusercontent.com/s/5c4bbeuw6ntlzs0/InterfaceReordering.0.2.oxz n/a
License CC BY-NC-SA 4 CC BY-NC-SA 4
File Size n/a
Upload date n/a

Documentation

Equipment

This expansion declares no equipment. This may be related to warnings.

Ships

This expansion declares no ships. This may be related to warnings.

Models

This expansion declares no models. This may be related to warnings.

Scripts

Path
Scripts/interface-reordering-interfaceLib.js
"use strict";
this.name = "InterfaceReordering_InterfaceLib";
this.author = "Alnivel";
this.copyright = "2022 Alnivel";
this.version = "0.4";
this.licence = "CC BY-NC-SA 4.0";

(function() {
    let ILib = this;

    this.__BaseController = function __BaseController() {}

    this.__BaseController.prototype.onRun = function onRun(callback) {
        this.callbackOnRun = callback;
        return this;
    }

    this.__BaseController.prototype.onUpdate = function onUpdate(callback) {
        this.callbackOnUpdate = callback;
        return this;
    }

    this.__BasePagedController = function __BasePagedController(screenParameters) {
        __BasePagedController_init(screenParameters);
    }

    this.__BasePagedController.prototype = Object.create(__BaseController.prototype);

    this.__BasePagedController.prototype.__BasePagedController_init = function (screenParameters)  {
        this.screenParameters = screenParameters;
        this.headerLines = [];
        this.contentLines = [];
        this.footerLines = [];

        this.headerPrefix = "00HEADER";
        this.contentPrefix = "20CONTENT";
        this.footerPrefix = "40FOOTER";

        this.currentPage = 0;
        this.pages = 0;

        this.selectedChoicesKey = undefined;
    }

    this.__BasePagedController.prototype.addToHeader = function addToHeader(line) {
        const headerLinesCount = this.headerLines.length;
        const key = (headerLinesCount < 10 ? ("0" + headerLinesCount) : headerLinesCount) + "_NO_KEY";
        this.headerLines.push({ key: key, line: line });
    }

    this.__BasePagedController.prototype.addToContent = function addToContent(line) {
        const contentLinesCount = this.contentLines.length;
        const key = (contentLinesCount < 10 ? ("0" + contentLinesCount) : contentLinesCount) + "_NO_KEY";
        this.contentLines.push({ key: key, line: line });
    }

    this.__BasePagedController.prototype.addToFooter = function addToFooter(line) {
        const footerLinesCount = this.footerLines.length;
        const key = (footerLinesCount < 10 ? ("0" + footerLinesCount) : footerLinesCount) + "_NO_KEY";
        this.footerLines.push({ key: key, line: line });
    }

    /*
        Remove line from ...
        this.__BasePagedScreenController.prototype.removeLineFromContent = function removeFromContent(line) {
        let removePos = -1;
        let i = this.contentLines.length;
        while (i--) {
            if (this.contentLines[i].line === line) {
                removePos = i;
                break;
            }
        }
        if (removePos === -1)
            return false;
        else {
            this.contentLines.splice(removePos, 1);
            return true;
        }
    }
    */

    this.__BasePagedController.prototype.clearHeader = function clearHeader() {
        this.headerLines.length = 0;
    }

    this.__BasePagedController.prototype.clearContent = function clearHeader() {
        this.contentLines.length = 0;
    }

    this.__BasePagedController.prototype.clearFooter = function clearHeader() {
        this.footerLines.length = 0;
    }

    /***
     * Update every line in header and fill "choises" parameter with them  
     * @param {object} choises
     * @returns {number} Header height in lines
     */
    this.__BasePagedController.prototype.updateHeader = function updateHeader(choises) {
        const linesForHeader = this.headerLines.length;

        for (let i = 0; i < linesForHeader; i++) {
            let pair = this.headerLines[i];
            let key = this.headerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesForHeader;
    }

    /**
     * Update content lines on specified page and fill "choises" parameter with them
     * @param {object} choises
     * @param {number} linesPerPage Maximum number of lines per page
     * @param {number} page Page number
     * @returns {number} Content height on specified page in lines
     */
    this.__BasePagedController.prototype.updateContent = function updateContent(choises, linesPerPage, page) {
        let linesStartIndex = linesPerPage * page;
        let linesEndIndex = linesStartIndex + linesPerPage;
        let contentLinesCount = this.contentLines.length;

        let linesEndIndexLimited = contentLinesCount < linesEndIndex ? contentLinesCount : linesEndIndex;

        for (let i = linesStartIndex; i < linesEndIndexLimited; i++) {
            let pair = this.contentLines[i];
            let key = this.contentPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesEndIndexLimited - linesStartIndex;
    }

    /***
     * Update every line in footer and fill "choises" parameter with them  
     * @param {object} choises
     * @returns {number} Footer height in lines
     */
    this.__BasePagedController.prototype.updateFooter = function updateFooter(choises) {
        const linesForFooter = this.footerLines.length;
        
        for (let i = 0; i < linesForFooter; i++) {
            let pair = this.footerLines[i];
            let key = this.footerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            choises[key] = pair.line.update();
        }

        return linesForFooter;
    }

    this.__BasePagedController.prototype.proccessChoises = function proccessChoises(choise) {
        for (let i = 0; i < this.headerLines.length; i++) {
            let pair = this.headerLines[i];
            let key = this.headerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select(); // if true returned then don`t redraw this screen
            }
        }
        
        for (let i = 0; i < this.contentLines.length; i++) {
            let pair = this.contentLines[i];
            let key = this.contentPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select();
            }
        }

        for (let i = 0; i < this.footerLines.length; i++) {
            let pair = this.footerLines[i];
            let key = this.footerPrefix + (i < 10 ? ("0" + i) : i) + "_" + pair.key;
            let line = pair.line;
            if (choise === key) {
                return line.select && line.select();
            }
        }

        // Restarting the screen, if necessary, is performed by the child 
        /* 
        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = initialChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
        */
    }

    this.__BasePagedController.prototype.runScreen = function runScreen() {
        log("Alnivel_InterfaceLib_Message", "Started " + this.screenParameters.title + " mission screen");
        if(this.callbackOnRun)
            this.callbackOnRun();
        
        this.currentPage = 0;
        this.screenParameters.choices = this.getPage(0);

        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    this.__BasePagedController.prototype.returnToScreen = function returnToScreen() {
        if(this.currentPage === undefined || this.currentPage > this.pages)
            this.currentPage = 0;

        log("Alnivel_InterfaceLib_Message", "Returned to " + this.screenParameters.title + " mission screen on page " + this.currentPage);
        if(this.callbackOnRun)
            this.callbackOnRun();
        
        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }


    /**
     * Create paged screen that can be filled with line objects.
     * Each time the line is selected, it will re-render, 
     * unless the Return option is selected (then will be called callbackOnReturn)
     * or the onSelect callback of the selected line returns true
     * @param {*} screenParameters 
     * @param {Function} [callbackOnReturn]
     */
    this.PagedScreenController = function PagedScreenController(screenParameters, callbackOnReturn) {
        this.__BasePagedController_init(screenParameters); // Don`t know how to get base constructor properly
        this.callbackOnReturn = callbackOnReturn ?  callbackOnReturn : null;
    }

    this.PagedScreenController.prototype = Object.create(__BasePagedController.prototype);

    this.PagedScreenController.prototype.onReturn = function onReturn(callback) {
        this.callbackOnReturn = callback;
        return this;
    }

    this.PagedScreenController.prototype.updateControls = function updateControls(choises, pages, page) {
        const spacer = { text: "" };
        
        if (pages > 1) {
            choises["70_SPACER"] = spacer;
            choises["71_PAGE_NUM"] = {
                text: "Page " + (1 + page) + " of " + pages,
                unselectable: true
            }
            choises["73_NEXT_PAGE"] = {
                text: "Next page",
                unselectable: page + 1 === pages
            };
            choises["74_PREV_PAGE"] = {
                text: "Previous page",
                unselectable: page === 0
            };

        } else {
            choises["70_SPACER"] = spacer;
            choises["71_SPACER"] = spacer;
            choises["73_SPACER"] = spacer;
            choises["74_SPACER"] = spacer;
        }

        choises["80_RETURN"] = { text: "Return" };

        return 5;
    }

    this.PagedScreenController.prototype.getPage = function getPage(page) {
        if(this.callbackOnUpdate)
            this.callbackOnUpdate();

        const screenHeight = (player.ship.hudAllowsBigGui ? 27 : 21);

        const choises = {};

        const headerHeight = this.updateHeader(choises);
        const footerHeight = this.updateFooter(choises);
        const controlsHeight = 5;

        const maxContentHeight = screenHeight - headerHeight - footerHeight - controlsHeight;
        const pages = this.pages = Math.ceil(this.contentLines.length / maxContentHeight);

        const contentHeight = this.updateContent(choises, maxContentHeight, page);
        const remainHeight = maxContentHeight - contentHeight;
        log("Alnivel_InterfaceLib_Message", "Remain lines after content " + remainHeight + " from " + maxContentHeight)
        /*** Fill empty ***/
        const spaceToFill = remainHeight < maxContentHeight? remainHeight : maxContentHeight;
        const spacer = { text: "" };
        for (let i = 0; i < spaceToFill; i++) {
            let key = "21EMPTY_" + (i < 10 ? ("0" + i) : i) + "_EMPTY";
            choises[key] = spacer;
        }

        /*** Controlls ***/
        this.updateControls(choises, pages, page);

        return choises;
    }

    this.PagedScreenController.prototype.getPageWithLineIndex = function getPageWithLineIndex(lineIndex) {
        if(lineIndex === null || lineIndex === undefined || 
            lineIndex === -1 || lineIndex >= this.contentLines.length ) {
            // TODO: add warning
            return this.getPage(0);
        }

        const screenHeight = (player.ship.hudAllowsBigGui ? 27 : 21);

        const choises = {};

        const headerHeight = this.updateHeader(choises);
        const footerHeight = this.updateFooter(choises);
        const controlsHeight = 5;

        const maxContentHeight = screenHeight - headerHeight - footerHeight - controlsHeight;
        const pages = this.pages = Math.ceil(this.contentLines.length / maxContentHeight);
        const page = this.currentPage = Math.floor(lineIndex / maxContentHeight);

        const contentHeight = this.updateContent(choises, maxContentHeight, page);
        const remainHeight = maxContentHeight - contentHeight;
        log("Alnivel_InterfaceLib_Message", "Remain lines after content " + remainHeight + " from " + maxContentHeight)
        /*** Fill empty ***/
        const spaceToFill = remainHeight < maxContentHeight? remainHeight : maxContentHeight;
        const spacer = { text: "" };
        for (let i = 0; i < spaceToFill; i++) {
            let key = "21EMPTY_" + (i < 10 ? ("0" + i) : i) + "_EMPTY";
            choises[key] = spacer;
        }

        /*** Controlls ***/
        this.updateControls(choises, pages, page);

        return choises;
    }

    this.PagedScreenController.prototype.getPageWithLine = function getPageWithLine(line) {
        const lineIndex = this.contentLines.findIndex(function (pair) {
            return pair.line === line;
        });

        return this.getPageWithLineIndex(lineIndex);
    }

    this.PagedScreenController.prototype.onChoiceSelected = function onChoiceSelected(choise) {
        if (choise === "80_RETURN") {
            if (this.callbackOnReturn) this.callbackOnReturn();
            return;
        }
        this.selectedChoicesKey = choise;
        if (choise === "74_PREV_PAGE") {
            this.currentPage--;
            if (this.currentPage === 0)
                this.selectedChoicesKey = "73_NEXT_PAGE";
        }
        else if (choise === "73_NEXT_PAGE") {
            this.currentPage++;
            if (this.currentPage + 1 === this.pages)
                this.selectedChoicesKey = "74_PREV_PAGE";
        }
        else {
            const stopRestartingScreen = this.proccessChoises(choise);
            
            if(stopRestartingScreen)
            {
                log("Alnivel_InterfaceLib_Message", "Screen \"" + this.screenParameters.title + "\" restarting has been stopped by key " + choise)
                return;
            }
                
        }

        this.screenParameters.choices = this.getPage(this.currentPage);
        this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    this.PagedScreenController.prototype.runPage = function runPage(page, noUpdate) {
        log("Alnivel_InterfaceLib_Message", "Started " + this.screenParameters.title + " mission screen");
        if(!noUpdate && this.callbackOnRun)
            this.callbackOnRun();
        else
            this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        
        if(this.currentPage === undefined || this.currentPage > this.pages)
            this.currentPage = 0;
        else
            this.currentPage = page;

        this.screenParameters.choices = this.getPage(this.currentPage);
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    this.PagedScreenController.prototype.runPageWithLine = function runPageWithLine(line, noUpdate) {
        log("Alnivel_InterfaceLib_Message", "Started " + this.screenParameters.title + " mission screen");
        if(!noUpdate && this.callbackOnRun)
            this.callbackOnRun();
        else
            this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        
        this.currentPage = 0;
        this.screenParameters.choices = this.getPageWithLine(line);
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }

    this.PagedScreenController.prototype.runPageWithLineIndex = function runPageWithLineIndex(lineIndex, noUpdate) {
        log("Alnivel_InterfaceLib_Message", "Started " + this.screenParameters.title + " mission screen");
        if(!noUpdate && this.callbackOnRun)
            this.callbackOnRun();
        else
            this.screenParameters.initialChoicesKey = this.selectedChoicesKey;
        
        this.currentPage = 0;
        this.screenParameters.choices = this.getPageWithLineIndex(lineIndex);
        mission.runScreen(this.screenParameters, this.onChoiceSelected.bind(this));
    }



    this.$limitText = function $limitText(text, limitWidth) {
        const ellipsis = "…";
        const hairSpace = String.fromCharCode(31);
        const hairSpaceWidth = defaultFont.measureString(hairSpace);

        let tmp = text;
        while (defaultFont.measureString(tmp) > limitWidth) {
            tmp = tmp.substring(0, tmp.length - 2) + ellipsis;
        }
        
        const padsNeeded = Math.floor((limitWidth - defaultFont.measureString(tmp)) / hairSpaceWidth);
        if (padsNeeded > 1)
            tmp = tmp + new Array(padsNeeded).join(hairSpace);

        return tmp;
    }

    /**
     * 
     * @param {string} text 
     * @param {"left"|"right"} padSide 
     * @param {number} desiredWidth 
     * @returns Padded text 
     */
    this.$padTextOnSide = function $padTextOnSide(text, padSide, desiredWidth) {
        const hairSpace = String.fromCharCode(31);
        const hairSpaceWidth = defaultFont.measureString(hairSpace);


        const textWidth = defaultFont.measureString(text);

        const padsNeeded = Math.floor((desiredWidth - textWidth) / hairSpaceWidth);

        let resultText;
        if (padsNeeded > 1)
            if (padSide == "left")
                resultText = new Array(padsNeeded).join(hairSpace) + text;
            else
                resultText = text + new Array(padsNeeded).join(hairSpace);
        else
            resultText = this.$limitText(text, desiredWidth);

        return resultText;
    }

    this.$padTextAround = function $padTextAround(text, desiredWidth) {
        const hairSpace = String.fromCharCode(31);
        const hairSpaceWidth = defaultFont.measureString(hairSpace);

        const textWidth = defaultFont.measureString(text);

        const padsNeeded = Math.floor((desiredWidth - textWidth) / (2 * hairSpaceWidth));

        let resultText;
        if (padsNeeded > 1) {
            let sidePadding = new Array(padsNeeded).join(hairSpace);
            resultText = sidePadding + text + sidePadding;
        }
        else
            resultText = this.$limitText(text, desiredWidth);

        return resultText;
    }

    this.$repeatPattern = function $repeatPattern(pattern, desiredWidth) {
        const patternWidth = defaultFont.measureString(pattern);
        const repeatsNeeded = Math.ceil(desiredWidth / patternWidth);

        let tmp = new Array(repeatsNeeded).join(pattern);
        while (defaultFont.measureString(tmp) > desiredWidth) {
            tmp = tmp.substring(0, tmp.length - 1);
        }

        return tmp
    }

    this.__LineWithCallbacksPrototype = {
        /**
         * Set callback that will be called when line is redrawing. 
         * The arguments for the function are same as for select callback, 
         * but it can return dictionary whose 
         * key-value pairs will be setted as choice parameters
         * @param {Function} updateCallback 
         * @returns Line object
         */
        onUpdate: function onUpdate(updateCallback) {
            this.updateCallback = updateCallback;
            return this;
        },
        /**
         * Set callback that will be called when line is selected. 
         * The arguments for the function depends on line type.
         * Is callback return true than screen will not redrawed. 
         * It can be used to change to another mission screen.
         * @param {*} selectCallback 
         * @returns Line object
         */
        onSelected: function onSelected(selectCallback) {
            this.selectCallback = selectCallback;
            return this;
        }
    }

    // TODO: Add lines hiding?

    /**
     * Create empty line object
     */
    this.EmptyLine = function () {}

    this.EmptyLine.prototype.update = function update() {
        return { text: "" };
    }

    /**
     * Create line object that can`t be selected
     * @param {string} text 
     */
    this.LabelLine = function (text, alignment) {
        this.choiceObject = {
            text: ILib.$padTextAround(text, 32),
            unselectable: true,
            color: "whiteColor",
            alignment: alignment? alignment : "CENTER"
        }
    }

    this.LabelLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.LabelLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback();

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined &&
                    key !== "unselectable") {

                    if (key === "text")
                        this.choiceObject[key] = ILib.$padTextOnSide(value, "right", 32);
                    else if (key === "color")
                        this.choiceObject[key] = value === "default" ? "whiteColor" : value;
                    else
                        this.choiceObject[key] = value;
                }
            }

        }
        return this.choiceObject;
    };

    /**
     * Create line object that can be selected.
     * When selected onSelected callback will be called without any arguments
     * @param {string} text 
     */
    this.ButtonLine = function (text) {
        this.choiceObject = {
            text: ILib.$padTextOnSide(text, "right", 32),
            color: "cyanColor",
            alignment: "LEFT"
        };
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
    }

    this.ButtonLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.ButtonLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback();
            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "color")
                        this.selectableColor = value === "default" ? "cyanColor" : value;
                    else if (key === "text")
                        this.choiceObject[key] = ILib.$padTextOnSide(value, "right", 32);
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        return this.choiceObject;
    }
    this.ButtonLine.prototype.select = function select() {
        if (this.selectCallback)
            return this.selectCallback();
    }


    /**
     * Create line object that toggles between true and false when selected.
     * When selected onSelected callback will be called with new state as argument
     * @param {string} text 
     * @param {Boolean} initialState 
     */
    this.ToggleLine = function (text, initialState) {
        this.choiceObject = {
            color: "cyanColor"
        };
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
        this.text = text;
        this.state = !!initialState;
    }

    this.ToggleLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.ToggleLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback(this.state);

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "color")
                        this.selectableColor = value === "default" ? "cyanColor" : value;
                    else if (key === "text")
                        this.text = value;
                    else if (key === "state")
                        this.state = !!value;
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        this.choiceObject.text =
            ILib.$padTextOnSide(this.text, "right", 24) +
            ILib.$padTextAround(" < " + (this.state ? "On" : "Off") + " > ", 8);
        return this.choiceObject;
    }
    this.ToggleLine.prototype.select = function select() {
        this.state = !this.state;
        if (this.selectCallback)
            return this.selectCallback(this.state);
    }


    /**
     * Create line object that cycles between the specified values when selected.
     * When selected onSelected callback will be called with new index and value as arguments
     * @param {string} text
     * @param {Array|Object.<string, any>} items If it is an array the values are 
     * the same as the labels, otherwise it is treated as a dictionary of labels to values
     * @param {any} [initialItem] 
     */
    this.CycleLine = function (text, items, initialItem) {
        const itemsIsArray = Array.isArray(items);
        const itemKeys = itemsIsArray ? items : Object.keys(items);

        let itemValues;
        if (itemsIsArray)
            itemValues = items
        else { // Object.values(items);
            itemValues = Array(itemKeys.length)
            for (let i = 0; i < itemKeys.length; i++)
                itemValues[i] = items[itemKeys[i]]
        }

        let initialIndex = itemKeys.indexOf(initialItem);
        if (initialIndex === -1)
            initialIndex = 0;

        this.choiceObject = {
            color: "cyanColor",
        };
        this.items = itemKeys;
        this.values = itemValues;
        this.currentIndex = initialIndex;
        this.text = text;
        this.selectableColor = "cyanColor";
        this.unselectableColor = "darkGrayColor";
    }

    this.CycleLine.prototype = Object.create(__LineWithCallbacksPrototype);
    this.CycleLine.prototype.update = function update() {
        if (this.updateCallback) {
            let updated = this.updateCallback(this.currentIndex, this.values[this.currentIndex]);

            const updatedKeys = Object.keys(updated);
            for (let i = 0; i < updatedKeys.length; i++) {
                const key = updatedKeys[i];
                const value = updated[key];
                if (value !== undefined) {
                    if (key === "color")
                        this.selectableColor = value === "default" ? "cyanColor" : value;
                    else if (key === "text")
                        this.text = value;
                    else if (key === "currentIndex") {
                        this.currentIndex = value;
                    }
                    else
                        this.choiceObject[key] = value;
                }
            }
            if (this.choiceObject.unselectable)
                this.choiceObject.color = this.unselectableColor;
            else
                this.choiceObject.color = this.selectableColor;
        }

        const currentItem = this.items[this.currentIndex];
        this.choiceObject.text =
            ILib.$padTextOnSide(this.text, "right", 24) +
            ILib.$padTextAround(" < " + currentItem + " > ", 8);

        return this.choiceObject;
    }
    this.CycleLine.prototype.select = function select() {
        this.currentIndex = (this.currentIndex + 1) % this.values.length;
        if (this.selectCallback)
            return this.selectCallback(this.currentIndex, this.values[this.currentIndex]);
    }

}).call(this);
Scripts/interface-reordering-main.js
"use strict";
this.name = "InterfaceReordering";
this.author = "Alnivel";
this.copyright = "2022 Alnivel";
this.version = "0.2";
this.licence = "CC BY-NC-SA 4.0";


this.$interfacePrefixes = {/* key: prefix */ };

this.$idToStationMap = [/* stationId: station */];
this.$stationIdToInterfacesMap = [/* stationId: { key: interfaceDefinition }*/];

/// Replace orginal method with one which modify original interface category 
/// to visualy similar string that will be placed acording to interfaceOrder
///
/// If something add interface directly in the body of the script, the set  
/// interface will be above all others and can't be reordered until it will be reset
///
/// Well, it could be worse

this.$originalSetInterface = Station.prototype.setInterface;

(function substituteSetInterface() {
    let idToStationMap = this.$idToStationMap;
    let stationIdToInterfacesMap = this.$stationIdToInterfacesMap;

    let script = this;

    let originalSetInterface = this.$originalSetInterface;

    Station.prototype.setInterface = function (key, interfaceDefinition) {

        let stationId = idToStationMap.indexOf(this);
        if (stationId === -1) {
            stationId = idToStationMap.length;
            idToStationMap.push(this);
            stationIdToInterfacesMap[stationId] = {};
        }
        let seenInterfaces = stationIdToInterfacesMap[stationId];

        if (interfaceDefinition !== null) {


            let reorderedDefinition = {};
            for (let idKey in interfaceDefinition) {
                reorderedDefinition[idKey] = interfaceDefinition[idKey];
            }

            // // A way to get the name of the calling script
            // let stack = new Error().stack;
            // let callerScript = stack.substring(
            //     stack.indexOf("\"(\"") + 3, 
            //     stack.indexOf("\",[object Object])")
            // );
            //            
            // interfaceDefinition.InterfaceReordering_callerScript = callerScript;
            // //

            // I hope someone doesn't use the same key as an existing category
            let prefix = script.$interfacePrefixes[key] ||
                script.$interfacePrefixes[interfaceDefinition.category] ||
                script.$interfacePrefixes["InterfaceReordering_OtherPrefix"];

            reorderedDefinition.category = prefix + interfaceDefinition.category;

            originalSetInterface.call(this, key, reorderedDefinition);
            seenInterfaces[key] = interfaceDefinition;


        }
        else {
            originalSetInterface.call(this, key, null);

            if (seenInterfaces[key])
                seenInterfaces[key] = null;
        }



    }

}).call(this);


this.startUp = function () {
    const storedInterfaceOrder = JSON.parse(missionVariables.interfaceReordering_interfaceOrder);
    if (storedInterfaceOrder)
        this.$interfaceOrder = storedInterfaceOrder;

    const hasOtherEntry = this.$interfaceOrder.some(function (orderItem) {
        return orderItem.key === "InterfaceReordering_OTHER";
    })
    if (!hasOtherEntry) {
        this.$interfaceOrder.push({ key: "InterfaceReordering_OTHER", name: "<Other interfaces>", type: "" });
    }

    // copy loaded order to variable that used by user interface
    this.$unsavedInterfaceOrder.length = 0;
    Array.prototype.push.apply(this.$unsavedInterfaceOrder, this.$interfaceOrder);

    this.$updateInterfaceOrder(this.$interfaceOrder);
}

this.interfaceInitialized = false;
this.startUpComplete = this.shipDockedWithStation = function () {
    if (!this.interfaceInitialized) {
        this.$initializeInterfaceScreens();
        this.interfaceInitialized = true;
    }

    const interfaceMain = this.$screens.main;
    const station = player.ship.dockedStation;
    station.setInterface("InterfaceReordering_ManageOrder", {
        title: "Reorder interfaces",
        category: "Ship Systems",
        summary: "Change the order of interfaces",
        callback: interfaceMain.runScreen.bind(interfaceMain)
    });
}

this.playerWillSaveGame = function () {
    missionVariables.interfaceReordering_interfacePrefixes = JSON.stringify(this.$interfacePrefixes);
    missionVariables.interfaceReordering_interfaceOrder = JSON.stringify(this.$interfaceOrder);
};

// Clear known stations and interfaces before 
// any interfaces from new system will be set
this.shipWillEnterWitchspace = function () {
    this.$idToStationMap.length = 0;
    this.$stationIdToInterfacesMap.length = 0;
};

// Settings interface //

this.$screens = {};

this.$interfaceOrder = [
    { key: "InterfaceReordering_OTHER", name: "<Other interfaces>", type: "" },
    /* Other entries looks like
    {key: "<interfaceKey>", name:"<interfaceName>",  type:"Interface"},
    {key: "<categoryName>", name:"<categoryName>",  type:"Category"},
    */
];
this.$unsavedInterfaceOrder = []; // changes in order stored here until they saved 

this.$initializeInterfaceScreens = function $initializeInterfaceScreens() {
    /*** Binding script variables to function scope ***/
    const ILib = worldScripts["InterfaceReordering_InterfaceLib"];
    const mainScript = this;
    const screens = this.$screens;
    const stationIdToInterfacesMap = this.$stationIdToInterfacesMap;
    const idToStationMap = this.$idToStationMap;
    const updateInterfaceOrder = this.$updateInterfaceOrder.bind(this);

    const interfaceOrder = this.$unsavedInterfaceOrder;
    let selectedOrderItemKey = null;
    let selectedOrderItemIndex = null;

    const station = player.ship.dockedStation
    const stationId = idToStationMap.indexOf(station);
    const interfacesOnStation = stationId === -1 ? {} : stationIdToInterfacesMap[stationId];

    /*** ***/
    let interfaceOrderWasChanged = false;
    const saveChangesAndResetSelected = function () {
        selectedOrderItemKey = null;
        selectedOrderItemIndex = null;
        if (interfaceOrderWasChanged) {
            updateInterfaceOrder(interfaceOrder);
            interfaceOrderWasChanged = false;
        }
    };

    const cancelChangesAndResetSelected = function () {
        selectedOrderItemKey = null;
        selectedOrderItemIndex = null;

        if (interfaceOrderWasChanged) {        
            interfaceOrder.length = 0;
            Array.prototype.push.apply(interfaceOrder, mainScript.$interfaceOrder)
            interfaceOrderWasChanged = false;
        }

        this.screenParameters.initialChoicesKey = undefined;
    }

    const resetOrderList = function () {
        selectedOrderItemIndex = null;
        selectedOrderItemKey = null;
        interfaceOrder.length = 0;
        interfaceOrder.push({ key: "InterfaceReordering_OTHER", name: "<Other interfaces>", type: "" });
        interfaceOrderWasChanged = true;
        screens.interfaceOrderList.runPageWithLineIndex(0);
    };

    const formatToReorderingListColumns = function (no, name, type) {
        return "" +
            ILib.$padTextAround(no, 4) +
            ILib.$padTextOnSide(name, "right", 22) +                
            ILib.$padTextAround("", 1) +
            ILib.$padTextOnSide(type, "right", 5)
    }

    const updateListItem = function () {
        let item = interfaceOrder[this.data_index];
        let isSelected = this.data_index === selectedOrderItemIndex;
        //log("InterfaceReordering_Message", "Updated line to \"" + "Name " + item.name + " Type " + item.type + "\"");
        return {
            text: formatToReorderingListColumns(this.data_index + 1, item.name, item.type),
            color: isSelected ? "yellowColor" : "default",
            alignment: "LEFT"
        }
    }

    const selectListItem = function () {
        let item = interfaceOrder[this.data_index];
        selectedOrderItemKey = item.key;
        selectedOrderItemIndex = this.data_index;

        screens.moveItem.screenParameters.initialChoicesKey = undefined;
        screens.moveItem.runPageWithLineIndex(this.data_index);
        return true;
    }

    const spacerLine = new ILib.EmptyLine();
    const delimiterLine = new ILib.LabelLine(ILib.$repeatPattern("-", 32));
    const interfaceOrderListHeaderLine = new ILib.LabelLine(formatToReorderingListColumns("#", "Name", "Type"));

    const interfaceOrderListScreenParameters = {
        title: "Reorder interfaces",
        screenID: "InterfaceReordering_Main",
        allowInterrupt: true,
        exitScreen: "GUI_SCREEN_INTERFACES"
    }

    const interfaceOrderListScreen = new ILib.PagedScreenController(interfaceOrderListScreenParameters);
    interfaceOrderListScreen: {
        this.$screens.main = interfaceOrderListScreen;
        this.$screens.interfaceOrderList = interfaceOrderListScreen;

        interfaceOrderListScreen.addToHeader(interfaceOrderListHeaderLine);
        interfaceOrderListScreen.addToHeader(delimiterLine);

        interfaceOrderListScreen.addToFooter(delimiterLine);
        interfaceOrderListScreen.addToFooter(new ILib.ButtonLine("Save changes")
            .onUpdate(function () {
                return {
                    color: "yellowColor", // selectable color
                    unselectable: !interfaceOrderWasChanged
                };
            })
            .onSelected(saveChangesAndResetSelected)
        );
        interfaceOrderListScreen.addToFooter(new ILib.ButtonLine("Add interface")
            .onSelected(function () {
                screens.addInterface.runScreen();
                return true;
            })
        );
        interfaceOrderListScreen.addToFooter(new ILib.ButtonLine("Add category")
            .onSelected(function () {
                screens.addCategory.runScreen();
                return true;
            })
        );
        interfaceOrderListScreen.addToFooter(new ILib.ButtonLine("Reset")
            .onSelected(resetOrderList)
        );
        interfaceOrderListScreen.addToFooter(spacerLine);


        const updateReorderingListScreen = function updateReorderingListScreen() {

            this.clearContent(); // this === interfaceOrderListScreen;

            const reorderListCount = interfaceOrder.length;

            for (let i = 0; i < reorderListCount; i++) {
                let line = new ILib.ButtonLine("Line")
                    .onUpdate(updateListItem)
                    .onSelected(selectListItem);
                line.data_index = i;
                this.addToContent(line);
            }
        }

        interfaceOrderListScreen.onRun(updateReorderingListScreen);
        interfaceOrderListScreen.onReturn(cancelChangesAndResetSelected);
    }

    const returnToOrderList = interfaceOrderListScreen.returnToScreen.bind(interfaceOrderListScreen);

    const moveItemScreen = new ILib.PagedScreenController(interfaceOrderListScreenParameters);
    moveItemScreen: {
        this.$screens.moveItem = moveItemScreen;

        moveItemScreen.addToHeader(interfaceOrderListHeaderLine);
        moveItemScreen.addToHeader(delimiterLine);

        moveItemScreen.addToFooter(delimiterLine);
        moveItemScreen.addToFooter(new ILib.ButtonLine("Deselect")
            .onSelected(function () {
                selectedOrderItemKey = null;
                selectedOrderItemIndex = null;
                interfaceOrderListScreen.screenParameters.initialChoicesKey = interfaceOrderListScreen.selectedChoicesKey;
                interfaceOrderListScreen.runPage(moveItemScreen.currentPage);
                return true;
            })
        );
        moveItemScreen.addToFooter(new ILib.ButtonLine("Move up")
            .onUpdate(function () {
                return {
                    unselectable: interfaceOrder.length <= 1
                };
            })
            .onSelected(function () {
                let minPosition = 0;
                let newPosition = selectedOrderItemIndex - 1;
                if (newPosition < minPosition)
                    newPosition = interfaceOrder.length - 1;

                let temp = interfaceOrder[selectedOrderItemIndex];
                interfaceOrder[selectedOrderItemIndex] = interfaceOrder[newPosition];
                interfaceOrder[newPosition] = temp;

                selectedOrderItemIndex = newPosition;

                interfaceOrderWasChanged = true;
                moveItemScreen.runPageWithLineIndex(selectedOrderItemIndex, true);
                return true;
            })
        );
        moveItemScreen.addToFooter(new ILib.ButtonLine("Move down")
            .onUpdate(function () {
                return {
                    unselectable: interfaceOrder.length <= 1
                };
            })
            .onSelected(function () {
                let maxPosition = interfaceOrder.length - 1;
                let newPosition = selectedOrderItemIndex + 1;
                if (newPosition > maxPosition)
                    newPosition = 0;

                let temp = interfaceOrder[selectedOrderItemIndex];
                interfaceOrder[selectedOrderItemIndex] = interfaceOrder[newPosition];
                interfaceOrder[newPosition] = temp;

                selectedOrderItemIndex = newPosition;

                interfaceOrderWasChanged = true;
                moveItemScreen.runPageWithLineIndex(selectedOrderItemIndex, true);
                return true;
            })
        );
        moveItemScreen.addToFooter(new ILib.ButtonLine("Remove")
            .onUpdate(function () {
                return {
                    unselectable: selectedOrderItemKey === "InterfaceReordering_OTHER"
                }
            })
            .onSelected(function () {
                if (selectedOrderItemKey !== "InterfaceReordering_OTHER") {
                    interfaceOrder.splice(selectedOrderItemIndex, 1);
                    let returnIndex = selectedOrderItemIndex === 1 ? 0 : selectedOrderItemIndex - 1;

                    selectedOrderItemKey = null;
                    selectedOrderItemIndex = null;

                    interfaceOrderWasChanged = true;
                    interfaceOrderListScreen.runPageWithLineIndex(returnIndex);

                    return true;
                }
            })
        );
        moveItemScreen.addToFooter(spacerLine);

        const updateMoveItemScreen = function updateMoveItemScreen() {
            this.clearContent(); // this === updateMoveItemScreen;

            const reorderListCount = interfaceOrder.length;

            for (let i = 0; i < reorderListCount; i++) {
                let line = new ILib.LabelLine("Line")
                    .onUpdate(updateListItem);
                line.data_index = i;
                this.addToContent(line);
            }
        }

        moveItemScreen.onRun(updateMoveItemScreen);
        moveItemScreen.onReturn(cancelChangesAndResetSelected);
    }


    /*** Adding order items to main list ***/

    const addItemAndReturnToList = function (pair, type) {
        const newItemIndex = interfaceOrder.length;
        interfaceOrder.push({ key: pair.key, name: pair.name, type: type });

        interfaceOrderWasChanged = true;
        screens.interfaceOrderList.screenParameters.initialChoicesKey = screens.interfaceOrderList.selectedChoicesKey;
        screens.interfaceOrderList.runPageWithLineIndex(newItemIndex);
        return true;
    }

    const addAllItemsAndReturnToList = function (items, type) {
        let lastItemIndex;
        for (let i = 0; i < items.length; i++) {
            let pair = items[i];
            lastItemIndex = interfaceOrder.length;
            interfaceOrder.push({ key: pair.key, name: pair.name, type: type });
        }

        interfaceOrderWasChanged = true;
        screens.interfaceOrderList.screenParameters.initialChoicesKey = screens.interfaceOrderList.selectedChoicesKey;
        screens.interfaceOrderList.runPageWithLineIndex(lastItemIndex);
        return true;
    }

    const updateItems = function updateItems(type, makePairFunc) {
        const itemsInUse = interfaceOrder.reduce(function (accum, orderItem) {
            if (orderItem.type === type)
                accum[orderItem.key] = 1;
            return accum;
        }, {});

        this.clearContent();
        let noItemsToAdd = true;
        const items = [];

        for (let key in interfacesOnStation) {
            let id = interfacesOnStation[key];
            if (id === null)
                continue;

            const pair = makePairFunc(key, id);
            if (itemsInUse[pair.key])
                continue;
            itemsInUse[pair.key] = 1;

            let line = new ILib.ButtonLine(pair.name).onSelected(function () {
                return addItemAndReturnToList(this.data_pair, this.data_type)
            });
            line.data_pair = pair;
            line.data_type = type;
            this.addToContent(line);

            noItemsToAdd = false;
            items.push(pair);
        }

        if (noItemsToAdd)
            this.addToContent(new ILib.LabelLine("No items to add", "CENTER"));
        this.data_items = items;
    }

    addInterfaceScreen: {
        const addInterfaceScreen = this.$screens.addInterface = new ILib.PagedScreenController({
            title: "Add interface",
            screenID: "InterfaceReordering_AddInterface",
            allowInterrupt: true,
            exitScreen: "GUI_SCREEN_INTERFACES"
        }, returnToOrderList)
            .onRun(function () {
                updateItems.call(this, "Interface", function (key, id) {
                    return { key: key, name: id.title };
                });
            });

        addInterfaceScreen.addToHeader(delimiterLine);
        addInterfaceScreen.addToFooter(delimiterLine);
        addInterfaceScreen.addToFooter(new ILib.ButtonLine("Add all")
            .onUpdate(function () {
                let isItemsToAdd = addInterfaceScreen.data_items &&
                    addInterfaceScreen.data_items.length > 0;
                return {
                    unselectable: !isItemsToAdd
                };
            })
            .onSelected(function () {
                const items = addInterfaceScreen.data_items;
                return addAllItemsAndReturnToList.call(this, items, "Interface")
            })
        );
    }

    addCategoryScreen: {
        const addCategoryScreen = this.$screens.addCategory = new ILib.PagedScreenController({
            title: "Add category",
            screenID: "InterfaceReordering_AddCategory",
            allowInterrupt: true,
            exitScreen: "GUI_SCREEN_INTERFACES"
        }, returnToOrderList)
            .onRun(function () {
                updateItems.call(this, "Category", function (key, id) {
                    return { key: id.category, name: id.category };
                });
            });

        addCategoryScreen.addToHeader(delimiterLine);
        addCategoryScreen.addToFooter(delimiterLine);
        addCategoryScreen.addToFooter(new ILib.ButtonLine("Add all")
            .onUpdate(function () {
                let isItemsToAdd = addCategoryScreen.data_items &&
                    addCategoryScreen.data_items.length > 0;
                return {
                    unselectable: !isItemsToAdd
                };
            })
            .onSelected(function () {
                const items = addCategoryScreen.data_items;
                return addAllItemsAndReturnToList.call(this, items, "Category")
            })
        );
    }
}

// Order updating //

this.$updateInterfaceOrder = function $updateInterfaceOrder(newReorderings) {
    const originalSetInterface = this.$originalSetInterface;

    for (let stationId = 0; stationId < this.$stationIdToInterfacesMap.length; stationId++) {
        let station = this.$idToStationMap[stationId];
        if (!station || !station.isValid) {
            this.$idToStationMap[stationId] = null;
            this.$stationIdToInterfacesMap[stationId] = null;
            continue;
        }

        let interfaces = this.$stationIdToInterfacesMap[stationId];
        for (let key in interfaces) {
            originalSetInterface.call(station, key, null); // hide all
        }
    }

    this.$interfacePrefixes = this.$makeInterfacePrefixes(newReorderings);
    this.$interfaceOrder = newReorderings.slice(); // NOT SPLICE
    this.$unsavedInterfaceOrder.length = 0;
    Array.prototype.push.apply(this.$unsavedInterfaceOrder, this.$interfaceOrder);

    log("InterfaceReordering_Message", "New prefixes set: " + JSON.stringify(this.$interfacePrefixes));

    for (let stationId = 0; stationId < this.$stationIdToInterfacesMap.length; stationId++) {
        let station = this.$idToStationMap[stationId];
        if (!station)
            continue;

        let interfaces = this.$stationIdToInterfacesMap[stationId];
        for (let key in interfaces) {
            let interfaceDefinition = interfaces[key];
            station.setInterface(key, interfaceDefinition); // show with new rules
        }
    }
}

this.$makeInterfacePrefixes = function $makeInterfacePrefixes(interfaceOrder) {
    return this.$makeInterfacePrefixes_V1(interfaceOrder);
}

/// Use zero width and hair spaces, on some computers they even don't display as "?"
this.$makeInterfacePrefixes_V0 = function $makeInterfacePrefixes_V0(interfaceOrder) {
    // 0x200A - hair space character
    // 0x200B - zero width character
    const markerBeforeOthers = String.fromCharCode(0x200A) + String.fromCharCode(0x200B) + String.fromCharCode(0x200B);
    const markerOthers = String.fromCharCode(0x200B) + String.fromCharCode(0x200A) + String.fromCharCode(0x200B);
    const markerAfterOthers = String.fromCharCode(0x200B) + String.fromCharCode(0x200B) + String.fromCharCode(0x200A);
    const markerDepth = String.fromCharCode(0x200B);

    const interfacePrefixes = {/* key: prefix */ };

    let beforeOthers = true;
    let depthLevel = 1;
    for (let i = 0; i < interfaceOrder.length; i++) {
        let orderItem = interfaceOrder[i];
        if (orderItem.type === "") {
            beforeOthers = false;
            depthLevel = 1;
        }
        else if (beforeOthers) {
            interfacePrefixes[orderItem.key] = markerBeforeOthers + new Array(depthLevel).join(markerDepth);
        }
        else {
            interfacePrefixes[orderItem.key] = markerAfterOthers + new Array(depthLevel).join(markerDepth);
        }
        depthLevel += 1;
    }

    interfacePrefixes["InterfaceReordering_OtherPrefix"] = markerOthers;

    return interfacePrefixes;
}

this.$makeInterfacePrefixes_V1 = function $makeInterfacePrefixes_V1(interfaceOrder) {
    const markerBeforeOthers = String.fromCharCode(0x1F, 0x1F);
    const markerOthers = String.fromCharCode(0x1F, 0x200A);
    const markerAfterOthers = String.fromCharCode(0x200A, 0x200A);
    const markerDepth = String.fromCharCode(0x200A);

    const interfacePrefixes = {/* key: prefix */ };

    let beforeOthers = true;
    let depthLevel = 1;
    for (let i = 0; i < interfaceOrder.length; i++) {
        let orderItem = interfaceOrder[i];
        if (orderItem.type === "") {
            beforeOthers = false;
            depthLevel = 1;
        }
        else if (beforeOthers) {
            interfacePrefixes[orderItem.key] = markerBeforeOthers + new Array(depthLevel).join(markerDepth);
        }
        else {
            interfacePrefixes[orderItem.key] = markerAfterOthers + new Array(depthLevel).join(markerDepth);
        }
        depthLevel += 1;
    }

    interfacePrefixes["InterfaceReordering_OtherPrefix"] = markerOthers;

    return interfacePrefixes;
}