'''
Python module containing Widget classes (Widget subclasses) of easy_gui project.
The classes in here are the individual GUI elements.
'''
# from .master_classes import Widget
import tkinter as tk
import tkinter.scrolledtext
from tkinter import ttk
from tkinter import _tkinter
import sys
import threading
from typing import List
import matplotlib
matplotlib.use('TkAgg')
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from contextlib import nullcontext
import datetime
import calendar



class Widget(tk.Frame):
    '''
    To be subclassed into specific EasyGUI widgets.
    Class assumes the "_widget" attribute is the actual tkinter widget (if used)
    '''
    def __init__(self, master=None, bg=None, grid_area=None, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.parent = master  # master attr used in tkinter; parent attr used in this code
        self.grid_area = grid_area
        self.configure(background=self.style.widget_bg_color)

    @property
    def style(self):
        '''Goes upsteam to evenually reference EasyGUI.style'''
        return self.parent.style

    @property
    def root(self):
        '''Goes upsteam to evenually reference EasyGUI as root'''
        return self.parent.root

    def position(self, force_row: bool=False) -> None:
        '''
        Physically position this Widget within its parent Section.
        '''
        try:
            if self.parent.grid_areas != {} and self.grid_area and not force_row:
                try:
                    bounds = self.parent.grid_areas[self.grid_area]
                    if isinstance(self, Tree):
                        self.grid(row=bounds['first_row'], column=bounds['first_column'], rowspan=bounds['last_row']-bounds['first_row']+1, columnspan=bounds['last_column']-bounds['first_column']+1, sticky='NSEW')
                        self._widget.pack(side='left', fill=tk.BOTH, expand=True)
                        self.scrollbar.pack(side='left', fill='y')
                    elif isinstance(self, LabelEntry):
                        self.grid(row=bounds['first_row'], column=bounds['first_column'], rowspan=bounds['last_row']-bounds['first_row']+1, columnspan=bounds['last_column']-bounds['first_column']+1)
                        self._lbl_widget.pack(side='left', expand=True)
                        self._widget.pack(side='left')
                    elif isinstance(self, Table):
                        self.grid(row=bounds['first_row'], column=bounds['first_column'], rowspan=bounds['last_row']-bounds['first_row']+1, columnspan=bounds['last_column']-bounds['first_column']+1) #, sticky='NSEW')
                        self.grid_cells()
                    elif isinstance(self, DatePicker):
                        self.grid(row=bounds['first_row'], column=bounds['first_column'], rowspan=bounds['last_row']-bounds['first_row']+1, columnspan=bounds['last_column']-bounds['first_column']+1)
                        self.grid_interior()
                    else:
                        self._widget.grid(row=bounds['first_row'], column=bounds['first_column'], rowspan=bounds['last_row']-bounds['first_row']+1, columnspan=bounds['last_column']-bounds['first_column']+1) #, sticky='NSEW')
                    return  # early return if everything works fine with initial attempt (no other actions needed)
                except KeyError:
                    print(f'"{self.grid_area}" not found in parent\'s grid areas.\nResorting to a new row.')
            name = self.__class__.__name__
            existing_grid_areas = [n for n in self.parent.grid_areas if name in n]
            self.grid_area = name if name not in existing_grid_areas else next((name + str(i) for i in range(1, 100) if name + str(i) not in existing_grid_areas))
            self.parent.add_grid_row(self.grid_area)
            self.parent.create()
        except _tkinter.TclError:
            print(f'\n--- GRID FAILED for Widget: "{ self.__class__.__name__}" ---\nTry ensuring "grid_area" arg is given for all Widgets in a given parent.\nAdding to a new row instead.')
            self.parent.create(force_row=True)  # go back and fully recreate section forcing all children to be packed/in new rows

    def bind_click(self, command_func, separate_thread: bool=False) -> None:
        '''
        Bind a left-mouse click to the widget to trigger a target "command_func" function.
        '''
        self.bind_event('<Button-1>', command_func, separate_thread=separate_thread)

    def bind_select(self, command_func, separate_thread: bool=False) -> None:
        '''
        Bind a left-mouse click to the widget to trigger a target "command_func" function.
        '''
        self.bind_event('<Button-1>', command_func, separate_thread=separate_thread)

    def bind_event(self, event: str, command_func, separate_thread: bool=False) -> None:
        '''
        Bind an event (specified by "event" string such as '<<ComboboxSelected>>' to trigger a target "command_func" function.
        Note that the "_widget" attribute of subclasses is assumed to be the tkinter widget itself!!!
        '''
        if separate_thread:
            def threaded_command_func(*args):
                threading.Thread(target=command_func).start()
            self._widget.bind(event, threaded_command_func, add='+')
        else:
            self._widget.bind(event, command_func, add='+')

    def add_tooltip(self, text: str, delay: float=1.0):
        '''
        Add a tooltip that shows up when mouse is over Widget.
        "delay" arg is number of seconds to delay showing the tooltip after mouse enters the Widget.

        Code here is messy from using/saving state between functions... but it works for now.
        '''
        self.tooltip = None

        def leave(*args):
            if self.tooltip:
                self.tooltip.destroy()
            self.tooltip = None

        def show_tooltip(*args):
            leave()  # close previous tooltip if somehow exists
            if self.last_event != '<Leave>':  # this checks to ensure that mouse hasn't left widget in time between <Enter> and the specified delay
                abs_coord_x = self.root.winfo_pointerx()
                abs_coord_y = self.root.winfo_pointery()

                max_line_length = max(len(sub_text) for sub_text in text.split('\n'))
                with self.root.popup(tooltip=True, width=int(6.2*max_line_length)+15, height=18 * (text.count('\n')+1) + 12, x=abs_coord_x+20, y=abs_coord_y+10) as tooltip:
                    sec = tooltip.add_section()
                    sec.configure(bg=self.style.tooltip_color)
                    sec.configure(relief='raised')
                    label = sec.add_widget('label', text)
                    label._widget.configure(bg=self.style.tooltip_color)
                    self.tooltip = tooltip

        def save_last_event(event):
            self.last_event = event

        self.bind_event('<Enter>', lambda _: self._widget.after(int(delay * 1000), show_tooltip))
        self.bind_event('<Enter>', lambda _: save_last_event('<Enter>'))
        self.bind_event('<Leave>', leave)
        self.bind_event('<Leave>', lambda _: save_last_event('<Leave>'))

    def destroy(self):
        self._widget.destroy()

    @property
    def width(self) -> float:
        '''
        Return width used by this Widget.
        '''
        return float(self._widget['width'])

    @property
    def height(self) -> float:
        '''
        Return height used by this Widget.
        '''
        return float(self._widget['height'])

    def config(self, *args, **kwargs):
        '''Just pass a config call through to the tkinter widget itself.'''
        return self._widget.config(*args, **kwargs)


def add_widget(self, type='label', text='', widget_name=None, grid_area=None, **kwargs):
        '''
        Add a Widget subclass object to the 'self' Section.
        This is used as a Section method in master_classes.Section.
        ('self' is passed in as a reference to the parent Section)
        '''
        def new_widget_name(w_type):
            if widget_name:
                return widget_name
            else:
                return f'{len(self.widgets) + 1}' + '_' + w_type

        type_lower = type.lower()
        if type_lower in ['label', 'lbl']:
            new_widget = Label(master=self, text=text, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('label')] = new_widget
        elif type_lower in ['button', 'btn']:
            new_widget = Button(master=self, text=text, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('button')] = new_widget
        elif type_lower in ['canvas']:
            new_widget = Canvas(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('canvas')] = new_widget
        elif type_lower in ['entry', 'input']:
            new_widget = Entry(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('entry')] = new_widget
        elif type_lower in ['labelentry', 'entrylabel']:
            new_widget = LabelEntry(master=self, text=text, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('labelentry')] = new_widget
        elif type_lower in ['checkbox', 'checkbutton']:
            new_widget = CheckBox(master=self, text=text, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('checkbox')] = new_widget
        elif type_lower == 'dropdown':
            new_widget = DropDown(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('dropdown')] = new_widget
        elif type_lower == 'listbox':
            new_widget = ListBox(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('listbox')] = new_widget
        elif type_lower == 'table':
            new_widget = Table(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('table')] = new_widget
        elif type_lower == 'tree':
            new_widget = Tree(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('tree')] = new_widget
        elif type_lower in ['matplotlib', 'matplotlibplot']:
            new_widget = MatplotlibPlot(master=self, section=self, widget_name=new_widget_name('matplotlibplot'), grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('matplotlibplot')] = new_widget
        elif type_lower == 'stdout':
            new_widget = StdOutBox(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('stdout')] = new_widget
        elif type_lower == 'scrolledtext':
            new_widget = ScrolledText(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('scrolledtext')] = new_widget
        elif type_lower == 'slider':
            new_widget = Slider(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('slider')] = new_widget
        elif type_lower in ['progress', 'progressbar']:
            new_widget = ProgressBar(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('progressbar')] = new_widget
        elif type_lower in ['date', 'datepicker']:
            new_widget = DatePicker(master=self, grid_area=grid_area, **kwargs)
            self.widgets[new_widget_name('datepicker')] = new_widget
        else:
            raise Exception(f'Error!  Widget type "{type}" not supported. (check spelling?)\n')

        return new_widget



class Button(Widget):
    def __init__(self, master=None, text='button', image=None, command_func=lambda x: None, separate_thread=False, use_ttk: bool=False, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.text = text
        self.image = None
        if image:
            if image[-4:].lower() != '.png':
                print('Error!  Can only use a ".png" file as a button image.')
            else:
                self.image = tk.PhotoImage(file=image)
        if 'grid_area' in kwargs:
            del kwargs['grid_area']
        if not use_ttk:
            self._widget = tk.Button(master=master, text=text, image=self.image, highlightbackground=self.style.button_color, font=self.style.font, **kwargs)
        else:
            self._widget = ttk.Button(master=master, text=text, image=self.image, **kwargs)
        self.bind_click(command_func, separate_thread)

    def place(self) -> None:
        '''
        Override Widget method for proper padding on outside!
        (have to supply padx and pady on pack call for button
        '''
        self._widget.pack(padx=self.style.button_padx, pady=self.style.button_pady)

    @property
    def width(self) -> float:
        '''
        Return width used by this Button.
        Overwrites Widget method.
        '''
        return 6.6 * len(self.text)

    @property
    def height(self) -> float:
        '''
        Return height used by this Button.
        Overwrites Widget method.
        '''
        return 39


class Canvas(Widget):
    def __init__(self, master=None, width=300, height=250, background='gray85', **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        if 'grid_area' in kwargs:
            del kwargs['grid_area']
        self._widget = tk.Canvas(master=master, width=width, height=height, background=background, **kwargs)

    def _clean_tag(self, tag):
        '''
        Canvas gets upset with a tag that is a string of all digits/integerish.
        This simply puts an underscore at the beginning if needed to fix that.
        '''
        if isinstance(tag, str) and tag and all(s.isdigit() for s in tag):
            tag = '_' + tag
        return tag

    def itemconfigure(self, tag, *args, **kwargs) -> None:
        '''See tkinter.Canvas.itemconfigure... Used to change tagged items.'''
        self._widget.itemconfigure(self._clean_tag(tag), *args, **kwargs)

    def bind_click(self, tag, command_func, separate_thread: bool=False) -> None:
        if separate_thread:
            def threaded_command_func(*args):
                threading.Thread(target=command_func).start()
            self._widget.tag_bind(self._clean_tag(tag), '<Button-1>', threaded_command_func, add='+')
        else:
            self._widget.tag_bind(self._clean_tag(tag), '<Button-1>', command_func, add='+')

    def create_text(self, x, y, text='Text', anchor='nw', fill='black', tags=None):
        self._widget.create_text(x, y, text=text, anchor=anchor, fill=fill, tags=self._clean_tag(tags))

    def create_line(self, x1, y1, x2, y2, fill='blue', width=3, tags=None):
        self._widget.create_line(x1, y1, x2, y2, fill=fill, width=width, tags=self._clean_tag(tags))

    def create_rectangle(self, x1, y1, x2, y2, fill='green', outline='blue', width=3, tags=None):
        self._widget.create_rectangle(x1, y1, x2, y2, fill=fill, outline=outline, width=width, tags=self._clean_tag(tags))

    def create_oval(self, x1, y1, x2, y2, fill='green', outline='blue', width=3, tags=None):
        self._widget.create_oval(x1, y1, x2, y2, fill=fill, outline=outline, width=width, tags=self._clean_tag(tags))

    def create_circle(self, x, y, radius=5, fill='green', outline='blue', width=3, tags=None):
        self.create_oval(x-radius, y-radius, x+radius, y+radius, fill=fill, outline=outline, width=width, tags=tags)

    def create_polygon(self, *args, fill='green', outline='blue', width=3, tags=None):
        self._widget.create_polygon(*args, fill=fill, outline=outline, width=width, tags=self._clean_tag(tags))




class Label(Widget):
    def __init__(self, master=None, text='label', bold=False, underline=False, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.text = text
        self.strvar = tk.StringVar()
        self.set(text)
        if 'grid_area' in kwargs:
            del kwargs['grid_area']
        font = self.style.font
        if bold:
            font = self.style.font_bold
        if underline:
            font = self.style.font_underline
        if bold and underline:
            font = self.style.font_bold_underline
        self._widget = tk.Label(master=master, textvariable=self.strvar, bg=self.style.widget_bg_color, fg=self.style.text_color,
                                padx=self.style.label_padx, pady=self.style.label_pady, font=font, **kwargs)

    def get(self):
        return self.strvar.get()

    def set(self, value):
        self.strvar.set(value)

    @property
    def width(self) -> float:
        '''
        Return width used by this Label.
        Overwrites Widget method.
        '''
        return 6.6 * len(self.text)

    @property
    def height(self) -> float:
        '''
        Return height used by this Label.
        Overwrites Widget method.
        '''
        return 39


class Entry(Widget):
    def __init__(self, master=None, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.strvar = tk.StringVar()
        if 'grid_area' in kwargs:
            del kwargs['grid_area']
        self._widget = tk.Entry(master=master, textvariable=self.strvar, **kwargs)

    def get(self):
        return self._widget.get()

    def set(self, value):
        self.strvar.set(value)


class LabelEntry(Widget):
    '''
    Widget combining a Label and and Entry in one (common use case).
    '''
    def __init__(self, master=None, text='label', bold=False, underline=False, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.text = text
        self.lbl_strvar = tk.StringVar()
        self.set(text)
        del kwargs['grid_area']
        font = self.style.font
        if bold:
            font = self.style.font_bold
        if underline:
            font = self.style.font_underline
        if bold and underline:
            font = self.style.font_bold_underline
        self._lbl_widget = tk.Label(master=self, textvariable=self.lbl_strvar, bg=self.style.widget_bg_color, fg=self.style.text_color,
                                padx=self.style.label_padx, pady=self.style.label_pady, font=font, **kwargs)

        self.strvar = tk.StringVar()
        self._widget = tk.Entry(master=self, textvariable=self.strvar, **kwargs)

    def destroy(self):
        '''Need custom destroy method as also have a _lbl_widget.'''
        self._lbl_widget.destroy()
        self._widget.destroy()

    def get(self):
        '''Get the value in the Entry box.'''
        return self._widget.get()

    def set(self, value):
        '''Set the value in the Label.'''
        self.lbl_strvar.set(value)


class CheckBox(Widget):
    def __init__(self, master=None, text='checkbox', **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        del kwargs['grid_area']
        self._widget = ttk.Checkbutton(master=master, text=text, offvalue=False, onvalue=True, **kwargs)
        self._widget.invoke()  # switch from whatever starting state to checked
        self._widget.invoke()  # switch from checked to unchecked

    def get(self) -> bool:
        return True if 'selected' in self._widget.state() else False

    def switch(self):
        self._widget.invoke()

    def check(self):
        if not self.get():
            self.switch()

    def uncheck(self):
        if self.get():
            self.switch()


class DropDown(Widget):
    def __init__(self, master=None, dropdown_options=[], **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.strvar = tk.StringVar()
        self.dropdown_options = dropdown_options
        del kwargs['grid_area']
        self._widget = ttk.Combobox(master, textvariable=self.strvar, values=dropdown_options, **kwargs)

    def get(self):
        return self._widget.get()

    def set(self, value):
        if value in self.dropdown_options:
            self.strvar.set(value)
        else:
            print(f'Error: {value} is not in current dropdown_options.')
            print('Please set to an existing option or use .set_options to set a new list of dropdown_options.')

    def set_options(self, dropdown_options):
        self._widget['values'] = dropdown_options
        self.strvar.set('')

    def bind_select(self, command_func, separate_thread: bool=False):
        '''
        Shortcut/convenience binding method
        '''
        self.bind_event('<<ComboboxSelected>>', command_func, separate_thread=separate_thread)


class ListBox(Widget):
    def __init__(self, master=None, options=[], **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.options = options
        del kwargs['grid_area']
        self._widget = tk.Listbox(master, selectmode=tk.MULTIPLE, **kwargs)
        for option in options:
            self._widget.insert(tk.END, option)

    def get(self) -> List[str]:
        return sorted(self._widget.get(i) for i in self._widget.curselection())


class Table(Widget):
    # TODO:
    # Need copy/paste ability if enabled
    # Want to be able to double-click on a cell and trigger event (like drill down into more data with a popup window?)
    def __init__(self, master=None, widget_name=None, type:str='label', rows:int=4, columns:int=3, border: bool=False, **kwargs) -> None:
        '''
        type can be 'label' or 'entry'
        '''
        super().__init__(master=master, **kwargs)
        self.widget_name = widget_name
        self.type = type
        self.rows = rows
        self.column = columns
        self.grid_area = kwargs.get('grid_area')
        self.kwargs = kwargs
        if 'grid_area' in kwargs:
            del kwargs['grid_area']

        self.cells = {row: {col: None for col in range(1, columns+1)} for row in range(1, rows+1)}
        self.cell_list = []  # another reference to the same cell objects in list form for easier access in some cases
        for row in range(1, rows+1):
            for col in range(1, columns+1):
                if self.type == 'label':
                    new_cell = Label(master=self, borderwidth=(1 if border else 0), relief='solid')  # self is a tk.Frame
                    new_cell.set(f'Cell [{row}, {col}]')
                elif self.type == 'entry':
                    new_cell = Entry(master=self)  # self is a tk.Frame
                new_cell.row = row
                new_cell.column = col
                self.cells[row][col] = new_cell
                self.cell_list.append(new_cell)


    def grid_cells(self):
        for cell in self.cell_list:
            cell._widget.grid(row=cell.row-1, column=cell.column-1, sticky='NSEW')

    def __getitem__(self, indices):
        row, column = indices
        return self.cells[row][column].get()

    def __setitem__(self, indices, value):
        row, column = indices
        self.cells[row][column].set(value)

    def destroy(self):
        for cell in self.cell_list:
            cell.destroy()


class Tree(Widget):
    def __init__(self, master=None, tree_col_header: str='Name', height: int=30, tree_col_width: int=120, **kwargs) -> None:
        super().__init__(master=master, **kwargs)

        del kwargs['grid_area']
        self._widget = ttk.Treeview(self, columns=(), style='Treeview', show='tree headings', height=height, **kwargs)
        self.tree_col_header = tree_col_header
        self.column_definitions = [{'column_name': '#0', 'width': tree_col_width, 'minwidth': 20, 'stretch': tk.NO}]
        self.scrollbar = ttk.Scrollbar(self, orient='vertical')
        self.scrollbar.configure(command=self._widget.yview)
        self._widget.configure(yscrollcommand=self.scrollbar.set)


    @property
    def current_row(self) -> dict:
        return self._widget.item(self._widget.focus())

    @property
    def current_rows(self) -> List[dict]:
        return [self._widget.item(id) for id in self._widget.selection()]

    def select_first_row(self):
        self._widget.focus(self._widget.get_children()[0])
        self._widget.selection_set(self._widget.get_children()[0])

    def insert_column(self, column_name, width=120, minwidth=40, stretch=tk.YES) -> None:
        '''
        Insert a column and associated heading (column title) at the top in the tree.
        Use "column_name" arg both for the displayed text and to reference this column in code.
        '''
        self.column_definitions.append({'column_name': column_name, 'width': width, 'minwidth': minwidth, 'stretch': stretch})
        self._rebuild_columns()

    def _rebuild_columns(self) -> None:
        '''
        Recreate columns each time one is added, because all columns
        must be set up before assigning headers.
        '''
        columns = [col['column_name'] for col in self.column_definitions]
        self._widget['columns'] = columns[1:]  # don't include assumed first "tree" column

        for col in self.column_definitions:
            self._widget.column(col['column_name'], width=col['width'], minwidth=col['minwidth'], stretch=col['stretch'])

        self._widget.heading('#0', text=self.tree_col_header, anchor=tk.W)
        for col in self.column_definitions[1:]:
            self._widget.heading(col['column_name'], text=col['column_name'], anchor=tk.W)

    def insert_row(self, text, values=('',), parent_row=None, open=False):
        '''
        Values arg must be provided as tuple of strings
        '''
        if parent_row is None:
            new_row = self._widget.insert('', 'end', text=text, values=values, open=open)
        else:
            new_row = self._widget.insert(parent_row, 'end', text=text, values=values, open=open)
        return new_row

    def clear(self) -> None:
        '''
        Clear all items from the tree.
        '''
        self._widget.delete(*self._widget.get_children())

    def bind_select(self, command_func, separate_thread=False):
        '''
        Shortcut/convenience binding method
        '''
        def command_func_with_tree_reselect(*args):
            command_func()
            # NEXT LINE NECESSARY to allow refocusing on tree due to odd Matplotlib behavior/bug?
            # See: https://github.com/matplotlib/matplotlib/issues/14081  (issue with FigureCanvasTkAgg.__init__ steals focus)
            self.root.update()
            self._widget.focus_set()  # want to refocus/keep focus on tree if lost it during command_func

        self.bind_event('<<TreeviewSelect>>', command_func_with_tree_reselect, separate_thread=separate_thread)


class Slider(Widget):
    def __init__(self, master=None, min=0, max=100, start=None, resolution=5, tickinterval=25, length=10, hz_or_vt='vt',
                      command_func=None, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        del kwargs['grid_area']
        orient = 'horizontal' if hz_or_vt.lower() == 'hz' else 'vertical'
        self._widget = tk.Scale(master, from_=min, to=max, resolution=resolution, tickinterval=tickinterval, orient=orient,
                                        length=length, command=command_func, **kwargs)
        if start:
            self.set(start)

    def get(self):
        return self._widget.get()

    def set(self, value):
        self._widget.set(value)


class MatplotlibPlot(Widget):
    def __init__(self, master=None, section=None, widget_name=None, toolbar=True, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self.section = section  # grabbing handle to Section so IT can handle replotting
        self.widget_name = widget_name
        self.toolbar = toolbar
        self.grid_area = kwargs.get('grid_area')
        self.kwargs = kwargs
        if 'grid_area' in kwargs:
            del kwargs['grid_area']
        self._widget = tk.Canvas(master=master, **kwargs)
        self.plot_drawn = False
        self.bindings = []
        self.small_figure_warning_given = False

    def draw_plot(self, mpl_figure=None) -> None:
        '''
        Draw new Matplotlib Figure (mpl_figure kwarg) on the widget.

        If a plot already exists, call parent section._clear_and_recreate_plot method
        to destroy and then recreate this widget!
        Must fully destroy this widget to clear all of the matplotlib/tkinter objects.
        '''
        if self.plot_drawn:
            self.section._clear_and_recreate_plot(mpl_figure, self.widget_name, self.grid_area, self.kwargs)
        else:
            self.plot_drawn = True
            self.fig_canvas = FigureCanvasTkAgg(mpl_figure, self._widget)
            if self.toolbar:
                toolbar = NavigationToolbar2Tk(self.fig_canvas, self._widget)
                # NOW TO MINIMIZE CRAZY FLICKERING/REDRAWING......
                # The next line overwrites and ignores the ._wait_cursor_for_draw_cm method
                # which is a context manager call in matplotlib.backends.backend_agg.FigureCanvasAgg.draw (line ~390).
                # This context manager appears to only attempt to change the cursor to a "wait" cursor... but I don't see it actually doing that,
                # and it's slow and therefore making the plot redraw take way too long and look ridiculous.
                toolbar._wait_cursor_for_draw_cm = lambda: nullcontext()
            self.fig_canvas.get_tk_widget().pack(expand=True)

            self.reset_bindings()
            # Check if provided figure is wide enough to prevent unstable width changing on mouseover...
            if mpl_figure.bbox._points[1][0] < 400 and not self.small_figure_warning_given:
                print('\nCaution!  Plot Matplotlib Figure with width >=4 to prevent unstable chart width.')
                self.small_figure_warning_given = True  # used to only print warning once

    def bind_event(self, event: str, command_func, separate_thread: bool=False) -> None:
        '''
        Custom bind_event method as need to intercept/store these and bind them AFTER every call to draw_plot.
        '''
        self.bindings.append((event, command_func, separate_thread))

    def reset_bindings(self):
        for event, command_func, separate_thread in self.bindings:
            if separate_thread:
                def threaded_command_func(*args):
                    threading.Thread(target=command_func).start()
                self.fig_canvas._tkcanvas.bind(event, threaded_command_func, add='+')
            else:
                self.fig_canvas._tkcanvas.bind(event, command_func, add='+')

    def __repr__(self):
        return f'MatplotlibPlot Widget: {self.widget_name} which belongs to: {self.section}'


class ProgressBar(Widget):
    def __init__(self, master=None, orient: str='horizontal', mode='determinate', length=100, **kwargs) -> None:
        '''mode arg can be "determinate" or "indeterminate" '''
        super().__init__(master=master, **kwargs)
        self.length = length
        del kwargs['grid_area']
        self._widget = ttk.Progressbar(master, orient=orient, mode=mode, length=length, **kwargs)
        self._widget['value'] = 0

    def set(self, value: float=0):
        self._widget['value'] = value

    def get(self):
        return self._widget['value']

    def progress_handler(self, *args):
        self._widget['value'] += 1
        if self._widget['value'] > self.length:
            self._widget['value'] %= self.length


class ScrolledText(Widget):
    def __init__(self, master=None, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        del kwargs['grid_area']
        self._widget = tk.scrolledtext.ScrolledText(master, wrap=tk.WORD, **kwargs)

    def get(self) -> List[str]:
        '''Return the lines of text in this widget'''
        return list(self._widget.get(1.0, tk.END).split('\n'))


class StdOutBox(Widget):
    def __init__(self, master=None, height: int=10, width: int=30, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        del kwargs['grid_area']
        self._widget = tk.Text(master, wrap='word', height=height, width=width, **kwargs)
        sys.stdout = self

    def write(self, s):
        '''Write printed text to text box on a new line'''
        self._widget.insert(tk.END, s)
        self._widget.see(tk.END)

    def flush(self):
        '''
        Method must be implemented for stdout replacement.
        Makes self a "file-like object."
        '''
        pass


class DatePicker(Widget):
    '''
    Widget for selecting a date - calendar style.
    '''
    def __init__(self, master=None, **kwargs) -> None:
        super().__init__(master=master, **kwargs)
        self._widget = self
        self.grid_area = kwargs.get('grid_area')
        self.kwargs = kwargs
        if 'grid_area' in kwargs:
            del kwargs['grid_area']

        self.selected_day = None
        self.selected_month = 9
        self.selected_year = 2021

        self.previous_month_btn = Button(master=self, text='<')
        self.previous_month_btn.bind_click(self.previous_month)
        self.current_month_year = Label(master=self)
        self.update_month_year()
        self.next_month_btn = Button(master=self, text='>')
        self.next_month_btn.bind_click(self.next_month)

        self.day_labels = []
        first_day, days_in_month = calendar.monthrange(self.selected_year, self.selected_month)
        self.build_days(days_in_month)


    def build_days(self, max_day):
        '''Handle creating labels (toggles/buttons) for each day in the month.'''
        if self.day_labels:
            for lbl in self.day_labels:
                lbl.destroy()
            self.day_labels = []

        for day_count in range(1, max_day+1):
            day_lbl = Label(master=self, text=day_count, borderwidth=1, relief='raised')  # self is a tk.Frame
            self.day_labels.append(day_lbl)

        # Not totally sure why below shenanigans is needed...
        # But had trouble with last day getting reference for all day clicks.
        def config_func(label):
            def func(*args):
                for lbl in self.day_labels:
                    lbl.config(bg=self.style.widget_bg_color)
                label.config(bg='#99BBFF')
                self.selected_day = int(label.get())
            return func
        for label in self.day_labels:
            label.bind_click(config_func(label))

    def get(self) -> datetime.date:
        try:
            return datetime.date(self.selected_year, self.selected_month, self.selected_day)
        except:
            return None

    def previous_month(self, *args):
        if self.selected_month == 1:
            self.selected_month = 12
            self.selected_year -= 1
        else:
            self.selected_month -= 1
        self.update_month_year()
        self.grid_interior()

    def next_month(self, *args):
        if self.selected_month == 12:
            self.selected_month = 1
            self.selected_year += 1
        else:
            self.selected_month += 1
        self.update_month_year()
        self.grid_interior()

    def update_month_year(self):
        mo_from_int = {1: 'January', 2: 'February', 3: 'March', 4: 'April', 5: 'May', 6: 'June', 7: 'July', 8: 'August', 9: 'September', 10: 'October', 11: 'November', 12: 'December'}
        self.current_month_year.set(f'{mo_from_int[self.selected_month]} {self.selected_year}')

    def grid_interior(self):
        first_day, days_in_month = calendar.monthrange(self.selected_year, self.selected_month)
        self.build_days(days_in_month)

        self.previous_month_btn._widget.grid(row=1, column=1)
        self.current_month_year._widget.grid(row=1, column=2, columnspan=5)
        self.next_month_btn._widget.grid(row=1, column=7)

        for i, day_of_week in enumerate(('S', 'M', 'T', 'W', 'T', 'F', 'S')):
            lbl = Label(master=self, text=day_of_week)
            lbl._widget.grid(row=2, column=i+1)

        # day_count adjusted so week is Sunday-> Saturday and moves day 1 to correct column
        day_count = -first_day - 1 if first_day != 6 else 0
        for row in range(1, 7):
            for col in range(1, 8):
                try:
                    if day_count >= 0:
                        day_lbl = self.day_labels[day_count]
                        day_lbl._widget.grid(row=row+2, column=col, stick='NSEW')
                except IndexError:
                    pass
                day_count += 1

    def destroy(self):
        for lbl in self.day_labels:
            lbl.destroy()
