diff --git a/software/contrib/euclid.py b/software/contrib/euclid.py index 16ad4a60e..1ffc13aaa 100644 --- a/software/contrib/euclid.py +++ b/software/contrib/euclid.py @@ -1,347 +1,619 @@ -#!/usr/bin/env python3 -"""Euclidean rhythm generator for the EuroPi - -@author Chris Iverach-Brereton -@author Brian House -@year 2011, 2023 - -This script contains code released under the MIT license, as -noted below """ +Contains objects used for interactive settings menus + +Menu interaction is done using a knob and a button (K2 and B2 by default): +- rotate the knob to select the menu item +- short-press to enter edit mode +- rotate knob to select an option +- short-press button to apply the new option +- long-press button to change between the 2 menu levels (if + possible) +""" + +import europi -import random +from configuration import * +from experimental.knobs import KnobBank +from framebuf import FrameBuffer, MONO_HLSB +from machine import Timer import time -from europi import * -from europi_script import EuroPiScript -from experimental.euclid import generate_euclidean_pattern -from experimental.screensaver import Screensaver -from experimental.settings_menu import * +AIN_GRAPHICS = bytearray(b'\x00\x00|\x00|\x00d\x00d\x00g\x80a\x80\xe1\xb0\xe1\xb0\x01\xf0\x00\x00\x00\x00') +KNOB_GRAPHICS = bytearray(b'\x06\x00\x19\x80 @@ @ \x80\x10\x82\x10A @\xa0 @\x19\x80\x06\x00') +AIN_LABEL = "AIN" +KNOB_LABEL = "Knob" -class EuclidGenerator: - """Generates the euclidean rhythm for a single output +AUTOSELECT_AIN = "autoselect_ain" +AUTOSELECT_KNOB = "autoselect_knob" + + +class MenuItem: + """ + Generic class for anything we can display in the menu """ - def __init__(self, cv_out, name, steps=1, pulses=0, rotation=0, skip=0): - """Create a generator that sends its output to the given CV output + def __init__( + self, children: list[object] = None, parent: object = None, is_visible: bool = True + ): + """ + Create a new abstract menu item - @param cv_out One of the six output jacks (cv1..cv6) - @param steps The initial number of steps (1-32) - @param pulses The initial number of pulses (0-32) - @param rotation The initial rotation (0-32) - @param skip The skip probability (0-1) + @param parent A MenuItem representing this item's parent, if this item is the bottom-level of a + multi-level menu + @param children A list of MenuItems representing this item's children, if this is the top-level of a + multi-level menu + @param is_visible Is this menu item visible by default? """ - setting_prefix = name.lower() + self.menu = None + self.parent = parent + self.children = children + self.is_visible = is_visible - self.rotatation = None - self.pulses = None - self.steps = None + if parent and children: + raise Exception("Cannot specify parent and children in the same menu item") - self.steps = SettingMenuItem( - config_point = IntegerConfigPoint( - f"{setting_prefix}_steps", - 1, - 32, - steps, - ), - prefix = name, - title = "Steps", - callback = self.update_steps, - analog_in = ain, - knob_in = k1, - ) + def short_press(self): + """Handler for when the user short-presses the button""" + pass - self.rotation = SettingMenuItem( - config_point = IntegerConfigPoint( - f"{setting_prefix}_rotation", - 0, - 32, - rotation, - ), - prefix = name, - title = "Rotation", - callback = self.update_rotation, - analog_in = ain, - knob_in = k1, - ) + def draw(self, oled=europi.oled): + """ + Draw the item to the screen - self.pulses = SettingMenuItem( - config_point = IntegerConfigPoint( - f"{setting_prefix}_pulses", - 0, - 32, - pulses, - ), - prefix = name, - title = "Pulses", - callback = self.update_pulses, - analog_in = ain, - knob_in = k1, - ) + @param oled A Display-compatible object we draw to + """ + pass - self.skip = SettingMenuItem( - config_point = IntegerConfigPoint( - f"{setting_prefix}_skip_prob", - 0, - 100, - skip, - ), - prefix = name, - title = "Skip %", - analog_in = ain, - knob_in = k1, - ) + @property + def is_editable(self): + return False - ## The CV output this generator controls - self.cv = cv_out + @is_editable.setter + def is_editable(self, can_edit): + pass - ## The name for this channel - self.name = name + @property + def is_visible(self): + return self._is_visible - ## The current position within the pattern - self.position = 0 + @is_visible.setter + def is_visible(self, is_visible): + self._is_visible = is_visible - ## The on/off pattern we generate - self.pattern = [] - ## Cached copy of the string representation - # - # __str__(self) will do some extra string processing - # if this is None; otherwise its value is simply returned - self.str = None +class SettingsMenu: + """ + A menu-based GUI for any EuroPi script. - # Initialize the pattern - self.update_steps(self.steps.value, 0, None, None) - self.regenerate() + This class is assumed to be the main interaction method for the program. + """ - def update_steps(self, new_steps, old_steps, config_point, arg=None): - """Update the max range of pulses & rotation to match the number of steps + # Treat a long press as anything more than 500ms + LONG_PRESS_MS = 500 + + def __init__( + self, + menu_items=None, + button=europi.b2, + knob=europi.k2, + short_press_cb=lambda: None, + long_press_cb=lambda: None, + ): + """ + Create a new menu from the given specification + + Long/short press callbacks are invoked inside the handler for the falling edge of the button. It is recommended + to avoid any lengthy operations inside these callbacks, as they may prevent other interrupts from being + handled properly. + + @param menu_items A list of MenuItem objects representing the top-level of the menu + @param button The button the user presses to interact with the menu + @param knob The knob the user turns to scroll through the menu. This may be an experimental.knobs.KnobBank + with 3 menu levels called "main_menu", "submenu" and "choice", or a raw knob like europi.k2 + @param short_press_cb An optional callback function to invoke when the user interacts with a short-press of + the button + @param long_press_cb An optional callback function to invoke when the user interacts with a long-press of + the button """ - self.pulses.src_config.maximum = new_steps - self.pulses.refresh_choices(new_default=new_steps) + self._knob = knob + self.button = button - self.rotation.src_config.maximum = new_steps - self.rotation.refresh_choices(new_default=new_steps) + self._ui_dirty = True - self.regenerate() + self.short_press_cb = short_press_cb + self.long_press_cb = long_press_cb - def update_pulses(self, new_pulses, old_pulses, config_point, arg=None): - self.regenerate() + self.button.handler(self.on_button_press) + self.button.handler_falling(self.on_button_release) - def update_rotation(self, new_rot, old_rot, config_point, arg=None): - self.regenerate() + self.items = [] + if menu_items: + for item in menu_items: + self.items.append(item) - def __str__(self): - """Return a string representation of the pattern + self.active_items = self.items + self.active_item = self.knob.choice(self.items) - The string consists of 4 characters: - - ^ current beat, high - - v current beat, low - - | high beat - - . low beat + self.button_down_at = time.ticks_ms() - e.g. |.|.^|.|.||. is a 7/12 pattern, where the 5th note - is currently playing - """ + # Indicates to the application that we need to save the settings to disk + self.settings_dirty = False - if self.str is None: - s = "" - for i in range(len(self.pattern)): - if i == self.position: - if self.pattern[i] == 0: - s = s+"v" - else: - s = s+"^" - else: - if self.pattern[i] == 0: - s = s+"." - else: - s = s+"|" - self.str = s - return self.str + # Iterate through the menu and get all of the config points + self.config_points_by_name = {} + self.menu_items_by_name = {} + for item in self.items: + item.menu = self - def regenerate(self): - """Re-calculate the pattern for this generator + if type(item) is SettingMenuItem: + self.config_points_by_name[item.config_point.name] = item.config_point + self.menu_items_by_name[item.config_point.name] = item - Call this after changing any of steps, pulses, or rotation to apply - the changes. + if item.children: + for c in item.children: + c.menu = self + + if type(c) is SettingMenuItem: + self.config_points_by_name[c.config_point.name] = c.config_point + self.menu_items_by_name[c.config_point.name] = c + + @property + def knob(self): + if type(self._knob) is KnobBank: + return self._knob.current + else: + return self._knob - Changing the pattern will reset the position to zero + def get_config_points(self): """ - self.position = 0 - self.pattern = generate_euclidean_pattern(self.steps.value, self.pulses.value, self.rotation.value) + Get the config points for the menu so we can load/save them as needed + """ + return list(self.config_points_by_name.values()) - # clear the cached string representation - self.str = None + def load_defaults(self, settings_file): + """ + Load the initial settings from the file - def advance(self): - """Advance to the next step in the pattern and set the CV output + @param settings_file The path to a JSON file where the user's settings are saved """ - # advance the position - # to ease CPU usage don't do any divisions, just reset to zero - # if we overflow - self.position = self.position+1 - if self.position >= len(self.pattern): - self.position = 0 + spec = ConfigSpec(self.get_config_points()) + settings = ConfigFile.load_from_file(settings_file, spec) + for k in settings.keys(): + self.menu_items_by_name[k].choose(settings[k]) - if self.steps == 0 or self.pattern[self.position] == 0: - self.cv.off() + def save(self, settings_file): + """ + Save the current settings to the specified file + """ + data = {} + for item in self.menu_items_by_name.values(): + data[item.config_point.name] = item.value_choice + ConfigFile.save_to_file(settings_file, data) + self.settings_dirty = False + + def on_button_press(self): + """Handler for the rising edge of the button signal""" + self.button_down_at = time.ticks_ms() + self._ui_dirty = True + + def on_button_release(self): + """Handler for the falling edge of the button signal""" + self._ui_dirty = True + if time.ticks_diff(time.ticks_ms(), self.button_down_at) >= self.LONG_PRESS_MS: + self.long_press() else: - if self.skip.value / 100 > random.random(): - self.cv.off() + self.short_press() + + def short_press(self): + """ + Handle a short button press + + This enters edit mode, or applies the selection and + exits edit mode + """ + self.active_item.short_press() + + # Cycle the knob bank, if necessary + if type(self.knob) is KnobBank: + if self.active_item.is_editable: + self.knob.set_current("choice") + elif self.active_item.children: + self.knob.set_current("main_menu") else: - self.cv.on() + self.active_item.set_current("submenu") + + self.short_press_cb() + + def long_press(self): + """ + Handle a long button press + + This changes between the two menu levels (if possible) + """ + # exit editable mode when we change menu levels + self.active_item.is_editable = False + + # we're in the top-level menu; go to the submenu if it exists + if self.active_items == self.items: + if self.active_item.children: + self.active_items = self.active_item.children + if type(self.knob) is KnobBank: + self.knob.set_current("submenu") + else: + self.active_items = self.items + if type(self.knob) is KnobBank: + self.knob.set_current("main_menu") + + self.long_press_cb() + + def draw(self, oled=europi.oled): + """ + Draw the menu to the given display + + You should call the display's .fill(0) function before calling this in order to clear the screen. Otherwise + the menu item will be drawn on top of whatever is on the screen right now. (In some cases this may be the + desired result, but when in doubt, call oled.fill(0) first). + + You MUST call the display's .show() function after calling this in order to send the buffer to the display + hardware + + @param oled The display object to draw to + """ + if not self.active_item.is_editable: + self.active_item = self.knob.choice(self.visible_items) + self.active_item.draw(oled) + self._ui_dirty = False + + @property + def ui_dirty(self): + """ + Is the UI currently dirty and needs re-drawing? + + This will be true if the user has pressed the button or rotated the knob sufficiently + to change the active item + """ + return self._ui_dirty or self.active_item != self.knob.choice(self.visible_items) + + @property + def visible_items(self): + """ + Get the set of visible menu items for the current state of the menu - # clear the cached string representation - self.str = None + Menu items can be shown/hidden by setting their is_visible property. Normally this should be done in + a value-change callback of a menu item to show/hide dependent other items. + """ + items = [] + for item in self.active_items: + if item.is_visible: + items.append(item) + return items -class EuclidVisualization(MenuItem): - """A menu item for displaying a specific Euclidean channel +class SettingMenuItem(MenuItem): """ + A single menu item that presents a setting the user can manipulate - def __init__(self, generator, children=None, parent=None): - super().__init__(children=children, parent=parent) + The menu item is a wrapper around a ConfigPoint, and uses + that object's values as the available selections. + """ - self.generator = generator + def __init__( + self, + config_point: ConfigPoint = None, + parent: MenuItem = None, + children: list[MenuItem] = None, + title: str = None, + prefix: str = None, + graphics: dict = None, + labels: dict = None, + callback=lambda new_value, old_value, config_point, arg: None, + callback_arg=None, + float_resolution=2, + value_map: dict = None, + is_visible: bool = True, + knob_in: europi.Knob = None, + analog_in: europi.AnalogueInput = None, + ): + """ + Create a new menu item around a ConfigPoint + + If the item has a callback function defined, it will be invoked once during initialization + + @param config_point The configration option this menu item controls + @param parent If the menu has multiple levels, what is this item's parent control? + @param children If this menu has multiple levels, whar are this item's child controls? + @param title The title to display at the top of the display when this control is active + @param prefix A prefix to display before the title when this control is active + @param graphics A dict of values mapped to FrameBuffer or bytearray objects, representing 12x12 MONO_HLSB + graphics to display along with the keyed values + @param labels A dict of values mapped to strings, representing human-readible versions of the ConfigPoint + options + @param float_resolution The resolution of floating-point config points (ignored if config_point is not + a FloatConfigPoint) + @param value_map An optional dict to map the underlying simple ConfigPoint values to more complex objects + e.g. map the string "CMaj" to a Quantizer object + @param is_visible Is this menu item visible by default? + @param knob_in If set to a Knob instance, allow the user to use a knob to automatically choose values for + this setting + @param analog_in If set to an analogue input, allow the user to automatically choose values for this + setting via CV control + """ + super().__init__(parent=parent, children=children, is_visible=is_visible) + + self.timer = Timer() + + # are we in edit mode? + self.edit_mode = False + + self.analog_in = analog_in + self.knob_in = knob_in + + self.graphics = graphics + self.labels = labels + self.value_map = value_map + + # the configuration setting that we're controlling via this menu item + # convert everything to a choice configuration; this way we can add the knob/ain options too + if type(config_point) is FloatConfigPoint: + self.float_resolution = float_resolution + self.src_config = config_point + choices = self.get_option_list() + self.config_point = ChoiceConfigPoint( + config_point.name, + choices=choices, + default=config_point.default, + ) - def draw(self, oled=oled): - pattern_str = str(self.generator) - oled.text(f"-- {self.generator.name} --", 0, 0) - if len(pattern_str) > 16: - pattern_row1 = pattern_str[0:16] - pattern_row2 = pattern_str[16:] - oled.text(f"{pattern_row1}", 0, 10) - oled.text(f"{pattern_row2}", 0, 20) + self.NUM_AUTOINPUT_CHOICES = 0 + if self.analog_in or self.knob_in: + if self.analog_in: + self.NUM_AUTOINPUT_CHOICES += 1 + if self.knob_in: + self.NUM_AUTOINPUT_CHOICES += 1 + + if not self.graphics: + self.graphics = {} + self.graphics[AUTOSELECT_AIN] = AIN_GRAPHICS + self.graphics[AUTOSELECT_KNOB] = KNOB_GRAPHICS + + if not self.labels: + self.labels = {} + self.labels[AUTOSELECT_AIN] = AIN_LABEL + self.labels[AUTOSELECT_KNOB] = KNOB_LABEL + + if title: + self.title = title else: - oled.text(f"{pattern_str}", 0, 10) + self.title = self.config_point.name + if prefix: + self.prefix = prefix + else: + self.prefix = "" -class EuclideanRhythms(EuroPiScript): - """Generates 6 different Euclidean rhythms, one per output + self.callback_fn = callback + self.callback_arg = callback_arg - Must be clocked externally into DIN - """ + # assign the initial value without firing any callbacks + self._value = self.config_point.default + self._value_choice = self.config_point.default + + def refresh_choices(self, new_default=None): + """ + Regenerate this item's available choices + + This is needed if we externally modify e.g. the maximum/minimum values of the underlying + config point as a result of one option needing to be within a range determined by another. + + @param new_default A value to assign to this setting if its existing value is out-of-range + """ + self.config_point.choices = self.get_option_list() + still_valid = self.config_point.validate(self.value) + if not still_valid.is_valid: + self.choose(new_default) + + def short_press(self): + """ + Handle a short button press + + This enters edit mode, or applies the selection and + exits edit mode + """ + if self.is_editable: + new_choice = self.menu.knob.choice(self.config_point.choices) + if new_choice != self.value_choice: + # apply the currently-selected choice if we're in edit mode + self.choose(new_choice) + self.ui_dirty = True + self.menu.settings_dirty = True + + self.is_editable = not self.is_editable + + def draw(self, oled=europi.oled): + """ + Draw the current item to the display object + + You MUST call the display's .show() function after calling this in order to send the buffer to the display + hardware + + @param oled A Display instance (or compatible class) to render the item + """ + SELECT_OPTION_Y = 16 - def __init__(self): - super().__init__() - - ## The euclidean pattern generators for each CV output - # - # We pre-load the defaults with some interesting patterns so the script - # does _something_ out of the box - self.generators = [ - EuclidGenerator(cv1, "CV1", 8, 5), - EuclidGenerator(cv2, "CV2", 16, 7), - EuclidGenerator(cv3, "CV3", 16, 11), - EuclidGenerator(cv4, "CV4", 32, 9), - EuclidGenerator(cv5, "CV5", 32, 15), - EuclidGenerator(cv6, "CV6", 32, 19) - ] - - menu_items = [] - for i in range(len(self.generators)): - menu_items.append( - EuclidVisualization( - self.generators[i], - children = [ - self.generators[i].steps, - self.generators[i].pulses, - self.generators[i].rotation, - self.generators[i].skip, - ] - ) + if self.is_editable: + display_value = self.menu.knob.choice(self.config_point.choices) + else: + display_value = self.value_choice + + text_left = 0 + prefix_left = 1 + prefix_right = len(self.prefix) * europi.CHAR_WIDTH + title_left = len(self.prefix) * europi.CHAR_WIDTH + 4 + + # If we're in a top-level menu the submenu is non-empty. In that case, the prefix in inverted text + # Otherwise, the title in inverted text to indicate we're in the sub-menu + if self.children and len(self.children) > 0: + oled.fill_rect(prefix_left - 1, 0, prefix_right + 1, europi.CHAR_HEIGHT + 2, 1) + oled.text(self.prefix, prefix_left, 1, 0) + oled.text(self.title, title_left, 1, 1) + else: + oled.fill_rect( + title_left - 1, + 0, + len(self.title) * europi.CHAR_WIDTH + 2, + europi.CHAR_HEIGHT + 2, + 1, ) + oled.text(self.prefix, prefix_left, 1, 1) + oled.text(self.title, title_left, 1, 0) + + if self.graphics: + gfx = self.graphics.get(display_value, None) + if gfx: + text_left = 14 # graphics are 12x12, so add 2 pixel padding + if type(gfx) is bytearray: + gfx = FrameBuffer(gfx, 12, 12, MONO_HLSB) + oled.blit(gfx, 0, SELECT_OPTION_Y) + + if self.labels: + display_text = self.labels.get(display_value, str(display_value)) + else: + display_text = str(display_value) - self.menu = SettingsMenu( - menu_items = menu_items, - short_press_cb = self.on_menu_short_press, - long_press_cb = self.on_menu_long_press - ) - self.menu.load_defaults(self._state_filename) + if self.is_editable: + # draw the value in inverted text + text_width = len(display_text) * europi.CHAR_WIDTH - # Is the visualization stale (i.e. have we received a pulse and not updated the visualization?) - self.viz_dirty = True + oled.fill_rect( + text_left, + SELECT_OPTION_Y, + text_left + text_width + 3, + europi.CHAR_HEIGHT + 4, + 1, + ) + oled.text(display_text, text_left + 1, SELECT_OPTION_Y + 2, 0) + else: + # draw the selection in normal text + oled.text(display_text, text_left + 1, SELECT_OPTION_Y + 2, 1) - self.screensaver = Screensaver() + def get_option_list(self): + """ + Get the list of options the user can choose from - self.last_user_interaction_at = time.ticks_ms() + @return A list of choices + """ + t = type(self.src_config) + if t is FloatConfigPoint: + FLOAT_RESOLUTION = 1.0 / (10**self.float_resolution) + items = [] + x = self.src_config.minimum + while x <= self.src_config.maximum: + items.append(x) + x += FLOAT_RESOLUTION + elif t is IntegerConfigPoint: + items = list(range(self.src_config.minimum, self.src_config.maximum + 1)) + elif t is BooleanConfigPoint: + items = [False, True] + elif t is ChoiceConfigPoint: + items = self.src_config.choices + else: + raise Exception(f"Unsupported ConfigPoint type: {type(self.src_config)}") - @din.handler - def on_rising_clock(): - """Handler for the rising edge of the input clock + # Add the autoselect inputs, if needed + if self.knob_in: + items.append(AUTOSELECT_KNOB) + if self.analog_in: + items.append(AUTOSELECT_AIN) - Advance all of the rhythms - """ - for g in self.generators: - g.advance() - self.viz_dirty = True + return items - @din.handler_falling - def on_falling_clock(): - """Handler for the falling edge of the input clock + def sample_ain(self, timer): + """ + Read from AIN and use it to dynamically choose an item - Turn off all of the CVs so we don't stay on for adjacent pulses - """ - turn_off_all_cvs() + @param timer The timer that fired this callback + """ + index = int(self.analog_in.percent() * (len(self.config_point.choices) - self.NUM_AUTOINPUT_CHOICES)) + item = self.config_point.choices[index] + if item != self._value: + old_value = self._value + self._value = item + self.callback_fn(item, old_value, self.config_point, self.callback_arg) + + def sample_knob(self, timer): + """ + Read from the secondary knob and use it to dynamically choose an item - @b1.handler - def on_b1_press(): - """Handler for pressing button 1 + @param timer The timer that fired this callback + """ + index = int(self.knob_in.percent() * (len(self.config_point.choices) - self.NUM_AUTOINPUT_CHOICES)) + item = self.config_point.choices[index] + if item != self._value: + old_value = self._value + self._value = item + self.callback_fn(item, old_value, self.config_point, self.callback_arg) + + def choose(self, choice): + """ + Set the raw value of this item's ConfigPoint - Advance all of the rhythms - """ - self.last_user_interaction_at = time.ticks_ms() - for g in self.generators: - g.advance() - self.viz_dirty = True + @param choice The value to assign to the ConfigPoint. - @b1.handler_falling - def on_b1_release(): - """Handler for releasing button 1 + @exception ValueError if the given choice is not valid for this setting + """ + # kick out early if we aren't actually choosing anything + if choice == self.value_choice: + return - Turn off all of the CVs so we don't stay on for adjacent pulses - """ - self.last_user_interaction_at = time.ticks_ms() - turn_off_all_cvs() + validation = self.config_point.validate(choice) + if not validation.is_valid: + raise ValueError(f"{choice} is not a valid value for {self.config_point.name}") - def on_menu_long_press(self): - self.last_user_interaction_at = time.ticks_ms() + if self.value_choice == AUTOSELECT_AIN or self.value_choice == AUTOSELECT_KNOB: + self.timer.deinit() - def on_menu_short_press(self): - self.last_user_interaction_at = time.ticks_ms() + old_value = self._value_choice + self._value_choice = choice - def main(self): + if self._value_choice == AUTOSELECT_AIN: + self.timer.init(freq=10, mode=Timer.PERIODIC, callback=self.sample_ain) + elif self._value_choice == AUTOSELECT_KNOB: + self.timer.init(freq=10, mode=Timer.PERIODIC, callback=self.sample_knob) + else: + self.callback_fn(choice, old_value, self.config_point, self.callback_arg) - # manually check the state of k1 since it's otherwise not used, but should - # disable the screensaver - prev_k1 = int(k1.percent() * 100) + @property + def value_choice(self): + """The value the user has chosen from the menu""" + return self._value_choice - while True: - now = time.ticks_ms() + @property + def value(self): + """ + Get the raw value of this item's ConfigPoint - current_k1 = int(k1.percent() * 100) - if current_k1 != prev_k1: - self.last_user_interaction_at = now - prev_k1 = current_k1 + You should use .mapped_value if you have assigned a value_map to the constructor + """ + return self._value - if self.menu.ui_dirty: - self.last_user_interaction_at = now + @property + def mapped_value(self): + """ + Get the value of this item mapped by the value_map - if time.ticks_diff(now, self.last_user_interaction_at) >= self.screensaver.ACTIVATE_TIMEOUT_MS: - self.last_user_interaction_at = time.ticks_add(now, -self.screensaver.ACTIVATE_TIMEOUT_MS) - self.screensaver.draw() - else: - if self.viz_dirty or self.menu.ui_dirty: - self.viz_dirty = False - oled.fill(0) - self.menu.draw() - oled.show() + If value_map was not set by the constructor, this property returns the same + thing as .value + """ + if self.value_map: + return self.value_map[self._value] + return self._value - if self.menu.settings_dirty: - self.menu.save(self._state_filename) + @property + def is_editable(self): + return self.edit_mode -if __name__=="__main__": - EuclideanRhythms().main() + @is_editable.setter + def is_editable(self, can_edit): + self.edit_mode = can_edit