//@title Tunginobi's Console Module
//@author Tunginobi
//@version 1.01
//@released Official release
//@link none
//@filedescription Adds text console functions to Sphere.
//@target Sphere 1.1

//#notes The console object provides the Sphere RPG engine with a way to
//#notes output text to the screen and provide a method of offering
//#notes plain text input from the keyboard.
//#notes 
//#notes The console object consists of an array of strings representing
//#notes the lines of the console. It is possible to store more lines
//#notes than can be viewed at once, so a side scrollbar is available.
//#notes 
//#notes Usage:
//#notes 
//#notes Create a new Console object.
//#notes   var funky_console = new Console(...);
//#notes 
//#notes For output, call the Console.print() method.
//#notes   funky_console.print("I am some output text.");
//#notes 
//#notes Then display it using the Console.render() method. Note that
//#notes you can check if a render is in order with the
//#notes Console.needUpdate property.
//#notes   if (funky_console.needUpdate)
//#notes   {
//#notes       funky_console.render();
//#notes       FlipScreen();
//#notes   }
//#notes 
//#notes If you want to have text inputted, use the Console.input()
//#notes method. Note that you don't need to manually use FlipScreen(),
//#notes and you can set pre- and post-render functions. Also note that
//#notes the functions and input-handling run at a constant framerate,
//#notes and you can set a default value.
//#notes   var user_name = funky_console.input("Bob");


//#log 2004.11.01 Created this console module file. Wrote notes on basic
//#log 2004.11.01 logic of console module.

//#log 2004.11.05 Restarted writing of console code since changing core
//#log 2004.11.05 console logic. Rewriting notes on logic of console
//#log 2004.11.05 object.

//#log 2004.11.09 Completed rendering function of console object.
//#log 2004.11.09 Repaired line rendering in rendering function. Coded
//#log 2004.11.09 the character and word wrapping features. Implemented
//#log 2004.11.09 the option to allow programmers to create a console
//#log 2004.11.09 object on one line. It is long, so it is not
//#log 2004.11.09 recommended. Completed coding of the input routine.

//#log 2004.11.10 Minor input adjustment. Doesn't crash with strange
//#log 2004.11.10 characters in the input anymore. Word wraps correctly.

//#log 2004.11.11 Fixed a basic coding error that caused a crash if two
//#log 2004.11.11 or more words that needed character wrapping in word
//#log 2004.11.11 wrap mode. I think it's finally done.

//#aim 100% Write Console object.
//#aim 100% Write Console object properties.
//#aim 100% Write Console output routines.
//#aim 100% Write Console input routines.

//#bug 2004.11.01 None yet.

//#bug 2004.11.10 * FIXED *
//#bug 2004.11.10 If a whole block of non-whitespace characters that
//#bug 2004.11.10 aren't letters are inputted, the program freezes.

//#bug 2004.11.10 * FIXED *
//#bug 2004.11.10 The above code was previously thought to have been
//#bug 2004.11.10 fixed, but it had to do with "words" longer than a
//#bug 2004.11.10 whole line on its own. The word pusher would enter an
//#bug 2004.11.10 infinite loop if it encountered such a word.

//#bug 2004.11.10 * FIXED *
//#bug 2004.11.10 In an attempt to word wrap properly, long words were
//#bug 2004.11.10 allowed to have additional words at the end if there
//#bug 2004.11.10 was enough space. This resulted in the final wrapped
//#bug 2004.11.10 part of the long word to be repeated as a result.
//#bug 2004.11.10 An earlier fix of this resulted in a double space
//#bug 2004.11.10 after the long wrapped word. This was corrected today.

//#bug 2004.11.11 * FIXED *
//#bug 2004.11.11 Word wrap. Crashed when more than one word requiring
//#bug 2004.11.11 dissection due to overwidth was processed. Very
//#bug 2004.11.11 simple one line fix.

/* CONSOLE OBJECT *****************************************************/

const SCROLLBAR_WIDTH = 8;
const MAX_INPUT_LENGTH = 256;

//@function Console(x, y, width, height, font, windowStyle, scrollbar, scrollbarTrack, scrollbarHandle, lineLimit, autoRender, wrapMode, inputColor, preRender, postRender)
//@description Console object constructor.
//@param 'x' Integer specifying the x coordinate of the console.
//@param 'y' Integer specifying the y coordinate of the console.
//@param 'width' Integer specifying the width of the console.
//@param 'height' Integer specifying the height of the console.
//@param 'font' Sphere font object used to draw console text during a render.
//@param 'windowStyle' Sphere windowstyle object used to draw the box in which the console text is rendered.
//@param 'scrollbar' Defaults to true, which indicates that a scrollbar should be rendered.
//@param 'scrollbarTrack' Sphere color object, defaults to semitransparent black. Describes color of scrollbar track.
//@param 'scrollbarHandle' Sphere color object, defaults to white. Color used to render the scrollbar.
//@param 'lineLimit' Integer specifying the maximum number of lines for the console to store in memory.
//@param 'autoRender' Defaults to false. If true, any output functions eg. Console.print() will render the console after executed.
//@param 'wrapMode' Has one of three string values. If "word", then printed text is wrapped by groups of non-whitespace characters. If "character", the text is wrapped by any characters that exceed the available width of the console.
//@param 'inputColor' Sphere color object specifying the color used to draw the background box for input.
//@param 'preRender' Function called before the console is drawn.
//@param 'postRender' Function called after the console is drawn.
//@returns A new Console object.
//@endfunc
function Console(x, y, width, height, font, windowStyle, scrollbar, scrollbarTrack, scrollbarHandle, lineLimit, autoRender, wrapMode, inputColor, preRender, postRender)
{
    if (this instanceof Console == false)
    {
        return new Console(x, y, width, height, font, windowStyle, scrollbar, scrollbarTrack, scrollbarHandle, lineLimit, autoRender, wrapMode, inputColor, inputPreRender, inputPostRender);
    }
    
    // Positioning
    this.x = x || 0;
    this.y = y || 0;
    this.width = width || GetScreenWidth();
    this.height = height || GetScreenHeight();
    
    // Appearance
    this.font = font || GetSystemFont();
    this.windowStyle = windowStyle || GetSystemWindowStyle();
    
    this.scrollbarTrack = scrollbarTrack || CreateColor(0, 0, 0, 128);
    this.scrollbarHandle = scrollbarHandle || CreateColor(255, 255, 255, 255);
    
    // Behaviour
    this.lineLimit = lineLimit || 100;
    this.scrollbar = scrollbar || true;
    this.autoRender = autoRender || false;
    this.wrapMode = wrapMode || "word";
    
    // Input features
    this.inputColor = inputColor || CreateColor(0, 0, 0, 128);
    this.preRender = preRender || function () {}
    this.postRender = postRender || function () {}
    
    // Internal
    this.lines = new Array(); // The lines stored in console's memory.
    this.needUpdate = true; // You can use this to see if the console needs to be updated.
    this.cursorRate = 0; // Used internally to blink input cursor.
    
    return this;
}

//@function Console.render()
//@description Renders the Console object onto the screen.
//@returns Undefined.
//@endfunc
Console.prototype.render = function ()
{
    var visible_lines = 0;
    var available_width = 0;
    var handle_height = 0;
    
    SetClippingRectangle(0, 0, GetScreenWidth(), GetScreenHeight());
    
    this.preRender();
    
    with (this)
    {
        visible_lines = Math.floor(height / font.getHeight());
        
        // Draw windowstyle
        windowStyle.drawWindow(x, y, width, height);
        
        // Draw scrollbar
        if (scrollbar)
        {
            available_width = width - SCROLLBAR_WIDTH;
            
            // Track
            Rectangle(x + width - SCROLLBAR_WIDTH, y, SCROLLBAR_WIDTH, height, scrollbarTrack);
            
            // Handle
            if (visible_lines < lines.length)
            {
                handle_height = Math.round((visible_lines / lines.length) * (height - 2));
                Rectangle(x + width - SCROLLBAR_WIDTH + 1, y + height - handle_height - 1, SCROLLBAR_WIDTH - 2, handle_height, scrollbarHandle);
            }
            else
            {
                handle_height = height - 2;
                Rectangle(x + width - SCROLLBAR_WIDTH + 1, y + 1, SCROLLBAR_WIDTH - 2, handle_height, scrollbarHandle);
            }
        }
        else
        {
            available_width = width;
        }
        
        // Draw text
        if (lines.length > 0)
        {
            var start_from = Math.max(0, lines.length - visible_lines);
            
            SetClippingRectangle(x, y, available_width, height);
            for (var i = start_from; i < (start_from + Math.min(visible_lines, lines.length)); i++)
            {
                font.drawText(x, y + (i - start_from) * font.getHeight(), lines[i]);
            }
        }
        
        needUpdate = false;
    }
    
    SetClippingRectangle(0, 0, GetScreenWidth(), GetScreenHeight());
    
    this.postRender();
}

//@function Console.wrap()
//@description Wraps text in the console object according to its wrap property setting.
//@returns Undefined.
//@endfunc
Console.prototype.wrap = function ()
{
    var available_width = this.scrollbar ? this.width - 8 : this.width;
    
    switch (this.wrapMode)
    {
        case "character":
        {
            // Wrap text based on characters.
            
            var clipped_line = "";
            var working_line = "";
            var queue_wrap = new Array();
            var current_char = 0;
            
            for (var current_line = 0; current_line < this.lines.length; current_line++)
            {
                // Check lines fit in available width
                if (this.font.getStringWidth(this.lines[current_line]) > available_width)
                {
                    working_line = "";
                    current_char = 0;
                    
                    // Make a line that fits
                    while (this.font.getStringWidth(working_line) < available_width && current_char < this.lines[current_line].length)
                    {
                        working_line += this.lines[current_line][current_char];
                        if (this.font.getStringWidth(working_line) >= available_width)
                        {
                            clipped_line = working_line.slice(0, working_line.length - 1);
                            working_line = "";
                            queue_wrap.push(clipped_line);
                        }
                        else
                        {
                            current_char++;
                            if (current_char >= this.lines[current_line].length)
                            {
                                queue_wrap.push(working_line);
                            }
                        }
                    }
                    
                    // Insert the clipped lines
                    this.lines.splice(current_line, 1);
                    while (queue_wrap.length > 0)
                    {
                        this.lines.splice(current_line, 0, queue_wrap.pop());
                    }
                }
            }
        }
        break;
        
        case "word":
        {
            // Wrap text based on contiguous chains of non-whitespace
            // characters.
            
            var find_spaces = new RegExp("\\s+", "g");
            var length_diff = 0;
            var working_line = "";
            var words = new Array();
            var spaces = new Array();
            var current_space = "";
            var new_line = "";
            var queue_wrap = new Array();
            
            var funky_word = "";
            var wrapped = true;
            
            for (var current_line = 0; current_line < this.lines.length; current_line++)
            {
                if (this.font.getStringWidth(this.lines[current_line]) > available_width)
                {
                    working_line = this.lines[current_line];
                    
                    if (find_spaces.test(working_line))
                    {
                        // Snatch the words
                        words = working_line.split(find_spaces);
                        
                        // Snatch the whitespace characters
                        find_spaces.lastIndex = 0
                        while (find_spaces.lastIndex < working_line.length)
                        {
                            current_space = find_spaces.exec(working_line);
                            if (current_space != null)
                            {
                                spaces.push(current_space[0]);
                            }
                            else
                            {
                                break;
                            }
                        }
                        
                        // Shove in words
                        new_line = words.shift();
                        while (words.length > 0)
                        {
                            if (this.font.getStringWidth(new_line + spaces[0] + words[0]) > available_width)
                            {
                                queue_wrap.push(new_line);
                                new_line = "";
                                spaces.shift();
                                if (this.font.getStringWidth(words[0]) > available_width)
                                {
                                    funky_word = words[0];
                                    wrapped = true;
                                    while (wrapped)
                                    {
                                        wrapped = false;
                                        while (this.font.getStringWidth(funky_word) > available_width)
                                        {
                                            funky_word = funky_word.slice(0, funky_word.length - 1);
                                            wrapped = true;
                                        }
                                        if (wrapped)
                                        {
                                            queue_wrap.push(funky_word);
                                            words[0] = words[0].slice(funky_word.length);
                                            funky_word = words[0];
                                        }
                                        else
                                        {
                                            words.shift();
                                            new_line = funky_word;
                                        }
                                    }
                                }
                            }
                            else
                            {
                                if (words.length > 0)
                                {
                                    if (new_line != "" && spaces.length > 0)
                                    {
                                        new_line += spaces.shift();
                                    }
                                    new_line += words.shift();
                                }
                            }
                        }
                        if (new_line != "")
                        {
                            queue_wrap.push(new_line);
                        }
                        
                        // Pop those lines back into the lines array
                        this.lines.splice(current_line, 1);
                        while (queue_wrap.length > 0)
                        {
                            this.lines.splice(current_line, 0, queue_wrap.pop());
                        }
                    }
                }
            }
        }
        break;
        
        case "none":
        case "":
        default:
        {
            return;
        }
        break;
    }
}

/* CONSOLE OUTPUT FUNCTIONS *******************************************/

//@function Console.print(text)
//@description Prints text to the console object. Adds text to the current line.
//@param 'text' Text to be printed.
//@returns Undefined.
//@endfunc
Console.prototype.print = function (text)
{
    var text_lines = text.split("\n");
    
    if (this.lines.length > 0)
    {
        this.lines[this.lines.length - 1] += text_lines[0];
    }
    else
    {
        this.lines.push(text_lines[0]);
    }
    
    for (var i = 1; i < text_lines.length; i++)
    {
        this.lines.push(text_lines[i]);
    }
    
    this.needUpdate = true;
    
    this.wrap();
    this.clipLines();
    
    if (this.autoRender)
    {
        this.render();
    }
}

//@function Console.clear()
//@description Clears all stored lines in memory.
//@returns Undefined.
//@endfunc
Console.prototype.clear = function ()
{
    while (this.lines.length > 0) this.lines.shift();
    this.needUpdate = true;
    if (this.autoRender)
    {
        this.render();
    }
}

//@function Console.clipLines()
//@description Removes lines the oldest lines beyond the line limit.
//@returns Undefined.
//@endfunc
Console.prototype.clipLines = function ()
{
    for (var i = this.lines.length; i > this.lineLimit; i--)
    {
        this.lines.shift();
    }
}

/* CONSOLE INPUT FUNCTIONS ********************************************/

//@function Console.input(default_string)
//@description Shows an input area in the console.
//@param 'default_string' Value to return if the Escape key is pressed.
//@returns User keyboard input if Enter is pressed, or default_string if Escape is pressed.
//@endfunc
Console.prototype.input = function (default_string)
{
    var available_width = this.scrollbar ? this.width - SCROLLBAR_WIDTH - 1 : this.width;
    var return_string = default_string ? default_string : "";
    var cursor_pos = default_string.length;
    var done = false;
    
    var visible_lines = 0;
    var fantastic_update = false;
    
    var input_x = 0;
    var input_y = 0;
    var input_width = 0;
    
    var key;
    var shift = false;
    var offset = 0;
    
    var fps = IsMapEngineRunning() ? GetMapEngineFrameRate() : 60;
    var start_time = 0;
    var current_time = 0;
    var last_time = 0;
    var updates = 0;
    var frames = 0;
    
    while (AreKeysLeft())
    {
        GetKey();
    }
    
    this.cursorRate = 0;
    
    start_time = GetTime();
    last_time = start_time;
    
    visible_lines = Math.floor(this.height / this.font.getHeight());
    
    // Prepare input area
    if ((available_width - this.font.getStringWidth(this.lines[(this.lines.length - 1 > 0) ? this.lines.length - 1 : 0])) < this.font.getStringWidth("  "))
    {
        this.print("\n");
    }
    input_width = available_width - this.font.getStringWidth(this.lines[(this.lines.length - 1 > 0) ? this.lines.length - 1 : 0]);
    input_x = this.x + this.font.getStringWidth(this.lines[(this.lines.length - 1 > 0) ? this.lines.length - 1 : 0]);
    input_y = this.y + this.font.getHeight() * (Math.min(visible_lines, this.lines.length) - 1);
    
    // The input loop
    while (!done)
    {
        // The frame regulator
        fantastic_update = false;
        updates += (GetTime() - last_time) * fps / 1000;
        for (; frames < updates; frames++)
        {
            this.preRender();
            this.render();
            
            this.cursorRate++;
            if (this.cursorRate > 30) this.cursorRate = 0;
            
            // Calculate offset
            if (this.font.getStringWidth(return_string + "|") > input_width)
            {
                offset = Math.floor(this.font.getStringWidth(return_string.slice(0, cursor_pos)) - input_width * 0.5);
                if (offset < 0)
                {
                    offset = 0;
                }
                if (offset > this.font.getStringWidth(return_string + "|") - input_width)
                {
                    offset = this.font.getStringWidth(return_string + "|") - input_width;
                }
            }
            else
            {
                offset = 0;
            }
            
            // Draw input box
            Rectangle(input_x, input_y, input_width, this.font.getHeight(), this.inputColor);
            
            // Draw the text and cursor
            SetClippingRectangle(input_x, input_y, input_width, this.font.getHeight());
            this.font.drawText(input_x - offset, input_y, return_string);
            if (Math.floor(this.cursorRate / 15) == 0)
            {
                this.font.drawText(input_x + this.font.getStringWidth(return_string.slice(0, cursor_pos)) - offset, input_y, "|");
            }
            SetClippingRectangle(0, 0, GetScreenWidth(), GetScreenHeight());
            
            // Keyboard input handler
            // Based on input.js, but much improved and with bug fixes.
            while (AreKeysLeft())
            {
                key = GetKey();
                
                switch (key)
                {
                    case KEY_ENTER:
                    {
                        done = true;
                    }
                    break;
                    
                    case KEY_ESCAPE:
                    {
                        return_string = default_string;
                        done = true;
                    }
                    break;
                    
                    case KEY_LEFT:
                    {
                        cursor_pos = (cursor_pos > 0) ? cursor_pos - 1: cursor_pos;
                        this.cursorRate = 0;
                    }
                    break;
                    
                    case KEY_RIGHT:
                    {
                        cursor_pos = (cursor_pos < return_string.length) ? cursor_pos + 1 : cursor_pos;
                        this.cursorRate = 0;
                    }
                    break;
                    
                    case KEY_BACKSPACE:
                    {
                        if (cursor_pos > 0)
                        {
                            return_string = return_string.slice(0, cursor_pos - 1) + return_string.slice(cursor_pos);
                            cursor_pos--;
                            this.cursorRate = 0;
                        }
                    }
                    break;
                    
                    case KEY_DELETE:
                    {
                        if (cursor_pos < return_string.length)
                        {
                            return_string = return_string.slice(0, cursor_pos) + return_string.slice(cursor_pos + 1);
                            this.cursorRate = 0;
                        }
                    }
                    break;
                    
                    case KEY_HOME:
                    {
                        cursor_pos = 0;
                        this.cursorRate = 0;
                    }
                    break;
                    
                    case KEY_END:
                    {
                        cursor_pos = (return_string.length > 0) ? return_string.length : 0;
                        this.cursorRate = 0;
                    }
                    break;
                    
                    default:
                    {
                        shift = IsKeyPressed(KEY_SHIFT);
                        if (GetKeyString(key, shift) != "" && return_string.length < MAX_INPUT_LENGTH)
                        {
                            return_string = return_string.slice(0, cursor_pos) + GetKeyString(key, shift) + return_string.slice(cursor_pos);
                            cursor_pos++;
                            this.cursorRate = 0;
                        }
                    }
                    break;
                }
            }
            this.postRender();
            fantastic_update = true;
        }
        last_time = GetTime();
        if (fantastic_update && !done) FlipScreen();
    }
    
    this.print(return_string + "\n");
    
    return return_string;
}

/* EOF ****************************************************************/

