From d991b4c83bf02ec86d51532fdefc234f507e9397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Janar=20S=C3=B6=C3=B6t?= Date: Sun, 9 Aug 2020 16:29:55 +0300 Subject: [PATCH] menu: Replace menu with new Jinja2 template system (#2344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit menu.cfg: - jinja2 template scripting - new Setup menu - new Calibration menu menu: - redesigned menu code - jinja2 support - option to reverse menu up and down directions - functionality set to support menu injection from other modules - a new way of defining menu hierarchy - other adjustments Signed-off-by: Janar Sööt --- config/example-extras.cfg | 3 + config/example-menu.cfg | 190 +--- klippy/extras/display/display.py | 2 +- klippy/extras/display/menu.cfg | 796 +++++++------ klippy/extras/display/menu.py | 1832 ++++++++++++------------------ 5 files changed, 1231 insertions(+), 1592 deletions(-) diff --git a/config/example-extras.cfg b/config/example-extras.cfg index ac833938..b0ce5749 100644 --- a/config/example-extras.cfg +++ b/config/example-extras.cfg @@ -1895,6 +1895,9 @@ # Timeout for menu. Being inactive this amount of seconds will trigger # menu exit or return to root menu when having autorun enabled. # The default is 0 seconds (disabled) +#menu_reverse_navigation: +# When enabled it will reverse up and down directions for list navigation. +# The default is False. This parameter is optional. #encoder_pins: # The pins connected to encoder. 2 pins must be provided when # using encoder. This parameter must be provided when using menu. diff --git a/config/example-menu.cfg b/config/example-menu.cfg index 03dff965..ea58abfc 100644 --- a/config/example-menu.cfg +++ b/config/example-menu.cfg @@ -3,153 +3,71 @@ # The snippets in this file may be copied into the main printer.cfg file. # See the "example.cfg" file for description of common config parameters. -# Available menu elements: -# item - purely visual element -# command - same like 'item' but with gcode trigger -# input - same like 'command' but has value changing capabilities -# list - menu element container, with entry and exit gcode triggers -# vsdcard - same as 'list' but will append files from virtual sdcard +# Available options in menu Jinja2 template context: +# Read-only attributes for menu element: +# menu.width - element width (number of display columns) +# menu.ns - element namespace +# menu.event - name of the event that triggered the script +# menu.input - input value, only available in input script context +# List of actions for menu element: +# menu.back(force, update) - will execute menu back command, optional boolean parameters and . +# When is set True then it will also stop editing. Default value is False +# When is set False then parent container items are not updated. Default value is True +# menu.exit(force) - will execute menu exit command, optional boolean parameter default value False +# When is set True then it will also stop editing. Default value is False -#[menu item1] -#type: item -# Type will determine menu item properties and behaviours: +# Common parameters available for all menu config sections. +#[menu some_name] +#type: +# One of command, input, list, text: +# command - basic menu element with various script triggers +# input - same like 'command' but has value changing capabilities. +# Press will start/stop edit mode. +# list - it allows for menu items to be grouped together in a scrollable list. +# Add to the list by creating menu configurations +# using "some_list" as a prefix - for example: [menu some_list some_item_in_the_list] +# vsdlist - same as 'list' but will append files from virtual sdcard (will be removed in the future) #name: -# This is mandatory attribute for every menu element. -# You can use Python output formatting for parameter and transform values. -# Quotes can be used in the beginning and end of name. -#cursor: -# It allows to change cursor character for selected menu element. -# The default is > -# This parameter is optional. -#width: -# This attribute accepts integer value. Element name is cut to this width. -# This parameter is optional. -#scroll: -# This attribute accepts static boolean value. You can use it together with 'width'. -# When this is enabled then names longer than width are scrolled back and forth. -# The default is disabled. This parameter is optional. +# Name of menu item - evaluated as a template. #enable: -# This attribute accepts static boolean values and parameters (converted to boolean). -# It accepts multiple logical expressions. Values separated by comma will return True if all elements are true. -# Values on different lines will return True if any element is true. -# You can use logical negation by using character ! as parameter prefix. -#parameter: -# This attribute accepts float values or special variables. Multiple values are delimited by comma. -# All available parameter variables can be listed by 'MENU DO=dump' gcode, menu itself must be running. -# This value is available for output formatting as {0}..{n} Where n is count of parameters. -#transform: -# This attribute allows to transform parameters value to something else. -# More than one transformation can be added. Each transformation must be on separate line. -# These transformed values are available for output formatting as {n+1}..{x} -# Where n is count of parameters and x is count of transformations. -# In order to transform the value of a particular parameter, you must add -# an parameter index as prefix. Like this "transform: 1.choose('OFF','ON')" -# If the index is not set then the default index 0 is used. -# -# map(fromLow,fromHigh,toLow,toHigh) - interpolate re-maps a parameter value from one range to another. -# Output value type is taken from toHigh. It can be int or float. -# -# choose(e1,e2) - boolean chooser, converts the value of the parameter to the boolean type (0 and 1), -# and selects the corresponding value by the index from the list. -# -# choose(e1,e2,...) - int chooser, converts the value of the parameter to the int type -# and selects the corresponding value by the index from the list. -# -# choose({key:value,..}) - special dictionary chooser, parameter value cast type by first key type. -# Selects the corresponding value by the key from the dictionary. -# -# int(), float(), bool(), str(), abs(), bin(), hex(), oct(), days(), hours(), minutes(), seconds() -# These will convert parameter value to the special form. -# int,float,bool,str,abs,bin,hex and oct are python functions. -# days,hours,minutes,seconds will convert parameter value (it's taken as seconds) to time specific value -# -# scale(xx) - Multiplies parameter value by this xx. Pure interger or float value is excpected. +# Template that evaluates to True or False. +#index: +# Position where an item needs to be inserted in list +# By default the item is added at the end. This parameter is optional. - -#[menu command1] -#type:command +#[menu some_list] +#type: list +#name: +#enable: + +#[menu some_list some_command] +#type: command #name: -#cursor: -#width: -#scroll: #enable: -#parameter: -#transform: #gcode: -# When menu element is clicked then gcodes on this attribute will be executed. -# Can have multiline gcode script and supports output formatting for parameter and transform values. -#action: -# Special action can be executed. Supports [back, exit] menu commands -# and [respond response_info] command. Respond command will send '// response_info' to host. +# Script to run on button click or long click. Evaluated as a template. -#[menu input1] +#[menu some_list some_input] #type: input #name: -#cursor: -#width: #enable: -#transform: -#parameter: -# Value from parameter (always index 0) is taken as input value when in edit mode. -#gcode: -# This will be triggered in realtime mode, on exit from edit mode -# or in edit mode this will be triggered after click button long press (>0.8sec). -#longpress_gcode: -# In edit mode this will be triggered after click button long press (>0.8sec). -# The default is empty. This parameter is optional. -#reverse: -# This attribute accepts static boolean value. -# When enabled it will reverse increment and decrement directions for input. -# The default is False. This parameter is optional. -#readonly: -# This attribute accepts same logical expression as 'enable'. -# When true then input element is readonly like 'item' and cannot enter to edit mode. -# The default is False. This parameter is optional. +#input: +# Initial value to use when editing - evaluated as a template. +# Result must be float. +#input_min: +# Minimum value of range - evaluated as a template. +# Default -99999. +#input_max: +# Maximum value of range - evaluated as a template. +# Default 99999. +#input_step: +# Editing step - Must be a positive integer or float value. +# It has internal fast rate step. When (input_max - input_min) / input_step > 100 +# then fast rate step is 10 * input_step else fast rate step is same input_step #realtime: # This attribute accepts static boolean value. -# When enabled it will execute gcode after each value change. +# When enabled then gcode script is run after each value change. # The default is False. This parameter is optional. -#input_min: -# It accepts integer or float value. Will set minimal bound for edit value. -# The default is 2.2250738585072014e-308. This parameter is optional. -#input_max: -# It accepts integer or float value. Will set maximal bound for edit value. -# The default is 1.7976931348623157e+308. This parameter is optional. -#input_step: -# This is mandatory attribute for input. -# It accepts positive integer or float value. Will determine increment -# and decrement steps for edit value. -#input_step2: -# This is optional attribute for input. -# It accepts positive integer or float value. Will determine fast rate -# increment and decrement steps for edit value. -# The default is 0 (input_step will be used instead) - -#[menu list1] -#type:list or vsdcard -#name: -#cursor: -#width: -#scroll: -#enable: -#enter_gcode: -# Will trigger gcode script when entering to this menu container. -# This parameter is optional. -#leave_gcode: -# Will trigger gcode script when leaving from this menu container. -# This parameter is optional. -#show_back: -# This attribute accepts static boolean value. -# Show back [..] as first element. -# The default is True. This parameter is optional. -#show_title: -# This attribute accepts static boolean value. -# Show container name next to back [..] element. -# The default is True. This parameter is optional. -#items: -# Menu elements listed in this container. -# Each element must be on separate line. -# Elements can be grouped on same line by separating them with comma -# -# When element name stars with . then menu system will add parent -# container config name as prefix to element name (delimited by space) +#gcode: +# Script to run on button click, long click or value change. Evaluated as a template. +# The button click will trigger the edit mode start or end. diff --git a/klippy/extras/display/display.py b/klippy/extras/display/display.py index 3bdc47ea..11f60a57 100644 --- a/klippy/extras/display/display.py +++ b/klippy/extras/display/display.py @@ -87,7 +87,7 @@ class PrinterLCD: name = config.get_name() if name == 'display': # only load menu for primary display - self.menu = menu.MenuManager(config, self.lcd_chip) + self.menu = menu.MenuManager(config, self) self.printer.load_object(config, "display_status") # Configurable display self.display_templates = {} diff --git a/klippy/extras/display/menu.cfg b/klippy/extras/display/menu.cfg index 56de02bd..95b3c29b 100644 --- a/klippy/extras/display/menu.cfg +++ b/klippy/extras/display/menu.cfg @@ -2,620 +2,726 @@ # See the "example-menu.cfg" file for description of common config parameters. ### DEFAULT MENU ### +# Main +# + Tune +# + Speed: 000% +# + Flow: 000% +# + Offset Z:00.00 +# + OctoPrint +# + Pause printing +# + Resume printing +# + Abort printing +# + SD Card +# + Start printing +# + Resume printing +# + Pause printing +# + ... (files) +# + Control +# + Home All +# + Home Z +# + Home X/Y +# + Steppers off +# + Fan: OFF +# + Fan speed: 000% +# + Lights: OFF +# + Lights: 000% +# + Move 10mm +# + Move X:000.0 +# + Move Y:000.0 +# + Move Z:000.0 +# + Move E:+000.0 +# + Move 1mm +# + Move X:000.0 +# + Move Y:000.0 +# + Move Z:000.0 +# + Move E:+000.0 +# + Move 0.1mm +# + Move X:000.0 +# + Move Y:000.0 +# + Move Z:000.0 +# + Move E:+000.0 +# + Temperature +# + Ex0:000 (0000) +# + Ex1:000 (0000) +# + Bed:000 (0000) +# + Preheat PLA +# + Preheat all +# + Preheat hotend +# + Preheat hotbed +# + Preheat ABS +# + Preheat all +# + Preheat hotend +# + Preheat hotbed +# + Cooldown +# + Cooldown all +# + Cooldown hotend +# + Cooldown hotbed +# + Filament +# + Ex0:000 (0000) +# + Load Fil. fast +# + Load Fil. slow +# + Unload Fil.fast +# + Unload Fil.slow +# + Feed: 000.0 +# + Setup +# + Save config +# + Restart +# + Restart host +# + Restart FW +# + PID tuning +# + Tune Hotend PID +# + Tune Hotbed PID +# + Calibration +# + Delta cal. auto +# + Delta cal. man +# + Start probing +# + Move Z: 000.00 +# + Test Z: ++ +# + Accept +# + Abort +# + Bed probe +# + Dump parameters ### menu main ### [menu __main] type: list -name: Main Menu -items: - __tune - __octoprint - __sdcard - __control - __temp - __filament - __prepare +name: Main ### menu tune ### -[menu __tune] +[menu __main __tune] type: list -enable: toolhead.is_printing +enable: {printer.idle_timeout.state == "Printing"} name: Tune -items: - .__speed - .__flow - .__offsetz -[menu __tune __speed] +[menu __main __tune __speed] type: input -name: "Speed: {1:3d}%" -parameter: gcode.speed_factor -transform: - map(0,2,0,200) +name: Speed: {'%3d' % (menu.input*100)}% +input: {printer.gcode.speed_factor} input_min: 0 input_max: 2 input_step: 0.01 -realtime: true -gcode: M220 S{1:d} +realtime: True +gcode: + M220 S{'%d' % (menu.input*100)} -[menu __tune __flow] +[menu __main __tune __flow] type: input -name: "Flow: {1:3d}%" -parameter: gcode.extrude_factor -transform: - map(0,2,0,200) +name: Flow: {'%3d' % (menu.input*100)}% +input: {printer.gcode.extrude_factor} input_min: 0 input_max: 2 input_step: 0.01 -realtime: true -gcode: M221 S{1:d} +realtime: True +gcode: + M221 S{'%d' % (menu.input*100)} -[menu __tune __offsetz] +[menu __main __tune __offsetz] type: input -name: "Offset Z:{0:05.3f} " -parameter: gcode.homing_zpos +name: Offset Z:{'%05.3f' % menu.input} +input: {printer.gcode.homing_zpos} input_min: -5 input_max: 5 input_step: 0.005 -realtime: true -gcode: SET_GCODE_OFFSET Z={0:.3f} MOVE=1 +realtime: True +gcode: + SET_GCODE_OFFSET Z={'%.3f' % menu.input} MOVE=1 + ### menu octoprint ### -[menu __octoprint] +[menu __main __octoprint] type: list name: OctoPrint -items: - .__pause - .__resume - .__abort -[menu __octoprint __pause] +[menu __main __octoprint __pause] type: command -enable: toolhead.is_printing +enable: {printer.idle_timeout.state == "Printing"} name: Pause printing -action: respond action:pause gcode: + {printer.gcode.action_respond_info('action:pause')} -[menu __octoprint __resume] +[menu __main __octoprint __resume] type: command -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Resume printing -action: respond action:resume gcode: + {printer.gcode.action_respond_info('action:resume')} -[menu __octoprint __abort] +[menu __main __octoprint __abort] type: command -enable: toolhead.is_printing +enable: {printer.idle_timeout.state == "Printing"} name: Abort printing -action: respond action:cancel gcode: + {printer.gcode.action_respond_info('action:cancel')} ### menu virtual sdcard ### -[menu __sdcard] -type: vsdcard +[menu __main __sdcard] +type: vsdlist +enable: {('virtual_sdcard' in printer)} name: SD Card -items: - .__start - .__resume - .__pause -[menu __sdcard __start] +[menu __main __sdcard __start] type: command -enable: !toolhead.is_printing +enable: {('virtual_sdcard' in printer) and not printer.idle_timeout.state == "Printing"} name: Start printing gcode: M24 -[menu __sdcard __resume] +[menu __main __sdcard __resume] type: command -enable: toolhead.is_printing +enable: {('virtual_sdcard' in printer) and printer.idle_timeout.state == "Printing"} name: Resume printing gcode: M24 -[menu __sdcard __pause] +[menu __main __sdcard __pause] type: command -enable: toolhead.is_printing +enable: {('virtual_sdcard' in printer) and printer.idle_timeout.state == "Printing"} name: Pause printing gcode: M25 ### menu control ### -[menu __control] +[menu __main __control] type: list name: Control -items: - .__home - .__homez - .__homexy - .__move_10mm - .__move_1mm - .__move_01mm - .__disable - .__fanonoff - .__fanspeed - .__caselightonoff - .__caselightpwm -[menu __control __home] +[menu __main __control __home] type: command +enable: {not printer.idle_timeout.state == "Printing"} name: Home All gcode: G28 -enable: !toolhead.is_printing -[menu __control __homez] +[menu __main __control __homez] type: command -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Home Z gcode: G28 Z -[menu __control __homexy] +[menu __main __control __homexy] type: command -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Home X/Y gcode: G28 X Y -[menu __control __disable] +[menu __main __control __disable] type: command -name: Disable steppers +name: Steppers off gcode: M84 M18 -[menu __control __fanonoff] +[menu __main __control __fanonoff] type: input -enable: fan.is_enabled -name: Fan {1:3s} -parameter: fan.speed -transform: - choose('OFF','ON') - choose(0,255) +enable: {'fan' in printer} +name: Fan: {'ON ' if menu.input else 'OFF'} +input: {printer.fan.speed} input_min: 0 input_max: 1 input_step: 1 -gcode: M106 S{2:d} +gcode: + M106 S{255 if menu.input else 0} -[menu __control __fanspeed] +[menu __main __control __fanspeed] type: input -enable: fan.is_enabled -name: Fan speed: {1:3d}% -parameter: fan.speed -transform: - map(0,1,0,100) - map(0,1,0,255) +enable: {'fan' in printer} +name: Fan speed: {'%3d' % (menu.input*100)}% +input: {printer.fan.speed} input_min: 0 input_max: 1 input_step: 0.01 -gcode: M106 S{2:d} +gcode: + M106 S{'%d' % (menu.input*255)} -[menu __control __caselightonoff] +[menu __main __control __caselightonoff] type: input -enable: output_pin.caselight.is_enabled -name: Case light: {1:3s} -parameter: output_pin.caselight.value -transform: - choose('OFF','ON') - choose(0,1) +enable: {'output_pin caselight' in printer} +name: Lights: {'ON ' if menu.input else 'OFF'} +input: {printer['output_pin caselight'].value} input_min: 0 input_max: 1 input_step: 1 -gcode: SET_PIN PIN=caselight VALUE={2} +gcode: + SET_PIN PIN=caselight VALUE={1 if menu.input else 0} -[menu __control __caselightpwm] +[menu __main __control __caselightpwm] type: input -enable: output_pin.caselight.is_enabled -name: Case light: {0:4.0%} -parameter: output_pin.caselight.value +enable: {'output_pin caselight' in printer} +name: Lights: {'%3d' % (menu.input*100)}% +input: {printer['output_pin caselight'].value} input_min: 0.0 input_max: 1.0 input_step: 0.01 -gcode: SET_PIN PIN=caselight VALUE={0:.2f} +gcode: + SET_PIN PIN=caselight VALUE={menu.input} ### menu move 10mm ### -[menu __control __move_10mm] +[menu __main __control __move_10mm] type: list -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Move 10mm -items: - .__axis_z - .__axis_x, .__axis_y - .__axis_e -[menu __control __move_10mm __axis_x] +[menu __main __control __move_10mm __axis_x] type: input -name: "X:{0:05.1f} " -parameter: gcode.move_xpos +name: Move X:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.x} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 10.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 X{0:.1f} F2400 + G1 X{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_10mm __axis_y] +[menu __main __control __move_10mm __axis_y] type: input -name: "Y:{0:05.1f} " -parameter: gcode.move_ypos +name: Move Y:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.y} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 10.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Y{0:.1f} F2400 + G1 Y{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_10mm __axis_z] +[menu __main __control __move_10mm __axis_z] type: input -enable: !toolhead.is_printing -name: "Move Z:{0:05.1f}" -parameter: gcode.move_zpos +enable: {not printer.idle_timeout.state == "Printing"} +name: Move Z:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.z} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 10.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Z{0:.1f} F240 + G1 Z{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_10mm __axis_e] +[menu __main __control __move_10mm __axis_e] type: input -enable: !toolhead.is_printing -name: "Move E:{0:+06.1f}" -parameter: 0 -input_min: -50.0 -input_max: 50.0 +enable: {not printer.idle_timeout.state == "Printing"} +name: Move E:{'%+06.1f' % menu.input} +input: 0 +input_min: -50 +input_max: 50 input_step: 10.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis M83 - G1 E{0:.1f} F240 + G1 E{menu.input} F240 + RESTORE_GCODE_STATE NAME=__move__axis ### menu move 1mm ### -[menu __control __move_1mm] +[menu __main __control __move_1mm] type: list -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Move 1mm -items: - .__axis_z - .__axis_x, .__axis_y - .__axis_e -[menu __control __move_1mm __axis_x] +[menu __main __control __move_1mm __axis_x] type: input -name: "X:{0:05.1f} " -parameter: gcode.move_xpos +name: Move X:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.x} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 1.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 X{0:.1f} F2400 + G1 X{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_1mm __axis_y] +[menu __main __control __move_1mm __axis_y] type: input -name: "Y:{0:05.1f} " -parameter: gcode.move_ypos +name: Move Y:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.y} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 1.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Y{0:.1f} F2400 + G1 Y{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_1mm __axis_z] +[menu __main __control __move_1mm __axis_z] type: input -enable: !toolhead.is_printing -name: "Move Z:{0:05.1f}" -parameter: gcode.move_zpos +enable: {not printer.idle_timeout.state == "Printing"} +name: Move Z:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.z} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 1.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Z{0:.1f} F240 + G1 Z{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_1mm __axis_e] +[menu __main __control __move_1mm __axis_e] type: input -enable: !toolhead.is_printing -name: "Move E:{0:+06.1f}" -parameter: 0 -input_min: -50.0 -input_max: 50.0 +enable: {not printer.idle_timeout.state == "Printing"} +name: Move E:{'%+06.1f' % menu.input} +input: 0 +input_min: -50 +input_max: 50 input_step: 1.0 gcode: + SAVE_GCODE_STATE NAME=__move__axis M83 - G1 E{0:.1f} F240 + G1 E{menu.input} F240 + RESTORE_GCODE_STATE NAME=__move__axis ### menu move 0.1mm ### -[menu __control __move_01mm] +[menu __main __control __move_01mm] type: list -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Move 0.1mm -items: - .__axis_z - .__axis_x, .__axis_y - .__axis_e -[menu __control __move_01mm __axis_x] +[menu __main __control __move_01mm __axis_x] type: input -name: "X:{0:05.1f} " -parameter: gcode.move_xpos +name: Move X:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.x} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 0.1 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 X{0:.1f} F2400 + G1 X{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_01mm __axis_y] +[menu __main __control __move_01mm __axis_y] type: input -name: "Y:{0:05.1f} " -parameter: gcode.move_ypos +name: Move Y:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.y} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 0.1 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Y{0:.1f} F2400 + G1 Y{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_01mm __axis_z] +[menu __main __control __move_01mm __axis_z] type: input -enable: !toolhead.is_printing -name: "Move Z:{0:05.1f}" -parameter: gcode.move_zpos +enable: {not printer.idle_timeout.state == "Printing"} +name: Move Z:{'%05.1f' % menu.input} +input: {printer.gcode.gcode_position.z} input_min: 0 -input_max: 200.0 +input_max: 200 input_step: 0.1 gcode: + SAVE_GCODE_STATE NAME=__move__axis G90 - G1 Z{0:.1f} F240 + G1 Z{menu.input} + RESTORE_GCODE_STATE NAME=__move__axis -[menu __control __move_01mm __axis_e] +[menu __main __control __move_01mm __axis_e] type: input -enable: !toolhead.is_printing -name: "Move E:{0:+06.1f}" -parameter: 0 -input_min: -50.0 -input_max: 50.0 +enable: {not printer.idle_timeout.state == "Printing"} +name: Move E:{'%+06.1f' % menu.input} +input: 0 +input_min: -50 +input_max: 50 input_step: 0.1 gcode: + SAVE_GCODE_STATE NAME=__move__axis M83 - G1 E{0:.1f} F240 + G1 E{menu.input} F240 + RESTORE_GCODE_STATE NAME=__move__axis ### menu temperature ### -[menu __temp] +[menu __main __temp] type: list name: Temperature -items: - .__hotend0_current, .__hotend0_target - .__hotend1_current, .__hotend1_target - .__hotbed_current, .__hotbed_target - .__preheat_pla - .__preheat_abs - .__cooldown -[menu __temp __hotend0_current] -type: item -enable: extruder.is_enabled -name: "Ex0:{0:4.0f} T" -parameter: extruder.temperature - -[menu __temp __hotend0_target] +[menu __main __temp __hotend0_target] type: input -enable: extruder.is_enabled -name: "{0:4.0f}" -parameter: extruder.target +enable: {'extruder' in printer} +name: {"Ex0:%3.0f (%4.0f)" % (menu.input, printer.extruder.temperature)} +input: {printer.extruder.target} input_min: 0 -input_max: 250 +input_max: {printer.configfile.config.extruder.max_temp} input_step: 1 -input_step2: 10 -gcode: M104 T0 S{0:.0f} +gcode: M104 T0 S{'%.0f' % menu.input} -[menu __temp __hotend1_current] -type: item -enable: extruder1.is_enabled -name: "Ex1:{0:4.0f} T" -parameter: extruder1.temperature - -[menu __temp __hotend1_target] +[menu __main __temp __hotend1_target] type: input -enable: extruder1.is_enabled -name: "{0:4.0f}" -parameter: extruder1.target +enable: {'extruder1' in printer} +name: {"Ex1:%3.0f (%4.0f)" % (menu.input, printer.extruder1.temperature)} +input: {printer.extruder1.target} input_min: 0 -input_max: 250 +input_max: {printer.configfile.config.extruder1.max_temp} input_step: 1 -input_step2: 10 -gcode: M104 T1 S{0:.0f} +gcode: M104 T1 S{'%.0f' % menu.input} -[menu __temp __hotbed_current] -type: item -enable: heater_bed.is_enabled -name: "Bed:{0:4.0f} T" -parameter: heater_bed.temperature - -[menu __temp __hotbed_target] +[menu __main __temp __hotbed_target] type: input -enable: heater_bed.is_enabled -name: "{0:4.0f}" -parameter: heater_bed.target +enable: {'heater_bed' in printer} +name: {"Bed:%3.0f (%4.0f)" % (menu.input, printer.heater_bed.temperature)} +input: {printer.heater_bed.target} input_min: 0 -input_max: 130 +input_max: {printer.configfile.config.heater_bed.max_temp} input_step: 1 -input_step2: 10 -gcode: M140 S{0:.0f} +gcode: M140 S{'%.0f' % menu.input} -[menu __temp __preheat_pla] +[menu __main __temp __preheat_pla] type: list name: Preheat PLA -items: - .__all - .__hotend - .__hotbed -[menu __temp __preheat_pla __all] +[menu __main __temp __preheat_pla __all] type: command -enable: extruder.is_enabled,heater_bed.is_enabled +enable: {('extruder' in printer) and ('heater_bed' in printer)} name: Preheat all gcode: M140 S60 M104 S200 -[menu __temp __preheat_pla __hotend] +[menu __main __temp __preheat_pla __hotend] type: command -enable: extruder.is_enabled +enable: {'extruder' in printer} name: Preheat hotend gcode: M104 S200 -[menu __temp __preheat_pla __hotbed] +[menu __main __temp __preheat_pla __hotbed] type: command -enable: heater_bed.is_enabled +enable: {'heater_bed' in printer} name: Preheat hotbed gcode: M140 S60 -[menu __temp __preheat_abs] +[menu __main __temp __preheat_abs] type: list name: Preheat ABS -items: - .__all - .__hotend - .__hotbed -[menu __temp __preheat_abs __all] +[menu __main __temp __preheat_abs __all] type: command -enable: extruder.is_enabled,heater_bed.is_enabled +enable: {('extruder' in printer) and ('heater_bed' in printer)} name: Preheat all gcode: M140 S110 M104 S245 -[menu __temp __preheat_abs __hotend] +[menu __main __temp __preheat_abs __hotend] type: command -enable: extruder.is_enabled +enable: {'extruder' in printer} name: Preheat hotend gcode: M104 S245 -[menu __temp __preheat_abs __hotbed] +[menu __main __temp __preheat_abs __hotbed] type: command -enable: heater_bed.is_enabled +enable: {'heater_bed' in printer} name: Preheat hotbed gcode: M140 S110 -[menu __temp __cooldown] +[menu __main __temp __cooldown] type: list name: Cooldown -items: - .__all - .__hotend - .__hotbed -[menu __temp __cooldown __all] +[menu __main __temp __cooldown __all] type: command -enable: extruder.is_enabled,heater_bed.is_enabled +enable: {('extruder' in printer) and ('heater_bed' in printer)} name: Cooldown all gcode: M104 S0 M140 S0 -[menu __temp __cooldown __hotend] +[menu __main __temp __cooldown __hotend] type: command -enable: extruder.is_enabled +enable: {'extruder' in printer} name: Cooldown hotend gcode: M104 S0 -[menu __temp __cooldown __hotbed] +[menu __main __temp __cooldown __hotbed] type: command -enable: heater_bed.is_enabled +enable: {'heater_bed' in printer} name: Cooldown hotbed gcode: M140 S0 ### menu filament ### -[menu __filament] +[menu __main __filament] type: list name: Filament -items: - __temp __hotend0_current, __temp __hotend0_target - .__unload - .__load - .__feed -[menu __filament __load] -type: command -name: Load Filament -gcode: - M83 - G1 E50 F1000 - G1 E50 F1000 - G1 E50 F1000 - G1 E50 F1000 - G1 E50 F300 - G1 E50 F300 - M82 - -[menu __filament __unload] -type: command -name: Unload Filament -gcode: - M83 - G1 E-50 F1000 - G1 E-50 F1000 - G1 E-50 F1000 - G1 E-50 F1000 - G1 E-50 F1800 - G1 E-50 F1800 - G1 E-50 F1800 - G1 E-50 F1800 - M82 - -[menu __filament __feed] +[menu __main __filament __hotend0_target] type: input -name: Feed: {0:.1f} -parameter: 0 +enable: {'extruder' in printer} +name: {"Ex0:%3.0f (%4.0f)" % (menu.input, printer.extruder.temperature)} +input: {printer.extruder.target} +input_min: 0 +input_max: {printer.configfile.config.extruder.max_temp} +input_step: 1 +gcode: M104 T0 S{'%.0f' % menu.input} + +[menu __main __filament __loadf] +type: command +name: Load Fil. fast +gcode: + SAVE_GCODE_STATE NAME=__filament__load + M83 + G1 E50 F960 + RESTORE_GCODE_STATE NAME=__filament__load + +[menu __main __filament __loads] +type: command +name: Load Fil. slow +gcode: + SAVE_GCODE_STATE NAME=__filament__load + M83 + G1 E50 F240 + RESTORE_GCODE_STATE NAME=__filament__load + +[menu __main __filament __unloadf] +type: command +name: Unload Fil.fast +gcode: + SAVE_GCODE_STATE NAME=__filament__load + M83 + G1 E-50 F960 + RESTORE_GCODE_STATE NAME=__filament__load + +[menu __main __filament __unloads] +type: command +name: Unload Fil.slow +gcode: + SAVE_GCODE_STATE NAME=__filament__load + M83 + G1 E-50 F240 + RESTORE_GCODE_STATE NAME=__filament__load + +[menu __main __filament __feed] +type: input +name: Feed: {'%.1f' % menu.input} +input: 5 input_step: 0.1 gcode: + SAVE_GCODE_STATE NAME=__filament__load M83 - G1 E{0:.1f} F30 + G1 E{'%.1f' % menu.input} F60 + RESTORE_GCODE_STATE NAME=__filament__load -### menu prepare ### -[menu __prepare] +### menu setup ### +[menu __main __setup] type: list -enable: !toolhead.is_printing -name: Prepare -items: - .__delta_calib - .__bedprobe - .__hotend_pid_tuning - .__hotbed_pid_tuning - .__host_restart - .__firmware_restart +enable: {not printer.idle_timeout.state == "Printing"} +name: Setup -[menu __prepare __host_restart] +[menu __main __setup __save_config] type: command -enable: !toolhead.is_printing +name: Save config +gcode: SAVE_CONFIG + +[menu __main __setup __restart] +type: list +name: Restart + +[menu __main __setup __restart __host_restart] +type: command +enable: {not printer.idle_timeout.state == "Printing"} name: Restart host gcode: RESTART -[menu __prepare __firmware_restart] +[menu __main __setup __restart __firmware_restart] type: command -enable: !toolhead.is_printing +enable: {not printer.idle_timeout.state == "Printing"} name: Restart FW gcode: FIRMWARE_RESTART -[menu __prepare __delta_calib] -type: command -enable: !toolhead.is_printing -name: Delta calibrate -gcode: DELTA_CALIBRATE +[menu __main __setup __tuning] +type: list +name: PID tuning -[menu __prepare __bedprobe] +[menu __main __setup __tuning __hotend_pid_tuning] type: command -enable: !toolhead.is_printing -name: Bed probe -gcode: PROBE - -[menu __prepare __hotend_pid_tuning] -type: command -enable: !toolhead.is_printing, extruder.is_enabled +enable: {(not printer.idle_timeout.state == "Printing") and ('extruder' in printer)} name: Tune Hotend PID gcode: PID_CALIBRATE HEATER=extruder TARGET=210 WRITE_FILE=1 -[menu __prepare __hotbed_pid_tuning] +[menu __main __setup __tuning __hotbed_pid_tuning] type: command -enable: !toolhead.is_printing, heater_bed.is_enabled +enable: {(not printer.idle_timeout.state == "Printing") and ('heater_bed' in printer)} name: Tune Hotbed PID gcode: PID_CALIBRATE HEATER=heater_bed TARGET=60 WRITE_FILE=1 + +[menu __main __setup __calib] +type: list +name: Calibration + +[menu __main __setup __calib __delta_calib_auto] +type: command +enable: {not printer.idle_timeout.state == "Printing"} +name: Delta cal. auto +gcode: + G28 + DELTA_CALIBRATE + +[menu __main __setup __calib __delta_calib_man] +type: list +enable: {not printer.idle_timeout.state == "Printing"} +name: Delta cal. man + +[menu __main __setup __calib __bedprobe] +type: command +enable: {not printer.idle_timeout.state == "Printing"} +name: Bed probe +gcode: PROBE + +[menu __main __setup __calib __delta_calib_man __start] +type: command +name: Start probing +gcode: + G28 + DELTA_CALIBRATE METHOD=manual + +[menu __main __setup __calib __delta_calib_man __move_z] +type: input +name: Move Z: {'%03.2f' % menu.input} +input: {printer.gcode.gcode_position.z} +input_step: 1 +realtime: True +gcode: + {%- if menu.event == 'change' -%} + G1 Z{'%.2f' % menu.input} + {%- elif menu.event == 'long_click' -%} + G1 Z{'%.2f' % menu.input} + SAVE_GCODE_STATE NAME=__move__axis + G91 + G1 Z2 + G1 Z-2 + RESTORE_GCODE_STATE NAME=__move__axis + {%- endif -%} + +[menu __main __setup __calib __delta_calib_man __test_z] +type: input +name: Test Z: {['++','+','+.01','+.05','+.1','+.5','-.5','-.1','-.05','-.01','-','--'][menu.input|int]} +input: 6 +input_min: 0 +input_max: 11 +input_step: 1 +gcode: + {%- if menu.event == 'long_click' -%} + TESTZ Z={['++','+','+.01','+.05','+.1','+.5','-.5','-.1','-.05','-.01','-','--'][menu.input|int]} + {%- endif -%} + +[menu __main __setup __calib __delta_calib_man __accept] +type: command +name: Accept +gcode: ACCEPT + +[menu __main __setup __calib __delta_calib_man __abort] +type: command +name: Abort +gcode: ABORT + +[menu __main __setup __dump] +type: command +name: Dump parameters +gcode: + {% for name1 in printer %} + {% for name2 in printer[name1] %} + { printer.gcode.action_respond_info("printer['%s'].%s = %s" % (name1, name2, printer[name1][name2])) } + {% else %} + { printer.gcode.action_respond_info("printer['%s'] = %s" % (name1, printer[name1])) } + {% endfor %} + {% endfor %} diff --git a/klippy/extras/display/menu.py b/klippy/extras/display/menu.py index e99b367c..7dc05864 100644 --- a/klippy/extras/display/menu.py +++ b/klippy/extras/display/menu.py @@ -1,45 +1,86 @@ # -*- coding: utf-8 -*- # Basic LCD menu support # -# Based on the RaspberryPiLcdMenu from Alan Aufderheide, February 2013 -# Copyright (C) 2018 Janar Sööt +# Copyright (C) 2020 Janar Sööt # # This file may be distributed under the terms of the GNU GPLv3 license. -import os, logging, sys, ast, re, string +import os, logging +from string import Template from . import menu_keys +from .. import gcode_macro + + +class sentinel: + pass + class error(Exception): pass -# static class for cursor -class MenuCursor: - NONE = ' ' - SELECT = '>' - EDIT = '*' +class MenuConfig(dict): + """Wrapper for dict to emulate configfile get_name for namespace. + __ns - item namespace key, used in item relative paths + $__id - generated id text variable + """ + def get_name(self): + __id = '__menu_' + hex(id(self)).lstrip("0x").rstrip("L") + return Template('menu ' + self.get( + '__ns', __id)).safe_substitute(__id=__id) + + def get_prefix_options(self, prefix): + return [o for o in self.keys() if o.startswith(prefix)] -# Menu element baseclass +# Scriptable menu element abstract baseclass class MenuElement(object): - def __init__(self, manager, config, namespace=''): - self.cursor = config.get('cursor', MenuCursor.SELECT) - self._namespace = namespace + def __init__(self, manager, config): + if type(self) is MenuElement: + raise error( + 'Abstract MenuElement cannot be instantiated directly') self._manager = manager - self._width = self._asint(config.get('width', '0')) - self._scroll = self._asbool(config.get('scroll', 'false')) - self._enable = self._aslist(config.get('enable', 'true'), - flatten=False) - self._name = self._asliteral(config.get('name')) + self.cursor = '>' + # scroll is always on + self._scroll = True + self._index = manager.asint(config.get('index', ''), None) + self._enable_tpl = manager.gcode_macro.load_template( + config, 'enable', 'True') + self._name_tpl = manager.gcode_macro.load_template( + config, 'name') + # item namespace - used in relative paths + self._ns = str(" ".join(config.get_name().split(' ')[1:])).strip() + self._last_heartbeat = None self.__scroll_offs = 0 self.__scroll_diff = 0 self.__scroll_dir = None self.__last_state = True - if len(self.cursor) < 1: - raise error("Cursor with unexpected length, expecting 1.") + # display width is used and adjusted by cursor size + self._width = self.manager.cols - len(self._cursor) + # menu scripts + self._script_tpls = {} + # init + self.init() # override - def _render(self): - return self._name + def init(self): + pass + + def _name(self): + context = self.get_context() + return self.manager.asflat(self._name_tpl.render(context)) + + def _load_scripts(self, config, *args, **kwargs): + """Load script(s) from config""" + + prefix = kwargs.get('prefix', '') + for arg in args: + name = arg[len(prefix):] + if name in self._script_tpls: + logging.info( + "Declaration of '%s' hides " + "previous script declaration" % (name,)) + self._script_tpls[name] = self.manager.gcode_macro.load_template( + config, arg, '') # override def _second_tick(self, eventtime): @@ -49,10 +90,6 @@ class MenuElement(object): def is_editing(self): return False - # override - def is_readonly(self): - return True - # override def is_scrollable(self): return True @@ -62,16 +99,33 @@ class MenuElement(object): return self.eval_enable() # override - def reset_editing(self): + def start_editing(self): pass - def eval_enable(self): - return self._parse_bool(self._enable) + # override + def stop_editing(self): + pass - def init(self): + # override + def get_context(self, cxt=None): + # get default menu context + context = self.manager.get_context(cxt) + context['menu'].update({ + 'width': self._width, + 'ns': self.get_ns() + }) + return context + + def eval_enable(self): + context = self.get_context() + return self.manager.asbool(self._enable_tpl.render(context)) + + # Called when a item is selected + def select(self): self.__clear_scroll() def heartbeat(self, eventtime): + self._last_heartbeat = eventtime state = bool(int(eventtime) & 1) if self.__last_state ^ state: self.__last_state = state @@ -97,7 +151,7 @@ class MenuElement(object): else: self.__clear_scroll() - def __render_scroll(self, s): + def __name_scroll(self, s): if self.__scroll_dir is None: self.__scroll_dir = 0 self.__scroll_offs = 0 @@ -105,137 +159,99 @@ class MenuElement(object): self.__scroll_offs:self._width + self.__scroll_offs ].ljust(self._width) - def render(self, scroll=False): - s = str(self._render()) + def render_name(self, selected=False): + s = str(self._name()) + # scroller if self._width > 0: self.__scroll_diff = len(s) - self._width - if (scroll and self._scroll is True and self.is_scrollable() + if (selected and self._scroll is True and self.is_scrollable() and self.__scroll_diff > 0): - s = self.__render_scroll(s) + s = self.__name_scroll(s) else: self.__clear_scroll() s = s[:self._width].ljust(self._width) else: self.__clear_scroll() + # add cursors + if selected and not self.is_editing(): + s = self.cursor + s + elif selected and self.is_editing(): + s = '*' + s + else: + s = ' ' + s return s - def _parse_bool(self, lst): - try: - return any([ - all([ - self._lookup_bool(l2) for l2 in self._words_aslist(l1) - ]) for l1 in lst - ]) - except Exception: - logging.exception("Boolean parsing error") - return False + def get_ns(self, name='.'): + name = str(name).strip() + if name.startswith('..'): + name = ' '.join( + [(' '.join(str(self._ns).split(' ')[:-1])), name[2:]]) + elif name.startswith('.'): + name = ' '.join([str(self._ns), name[1:]]) + return name.strip() - def _lookup_bool(self, b): - if not self._asbool(b): - if b[0] == '!': # logical negation: - return not (not not self._lookup_parameter(b[1:])) - else: - return not not self._lookup_parameter(b) - return True + def send_event(self, event, *args): + return self.manager.send_event( + "%s:%s" % (self.get_ns(), str(event)), *args) - def _lookup_parameter(self, literal): - if self._isfloat(literal): - return float(literal) - else: - # only 2 level dot notation - keys = literal.rsplit('.', 1) - name = keys[0] if keys[0:1] else None - attr = keys[1] if keys[1:2] else None - if isinstance(self._manager.parameters, dict): - return (self._manager.parameters.get(name) or {}).get(attr) - else: - logging.error("Parameter storage is not dictionary") + def get_script(self, name): + if name in self._script_tpls: + return self._script_tpls[name] return None - def _asliteral(self, s): - s = str(s).strip() - if s.startswith(('"', "'")): - s = s[1:] - if s.endswith(('"', "'")): - s = s[:-1] - return s - - def _asbool(self, s, default=False): - if s is None: - return default - if isinstance(s, bool): - return s - s = str(s).strip() - return s.lower() in ('y', 'yes', 't', 'true', 'on', '1') - - def _asint(self, s, default=0): - if s is None: - return default - if isinstance(s, (int, float)): - return int(s) - s = str(s).strip() - return int(float(s)) if self._isfloat(s) else int(default) - - def _asfloat(self, s, default=0.0): - if s is None: - return default - if isinstance(s, (int, float)): - return float(s) - s = str(s).strip() - return float(s) if self._isfloat(s) else float(default) - - def _lines_aslist(self, value, default=[]): - if isinstance(value, str): - value = filter(None, [x.strip() for x in value.splitlines()]) - try: - return list(value) - except Exception: - logging.exception("Lines as list parsing error") - return list(default) - - def _words_aslist(self, value, sep=',', default=[]): - if isinstance(value, str): - value = filter(None, [x.strip() for x in value.split(sep)]) - try: - return list(value) - except Exception: - logging.exception("Words as list parsing error") - return list(default) - - def _aslist(self, value, flatten=True, default=[]): - values = self._lines_aslist(value) - if not flatten: - return values - result = [] - for value in values: - subvalues = self._words_aslist(value, sep=',') - result.extend(subvalues) + def run_script(self, name, **kwargs): + event = kwargs.get('event', None) + context = kwargs.get('context', None) + render_only = kwargs.get('render_only', False) + result = "" + # init context + context = self.get_context(context) + if name in self._script_tpls: + context['menu'].update({ + 'event': event or name + }) + result = self._script_tpls[name].render(context) + if not render_only: + # run result as gcode + self.manager.queue_gcode(result) + # default behaviour + _handle = getattr(self, "handle_script_" + name, None) + if callable(_handle): + _handle() return result - def _isfloat(self, value): - try: - float(value) - return True - except ValueError: - return False + @property + def cursor(self): + return str(self._cursor)[:1] + + @cursor.setter + def cursor(self, value): + self._cursor = str(value)[:1] @property - def namespace(self): - return self._namespace + def manager(self): + return self._manager - @namespace.setter - def namespace(self, ns): - self._namespace = ns + @property + def index(self): + return self._index -# menu container baseclass class MenuContainer(MenuElement): - def __init__(self, manager, config, namespace=''): - super(MenuContainer, self).__init__(manager, config, namespace) - self._show_back = self._asbool(config.get('show_back', 'true')) - self._show_title = self._asbool(config.get('show_title', 'true')) + """Menu container abstract class""" + def __init__(self, manager, config): + if type(self) is MenuContainer: + raise error( + 'Abstract MenuContainer cannot be instantiated directly') + super(MenuContainer, self).__init__(manager, config) + self.cursor = '>' + self.__selected = None self._allitems = [] + self._names = [] self._items = [] + + def init(self): + super(MenuContainer, self).init() # recursive guard self._parents = [] @@ -247,33 +263,45 @@ class MenuContainer(MenuElement): def is_accepted(self, item): return isinstance(item, MenuElement) - def is_readonly(self): - return False - def is_editing(self): return any([item.is_editing() for item in self._items]) - def reset_editing(self): + def stop_editing(self): for item in self._items: if item.is_editing(): - item.reset_editing() + item.stop_editing() - def _lookup_item(self, item): + def lookup_item(self, item): if isinstance(item, str): - s = item.strip() - if s.startswith('.'): - s = ' '.join([self.namespace, s[1:]]) - item = self._manager.lookup_menuitem(s) - return item + name = item.strip() + ns = self.get_ns(name) + return (self.manager.lookup_menuitem(ns), name) + elif isinstance(item, MenuElement): + return (item, item.get_ns()) + return (None, item) - def find_item(self, item): - index = None - if item in self._items: - index = self._items.index(item) - else: - for con in self._items: - if isinstance(con, MenuContainer) and item in con: - index = self._items.index(con) + # overload + def _lookup_item(self, item): + return self.lookup_item(item) + + def _index_of(self, item): + try: + index = None + if isinstance(item, str): + s = item.strip() + index = self._names.index(s) + elif isinstance(item, MenuElement): + index = self._items.index(item) + return index + except ValueError: + return None + + def index_of(self, item, look_inside=False): + index = self._index_of(item) + if index is None and look_inside is True: + for con in self: + if isinstance(con, MenuContainer) and con._index_of(item): + index = self._index_of(con) return index def add_parents(self, parents): @@ -284,34 +312,96 @@ class MenuContainer(MenuElement): def assert_recursive_relation(self, parents=None): assert self not in (parents or self._parents), \ - "Recursive relation of '%s' container" % (self.namespace,) + "Recursive relation of '%s' container" % (self.get_ns(),) - def append_item(self, s): - item = self._lookup_item(s) + def insert_item(self, s, index=None): + self._insert_item(s, index) + + def _insert_item(self, s, index=None): + item, name = self._lookup_item(s) if item is not None: if not self.is_accepted(item): raise error("Menu item '%s'is not accepted!" % str(type(item))) + if isinstance(item, (MenuElement)): + item.init() if isinstance(item, (MenuContainer)): item.add_parents(self._parents) item.add_parents(self) item.assert_recursive_relation() - item.populate_items() - self._allitems.append(item) + item.populate() + if index is None: + self._allitems.append((item, name)) + else: + self._allitems.insert(index, (item, name)) - def populate_items(self): + # overload + def _populate(self): + pass + + def populate(self): self._allitems = [] # empty list - if self._show_back is True: - name = '[..]' - if self._show_title: - name += ' %s' % str(self._name) - self.append_item(MenuCommand(self._manager, { - 'name': name, 'gcode': '', 'action': 'back'}, self.namespace)) for name in self._names_aslist(): - self.append_item(name) + self._insert_item(name) + # populate successor items + self._populate() + # send populate event + self.send_event('populate', self) self.update_items() def update_items(self): - self._items = [item for item in self._allitems if item.is_enabled()] + _a = [(item, name) for item, name in self._allitems + if item.is_enabled()] + self._items, self._names = zip(*_a) or ([], []) + + # select methods + def init_selection(self): + self.select_at(0) + + def select_at(self, index): + self.__selected = index + # select element + item = self.selected_item() + if isinstance(item, MenuElement): + item.select() + return item + + def select_item(self, needle): + if isinstance(needle, MenuElement): + if self.selected_item() is not needle: + index = self.index_of(needle) + if index is not None: + self.select_at(index) + else: + logging.error("Cannot select non menuitem") + return self.selected + + def selected_item(self): + if isinstance(self.selected, int) and 0 <= self.selected < len(self): + return self[self.selected] + else: + return None + + def select_next(self): + if not isinstance(self.selected, int): + index = 0 if len(self) else None + elif 0 <= self.selected < len(self) - 1: + index = self.selected + 1 + else: + index = self.selected + return self.select_at(index) + + def select_prev(self): + if not isinstance(self.selected, int): + index = 0 if len(self) else None + elif 0 < self.selected < len(self): + index = self.selected - 1 + else: + index = self.selected + return self.select_at(index) + + # override + def render_container(self, eventtime): + return ("", None) def __iter__(self): return iter(self._items) @@ -322,773 +412,282 @@ class MenuContainer(MenuElement): def __getitem__(self, key): return self._items[key] - -class MenuItem(MenuElement): - def __init__(self, manager, config, namespace=''): - super(MenuItem, self).__init__(manager, config, namespace) - self.parameter = config.get('parameter', '') - self.transform = config.get('transform', '') - - def _parse_transform(self, t): - flist = { - 'int': int, - 'float': float, - 'bool': bool, - 'str': str, - 'abs': abs, - 'bin': bin, - 'hex': hex, - 'oct': oct - } - - def mapper(left_min, left_max, right_min, right_max, cast_fn, index=0): - # interpolate - left_span = left_max - left_min - right_span = right_max - right_min - scale_factor = float(right_span) / float(left_span) - - def map_fn(values): - return cast_fn( - right_min + (values[index] - left_min) * scale_factor - ) - return map_fn - - def scaler(scale_factor, cast_fn, index=0): - def scale_fn(values): - return cast_fn(values[index] * scale_factor) - return scale_fn - - def chooser(choices, cast_fn, index=0): - def choose_fn(values): - return choices[cast_fn(values[index])] - return choose_fn - - def timerizer(key, index=0): - time = {} - - def time_fn(values): - try: - seconds = int(values[index]) - except Exception: - logging.exception("Seconds parsing error") - seconds = 0 - - time['days'], time['seconds'] = divmod(seconds, 86400) - time['hours'], time['seconds'] = divmod(time['seconds'], 3600) - time['minutes'], time['seconds'] = divmod(time['seconds'], 60) - - if key in time: - return time[key] - else: - return 0 - return time_fn - - def functionizer(key, index=0): - def func_fn(values): - if key in flist and callable(flist[key]): - return flist[key](values[index]) - else: - logging.error("Unknown function: '%s'" % str(key)) - return values[index] - return func_fn - - fn = None - t = str(t).strip() - # transform: idx.func(a,b,...) - m = re.search(r"^(\d*)(?:\.?)([\S]+)(\(.*\))$", t) - if m is not None: - index = int(m.group(1) or 0) - fname = str(m.group(2)).lower() - try: - o = ast.literal_eval(m.group(3)) - if (fname == 'map' and isinstance(o, tuple) and len(o) == 4 - and isinstance(o[3], (float, int))): - # mapper (interpolate), cast type by last parameter type - fn = mapper(o[0], o[1], o[2], o[3], type(o[3]), index) - elif (fname == 'choose' and isinstance(o, tuple) - and len(o) == 2): - # boolean chooser for 2 size tuple - fn = chooser(o, bool, index) - elif fname == 'choose' and isinstance(o, tuple) and len(o) > 2: - # int chooser for list - fn = chooser(o, int, index) - elif (fname == 'choose' and isinstance(o, dict) and o.keys() - and isinstance(o.keys()[0], (int, float, str))): - # chooser, cast type by first key type - fn = chooser(o, type(o.keys()[0]), index) - elif fname == 'scale' and isinstance(o, (float, int)): - # scaler, cast type depends from scale factor type - fn = scaler(o, type(o), index) - elif fname in ('days', 'hours', 'minutes', 'seconds'): - fn = timerizer(fname, index) - elif fname in flist: - fn = functionizer(fname, index) - else: - logging.error( - "Unknown transform function: '%s'" % str(m.group(0))) - except Exception: - logging.exception("Transform parsing error") - else: - logging.error( - "Invalid transform parameter: '%s'" % str(t)) - return fn - - def _transform_aslist(self): - return list(filter(None, ( - self._parse_transform(t) for t in self._aslist( - self.transform, flatten=False) - ))) - - def _parameter_aslist(self): - lst = [] - for p in self._words_aslist(self.parameter): - lst.append(self._lookup_parameter(p)) - if lst[-1] is None: - logging.error("Parameter '%s' not found" % str(p)) - return list(lst) - - def _prepare_values(self, value=None): - values = [] - for i, v in enumerate(self._parameter_aslist()): - values += [value if i == 0 and value is not None else v] - if values: - try: - values += [t(list(values)) for t in self._transform_aslist()] - except Exception: - logging.exception("Transformation execution failed") - return tuple(values) - - def _get_formatted(self, literal, val=None): - values = self._prepare_values(val) - if isinstance(literal, str) and len(values) > 0: - try: - literal = literal.format(*values) - except Exception: - logging.exception("Literal formatting failed") - return literal - - def _render(self): - return self._get_formatted(self._name) + @property + def selected(self): + return self.__selected -class MenuCommand(MenuItem): - def __init__(self, manager, config, namespace=''): - super(MenuCommand, self).__init__(manager, config, namespace) - self._gcode = config.get('gcode', '') - self._action = config.get('action', None) - if self._action is None and not self._gcode: - raise error("Missing or empty 'gcode' option") - - def is_readonly(self): - return False - - def get_gcode(self): - return self._get_formatted(self._gcode) - - def __call__(self): - if self._action is not None: - try: - fmt = self._get_formatted(self._action) - args = fmt.split() - self._manager.run_action(args[0], *args[1:]) - except Exception: - logging.exception("Action formatting failed") +class MenuCommand(MenuElement): + def __init__(self, manager, config): + super(MenuCommand, self).__init__(manager, config) + self._load_scripts(config, 'gcode') class MenuInput(MenuCommand): - def __init__(self, manager, config, namespace=''): - super(MenuInput, self).__init__(manager, config, namespace) - self._reverse = self._asbool(config.get('reverse', 'false')) - self._realtime = self._asbool(config.get('realtime', 'false')) - self._readonly = self._aslist( - config.get('readonly', 'false'), flatten=False) - self._input_value = None - self.__last_value = None - self._input_min = config.getfloat('input_min', sys.float_info.min) - self._input_max = config.getfloat('input_max', sys.float_info.max) + def __init__(self, manager, config,): + super(MenuInput, self).__init__(manager, config) + self._realtime = manager.asbool(config.get('realtime', 'false')) + self._input_tpl = manager.gcode_macro.load_template(config, 'input') + self._input_min_tpl = manager.gcode_macro.load_template( + config, 'input_min', '-999999.0') + self._input_max_tpl = manager.gcode_macro.load_template( + config, 'input_max', '999999.0') self._input_step = config.getfloat('input_step', above=0.) - self._input_step2 = config.getfloat('input_step2', 0, minval=0.) - self._longpress_gcode = config.get('longpress_gcode', '') + + def init(self): + super(MenuInput, self).init() + self._is_dirty = False + self.__last_change = None + self._input_value = None def is_scrollable(self): return False - def is_readonly(self): - return self._parse_bool(self._readonly) - - def _render(self): - return self._get_formatted(self._name, self._input_value) - - def get_gcode(self): - return self._get_formatted(self._gcode, self._input_value) - - def get_longpress_gcode(self): - return self._get_formatted(self._longpress_gcode, self._input_value) - def is_editing(self): return self._input_value is not None - def reset_editing(self): - self.reset_value() - - def _onchange(self): - self._manager.queue_gcode(self.get_gcode()) - - def init_value(self): - self._input_value = None - self.__last_value = None - if not self.is_readonly(): - args = self._prepare_values() - if len(args) > 0 and self._isfloat(args[0]): - self._input_value = float(args[0]) - if self._realtime: - self._onchange() - else: - logging.error("Cannot init input value") - - def reset_value(self): - self._input_value = None - - def inc_value(self, fast_rate=False): - last_value = self._input_value - input_step = (self._input_step2 if fast_rate and self._input_step2 > 0 - else self._input_step) - if self._input_value is None: + def stop_editing(self): + if not self.is_editing(): return + self._reset_value() - if(self._reverse is True): - self._input_value -= abs(input_step) - else: - self._input_value += abs(input_step) - self._input_value = min(self._input_max, max( - self._input_min, self._input_value)) - - if self._realtime and last_value != self._input_value: - self._onchange() - - def dec_value(self, fast_rate=False): - last_value = self._input_value - input_step = (self._input_step2 if fast_rate and self._input_step2 > 0 - else self._input_step) - if self._input_value is None: + def start_editing(self): + if self.is_editing(): return - - if(self._reverse is True): - self._input_value += abs(input_step) - else: - self._input_value -= abs(input_step) - self._input_value = min(self._input_max, max( - self._input_min, self._input_value)) - - if self._realtime and last_value != self._input_value: - self._onchange() - - -class MenuGroup(MenuContainer): - def __init__(self, manager, config, namespace='', sep=','): - super(MenuGroup, self).__init__(manager, config, namespace) - self._sep = sep - self._show_back = False - self.selected = None - self.use_cursor = self._asbool(config.get('use_cursor', 'false')) - self.items = config.get('items', '') - - def is_accepted(self, item): - return (super(MenuGroup, self).is_accepted(item) - and type(item) is not MenuCard) - - def is_scrollable(self): - return False - - def is_enabled(self): - return not not len(self) - - def is_readonly(self): - return all([item.is_readonly() for item in self._items]) - - def _names_aslist(self): - return self._words_aslist(self.items, sep=self._sep) - - def init(self): - super(MenuGroup, self).init() - for item in self._items: - item.init() - - def _render_item(self, item, selected=False, scroll=False): - name = "%s" % str(item.render(scroll)) - if selected and not self.is_editing(): - if self.use_cursor: - name = (item.cursor if isinstance(item, MenuElement) - else MenuCursor.SELECT) + name - else: - name = (name if self._manager.blink_slow_state - else ' '*len(name)) - elif selected and self.is_editing(): - if self.use_cursor: - name = MenuCursor.EDIT + name - else: - name = (name if self._manager.blink_fast_state - else ' '*len(name)) - elif self.use_cursor: - name = MenuCursor.NONE + name - return name - - def _render(self): - s = "" - if self.selected is not None: - self.selected = ( - (self.selected % len(self)) if len(self) > 0 else None) - - for i, item in enumerate(self): - s += self._render_item(item, (i == self.selected), True) - return s - - def _call_selected(self, method=None, *args): - res = None - if self.selected is not None: - try: - if method is None: - res = self[self.selected] - else: - res = getattr(self[self.selected], method)(*args) - except Exception: - logging.exception("Call selected error") - return res - - def reset_editing(self): - return self._call_selected('reset_editing') - - def is_editing(self): - return self._call_selected('is_editing') - - def inc_value(self, fast_rate=False): - self._call_selected('inc_value', fast_rate) - - def dec_value(self, fast_rate=False): - self._call_selected('dec_value', fast_rate) - - def selected_item(self): - return self._call_selected() - - def find_next_item(self): - if self.selected is None: - self.selected = 0 - elif self.selected < len(self) - 1: - self.selected += 1 - else: - self.selected = None - # skip readonly - while (self.selected is not None - and self.selected < len(self) - and self._call_selected('is_readonly')): - if self.selected < len(self) - 1: - self.selected = (self.selected + 1) - else: - self.selected = None - return self.selected - - def find_prev_item(self): - if self.selected is None: - self.selected = len(self) - 1 - elif self.selected > 0: - self.selected -= 1 - else: - self.selected = None - # skip readonly - while (self.selected is not None - and self.selected >= 0 - and self._call_selected('is_readonly')): - self.selected = (self.selected - 1) if self.selected > 0 else None - return self.selected - - -class MenuItemGroup(MenuGroup): - def __init__(self, manager, config, namespace='', sep='|'): - super(MenuItemGroup, self).__init__(manager, config, namespace, sep) - - def is_readonly(self): - return True - - def is_accepted(self, item): - return type(item) is MenuItem - - -class MenuCycler(MenuGroup): - def __init__(self, manager, config, namespace='', sep=','): - super(MenuCycler, self).__init__(manager, config, namespace, sep) - self._interval = 0 - self.__interval_cnt = 0 - self.__alllen = 0 - self._curr_idx = 0 - - def is_readonly(self): - return True - - def is_accepted(self, item): - return type(item) in (MenuItem, MenuItemGroup) - - def _lookup_item(self, item): - if isinstance(item, str) and '|' in item: - item = MenuItemGroup(self._manager, { - 'name': ' '.join([self._name, 'ItemGroup']), - 'items': item - }, self.namespace, '|') - elif isinstance(item, str) and item.isdigit(): - try: - self._interval = max(0, int(item)) - except Exception: - logging.exception("Interval parsing error") - item = None - return super(MenuCycler, self)._lookup_item(item) - - def _second_tick(self, eventtime): - super(MenuCycler, self)._second_tick(eventtime) - if self._interval > 0: - self.__interval_cnt = (self.__interval_cnt + 1) % self._interval - if self.__interval_cnt == 0 and self.__alllen > 0: - self._curr_idx = (self._curr_idx + 1) % self.__alllen - else: - self._curr_idx = 0 + self._init_value() def heartbeat(self, eventtime): - super(MenuCycler, self).heartbeat(eventtime) - for item in self._items: - item.heartbeat(eventtime) + super(MenuInput, self).heartbeat(eventtime) + if (self._is_dirty is True + and self.__last_change is not None + and self._input_value is not None + and (eventtime - self.__last_change) > 0.250): + if self._realtime is True: + self.run_script('gcode', event='change') + self.run_script('change') + self._is_dirty = False - def update_items(self): - items = [item for item in self._allitems if item.is_enabled()] - self.__alllen = len(items) - if self.__alllen > 0: - self._curr_idx = self._curr_idx % self.__alllen - self._items = [items[self._curr_idx]] + def get_context(self, cxt=None): + context = super(MenuInput, self).get_context(cxt) + context['menu'].update({ + 'input': self.manager.asfloat( + self._eval_value() if self._input_value is None + else self._input_value) + }) + return context + + def eval_enable(self): + context = super(MenuInput, self).get_context() + return self.manager.asbool(self._enable_tpl.render(context)) + + def _eval_min(self): + context = super(MenuInput, self).get_context() + return self._input_min_tpl.render(context) + + def _eval_max(self): + context = super(MenuInput, self).get_context() + return self._input_max_tpl.render(context) + + def _eval_value(self): + context = super(MenuInput, self).get_context() + return self._input_tpl.render(context) + + def _value_changed(self): + self.__last_change = self._last_heartbeat + self._is_dirty = True + + def _init_value(self): + self._input_value = None + self._input_min = self.manager.asfloat(self._eval_min()) + self._input_max = self.manager.asfloat(self._eval_max()) + value = self._eval_value() + if self.manager.isfloat(value): + self._input_value = min(self._input_max, max( + self._input_min, self.manager.asfloat(value))) + self._value_changed() else: - self._curr_idx = 0 - self._items = [] + logging.error("Cannot init input value") + + def _reset_value(self): + self._input_value = None + + def _get_input_step(self, fast_rate=False): + return ((10.0 * self._input_step) if fast_rate and ( + (self._input_max - self._input_min) / self._input_step > 100.0) + else self._input_step) + + def inc_value(self, fast_rate=False): + last_value = self._input_value + if self._input_value is None: + return + + input_step = self._get_input_step(fast_rate) + self._input_value += abs(input_step) + self._input_value = min(self._input_max, max( + self._input_min, self._input_value)) + + if last_value != self._input_value: + self._value_changed() + + def dec_value(self, fast_rate=False): + last_value = self._input_value + if self._input_value is None: + return + + input_step = self._get_input_step(fast_rate) + self._input_value -= abs(input_step) + self._input_value = min(self._input_max, max( + self._input_min, self._input_value)) + + if last_value != self._input_value: + self._value_changed() + + # default behaviour on click + def handle_script_click(self): + if not self.is_editing(): + self.start_editing() + elif self.is_editing(): + self.stop_editing() class MenuList(MenuContainer): - def __init__(self, manager, config, namespace=''): - super(MenuList, self).__init__(manager, config, namespace) - self._enter_gcode = config.get('enter_gcode', None) - self._leave_gcode = config.get('leave_gcode', None) - self.items = config.get('items', '') - - def is_accepted(self, item): - return (super(MenuList, self).is_accepted(item) - and type(item) is not MenuCard) + def __init__(self, manager, config): + super(MenuList, self).__init__(manager, config) + self._show_title = True def _names_aslist(self): - return self._lines_aslist(self.items) + return self.manager.lookup_children(self.get_ns()) - def _lookup_item(self, item): - if isinstance(item, str) and ',' in item: - item = MenuGroup(self._manager, { - 'name': ' '.join([self._name, 'Group']), - 'items': item - }, self.namespace, ',') - return super(MenuList, self)._lookup_item(item) + def _populate(self): + super(MenuList, self)._populate() + # add back as first item + name = '..' + if self._show_title: + name += ' %s' % str(self._name()) + item = self.manager.menuitem_from({ + 'type': 'command', + 'name': self.manager.asliteral(name), + 'gcode': '{menu.back()}' + }) + self.insert_item(item, 0) - def update_items(self): - super(MenuList, self).update_items() - for item in self._items: - if isinstance(item, MenuGroup) and not item.is_editing(): - item.update_items() - - def get_enter_gcode(self): - return self._enter_gcode - - def get_leave_gcode(self): - return self._leave_gcode + def render_container(self, eventtime): + rows = [] + selected_row = None + try: + for row, item in enumerate(self): + s = "" + selected = (row == self.selected) + if selected: + item.heartbeat(eventtime) + selected_row = len(rows) + name = str(item.render_name(selected)) + if isinstance(item, MenuList): + s += name[:self.manager.cols-1].ljust(self.manager.cols-1) + s += '>' + else: + s += name[:self.manager.cols].ljust(self.manager.cols) + rows.append(s) + except Exception: + logging.exception('List rendering error') + return ("\n".join(rows), selected_row) -class MenuVSDCard(MenuList): - def __init__(self, manager, config, namespace=''): - super(MenuVSDCard, self).__init__(manager, config, namespace) +class MenuVSDList(MenuList): + def __init__(self, manager, config): + super(MenuVSDList, self).__init__(manager, config) - def _populate_files(self): - sdcard = self._manager.objs.get('virtual_sdcard') + def _populate(self): + super(MenuVSDList, self)._populate() + sdcard = self.manager.printer.lookup_object('virtual_sdcard', None) if sdcard is not None: files = sdcard.get_file_list() for fname, fsize in files: gcode = [ 'M23 /%s' % str(fname) ] - self.append_item(MenuCommand(self._manager, { - 'name': '%s' % str(fname), - 'cursor': '+', - 'gcode': "\n".join(gcode), - 'scroll': True, - # mind the cursor size in width - 'width': (self._manager.cols-1) + self.insert_item(self.manager.menuitem_from({ + 'type': 'command', + 'name': self.manager.asliteral(fname), + 'gcode': "\n".join(gcode) })) - def populate_items(self): - super(MenuVSDCard, self).populate_items() - self._populate_files() - - -class MenuCard(MenuGroup): - def __init__(self, manager, config, namespace=''): - super(MenuCard, self).__init__(manager, config, namespace) - self.content = config.get('content') - self._allow_without_selection = self._asbool( - config.get('allow_without_selection', 'true')) - if not self.items: - self.content = self._parse_content_items(self.content) - - def _parse_content_items(self, content): - formatter = string.Formatter() - out = "" - items = [] - - try: - parsed_content = list(formatter.parse(content)) - except Exception: - logging.exception("Card content parsing error") - - for part in parsed_content: - # (literal_text, field_name, format_spec, conversion) - out += part[0] - if part[1]: - out += "{%s%s%s}" % ( - len(items), - ("!" + part[3]) if part[3] else '', - (":" + part[2]) if part[2] else '', - ) - items.append(str(part[1])) - - self.items = "\n".join(items) - return out - - def _names_aslist(self): - return self._lines_aslist(self.items) - - def _content_aslist(self): - return filter(None, [ - self._asliteral(s) for s in self._lines_aslist(self.content) - ]) - - def update_items(self): - self._items = self._allitems[:] - for item in self._items: - if isinstance(item, MenuGroup) and not item.is_editing(): - item.update_items() - - def _lookup_item(self, item): - if isinstance(item, str) and ',' in item: - item = MenuCycler(self._manager, { - 'name': ' '.join([self._name, 'Cycler']), - 'items': item - }, self.namespace, ',') - return super(MenuCard, self)._lookup_item(item) - - def render_content(self, eventtime): - if self.selected is not None: - self.selected = ( - (self.selected % len(self)) if len(self) > 0 else None) - if self._allow_without_selection is False and self.selected is None: - self.selected = 0 if len(self) > 0 else None - - items = [] - for i, item in enumerate(self): - name = '' - if item.is_enabled(): - item.heartbeat(eventtime) - name = self._render_item(item, (i == self.selected), True) - items.append(name) - lines = [] - for line in self._content_aslist(): - try: - lines.append(str(line).format(*items)) - except Exception: - logging.exception('Card rendering error') - return lines - - def _render(self): - return self._name - - -class MenuDeck(MenuList): - def __init__(self, manager, config, namespace=''): - super(MenuDeck, self).__init__(manager, config, namespace) - self._menu = config.get('longpress_menu', None) - self.menu = None - self._show_back = False - self._show_title = False - if not self.items: - card = MenuCard(self._manager, { - 'name': ' '.join([self._name, 'Card']), - 'use_cursor': config.get('use_cursor', 'false'), - 'allow_without_selection': config.get( - 'allow_without_selection', 'true'), - 'content': config.get('content') - }, self.namespace) - name = " ".join( - config.get_name().split()[1:]) + "__singlecarddeck__" - self._manager.add_menuitem(name, card) - self.items = name - - def _populate_menu(self): - self.menu = None - if self._menu is not None: - menu = self._manager.lookup_menuitem(self._menu) - if isinstance(menu, MenuContainer): - menu.assert_recursive_relation(self._parents) - menu.populate_items() - self.menu = menu - - def populate_items(self): - super(MenuDeck, self).populate_items() - self._populate_menu() - - def _names_aslist(self): - return self._aslist(self.items) - - def is_accepted(self, item): - return type(item) is MenuCard - - def _render(self): - return self._name - menu_items = { - 'item': MenuItem, 'command': MenuCommand, 'input': MenuInput, 'list': MenuList, - 'vsdcard': MenuVSDCard, - #'deck': MenuDeck, - #'card': MenuCard + 'vsdlist': MenuVSDList } + MENU_UPDATE_DELAY = .100 -TIMER_DELAY = .200 -BLINK_FAST_SEQUENCE = (True, True, False, False) -BLINK_SLOW_SEQUENCE = (True, True, True, True, False, False, False) +TIMER_DELAY = .100 class MenuManager: - def __init__(self, config, lcd_chip): + def __init__(self, config, display): self.running = False self.menuitems = {} self.menustack = [] - self._autorun = False + self.children = {} self.top_row = 0 - self.selected = 0 - self.blink_fast_state = True - self.blink_slow_state = True - self.blink_fast_idx = 0 - self.blink_slow_idx = 0 self.timeout_idx = 0 - self.lcd_chip = lcd_chip + self.display = display self.printer = config.get_printer() self.pconfig = self.printer.lookup_object('configfile') self.gcode = self.printer.lookup_object('gcode') self.gcode_queue = [] - self.parameters = {} - self.objs = {} + self.context = {} self.root = None self._root = config.get('menu_root', '__main') - self.cols, self.rows = lcd_chip.get_dimensions() + self.cols, self.rows = self.display.lcd_chip.get_dimensions() self.timeout = config.getint('menu_timeout', 0) self.timer = 0 + self.eventtime = 0 + # reverse container navigation + self._reverse_navigation = config.getboolean( + 'menu_reverse_navigation', False) + # load printer objects + self.gcode_macro = self.printer.load_object(config, 'gcode_macro') # register itself for printer callbacks self.printer.add_object('menu', self) self.printer.register_event_handler("klippy:ready", self.handle_ready) + # register for key events menu_keys.MenuKeys(config, self.key_event) - # Add MENU commands - self.gcode.register_mux_command("MENU", "DO", 'dump', self.cmd_DO_DUMP, - desc=self.cmd_DO_help) - # Load local config file in same directory as current module self.load_config(os.path.dirname(__file__), 'menu.cfg') # Load items from main config self.load_menuitems(config) # Load menu root - self.load_root() + self.root = self.lookup_menuitem(self._root) + # send init event + self.send_event('init', self) def handle_ready(self): - # Load all available printer objects - for cfg_name, obj in self.printer.lookup_objects(): - name = ".".join(str(cfg_name).split()) - self.objs[name] = obj - logging.debug("Load module '%s' -> %s" % ( - str(name), str(obj.__class__))) # start timer reactor = self.printer.get_reactor() reactor.register_timer(self.timer_event, reactor.NOW) def timer_event(self, eventtime): - # take next from sequence - self.blink_fast_idx = ( - (self.blink_fast_idx + 1) % len(BLINK_FAST_SEQUENCE) - ) - self.blink_slow_idx = ( - (self.blink_slow_idx + 1) % len(BLINK_SLOW_SEQUENCE) - ) - self.timeout_idx = (self.timeout_idx + 1) % 5 # 0.2*5 = 1s - self.blink_fast_state = ( - not not BLINK_FAST_SEQUENCE[self.blink_fast_idx] - ) - self.blink_slow_state = ( - not not BLINK_SLOW_SEQUENCE[self.blink_slow_idx] - ) + self.eventtime = eventtime + self.timeout_idx = (self.timeout_idx + 1) % 10 # 0.1*10 = 1s if self.timeout_idx == 0: self.timeout_check(eventtime) return eventtime + TIMER_DELAY def timeout_check(self, eventtime): - # check timeout if (self.is_running() and self.timeout > 0 - and not self._timeout_autorun_root()): + and isinstance(self.root, MenuContainer)): if self.timer >= self.timeout: self.exit() - self.timer += 1 + else: + self.timer += 1 else: self.timer = 0 - def _timeout_autorun_root(self): - return (self._autorun is True and self.root is not None - and self.stack_peek() is self.root and self.selected == 0) - - def restart_root(self, root=None, force_exit=True): - if self.is_running(): - self.exit(force_exit) - self.load_root(root) - - def load_root(self, root=None, autorun=False): - root = self._root if root is None else root - if root is not None: - self.root = self.lookup_menuitem(root) - if isinstance(self.root, MenuDeck): - self._autorun = True - else: - self._autorun = autorun - - def register_object(self, obj, name=None, override=False): - """Register an object with a "get_status" callback""" - if obj is not None: - if name is None: - name = obj.__class__.__name__ - if override or name not in self.objs: - self.objs[name] = obj - - def unregister_object(self, name): - """Unregister an object from "get_status" callback list""" - if name is not None: - if not isinstance(name, str): - name = name.__class__.__name__ - if name in self.objs: - self.objs.pop(name) - - def after(self, timeout, callback, *args): - """Helper method for reactor.register_callback. - The callback will be executed once after given timeout (sec).""" - def callit(eventtime): - callback(eventtime, *args) - reactor = self.printer.get_reactor() - starttime = reactor.monotonic() + max(0., float(timeout)) - reactor.register_callback(callit, starttime) + def send_event(self, event, *args): + return self.printer.send_event("menu:" + str(event), *args) def is_running(self): return self.running @@ -1096,85 +695,71 @@ class MenuManager: def begin(self, eventtime): self.menustack = [] self.top_row = 0 - self.selected = 0 self.timer = 0 if isinstance(self.root, MenuContainer): - self.update_parameters(eventtime) - self.root.populate_items() + # send begin event + self.send_event('begin', self) + self.update_context(eventtime) + if isinstance(self.root, MenuContainer): + self.root.init_selection() + self.root.populate() self.stack_push(self.root) self.running = True return elif self.root is not None: - logging.error("Invalid root '%s', menu stopped!" % str(self._root)) + logging.error("Invalid root, menu stopped!") + self.running = False self.running = False def get_status(self, eventtime): return { - 'eventtime': eventtime, 'timeout': self.timeout, - 'autorun': self._autorun, - 'isRunning': self.running, - 'is2004': (self.rows == 4 and self.cols == 20), - 'is2002': (self.rows == 2 and self.cols == 20), - 'is1604': (self.rows == 4 and self.cols == 16), - 'is1602': (self.rows == 2 and self.cols == 16), - 'is20xx': (self.cols == 20), - 'is16xx': (self.cols == 16) + 'running': self.running, + 'rows': self.rows, + 'cols': self.cols } - def update_parameters(self, eventtime): - self.parameters = {} - objs = dict(self.objs) - # getting info this way is more like hack - # all modules should have special reporting method (maybe get_status) - # for available parameters - # Only 2 level dot notation - for name in objs.keys(): - try: - if objs[name] is not None: - class_name = str(objs[name].__class__.__name__) - get_status = getattr(objs[name], "get_status", None) - if callable(get_status): - self.parameters[name] = get_status(eventtime) - else: - self.parameters[name] = {} + def _action_back(self, force=False, update=True): + self.back(force, update) + return "" - self.parameters[name].update({'is_enabled': True}) - # get additional info - if class_name == 'ToolHead': - pos = objs[name].get_position() - self.parameters[name].update({ - 'xpos': pos[0], - 'ypos': pos[1], - 'zpos': pos[2], - 'epos': pos[3] - }) - self.parameters[name].update({ - 'is_printing': ( - self.parameters[name]['status'] == "Printing"), - 'is_ready': ( - self.parameters[name]['status'] == "Ready"), - 'is_idle': ( - self.parameters[name]['status'] == "Idle") - }) - else: - self.parameters[name] = {'is_enabled': False} - except Exception: - logging.exception("Parameter '%s' update error" % str(name)) + def _action_exit(self, force=False): + self.exit(force) + return "" + + def get_context(self, cxt=None): + context = dict(self.context) + if isinstance(cxt, dict): + context.update(cxt) + return context + + def update_context(self, eventtime): + # menu default jinja2 context + self.context = { + 'printer': gcode_macro.GetStatusWrapper(self.printer, eventtime), + 'menu': { + 'eventtime': eventtime, + 'back': self._action_back, + 'exit': self._action_exit + } + } def stack_push(self, container): if not isinstance(container, MenuContainer): raise error("Wrong type, expected MenuContainer") top = self.stack_peek() if top is not None: - self.queue_gcode(top.get_leave_gcode()) - self.queue_gcode(container.get_enter_gcode()) + if isinstance(top, MenuList): + top.run_script('leave') + if isinstance(container, MenuList): + container.run_script('enter') if not container.is_editing(): container.update_items() + container.init_selection() self.menustack.append(container) - def stack_pop(self): + def stack_pop(self, update=True): container = None if self.stack_size() > 0: container = self.menustack.pop() @@ -1184,12 +769,16 @@ class MenuManager: if top is not None: if not isinstance(container, MenuContainer): raise error("Wrong type, expected MenuContainer") - if not top.is_editing(): + if not top.is_editing() and update is True: top.update_items() - self.queue_gcode(container.get_leave_gcode()) - self.queue_gcode(top.get_enter_gcode()) + top.init_selection() + if isinstance(container, MenuList): + container.run_script('leave') + if isinstance(top, MenuList): + top.run_script('enter') else: - self.queue_gcode(container.get_leave_gcode()) + if isinstance(container, MenuList): + container.run_script('leave') return container def stack_size(self): @@ -1201,72 +790,36 @@ class MenuManager: container = self.menustack[self.stack_size() - lvl - 1] return container - def _unescape_cchars(self, text): - def fixup(m): - text = str(m.group(0)) - if text[:2] == "\\x": - try: - return "%c" % (int(text[2:], 16),) - except ValueError: - logging.exception('Custom character unescape error') - else: - return text - return re.sub(r'\\x[0-9a-f]{2}', fixup, str(text), flags=re.IGNORECASE) - def render(self, eventtime): lines = [] - self.update_parameters(eventtime) + self.update_context(eventtime) container = self.stack_peek() if self.running and isinstance(container, MenuContainer): container.heartbeat(eventtime) - if(isinstance(container, MenuDeck) and not container.is_editing()): - container.update_items() - # clamps - self.top_row = max(0, min( - self.top_row, len(container) - self.rows)) - self.selected = max(0, min( - self.selected, len(container) - 1)) - if isinstance(container, MenuDeck): - container[self.selected].heartbeat(eventtime) - lines = container[self.selected].render_content(eventtime) + content, viewport_row = container.render_container(eventtime) + if viewport_row is not None: + while viewport_row >= (self.top_row + self.rows): + self.top_row += 1 + while viewport_row < self.top_row and self.top_row > 0: + self.top_row -= 1 else: - for row in range(self.top_row, self.top_row + self.rows): - s = "" - if row < len(container): - selected = (row == self.selected) - current = container[row] - if selected: - current.heartbeat(eventtime) - if (isinstance(current, (MenuInput, MenuGroup)) - and current.is_editing()): - s += MenuCursor.EDIT - elif isinstance(current, MenuElement): - s += current.cursor - else: - s += MenuCursor.SELECT - else: - s += MenuCursor.NONE - - name = "%s" % str(current.render(selected)) - i = len(s) - if isinstance(current, MenuList): - s += name[:self.cols-i-1].ljust(self.cols-i-1) - s += '>' - else: - s += name[:self.cols-i].ljust(self.cols-i) - lines.append(s.ljust(self.cols)) + self.top_row = 0 + rows = self.aslatin(content).splitlines() + for row in range(0, self.rows): + try: + text = self.stripliterals(rows[self.top_row + row]) + except IndexError: + text = "" + lines.append(text.ljust(self.cols)) return lines def screen_update_event(self, eventtime): + # screen update if self.is_running(): - self.lcd_chip.clear() + self.display.lcd_chip.clear() for y, line in enumerate(self.render(eventtime)): - self.lcd_chip.write_text(0, y, self._unescape_cchars(line)) - self.lcd_chip.flush() - return eventtime + MENU_UPDATE_DELAY - elif not self.is_running() and self._autorun is True: - # lets start and populate the menu items - self.begin(eventtime) + self.display.draw_text(y, 0, line, eventtime) + self.display.lcd_chip.flush() return eventtime + MENU_UPDATE_DELAY else: return 0 @@ -1275,141 +828,90 @@ class MenuManager: container = self.stack_peek() if self.running and isinstance(container, MenuContainer): self.timer = 0 - current = container[self.selected] - if (isinstance(current, (MenuInput, MenuGroup)) - and current.is_editing()): + current = container.selected_item() + if isinstance(current, MenuInput) and current.is_editing(): current.dec_value(fast_rate) - elif (isinstance(current, MenuGroup) - and current.find_prev_item() is not None): - pass else: - if self.selected == 0: - return - if self.selected > self.top_row: - self.selected -= 1 + if self._reverse_navigation is True: + container.select_next() # reverse else: - self.top_row -= 1 - self.selected -= 1 - # init element - if isinstance(container[self.selected], MenuElement): - container[self.selected].init() - # wind up group last item or init item - if isinstance(container[self.selected], MenuGroup): - container[self.selected].find_prev_item() + container.select_prev() # normal def down(self, fast_rate=False): container = self.stack_peek() if self.running and isinstance(container, MenuContainer): self.timer = 0 - current = container[self.selected] - if (isinstance(current, (MenuInput, MenuGroup)) - and current.is_editing()): + current = container.selected_item() + if isinstance(current, MenuInput) and current.is_editing(): current.inc_value(fast_rate) - elif (isinstance(current, MenuGroup) - and current.find_next_item() is not None): - pass else: - if self.selected >= len(container) - 1: - return - if self.selected < self.top_row + self.rows - 1: - self.selected += 1 + if self._reverse_navigation is True: + container.select_prev() # reverse else: - self.top_row += 1 - self.selected += 1 - # init element - if isinstance(container[self.selected], MenuElement): - container[self.selected].init() - # wind up group first item - if isinstance(container[self.selected], MenuGroup): - container[self.selected].find_next_item() + container.select_next() # normal - def back(self): + def back(self, force=False, update=True): container = self.stack_peek() if self.running and isinstance(container, MenuContainer): self.timer = 0 - current = container[self.selected] - if (isinstance(current, (MenuInput, MenuGroup)) - and current.is_editing()): - return + current = container.selected_item() + if isinstance(current, MenuInput) and current.is_editing(): + if force is True: + current.stop_editing() + else: + return parent = self.stack_peek(1) if isinstance(parent, MenuContainer): - self.stack_pop() - index = parent.find_item(container) - if index is not None and index < len(parent): - self.top_row = index - self.selected = index - else: - self.top_row = 0 - self.selected = 0 - # init element - if isinstance(parent[self.selected], MenuElement): - parent[self.selected].init() - # wind up group first item or init item - if isinstance(parent[self.selected], MenuGroup): - parent[self.selected].find_next_item() + self.stack_pop(update) + index = parent.index_of(container, True) + if index is not None: + parent.select_at(index) + elif parent.selected_item() is None: + parent.init_selection() + else: self.stack_pop() self.running = False - def select(self, long_press=False): - container = self.stack_peek() - if self.running and isinstance(container, MenuContainer): - self.timer = 0 - current = container[self.selected] - if isinstance(current, MenuGroup): - current = current.selected_item() - if isinstance(current, MenuList): - self.stack_push(current) - self.top_row = 0 - self.selected = 0 - elif isinstance(current, MenuInput): - if current.is_editing(): - if long_press is True: - self.queue_gcode(current.get_gcode()) - self.queue_gcode(current.get_longpress_gcode()) - else: - self.queue_gcode(current.get_gcode()) - current.reset_value() - else: - current.init_value() - elif isinstance(current, MenuCommand): - current() - self.queue_gcode(current.get_gcode()) - def exit(self, force=False): container = self.stack_peek() if self.running and isinstance(container, MenuContainer): - current = container[self.selected] - if (not force and isinstance(current, (MenuInput, MenuGroup)) + self.timer = 0 + current = container.selected_item() + if (not force and isinstance(current, MenuInput) and current.is_editing()): return - self.queue_gcode(container.get_leave_gcode()) + if isinstance(container, MenuList): + container.run_script('leave') + self.send_event('exit', self) self.running = False - def run_action(self, action, *args): - try: - action = str(action).strip().lower() - if action == 'back': - self.back() - elif action == 'exit': - self.exit() - elif action == 'respond': - self.gcode.respond_info("{}".format(' '.join(map(str, args))), - log=False) - elif action == 'event' and len(args) > 0: - if len(str(args[0])) > 0: - self.printer.send_event( - "menu:action:" + str(args[0]), *args[1:]) - else: - logging.error("Malformed event call: {} {}".format( - action, ' '.join(map(str, args)))) + def push_container(self, menu): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + if (isinstance(menu, MenuContainer) + and not container.is_editing() + and menu is not container): + self.stack_push(menu) + return True + return False + + def press(self, event='click'): + container = self.stack_peek() + if self.running and isinstance(container, MenuContainer): + self.timer = 0 + current = container.selected_item() + if isinstance(current, MenuContainer): + self.stack_push(current) + elif isinstance(current, MenuCommand): + current.run_script('gcode', event=event) + current.run_script(event) else: - logging.error("Unknown action %s" % (action)) - except Exception: - logging.exception("Malformed action call") + # current is None, no selection. passthru to container + container.run_script(event) def queue_gcode(self, script): - if script is None: + if not script: return if not self.gcode_queue: reactor = self.printer.get_reactor() @@ -1425,20 +927,42 @@ class MenuManager: logging.exception("Script running error") self.gcode_queue.pop(0) - def add_menuitem(self, name, menu): + def menuitem_from(self, config): + if isinstance(config, dict): + config = MenuConfig(dict(config)) + return self.aschoice( + config, 'type', menu_items)(self, config) + + def add_menuitem(self, name, item): if name in self.menuitems: logging.info( "Declaration of '%s' hides " "previous menuitem declaration" % (name,)) - self.menuitems[name] = menu + self.menuitems[name] = item + if isinstance(item, MenuElement): + parent = item.get_ns('..') + if parent: + if item.index is not None: + self.children.setdefault(parent, []).insert( + item.index, item.get_ns()) + else: + self.children.setdefault(parent, []).append( + item.get_ns()) - def lookup_menuitem(self, name): + def lookup_menuitem(self, name, default=sentinel): if name is None: return None - if name not in self.menuitems: + if name in self.menuitems: + return self.menuitems[name] + if default is sentinel: raise self.printer.config_error( "Unknown menuitem '%s'" % (name,)) - return self.menuitems[name] + return default + + def lookup_children(self, ns): + if ns in self.children: + return list(self.children[ns]) + return list() def load_config(self, *args): cfg = None @@ -1454,55 +978,21 @@ class MenuManager: def load_menuitems(self, config): for cfg in config.get_prefix_sections('menu '): - name = " ".join(cfg.get_name().split()[1:]) - item = cfg.getchoice('type', menu_items)(self, cfg, name) - self.add_menuitem(name, item) + item = self.menuitem_from(cfg) + self.add_menuitem(item.get_ns(), item) - cmd_DO_help = "Menu do things" - - def cmd_DO_DUMP(self, gcmd): - for key1 in self.parameters: - if type(self.parameters[key1]) == dict: - for key2 in self.parameters[key1]: - msg = "{0}.{1} = {2}".format( - key1, key2, - self.parameters[key1].get(key2) - ) - gcmd.respond_info(msg) - else: - msg = "{0} = {1}".format(key1, self.parameters.get(key1)) - gcmd.respond_info(msg) - - def _short_click_callback(self, eventtime): + def _click_callback(self, eventtime, event): if self.is_running(): - self.select() + self.press(event) else: # lets start and populate the menu items self.begin(eventtime) - def _long_click_callback(self, eventtime): - if not self.is_running(): - # lets start and populate the menu items - self.begin(eventtime) - else: - container = self.stack_peek() - if isinstance(container, MenuDeck): - menu = container.menu - if (isinstance(menu, MenuList) - and not container.is_editing() - and menu is not container): - self.stack_push(menu) - self.top_row = 0 - self.selected = 0 - return - if container.is_editing(): - self.select(True) - def key_event(self, key, eventtime): if key == 'click': - self._short_click_callback(eventtime) + self._click_callback(eventtime, key) elif key == 'long_click': - self._long_click_callback(eventtime) + self._click_callback(eventtime, key) elif key == 'up': self.up(False) elif key == 'fast_up': @@ -1513,3 +1003,125 @@ class MenuManager: self.down(True) elif key == 'back': self.back() + + # Collection of manager class helper methods + + @classmethod + def stripliterals(cls, s): + """Literals are beginning or ending by the double or single quotes""" + s = str(s) + if (s.startswith('"') and s.endswith('"')) or \ + (s.startswith("'") and s.endswith("'")): + s = s[1:-1] + return s + + @classmethod + def asliteral(cls, s): + """Enclose text by the single quotes""" + return "'" + str(s) + "'" + + @classmethod + def aslatin(cls, s): + if isinstance(s, str): + return s + elif isinstance(s, unicode): + return unicode(s).encode('latin-1', 'ignore') + else: + return str(s) + + @classmethod + def asflatline(cls, s): + return ''.join(cls.aslatin(s).splitlines()) + + @classmethod + def asflat(cls, s): + return cls.stripliterals(cls.asflatline(s)) + + @classmethod + def asbool(cls, s): + if isinstance(s, (bool, int, float)): + return bool(s) + elif cls.isfloat(s): + return bool(cls.asfloat(s)) + s = str(s).strip() + return s.lower() in ('y', 'yes', 't', 'true', 'on', '1') + + @classmethod + def asint(cls, s, default=sentinel): + if isinstance(s, (int, float)): + return int(s) + s = str(s).strip() + prefix = s[0:2] + try: + if prefix == '0x': + return int(s, 16) + elif prefix == '0b': + return int(s, 2) + else: + return int(float(s)) + except ValueError as e: + if default is not sentinel: + return default + raise e + + @classmethod + def asfloat(cls, s, default=sentinel): + if isinstance(s, (int, float)): + return float(s) + s = str(s).strip() + try: + return float(s) + except ValueError as e: + if default is not sentinel: + return default + raise e + + @classmethod + def isfloat(cls, value): + try: + float(value) + return True + except ValueError: + return False + + @classmethod + def lines_aslist(cls, value, default=[]): + if isinstance(value, str): + value = filter(None, [x.strip() for x in value.splitlines()]) + try: + return list(value) + except Exception: + logging.exception("Lines as list parsing error") + return list(default) + + @classmethod + def words_aslist(cls, value, sep=',', default=[]): + if isinstance(value, str): + value = filter(None, [x.strip() for x in value.split(sep)]) + try: + return list(value) + except Exception: + logging.exception("Words as list parsing error") + return list(default) + + @classmethod + def aslist(cls, value, flatten=True, default=[]): + values = cls.lines_aslist(value) + if not flatten: + return values + result = [] + for value in values: + subvalues = cls.words_aslist(value, sep=',') + result.extend(subvalues) + return result + + @classmethod + def aschoice(cls, config, option, choices, default=sentinel): + if default is not sentinel: + c = config.get(option, default) + else: + c = config.get(option) + if c not in choices: + raise error("Choice '%s' for option '%s'" + " is not a valid choice" % (c, option)) + return choices[c]