"""
lcdMenu - A micropython library, which supports vertical and horizontal scrolling through menu items on both 2x16 and 4x20 LCDs
Copyright (C) 2024  Benjamin Burkhardt <bluefox@privacynerd.de>

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.
"""

from time import sleep


class lcdMenu:
    # lcd:               I2C_LCD - an object of the I2C_LCD class (https://git.privacynerd.de/BlueFox/micropython-libraries/src/branch/main/PCF8574T)
    # buttons:           dict    - a dictionary with machine.Pin objects as items with following keys
    # - "prev_btn"  - OPTIONAL - when pressed, select the previous menu item
    # - "next_btn"  - REQUIRED - when pressed, select the next menu item
    # - "ok_btn"    - REQUIRED - when pressed, call the callback function of the menu item
    # menu_items:        list    - a list (-> maintains order!) containing tuples with the following format: (<ENTRY_NAME>,<CALLBACK FUNCTION>) 
    # scroll_direction:  bool    - if true, the scrolling direction is horizontal, if false, vertical
    # cycle:             bool    - if true, start again with the first menu entry after the last one (and show the last before the first)
    # hide_menu_name:    bool    - OPTIONAL - if true, hide the menu's name (won't work in combination with a vertical scrolling direction)
    # name:              str     - OPTIONAL - a string with the name of the menu (can be hidden under certain circumstances)
    # debounce_time:     float   - OPTIONAL - the debounce time used by the library to debounce button presses
    def __init__(self, lcd, buttons: dict, scroll_direction: bool, cycle: bool, hide_menu_name: bool = False, name: str = "CHOOSE", debounce_time: float = 0.15):
        # save the argument variables
        self.lcd = lcd
        if "prev_btn" in buttons.keys():
            self.prev_btn = buttons["prev_btn"]
        else:
            self.prev_btn = None
        self.next_btn = buttons["next_btn"]
        self.ok_btn = buttons["ok_btn"]
        self.scroll_direction = scroll_direction
        self.cycle = cycle
        self.hide_menu_name = hide_menu_name
        self.name = name
        self.debounce_time = debounce_time
        
        self.current_selection = 0  # set a standard value (can/most of the time will be changed directly after __init__ by a call to self.setup())
        self.menu_items = []  # set a standard empty (can/most of the time will be changed directly after __init__ by a call to self.setup())
        
        # variables that are very unlikely the user want's to set them (but can be set via <lcdMenuObject>.<attribute_name>, of course)
        self.start_execution_msg = "Selected..."  # the string displayed when an menu item is selected
        self.end_execution_msg = "Closing..."  # the string displayed when the callback function of a selected menu item returns        self.end_execution_wait = 1  # the time (in seconds) to wait after the callback function of a selected menu item returns
        self.start_execution_wait = 0.5  # the time (in seconds) to wait before the callback function of a selected menu item is called
        self.end_execution_wait = 1  # the time (in seconds) to wait after the callback function of a selected menu item returns
        self.fill_char = '-'  # the character used to fill up space (used only on 4x20 displays); MUST BE 1 character long


    # menu_items:       list  - a list (-> maintains order!) containing tuples with the following format: (<ENTRY_NAME>,<CALLBACK FUNCTION>)
    # start_selection:  int   - OPTIONAL - the index of the item selected by default (starting with 0) - DON'T USE NEGATIVE INDEXES
    def setup(self, menu_items: list, start_selection: int = 0):
        self.menu_items = menu_items
        self.current_selection = start_selection


    def show_selection(self):
        # some checks:
        # 1. if you scrolling vertically, I found no elegant way to hide the name (there just need's to be something up there!)
        if self.scroll_direction and self.hide_menu_name:
            raise TypeError("Hiding the menu name whilst having the scroll direction set to horizontal is unsupported!")
        # 2. if there are no menu items to display...
        if len(self.menu_items) == 0:
            raise TypeError("Can't show empty menus! Maybe you forgot calling self.setup() after initializing me?")
        
        # get some often used values into local variables
        selection_name = self.menu_items[self.current_selection][0]
        lw = self.lcd.num_columns
        
        # fill the custom character fields in the displays memory
        self.lcd.custom_char(0, bytearray([0x04,0x0A,0x11,0x00,0x00,0x00,0x00,0x00]))  # arrow up
        self.lcd.custom_char(1, bytearray([0x00,0x00,0x00,0x00,0x00,0x11,0x0A,0x04]))  # arrow down
        self.lcd.custom_char(2, bytearray([0x04,0x0A,0x11,0x00,0x00,0x11,0x0A,0x04]))  # arrow up and down
        self.lcd.custom_char(3, bytearray([0x11,0x0A,0x04,0x00,0x00,0x04,0x0A,0x11]))  # no options
        self.lcd.custom_char(4, bytearray([0x08,0x08,0x08,0x0F,0x08,0x08,0x08,0x08]))  # line with a fork (to show the current selection - v scrolling)
        self.lcd.custom_char(5, bytearray([0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08]))  # line without a fork (to show unselected items - v scrolling)
        self.lcd.custom_char(6, bytearray([0x00,0x00,0x00,0x00,0x00,0x00,0x15,0x00]))  # three dots in a row
        
        # now show it off!
        # Horizontal scrolling:
        if self.scroll_direction:
            self.lcd.move_to(0,0)
            if self.lcd.num_lines == 4:
                self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}")  # fill the first and last line with 'fill_char's
                self.lcd.move_to(0,1)  # move to the second line for the starting message below (takes two lines)
            self.lcd.putstr(f"[{self.name[0:lw-2].center(lw-2)}]")  # the menu name (cannot be hidden in this mode)
            self.lcd.putstr(f"<{selection_name[0:lw-2].center(lw-2)}>")  # the current selected menu item's name
        # Vertical scrolling:
        else:
            lines_for_display = self.lcd.num_lines
            items_before_selection = self.current_selection  # e.g. if current selection is the second -> 1; one item is before this
            items_after_selection = len(self.menu_items) - self.current_selection - 1
            self.lcd.move_to(0,0)
            if not self.hide_menu_name:
                self.lcd.putstr(f"[{self.name[0:lw-2].center(lw-2)}]")  # print the menu name surrounded by sqare brackets []
                lines_for_display -= 1  # decrease the amount of lines available for displaying menu items by 1 (as it's used for the menu name)
            
            # now we want to display the selections
            # where the currently selected item should be the uppermost if possible
            # only at the end of the item list, the current item has to be in a lower line...
            # ... to avoid emtpy lines at the end as this would seem unprofessional/unclean to me
            # but it adds an noticeable amount of extra work / a complex algorithm
            if items_after_selection < (lines_for_display - 1):  # if there aren't enough items to fill the display after the current selection
                # maybe the following could be done with a crazy math formula - but I want to keep it simpler!
                # as there aren't enough menu items after the current selection to fill the display, we have to...
                # ... calculate where to place the current selection, how many items there are before it and how many after it
                menu_items_cut = self.menu_items[::-1][:lines_for_display][::-1]  # cut the menu_items list to the relevant last n ones maintaining order (n = number of lines for display)
                current_pos_in_cut = -len(self.menu_items) + self.current_selection + lines_for_display  # calculate the current index of the selection in the new cut
                # draw all the lines
                for i in range(lines_for_display):
                    if i == current_pos_in_cut:  # if drawing the currently selected item
                        self.lcd.putstr(f"{chr(4)} {menu_items_cut[i][0][0:lw-4]}")
                        self.lcd.putstr(" " * ((lw-4)-len(menu_items_cut[i][0][0:lw-4])))  # fit the line
                    else:
                        self.lcd.putstr(f"{chr(5)} {menu_items_cut[i][0][0:lw-4]}")
                        self.lcd.putstr(" " * ((lw-4)-len(menu_items_cut[i][0][0:lw-4])))  # fit the line
                    # now the arrow
                    if i == 0:  # if the first element is drawn, think about printing or not printing the up arrow
                        if self.current_selection == 0 and not self.cycle:  # first item selected and no cycling
                            self.lcd.putstr("  ")  # leave the line with spaces
                        else:
                            self.lcd.putstr(" " + chr(0))
                    elif i == lines_for_display-1:  # if the last element is drawn, print a down arrow?
                        if self.current_selection == (len(self.menu_items)-1) and not self.cycle:  # first item selected and no cycling
                            self.lcd.putstr("  ")  # leave the line with spaces
                        else:
                            self.lcd.putstr(" " + chr(1))  # arrow down
                        
            else:  # there are enough items to fill the display after the current selection
                # the first line
                self.lcd.putstr(f"{chr(4)} {selection_name[0:lw-4]}")
                self.lcd.putstr(" " * ((lw-4)-len(selection_name[0:lw-4])))  # fill the line with spaces up to 2 before the end of the line
                
                if len(self.menu_items) <= 1:
                    self.lcd.putstr(" " + chr(3))  # no options icon (as there's only one menu item!)
                elif lines_for_display == 1:  # if there is exactly one line to display the menu entries...
                    if self.current_selection == 0 and not self.cycle:  # ...and the first element is selected and cycling is disabled so you can't go back
                        self.lcd.putstr(" "+chr(1))
                    elif self.current_selection == (len(self.menu_items)-1) and not self.cycle:  # ...as before but with the last element -> you can't go further
                        self.lcd.putstr(" "+chr(0))
                    else:  # ...or anything else (cycling or in the middle -> you can go in both directions
                        self.lcd.putstr(" "+chr(2))
                elif self.current_selection == 0 and not self.cycle:  # first item selected and no cycling
                    self.lcd.putstr("  ")  # leave the line with spaces
                else:
                    self.lcd.putstr(" " + chr(0))
                
                # the other lines... (if existing!)
                for i in range(lines_for_display-1):
                    self.lcd.putstr(f"{chr(5)} {self.menu_items[self.current_selection+i+1][0][0:lw-4]}")
                    self.lcd.putstr(" " * ((lw-4)-len(self.menu_items[self.current_selection+i+1][0][0:lw-4])))  # fit the line
                    # check if it's the last line being drawn...
                    if (i+1) == (lines_for_display-1):
                        if self.current_selection == (len(self.menu_items)-1) and not self.cycle:  # last item selected and no cycling
                            self.lcd.putstr("  ")
                        else:
                            self.lcd.putstr(" " + chr(1))
                    else:  # else: if it's not the last line, leave the last two columns of the line empty
                        self.lcd.putstr("  ")
                            
    
    
    def previous_selection(self):
        self.current_selection -= 1
        if self.current_selection < 0:  # after the last element:
            if self.cycle:  # if cycling is enabled, set it to the index of the last element
                self.current_selection = len(self.menu_items)-1
            else:  # else, go to first element
                self.current_selection = 0
    
    
    def next_selection(self):
        self.current_selection += 1
        if self.current_selection >= len(self.menu_items):  # after the last element:
            if self.cycle:  # if cycling is enabled, go to first element
                self.current_selection = 0
            else:  # else, set it to the index of the last element
                self.current_selection = len(self.menu_items)-1
    
    
    def execute_selection(self):
        selection = self.menu_items[self.current_selection]
        lw = self.lcd.num_columns
        
        # if the program executed had no display (so the user notices something happens!)
        self.lcd.move_to(0,0)
        if self.lcd.num_lines == 4:
            self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}")  # fill the first and last line with 'fill_char's
            self.lcd.move_to(0,1)  # move to the second line for the starting message below (takes two lines)
        self.lcd.putstr(f"[{selection[0][0:lw-2].center(lw-2)}]{self.start_execution_msg[0:lw].center(lw)}")
        sleep(self.start_execution_wait)  # wait some time before execution (so that the text can be read)
        
        # run the program
        return_value = selection[1]()
        
        # show a exit when there's no specific return value
        if not return_value:  # if the return value is None / nothing was returned -> show a closing message
            while self.ok_btn.value() == 1: time.sleep(self.debounce_time)  # wait till ok_btn release (e.g. if the "program" is a simple send action)
            self.lcd.move_to(0,0)
            if self.lcd.num_lines == 4:
                self.lcd.putstr(f"{self.fill_char*lw}{' '*lw*2}{self.fill_char*lw}")  # fill the first and last line with 'fill_char's
                self.lcd.move_to(0,1)  # move to the second line for the starting message below (takes two lines)
            self.lcd.putstr(f"[{selection[0][0:lw].center(lw-2)}]{self.end_execution_msg[0:lw].center(lw)}")
            sleep(self.end_execution_wait)
            self.show_selection()
        else:  # -> show no message and quit directly back into the lcdMenu
            self.show_selection()
    
    
    # listen for button presses (this method should be called in an endless loop, see method run)
    def loop(self):
        if self.prev_btn:
            if self.prev_btn.value() == 1:
                self.previous_selection()
                self.show_selection()
                while self.prev_btn.value() == 1: sleep(self.debounce_time)  # wait till release
        if self.next_btn.value() == 1:
            self.next_selection()
            self.show_selection()
            while self.next_btn.value() == 1: sleep(self.debounce_time) # wait till release
        if self.ok_btn.value() == 1:
            while self.ok_btn.value() == 1: sleep(self.debounce_time) # wait till release
            self.execute_selection()
        
    def stop(self):  # here to act as a callback for a menu entry (if the user wants to ofc!)
        self.running = False
        return True
    
    def run(self):
        # show the selection first
        self.show_selection()
        self.running = True
        
        # then listen on button presses in a loop...
        while self.running:
            self.loop()
            
        return True  # to prevent a "Closing menu ..." in submenu-situations
