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

#! /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"