diff --git a/TESTING.py b/TESTING.py new file mode 100644 index 0000000..8d2fae7 --- /dev/null +++ b/TESTING.py @@ -0,0 +1,3 @@ +import tkinter + +tkinter.messagebox.showinfo("hi!") \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..a4af114 --- /dev/null +++ b/config.py @@ -0,0 +1,50 @@ +#CONFIG FILE WITH CONSTANTS + +DEBUG = True + +#Livesplit connection +HOST = "localhost" +PORT = 16834 + +#In network communication, time out after this time. (in seconds) +COM_TIMEOUT = 0.5 + +#Possible commands to send to LiveSplit +LS_COMMANDS = { + "best_possible": "getbestpossibletime\r\n", + "cur_split_index": "getsplitindex\r\n", + "cur_split_name": "getcurrentsplitname\r\n" +} + +#Default Window Size +DEFAULT_WINDOW = {"WIDTH": 200, "HEIGHT": 500, "TITLE": "SplitNotes"} + +#Color Scheme +COLOR_SCHEME = {} + +#Default Welcome Message +DEFAULT_MSG = "Right Click to Open Notes." + +#Update time for polling livesplit and other actions (in seconds) +POLLING_TIME = 0.5 + +#file names and path of icons +ICON_FOLDER = "resources" +ICONS= {"GREEN": "green.png", "RED": "red.png"} + +#Popup menu options +MENU_OPTIONS = { + "SINGLE": "Set Single Layout", + "DOUBLE": "Set Double Layout", + "LOAD": "Load Notes" + } + +#Error messages +ERRORS = {"NOTES_EMPTY": ("Error", "Notes empty or can not be loaded!")} + +#Max file size for notes +MAX_FILE_SIZE = 1000000000 #1 Giga-Byte + +#TO be added to title to alert user that timer is running +RUNNING_ALERT = "RUNNING" + diff --git a/ls_connection.py b/ls_connection.py new file mode 100644 index 0000000..06a3b31 --- /dev/null +++ b/ls_connection.py @@ -0,0 +1,99 @@ +""" +ls refers to LiveSplit in the entire document. +Conversation with livesplit is done through the server component. +""" +import socket +from threading import Thread +import config +import select #used for checking if socket has data pending + +def ls_connect(ls_socket, call_func, window): + """Connects given socket to the livesplit server.""" + con_thread = Thread(target=try_connection, args=(ls_socket, call_func, window)) + con_thread.start() + + +def init_socket(): + """Returns a fresh socket""" + return socket.socket() + +def try_connection(ls_socket, call_func, window): + """ + Tries to connect given socket to ls. + If connection is successful given "call_func" + is called with window as argument. + (made to be ran in a separate thread) + """ + try: + ls_socket.connect((config.HOST, config.PORT)) + except: + return False + + call_func(window) + +def close_socket(socket): + """Closes given socket.""" + socket.close() + +def check_connection(ls_socket): + """ + Check so connection between socket and livesplit + is still active and working. + Returns boolean + """ + if send_to_ls(ls_socket, "best_possible"): + return True + else: + return False + + +def send_to_ls(ls_socket, command): + """ + Sends given command to ls using given socket. + If connected is False, tries to send without socket being connected to ls. + Returns the response, or False if an error occurs. + Check config.LS_COMMANDS for avaiable commands. + """ + + try: + ls_socket.send(str.encode(config.LS_COMMANDS[command])) + except: + return False + + socket_ready = select.select([ls_socket], [], [], config.COM_TIMEOUT) + if socket_ready[0]: + try: + return (ls_socket.recv(1000)).decode("utf-8") + except: + return False + else: + return False + + +def get_split_index(ls_socket): + """ + Returns the index of the active split in livesplit. + Returns -1 if timer is not yet started. + (First split is 0) + + Returns False on Error + """ + ls_data = send_to_ls(ls_socket, "cur_split_index") + + if not isinstance(ls_data, bool): + return int(ls_data) + else: + return False + + +def get_split_name(ls_socket): + """ + Returns name of the active split in livesplit. + Returns False if no split is active or Error occurs. + """ + ls_data = send_to_ls(ls_socket, "cur_split_name") + + if ls_data: + return ls_data + else: + return False \ No newline at end of file diff --git a/main_window.py b/main_window.py new file mode 100644 index 0000000..dc46ab6 --- /dev/null +++ b/main_window.py @@ -0,0 +1,370 @@ +import tkinter +from tkinter import messagebox + +import socket +import os + +import config +import ls_connection as con +import note_reader as noter + +runtime_info = { + "ls_connected": False, + "timer_running": False, + "icon_active": False, + "active_split": -1, + "notes": [], + "double_layout": False + } + +root = tkinter.Tk() + +red_path= os.path.join( + str(os.path.dirname(os.path.realpath(__file__))), + config.ICON_FOLDER, + config.ICONS["RED"] + ) +green_path= os.path.join( + str(os.path.dirname(os.path.realpath(__file__))), + config.ICON_FOLDER, + config.ICONS["GREEN"] + ) +red_icon = tkinter.Image("photo", file=red_path) +green_icon = tkinter.Image("photo", file=green_path) + +def update(window, com_socket, text1, text2): + """ + Function to loop along tkinter mainloop. + """ + if not runtime_info["ls_connected"]: + #try connecting to ls + connection_ok = con.ls_connect(com_socket, server_found, window) + else: + #is_connected + if runtime_info["notes"]: + #notes loaded + + #get index of current split + new_index = con.get_split_index(com_socket) + + if isinstance(new_index, bool): + #Connection error + test_connection(com_socket) + else: + #index retrieved succesfully + if new_index == -1: + #timer not running + if runtime_info["timer_running"]: + runtime_info["timer_running"] = False + runtime_info["active_split"] = new_index + update_notes(window, text1, text2, new_index) + set_title_notes(window, 0) + else: + #timer is running + runtime_info["timer_running"] = True + + if not runtime_info["active_split"] == new_index: + #new split, need to update + + #update notes + update_notes(window, text1, text2, new_index) + + #set new window title + new_split = con.get_split_name(com_socket) + + if new_split: + set_title_notes(window, new_index, new_split) + else: + #connection error + set_title_notes(window, new_index) + test_connection(com_socket) + + runtime_info["active_split"] = new_index + + else: + #notes not yet loaded + if not con.check_connection(com_socket): + #connection lost + runtime_info["ls_connected"] = False + update_icon(False, window) + com_socket = con.init_socket() + + + + #self looping + window.after(int(config.POLLING_TIME * 1000), update, window, com_socket, text1, text2) + + +def test_connection(com_socket): + if con.check_connection(com_socket): + return True + else: + reset_connection(com_socket) + return False + + +def reset_connection(com_socket): + runtime_info["ls_connected"] = False + com_socket = con.init_socket() + + +def server_found(window): + """ + Executes correct settings for when + ls connection has been established. + """ + runtime_info["ls_connected"] = True + update_icon(True, window) + + +def update_icon(active, window): + """Updates icon of window depending on "active" variable""" + if active and not runtime_info["icon_active"]: + window.tk.call('wm', 'iconphoto', window._w, green_icon) + runtime_info["icon_active"] = True + elif runtime_info["icon_active"]: + window.tk.call('wm', 'iconphoto', window._w, red_icon) + runtime_info["icon_active"] = False + + +def update_title(name, window): + """Sets the title of given window to name.""" + window.wm_title(name) + + +def adjust_content(window, box1, box2): + """ + Adjusts size of box1 and box2 according to + layout and size of window. + """ + if runtime_info["double_layout"]: + set_double_layout(window, box1, box2) + else: + set_single_layout(window, box1, box2) + + +def set_double_layout(window, box1, box2): + """ + Configures boxes in the window to fit as in double layout. + """ + runtime_info["double_layout"] = True + + w_width = window.winfo_width() + w_height = window.winfo_height() + + box1.place(height=(w_height // 2), width=w_width) + box2.place(height=(w_height // 2), width=w_width, y=(w_height // 2)) + + +def set_single_layout(window, box1, box2): + """ + Configures boxes in the window to fit as in single layout. + """ + runtime_info["double_layout"] = False + + box2.place_forget() + box1.place(height=window.winfo_height(), width=window.winfo_width()) + + +def show_popup(event, menu): + """Displays given popup menu at cursor position.""" + menu.post(event.x_root, event.y_root) + + +def menu_change_layout(window, box1, box2, popup): + """Menu option for changing layout selected.""" + if runtime_info["double_layout"]: + set_single_layout(window, box1, box2) + popup.entryconfig(0, label=config.MENU_OPTIONS["DOUBLE"]) + else: + set_double_layout(window, box1, box2) + popup.entryconfig(0, label=config.MENU_OPTIONS["SINGLE"]) + + +def menu_load_notes(window, text1, text2): + """Menu selected load notes option.""" + load_notes(window, text1, text2) + + +def load_notes(window, text1, text2): + """ + Prompts user to select notes and then tries to load these into the UI. + """ + file = noter.select_file() + + if file: + notes = noter.get_notes(file) + if notes: + #Notes loaded correctly + runtime_info["notes"] = notes + + split_c = len(notes) + show_info(("Notes Loaded", ("Loaded notes with " + str(split_c) + " splits."))) + + update_notes(window, text1, text2, runtime_info["active_split"]) + set_title_notes(window, 0, split_name = False) + else: + show_info(config.ERRORS["NOTES_EMPTY"], True) + + +def show_info(info, warning = False): + """ + Displays an infor popup window. + if warning is True window has a warning triangle. + """ + if warning: + messagebox.showwarning(info[0], info[1]) + else: + messagebox.showinfo(info[0], info[1]) + + +def update_notes(window, text1, text2, index): + """ + Displays notes with the given index in given text widgets. + If index is lower than 0, displays notes for index 0. + If index is higher than the highest index there are + notes for the text widgets are left empty. + + text2 is always given the notes at index (index + 1) if existing + """ + max_index = (len(runtime_info["notes"]) - 1) + + if index < 0: + index = 0 + + text1.config(state=tkinter.NORMAL) + text2.config(state=tkinter.NORMAL) + + text1.delete("1.0", tkinter.END) + text2.delete("1.0", tkinter.END) + + if index <= max_index: + text1.insert(tkinter.END, runtime_info["notes"][index]) + + #cand disply notes for index+1 + if index < max_index: + text2.insert(tkinter.END, runtime_info["notes"][index + 1]) + + text1.config(state=tkinter.DISABLED) + text2.config(state=tkinter.DISABLED) + + +def right_arrow(window, text1, text2): + """Event handler for right arrow key.""" + change_preview(window, text1, text2, 1) + + +def left_arrow(window, text1, text2): + """Event handler for left arrow key.""" + change_preview(window, text1, text2, -1) + + +def change_preview(window, text1, text2, move): + """move is either 1 for next or -1 for previous.""" + if runtime_info["notes"] and (not runtime_info["timer_running"]): + max_index = (len(runtime_info["notes"]) - 1) + index = runtime_info["active_split"] + + if index < 0: + index = 0 + + index += move + + if index > max_index: + index = max_index + + update_notes(window, text1, text2, index) + runtime_info["active_split"] = index + + if index < 0: + index = 0 + set_title_notes(window, index) + +def set_title_notes(window, index, split_name = False): + """ + Set window title to fit with displayed notes. + """ + title = config.DEFAULT_WINDOW["TITLE"] + + disp_index = str(index + 1) #start at 1 + title += " - " + disp_index + + if split_name: + title += " - " + split_name + + if runtime_info["timer_running"]: + title += " - " + config.RUNNING_ALERT + + update_title(title, window) + +def init_UI(root): + """Draws default UI and creates event bindings.""" + + #Graphical components + root.geometry(str(config.DEFAULT_WINDOW["WIDTH"]) + "x" + str(config.DEFAULT_WINDOW["HEIGHT"])) + + box1 = tkinter.Frame(root) + box2 = tkinter.Frame(root) + + scroll1 = tkinter.Scrollbar(box1) + scroll1.pack(side=tkinter.RIGHT, fill=tkinter.Y) + + scroll2 = tkinter.Scrollbar(box2) + scroll2.pack(side=tkinter.RIGHT, fill=tkinter.Y) + + + text1 = tkinter.Text( + box1, + yscrollcommand=scroll1.set, + wrap=tkinter.WORD + ) + text1.insert(tkinter.END, config.DEFAULT_MSG) + text1.config(state=tkinter.DISABLED) + text1.pack(fill=tkinter.BOTH, expand=True) + + text2 = tkinter.Text( + box2, + yscrollcommand=scroll2.set, + wrap=tkinter.WORD + ) + text2.insert(tkinter.END, config.DEFAULT_MSG) + text2.config(state=tkinter.DISABLED) + text2.pack(fill=tkinter.BOTH, expand=True) + + scroll1.config(command=text1.yview) + scroll2.config(command=text2.yview) + + set_single_layout(root, box1, box2) + + #create popup menu + popup = tkinter.Menu(root, tearoff=0) + popup.add_command( + label=config.MENU_OPTIONS["DOUBLE"], + command=( + lambda: menu_change_layout(root, box1, box2, popup) + ) + ) #Needs to be at index 0 + popup.add_command( + label=config.MENU_OPTIONS["LOAD"], + command=(lambda: menu_load_notes(root, text1, text2)) + ) + + + #Set default window icon and title + root.tk.call('wm', 'iconphoto', root._w, red_icon) + update_title(config.DEFAULT_WINDOW["TITLE"], root) + + #Event binds + root.bind("", (lambda e: adjust_content(root, box1, box2))) + root.bind("", (lambda e: show_popup(e, popup))) + root.bind("", (lambda e: right_arrow(root, text1, text2))) + root.bind("", (lambda e: left_arrow(root, text1, text2))) + + #call update loop + com_socket = con.init_socket() + update(root, com_socket, text1, text2) + + +init_UI(root) + +root.mainloop() \ No newline at end of file diff --git a/note_reader.py b/note_reader.py new file mode 100644 index 0000000..8a57647 --- /dev/null +++ b/note_reader.py @@ -0,0 +1,122 @@ +import tkinter.filedialog as file_dia +import os.path as path + +import config + +""" +NOTE STANDARD FORMATTING + +empty newlines separate notes for different splits + +lines that start and end with [ ] are ignored for notes. +these can be used for titles. +(ex. [Split1] is not included in notes) + +all other lines of text are added as notes +""" + +def get_note_lines(file_path): + """ + Reads file at given path and returns + a list containing all rows of text in given file. + Returns false if file can not be read. + """ + + + #check so file isn't too big + if path.getsize(file_path) > config.MAX_FILE_SIZE: + return False + + try: + notes_file = open(file_path, "r") + except: + return False + + #read file line per line + f_lines = [] + keep_reading = True + while keep_reading: + cur_line = notes_file.readline() + + if cur_line: + f_lines.append(cur_line) + else: + keep_reading = False + + return f_lines + + +def encode_notes(note_lines): + """ + Takes a list containing strings. + Encodes given strings according to the note formatting. + Returns the list containing the notes for every split. + """ + + def is_title(line): + if not line: + return False + + return (line[0] == "[") and (line[-1] == "]") + + def is_newline(line): + return (line == "\n") or (line == "\r") + + def remove_new_line(line): + if (len(line) >= 1) and (is_newline(line[-1])): + return line[:-1] + else: + return line + + note_list = [] + cur_notes = "" + + for line in note_lines: + #remove whitespace at beginning and end + line = line.strip(" ") + + if is_newline(line): + if cur_notes: + note_list.append(cur_notes) + cur_notes = "" + else: + line = remove_new_line(line) + if not is_title(line): + cur_notes += line + "\n" #newline + + if cur_notes: + note_list.append(cur_notes) + + return note_list + + +def get_notes(file_path): + """ + Takes a path to a file and returns a list with the notes + in the file encoded according to the note fromatting. + + Returns False if file is empty. + """ + note_lines = get_note_lines(file_path) + + if not note_lines: + return False + + note_list = encode_notes(note_lines) + + return note_list + + +def select_file(): + """ + Opens a file select window. + Returns False upon no file selection. + Otherwise returns absolute path to selected file. + """ + + file = file_dia.askopenfilename() + + if file: + return file + else: + return False \ No newline at end of file diff --git a/resources/green.png b/resources/green.png new file mode 100644 index 0000000..a3a139b Binary files /dev/null and b/resources/green.png differ diff --git a/resources/red.png b/resources/red.png new file mode 100644 index 0000000..ad9476a Binary files /dev/null and b/resources/red.png differ