2024-10-31 17:23:28 +00:00
"""
2024-10-31 17:24:46 +00:00
lcdMenu - A micropython library , which supports vertical and horizontal scrolling through menu items on both 2 x16 and 4 x20 LCDs
2024-10-31 17:23:28 +00:00
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 / > .
"""
2024-11-01 16:30:41 +00:00
from time import sleep
2024-10-31 21:03:48 +00:00
2024-10-31 17:24:46 +00:00
class lcdMenu :
2024-10-31 20:11:12 +00:00
# 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)
2024-11-01 16:30:41 +00:00
# debounce_time: float - OPTIONAL - the debounce time used by the library to debounce button presses
2024-11-12 21:15:15 +00:00
def __init__ ( self , lcd , buttons : dict , scroll_direction : bool , cycle : bool , hide_menu_name : bool = False , name : str = " CHOOSE " , debounce_time : float = 0.15 ) :
2024-11-01 16:30:41 +00:00
# save the argument variables
2024-10-31 20:11:12 +00:00
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
2024-11-01 16:30:41 +00:00
self . debounce_time = debounce_time
2024-11-12 21:15:15 +00:00
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())
2024-11-01 16:30:41 +00:00
# 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
2024-11-12 21:15:15 +00:00
# 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
2024-10-31 20:11:12 +00:00
def show_selection ( self ) :
2024-11-12 20:52:05 +00:00
# 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!)
2024-10-31 20:11:12 +00:00
if self . scroll_direction and self . hide_menu_name :
2024-11-13 19:33:24 +00:00
raise TypeError ( " Hiding the menu name whilst having the scroll direction set to horizontal is unsupported! " )
2024-11-12 20:52:05 +00:00
# 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? " )
2024-10-31 20:11:12 +00:00
2024-11-01 16:38:37 +00:00
# get some often used values into local variables
selection_name = self . menu_items [ self . current_selection ] [ 0 ]
lw = self . lcd . num_columns
2024-11-01 20:51:49 +00:00
# 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
2024-11-12 20:26:34 +00:00
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)
2024-11-01 20:51:49 +00:00
self . lcd . custom_char ( 6 , bytearray ( [ 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x15 , 0x00 ] ) ) # three dots in a row
2024-11-01 16:38:37 +00:00
# now show it off!
# Horizontal scrolling:
2024-11-01 20:51:49 +00:00
if self . scroll_direction :
2024-11-01 16:38:37 +00:00
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 :
2024-11-01 20:51:49 +00:00
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
2024-11-12 21:15:15 +00:00
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)
2024-11-12 20:26:34 +00:00
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
2024-11-01 20:51:49 +00:00
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!)
2024-11-12 20:26:34 +00:00
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
2024-11-01 20:51:49 +00:00
self . lcd . putstr ( " " + chr ( 1 ) )
2024-11-12 20:26:34 +00:00
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
2024-11-01 20:51:49 +00:00
self . lcd . putstr ( " " + chr ( 0 ) )
2024-11-12 20:26:34 +00:00
else : # ...or anything else (cycling or in the middle -> you can go in both directions
2024-11-01 20:51:49 +00:00
self . lcd . putstr ( " " + chr ( 2 ) )
elif self . current_selection == 0 and not self . cycle : # first item selected and no cycling
2024-11-01 20:54:54 +00:00
self . lcd . putstr ( " " ) # leave the line with spaces
2024-11-01 20:51:49 +00:00
else :
self . lcd . putstr ( " " + chr ( 0 ) )
2024-11-12 20:26:34 +00:00
2024-11-01 20:51:49 +00:00
# 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 ( " " )
2024-10-31 20:11:12 +00:00
2024-10-31 21:03:48 +00:00
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
2024-10-31 20:11:12 +00:00
def execute_selection ( self ) :
2024-11-01 16:30:41 +00:00
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)
2024-11-01 16:38:37 +00:00
self . lcd . putstr ( f " [ { selection [ 0 ] [ 0 : lw - 2 ] . center ( lw - 2 ) } ] { self . start_execution_msg [ 0 : lw ] . center ( lw ) } " )
2024-11-01 16:30:41 +00:00
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
2024-11-12 20:26:34 +00:00
if not return_value : # if the return value is None / nothing was returned -> show a closing message
2024-11-15 21:28:35 +00:00
while self . ok_btn . value ( ) == 1 : sleep ( self . debounce_time ) # wait till ok_btn release (e.g. if the "program" is a simple send action)
2024-11-01 16:30:41 +00:00
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 )
2024-11-01 21:12:14 +00:00
self . show_selection ( )
2024-11-12 20:28:11 +00:00
else : # -> show no message and quit directly back into the lcdMenu
self . show_selection ( )
2024-10-31 20:11:12 +00:00
2024-11-01 16:30:41 +00:00
# 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 ( )
2024-11-12 21:15:15 +00:00
def stop ( self ) : # here to act as a callback for a menu entry (if the user wants to ofc!)
self . running = False
return True
2024-10-31 20:11:12 +00:00
def run ( self ) :
2024-11-01 16:30:41 +00:00
# show the selection first
self . show_selection ( )
2024-11-12 21:15:15 +00:00
self . running = True
2024-11-01 16:30:41 +00:00
# then listen on button presses in a loop...
2024-11-12 21:15:15 +00:00
while self . running :
2024-11-01 16:30:41 +00:00
self . loop ( )
2024-11-12 21:15:15 +00:00
return True # to prevent a "Closing menu ..." in submenu-situations