Browse Source

various lilypond stuff

master
Nils 6 years ago
parent
commit
f1ebbb01bc
  1. 27
      engine/api.py
  2. 70
      engine/items.py
  3. 16
      engine/main.py
  4. 17
      qtgui/designer/mainwindow.py
  5. 19
      qtgui/designer/mainwindow.ui
  6. 10
      qtgui/items.py
  7. 8
      qtgui/menu.py
  8. 2
      qtgui/scoreview.py
  9. 45
      qtgui/submenus.py

27
engine/api.py

@ -25,6 +25,7 @@ import logging; logging.info("import {}".format(__file__))
#Python Standard Library #Python Standard Library
import sys import sys
import random import random
from typing import Iterable, Callable, Tuple
#Third Party Modules #Third Party Modules
@ -2325,6 +2326,11 @@ def midiRelativeChannelReset():
_applyToSelection("midiRelativeChannelReset") _applyToSelection("midiRelativeChannelReset")
else: else:
_applyToItem("midiRelativeChannelReset") _applyToItem("midiRelativeChannelReset")
#Lilypond
def lilypondText(text):
insertItem(items.LilypondText(text))
def exportLilypond(absoluteFilePath): def exportLilypond(absoluteFilePath):
lilypond.saveAsLilypond(session.data, absoluteFilePath) lilypond.saveAsLilypond(session.data, absoluteFilePath)
@ -2332,6 +2338,27 @@ def exportLilypond(absoluteFilePath):
def showPDF(): def showPDF():
lilypond.saveAsLilypondPDF(session.data, openPDF = True) lilypond.saveAsLilypondPDF(session.data, openPDF = True)
def getLilypondBarlineList()->Iterable[Tuple[str, Callable]]:
"""Return a list of very similar functions for a convenience menu in a GUI.
They are trivial so we don't need to create seperate api function for them individually.
the qt gui submenus ChooseOne uses this format."""
return [
#("Bold", lambda: lilypondText('\\bar "."')),
("Double", lambda: lilypondText('\\bar "||"')),
#("Bold Double", lambda: lilypondText('\\bar ".."')),
("Open", lambda: lilypondText('\\bar ".|"')),
("End/Open", lambda: lilypondText('\\bar "|.|"')),
("End", lambda: lilypondText('\\bar "|."')),
]
def getLilypondRepeatList()->Iterable[Tuple[str, Callable]]:
return [
("Open", lambda: lilypondText('\\bar ".|:"')),
("Close/Open", lambda: lilypondText('\\bar ":|.|:"')),
("Close", lambda: lilypondText('\\bar ":|."')),
]
#Debug #Debug
def printPitches(): def printPitches():
track = session.data.currentTrack() track = session.data.currentTrack()

70
engine/items.py

@ -1,4 +1,4 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Copyright 2017, Nils Hilbricht, Germany ( https://www.hilbricht.net ) Copyright 2017, Nils Hilbricht, Germany ( https://www.hilbricht.net )
@ -565,15 +565,21 @@ class Duration(object):
def lilypond(self): def lilypond(self):
"""Called by note.lilypond(), See Item.lilypond for the general docstring. """Called by note.lilypond(), See Item.lilypond for the general docstring.
returns a number as string.""" returns a number as string."""
if self.durationKeyword == D_TIE:
append = "~"
else:
append = ""
n = self.genericNumber n = self.genericNumber
if n == 0: if n == 0:
return "\\breve" return "\\breve" + append
elif n == -1: elif n == -1:
return "\\longa" return "\\longa" + append
elif n == -2: elif n == -2:
return "\\maxima" return "\\maxima" + append
else: else:
return str(n) + self.dots*"." return str(n) + self.dots*"." + append
class DurationGroup(object): class DurationGroup(object):
"""Holds several durations and returns values meant for chords. """Holds several durations and returns values meant for chords.
@ -661,9 +667,8 @@ class Item(object):
"explicit" : False, "explicit" : False,
} }
def _secondInit(self, parentBlock): def _secondInit(self, parentBlock):
"""see Score._secondInit""" """see Score._secondInit"""
def deserializeDurationAndNotelistInPlace(self, serializedObject): def deserializeDurationAndNotelistInPlace(self, serializedObject):
"""Part of instanceFromSerializedData. """Part of instanceFromSerializedData.
@ -686,7 +691,7 @@ class Item(object):
every child class!""" every child class!"""
assert cls.__name__ == serializedObject["class"] assert cls.__name__ == serializedObject["class"]
self = cls.__new__(cls) self = cls.__new__(cls)
self.deserializeDurationAndNotelistInPlace(serializedObject) self.deserializeDurationAndNotelistInPlace(serializedObject) #creates parentBlocks
self._secondInit(parentTrack = parentObject) self._secondInit(parentTrack = parentObject)
return self return self
@ -2822,3 +2827,52 @@ class RecordedNote(Item):
def _lilypond(self): def _lilypond(self):
"""absolutely not""" """absolutely not"""
return "" return ""
class LilypondText(Item):
"""lilypond text as a last resort"""
def __init__(self, text):
super().__init__()
self.text = text
self._secondInit(parentBlock = None) #On item creation there is no parentBlock. The block adds itself to the item during insert.
def _secondInit(self, parentBlock):
"""see Item._secondInit"""
super()._secondInit(parentBlock) #Item._secondInit
def _lilypond(self):
"""called by block.lilypond(), returns a string.
Don't create white-spaces yourself, this is done by the structures.
When in doubt prefer functionality and robustness over 'beautiful' lilypond syntax."""
return self.text
@classmethod
def instanceFromSerializedData(cls, serializedObject, parentObject):
"""see Score.instanceFromSerializedData"""
assert cls.__name__ == serializedObject["class"]
self = cls.__new__(cls)
self.text = serializedObject["text"]
self.deserializeDurationAndNotelistInPlace(serializedObject) #creates parentBlocks
self._secondInit(parentBlock = parentObject)
return self
def serialize(self):
result = super().serialize() #call this in child classes
result["text"] = self.text
return result
def _copy(self):
"""return an independent copy of self"""
new = LilypondText(self.text)
return new
def _exportObject(self, trackState):
return {
"type" : "LilypondText",
"completeDuration" : 0,
"tickindex" : trackState.tickindex, #we parse the tickindex after we stepped over the item.
"midiBytes" : [],
"text" : self.text,
"UIstring" : self.text, #this is for a UI, possibly a text UI, maybe for simple items of a GUI. Make it as short and unambigious as possible.
}

16
engine/main.py

@ -73,6 +73,7 @@ class Data(template.engine.sequencer.Score):
return tr return tr
def trackById(self, trackId): def trackById(self, trackId):
"""Also looks up hidden and deleted tracks"""
try: try:
ret = Track.allTracks[trackId] ret = Track.allTracks[trackId]
except: except:
@ -835,10 +836,11 @@ class Data(template.engine.sequencer.Score):
def allItems(self, hidden = True, removeContentLinkedData = False): def allItems(self, hidden = True, removeContentLinkedData = False):
seenItems = set() seenItems = set()
if hidden: if hidden:
what = Track.allTracks.values() #this includes hidden tracks what = list(self.hiddenTracks.keys()) + self.tracks
#what = Track.allTracks.values() #this includes deleted tracks
else: else:
what = self.tracks what = self.tracks
for track in what: for track in what:
for block in track.blocks: for block in track.blocks:
for item in block.data: for item in block.data:
@ -1001,8 +1003,10 @@ class Data(template.engine.sequencer.Score):
def removeEmptyBlocks(self): def removeEmptyBlocks(self):
dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds] dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds]
for trId, track in Track.allTracks.items(): #for trId, track in Track.allTracks.items():
for track in list(self.hiddenTracks.keys()) + self.tracks:
trId = id(track)
listOfBlockIds = track.asListOfBlockIds() listOfBlockIds = track.asListOfBlockIds()
for block in track.blocks: for block in track.blocks:
if not block.data and len(track.blocks) > 1: if not block.data and len(track.blocks) > 1:
@ -1039,8 +1043,8 @@ class Data(template.engine.sequencer.Score):
assert cls.__name__ == serializedData["class"] assert cls.__name__ == serializedData["class"]
self = cls.__new__(cls) self = cls.__new__(cls)
super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap
self.parentSession = parentSession self.parentSession = parentSession
self.hiddenTracks = {Track.instanceFromSerializedData(track, parentData = self):originalIndex for track, originalIndex in serializedData["hiddenTracks"]} self.hiddenTracks = {Track.instanceFromSerializedData(parentData=self, serializedData=track):originalIndex for track, originalIndex in serializedData["hiddenTracks"]}
self.tempoTrack = TempoTrack.instanceFromSerializedData(serializedData["tempoTrack"], parentData = self) self.tempoTrack = TempoTrack.instanceFromSerializedData(serializedData["tempoTrack"], parentData = self)
self.metaData = serializedData["metaData"] self.metaData = serializedData["metaData"]
self.currentMetronomeTrack = self.tracks[serializedData["currentMetronomeTrackIndex"]] self.currentMetronomeTrack = self.tracks[serializedData["currentMetronomeTrackIndex"]]

17
qtgui/designer/mainwindow.py

@ -2,12 +2,13 @@
# Form implementation generated from reading ui file 'mainwindow.ui' # Form implementation generated from reading ui file 'mainwindow.ui'
# #
# Created by: PyQt5 UI code generator 5.11.3 # Created by: PyQt5 UI code generator 5.12.1
# #
# WARNING! All changes made in this file will be lost! # WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
class Ui_MainWindow(object): class Ui_MainWindow(object):
def setupUi(self, MainWindow): def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow") MainWindow.setObjectName("MainWindow")
@ -437,6 +438,12 @@ class Ui_MainWindow(object):
self.actionRandom_in_scale_in_octave_around_cursor.setObjectName("actionRandom_in_scale_in_octave_around_cursor") self.actionRandom_in_scale_in_octave_around_cursor.setObjectName("actionRandom_in_scale_in_octave_around_cursor")
self.actionCreate_pool_from_selection = QtWidgets.QAction(MainWindow) self.actionCreate_pool_from_selection = QtWidgets.QAction(MainWindow)
self.actionCreate_pool_from_selection.setObjectName("actionCreate_pool_from_selection") self.actionCreate_pool_from_selection.setObjectName("actionCreate_pool_from_selection")
self.actionLyBarline = QtWidgets.QAction(MainWindow)
self.actionLyBarline.setObjectName("actionLyBarline")
self.actionLyFree_Instruction = QtWidgets.QAction(MainWindow)
self.actionLyFree_Instruction.setObjectName("actionLyFree_Instruction")
self.actionLyRepeat = QtWidgets.QAction(MainWindow)
self.actionLyRepeat.setObjectName("actionLyRepeat")
self.menuObjects.addAction(self.actionMetrical_Instruction) self.menuObjects.addAction(self.actionMetrical_Instruction)
self.menuObjects.addAction(self.actionClef) self.menuObjects.addAction(self.actionClef)
self.menuObjects.addAction(self.actionKey_Signature) self.menuObjects.addAction(self.actionKey_Signature)
@ -612,6 +619,10 @@ class Ui_MainWindow(object):
self.menuToolbox.addAction(self.menuMIDI.menuAction()) self.menuToolbox.addAction(self.menuMIDI.menuAction())
self.menuLilypond.addAction(self.actionShow_PDF) self.menuLilypond.addAction(self.actionShow_PDF)
self.menuLilypond.addAction(self.actionExport_to_Ly) self.menuLilypond.addAction(self.actionExport_to_Ly)
self.menuLilypond.addSeparator()
self.menuLilypond.addAction(self.actionLyBarline)
self.menuLilypond.addAction(self.actionLyRepeat)
self.menuLilypond.addAction(self.actionLyFree_Instruction)
self.menubar.addAction(self.menuView.menuAction()) self.menubar.addAction(self.menuView.menuAction())
self.menubar.addAction(self.menuEdit_2.menuAction()) self.menubar.addAction(self.menuEdit_2.menuAction())
self.menubar.addAction(self.menu.menuAction()) self.menubar.addAction(self.menu.menuAction())
@ -953,4 +964,8 @@ class Ui_MainWindow(object):
self.actionRandom_in_scale_in_cursor_plus_octave.setText(_translate("MainWindow", "Random in-scale in cursor plus octave (authentic mode)")) self.actionRandom_in_scale_in_cursor_plus_octave.setText(_translate("MainWindow", "Random in-scale in cursor plus octave (authentic mode)"))
self.actionRandom_in_scale_in_octave_around_cursor.setText(_translate("MainWindow", "Random in-scale in octave around cursor (hypo mode)")) self.actionRandom_in_scale_in_octave_around_cursor.setText(_translate("MainWindow", "Random in-scale in octave around cursor (hypo mode)"))
self.actionCreate_pool_from_selection.setText(_translate("MainWindow", "Create pool from selection")) self.actionCreate_pool_from_selection.setText(_translate("MainWindow", "Create pool from selection"))
self.actionLyBarline.setText(_translate("MainWindow", "Barline"))
self.actionLyFree_Instruction.setText(_translate("MainWindow", "Free Instruction"))
self.actionLyRepeat.setText(_translate("MainWindow", "Repeat"))

19
qtgui/designer/mainwindow.ui

@ -315,6 +315,10 @@
</property> </property>
<addaction name="actionShow_PDF"/> <addaction name="actionShow_PDF"/>
<addaction name="actionExport_to_Ly"/> <addaction name="actionExport_to_Ly"/>
<addaction name="separator"/>
<addaction name="actionLyBarline"/>
<addaction name="actionLyRepeat"/>
<addaction name="actionLyFree_Instruction"/>
</widget> </widget>
<addaction name="menuView"/> <addaction name="menuView"/>
<addaction name="menuEdit_2"/> <addaction name="menuEdit_2"/>
@ -1669,6 +1673,21 @@
<string>Create pool from selection</string> <string>Create pool from selection</string>
</property> </property>
</action> </action>
<action name="actionLyBarline">
<property name="text">
<string>Barline</string>
</property>
</action>
<action name="actionLyFree_Instruction">
<property name="text">
<string>Free Instruction</string>
</property>
</action>
<action name="actionLyRepeat">
<property name="text">
<string>Repeat</string>
</property>
</action>
</widget> </widget>
<resources/> <resources/>
<connections/> <connections/>

10
qtgui/items.py

@ -28,6 +28,10 @@ from .constantsAndConfigs import constantsAndConfigs
from template.qtgui.helper import stretchRect from template.qtgui.helper import stretchRect
import engine.api as api import engine.api as api
from template.engine import pitch
"""All svg items (execept pitch-related) graphics are expected to be """All svg items (execept pitch-related) graphics are expected to be
already on the correct position and will be inserter ON the middle line already on the correct position and will be inserter ON the middle line
That means g-clef needs to be 14 pxiels below the y=0 coordinate since That means g-clef needs to be 14 pxiels below the y=0 coordinate since
@ -667,7 +671,7 @@ class GuiKeySignature(GuiItem):
self.bigNatural.setParentItem(self) self.bigNatural.setParentItem(self)
self.bigNatural.setPos(constantsAndConfigs.magicPixel, constantsAndConfigs.stafflineGap * -1) self.bigNatural.setPos(constantsAndConfigs.magicPixel, constantsAndConfigs.stafflineGap * -1)
self.rootGlyph = QtWidgets.QGraphicsSimpleTextItem(constantsAndConfigs.baseNotes[self.staticItem["root"]]) self.rootGlyph = QtWidgets.QGraphicsSimpleTextItem(pitch.baseNotesToBaseNames[self.staticItem["root"]])
self.rootGlyph.setParentItem(self) self.rootGlyph.setParentItem(self)
self.rootGlyph.setPos(constantsAndConfigs.negativeMagicPixel, 2*constantsAndConfigs.stafflineGap) self.rootGlyph.setPos(constantsAndConfigs.negativeMagicPixel, 2*constantsAndConfigs.stafflineGap)
@ -916,7 +920,7 @@ def staticItem2Item(staticItem):
return GuiMetricalInstruction(staticItem) return GuiMetricalInstruction(staticItem)
elif typ is "BlockEndMarker": elif typ is "BlockEndMarker":
return GuiBlockEndMarker(staticItem) return GuiBlockEndMarker(staticItem)
elif typ in ("InstrumentChange", "ChannelChange",): elif typ in ("InstrumentChange", "ChannelChange", "LilypondText"):
return GuiGenericText(staticItem) return GuiGenericText(staticItem)
else: else:
raise ValueError("Unknown Item Type:", staticItem) raise ValueError("Unknown Item Type:", staticItem)

8
qtgui/menu.py

@ -33,7 +33,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets
import engine.api as api import engine.api as api
from midiinput.stepmidiinput import stepMidiInput #singleton instance from midiinput.stepmidiinput import stepMidiInput #singleton instance
from .constantsAndConfigs import constantsAndConfigs from .constantsAndConfigs import constantsAndConfigs
from .submenus import SecondaryClefMenu, SecondaryKeySignatureMenu, SecondaryDynamicsMenu, SecondaryMetricalInstructionMenu, SecondaryTempoChangeMenu, SecondaryTemporaryTempoChangeMenu, SecondarySplitMenu, TransposeMenu, pedalNoteChooser, SecondaryProperties, SecondaryProgramChangeMenu, SecondaryChannelChangeMenu from .submenus import SecondaryClefMenu, SecondaryKeySignatureMenu, SecondaryDynamicsMenu, SecondaryMetricalInstructionMenu, SecondaryTempoChangeMenu, SecondaryTemporaryTempoChangeMenu, SecondarySplitMenu, TransposeMenu, pedalNoteChooser, SecondaryProperties, SecondaryProgramChangeMenu, SecondaryChannelChangeMenu, ChooseOne, forwardText
class ModalKeys(object): class ModalKeys(object):
def __init__(self): def __init__(self):
@ -294,6 +294,12 @@ class MenuActionDatabase(object):
#Midi #Midi
self.mainWindow.ui.actionInstrument_Change: SecondaryProgramChangeMenu(self.mainWindow), #no lambda for submenus. They get created here once and have a __call__ option that executes them. There is no internal state in these menus. self.mainWindow.ui.actionInstrument_Change: SecondaryProgramChangeMenu(self.mainWindow), #no lambda for submenus. They get created here once and have a __call__ option that executes them. There is no internal state in these menus.
self.mainWindow.ui.actionChannel_Change: SecondaryChannelChangeMenu(self.mainWindow), #no lambda for submenus. They get created here once and have a __call__ option that executes them. There is no internal state in these menus. self.mainWindow.ui.actionChannel_Change: SecondaryChannelChangeMenu(self.mainWindow), #no lambda for submenus. They get created here once and have a __call__ option that executes them. There is no internal state in these menus.
#Lilypond
#Print and Export is in self.actions
self.mainWindow.ui.actionLyBarline: ChooseOne(self.mainWindow, "Choose a Barline", api.getLilypondBarlineList()),
self.mainWindow.ui.actionLyRepeat: ChooseOne(self.mainWindow, "Choose a Repeat", api.getLilypondRepeatList()),
self.mainWindow.ui.actionLyFree_Instruction: lambda: forwardText(self.mainWindow, "Enter Instruction", api.lilypondText),
} }
self.modalActions = { #these are only available in Note Edit Mode, not in CC Edit Mode etc. self.modalActions = { #these are only available in Note Edit Mode, not in CC Edit Mode etc.

2
qtgui/scoreview.py

@ -31,7 +31,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets, QtOpenGL
from template.helper import onlyOne from template.helper import onlyOne
#Our Modules #Our Modules
from .submenus import GridRhytmEdit
from .constantsAndConfigs import constantsAndConfigs from .constantsAndConfigs import constantsAndConfigs
from .structures import GuiScore from .structures import GuiScore
import engine.api as api import engine.api as api

45
qtgui/submenus.py

@ -20,6 +20,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. along with this program. If not, see <http://www.gnu.org/licenses/>.
""" """
from typing import Iterable, Callable, Tuple
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
import engine.api as api import engine.api as api
@ -149,10 +151,10 @@ class TickWidget(QtWidgets.QDialog):
class Submenu(QtWidgets.QDialog): class Submenu(QtWidgets.QDialog):
#TODO: instead of using a QDialog we could use a QWidget and use it as proxy widget on the graphic scene, placing the menu where the input cursor is. #TODO: instead of using a QDialog we could use a QWidget and use it as proxy widget on the graphic scene, placing the menu where the input cursor is.
def __init__(self, mainWindow, labelString): def __init__(self, mainWindow, labelString):
super().__init__(mainWindow) #if you don't set the parent to the main window the whole screen will be the root and the dialog pops up in the middle of it. super().__init__(mainWindow) #if you don't set the parent to the main window the whole screen will be the root and the dialog pops up in the middle of it.
#self.setModal(True) #we don't need this when called with self.exec() instead of self.show() #self.setModal(True) #we don't need this when called with self.exec() instead of self.show()
self.layout = QtWidgets.QFormLayout() self.layout = QtWidgets.QFormLayout()
#self.layout = QtWidgets.QVBoxLayout() #self.layout = QtWidgets.QVBoxLayout()
self.setLayout(self.layout) self.setLayout(self.layout)
label = QtWidgets.QLabel(labelString) #"Choose a clef" or so. label = QtWidgets.QLabel(labelString) #"Choose a clef" or so.
@ -180,14 +182,20 @@ class Submenu(QtWidgets.QDialog):
except AttributeError: except AttributeError:
super().keyPressEvent(event) super().keyPressEvent(event)
def showEvent(self, event):
#TODO: not optimal but better than nothing.
super().showEvent(event)
#self.resize(self.layout.geometry().width(), self.layout.geometry().height())
self.resize(self.childrenRect().height(), self.childrenRect().width())
self.updateGeometry()
def abortHandler(self): def abortHandler(self):
pass pass
def __call__(self): def __call__(self):
"""This instance can be called like a function""" """This instance can be called like a function"""
self.exec() #blocks until the dialog gets closed self.exec() #blocks until the dialog gets closed
""" """
Most submenus have the line "lambda, r, value=value"... Most submenus have the line "lambda, r, value=value"...
the r is the return value we get automatically from the Qt buttons which need to be handled. the r is the return value we get automatically from the Qt buttons which need to be handled.
@ -280,6 +288,22 @@ class SecondaryMetricalInstructionMenu(Submenu):
button.clicked.connect(function) button.clicked.connect(function)
button.clicked.connect(self.done) button.clicked.connect(self.done)
class ChooseOne(Submenu):
"""A generic submenu that presents a list of options to the users.
Only supports up to ten entries, for number shortcuts"""
def __init__(self, mainWindow, title:str, lst:Iterable[Tuple[str, Callable]]):
if len(lst) > 9:
raise ValueError(f"ChooseOne submenu supports up to nine entries. You have {len(lst)}")
super().__init__(mainWindow, title)
for number, (prettyname, function) in enumerate(lst):
button = QtWidgets.QPushButton(f"[{number+1}] {prettyname}")
button.setShortcut(QtGui.QKeySequence(str(number+1)))
button.setStyleSheet("Text-align:left; padding: 5px;");
self.layout.addWidget(button)
button.clicked.connect(function)
button.clicked.connect(self.done)
class SecondaryTempoChangeMenu(Submenu): class SecondaryTempoChangeMenu(Submenu):
"""A single tempo change where the user can decide which reference unit and how many of them """A single tempo change where the user can decide which reference unit and how many of them
per minute. per minute.
@ -610,3 +634,16 @@ def pedalNoteChooser(mainWindow):
for baseDuration, v in constantsAndConfigs.commonNotes: for baseDuration, v in constantsAndConfigs.commonNotes:
if v == rhythmString[0]: if v == rhythmString[0]:
api.pedalNotes(baseDuration) api.pedalNotes(baseDuration)
def forwardText(mainWindow, title, function):
text, status = QtWidgets.QInputDialog.getText(mainWindow, title, title)
if status:
function(text)

Loading…
Cancel
Save