Welcome, guest | Sign In | My Account | Store | Cart
#! /usr/bin/env python
import tkinter.ttk
import tkinter.messagebox
import tkinter.font
import idlelib.textView

import datetime
import getpass
import os
import string
import random
import colorsys
import urllib.parse
import webbrowser
import pickle
import traceback
import sys
import contextlib
import io

################################################################################

class Color:

    HTML = dict(reversed(color.split(' ')) for color in '''\
#F0F8FF AliceBlue
#FAEBD7 AntiqueWhite
#00FFFF Aqua
#7FFFD4 Aquamarine
#F0FFFF Azure
#F5F5DC Beige
#FFE4C4 Bisque
#000000 Black
#FFEBCD BlanchedAlmond
#0000FF Blue
#8A2BE2 BlueViolet
#A52A2A Brown
#DEB887 BurlyWood
#5F9EA0 CadetBlue
#7FFF00 Chartreuse
#D2691E Chocolate
#FF7F50 Coral
#6495ED CornflowerBlue
#FFF8DC Cornsilk
#DC143C Crimson
#00FFFF Cyan
#00008B DarkBlue
#008B8B DarkCyan
#B8860B DarkGoldenRod
#A9A9A9 DarkGray
#A9A9A9 DarkGrey
#006400 DarkGreen
#BDB76B DarkKhaki
#8B008B DarkMagenta
#556B2F DarkOliveGreen
#FF8C00 Darkorange
#9932CC DarkOrchid
#8B0000 DarkRed
#E9967A DarkSalmon
#8FBC8F DarkSeaGreen
#483D8B DarkSlateBlue
#2F4F4F DarkSlateGray
#2F4F4F DarkSlateGrey
#00CED1 DarkTurquoise
#9400D3 DarkViolet
#FF1493 DeepPink
#00BFFF DeepSkyBlue
#696969 DimGray
#696969 DimGrey
#1E90FF DodgerBlue
#B22222 FireBrick
#FFFAF0 FloralWhite
#228B22 ForestGreen
#FF00FF Fuchsia
#DCDCDC Gainsboro
#F8F8FF GhostWhite
#FFD700 Gold
#DAA520 GoldenRod
#808080 Gray
#808080 Grey
#008000 Green
#ADFF2F GreenYellow
#F0FFF0 HoneyDew
#FF69B4 HotPink
#CD5C5C IndianRed
#4B0082 Indigo
#FFFFF0 Ivory
#F0E68C Khaki
#E6E6FA Lavender
#FFF0F5 LavenderBlush
#7CFC00 LawnGreen
#FFFACD LemonChiffon
#ADD8E6 LightBlue
#F08080 LightCoral
#E0FFFF LightCyan
#FAFAD2 LightGoldenRodYellow
#D3D3D3 LightGray
#D3D3D3 LightGrey
#90EE90 LightGreen
#FFB6C1 LightPink
#FFA07A LightSalmon
#20B2AA LightSeaGreen
#87CEFA LightSkyBlue
#778899 LightSlateGray
#778899 LightSlateGrey
#B0C4DE LightSteelBlue
#FFFFE0 LightYellow
#00FF00 Lime
#32CD32 LimeGreen
#FAF0E6 Linen
#FF00FF Magenta
#800000 Maroon
#66CDAA MediumAquaMarine
#0000CD MediumBlue
#BA55D3 MediumOrchid
#9370D8 MediumPurple
#3CB371 MediumSeaGreen
#7B68EE MediumSlateBlue
#00FA9A MediumSpringGreen
#48D1CC MediumTurquoise
#C71585 MediumVioletRed
#191970 MidnightBlue
#F5FFFA MintCream
#FFE4E1 MistyRose
#FFE4B5 Moccasin
#FFDEAD NavajoWhite
#000080 Navy
#FDF5E6 OldLace
#808000 Olive
#6B8E23 OliveDrab
#FFA500 Orange
#FF4500 OrangeRed
#DA70D6 Orchid
#EEE8AA PaleGoldenRod
#98FB98 PaleGreen
#AFEEEE PaleTurquoise
#D87093 PaleVioletRed
#FFEFD5 PapayaWhip
#FFDAB9 PeachPuff
#CD853F Peru
#FFC0CB Pink
#DDA0DD Plum
#B0E0E6 PowderBlue
#800080 Purple
#FF0000 Red
#BC8F8F RosyBrown
#4169E1 RoyalBlue
#8B4513 SaddleBrown
#FA8072 Salmon
#F4A460 SandyBrown
#2E8B57 SeaGreen
#FFF5EE SeaShell
#A0522D Sienna
#C0C0C0 Silver
#87CEEB SkyBlue
#6A5ACD SlateBlue
#708090 SlateGray
#708090 SlateGrey
#FFFAFA Snow
#00FF7F SpringGreen
#4682B4 SteelBlue
#D2B48C Tan
#008080 Teal
#D8BFD8 Thistle
#FF6347 Tomato
#40E0D0 Turquoise
#EE82EE Violet
#F5DEB3 Wheat
#FFFFFF White
#F5F5F5 WhiteSmoke
#FFFF00 Yellow
#9ACD32 YellowGreen'''.split('\n'))

    @property
    def best_name(self):
        diffs = []
        for name in self.HTML.keys():
            diffs.append((name, self.diff(getattr(self, name))))
        error = min(diffs, key=lambda pair: pair[1])[1]
        return tuple(pair[0] for pair in diffs if pair[1] == error)

    ########################################################################

    @classmethod
    def hsv(cls, hue, saturation, value):
        assert 0 <= hue <= 1 and 0 <= saturation <= 1 and 0 <= value <= 1
        r, g, b = colorsys.hsv_to_rgb(hue, saturation, value)
        return cls(round(r * 0xFF), round(g * 0xFF), round(b * 0xFF))

    @classmethod
    def parse(cls, string):
        assert len(string) == 7 and string[0] == '#'
        return cls(int(string[1:3], 16),
                   int(string[3:5], 16),
                   int(string[5:7], 16))

    ########################################################################

    def __init__(self, red, green, blue):
        self.__rgb = bytes((red, green, blue))

    def __str__(self):
        return '#{:02X}{:02X}{:02X}'.format(*self.__rgb)

    def __repr__(self):
        return '{}({}, {}, {})'.format(self.__class__.__name__, *self.__rgb)

    def __hash__(self):
        return hash(self.__rgb)

    def __eq__(self, other):
        return self.__rgb == other.__rgb

    ########################################################################

    @property
    def red(self):
        return self.__rgb[0]

    @property
    def green(self):
        return self.__rgb[1]

    @property
    def blue(self):
        return self.__rgb[2]

    r, g, b = red, green, blue

    def set_red(self, value):
        return self.__class__(value, self.g, self.b)

    def set_green(self, value):
        return self.__class__(self.r, value, self.b)

    def set_blue(self, value):
        return self.__class__(self.r, self.g, value)

    def add_red(self, value):
        return self.__class__(self.r + value & 0xFF, self.g, self.b)

    def add_green(self, value):
        return self.__class__(self.r, self.g + value & 0xFF, self.b)

    def add_blue(self, value):
        return self.__class__(self.r, self.g, self.b + value & 0xFF)

    ########################################################################

    @property
    def hue(self):
        return colorsys.rgb_to_hsv(self.__rgb[0] / 0xFF,
                                   self.__rgb[1] / 0xFF,
                                   self.__rgb[2] / 0xFF)[0]

    @property
    def saturation(self):
        return colorsys.rgb_to_hsv(self.__rgb[0] / 0xFF,
                                   self.__rgb[1] / 0xFF,
                                   self.__rgb[2] / 0xFF)[1]

    @property
    def value(self):
        return colorsys.rgb_to_hsv(self.__rgb[0] / 0xFF,
                                   self.__rgb[1] / 0xFF,
                                   self.__rgb[2] / 0xFF)[2]

    h, s, v = hue, saturation, value

    def set_hue(self, value):
        return self.hsv(value, self.s, self.v)

    def set_saturation(self, value):
        return self.hsv(self.h, value, self.v)

    def set_value(self, value):
        return self.hsv(self.h, self.s, value)

    def add_hue(self, value):
        return self.hsv(self.__mod(self.h + value), self.s, self.v)

    def add_saturation(self, value):
        return self.hsv(self.h, self.__mod(self.s + value), self.v)

    def add_value(self, value):
        return self.hsv(self.h, self.s, self.__mod(self.v + value))

    ########################################################################

    def invert(self):
        return self.__class__(0xFF - self.r, 0xFF - self.g, 0xFF - self.b)

    def rotate(self, value):
        return self.__class__(self.r + value & 0xFF,
                              self.g + value & 0xFF,
                              self.b + value & 0xFF)

    def diff(self, other):
        r = (self.r - other.r) ** 2
        g = (self.g - other.g) ** 2
        b = (self.b - other.b) ** 2
        return r + g + b

    def mix(self, bias, other):
        assert 0 <= bias <= 1
        alpha = 1 - bias
        return self.__class__(round(self.r * alpha + other.r * bias),
                              round(self.g * alpha + other.g * bias),
                              round(self.b * alpha + other.b * bias))

    @staticmethod
    def get(bias, *colors):
        assert 0 <= bias <= 1
        ranges = len(colors) - 1
        assert ranges > 0
        length = 1 / ranges
        index = int(bias / length)
        if index == ranges:
            return colors[-1]
        first, second = colors[index:index+2]
        return first.mix(bias % length / length, second)

    ########################################################################

    @staticmethod
    def __mod(value):
        div, mod = divmod(value, 1.0)
        if div > 0.0 and not mod:
            return 1.0
        return mod

for key, value in Color.HTML.items():
    setattr(Color, key, Color.parse(value))

################################################################################

class ColorOptions(tkinter.Toplevel):

    LABEL = dict(width=9, anchor=tkinter.CENTER)
    SCALE = dict(orient=tkinter.HORIZONTAL, length=256, from_=0.0, to=1.0)
    VALUE = dict(text='0.0', width=5, relief=tkinter.GROOVE)
    BYTE = dict(text='00', width=3, relief=tkinter.GROOVE,
                anchor=tkinter.CENTER)
    PADDING = dict(padx=2, pady=2)

    ########################################################################

    OPEN = False

    @classmethod
    def open_window(cls, root, color):
        # Only open if not already open and return selection.
        if not cls.OPEN:
            cls.OPEN = True
            window = cls(root, color)
            window.mainloop()
            return window.color
        return ''

    ########################################################################

    def __init__(self, master, color):
        super().__init__(master)
        self.transient(master)
        self.geometry('+{}+{}'.format(master.winfo_rootx(),
                                      master.winfo_rooty()))
        # Build all the widgets that will in the window.
        self.create_interface()
        # Populate the widgets with the correct settings.
        self.load_widget_settings(color)
        # Override the closing of this window to keep track of its state.
        self.protocol('WM_DELETE_WINDOW', self.ask_destroy)
        # Prepare the window for general display.
        self.title('Colors')
        self.resizable(False, False)
        # Create a message box to warn about closing.
        options = dict(title='Warning?', icon=tkinter.messagebox.QUESTION,
                       type=tkinter.messagebox.YESNO, message='''\
Are you sure you want to close?
You will lose all your changes.''')
        self.__cancel_warning = tkinter.messagebox.Message(self, **options)

    def load_widget_settings(self, color):
        # Set the colors.
        color = Color.parse(color)
        self.update_hsv(color.h, color.s, color.v)
        self.hsv_updated(color)

    @property
    def color(self):
        # Return the color of the canvas.
        return self.__color

    def ask_destroy(self):
        # Only close if user wants to lose settings.
        if self.__cancel_warning.show() == tkinter.messagebox.YES:
            self.destroy()
        else:
            self.focus_set()

    def destroy(self):
        # Destroy this window and unset the OPEN flag.
        super().destroy()
        self.quit()
        self.__class__.OPEN = False

    def create_interface(self):
        # Create all the widgets.
        self.rgb_scales = self.create_rgb_scales()
        self.hsv_scales = self.create_hsv_scales()
        self.color_area = self.create_color_area()
        self.input_buttons = self.create_buttons()
        # Place them on the grid.
        self.rgb_scales.grid(row=0, column=0)
        self.hsv_scales.grid(row=1, column=0)
        self.color_area.grid(row=2, column=0, sticky=tkinter.EW)
        self.input_buttons.grid(row=3, column=0, sticky=tkinter.EW)

    def create_rgb_scales(self):
        rgb_scales = tkinter.ttk.Labelframe(self, text='RGB Scales')
        # Create the inner widget.
        self.r_label = tkinter.ttk.Label(rgb_scales, text='Red', **self.LABEL)
        self.g_label = tkinter.ttk.Label(rgb_scales, text='Green', **self.LABEL)
        self.b_label = tkinter.ttk.Label(rgb_scales, text='Blue', **self.LABEL)
        self.r_scale = tkinter.ttk.Scale(rgb_scales, command=self.rgb_updated,
                                         **self.SCALE)
        self.g_scale = tkinter.ttk.Scale(rgb_scales, command=self.rgb_updated,
                                         **self.SCALE)
        self.b_scale = tkinter.ttk.Scale(rgb_scales, command=self.rgb_updated,
                                         **self.SCALE)
        self.r_value = tkinter.ttk.Label(rgb_scales, **self.VALUE)
        self.g_value = tkinter.ttk.Label(rgb_scales, **self.VALUE)
        self.b_value = tkinter.ttk.Label(rgb_scales, **self.VALUE)
        self.r_byte = tkinter.ttk.Label(rgb_scales, **self.BYTE)
        self.g_byte = tkinter.ttk.Label(rgb_scales, **self.BYTE)
        self.b_byte = tkinter.ttk.Label(rgb_scales, **self.BYTE)
        # Place widgets on grid.
        self.r_label.grid(row=0, column=0, **self.PADDING)
        self.g_label.grid(row=1, column=0, **self.PADDING)
        self.b_label.grid(row=2, column=0, **self.PADDING)
        self.r_scale.grid(row=0, column=1, **self.PADDING)
        self.g_scale.grid(row=1, column=1, **self.PADDING)
        self.b_scale.grid(row=2, column=1, **self.PADDING)
        self.r_value.grid(row=0, column=2, **self.PADDING)
        self.g_value.grid(row=1, column=2, **self.PADDING)
        self.b_value.grid(row=2, column=2, **self.PADDING)
        self.r_byte.grid(row=0, column=3, **self.PADDING)
        self.g_byte.grid(row=1, column=3, **self.PADDING)
        self.b_byte.grid(row=2, column=3, **self.PADDING)
        # Return the label frame.
        return rgb_scales

    def create_hsv_scales(self):
        hsv_scales = tkinter.ttk.Labelframe(self, text='HSV Scales')
        # Create the inner widget.
        self.h_label = tkinter.ttk.Label(hsv_scales, text='Hue', **self.LABEL)
        self.s_label = tkinter.ttk.Label(hsv_scales, text='Saturation',
                                         **self.LABEL)
        self.v_label = tkinter.ttk.Label(hsv_scales, text='Value', **self.LABEL)
        self.h_scale = tkinter.ttk.Scale(hsv_scales, command=self.hsv_updated,
                                         **self.SCALE)
        self.s_scale = tkinter.ttk.Scale(hsv_scales, command=self.hsv_updated,
                                         **self.SCALE)
        self.v_scale = tkinter.ttk.Scale(hsv_scales, command=self.hsv_updated,
                                         **self.SCALE)
        self.h_value = tkinter.ttk.Label(hsv_scales, **self.VALUE)
        self.s_value = tkinter.ttk.Label(hsv_scales, **self.VALUE)
        self.v_value = tkinter.ttk.Label(hsv_scales, **self.VALUE)
        self.h_byte = tkinter.ttk.Label(hsv_scales, **self.BYTE)
        self.s_byte = tkinter.ttk.Label(hsv_scales, **self.BYTE)
        self.v_byte = tkinter.ttk.Label(hsv_scales, **self.BYTE)
        # Place widgets on grid.
        self.h_label.grid(row=0, column=0, **self.PADDING)
        self.s_label.grid(row=1, column=0, **self.PADDING)
        self.v_label.grid(row=2, column=0, **self.PADDING)
        self.h_scale.grid(row=0, column=1, **self.PADDING)
        self.s_scale.grid(row=1, column=1, **self.PADDING)
        self.v_scale.grid(row=2, column=1, **self.PADDING)
        self.h_value.grid(row=0, column=2, **self.PADDING)
        self.s_value.grid(row=1, column=2, **self.PADDING)
        self.v_value.grid(row=2, column=2, **self.PADDING)
        self.h_byte.grid(row=0, column=3, **self.PADDING)
        self.s_byte.grid(row=1, column=3, **self.PADDING)
        self.v_byte.grid(row=2, column=3, **self.PADDING)
        # Return the label frame.
        return hsv_scales

    def create_color_area(self):
        # Create a display area set to black to begin with.
        color_area = tkinter.ttk.Labelframe(self, text='Color Sample')
        self.canvas = tkinter.Canvas(color_area, height=70,
                                     background='#000000')
        self.canvas.grid(row=0, column=0)
        return color_area

    def create_buttons(self):
        # Create a frame for the buttons.
        input_buttons = tkinter.ttk.Frame(self)
        # Create the buttons.
        self.empty_space = tkinter.ttk.Label(input_buttons, width=38)
        self.okay_button = tkinter.ttk.Button(input_buttons, text='Accept',
                                              command=self.accept)
        self.cancel_button = tkinter.ttk.Button(input_buttons, text='Cancel',
                                                command=self.cancel)
        # Place them on the grid.
        self.empty_space.grid(row=0, column=0, sticky=tkinter.EW)
        self.okay_button.grid(row=0, column=1, sticky=tkinter.EW)
        self.cancel_button.grid(row=0, column=2, sticky=tkinter.EW)
        # Return the containing frame.
        return input_buttons

    def accept(self):
        # Close the window and allow color to be returned.
        self.destroy()

    def cancel(self):
        # Cancel the color before closing window.
        self.__color = ''
        self.destroy()

    def rgb_updated(self, value):
        # Update the interface after RBG change.
        r = self.r_scale['value']
        g = self.g_scale['value']
        b = self.b_scale['value']
        self.update_rgb(r, g, b)
        h, s, v = colorsys.rgb_to_hsv(r, g, b)
        self.update_hsv(h, s, v)
        self.update_color_area()

    def hsv_updated(self, value):
        # Update the interface after HSV change.
        h = self.h_scale['value']
        s = self.s_scale['value']
        v = self.v_scale['value']
        self.update_hsv(h, s, v)
        r, g, b = colorsys.hsv_to_rgb(h, s, v)
        self.update_rgb(r, g, b)
        self.update_color_area()

    def update_rgb(self, r, g, b):
        # Update RGB values to those given.
        self.r_scale['value'] = r
        self.g_scale['value'] = g
        self.b_scale['value'] = b
        self.r_value['text'] = str(r)[:5]
        self.g_value['text'] = str(g)[:5]
        self.b_value['text'] = str(b)[:5]
        self.r_byte['text'] = '{:02X}'.format(round(r * 255))
        self.g_byte['text'] = '{:02X}'.format(round(g * 255))
        self.b_byte['text'] = '{:02X}'.format(round(b * 255))
        
    def update_hsv(self, h, s, v):
        # Update HSV values to those given.
        self.h_scale['value'] = h
        self.s_scale['value'] = s
        self.v_scale['value'] = v
        self.h_value['text'] = str(h)[:5]
        self.s_value['text'] = str(s)[:5]
        self.v_value['text'] = str(v)[:5]
        self.h_byte['text'] = '{:02X}'.format(round(h * 255))
        self.s_byte['text'] = '{:02X}'.format(round(s * 255))
        self.v_byte['text'] = '{:02X}'.format(round(v * 255))

    def update_color_area(self):
        # Change the color of preview area based on RGB.
        color = '#{}{}{}'.format(self.r_byte['text'],
                                 self.g_byte['text'],
                                 self.b_byte['text'])
        self.canvas['background'] = color
        self.__color = color

################################################################################

class AboutFSM(tkinter.Toplevel):

    NEW_FEATURES = '''\
What's New in FSM 2.5?
=========================

- Timestamps are still encoded in GMT but are automatically converted
  to local time when displayed. Program will not need to be restarted
  if daylight savings time changes while program is running.

- Errors will be recorded to the "FSM Settings" folder if any occur
  during execution. Once the program closes, the file will be created
  with a record of your name, the time, and a stack trace taken from
  the exceptions.

- Links are automatically created to files referenced in relative to
  the program's root folder. If FSM is running on Windows, clicking
  on those links will open the file. See General Help for more info.


What's New in FSM 2.4?
=========================

- Pressing F1 now brings up an "About FSM" box that allows access to
  various documentation regarding the program.

- Menus have been slightly modified in how they come up and close
  down. Fewer errors should be generated in the background when
  closing dialogs that own open child windows (though some may exist).


What's New in FSM 2.3?
=========================

- Pressing F2 allows access to user-settable options in the program.
  Reasonable defaults are provided, and the settings can easily be
  reset by deleting the settings file in the settings folder.

- Clicking on buttons brings up a custom color picker. The only way to
  set the color is by clicking on the "Okay" button at the bottom.

- Ten settings are supplied, but some do not take effect until restart
  while others only apply to new messages. To get the most current
  view according to the settings, the program must be restarted.


What's New in FSM 2.2?
=========================

- Wispering and reverse wispering is now possible. Writing "@[name]"
  before a message should allow only the intended recipient to view
  the message.

- Reverse wispering is accomplished by placing a "!" mark before the
  wispered message ("!@[name] message"). The person named should not
  receive the message.


What's New in FSM 2.1?
=========================

- Links are automatically recognized now when entered into messages.
  Clicking on them should open them up in your default browser.


What's New in FSM 2.0?
=========================

- Entire program was written from scratch. FSM 1 has been canceled
  and is not able to work with the new I/O system. Major version
  changes will probably continue based on changes to the I/O system
  that would not be compatible with older designs.'''

    GENERAL_HELP = '''\
Automatic Links
=========================

If FSM detects a special attribute of the text as described in the
following sections, it will create a "link" that highlights and
possibly reformats that text. Clicking on links is system dependent.

URL - If the text appears to be a URL, it will be highlighted and
      changed into a link that can be clicked on. Clicking on the link
      should try opening the URL in the system's default browser. As
      of right now, only HTTP, HTTPS, and FTP links can be recognized.

PATH - If the text has been formatted to reference a file relative to
       the program's root folder, then a link will be created will the
       file's name highlighted. If the OS is Windows, clicking on that
       link will open that file as though it have been double-clicked.

       Note: the syntax of the command is <path>. As an easy example:
       Has anyone checked out <Stephen Chappell\My Files.txt> yet?


Function Keys
=========================

F1 - Opens "About FSM" and displays a menu to open various bodies of
     documentation. You may browse the history of changes to this
     program, find out different features and how to use this program,
     and find a list of things yet to be accomplished in FSM.

F2 - Brings up a list of options that can be set to change the
     operation of FSM. Colors can be set by clicking on the buttons
     and using the color picker to select a new color. Some options do
     not take effect until restarting the program and cannot be
     changed by others. Options are saved to disk on program exit.


Writing Messages
=========================

Normal Wispering - If you want to write a message to one person, then
     you have the option of wispering to that person. Messages are
     always displayed will the origin's name in brackets beside it. To
     wisper to someone, write "@[name] message" where "name" is the
     person's name and the message follows special wisper syntax.

Reversed Wispering - When you want to send a message to everyone
     except someone in particular, you can reverse wisper by adding a
     "!" to the front of your wispered message. The full syntax for
     the command is "!@[name] message" and is simple to remember since
     "!" and "@" are right beside each other on the keyboard.


Effects of Settings
=========================

Message Settings - Different colors may be selected for highlighting
     messenger names. By default, normal text messages show up light
     blue, wispered text messages show up light red, and reverse
     wispers show up light green. Only names are actually colorized.

Timestamp Settings - If you want to see when a message was written,
     you can turn on timestamps. You have the ability of toggling if
     they are displayed along with the background and foreground color
     of the text.

Hyperlink Settings - When the program identifies possible links to web
     sites, they are changed into clickable text to open up the link
     in your default browser. You may change whether or not links are
     underlines along with the color they show up in.

Display Settings - Normally, only the past day's worth of messages are
     shown when the FSM opens. You can change this in the options to
     be up to ten days. You may also test your ability to read text
     that has been modified to test if spelling is as important as
     your English teachers says it is. You might be surprised.'''

    FUTURE_PLANS = '''\
There are no future plans for FSM at this time.'''

    ########################################################################

    STYLE = dict(padx=5, pady=5, sticky=tkinter.EW)

    OPEN = False

    @classmethod
    def open_window(cls, root):
        # Only open window if not already open.
        if not cls.OPEN:
            cls.OPEN = True
            window = cls(root)
            window.mainloop()
            cls.OPEN = False

    ########################################################################

    def __init__(self, master):
        super().__init__(master)
        self.geometry('+{}+{}'.format(master.winfo_rootx(),
                                      master.winfo_rooty()))
        self.transient(master)
        self.protocol('WM_DELETE_WINDOW', self.close)
        # Create the interface for this window.
        self.resizable(False, False)
        self.create_widgets()

    def create_widgets(self):
        self.title('About FSM')
        # Create a header for the buttons.
        self.font = tkinter.font.Font(self, family='arial', size=24,
                                      weight=tkinter.font.NORMAL)
        self.name = tkinter.ttk.Label(self, text=self.master.title(), width=20,
                                      font=self.font, anchor=tkinter.CENTER)
        self.name.grid(column=0, row=0, columnspan=3)
        # Separate head from the options.
        self.divide = tkinter.Frame(self, borderwidth=1, height=2,
                                    relief=tkinter.SUNKEN, bg='#777')
        self.divide.grid(column=0, row=1, columnspan=3, **self.STYLE)
        # Create buttons to open various informational dialogs.
        # ============
        # New Features
        # General Help
        # Future Plans
        self.new_features = \
            tkinter.ttk.Button(self, text='New Features',
                command=lambda: idlelib.textView.TextViewer(self.master,
                    'New Features', self.NEW_FEATURES))
        self.general_help = \
            tkinter.ttk.Button(self, text='General Help',
                command=lambda: idlelib.textView.TextViewer(self.master,
                    'General Help', self.GENERAL_HELP))
        self.future_plans = \
            tkinter.ttk.Button(self, text='Future Plans',
                command=lambda: idlelib.textView.TextViewer(self.master,
                    'Future Plans', self.FUTURE_PLANS))
        # Place the button on the window.
        self.new_features.grid(column=0, row=2, **self.STYLE)
        self.general_help.grid(column=1, row=2, **self.STYLE)
        self.future_plans.grid(column=2, row=2, **self.STYLE)

    def close(self):
        # Cancel execution of this widnow.
        self.destroy()
        self.quit()        

################################################################################

class SettingsDialog(tkinter.Toplevel):

    FRAME = dict(sticky=tkinter.EW, padx=4, pady=2)
    LABEL = dict(width=19)
    BUTTON = dict(width=5)
    BUTTON_GRID = dict(padx=1, pady=1)

    MIN_CUTOFF = 1
    MAX_CUTOFF = 240

    ########################################################################

    OPEN = False

    @classmethod
    def open_window(cls, root):
        # Only open settings if not open.
        if not cls.OPEN:
            cls.OPEN = True
            window = cls(root)
            window.mainloop()

    ########################################################################

    def __init__(self, master):
        super().__init__(master)
        self.geometry('+{}+{}'.format(master.winfo_rootx(),
                                      master.winfo_rooty()))
        self.transient(master)
        # Build all the widgets that will be in the window.
        self.create_interface()
        # Populate the widgets with the correct settings.
        self.load_widget_settings()
        # Override the closing of this window to keep track of its state.
        self.protocol('WM_DELETE_WINDOW', self.ask_destroy)
        # Prepare the window for general display.
        self.title('Settings')
        self.resizable(False, False)
        # Create a message box to warn about closing.
        options = dict(title='Warning?', icon=tkinter.messagebox.QUESTION,
                       type=tkinter.messagebox.YESNO, message='''\
Are you sure you want to close?
You will lose all your changes.''')
        self.__cancel_warning = tkinter.messagebox.Message(self, **options)

    def ask_destroy(self):
        # Only close if user wants to lose settings.
        if self.__cancel_warning.show() == tkinter.messagebox.YES:
            self.destroy()
        else:
            self.focus_set()

    def destroy(self):
        # Destroy this window and unset the OPEN flag.
        super().destroy()
        self.quit()
        self.__class__.OPEN = False

    def create_interface(self):
        # Create label frames for the different settings.
        self.message_settings()
        self.timestamp_settings()
        self.hyperlink_settings()
        self.display_settings()
        # Create buttons for accepting or cancelling changes.
        self.create_ok_cancel()

    def bind_color_button(self, button):
        # Setup a command for changing the color.
        button['command'] = lambda: self.get_new_color(button)

    def message_settings(self):
        # Create frame for widgets.
        m = self.message = tkinter.ttk.Labelframe(self, text='Message Settings')
        # Create the widgets.
        m.normal_label = tkinter.ttk.Label(m, text='Normal Text:', **self.LABEL)
        m.wisper_label = tkinter.ttk.Label(m, text='Wisper Text:', **self.LABEL)
        m.reverse_label = tkinter.ttk.Label(m, text='Reversed Text:',
                                            **self.LABEL)
        m.normal_button = tkinter.Button(m, **self.BUTTON)
        m.wisper_button = tkinter.Button(m, **self.BUTTON)
        m.reverse_button = tkinter.Button(m, **self.BUTTON)
        # Position the widgets.
        m.normal_label.grid(row=0, column=0)
        m.wisper_label.grid(row=1, column=0)
        m.reverse_label.grid(row=2, column=0)
        m.normal_button.grid(row=0, column=1, **self.BUTTON_GRID)
        m.wisper_button.grid(row=1, column=1, **self.BUTTON_GRID)
        m.reverse_button.grid(row=2, column=1, **self.BUTTON_GRID)
        # Configure the buttons.
        self.bind_color_button(m.normal_button)
        self.bind_color_button(m.wisper_button)
        self.bind_color_button(m.reverse_button)
        # Position the frame.
        m.grid(row=0, column=0, **self.FRAME)

    def timestamp_settings(self):
        # Create frame for widgets.
        t = self.timestamp = tkinter.ttk.Labelframe(self,
                                                    text='Timestamp Settings')
        # Create the widgets.
        t.show_string = tkinter.StringVar(t)
        t.show_checkbutton = tkinter.ttk.Checkbutton(t, text='Show Timestamp',
                                                     variable=t.show_string,
                                                     onvalue='True',
                                                     offvalue='False')
        t.background_label = tkinter.ttk.Label(t, text='Background Color:',
                                               **self.LABEL)
        t.foreground_label = tkinter.ttk.Label(t, text='Foreground Color:',
                                               **self.LABEL)
        t.background_button = tkinter.Button(t, **self.BUTTON)
        t.foreground_button = tkinter.Button(t, **self.BUTTON)
        # Position the widets.
        t.show_checkbutton.grid(row=0, column=0, columnspan=2)
        t.background_label.grid(row=1, column=0)
        t.foreground_label.grid(row=2, column=0)
        t.background_button.grid(row=1, column=1, **self.BUTTON_GRID)
        t.foreground_button.grid(row=2, column=1, **self.BUTTON_GRID)
        # Configure the buttons.
        self.bind_color_button(t.background_button)
        self.bind_color_button(t.foreground_button)
        # Position the frame.
        t.grid(row=1, column=0, **self.FRAME)

    def hyperlink_settings(self):
        # Create frame for widgets.
        h = self.hyperlink = tkinter.ttk.Labelframe(self,
                                                    text='Hyperlink Settings')
        # Create the widgets.
        h.underline_string = tkinter.StringVar(h)
        h.underline_checkbutton = \
            tkinter.ttk.Checkbutton(h, text='Underline Link',
                                    variable=h.underline_string,
                                    onvalue='True', offvalue='False')
        h.foreground_label = tkinter.ttk.Label(h, text='Foreground Color:',
                                               **self.LABEL)
        h.foreground_button = tkinter.Button(h, **self.BUTTON)
        # Position the widgets.
        h.underline_checkbutton.grid(row=0, column=0, columnspan=2)
        h.foreground_label.grid(row=1, column=0)
        h.foreground_button.grid(row=1, column=1, **self.BUTTON_GRID)
        # Configure the button.
        self.bind_color_button(h.foreground_button)
        # Position the frame.
        h.grid(row=2, column=0, **self.FRAME)

    def display_settings(self):
        # Create frame for widgets.
        d = self.display = tkinter.ttk.Labelframe(self, text='Display Settings')
        # Create the widgets.
        d.cutoff_label = tkinter.ttk.Label(d, text='Text Cutoff (hours):',
                                           **self.LABEL)
        d.cutoff_string = tkinter.StringVar(d)
        d.cutoff_spinbox = tkinter.Spinbox(d, from_=self.MIN_CUTOFF,
                                           to=self.MAX_CUTOFF,
                                           textvariable=d.cutoff_string,
                                           **self.BUTTON)
        d.confuse_string = tkinter.StringVar(d)
        d.confuse_checkbutton = tkinter.ttk.\
                                Checkbutton(d, text='Scramble Text',
                                            variable=d.confuse_string,
                                            onvalue='True', offvalue='False')
        # Position the widgets.
        d.cutoff_label.grid(row=0, column=0)
        d.cutoff_spinbox.grid(row=0, column=1)
        d.confuse_checkbutton.grid(row=1, column=0, columnspan=2)
        # Position the frame.
        d.grid(row=3, column=0, **self.FRAME)

    def create_ok_cancel(self):
        # Create frame for widgets.
        b = self.buttons = tkinter.ttk.Frame(self)
        # Create the widgets.
        b.accept = tkinter.ttk.Button(b, text='Accept', command=self.accept)
        b.label = tkinter.ttk.Label(b, width=3)
        b.cancel = tkinter.ttk.Button(b, text='Cancel', command=self.cancel)
        # Position the widgets.
        b.accept.grid(row=0, column=0)
        b.label.grid(row=0, column=1)
        b.cancel.grid(row=0, column=2)
        # Position the frame.
        b.grid(row=4, column=0, **self.FRAME)

    def accept(self):
        # Close window after changing settings.
        self.save_widget_settings()
        self.destroy()

    def cancel(self):
        # Close the window without changing anything.
        self.destroy()

    def save_widget_settings(self):
        # Save settings by their catagories.
        self.save_message_settings()
        self.save_timestamp_settings()
        self.save_hyperlink_settings()
        self.save_display_settings()

    def load_widget_settings(self):
        # Have the widgets display the correct information.
        self.load_message_settings()
        self.load_timestamp_settings()
        self.load_hyperlink_settings()
        self.load_display_settings()

    def load_message_settings(self):
        # Set the color for the name background.
        self.message.normal_button['background'] = SETTINGS.normal_message
        self.message.wisper_button['background'] = SETTINGS.wisper_message
        self.message.reverse_button['background'] = SETTINGS.reverse_wisper

    def save_message_settings(self):
        # Copy current settings back out to global settings.
        SETTINGS.normal_message = Color.parse(self.message.normal_button['bg'])
        SETTINGS.wisper_message = Color.parse(self.message.wisper_button['bg'])
        SETTINGS.reverse_wisper = Color.parse(self.message.reverse_button['bg'])

    def load_timestamp_settings(self):
        # Get timstamp settings and load them in the GUI.
        boolean = ('False', 'True')[SETTINGS.show_timestamp]
        self.timestamp.show_string.set(boolean)
        self.timestamp.background_button['bg'] = SETTINGS.time_background
        self.timestamp.foreground_button['bg'] = SETTINGS.time_foreground

    def save_timestamp_settings(self):
        # Take timestamp options and save in global settings.
        SETTINGS.show_timestamp = self.timestamp.show_string.get() == 'True'
        SETTINGS.time_background = \
            Color.parse(self.timestamp.background_button['bg'])
        SETTINGS.time_foreground = \
            Color.parse(self.timestamp.foreground_button['bg'])

    def load_hyperlink_settings(self):
        # Update the GUI according to the hyperlink settings.
        boolean = ('False', 'True')[SETTINGS.link_underline]
        self.hyperlink.underline_string.set(boolean)
        self.hyperlink.foreground_button['bg'] = SETTINGS.link_foreground

    def save_hyperlink_settings(self):
        # Save the hyperlink settings in the global settings object.
        SETTINGS.link_underline = \
            self.hyperlink.underline_string.get() == 'True'
        SETTINGS.link_foreground = \
            Color.parse(self.hyperlink.foreground_button['bg'])

    def load_display_settings(self):
        # Load the display settings into the GUI.
        self.display.cutoff_string.set(SETTINGS.message_cutoff)
        boolean = ('False', 'True')[SETTINGS.message_confuser]
        self.display.confuse_string.set(boolean)

    def save_display_settings(self):
        # Save user's setting for display for use in program.
        try:
            cutoff = int(self.display.cutoff_string.get())
        except ValueError:
            pass
        else:
            if self.MIN_CUTOFF <= cutoff <= self.MAX_CUTOFF:
                SETTINGS.message_cutoff = cutoff
        SETTINGS.message_confuser = self.display.confuse_string.get() == 'True'

    def get_new_color(self, button):
        # Try changing the color of the button.
        color = ColorOptions.open_window(self.master, button['background'])
        if color:
            button['background'] = color
        self.focus_force()

################################################################################

class Settings:

    FILENAME = 'settings.pickle'
    SLOTS = {'_Settings__path', '_Settings__data'}
    DEFAULT = {'normal_message': Color.LightSteelBlue,
               'wisper_message': Color.LightSteelBlue.set_hue(0),
               'reverse_wisper': Color.LightSteelBlue.set_hue(1 / 3),
               'show_timestamp': False,
               'time_background': Color.Black,
               'time_foreground': Color.White,
               'link_foreground': Color.Blue,
               'link_underline': True,
               'message_cutoff': 24,
               'message_confuser': False}
    
    def __init__(self, path):
        # Save the path and load settings from file.
        self.__path = path
        new, self.__data = self.get_settings()
        # If these the settings did not exist, create and save them.
        if new:
            self.save_settings()

    def get_settings(self):
        # Try opening and loading the settings from file.
        filename = os.path.join(self.__path, self.FILENAME)
        try:
            with open(filename, 'rb') as file:
                settings = pickle.load(file)
            # Test the pickle and check each setting inside it.
            assert isinstance(settings, dict)
            key_list = list(self.DEFAULT)
            for key in settings:
                assert isinstance(key, str)
                assert key in self.DEFAULT
                key_list.remove(key)
            # Add new settings as needed (when new ones are created).
            for key in key_list:
                settings[key] = self.DEFAULT[key]
            # Return old settings, or on error, the default settings.
            return False, settings
        except (IOError, pickle.UnpicklingError, AssertionError):
            return True, self.DEFAULT

    def save_settings(self):
        # Make the directory if it does not exist or check its type.
        if not os.path.exists(self.__path):
            os.makedirs(self.__path)
        elif os.path.isfile(self.__path):
            raise IOError('Directory cannot be created!')
        # Pickle and save the settings in the specified path (filename).
        filename = os.path.join(self.__path, self.FILENAME)
        with open(filename, 'wb') as file:
            pickle.dump(self.__data, file, pickle.HIGHEST_PROTOCOL)

    def __getattr__(self, name):        # Get an attribute.
        # If the name is an instance variable, return it.
        if name in self.SLOTS:
            return vars(self)[name]
        # Otherwise, get it from the settings stored in __data.
        return self.__data[name]

    def __setattr__(self, name, value): # Set an attribute.
        # If the name is an instance variable, go ahead and set it.
        if name in self.SLOTS:
            vars(self)[name] = value
        else:
            # Otherwise, store the setting in the __data attribute.
            self.__data[name] = value

################################################################################

random = random.SystemRandom().sample
string = string.digits + string.ascii_uppercase
# For version 3, use all ASCII letter (uppercase and lowercase).

uuid = lambda: ''.join(random(string, len(string)))

################################################################################

class DirectoryMonitor:

    def __init__(self, path):
        # Save directory path and file monitors (by path).
        self.__path = path
        self.__files = {}

    def update(self, callback):
        # Discover any files are new to the path.
        for name in os.listdir(self.__path):
            if self.valid_name(name) and name not in self.__files:
                path_name = os.path.join(self.__path, name)
                self.__files[name] = FileMonitor(path_name)
        errors = set()
        # Try updating each file monitor with reference to callback.
        for name, monitor in self.__files.items():
            try:
                monitor.update(callback)
            except OSError:
                errors.add(name)
        # Remove any problem files from the list.
        for name in errors:
            del self.__files[name]

    @staticmethod
    def valid_name(name):
        # There should be 36 characters in a valid name.
        if len(name) != len(string):
            return False
        # Every single character should be there (from the template).
        expected_characters = set(string)
        in_both = set(name) & expected_characters
        return in_both == expected_characters
        

################################################################################

class FileMonitor:

    def __init__(self, path):
        # Track mondification is a file and present position within file.
        self.__path = path
        self.__modified = 0
        self.__position = 0

    def update(self, callback):
        # Find out if the file has been modified.
        modified = os.path.getmtime(self.__path)
        if modified != self.__modified:
            # Remember the present time (we are getting an update).
            self.__modified = modified
            with open(self.__path, 'r') as file:
                # Go to present location, read to EOF, and remember position.
                file.seek(self.__position)
                try:
                    text = file.read()
                except UnicodeError:
                    print('Please report problem with:', repr(self.__path))
                    traceback.print_exc()
                    print('-' * 80)
                self.__position = file.tell()
            # Execute callback with file ID and new text update.
            callback(self.__path, text)

################################################################################

class Aggregator:

    def __init__(self):
        # Keep track of message streams.
        self.__streams = {}

    def update(self, path, text):
        # Create a new MessageStream if the path is not recognized.
        if path not in self.__streams:
            self.__streams[path] = MessageStream()
        # Split text on NULL and check that there is nothing following.
        parts = text.split('\0')
        if parts[-1]:
            raise IOError('Text is not properly terminated!')
        # Update stream with all message parts except the last empty one.
        self.__streams[path].update(parts[:-1])

    def get_messages(self):
        all_messages = []
        # Get all new messages waiting in the streams.
        for stream in self.__streams.values():
            all_messages.extend(stream.get_messages())
        # Return them sorted by the timestamps.
        return sorted(all_messages, key=lambda message: message.time)

################################################################################

class MessageStream:

    def __init__(self):
        # Save name, buffered tail, and any waiting messages.
        self.__name = None
        self.__buffer = None
        self.__waiting = []

    def update(self, parts):
        # If there is no name, assume the first part is the name.
        if self.__name is None:
            self.__name = parts.pop(0)
        # If something is in the buffer, add it to front of parts and clear.
        if self.__buffer is not None:
            parts.insert(0, self.__buffer)
            self.__buffer = None
        # If the parts length is odd, save tail in the buffer.
        if len(parts) & 1:
            self.__buffer = parts.pop()
        # Append new, waiting messages to the list.
        for index in range(0, len(parts), 2):
            self.__waiting.append(Message(self.__name, *parts[index:index+2]))

    def get_messages(self):
        # Return the messages and clear the list.
        messages = self.__waiting
        self.__waiting = []
        return messages

################################################################################

class Message:

    def __init__(self, name, timestamp, text):
        self.name = name
        try:
            # Try to parse the timestamp.
            self.time = datetime.datetime.strptime(timestamp,
                                                   '%Y-%m-%dT%H:%M:%SZ')
            self.text = text.strip()
        except ValueError:
            # The messages appear corrupt.
            self.time = datetime.datetime.utcnow()
            self.text = '[STREAM IS CORRUPT]'
        # Assume this is a normal message (for name color).
        self.tag = 'name'

################################################################################

class MessageWriter:

    def __init__(self, path, name):
        # Check the name, save it, and set a couple other variables.
        assert '\0' not in name, 'Name may not have null characters!'
        self.__name = name
        self.__primed = False
        self.__path = os.path.join(path, self.__find(path))

    def __find(self, path):
        # For each file in the directory ...
        for name in os.listdir(path):
            full_path = os.path.join(path, name)
            if os.path.isfile(full_path):
                # Check the first 256 bytes for a name.
                with open(full_path, 'r') as file:
                    data = file.read(256).split('\0', 1)[0]
                # If (your) name was found, file exists.
                if data == self.__name:
                    self.__primed = True
                    return name
        # A new file will need to be created with a unique identifier.
        return uuid()

    def write(self, text):
        # Check the message for invalid characters and try priming the file.
        assert '\0' not in text, 'Text may not have null characters!'
        self.prime()
        # Save the message as (timestamp, null, text, null) in the file.
        timestamp = datetime.datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
        with open(self.__path, 'a') as file:
            file.write(timestamp + '\0' + text + '\0')

    def prime(self):
        if not self.__primed:
            # Write name to file followed by a null.
            with open(self.__path, 'w') as file:
                file.write(self.__name + '\0')
            self.__primed = True
                
################################################################################

# This code provides error logging facilities.

def main():
    # Figure out where files should be stored.
    public_path = os.path.join('Message Storage', 'V2.5')
    private_path = os.path.join('..', 'FSM Settings')
    # Execute the main class (static) function of FSM.
    with capture_stderr() as stderr:
        FSM.main(public_path, private_path)
    # Cleanup stderr and save and errors to file.
    record(stderr, private_path, 'errorlog.pickle')

@contextlib.contextmanager
def capture_stderr():
    # Provide a context manager that captures standard error.
    orig_stderr = sys.stderr
    sys.stderr = io.StringIO()
    try:
        yield sys.stderr
    finally:
        sys.stderr = orig_stderr

def record(stream, path, filename):
    # Find out if there were any errors during execution.
    errors = stream.getvalue()
    if errors:
        # Save them to a pickled file with a timestamp.
        with open(os.path.join(path, filename), 'ab', 0) as file:
            problem = getpass.getuser(), datetime.datetime.utcnow(), errors
            pickle.dump(problem, file)

################################################################################

class FSM(tkinter.ttk.Frame):

    @classmethod
    def main(cls, log_path, settings_path):
        # Create a global settings object for the application.
        global SETTINGS
        SETTINGS = Settings(settings_path)
        # Create the root GUI object.
        tkinter.NoDefaultRoot()
        root = tkinter.Tk()
        # Bind an event handler for closing the program.
        def on_close():
             SETTINGS.save_settings()
             root.destroy()
             root.quit()
        root.protocol('WM_DELETE_WINDOW', on_close)
        # Set the window title and minimum size for the window.
        name = os.path.splitext(os.path.basename(sys.argv[0]))[0]
        root.title(name)
        root.minsize(320, 240)  # QVGA
        # Create, position, and setup FSM widget for resizing.
        view = cls(root, log_path)
        view.grid(row=0, column=0, sticky=tkinter.NSEW)
        root.grid_rowconfigure(0, weight=1)
        root.grid_columnconfigure(0, weight=1)
        # Bind buttons to access the application's menus.
        root.bind_all('<F2>', lambda event: SettingsDialog.open_window(root))
        root.bind_all('<F1>', lambda event: AboutFSM.open_window(root))
        # Start the application's event loop.
        root.mainloop()

    def __init__(self, master, log_path, **kw):
        super().__init__(master, **kw)
        self.configure_widgets()
        # Save username and prepare for program I/O.
        self.__username = getpass.getuser()
        self.__writer = MessageWriter(log_path, self.__username)
        self.__monitor = DirectoryMonitor(log_path)
        self.__messages = Aggregator()
        # Start looking for updates to the files.
        self.after_idle(self.update)

    def configure_widgets(self):
        # Create widgets.
        self.__text = tkinter.Text(self, state=tkinter.DISABLED,
                                   wrap=tkinter.WORD, cursor='arrow')
        self.__scroll = tkinter.ttk.Scrollbar(self, orient=tkinter.VERTICAL,
                                              command=self.__text.yview)
        self.__entry = tkinter.ttk.Entry(self, cursor='xterm')
        # Alter their settings.
        self.__text.configure(yscrollcommand=self.__scroll.set)
        self.__text.tag_configure('name', background=SETTINGS.normal_message)
        self.__text.tag_configure('high', background=SETTINGS.wisper_message)
        self.__text.tag_configure('mess', background=SETTINGS.reverse_wisper)
        self.__text.tag_configure('time', background=SETTINGS.time_background,
                                  foreground=SETTINGS.time_foreground)
        # Configure settings for hyperlinks.
        self.__text.tag_configure('dynamic_link',
                                  foreground=SETTINGS.link_foreground,
                                  underline=SETTINGS.link_underline)
        self.__text.tag_bind('dynamic_link', '<Enter>',
                      lambda event: self.__text.configure(cursor='hand2'))
        self.__text.tag_bind('dynamic_link', '<Leave>',
                      lambda event: self.__text.configure(cursor='arrow'))
        # Configure settings for static links.
        self.__text.tag_configure('static_link',
                                  foreground=SETTINGS.link_foreground,
                                  underline=SETTINGS.link_underline)
        # Place everything on the grid.
        self.__text.grid(row=0, column=0, sticky=tkinter.NSEW)
        self.__scroll.grid(row=0, column=1, sticky=tkinter.NS)
        self.__entry.grid(row=1, column=0, columnspan=2, sticky=tkinter.EW)
        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)
        # Setup box for typing.
        self.__entry.bind('<Control-Key-a>', self.select_all)
        self.__entry.bind('<Control-Key-/>', lambda event: 'break')
        self.__entry.bind('<Return>', self.send_message)
        self.__entry.focus_set()
        # Save first status and link counts.
        self.__first_line = True
        self.__url_id = 0
        self.__path_id = 0

    def select_all(self, event):
        # Select everything in the widget.
        event.widget.selection_range(0, tkinter.END)
        return 'break'

    def send_message(self, event):
        # Cut everything from the entry and write to file.
        text = self.__entry.get()
        self.__entry.delete(0, tkinter.END)
        self.__writer.write(text)

    def update(self):
        # Update the directory monitor once a second.
        self.after(1000, self.update)
        self.__monitor.update(self.__messages.update)
        # For each message, show those less than a day old.
        utcnow = datetime.datetime.utcnow()
        for message in self.__messages.get_messages():
            hours = (utcnow - message.time).total_seconds() / 3600
            if hours < SETTINGS.message_cutoff and self.allowed(message):
                self.display(message)

    def allowed(self, message):
        # If there is no text, it is not allowed.
        if not message.text:
            return False
        # Extract some information about the text.
        dest, text, reverse = self.get_wisper(message.text)
        # If there is no destination, it is allowed.
        if dest is None:
            return True
        # Change the message's color.
        message.tag = 'mess' if reverse else 'high'
        if self.__username == message.name:
            # ... unless the source really wants to ignore himself.
            if reverse:
                return False
            # Reformat the text and allow message.
            form = '![{}] {}' if reverse else '-> [{}] {}'
            message.text = form.format(dest, text)
            return True
        # If this is not a reversed whisper ...
        if not reverse:
            # It is only allowed for the destination.
            if dest == self.__username:
                message.text = text
                return True
            return False
        # Otherwise, it is not allowed to anyone else.
        if dest != self.__username:
            message.text = '![{}] {}'.format(dest, text)
            return True
        return False

    def get_wisper(self, message):
        # Note to self: start wispers as "@[" and reversals as "!["
        # next time you implement this system for version 3 of FSM.
        reverse, cleaned = False, message
        # If the messages starts with a '!', it should be cleaned.
        if message[0] == '!':
            reverse, cleaned = True, message[1:]
        # If the message starts with the proper prefix ...
        if cleaned[:2] == '@[':
            try:
                # Find the "end of name" marker.
                index = cleaned.index(']')
            except ValueError:
                pass    # Not wispered.
            else:
                # Return name, cleaned text, and reversal flag.
                dest = cleaned[2:index]
                text = cleaned[index+1:].strip()
                return dest, text, reverse
        # It was not wispered.
        return None, message, False

    def display(self, message):
        # Enable changes and take first line into account.
        self.__text['state'] = tkinter.NORMAL
        if self.__first_line:
            self.__first_line = False
        else:
            self.__text.insert(tkinter.END, '\n')
        # Show the timestamp if requested.
        if SETTINGS.show_timestamp:
            diff = datetime.datetime.now() - datetime.datetime.utcnow()
            time = message.time + diff
            # Display string that has been corrected for local time zone.
            self.__text.insert(tkinter.END, time.strftime('%I:%M %p'), 'time')
            self.__text.insert(tkinter.END, ' ')
        # Show the name with the proper color (message.tag).
        self.__text.insert(tkinter.END, '[' + message.name + ']', message.tag)
        # Add text with formatting, scroll to botton, and disable changes.
        self.add_text_with_URLs(' ' + message.text)
        self.__text.see(tkinter.END)
        self.__text['state'] = tkinter.DISABLED

    def add_text_with_URLs(self, message):
        url_list = self.find_urls(message)
        # Split on each URL, prefix, and create URL.
        for url in url_list:
            head, message = message.split(url, 1)
            self.add_text_with_PATHs(head)
            self.create_url(url)
        # Display whatever may be left.
        self.add_text_with_PATHs(message)

    def add_text_with_PATHs(self, message):
        path_list = self.find_paths(message)
        # Split on each path markup and create path links.
        for markup, path, name in path_list:
            head, message = message.split(markup, 1)
            self.add_plain_text(head)
            self.create_path(path, name)
        # Finish displaying any trailing text.
        self.add_plain_text(message)

    def create_url(self, url):
        # Create a new, incremented URL tag for text.
        self.__url_id += 1
        tag = 'url' + str(self.__url_id)
        # Insert the text and bind a command to open a webbrowser.
        self.__text.insert(tkinter.END, url, ('dynamic_link', tag))
        self.__text.tag_bind(tag, '<1>', lambda event: webbrowser.open(url))

    def create_path(self, path, name):
        # If the user is running Windows ...
        if hasattr(os, 'startfile'):
            # Create a new tag for the path.
            self.__path_id += 1
            tag = 'path' + str(self.__path_id)
            # Add the text and create an opening command.
            self.__text.insert(tkinter.END, name, ('dynamic_link', tag))
            self.__text.tag_bind(tag, '<1>', lambda event: os.startfile(path))
        else:
            # Insert a link that does not do anything.
            self.__text.insert(tkinter.END, name, 'static_link')

    def add_plain_text(self, message):
        # Confuse text if needed before adding text to display.
        if SETTINGS.message_confuser:
            message = confuse(message)
        self.__text.insert(tkinter.END, message)

    def find_paths(self, message):
        # Track found paths and current search positions.
        paths = []
        index_a = index_b = 0
        # While we are still searching the message's end ...
        while index_a > -1 and index_b > -1:
            index_a = message.find('<', index_b)
            # If the less than symbol has been found ...
            if index_a > -1:
                index_b = message.find('>', index_a)
                # If the greater than symbol has been found ...
                if index_b > -1:
                    path_markup = message[index_a:index_b+1]
                    # Add path to list if it exists.
                    self.test_and_add_path(path_markup, paths)
        return paths

    def test_and_add_path(self, markup, paths):
        # Extract the path and create an "absolute" path.
        pulled = markup[1:-1].strip()
        program = os.path.dirname(sys.argv[0])
        absolute = os.path.join(program, pulled)
        # Turn the path into a normal path and test for existence.
        normal = os.path.normpath(absolute)
        if os.path.exists(normal):
            # Record the markup, normal path, and filename.
            base = os.path.basename(normal)
            file = os.path.splitext(base)[0]
            paths.append((markup, normal, file))

    def find_urls(self, message):
        urls = []
        # Split text on whitespace.
        for text in message.split():
            result = urllib.parse.urlparse(text)
            # It is a URL if the protocol is correct and there is a location.
            if result.scheme in {'http', 'https', 'ftp'} and result.netloc:
                urls.append(text)
        # Return the list of found URLs.
        return urls

################################################################################

def confuse(text):
    # Collect all the words in a buffer after processing.
    buffer = []
    for data in words(text):
        if isinstance(data, str):
            buffer.append(data)             # Normal Text
        elif len(data) < 4:
            buffer.append(''.join(data))    # Short Text
        else:
            buffer.append(scramble(data))   # Confused Text
    # Return the processed string.
    return ''.join(buffer)

def words(string):
    # Prepare to process a string.
    data = str(string)
    if data:
        # Collect words and non-words and determine starting state.
        buffer = ''
        mode = 'A' <= data[0] <= 'Z' or 'a' <= data[0] <= 'z'
        for character in data:
            # Add characters to buffer until a mode change.
            if mode == ('A' <= character <= 'Z' or 'a' <= character <= 'z'):
                buffer += character
            else:
                # Yield a data type indicating what has been found.
                yield tuple(buffer) if mode else buffer
                buffer = character
                mode = not mode
        # Yield any remaining data in the buffer.
        yield tuple(buffer) if mode else buffer
    else:
        yield data

def scramble(data):
    # Get the first letter and scramble the middle letters.
    array = [data[0]]
    array.extend(random(data[1:-1], len(data) - 2))
    # Append the last letter and return the final string.
    array.append(data[-1])
    return ''.join(array)

################################################################################

if __name__ == '__main__':
    main()

History