# CLN: A simplistic CLI using XML to store commands
# Copyright (C) 2009  Niels Serup
#  
#	This file is part of CLN.
#
#	CLN is free software: you can redistribute it and/or modify
#	it under the terms of the GNU General Public License as published by
#	the Free Software Foundation, either version 3 of the License, or
#	(at your option) any later version.
#
#	CLN 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 General Public License for more details.
#
#	You should have received a copy of the GNU General Public License
#	along with CLN.  If not, see <http://www.gnu.org/licenses/>.

# Version 0.1
# See README for information regarding this program


# Imports
import sys, os, pygame, threading, random
from pygame.locals import *
from xml.etree.cElementTree import parse
from time import sleep

# Importing extra functions. It is not possible to use the import command in
# this case, as defs.py contains references to global variables not existant
# in defs.py. That is, defs.py is unable to run without cln.py.
def_file = 'dat/defs.py'
try: execfile(def_file)
except: print 'Error in extra functions'

class thread(threading.Thread):
    # Threading is used by CLN when getting user input
    
    def __init__(self, what):
        threading.Thread.__init__(self)
        self.what = what
        self.start()
        #self.join()
        
    def run(self):
        exec(self.what)
        del self

class keytools():
    # Keeps track of the various keys used by CLN
    
    def __init__(self):
        self.keys = dict()
    
    def add(self, n, v):
        self.keys[n] = v
    
    def match(self, n, v):
        if self.keys[n].count(v) > 0:
            return True
        else:
            return False
        
class texttools():
    # Controls all of the text displayed in the CLN window
    
    def __init__(self):
        self.alltxt = '>' # Start
        self.txt = ''
        self.stop = len(self.alltxt) # The '>' character is 1 character long
        self.scrolled = 0 # Not scrolled
        self.length = len(self.alltxt) # It's 1 at the beginning ('>')
        self.pos = [self.length, self.length] # No chars entered
        self.font = pygame.font.Font('dat/fonts/VeraMono.ttf', 13)
        self.working = False # It's not doing much
        self.getting = False # It's not getting user input
        self.svdtxt = '' # Saves current text in case of command switcing
        self.cmdmem = list() # Previously used commands
        self.cmdmemnum = 0 # Number of previously used commands

    def add(self, name = '', wa = 0, wb = 1):
        # Adds text to the CLN window
        
        self.posgen(wa)
        
        self.alltxt = self.alltxt[:self.pos[0]] + name + self.alltxt[self.pos[0]:]
        self.txt = self.txt[:self.pos[0] - self.stop] + name + self.txt[self.pos[0] - self.stop:]
        self.length = len(self.alltxt)
        
        self.posgen(wb)
        self.scrolled = 0 # No scrolling here

    def back(self):
        # Deletes 1 character (backspace)
        
        self.posgen(0) # Focus
        
        if self.pos[0] > self.stop:
            self.alltxt = self.alltxt[:self.pos[0] - 1] + self.alltxt[self.pos[0]:]
            self.txt = self.txt[:self.pos[0] - self.stop - 1] + self.txt[self.pos[0] - self.stop:]
        self.length = len(self.alltxt)
        self.posgen(-1) # Go back 1 step
        
        global highlighting
        highlighting = False
        
        self.scrolled = 0 # No scrolling here
    
    def delback(self):
        # Deletes 1 character (delete)
        
        self.posgen(0) # Focus
        if self.pos[0] > self.stop - 1:
            self.alltxt = self.alltxt[:self.pos[0]] + self.alltxt[self.pos[0] + 1:]
            self.txt = self.txt[:self.pos[0] - self.stop] + self.txt[self.pos[0] - self.stop + 1:]
        self.length = len(self.alltxt)
        
        global highlighting
        highlighting = False
        
        self.scrolled = 0 # No scrolling here
    
    def posgen(self, w = 0):
        # Positions text cursor somewhere
        
        if w == 0:
            if self.pos[0] > self.pos[1]: pos = self.pos[1]
            else: pos = self.pos[0]
            self.pos = [pos, pos]
            if pos < self.stop: self.pos = [self.stop, self.stop]
        elif w > 0:
            if self.pos[0] < self.length:
                self.pos[0] += w
                self.pos[1] += w
        elif w == -1:
            if self.pos[0] > self.stop:
                self.pos[0] -= 1
                self.pos[1] -= 1
        elif w == -2:
            self.pos = [self.length, self.length]
        elif w == -3:
            if self.pos[0] > self.stop and self.pos[1] > self.stop:
                self.pos = [self.pos[0], self.pos[0]]
            else:
                if self.pos[0] > self.pos[1]: pos = self.pos[1]
                else: pos = self.pos[0]
                self.pos = [pos, pos]
                if pos < self.stop: self.pos = [self.stop, self.stop]

    def setstop(self):
        # Where to stop
        
        self.stop = len(self.alltxt)

    def newline(self):
        # Adds newline
        
        if not self.getting:
            thread('text.process()')
        else:
            global freetogo
            freetogo = False
            self.getting = False
            self.alltxt += '\n'        
            self.length = len(self.alltxt)
            self.pos = [self.length, self.length]
        
        global highlighting
        highlighting = False
        
        self.scrolled = 0 # No scrolling here

    def inp2arr(self):
        # Converts text input with spaces into list
        
        inp = ' ' + self.txt.strip() + ' '
        inps=list()
        inp_len=len(inp)
        
        quot = '' # No quotes at start    
        p=0 # Starting point is 0

        # Looping through characters.. characters.. characters..
        for x in range(1, inp_len):
            curr=inp[x] # Current character
            prev=inp[x-1] # Previous character

            # To quote or not to quote
            if quot == '' and inp[x-1] == ' ' and (curr == '"' or curr == "'"):
                quot = curr
            elif (curr == '"' and quot == '"') or (curr == "'" and quot == "'"):
                quot = ''

            # Various checking
            if curr == ' ' and quot == '' and prev != '\\' and prev != ' ':
                toadd = inp[p:x].strip()
                if (toadd[0] == '"' and toadd[-1:] == '"') or (toadd[0] == "'" and toadd[-1:] == "'"):
                    toadd = toadd[1:-1]
                toadd = toadd.replace('\\ ', ' ')
                inps.append(toadd)
                p=x # The former ending point turns into the new starting point

        if p < x: # If inp looks like "do 'nil" (odd-placed comment before letters)
            inps.append(inp[p:x].strip())
        return inps
    
    def paste(self):
        # Pastes text into CLN
        
        txt = pygame.scrap.get(SCRAP_TEXT)
        if txt:
            txt = txt[:-1].replace('\r\n', ' / ').replace('\n', ' / ').replace('\r', ' / ')
            self.alltxt = self.alltxt[:self.pos[0]] + txt + self.alltxt[self.pos[0]:]
            self.txt = self.txt[:self.pos[0] - self.stop] + txt + self.txt[self.pos[0] - self.stop:]
            self.length = len(self.alltxt)
            self.posgen(len(txt))
            
            self.scrolled = 0 # No scrolling here
    
    def copy(self):
        # Copies text from CLN
        
        if self.pos[0] > self.pos[1]:
            start = self.pos[1]
            end = self.pos[0]
        else:
            start = self.pos[0]
            end = self.pos[1]
        pygame.scrap.put(SCRAP_TEXT, self.alltxt[start:end])

    def del_whitespace(self, txt = ''):
        # Intelligent strip
        # Does the same as strip but maintains indentation levels
        
        indented = lambda txt = '': len(txt) - len(txt.lstrip())
        spl = txt.split('\n')
        indent = -1
        ntxt = ''
        for x in spl:
            stripped = x.strip()
            if stripped != '' or indent != -1:
                if indent == -1:
                    indent = indented(x)
                spaces = indented(x) - indent
                if spaces < 0: spaces = 0
                ntxt += '\n' + ' ' * spaces + x.strip()
        return ntxt[1:].rstrip()

    def execute(self, inps, code):
        # Executes Python code
        
        ilen = len(inps) # Is sometimes used by code
        exec(code)

    def process(self):
        # Processes entered text (may be a command)
        
        self.working = True
        global freetogo
        freetogo = False
        
        self.alltxt += '\n'
        self.setstop()
        # Processing
        if self.txt != '':
            inps = self.inp2arr()
            cmd = inps[0]
            self.cmdmem.append(cmd)
            self.cmdmemnum = 0

            cnode = False
            for node in command_topelem:
                if getsubitem(node, {'tag': 'name', 'text': cmd}) != None: # Does it exist?
                    cnode = node # IT EXISTS!
                    break # Phew..
            
            if cnode:
                code = getsubitem(cnode, {'tag': 'code'})
                #try:
                self.execute(inps, self.del_whitespace(code.text))
                #except:
                #self.add('Congratulations! You have somehow fooled this program into making an error (though this probably have something to do with the command itself, and not CLN.')
            else: self.add('The command "' + cmd + '" does not exist.')
            
            self.alltxt += '\n'
        # Other stuff
        self.alltxt += '>'
        self.txt = '' # New line, new text
        self.setstop()

        freetogo = True
        self.working = False
        self.length = len(self.alltxt)
        self.pos = [self.length, self.length]
        
    def scroll(self, level = 0):
        # Scrolls up or down
        self.scrolled += level

    def chunk_split(self, nl_inc = False):
        # Splits text into lines or max character blocks.
        
        txt = self.alltxt.split('\n')
        if nl_inc: # Newline include
            for x in range(len(txt)):
                txt[x] += '\n'
            #txt[-1] = txt[-1][:-1] # Still no newline at last line
        
        ntxt = list()
        for n in txt:
            nlen = len(n)
            if nlen > 0:
                for x in range(0, nlen, width):
                    ntxt.append(n[x:x+width])
            else:
                ntxt.append('')
        return ntxt
    
    def linecheck(self, m):
        # Returns the amount of characters needed to go m line down
        
        pos = self.pos[0]
        
        txtlst = self.chunk_split(True)
        lstlen = len(txtlst)
        c = 0
        f = False
        md = (m - 1) / 2
        for x in range(lstlen):
            c += len(txtlst[x])
            if c > pos:
                f = x + m
                break
        
        if f > -1 and f < lstlen:
            wa = len(txtlst[f])
            b = self.pos2coor(pos)
            wb = b[0]
            wc = len(txtlst[b[1]])
            
            if m < 0:
                if wa > wb:
                    w = wa
                else:
                    w = wb
                rv = m * w
                if self.length > pos + rv - 1:
                    if self.alltxt[pos + rv - 1] == '\n': rv -= 1
            else:
                if wa > wb:
                    w = wc
                else:
                    w = wc - wb + wa
                rv = w
            
            return rv
        elif f == -1:
            return -width
        elif f == lstlen:
            return width
        else:
            return 0
    
    def charcheck(self, m):
        # # Returns the amount of characters needed to go m characters
        
        if self.length > self.pos[0] + m and self.pos[0] + m > -1:
            if self.alltxt[self.pos[0] + m] == '\n' and m < 0:
                m *= 2
        if self.length > self.pos[0] and self.pos[0] > -1:
            if self.alltxt[self.pos[0]] == '\n' and m > 0:
                m += 1
        
        return m
    
    def actions(self):
        # Different actions and reactions, Slightly messy.
        
        global highlighting
        key = key_order
        if key[0] == 'backspace':
            if freetogo: self.back()
        elif key[0] == 'delete':
            if freetogo: self.delback()
        elif key[0] == 'enter' or key[0] == 'return':
            if freetogo: self.newline()
        elif key[0] == 'page up' and not highlighting:
            text.scroll(1)
        elif key[0] == 'page down' and not highlighting:
            text.scroll(-1)
        elif key[0] == 'home' and not highlighting:
            self.pos = [self.stop, self.stop]
        elif key[0] == 'end' and not highlighting:
            self.pos = [self.length, self.length]
        elif key[0] == 'f11':
            global fullscreen
            if not fullscreen: fullscreen = True
            else: fullscreen = False
            fullscreen_change()
        elif len(key) > 1 and keys.match('alt', key[1]) and keyname_order[0] == 'h':
            if highlighting:
                highlighting = False
                self.posgen(-3)
            else: highlighting = True 
        elif not highlighting and len(key) > 1 and keys.match('ctrl', key[1]) and keyname_order[0] == 'v':
            self.paste()
        elif highlighting and len(key) > 1 and keys.match('ctrl', key[1]) and keyname_order[0] == 'c':
            self.copy()
        elif highlighting and len(key) > 1 and keys.match('ctrl', key[1]) and keyname_order[0] == 'a':
            self.highlight_all()
        elif highlighting and keys.match('dir', key[0]):
            # Moving and highlighting for eventual copying
            if len(key) > 1 and keys.match('ctrl', key[1]):
                if key[0] == 'up': change = self.linecheck(-1)
                elif key[0] == 'right': change = 1
                elif key[0] == 'down': change = self.linecheck(1)
                elif key[0] == 'left': change = -1
                self.move(change)
            else:
                if key[0] == 'up': change = self.linecheck(-1)
                elif key[0] == 'right': change = self.charcheck(1)
                elif key[0] == 'down': change = self.linecheck(1)
                elif key[0] == 'left': change = self.charcheck(-1)
                self.highlight(change)
        elif not highlighting and keys.match('hori_dir', key[0]):
            if key[0] == 'left': change = -1
            elif key[0] == 'right': change = 1
            self.nhmove(change)
        elif not highlighting and freetogo and not text.getting and keys.match('verti_dir', key[0]):
            if key[0] == 'up': self.choose_cmd(-1)
            elif key[0] == 'down': self.choose_cmd(1)
        elif len(key[0]) == 1:
            if freetogo:
                if highlighting:
                    self.add(key[0], -2)
                    highlighting = False
                else:
                    self.add(key[0])
    
    def choose_cmd(self, i):
        # Shows previously entered command
        if self.cmdmemnum == 0:
            self.svdtxt = self.txt
        cl = len(self.cmdmem)
        nn = self.cmdmemnum + cl + i
        if nn > -1 and nn < cl:
            self.cmdmemnum += i
            txt = self.cmdmem[self.cmdmemnum]
        elif nn == cl:
            self.cmdmemnum = 0
            txt = self.svdtxt
        
        if nn >= 0 and nn <= cl:
            tl = len(self.txt)
            if tl > 0:
                alltxt = self.alltxt[:-tl]
            else:
                alltxt = self.alltxt
            self.alltxt = alltxt + txt
            self.txt = txt
            self.length = len(self.alltxt)
            self.pos = [self.length, self.length]
            
    def nhmove(self, change):
        # Changes the position of the text cursor
        
        self.pos[0] += change
        self.pos[1] = self.pos[0]

        if self.pos[0] < self.stop:
            self.pos = [self.stop, self.stop]
        elif self.pos[0] > self.length:
            self.pos = [self.length, self.length]
        
        self.scrolled = 0 # No scrolling here
    
    def move(self, change):
        # Changes the position of the text cursor in highlight mode
        
        if self.pos[0] > self.pos[1] and change < 0:
            self.pos[0] = self.pos[1]
        elif self.pos[0] > self.pos[1] and change > 0:
            self.pos[0] = self.pos[0]
        elif self.pos[0] < self.pos[1] and change > 0:
            self.pos[0] = self.pos[1]
        elif self.pos[0] < self.pos[1] and change < 0:
            self.pos[0] = self.pos[0]
        else:
            self.pos[0] += change
        
        self.pos[1] = self.pos[0]

        if self.pos[0] < 0:
            self.pos = [0, 0]
        elif self.pos[0] > self.length:
            self.pos = [self.length, self.length]
    
    def highlight(self, change):
        # Highlights text
         
        self.pos[0] += change
        if self.pos[0] < 0:
            self.pos[0] = 0
        elif self.pos[0] > self.length:
            self.pos[0] = self.length
    
    def highlight_all(self):
        # Highlights the universe
        
        self.pos = [0, self.length]
    
    def pos2coor(self, pos):
        # Transforms a position (1 number) into a coordinate set (2 numbers)
        
        txtlst = self.chunk_split(True)
        lstlen = len(txtlst)
        c = 0
        f = False
        for x in range(lstlen):
            c += len(txtlst[x])
            if c > pos:
                f = x
                break
        return [len(txtlst[f]) - (c - pos), f]

    def render(self):
        # Renders text.
        
        txt = self.chunk_split() # Returns list with lines
        txtlen = len(txt)
        scrolled = 0 # Assumes stuff's good
        if txtlen > height: scrolled = txtlen - height # Show newest text
        
        if self.scrolled > scrolled:
            self.scrolled = scrolled # Don't show blank lines before text
        elif self.scrolled < 0:
            self.scrolled = 0 # Don't show blank lines after text
        self_scrolled = self.scrolled
        tcolor = (0, 255, 0)
        
        # Eventual highlighting
        if highlighting:
            self_scrolled = 0
            hcolor = (100, 100, 100)
            tcolor = (127, 127, 255)
            
            if (self.pos[0] > self.pos[1]):
                start = self.pos[1]
                end = self.pos[0]
            else:
                start = self.pos[0]
                end = self.pos[1]
            
            start_inf = self.pos2coor(start)
            end_inf = self.pos2coor(end)
            
            if self.pos2coor(self.length)[1] - start_inf[1] + 1> height:
                scrolled = start_inf[1]
            
            if self.pos[0] != self.pos[1]:
                                
                if start_inf[1] != end_inf[1]:
                    if (len(txt[start_inf[1]]) - start_inf[0]) > 0:
                        x = start_inf[1]
                        rendered = self.font.render(' ' * (len(txt[x]) - start_inf[0]), 1, hcolor, hcolor)
                        screen.blit(rendered, (l_width * start_inf[0], l_height * (x - scrolled) - 1))
                    
                    if end_inf[0] > 0:
                        x = end_inf[1]
                        rendered = self.font.render(' ' * (end_inf[0]), 1, hcolor, hcolor)
                        screen.blit(rendered, (0, l_height * (x - scrolled) - 1))
                    
                    for x in range(start_inf[1] + 1, end_inf[1]):
                        rendered = self.font.render(' ' * len(txt[x]), 1, hcolor, hcolor)
                        screen.blit(rendered, (0, l_height * (x - scrolled) - 1))
                else:
                    x = start_inf[1]
                    rendered = self.font.render(' ' * (end - start), 1, hcolor, hcolor)
                    screen.blit(rendered, (l_width * start_inf[0], l_height * (x - scrolled) - 1))
                    
            else:
                d = self.pos2coor(self.pos[0])
                w = d[0]
                h = d[1]
                rendered = self.font.render('|', 1, (127, 255, 127))
                screen.blit(rendered, (l_width * w - l_width / 2, l_height * (h - scrolled) - 1))
        
        # Cursor in case of wanting to delete somewhere inside the current text
        if self.pos[0] == self.pos[1] and self.pos[0] < self.length and self.stop - 1 < self.pos[0] and not highlighting:
            try:
                w = d[0]
            except:
                d = self.pos2coor(self.pos[0])
                w = d[0]
            h = d[1]
            rendered = self.font.render('|', 1, (127, 255, 127)) # Text cursor
            screen.blit(rendered, (l_width * w - l_width / 2, l_height * (h - scrolled + self_scrolled) - 1))
        
        # Outputting the text
        for x in range(scrolled - self.scrolled, txtlen):
            rendered = self.font.render(txt[x], 1, tcolor)
            screen.blit(rendered, (0, l_height * (x - scrolled + self_scrolled) - 1))

def main():
    # Runs CLN
    
    # Globals
    global fullscreen, width, height, l_width, l_height, screen, background, command_file, var_file, command_topelem, text, key_order, keynum_order, keyname_order, uniname_order, keys, fps, spf, done, freetogo, highlighting
    
    fullscreen = False # Default
    
    command_file = 'dat/commands.xml'
    var_file = 'dat/vars.ini'
    
    # Loading the commands
    if os.path.isfile(command_file):
        command_tree = parse(command_file)
    else:
        print 'Error loading the commands'
        sys.exit()
    command_topelem = command_tree.getroot()
    
    # Importing variables
    if os.path.isfile(var_file):
        getvars(open(var_file, 'r').read())
    else:
        f = open(var_file, 'w')
        f.write('# This is the file containing global variables. There shouldn\'t be any need to edit this file manually, as CLN will take care of that. If this file is deleted, it will automatically be restored by CLN on startup. All lines with a \'#\' in the start and/or no equal sign will be treated as comments.')
    
    # Simple
    width = 80 # Characters
    height = 25 # Lines
    l_width = 8 # Letters
    l_height = 16 # Letters
    
    pygame.init() # LET IT BEGIN!
    
    icon = pygame.image.load('dat/icon.png')
    pygame.display.set_icon(icon)
    screen = pygame.display.set_mode((width * l_width, height * l_height))
    fullscreen_change()
    background = pygame.Surface(screen.get_size()).convert()
    background.fill((0, 0, 0))
    
    pygame.display.set_caption('cln')
    pygame.mouse.set_visible(0)
    
    pygame.scrap.init() # Copy/paste
    
    text = texttools()
    key_order = list()
    keynum_order = list()
    keyname_order = list()
    uniname_order = list()
    autotyping_wait = 0
    
    # Organise keys
    keys = keytools()
    
    keys.add('special', [
        'backspace',
        'delete',
        'enter',
        'return',
        'page up',
        'page down',
        'f11',
        'left ctrl',
        'right ctrl',
        'left alt',
        'right alt',
        'left shift',
        'right shift',
        'up',
        'right',
        'down',
        'left',
        'home',
        'end'
    ])
    
    keys.add('ctrl', [
        'left ctrl',
        'right ctrl'
    ])
    
    keys.add('alt', [
        'left alt',
        'right alt'
    ])
    
    keys.add('shift', [
        'left shift',
        'right shift'
    ])
    
    keys.add('dir', [
        'up',
        'right',
        'down',
        'left'
    ])
    
    keys.add('hori_dir', [
        'right',
        'left'
    ])
    
    keys.add('verti_dir', [
        'up',
        'down'
    ])

    fps = 25 # Frames per second
    spf = 1000 / fps

    done = False # Not done!
    freetogo = True # No stop signs
    
    highlighting = False

    # LISTENING TO EVENTS
    while not done:
        pygame.time.Clock().tick(fps)
        for event in pygame.event.get():
            if event.type == QUIT:
                done = True
                break
            
            elif event.type == KEYDOWN:
                keynum = event.key
                keyname = pygame.key.name(keynum)
                uniname = event.unicode
                if keys.match('special', keyname):
                    key = keyname
                elif len(keyname) == 3 and keyname[0] == '[' and keyname[2] == ']': # If numpad
                    key = keyname[1]
                elif len(keyname) > 1 and keyname != 'space':
                    key = None
                else:
                    key = uniname

                if key:
                    key_order.insert(0, key)
                    keynum_order.insert(0, keynum)
                    keyname_order.insert(0, keyname)
                    uniname_order.insert(0, uniname)
                    
                    #if len(key_order) == 1:
                    # Uncomment the above to wait only
                    # if the key is the only one pressed
                    
                    text.actions()
                    autotyping_wait = 500

            elif event.type == KEYUP:
                if len(key) > 0 and event.key == keynum_order[0]:
                    autotyping_wait = 500
                for x in range(len(key_order)):
                    if event.key == keynum_order[x]:
                        key_order.pop(x)
                        keynum_order.pop(x)
                        keyname_order.pop(x)
                        uniname_order.pop(x)
                        break

        if len(key_order) > 0:
            if autotyping_wait > 0:
                autotyping_wait -= spf
            else:
                text.actions()

        screen.blit(background, (0, 0))
        text.render()
        pygame.display.flip()
    
    sys.exit()
    # Using sys.exit() is needed sometimes. When text is being processed in
    # a thread of its own, sometimes code will suffer from the Halting Problem.
    # An infinite get() loop in one of the commands will make it impossible to
    # CLN without using force.
if __name__ == '__main__': main()
# Phew..