#! /usr/bin/env python3 # -*- coding: utf-8 -*- """ Copyright 2019, Nils Hilbricht, Germany ( https://www.hilbricht.net ) This file is part of the Laborejo Software Suite ( https://www.laborejo.org ), more specifically its template base application. Laborejo2 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ import logging; logging.info("import {}".format(__file__)) #Standar Library import sys #3rd Party #Template import template.engine.sequencer #Our modules from .block import Block from .track import Track from .cursor import Cursor from .tempotrack import TempoTrack from .lilypond import fromTemplate class Data(template.engine.sequencer.Score): def __init__(self, parentSession): super().__init__(parentSession) self.tracks = [Track(parentData = self)] #this is the track order and all visible tracks. For getting a specific track use Track.allTracks with a track id. self.hiddenTracks = {} #track-instance:original Position. The value can exist multiple times. These still create a playback representation but are read-only and do not react to editing or GUI requests because you can't access them except through Track.allTrack (which is exactly the same for deleted tracks). Hidden tracks are saved though, deleted ones not. self.tempoTrack = TempoTrack(parentData = self) #The tempoTrack is a Laborejo class. TempoMap is a template.sequencer class that is used by TempoTrack internally #Metadata has only strings as keys, even the numbers. self.metaData = {key:"" for key in ("title", "subtitle", "dedication","composer","subsubtitle","instrument","meter","arranger", "poet","piece","opus","copyright","tagline", "subtext")} self.currentMetronomeTrack = self.tracks[0] #A Laborejo Track, indepedent of currentTrack. The metronome is in self.metronome, set by the template Score. self._processAfterInit() def _processAfterInit(self): self.cursor = Cursor(self) self.trackIndex = 0 #a temp variable. self.cursorWhenSelectionStarted = None #A cursor dict, from self.cursorExport(). Is None when there is no selection. Can be used for "if selection:" questions. Gets changed quite often. self.copyObjectsBuffer = [] #for copy and paste. obviously empty after load file. Also not saved. self.cachedTrackDurations = {} #updated after every track export #track.asMetronomeData is a generated value from staticExport. Not available yet. needs to be done in api.startEngine #self.metronome.generate(data=self.currentMetronomeTrack.asMetronomeData, label=self.currentMetronomeTrack.name) def duration(self): """Return the duration of the whole score, in ticks""" #TODO: use cached duration? How often is this used? Pretty often. 3 Times for a single track note update. #TODO: Measure before trying to improve performance. result = [] for track in self.tracks: result.append(track.duration()) return max(result) def currentTrack(self): tr = self.tracks[self.trackIndex] return tr def trackById(self, trackId): try: ret = Track.allTracks[trackId] except: print (sys.exc_info()[0]) print (trackId, Track.allTracks, self.tracks, self.hiddenTracks, self.trackIndex) raise return ret def noteById(self, noteId): """A note, and its parent Chord/Item, can be in multiple tracks. But only ever in one block""" return Note.allNotes[noteId].parentChord.parentTracks, Note.allNotes[noteId] #this returns the actual instances, not the weak references. #def itemById(self, itemId): # """An item can be in multiple tracks. Return all those""" # return self.allItems[itemId].parentTracks, self.allItems[itemId] #this returns the actual instances, not the weak references. def allBlocks(self): """return all blocks in all tracks in order from top left to bottom right, thinking in score-layout.""" for track in self.tracks + list(self.hiddenTracks.keys()): for block in track.blocks: yield block def blockById(self, blockId): """A block (not it's content!) is unique. There is exactly one instance with this id, in one track""" return Block.allBlocks[blockId].parentTrack, Block.allBlocks[blockId] #returns the actual instances, not the weak references. def currentTrackId(self): trId = id(self.currentTrack()) return trId def currentBlock(self): """There is always a block, this is guaranteed""" return self.currentTrack().currentBlock() def currentItem(self): return self.currentTrack().currentItem() #def currentItemAndItsTrack(self): # curItem = self.currentItem() # if curItem: # return curItem.currentItem.parentTracks, curItem # else: # return None def currentContentLinkAndItsBlocksInAllTracks(self): curBlock = self.currentBlock() trackList = list(set([block.parentTrack for block in curBlock.linkedContentBlocksInScore()])) #for this list(set( it doesn't matter that the trackList will not be in order. return trackList, curBlock def insertTrack(self, atIndex, trackObject): """This is also newTrack. the api creates a track object for that. atIndex inserts the track before the item currently in this position. If atIndex is bigger than the highest current index it will be appended through standard python behaviour We don't use the template-function addTrack. """ assert trackObject.sequencerInterface assert trackObject.parentData == self self.tracks.insert(atIndex, trackObject) self.trackIndex = self.tracks.index(trackObject) assert atIndex == self.trackIndex def tracksAsDict(self): """used by rearrangeTracks Key is the track id, value the track instance""" result = {} for track in self.tracks: result[id(track)] = track return result def asListOfTrackIds(self): """Return an ordered list of track ids""" result = [] for track in self.tracks: result.append(id(track)) return result def rearrangeTracks(self, listOfTrackIds): """Reorder the tracks. Achtung! Not including a track will delete this track. This is not the right place to delete a track. So we check for that case""" startTrack = self.currentTrack() #we will return later to this track originalLength = len(self.tracks) tracksDict = self.tracksAsDict() newArrangement = [] for idLong in listOfTrackIds: newArrangement.append(tracksDict[idLong]) seen = set() seen_add = seen.add self.tracks = [ x for x in newArrangement if x not in seen and not seen_add(x)] assert originalLength == len(self.tracks) assert self.tracks self.trackIndex = self.tracks.index(startTrack) def deleteCurrentTrack(self): if len(self.tracks) > 1: nowInTicks = self.currentTrack().state.tickindex result = self.currentTrack(), self.trackIndex #return to the API for undo. super().deleteTrack(self.currentTrack()) #self.currentTrack().prepareForDeletion() #remove calfbox objects #self.tracks.remove(self.currentTrack()) if self.trackIndex+1 > len(self.tracks): #trackIndex from 0, len from 1. self.trackIndex -= 1 self.currentTrack().head() self.currentTrack().goToTickindex(nowInTicks) #that is already the new current track return result def deleteTrack(self, track): if track == self.currentTrack(): return self.deleteCurrentTrack() #redirect. This way we have no problems with cursor positions etc. if len(self.tracks) > 1: #We need to know if the old current track was AFTER the deleted one. In this case the trackindex needs to get subtracted by one (before the track is actually deleted) result = self.currentTrack(), self.trackIndex #so undo has something to save if self.trackIndex > self.tracks.index(track): #there is only > or <. == is ruled out above. self.trackIndex -= 1 super().deleteTrack(track) #track.prepareForDeletion() #self.tracks.remove(track) return result def hideTrack(self, track): """This track cannot be edited while hidden. We don't delete any calfbox data here, contrary to delete Track. That means we don't need to touch anything and the playback version will remain correct. Also make sure we are in the right track afterwards since hideTrack changes the trackIndices""" assert track in self.tracks #track is visible so it should be in the list of visible tracks assert not track in self.hiddenTracks #track is visible so it should not be in the list of hidden tracks if len(self.tracks) > 1: #same rules as for delete. do not hide the last visible track self.hiddenTracks[track] = self.tracks.index(track) if self.trackIndex == self.tracks.index(track): #the track with the active cursor gets hidden self.tracks.remove(track) self.trackIndex = 0 else: currentTrack = self.tracks[self.trackIndex] self.tracks.remove(track) self.trackIndex = self.tracks.index(currentTrack) #find out where the current track is now. return True #succesful def unhideTrack(self, track): assert not track in self.tracks #track is hidden so it should not be in the list of visible tracks assert track in self.hiddenTracks #track is hidden so it should be in the list of hidden tracks currentTrack = self.tracks[self.trackIndex] self.tracks.insert(self.hiddenTracks[track], track) del self.hiddenTracks[track] self.trackIndex = self.tracks.index(currentTrack) #find out where the current track is now. return True def trackUp(self): if self.trackIndex > 0: #there is still a track above. At least index 0. nowInTicks = self.currentTrack().state.tickindex self.trackIndex -= 1 self.currentTrack().goToTickindex(nowInTicks) return True def trackDown(self): if self.trackIndex < len(self.tracks)-1: #there is still a track below nowInTicks = self.currentTrack().state.tickindex self.trackIndex += 1 self.currentTrack().goToTickindex(nowInTicks) return True def trackFirst(self): nowInTicks = self.currentTrack().state.tickindex self.trackIndex = 0 self.currentTrack().goToTickindex(nowInTicks) def trackLast(self): nowInTicks = self.currentTrack().state.tickindex self.trackIndex = len(self.tracks)-1 self.currentTrack().goToTickindex(nowInTicks) def cursorExport(self): return self.cursor.exportObject(self.currentTrack().state) def _cursorExportTrackTick(self, track, tickPosition): originalPosition = track.state.position() track.goToTickindex(tickPosition) exportCursor = self.cursor.exportObject(track.state) track.toPosition(originalPosition) #has head() in it return exportCursor def setSelectionBeginning(self): if self.cursorWhenSelectionStarted: return False #because we did nothing else: self.cursorWhenSelectionStarted = self.cursorExport() #There was no selection before. setSelectionBeginning is called by selection-creating functions. So this was the first step of shift+right or so. return True #because we did something def goToSelectionEnd(self): """Keeps the selection alive. Written for processing functions like apply to selection. Or maybe as a user command. Is not used in the internal score selection commands because we want to keep everything original.""" result = self.checkSelectionOrder() if not result: raise RuntimeError("called score.goToSelectionEnd without a selection") else: topLeftCursor, bottomRightCursor = result #The cursor when selection started is always the opposite of the current cursor position. except if the selection has zero width, which is ruled out by the test above. if self.cursorWhenSelectionStarted == topLeftCursor: #already at the end return False else: #self.cursorWhenSelectionStarted == bottomRightCursor: self.cursorWhenSelectionStarted = topLeftCursor self.goTo(bottomRightCursor["trackIndex"], bottomRightCursor["blockindex"], bottomRightCursor["localCursorIndex"]) #does not work if the number of items change. e.g. split return True #else: This fires when the selection goes from bottomLeft to topRight or reverse. Make the "elif" above the new "else". #raise ValueError("Cursor was neither at the start nor the end of the selection but a selection was present. This is a serious error. Only selections with the cursor on either end are allowed") def goToSelectionStart(self): result = self.checkSelectionOrder() if not result: raise RuntimeError("called score.goToSelectionEnd without a selection") else: topLeftCursor, bottomRightCursor = result #one of these is always the current cursor, not a cached value. You don't need to test if the current cursor is actually one of these two. #The cursor when selection started is always the opposite of the current cursor position. except if the selection has zero width, which is ruled out by the test above. if self.cursorWhenSelectionStarted == bottomRightCursor: #already at the beginning return False else:# self.cursorWhenSelectionStarted == topLeftCursor: self.cursorWhenSelectionStarted = bottomRightCursor self.goTo(topLeftCursor["trackIndex"], topLeftCursor["blockindex"], topLeftCursor["localCursorIndex"]) #contrary to goToSelectionEnd this works always. No duration or number change can affect this position. return True #else: This fires when the selection goes from bottomLeft to topRight or reverse. Make the "elif" above the new "else". # raise ValueError("Cursor was neither at the start nor the end of the selection but a selection was present. This is a serious error. Only selections with the cursor on either end are allowed") def checkSelectionOrder(self): """Return the correct order of the selection boundaries (topLeft, bottomRight) Returns empty tuple if we don't have a selection or a selection of size 0.""" if self.cursorWhenSelectionStarted: #is None if we don't have a selection at all. Otherwise a cursor export object like self.cursorExport() current = self.cursorExport() if self.cursorWhenSelectionStarted["tickindex"] == current["tickindex"]: #The only time this should happen is after a selection modification by the user which results in having no selections. e.g. "select right", "select left". #A special case if a selection changes direction, or "inverts". #Note that we don't reset self.cursorWhenSelectionStarted. The selection process is still going on. return tuple() #we have a selection with dimensions > 0. if self.cursorWhenSelectionStarted["trackIndex"] < current["trackIndex"]: #selection order GUI-view: top to bottom if self.cursorWhenSelectionStarted["tickindex"] < current["tickindex"]: #selection order left to right result = self.cursorWhenSelectionStarted, current else: #selection order right to left. Both topLeft and bottomRight are not created by the user directly. We have to guess what start item the user wants which can be hard if zero-duration items are involved. #There is no other way to go the tickindex of the real first item and force a cursor export. topLeft = self._cursorExportTrackTick(self.cursorWhenSelectionStarted["track"], current["tickindex"]) #yes, the tickindex of the other track bottomRight = self._cursorExportTrackTick(current["track"], self.cursorWhenSelectionStarted["tickindex"]) #yes, the tickindex of the other track return topLeft, bottomRight elif self.cursorWhenSelectionStarted["trackIndex"] > current["trackIndex"]: #selection order GUI-view: bottom to top if self.cursorWhenSelectionStarted["tickindex"] > current["tickindex"]: #selection order left to right result = current, self.cursorWhenSelectionStarted else: #selection order right to left. Both topLeft and bottomRight are not created by the user directly. We have to guess what start item the user wants which can be hard if zero-duration items are involved. #There is no other way to go the tickindex of the real first item and force a cursor export. bottomRight = self._cursorExportTrackTick(self.cursorWhenSelectionStarted["track"], current["tickindex"]) #yes, the tickindex of the other track topLeft = self._cursorExportTrackTick(current["track"], self.cursorWhenSelectionStarted["tickindex"]) #yes, the tickindex of the other track return topLeft, bottomRight else: #in the same track assert self.cursorWhenSelectionStarted["trackIndex"] == current["trackIndex"] #anything else is logically impossible. if self.cursorWhenSelectionStarted["position"] <= current["position"]: result = self.cursorWhenSelectionStarted, current else: result = current, self.cursorWhenSelectionStarted return result else: return tuple() def selectionExport(self): """The boundaries of the selection as two cursor export objects. So not the selected items. The selectionExport is sorted. See api.callbacks _setSelection for more documentation For the sake of simplicity a selection is only valid if the start-tick and end-tick in all tracks are the same. An invalid selection does not prevent the extension or modification of a selection itself but it prevents processing its content. """ #TODO: If this is a performance problem it has the potential to get cached. Functions like listOfSelectedItems can then use the cached value. result = self.checkSelectionOrder() if not result: return result #empty tuple topLeft, bottomRight = result startTick = topLeft["tickindex"] endTick = bottomRight["tickindex"] tracksWithSelectedItems = [track for track in self.tracks[topLeft["trackIndex"]:bottomRight["trackIndex"]+1]] #slice does not include the last value, so +1 for track in tracksWithSelectedItems: originalPosition = track.state.position() track.goToTickindex(startTick) #includes track.head() which resets the state if (not track.state.tickindex == startTick): break track.goToTickindex(endTick) if (not track.state.tickindex == endTick): break track.toPosition(originalPosition) else: #not executed if break. Means everything was all right. return (True, topLeft, bottomRight) #We only get here through a for-loop break, which means the selection is invalid. But we still export the selection boundaries for the GUI #Reset the last track track.toPosition(originalPosition) #has head() in it return (False, topLeft, bottomRight) def listOfSelectedItems(self, removeContentLinkedData): """ validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = returnedStuff Most important is the data, which is in selectedTracksAndItems and has this format: [ [(object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), ...], #track 1 [(object, {"keySignature": keysigobject}), (object, {"keySignature": keysigobject}), ...], #track 2 ] Please note that the 2nd in the tuple, the cached track state, will be taken out by copyObjects. If you want to create a buffer by hand (like the midi module does) just use a list of track-lists with items in it Block boundaries are ignored. Just the content. We keep the relative position to each other and the relative tracks. Eleminates duplicate items. That means content-linked block.data is only in here once. Use this for processing, not for analysis. Also don't depend on len() or use a for item in ... loop as counter. You can deactivate this removal by setting the parameter removeContentLinkedData to False. self.copyObjects() does this. There is no processing in this function. All items stay the same and subsequently have the same durations, positions and tickindices. When it gets to processing you need to be careful because those values change. The only value which is constant is the topLeft tickindex and position. We take care of duration-changed content linked items left of the selection as well. """ t = self.selectionExport() #is there a selection at all? if not t: return [False, None, None, [], []] #mimickes the true result. the api can use this to decide to do nothing instead of processing the selection else: selectionValid, topLeft, bottomRight = t if not selectionValid: return [False, topLeft, bottomRight, [], []] #mimics the result format. the api can use this to decide to do nothing instead of processing the selection startTick = topLeft["tickindex"] endTick = bottomRight["tickindex"] seenItems = set() #keep duplicate items, aka. content link block data, out of the result. tracksWithSelectedItems = [track for track in self.tracks[topLeft["trackIndex"]:bottomRight["trackIndex"]+1]] #slice does not include the last value, so +1 #listOfChangedTrackIds = set([id(track) for track in tracksWithSelectedItems]) #this is not the same as tracksWithSelectedItems. It gets more ids than the initial ones here. A content-linked note outside a selected track will still be affected. We start with the trackIds we already know. listOfChangedTrackIds = set() finalResult = [selectionValid, topLeft, bottomRight, listOfChangedTrackIds] #listOfChangedTrackIds is mutable. It will change in the loop below firstRound = True for track in tracksWithSelectedItems: originalPosition = track.state.position() result = [] if firstRound: #first track must be accurate. The user selected a specific item, find that, not the tick position which may include zero duration items track.toItemInBlock(topLeft["item"], topLeft["block"]) firstRound = False else: #all other tracks are only approximations track.goToTickindex(startTick, skipOverZeroDurations = False) #includes track.head() which resets the state while track.state.tickindex <= endTick: #we must use the tickindex here because we do not know the end items of the tracks r = track.right() if r == 1: # the item we want is already left of the cursor. So we want the previous item. if track.state.tickindex <= endTick: #yes, it is correct that the state in the parameter is ahead by one position. Why is it that way? Because everything that matters, like new dynamics will only be parsed afterwards. The trackState is always correct except for the tickindex when exporting after parsing. Thats why exportObject sometimes substracts its own duration for finding its starting tick. item = track.previousItem() if (not removeContentLinkedData) or (not item in seenItems): result.append((item, {"keySignature" : track.state.keySignature()})) #The keysig is needed for undo operations after apply to selection seenItems.add(item) for parentBlock in item.parentBlocks: #TODO: just be naive and add them all for now. Performance can be improved later. listOfChangedTrackIds.add(id(parentBlock.parentTrack)) else: continue elif r == 2: #block end. Again, this is already the previous state. right now we are in the next block already. continue else: #When does this happen again? break track.toPosition(originalPosition) #has head() in it finalResult.append(result) #finalResult[4:] is only lists(tracks) with items return finalResult #The next two functions have "Objects" in their name so copy gets not confused with block.copy or item.copy. This is Ctrl+C as in copy and paste. #Cut is done in the api module def copyObjects(self, writeInSessionBuffer = True): """This is Ctrl+c Only works with valid selections. We can assume the same tick-duration in all tracks. We can also assume that tracks and objects are continuous. There are no gaps in the data. for external functions: len(result) gives the number of tracks. You can just return the buffer without overwriting the clipboard if you set writeInBuffer to False. api.duplicate uses this. """ #selectedItems = self.listOfSelectedItems(removeContentLinkedData = False) #list of tracks with selected objects. no block data, just the objects. validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = self.listOfSelectedItems(removeContentLinkedData = False) result = [] if validSelection: for track in selectedTracksAndItems: result.append([item.copy() for item, cachedTrackState in track]) if writeInSessionBuffer: self.copyObjectsBuffer = result return result else: #self.copyObjectsBuffer = [] #maybe that is a bit harsh. If you press Cltr+C by accident, without a selection, you loose your copied data. This is especially bad for cut and paste return [] def pasteObjects(self, customBuffer = None, overwriteSelection = True): """This is ctrl+v Rules: If you have copied n tracks you need at least n-1 tracks below the current. In other words: The number of tracks you copied is the number of tracks you paste. There needs to be a cursor position, aka slot, at exactly the current tickindex in all tracks you are going to paste to. Since the copied area is always a 'rectangle' with the same tick-duration in each track we want this to be a perfect paste as well: everything starts and ends at the same position. """ if (not self.copyObjectsBuffer) and (not customBuffer): return False if customBuffer: workBuffer = customBuffer else: workBuffer = self.copyObjectsBuffer #First do a few tests if we are allowed to paste ######################################################## tickStart = self.currentTrack().state.tickindex trackIndexStart = self.trackIndex startPosition = self.where() #trackIndex, blockindex, localCursorIndexInBlock startItem = self.currentItem() #Check if there are enough tracks below if len(workBuffer)+trackIndexStart > len(self.tracks): #not enough tracks broken = True #Don't leave this function here because we need to return to the start position below. else: broken = False if not broken: #Check if all tracks have a slot at our tickindex for track in workBuffer: curTrack = self.currentTrack() curTrack.goToTickindex(tickStart) if not curTrack.state.tickindex == tickStart: #no slot #not the same tick positions broken = True break self.trackDown() else: broken = False #return #while not self.trackIndex == trackIndexStart: # self.trackUp() #self.currentTrack().goToTickindex(tickStart) self.goTo(*startPosition) #has assertions as test within if broken: return False #Up to this point no modifications were made. The cursor is in the same position as it was before. ######################################################### #We are ready to paste. There are enough tracks and an empty slots at exactly the tickindex we need. if self.cursorWhenSelectionStarted and overwriteSelection: listOfChangedTrackIdsFromDelete = self.deleteSelection() startPosition = self.where() startItem = self.currentItem() #TODO: check if the cursor is still in the correct position after delete selection listOfChangedTrackIds = set() for number, track in enumerate(workBuffer): curTrack = self.currentTrack() #Here comes an additional condition. Most of the time pastes are single-track. If so, or we simply are in the starting track, we want to paste at the actual position, not the tickindex. This circumvents problems with block boundaries and zero-duration items. #TODO: In the future add a "smart paste" that checks if the cursor starts at the beginning or at the end of zero duration items or a block boundary and try to match that position in the other tracks, eventhough we can't be certain if these conditions actually match if self.trackIndex == trackIndexStart: self.goTo(*startPosition) #has assertions as test within else: curTrack.goToTickindex(tickStart) #assert curTrack.state.tickindex == tickStart #does not work with overwrite selection for item in track: newItem = item.copy() curTrack.insert(newItem) #eventhough copyObjectsBuffer is already a copy of the original notes we don't want every paste to be the same instances. #newItem has now a parentBlock and a parentTrack. We add this parent track to the list of changed Tracks listOfChangedTrackIds.update(newItem.parentTrackIds) #TODO: profiling. All items in a block have the same parentTrack. This is highly redundant BUT can be different for items which originated from block boundaries if not number+1 == len(workBuffer): #enumerate from 0, len from 1. #we have to prevent the track going down one too far in the last step and messing with the tick index though. self.trackDown() #once again, return to the start track and go to the final tick index. Make the cursor ready to paste again in succession. finalTickIndex = self.currentTrack().state.tickindex while not self.trackIndex == trackIndexStart: self.trackUp() assert self.trackIndex == trackIndexStart == startPosition[0] assert self.currentTrack().state.tickindex == finalTickIndex #this is not enough to pinpoint the real location. #Return to the item where pasting starting. Pasting counts as insert, and insert sticks to the item right of cursor. #Therefore we go to the starting track and block, but not to the starting localCursorIndexInBlock. Instead we search for the specific item in a second step self.goTo(trackIndex=startPosition[0], blockindex=startPosition[1], localCursorIndexInBlock=0) self.goToItemInCurrentBlock(startItem) #we actually want to be at the same item where we started, not just the tick index which gets confused with block boundaries and zero duration items try: #overwrite? #TODO: What is that? assert with AssertionError pass? assert listOfChangedTrackIds == listOfChangedTrackIdsFromDelete except AssertionError: pass except UnboundLocalError: pass return listOfChangedTrackIds def deleteSelection(self): """Mortals, Hear The Prophecy: Delete Selection is notoriously tricky. It was hard in Laborejo 1 and it is still hard here. Please use as many asserts and tests as you possibly can. The next bug or regression WILL come. And it will be here.""" validSelection, topLeftCursor, bottomRightCursor, listOfChangedTrackIds, *selectedTracksAndItems = self.listOfSelectedItems(removeContentLinkedData = True) #selectedTracksAndItems format: [track1[(item, itsKeysig), (item, itsKeysig), (item, itsKeysig)], track2[(item, itsKeysig), (item, itsKeysig), (item, itsKeysig)]] if validSelection: numberOfTracks = len(selectedTracksAndItems) firstSelectedItem = selectedTracksAndItems[0][0][0] lastSelectedItem = selectedTracksAndItems[-1][-1][0] #We need to find the topLeft selected item. This is NOT goToSelectionStart() which would be the actual starting point of the user, which includes bottomRight. #go to first selected track, starting tick position. selections are always top left to bottom right while not self.trackIndex == topLeftCursor["trackIndex"]: self.trackUp() #self.currentTrack().goToTickindex(topLeftCursor["tickindex"], skipOverZeroDurations = False) self.currentTrack().toItemInBlock(topLeftCursor["item"], topLeftCursor["block"]) #includes track.head() which resets the state #print ("start deleting from", topLeftCursor["item"], "to BEFORE this item", bottomRightCursor["item"]) assert topLeftCursor["track"] is self.currentTrack(), (topLeftCursor["track"], self.currentTrack()) assert topLeftCursor["block"] is self.currentTrack().currentBlock(), (topLeftCursor["block"], self.currentTrack().currentBlock()) assert topLeftCursor["item"] is self.currentTrack().currentItem(), (topLeftCursor["item"], self.currentTrack().currentItem()) assert topLeftCursor["itemId"] == id(self.currentTrack().currentItem()), (topLeftCursor["itemId"], id(self.currentTrack().currentItem())) if topLeftCursor["item"]: assert topLeftCursor["item"] is firstSelectedItem, (topLeftCursor["item"], firstSelectedItem) #else: yet unknown set of scenarios. One known is that the selection was from right to left, included normal duration items but ends on a block split which is at the tracks beginning. most likely a leftover from some deletion. #NO! bottomRight is not the last selected item but the first item that is not selected anymore!! assert bottomRightCursor["item"] is lastSelectedItem, (bottomRightCursor["item"], lastSelectedItem) assert self.currentTrack().currentBlock().localCursorIndex == topLeftCursor["localCursorIndex"], (self.currentTrack().currentBlock().localCursorIndex, topLeftCursor["localCursorIndex"]) firstRound = True for number, selectionTrack in enumerate(selectedTracksAndItems): curTrack = self.currentTrack() if firstRound: #In the first track the user selected a specific item, which may be zero duration or not. We must delete this, not more not less. All other tracks are approximations and can work with the tickindex. self.currentTrack().toItemInBlock(topLeftCursor["item"], topLeftCursor["block"]) #includes track.head() which resets the state firstRound = False else: curTrack.goToTickindex(topLeftCursor["tickindex"], skipOverZeroDurations = False) assert curTrack.state.tickindex == topLeftCursor["tickindex"], (curTrack.state.tickindex, topLeftCursor["tickindex"]) #We delete always from the same cursor position. After one deletion the rest items gravitate left to our position. Except block boundaries, those will not be crossed so we have to check for them: for item, cached6State in selectionTrack: if item is bottomRightCursor["item"]: break #without this the program will delete the first non-selected item, if zero duration, as well endOfTrackBreaker = False while curTrack.currentBlock().isAppending(): #Block end, or even track end if not curTrack.right(): #end of track. endOfTrackBreaker = True break if endOfTrackBreaker: break assert curTrack.currentItem() is item, (curTrack.currentItem(), item) #since listOfSelectedItems already removed all duplicate items this will work. curTrack.delete() # side effect: delete the actual item. returns none on block boundary. #Don't go down if we are already in the last track of the selection if not number+1 == numberOfTracks: #enumerate from 0, len from 1. self.trackDown() #assert curTrack.state.tickindex == topLeftCursor["tickindex"] We cannot be sure of this. The selection could have started at a content-linked item that got deleted and the tickindex has no item anymore #return [id(track) for track in self.tracks] #TODO: only for debug reasons. the GUI will draw everything with this! Either this function or the api has to figure out the tracks that really changed. we could do a len check of the blocks data. return listOfChangedTrackIds def getBlockAndItemOrder(self): """ Docstring for getBlockAndItemOrder and putBlockAndItemOrder. getBlockAndItemOrder creates a complete snapshot of the whole score. Since saving tracks and blocks in the usual musical order of magnitude is no performance-problem for python. It can be put back in the score with putBlockAndItemOrder. We use this for undo/redo of deleteSelection and paste. Even after items got deleted deleteSelection, they are still avaible to get re-inserted as long as the data was kept around somewhere, for example in the undo/redo stack. Effectively we discard any additional items introduced by paste or restore lost items through deleteSelection. This is only the order of items, not the state of the items, but since undo/redo works lineary there can be no changes to the items left. They would have been undone before this step was possibly at all. It is very important to be aware of content-linked blocks data. If there is such a block in the given data all tracks with its link must be in dictWithTrackIDsAndDataLists as well, with the data already linked again. This is easy when we save a complete snapshot instead of selectively choosing tracks. However, the api callbacks must only update tracks that really changed, not the whole score. This would be far too expensive for a GUI. Other functions do not need to filter the dict when using putBlockAndItemOrder. It looks for true changes itself and returns a list of trackIDs that need callback updates. No blocks will be deleted or replaced with this because deleteSelection leaves empty blocks and paste is not capable of creating new blocks. """ result = {} #track id, blockAndItemOrder seen = {} # originalDataId : sliceCopy. Takes care of content linked blocks data. for track in self.tracks: newWorldOrder = [] for block in track.blocks: blockDataId = id(block.data) if not blockDataId in seen: seen[blockDataId] = block.data[:] newWorldOrder.append(seen[blockDataId]) result[id(track)] = newWorldOrder return result def putBlockAndItemOrder(self, dictWithTrackIDsAndDataLists): """see getBlockAndItemOrder for documentation""" for trackId, listWithBlocksData in dictWithTrackIDsAndDataLists.items(): assert len(self.trackById(trackId).blocks) == len(listWithBlocksData) #paste and delete selection never delete or create blocks. So the len is always the same. we can zip for block, parameterBlockData in zip(self.trackById(trackId).blocks, listWithBlocksData): block.data = parameterBlockData return [id(track) for track in self.tracks] #TODO: only for debug reasons. the GUI will draw everything with this! Either this function or the api has to figure out the tracks that really changed. we could do a len check of the blocks data. def listOfTrackIds(self): """Mostly used for the initial callback sweep after loading a new file in the api.""" #return Track.allTracks.keys() #this does contain deleted tracks (in the undo stack) as well. It is only valid when the program starts up. The undo stack is empty then. #assert len (Track.allTracks) == len(self.tracks) #make sure to only call this function when it is correct. If we ever use this for non-startup reasons change to the list comprehension below. #return Track.allTracks.keys() return [id(track) for track in self.tracks] #tracks in the score, not in the undo stack. def listOfStaticTrackRepresentations(self): """A list of tracks""" result = [] for track in self.tracks: static = track.staticTrackRepresentation() static["hiddenPosition"] = None result.append(static) return result def listOfStaticHiddenTrackRepresentations(self): """A list of hidden tracks. Mutually exclusive with listOfStaticTrackRepresentations """ result = [] for track, originalPosition in self.hiddenTracks.items(): static = track.staticTrackRepresentation() static["hiddenPosition"] = originalPosition result.append(static) return result def graphBlockById(self, blockId): for track in self.tracks: for cc, graphTrackCC in track.ccGraphTracks.items(): for graphBlock in graphTrackCC.blocks: if id(graphBlock) == blockId: return id(track), cc, graphBlock else: raise ValueError("graphBlock with this id not in any track") def graphItemById(self, graphItemId): for track in self.tracks: for cc, graphTrackCC in track.ccGraphTracks.items(): try: graphBlock, graphItem = graphTrackCC.graphItemById(graphItemId) return id(track), cc, graphBlock, graphItem except ValueError: #raised when not in thie cc/track pass else: raise ValueError("graphItem with this id not in any track") def goTo(self, trackIndex, blockindex, localCursorIndexInBlock): """Handles even shifting positions in a content link situation""" self.trackIndex = trackIndex self.currentTrack().toBlockAndLocalCursorIndex(blockindex, localCursorIndexInBlock) assert self.where() == (trackIndex, blockindex, localCursorIndexInBlock) def where(self): """return the data needed by self.goto where even keeps the local position if a content linked block inserts items before our position """ return self.trackIndex, self.currentTrack().state.blockindex, self.currentTrack().currentBlock().localCursorIndex def goToItemInCurrentBlock(self, itemInstance): """Can also be None for the next block-end/appending """ self.currentBlock().goToItem(itemInstance) #Deprecated #def goToItem(self, itemInstance): # """aka search for item. Item instances are unique across a session""" # this is quite expensive. Most of the time we already know the track and block. Use score.goTo with localCUrsorIndexInBlock=0 def allItems(self, hidden = True, removeContentLinkedData = False): seenItems = set() if hidden: what = Track.allTracks.values() #this includes hidden tracks else: what = self.tracks for track in what: for block in track.blocks: for item in block.data: if (not removeContentLinkedData) or (not item in seenItems): seenItems.add(item) yield item def transposeScore(self, rootPitch, targetPitch): """Based on intervalAutomatic""" for item in self.allItems(removeContentLinkedData=True): item.intervalAutomatic(rootPitch, targetPitch) def joinCurrentBlockAndAllContentLinks(self): """This is in score and not in track because it works on content links across tracks as well Take pairs of blocks current+next and all its content links in the score and join them together. The blocks will get the meta-information of the first block. a) Every content linked block to the current one is followed by a block which is content-linked itself to all other follow-ups. b) The current block has no content links. This is really just special case of a) All content links that are followed by their own link are caught as well. Logic dictates that at least one of the content links will have either no follow up (end of track) or a different follow up. Both are cases that will prevent a join from happening. This function does not actually join the blocks but creates new blocks that will replace the old pairs. We then return a data structure that api.rearrangeBlocksInMultipleTracks can use to create the new order. The old blocks are kept for undo and redo. dictOfTrackIdsWithListOfBlockIds is [trackId] = [listOfBlockIds] """ startItem = self.currentTrack().currentItem() startBlock = self.currentTrack().currentBlock() workingList = [] #pairs of (block, followUpBlock) try: startFollowUpBlock = startBlock.parentTrack.blocks[startBlock.parentTrack.blocks.index(startBlock) +1] #the follow up for the current block. except: #current block already without follow up return False #already no follow up. Leave. #Get the blocks which are actually in the score and not just a reference and compare their lengths. if not len([True for strtblk in startBlock.linkedContentBlocks if strtblk._parentTrack]) == len([True for flwupblk in startFollowUpBlock.linkedContentBlocks if flwupblk._parentTrack]): #startBlock and FollowUp content link registers do not have same length return False for block in startBlock.linkedContentBlocks: #all linked blocks in all tracks, including currentBlock. All blocks are unique. The content is not. blockIndex = block.parentTrack.blocks.index(block) try: followUpBlock = block.parentTrack.blocks[blockIndex +1] except IndexError: #there is no follow up block. This violates our two requirements. #block without follow up found return False else: #the block is there. continue: if not followUpBlock in startFollowUpBlock.linkedContentBlocks: #not all follow up blocks are the same content return False else: workingList.append((block, followUpBlock)) #we now have made it clear that the requirements from the docstring are met. dictOfTrackIdsWithListOfBlockIds = {} protoBlock = startBlock.copy(newParentTrack = self) protoBlock.data += startFollowUpBlock.data for firstBlock, secondBlock in workingList: parentTrackId = id(firstBlock.parentTrack) if parentTrackId in dictOfTrackIdsWithListOfBlockIds: blockOrder = dictOfTrackIdsWithListOfBlockIds[parentTrackId] else: blockOrder = firstBlock.parentTrack.asListOfBlockIds() uniqueBlockForThisPosition = firstBlock.copy(newParentTrack = firstBlock.parentTrack) uniqueBlockForThisPosition.name = uniqueBlockForThisPosition.name[:-5] #remove "-copy" uniqueBlockForThisPosition.data = protoBlock.data uniqueBlockForThisPosition.linkedContentBlocks = protoBlock.linkedContentBlocks uniqueBlockForThisPosition.linkedContentBlocks.add(uniqueBlockForThisPosition) assert id(uniqueBlockForThisPosition) in Block.allBlocks blockOrder[blockOrder.index(id(firstBlock))] = id(uniqueBlockForThisPosition) assert not id(firstBlock) in blockOrder #We don't need to actually delete the blocks here, this will be done indirectly by rearrangeBlocks through the api later. blockOrder.remove(id(secondBlock)) assert not id(secondBlock) in blockOrder dictOfTrackIdsWithListOfBlockIds[parentTrackId] = blockOrder #eventhough it is mutable and therefore changes in the list are reflected in the dict-value we did not make sure that the list is in the dict in the first place above. protoBlock.linkedContentBlocks.remove(protoBlock) del(protoBlock) return dictOfTrackIdsWithListOfBlockIds def splitCurrentBlockAndAllContentLinks(self): """split the current block around the cursor position. If used in an appending position it just creates a new block. The new block gets inserted directly right of each original content-linked block. For the user this looks like all contentLinked blocks got splitted in half, but they remain content linked. """ track = self.currentTrack() startBlock = track.currentBlock() #this is the only unique thing we can rely on. dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds] protoBlockLeft = startBlock.copy(track) protoBlockLeft.data = startBlock.data[:startBlock.localCursorIndex] #everything left of the cursor. The local cursor index of the current block is guaranteed to be correct. The one from other blocks is not. protoBlockRight = startBlock.copy(track) protoBlockRight.data = startBlock.data[startBlock.localCursorIndex:] #everything right of the cursor. The local cursor index of the current block is guaranteed to be correct. The one from other blocks is not. for block in startBlock.linkedContentBlocksInScore(): #all linked blocks in all tracks, including currentBlock. All blocks are unique. The content is not. parentTrackId = id(block.parentTrack) if parentTrackId in dictOfTrackIdsWithListOfBlockIds: blockOrder = dictOfTrackIdsWithListOfBlockIds[parentTrackId] else: blockOrder = block.parentTrack.asListOfBlockIds() newLeft = protoBlockLeft.contentLink() newLeftId = id(newLeft) newLeft.name = block.name newLeft.minimumInTicks = block.minimumInTicks / 2 assert newLeftId in Block.allBlocks assert newLeft.parentTrack is block.parentTrack assert newLeft in protoBlockLeft.linkedContentBlocks newRight = protoBlockRight.contentLink() newRightId = id(newRight) newRight.name = block.name newRight.minimumInTicks = newLeft.minimumInTicks assert newRightId in Block.allBlocks assert newRight.parentTrack is block.parentTrack assert newRight in protoBlockRight.linkedContentBlocks positionToInsert = blockOrder.index(id(block)) blockOrder[positionToInsert] = newLeftId #replaces the original block which will be kept intact for undo blockOrder.insert(positionToInsert + 1, newRightId) dictOfTrackIdsWithListOfBlockIds[parentTrackId] = blockOrder #eventhough it is mutable and therefore changes in the list are reflected in the dict-value we did not make sure that the list is in the dict in the first place above. protoBlockLeft.linkedContentBlocks.remove(protoBlockLeft) #probably redundant. But makes the intention clear. protoBlockRight.linkedContentBlocks.remove(protoBlockRight) del(protoBlockLeft) del(protoBlockRight) return dictOfTrackIdsWithListOfBlockIds def removeEmptyBlocks(self): dictOfTrackIdsWithListOfBlockIds = {} # [trackId] = [listOfBlockIds] for trId, track in Track.allTracks.items(): listOfBlockIds = track.asListOfBlockIds() for block in track.blocks: if not block.data and len(track.blocks) > 1: listOfBlockIds.remove(id(block)) dictOfTrackIdsWithListOfBlockIds[trId] = listOfBlockIds return dictOfTrackIdsWithListOfBlockIds def lilypond(self): """Entry point for converting the score into lilypond. Called by session.saveAsLilypond(), returns a string, which is the file content. What is happening here? Only visible tracks get exported. Each object controls what it exports. Overrides are possible at every level. """ tempoStaff = self.tempoTrack.lilypond() data = {track:track.lilypond() for track in self.tracks} #processed in the lilypond module return fromTemplate(session = self.parentSession, templateFile = "default.ly", data = data, meta = self.metaData, tempoStaff = tempoStaff) #Save / Load / Export def serialize(self)->dict: dictionary = super().serialize() dictionary["class"] = self.__class__.__name__ dictionary["tracks"] = [track.serialize() for track in self.tracks] #we can't save hiddenTracks as dict because the serialized track is a dict itself, which is not hashable and therefore not a dict-key. dictionary["hiddenTracks"] = [[track.serialize(), originalIndex] for track, originalIndex in self.hiddenTracks.items()] dictionary["tempoTrack"] = self.tempoTrack.serialize() dictionary["metaData"] = self.metaData dictionary["currentMetronomeTrackIndex"] = self.tracks.index(self.currentMetronomeTrack) return dictionary @classmethod def instanceFromSerializedData(cls, parentSession, serializedData): assert cls.__name__ == serializedData["class"] self = cls.__new__(cls) super().copyFromSerializedData(parentSession, serializedData, self) #Tracks, parentSession and tempoMap self.parentSession = parentSession self.hiddenTracks = {Track.instanceFromSerializedData(track, parentData = self):originalIndex for track, originalIndex in serializedData["hiddenTracks"]} self.tempoTrack = TempoTrack.instanceFromSerializedData(serializedData["tempoTrack"], parentData = self) self.metaData = serializedData["metaData"] self.currentMetronomeTrack = self.tracks[serializedData["currentMetronomeTrackIndex"]] self._processAfterInit() return self def export(self)->dict: dictionary = super().export() return { "numberOfTracks" : len(self.tracks), "howManyUnits" : self.howManyUnits, "whatTypeOfUnit" : self.whatTypeOfUnit, "numberOfMeasures" : self.numberOfMeasures, "measuresPerGroup" : self.measuresPerGroup, "subdivisions" : self.subdivisions, "isTransportMaster" : self.tempoMap.export()["isTransportMaster"], }