You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
373 lines
16 KiB
373 lines
16 KiB
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )
|
|
|
|
This file is part of Laborejo ( https://www.laborejo.org )
|
|
|
|
Laborejo 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 <http://www.gnu.org/licenses/>.
|
|
"""
|
|
|
|
import logging; logger = logging.getLogger(__name__); logger.info("import")
|
|
|
|
from weakref import WeakSet
|
|
from weakref import ref as weakref_ref
|
|
from .items import * #loading from file needs all items.
|
|
|
|
class Block(object):
|
|
#allBlocks = WeakValueDictionary() #key is the blockId, value is the weak reference to the Block
|
|
allBlocks = {} #key is the blockId, value is the Block. This is a one way dict. It gets never deleted so undo can recover old blocks. Since blocks are unique this is no problem.
|
|
#NEVER!! iteratre over allBlocks if you don't know what you are doing. This contains deleted blocks as well.
|
|
firstBlockWithNewContentDuringDeserializeToObject = dict() #this is not resetted anywhere since each load is a program start.
|
|
|
|
def __init__(self, track):
|
|
self.data = list()
|
|
self._name = [ str(id(self)) ] #list of len==1 so it is mutable for linked blocks. See @property below
|
|
self._minimumInTicks = [0] # if this is bigger than the actual duration-sum of the content this will be used instead. Advice: best used in the form of x*210 (multiple of real base-durations) #is content linked, thats why it is a mutable list of len==1. See @property below
|
|
self.linkedContentBlocks = WeakSet() #only new standalone blocks use this empty WeakSet. once you contentLink a block it will be overwritten.
|
|
self._secondInit(parentTrack = track)
|
|
|
|
def _secondInit(self, parentTrack):
|
|
"""see Score._secondInit"""
|
|
self._parentTrack = weakref_ref(parentTrack) #a block can only be in one track.
|
|
self.localCursorIndex = 0
|
|
self.linkedContentBlocks.add(self)
|
|
self.rememberBlock()
|
|
|
|
@property
|
|
def name(self):
|
|
"""name must be mutable so it is shared between content link blocks"""
|
|
return self._name[0]
|
|
|
|
@name.setter
|
|
def name(self, name:str):
|
|
listId = id(self._name) #just a precaution
|
|
self._name.pop()
|
|
self._name.append(name)
|
|
assert len(self._name) == 1
|
|
assert listId == id(self._name)
|
|
|
|
@property
|
|
def minimumInTicks(self):
|
|
"""minimumInTicks must be mutable so it is shared between content link blocks"""
|
|
return self._minimumInTicks[0]
|
|
|
|
@minimumInTicks.setter
|
|
def minimumInTicks(self, newValue:int):
|
|
"""Keep the mutable list at all cost"""
|
|
listId = id(self._minimumInTicks) #just a precaution
|
|
self._minimumInTicks.pop()
|
|
self._minimumInTicks.append(newValue)
|
|
assert len(self._minimumInTicks) == 1
|
|
assert listId == id(self._minimumInTicks)
|
|
|
|
@classmethod
|
|
def instanceFromSerializedData(cls, serializedObject, parentObject):
|
|
"""see Score.instanceFromSerializedData"""
|
|
assert cls.__name__ == serializedObject["class"]
|
|
self = cls.__new__(cls)
|
|
if serializedObject["data"] is None: #Found a content linked block which already has one member of its group in the score
|
|
firstBlock = Block.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] #block with the same contentGroup. This is the one with the real data.
|
|
self.data = firstBlock.data
|
|
self._minimumInTicks = firstBlock._minimumInTicks #mutable list
|
|
self._name = firstBlock._name #mutable list
|
|
self.linkedContentBlocks = firstBlock.linkedContentBlocks #add self to this is in _secondInit
|
|
else: #found a stand-alone block or the first one of a content link group
|
|
self.linkedContentBlocks = WeakSet()
|
|
self.data = [eval(item["class"]).instanceFromSerializedData(item, parentObject = self) for item in serializedObject["data"]]
|
|
self._name = [ str(serializedObject["name"]) ] #saved as string, used as list
|
|
self._minimumInTicks = [ int(serializedObject["minimumInTicks"]) ] #saved as int, used as list
|
|
Block.firstBlockWithNewContentDuringDeserializeToObject[serializedObject["contentLinkGroup"]] = self
|
|
for item in self.data:
|
|
item.parentBlocks.add(self)
|
|
self._secondInit(parentTrack = parentObject)
|
|
return self
|
|
|
|
def serialize(self):
|
|
"""Return a serialized data from this instance.
|
|
Used for save and load.
|
|
|
|
Can be called in a chain by subclasses with super().serialize
|
|
|
|
The main difference between serialize and exportObject is that
|
|
serialize does not compute anything. I just saves the state
|
|
without calulating note on and off or stem directions,
|
|
for example.
|
|
"""
|
|
#result = super()._serialize() #call this in child classes
|
|
result = {}
|
|
result["class"] = self.__class__.__name__
|
|
result["name"] = self.name
|
|
result["minimumInTicks"] = self.minimumInTicks
|
|
|
|
#We only save the data if this is the first content-linked block in a sequence.
|
|
contentLinkGroupId = id(self.data)
|
|
result["contentLinkGroup"] = contentLinkGroupId
|
|
|
|
for block in self.parentTrack.parentData.allBlocks():
|
|
blockId = id(block)
|
|
dataId = id(block.data)
|
|
if dataId == contentLinkGroupId:
|
|
if blockId == id(self): #first block with this dataId found.
|
|
result["data"] = [item.serialize() for item in self.data]
|
|
else: #content linked, but not the first. Block already serialized.
|
|
result["data"] = None #we don't need to do anything more. The rest is handled by load and instanceFromSerializedData
|
|
break
|
|
#else:
|
|
#loop ran through. This never happens.
|
|
return result
|
|
|
|
def rememberBlock(self):
|
|
oid = id(self)
|
|
Block.allBlocks[oid] = self #This is on the score level, or a global level. That means we don't need to change this, even if the track gets moved to a new track by the api.
|
|
#weakref_finalize(self, print, "deleted block "+str(oid))
|
|
return oid
|
|
|
|
@property
|
|
def parentTrack(self):
|
|
if self._parentTrack:
|
|
return self._parentTrack() #return the real parentTrack
|
|
else:
|
|
assert self._parentTrack is None
|
|
return self._parentTrack
|
|
|
|
@parentTrack.setter
|
|
def parentTrack(self, newTrack):
|
|
if not self._parentTrack: #because it was None/not in a track
|
|
self._parentTrack = WeakSet()
|
|
if newTrack:
|
|
self._parentTrack = weakref_ref(newTrack)
|
|
else:
|
|
self._parentTrack = None
|
|
|
|
def copy(self, newParentTrack, nameSuffix="-copy"):
|
|
"""Return an independet copy of this block.
|
|
It will not be inserted into a track here but in the parentTrack
|
|
|
|
It is by design only possible that a block will be inserted
|
|
in the same track, next to the original block.
|
|
It can be moved later by the api module which will reset
|
|
the parentTrack. But at first the parentTrack stays the same."""
|
|
new = Block(newParentTrack)
|
|
assert newParentTrack
|
|
|
|
#Do not change new.linkedContentBlocks! Copy creates a stand alone copy.
|
|
assert len(new.linkedContentBlocks) == 1
|
|
|
|
for item in self.data:
|
|
copyItem = item.copy()
|
|
new.data.append(copyItem)
|
|
|
|
assert not copyItem.parentBlocks #parentBlock was empty until now
|
|
copyItem.parentBlocks.add(new)
|
|
if self in copyItem.parentBlocks: #TODO: investigate
|
|
copyItem.parentBlocks.remove(self)
|
|
|
|
if self.name.endswith(nameSuffix): #name is mutable, but getter and setter deal with that.
|
|
new.name = self.name
|
|
else:
|
|
new.name = self.name + nameSuffix
|
|
new._minimumInTicks = self._minimumInTicks[:] #it is a mutable value, we make a copy of the list (which has an int inside, which is immutable and copied automatically)
|
|
return new
|
|
|
|
def getUnlinkedData(self):
|
|
"""
|
|
Returns a new list with copies of items.
|
|
Set and handled for undo/redo by the api.
|
|
"""
|
|
newData = []
|
|
newParentBlocks = WeakSet()
|
|
newParentBlocks.add(self)
|
|
|
|
for item in self.data:
|
|
copyItem = item.copy()
|
|
newData.append(copyItem)
|
|
copyItem.parentBlocks = newParentBlocks
|
|
|
|
assert len(newData) == len(self.data)
|
|
return newData
|
|
|
|
|
|
def getDataAsDict(self):
|
|
return { "name" : self.name,
|
|
"minimumInTicks" : self.minimumInTicks,
|
|
"id" : id(self), #read only. Not applied in putDataFromDict
|
|
}
|
|
|
|
def putDataFromDict(self, dataDict):
|
|
"""modify inplace. Useful for a gui function. Compatible with
|
|
the data from getDataAsDict"""
|
|
self.name = dataDict["name"]
|
|
self.minimumInTicks = dataDict["minimumInTicks"]
|
|
|
|
def contentLink(self):
|
|
"""Return a copy where only certain parameters
|
|
like Content are linked. Others can be changed.
|
|
It will not be inserted into a track here but in the parentTrack
|
|
|
|
It is by design only possible that a block will be inserted
|
|
in the same track, next to the original block.
|
|
It can be moved later by the api module which will reset
|
|
the parentTrack. But at first the parentTrack stays the same.
|
|
"""
|
|
assert self.parentTrack
|
|
new = Block(self.parentTrack)
|
|
new.linkedContentBlocks = self.linkedContentBlocks
|
|
new.linkedContentBlocks.add(new)
|
|
new.data = self.data #mutable. Will change in all blocks together.
|
|
new._name = self._name #mutable as well.
|
|
new._minimumInTicks = self._minimumInTicks #mutable
|
|
#Add the new block to the parentBlocks set but don't delete self from it, as in copy(). The items are now in more than one block simultaniously
|
|
for item in new.data:
|
|
item.parentBlocks.add(new)
|
|
return new
|
|
|
|
def linkedContentBlocksInScore(self):
|
|
"""filters linkedContentBlocks to only include
|
|
those currently in the score. Not those in the undo repository
|
|
"""
|
|
return (block for block in self.linkedContentBlocks if block.parentTrack)
|
|
|
|
def duration(self):
|
|
"""The first block might have an upbeat. We check that here and not in the track."""
|
|
|
|
actualBlockDuration = 0
|
|
for item in self.data:
|
|
actualBlockDuration += item.logicalDuration()
|
|
|
|
if actualBlockDuration >= self.minimumInTicks:
|
|
return actualBlockDuration
|
|
else:
|
|
return self.minimumInTicks
|
|
|
|
def staticExportEndMarkerDuration(self):
|
|
actualBlockDuration = 0
|
|
for item in self.data:
|
|
actualBlockDuration += item.logicalDuration()
|
|
if actualBlockDuration >= self.minimumInTicks: #this also guarantees that the substraction below is > 0.
|
|
return 0
|
|
else:
|
|
return self.minimumInTicks - actualBlockDuration #this is the difference to self.duration()
|
|
|
|
def position(self):
|
|
"""the position of the subcursor in this block"""
|
|
return self.localCursorIndex
|
|
|
|
def left(self):
|
|
if self.localCursorIndex > 0:
|
|
self.localCursorIndex -= 1
|
|
return True
|
|
else:
|
|
return False #Already at the start.
|
|
|
|
def right(self):
|
|
if not self.isAppending():
|
|
self.localCursorIndex += 1
|
|
return True
|
|
else:
|
|
return False #Already at the end.
|
|
|
|
def head(self):
|
|
self.localCursorIndex = 0
|
|
|
|
def tail(self):
|
|
self.localCursorIndex = len(self.data) #eventhough len counts from 1 and the cursorIndex from 0 we want exactly to be after the last item in data.
|
|
|
|
def goToItem(self, itemInstance):
|
|
if itemInstance:
|
|
self.head()
|
|
while self.right():
|
|
item = self.currentItem()
|
|
if item is itemInstance:
|
|
return True
|
|
else:
|
|
raise ValueError("Item not in this block. ", self, itemInstance)
|
|
elif itemInstance is None:
|
|
self.tail()
|
|
else:
|
|
raise ValueError("You must go to an item or None, not to: ", itemInstance)
|
|
|
|
|
|
def currentItem(self):
|
|
"""Can be used with goToItem"""
|
|
if not self.isAppending():
|
|
return self.data[self.localCursorIndex]
|
|
else:
|
|
return None
|
|
|
|
def previousItem(self):
|
|
"""Can be used with goToItem"""
|
|
if self.localCursorIndex > 0:
|
|
return self.data[self.localCursorIndex-1]
|
|
else:
|
|
return None
|
|
|
|
def nextItem(self):
|
|
"""Can be used with goToItem"""
|
|
if self.localCursorIndex+1 >= len(self.data): #one before appending or appending itself
|
|
return None
|
|
else:
|
|
return self.data[self.localCursorIndex+1]
|
|
|
|
def insert(self, item):
|
|
self.data.insert(self.localCursorIndex, item) #we do not need to check if appending or not. list.insert appends if the index is higher then len()
|
|
#self.localCursorIndex += 1 #we don't need to go right here because track.insert() is calling its own right() directly after insert, which triggers block.right()
|
|
item.parentBlocks.add(self)
|
|
|
|
def delete(self):
|
|
"""The commented out is the immediate garbage collector which
|
|
made sure that deleted item are not in the allNotes weakref
|
|
dict any more. However, this does not happen any more since
|
|
we introduced undo which keeps a saved version.
|
|
For consitency reasons we chose not to save a copy in the undo
|
|
register (return ...copy()) but the real item.
|
|
|
|
If this ever leads to problems with the weakref dict we must
|
|
reintroduce the copy"""
|
|
if not self.isAppending():
|
|
result = self.data[self.localCursorIndex]
|
|
del self.data[self.localCursorIndex]
|
|
#we don't need to delete self from item.parentBlocks. It is automatically deleted in all contentLinked blocks, of course including its parentBlocks WeakSet()
|
|
return result
|
|
|
|
def isAppending(self):
|
|
return self.localCursorIndex == len(self.data) #len counts from 1 and the cursorIndex from 0. So if they are the same we are one cursor position right of last item
|
|
|
|
def zeroLogicalDuration(self)->bool:
|
|
"""Return true if this has no actual items with duration in it.
|
|
A block with only text items and barlines is empty.
|
|
Minimum tick duration does not affect isEmpty"""
|
|
for item in self.data:
|
|
if item.logicalDuration() > 0:
|
|
return False
|
|
return True
|
|
|
|
|
|
def lilypond(self, carryLilypondRanges):
|
|
"""Called by track.lilypond(), returns a string.
|
|
carryLilypondRanges is handed from item to item for ranges
|
|
such as tuplets.
|
|
Can act like a stack or simply remember stuff.
|
|
"""
|
|
|
|
#Calculate padding from block minimum tick duration in D1024 notes. Lilypond doesn't care.
|
|
difference = self.staticExportEndMarkerDuration()
|
|
if difference:
|
|
skipstring = f" s1024*{int(difference / D1024)}"
|
|
else:
|
|
skipstring = ""
|
|
|
|
#Format: Block name as comment, followed by a music expression { block data }
|
|
#The music expression doesn't hurt, but is useful for repeats
|
|
return "\n % Block: " + self.name + "\n { " + " ".join(item.lilypond(carryLilypondRanges) for item in self.data) + skipstring + " } \n"
|
|
|