diff --git a/bin/keyboardcomposer b/bin/keyboardcomposer --- a/bin/keyboardcomposer +++ b/bin/keyboardcomposer @@ -120,26 +120,13 @@ class SubmasterBox(Frame): ?session :subSetting [a :SubSetting; :sub ?s; :level ?l] """ # move to syncedgraph patchMapping - with self.sub.graph.currentState() as graph: - adds = set([]) - for setting in graph.objects(self.session, L9['subSetting']): - if graph.value(setting, L9['sub']) == uri: - break - else: - setting = URIRef(self.session + "/setting/%s" % - random.randrange(999999999)) - adds.update([ - (self.session, L9['subSetting'], setting, self.session), - (setting, RDF.type, L9['SubSetting'], self.session), - (setting, L9['sub'], uri, self.session), - ]) - dels = set([]) - for prev in graph.objects(setting, L9['level']): - dels.add((setting, L9['level'], prev, self.session)) - adds.add((setting, L9['level'], Literal(level), self.session)) - if adds != dels: - self.sub.graph.patch(Patch(delQuads=dels, addQuads=adds)) + self.sub.graph.patchMapping(context=self.session, + subject=self.session, + predicate=L9['subSetting'], + nodeClass=L9['SubSetting'], + keyPred=L9['sub'], newKey=uri, + valuePred=L9['level'], newValue=Literal(level)) def updateLevelFromGraph(self): """read rdf level, write it to subbox.slider_var""" diff --git a/bin/subcomposer b/bin/subcomposer --- a/bin/subcomposer +++ b/bin/subcomposer @@ -1,6 +1,22 @@ #!bin/python +""" +subcomposer + session + observable(currentSub), a Submaster which tracks the graph + + EditChoice widget + can change currentSub to another sub + Levelbox widget + watch observable(currentSub) for a new sub, and also watch currentSub for edits to push to the OneLevel widgets + + OneLevel widget + UI edits are caught here and go all the way back to currentSub + + +""" from __future__ import division, nested_scopes +import time, logging from optparse import OptionParser import logging import Tkinter as tk @@ -10,14 +26,12 @@ from rdflib import URIRef from run_local import log from light9.dmxchanedit import Levelbox -from light9 import dmxclient, Patch, Submaster, showconfig, prof +from light9 import dmxclient, Patch, Submaster, prof from light9.uihelpers import toplevelat from light9.rdfdb.syncedgraph import SyncedGraph from light9.rdfdb import clientsession from light9.tkdnd import initTkdnd, dragSourceRegister, dropTargetRegister -log.setLevel(logging.DEBUG) - class _NoNewVal(object): pass @@ -94,6 +108,7 @@ class EditChoice(tk.Frame): def uriChanged(self, newUri): + print "chg", newUri # i guess i show the label and icon for this if newUri is Local: self.subIcon.config(text="(local)") @@ -110,70 +125,98 @@ class Subcomposer(tk.Frame): editing. If we don't have a currentSub, then we're actually editing a session-local sub called l9:currentSub + I'm not sure that Locals should even be PersistentSubmaster with + uri and graph storage, but I think that way is making fewer + special cases. + Contains an EditChoice widget - UI actions: - - drag a sub uri on here to make it the one we're editing - - - button to clear the currentSub (putting it back to - sessionLocalSub, and also resetting sessionLocalSub to be empty - again) - - - drag the sub uri off of here to send it to another receiver, but - session local sub is not easily addressable elsewhere + Dependencies: - - make a new sub: transfers the current data (from a shared sub or - from the local one) to the new sub. If you're on a local sub, - the new sub is named automatically, ideally something brief, - pretty distinct, readable, and based on the lights that are - on. If you're on a named sub, the new one starts with a - 'namedsub 2' style name. The uri can also be with a '2' suffix, - although maybe that will be stupid. If you change the name - before anyone knows about this uri, we could update the current - sub's uri to a slug of the new label. + graph (?session :currentSub ?s) -> self.currentSub + self.currentSub -> graph + self.currentSub -> self._currentChoice (which might be Local) + self._currentChoice (which might be Local) -> self.currentSub - - rename this sub: not available if you're on a local sub. Sets - the label of a named sub. Might update the uri of the named sub - if it's new enough that no one else would have that uri. Not - sure where we measure that 'new enough' value. Maybe track if - the sub has 'never been dragged out of this subcomposer - session'? But subs will also show up in other viewers and - finders. + inside the current sub: + graph -> Submaster levels (handled in Submaster) + Submaster levels -> OneLevel widget + OneLevel widget -> Submaster.editLevel + Submaster.editLevel -> graph (handled in Submaster) """ def __init__(self, master, graph, session): tk.Frame.__init__(self, master, bg='black') self.graph = graph self.session = session - self.currentSub = Submaster.PersistentSubmaster(graph, URIRef('http://hello')) - self.levelbox = Levelbox(self, graph) - self.levelbox.pack(side='top') + # this is a PersistentSubmaster (even for local) or None if we're not initialized + self.currentSub = Observable(None) - currentUri = Observable(Local) + currentUri = Observable("http://curr") def pc(val): - log.info("change viewed sub to %s", val) + print "change viewed sub to", val currentUri.subscribe(pc) - EditChoice(self, self.graph, currentUri).frame.pack(side='top') + EditChoice(self, self.graph, self._currentChoice).frame.pack(side='top') + + def setupSubChoiceLinks(self): + + def ann(): + print "currently: session=%s currentSub=%r _currentChoice=%r" % ( + self.session, self.currentSub(), self._currentChoice()) - def alltozero(): - for lev in self.levelbox.levels: - lev.setlevel(0) + @graph.addHandler + def graphChanged(): + s = graph.value(self.session, L9['currentSub']) + if s is None: + s = self.makeLocal() + self.currentSub(Submaster.PersistentSubmaster(graph, s)) - tk.Button(self, text="all to zero", command=alltozero).pack(side='top') + @self.currentSub.subscribe + def subChanged(newSub): + if newSub is None: + graph.patchObject(self.session, + self.session, L9['currentSub'], None) + return + self.sendupdate() + graph.patchObject(self.session, + self.session, L9['currentSub'], newSub.uri) - dispatcher.connect(self.sendupdate, "levelchanged") + if newSub and 'local' in newSub.uri: # wrong- use rdf:type or something? + self._currentChoice(Local) + else: + # i think right here is the point that the last local + # becomes garbage, and we could clean it up. + self._currentChoice(newSub.uri) + + dispatcher.connect(self.levelsChanged, "sub levels changed") + + @self._currentChoice.subscribe + def choiceChanged(newChoice): + if newChoice is Local: + newChoice = self.makeLocal() + if newChoice is not None: + self.currentSub(Submaster.PersistentSubmaster( + graph, newChoice)) - def switchToLocalSub(self, *args): - """ - stop editing a shared sub and go back to our local sub - """ + def levelsChanged(self, sub): + if sub == self.currentSub(): + self.sendupdate() + + def makeLocal(self): + # todo: put a type on this, so subChanged can identify it right + # todo: where will these get stored, or are they local to this + # subcomposer process and don't use PersistentSubmaster at all? + return URIRef("http://local/%s" % time.time()) + + def setupLevelboxUi(self): + self.levelbox = Levelbox(self, graph, self.currentSub) + self.levelbox.pack(side='top') - def fill_both_boxes(self, subname): - for box in [self.savebox, self.loadbox]: - box.set(subname) + tk.Button(self, text="All to zero", + command=lambda *args: self.currentSub().clear()).pack(side='top') def savenewsub(self, subname): leveldict={} @@ -184,58 +227,49 @@ class Subcomposer(tk.Frame): s=Submaster.Submaster(subname,leveldict=leveldict) s.save() - # this is going to be more like 'tie to sub' and 'untied' - def loadsub(self, subname): - """puts a sub into the levels, replacing old level values""" - s=Submaster.Submasters(showconfig.getGraph()).get_sub_by_name(subname) - self.set_levels(s.get_dmx_list()) - dispatcher.send("levelchanged") - - def toDmxLevels(self): - # the dmx levels we edit and output, range is 0..1 (dmx chan 1 is - # the 0 element) - out = {} - for lev in self.levelbox.levels: - out[lev.channelnum] = lev.currentlevel - if not out: - return [] - - return [out.get(i, 0) for i in range(max(out.keys()) + 1)] - def sendupdate(self): - dmxclient.outputlevels(self.toDmxLevels(), twisted=True) + d = self.currentSub().get_dmx_list() + dmxclient.outputlevels(d, twisted=True) -class EntryCommand(tk.Frame): - def __init__(self, master, verb="Save", cmd=None): - tk.Frame.__init__(self, master, bd=2, relief='raised') - tk.Label(self, text="Sub name:").pack(side='left') - self.cmd = cmd - self.entry = tk.Entry(self) - self.entry.pack(side='left', expand=True, fill='x') +def launch(opts, args, root, graph, session): + if not opts.no_geometry: + toplevelat("subcomposer - %s" % opts.session, root, graph, session) + + sc = Subcomposer(root, graph, session) + sc.pack() - self.entry.bind("", self.action) - tk.Button(self, text=verb, command=self.action).pack(side='left') + tk.Label(root,text="Bindings: B1 adjust level; B2 set full; B3 instant bump", + font="Helvetica -12 italic",anchor='w').pack(side='top',fill='x') + + if len(args) == 1: + # it might be a little too weird that cmdline arg to this + # process changes anything else in the same session. But also + # I'm not sure who would ever make 2 subcomposers of the same + # session (except when quitting and restarting, to get the + # same window pos), so maybe it doesn't matter. But still, + # this tool should probably default to making new sessions + # usually instead of loading the same one def action(self, *args): subname = self.entry.get() self.cmd(subname) - log.info("command %s %s", self.cmd, subname) + print "sub", self.cmd, subname - def set(self, text): - self.entry.delete(0, 'end') - self.entry.insert(0, text) + task.LoopingCall(sc.sendupdate).start(10) ############################# if __name__ == "__main__": - parser = OptionParser(usage="%prog [subname]") + parser = OptionParser(usage="%prog [suburi]") parser.add_option('--no-geometry', action='store_true', help="don't save/restore window geometry") clientsession.add_option(parser) opts, args = parser.parse_args() + logging.basicConfig(level=logging.DEBUG) + root=tk.Tk() root.config(bg='black') root.tk_setPalette("#004633") @@ -245,21 +279,7 @@ if __name__ == "__main__": graph = SyncedGraph("subcomposer") session = clientsession.getUri('subcomposer', opts) - if not opts.no_geometry: - toplevelat("subcomposer - %s" % opts.session, root, graph, session) - - sc = Subcomposer(root, graph, session) - sc.pack() - - tk.Label(root,text="Bindings: B1 adjust level; B2 set full; B3 instant bump", - font="Helvetica -12 italic",anchor='w').pack(side='top',fill='x') - - if len(args) == 1: - root.config(bg='green') # trying to make these look distinctive - sc.loadsub(args[0]) - sc.fill_both_boxes(args[0]) - - task.LoopingCall(sc.sendupdate).start(1) + graph.initiallySynced.addCallback(lambda _: launch(opts, args, root, graph, session)) root.protocol('WM_DELETE_WINDOW', reactor.stop) tksupport.install(root,ms=10) diff --git a/light9/Submaster.py b/light9/Submaster.py --- a/light9/Submaster.py +++ b/light9/Submaster.py @@ -9,7 +9,7 @@ from louie import dispatcher log = logging.getLogger('submaster') class Submaster(object): - "Contain a dictionary of levels, but you didn't need to know that" + """mapping of channels to levels""" def __init__(self, name, levels): """this sub has a name just for debugging. It doesn't get persisted. See PersistentSubmaster. @@ -67,6 +67,8 @@ class Submaster(object): def __cmp__(self, other): # not sure how useful this is + if not isinstance(other, Submaster): + return -1 return cmp(self.ident(), other.ident()) def __hash__(self): @@ -82,7 +84,8 @@ class Submaster(object): try: dmxchan = Patch.get_dmx_channel(k) - 1 except ValueError: - log.error("error trying to compute dmx levels for submaster %s" % self.name) + log.error("error trying to compute dmx levels for submaster %s" + % self.name) raise if dmxchan >= len(levels): levels.extend([0] * (dmxchan - len(levels) + 1)) @@ -124,6 +127,8 @@ class Submaster(object): class PersistentSubmaster(Submaster): def __init__(self, graph, uri): + if uri is None: + raise TypeError("uri must be URIRef") self.graph, self.uri = graph, uri self.graph.addHandler(self.setName) self.graph.addHandler(self.setLevels) @@ -148,6 +153,8 @@ class PersistentSubmaster(Submaster): self.setLevelsFromGraph() if oldLevels != self.levels: log.info("sub %s changed" % self.name) + # dispatcher too? this would help subcomposer + dispatcher.send("sub levels changed", sub=self) def setLevelsFromGraph(self): if hasattr(self, 'levels'): @@ -155,16 +162,55 @@ class PersistentSubmaster(Submaster): else: self.levels = {} for lev in self.graph.objects(self.uri, L9['lightLevel']): + log.debug(" lightLevel %s %s", self.uri, lev) chan = self.graph.value(lev, L9['channel']) name = self.graph.label(chan) if not name: - log.error("sub %r has channel %r with no name- leaving out that channel" % (self.name, chan)) + log.error("sub %r has channel %r with no name- " + "leaving out that channel" % (self.name, chan)) continue val = self.graph.value(lev, L9['level']) - self.levels[name] = float(val) + if val is None: + raise ValueError("sub %r has lightLevel %r with channel %r " + "and level %r" % (self.uri, lev, chan, val)) + log.debug(" new val %r", val) + try: + self.levels[name] = float(val) + except: + log.error("name %r val %r" % (name, val)) + raise + + def saveContext(self): + """the context in which we should save all the lightLevel triples for + this sub""" + with self.graph.currentState() as g: + try: + ctx = g.contextsForStatement( + (self.uri, RDF.type, L9['Submaster']))[0] + except IndexError: + raise ValueError( + "no context has %s rdf:type :Submaster, " + "so I don't know where to save this sub's data" % self.uri) + return ctx + + def editLevel(self, chan, newLevel): + self.graph.patchMapping(self.saveContext(), + subject=self.uri, predicate=L9['lightLevel'], + nodeClass=L9['ChannelSetting'], + keyPred=L9['channel'], newKey=chan, + valuePred=L9['level'], + newValue=Literal(newLevel)) + + def clear(self): + """set all levels to 0""" + with self.graph.currentState() as g: + levs = list(g.objects(self.uri, L9['lightLevel'])) + for lev in levs: + self.graph.removeMappingNode(self.saveContext(), lev) def save(self): + raise NotImplementedError("obsolete?") if self.temporary: log.info("not saving temporary sub named %s",self.name) return @@ -176,7 +222,10 @@ class PersistentSubmaster(Submaster): try: chanUri = Patch.get_channel_uri(chan) except KeyError: - log.error("saving dmx channels with no :Channel node is not supported yet. Give channel %s a URI for it to be saved. Omitting this channel from the sub." % chan) + log.error("saving dmx channels with no :Channel node " + "is not supported yet. Give channel %s a URI " + "for it to be saved. Omitting this channel " + "from the sub." % chan) continue lev = BNode() graph.add((subUri, L9['lightLevel'], lev)) diff --git a/light9/dmxchanedit.py b/light9/dmxchanedit.py --- a/light9/dmxchanedit.py +++ b/light9/dmxchanedit.py @@ -18,9 +18,8 @@ proposal for new attribute system: """ from __future__ import nested_scopes,division import Tkinter as tk -from rdflib import RDF +from rdflib import RDF, Literal from light9.namespaces import L9 -import louie as dispatcher stdfont = ('Arial', 9) @@ -36,12 +35,23 @@ class Onelevel(tk.Frame): ch:b11-c a :Channel; :output dmx:c54; rdfs:label "b11-c" . + + and the level is like this: + + ?editor :currentSub ?sub . + ?sub :lightLevel [:channel ?ch; :level ?level] . + + levels come in with self.setTo and go out by the onLevelChange + callback. This object does not use the graph for level values, + which I'm doing for what I think is efficiency. Unclear why I + didn't use Observable for that API. """ - def __init__(self, parent, graph, channelUri): + def __init__(self, parent, graph, channelUri, onLevelChange): tk.Frame.__init__(self,parent, height=20) self.graph = graph + self.onLevelChange = onLevelChange self.uri = channelUri - self.currentlevel = 0 # the level we're displaying, 0..1 + self.currentLevel = 0 # the level we're displaying, 0..1 # no statement yet self.channelnum = int( @@ -73,7 +83,6 @@ class Onelevel(tk.Frame): padx=1, pady=0, bd=0, height=1) self.level_lab.pack(side='left') - self.setlevel(0) self.setupmousebindings() def updateLabel(self): @@ -83,10 +92,10 @@ class Onelevel(tk.Frame): def b1down(ev): self.desc_lab.config(bg='cyan') self._start_y=ev.y - self._start_lev=self.currentlevel + self._start_lev=self.currentLevel def b1motion(ev): delta=self._start_y-ev.y - self.setlevel(self._start_lev+delta*.005) + self.setlevel(max(0, min(1, self._start_lev+delta*.005))) def b1up(ev): self.desc_lab.config(bg='black') def b3up(ev): @@ -114,28 +123,40 @@ class Onelevel(tk.Frame): self.level_lab.config(bg=gradient(lev)) def setlevel(self, newlev): - """the main program is telling us to change our - display. newlev is 0..1""" - self.currentlevel = min(1, max(0, newlev)) - newlev = "%d" % (self.currentlevel * 100) + """UI received a level change, which we put in the graph""" + self.onLevelChange(self.uri, newlev) + + def setTo(self, newLevel): + """levelbox saw a change in the graph""" + self.currentLevel = min(1, max(0, newLevel)) + newLevel = "%d" % (self.currentLevel * 100) olddisplay=self.level_lab.cget('text') - if newlev!=olddisplay: - self.level_lab.config(text=newlev) + if newLevel != olddisplay: + self.level_lab.config(text=newLevel) self.colorlabel() - dispatcher.send("levelchanged", channel=self.uri, newlevel=newlev) + class Levelbox(tk.Frame): - def __init__(self, parent, graph): + """ + this also watches all the levels in the sub and sets the boxes when they change + """ + def __init__(self, parent, graph, currentSub): + """ + currentSub is an Observable(PersistentSubmaster) + """ tk.Frame.__init__(self,parent) + self.currentSub = currentSub self.graph = graph graph.addHandler(self.updateChannels) + self.currentSub.subscribe(lambda _: graph.addHandler(self.updateLevelValues)) + def updateChannels(self): """(re)make Onelevel boxes for the defined channels""" [ch.destroy() for ch in self.winfo_children()] - self.levels = [] # Onelevel objects + self.levelFromUri = {} # channel : OneLevel chans = list(self.graph.subjects(RDF.type, L9.Channel)) chans.sort(key=lambda c: int(self.graph.value(c, L9.output).rsplit('/c')[-1])) @@ -151,14 +172,33 @@ class Levelbox(tk.Frame): for i, channel in enumerate(chans): # sort? # frame for this channel - f = Onelevel(columnFrames[i // rows], self.graph, channel) + f = Onelevel(columnFrames[i // rows], self.graph, channel, + self.onLevelChange) + + self.levelFromUri[channel] = f + f.pack(side='top') - self.levels.append(f) - f.pack(side='top') - #dispatcher.connect(setalevel,"setlevel") + def updateLevelValues(self): + """set UI level from graph""" + submaster = self.currentSub() + if submaster is None: + return + sub = submaster.uri + if sub is None: + raise ValueError("currentSub is %r" % submaster) - def setlevels(self,newlevels): - """sets levels to the new list of dmx levels (0..1). list can - be any length""" - for l,newlev in zip(self.levels,newlevels): - l.setlevel(newlev) + remaining = set(self.levelFromUri.keys()) + for ll in self.graph.objects(sub, L9['lightLevel']): + chan = self.graph.value(ll, L9['channel']) + lev = self.graph.value(ll, L9['level']).toPython() + self.levelFromUri[chan].setTo(lev) + remaining.remove(chan) + for channel in remaining: + self.levelFromUri[channel].setTo(0) + + def onLevelChange(self, chan, newLevel): + """UI received a change which we put in the graph""" + if self.currentSub() is None: + raise ValueError("no currentSub in Levelbox") + self.currentSub().editLevel(chan, newLevel) + diff --git a/light9/editchoice.py b/light9/editchoice.py new file mode 100644 --- /dev/null +++ b/light9/editchoice.py @@ -0,0 +1,109 @@ +import Tkinter as tk +from rdflib import URIRef +from light9.tkdnd import dragSourceRegister, dropTargetRegister + +class Local(object): + """placeholder for the local uri that EditChoice does not + manage. Set resourceObservable to Local to indicate that you're + unlinked""" + +class EditChoice(tk.Frame): + """ + Observable <-> linker UI + + widget for tying some UI to a shared resource for editing, or + unlinking it (which means associating it with a local resource + that's not named or shared). This object does not own the choice + of resource; the caller does. + + UI actions: + - drag a uri on here to make it the one we're editing + + - button to clear the currentSub (putting it back to + sessionLocalSub, and also resetting sessionLocalSub to be empty + again) + + - drag the sub uri off of here to send it to another receiver, + but, if we're in local mode, the local sub should not be so + easily addressable. Maybe you just can't drag it off. + + + Todo: + + - filter by type so you can't drag a curve onto a subcomposer + + - 'save new' : make a new sub: transfers the current data (from a shared sub or + from the local one) to the new sub. If you're on a local sub, + the new sub is named automatically, ideally something brief, + pretty distinct, readable, and based on the lights that are + on. If you're on a named sub, the new one starts with a + 'namedsub 2' style name. The uri can also be with a '2' suffix, + although maybe that will be stupid. If you change the name + before anyone knows about this uri, we could update the current + sub's uri to a slug of the new label. + + - rename this sub: not available if you're on a local sub. Sets + the label of a named sub. Might update the uri of the named sub + if it's new enough that no one else would have that uri. Not + sure where we measure that 'new enough' value. Maybe track if + the sub has 'never been dragged out of this subcomposer + session'? But subs will also show up in other viewers and + finders. + + - list of recent resources that this choice was set to + """ + def __init__(self, parent, graph, resourceObservable): + """ + getResource is called to get the URI of the currently + """ + self.graph = graph + self.frame = tk.Frame(parent, relief='raised', border=2) + self.frame.pack(side='top') + tk.Label(self.frame, text="Editing:").pack(side='left') + self.currentLinkFrame = tk.Frame(self.frame) + self.currentLinkFrame.pack(side='left') + + self.subIcon = tk.Label(self.currentLinkFrame, text="...", + borderwidth=2, relief='raised', + padx=10, pady=10) + self.subIcon.pack() + + self.resourceObservable = resourceObservable + resourceObservable.subscribe(self.uriChanged) + + # when the value is local, this should stop being a drag source + dragSourceRegister(self.subIcon, 'copy', 'text/uri-list', + self.resourceObservable) + def onEv(ev): + self.resourceObservable(URIRef(ev.data)) + return "link" + self.onEv = onEv + + b=tk.Button(self.frame, text="Unlink", command=self.switchToLocalSub) + b.pack(side='left') + + # it would be nice if I didn't receive my own drags here, and + # if the hover display wasn't per widget + for target in ([self.frame, self.currentLinkFrame] + + self.frame.winfo_children() + + self.currentLinkFrame.winfo_children()): + dropTargetRegister(target, typeList=["*"], onDrop=onEv, + hoverStyle=dict(background="#555500")) + + def uriChanged(self, newUri): + # if this resource had a type icon or a thumbnail, those would be + # cool to show in here too + if newUri is Local: + self.subIcon.config(text="(local)") + else: + self.graph.addHandler(self.updateLabel) + + def updateLabel(self): + uri = self.resourceObservable() + print "get label", repr(uri) + label = self.graph.label(uri) + self.subIcon.config(text=label or uri) + + def switchToLocalSub(self): + self.resourceObservable(Local) + diff --git a/light9/observable.py b/light9/observable.py new file mode 100644 --- /dev/null +++ b/light9/observable.py @@ -0,0 +1,32 @@ +class _NoNewVal(object): + pass + +class Observable(object): + """ + like knockout's observable. Hopefully this can be replaced by a + better python one + + compare with: + http://knockoutjs.com/documentation/observables.html + https://github.com/drpancake/python-observable/blob/master/observable/observable.py + """ + def __init__(self, val): + self.val = val + self.subscribers = set() + + def __call__(self, newVal=_NoNewVal): + if newVal is _NoNewVal: + return self.val + if newVal == self.val: + print "%r unchanged from %r" % (newVal, self.val) + return + self.val = newVal + for s in self.subscribers: + s(newVal) + + def subscribe(self, cb, callNow=True): + """cb is called with new values, and also right now with the + current value unless you opt out""" + self.subscribers.add(cb) + if callNow: + cb(self.val) diff --git a/light9/rdfdb/patch.py b/light9/rdfdb/patch.py --- a/light9/rdfdb/patch.py +++ b/light9/rdfdb/patch.py @@ -62,19 +62,19 @@ class Patch(object): @property def addQuads(self): if self._addQuads is None: - if self._addGraph is None: - return [] - self._addQuads = list(quadsWithContextUris( - self._addGraph.quads(ALLSTMTS))) + if self._addGraph is not None: + self._addQuads = list(self._addGraph.quads(ALLSTMTS)) + else: + raise return self._addQuads @property def delQuads(self): if self._delQuads is None: - if self._delGraph is None: - return [] - self._delQuads = list(quadsWithContextUris( - self._delGraph.quads(ALLSTMTS))) + if self._delGraph is not None: + self._delQuads = list(self._delGraph.quads(ALLSTMTS)) + else: + raise return self._delQuads @property diff --git a/light9/rdfdb/syncedgraph.py b/light9/rdfdb/syncedgraph.py --- a/light9/rdfdb/syncedgraph.py +++ b/light9/rdfdb/syncedgraph.py @@ -1,9 +1,15 @@ -from rdflib import ConjunctiveGraph, RDFS, RDF, Graph -import logging, cyclone.httpclient, traceback, urllib +from rdflib import ConjunctiveGraph, RDFS, RDF, URIRef +import logging, cyclone.httpclient, traceback, urllib, random +from itertools import chain from twisted.internet import reactor, defer log = logging.getLogger('syncedgraph') -from light9.rdfdb.patch import Patch, ALLSTMTS -from light9.rdfdb.rdflibpatch import patchQuads +from light9.rdfdb.patch import Patch +from light9.rdfdb.rdflibpatch import patchQuads, contextsForStatement as rp_contextsForStatement + +# everybody who writes literals needs to get this +from rdflibpatch_literal import patch +patch() + def sendPatch(putUri, patch, **kw): """ @@ -176,6 +182,12 @@ class SyncedGraph(object): This api is like rdflib.Graph but it can also call you back when there are graph changes to the parts you previously read. + You may want to attach to self.initiallySynced deferred so you + don't attempt patches before we've heard the initial contents of + the graph. It would be ok to accumulate some patches of new + material, but usually you won't correctly remove the existing + statements unless we have the correct graph. + If we get out of sync, we abandon our local graph (even any pending local changes) and get the data again from the server. @@ -185,6 +197,7 @@ class SyncedGraph(object): label is a string that the server will display in association with your connection """ + self.initiallySynced = defer.Deferred() _graph = self._graph = ConjunctiveGraph() self._watchers = GraphWatchers() @@ -201,6 +214,11 @@ class SyncedGraph(object): # receive its patch correctly. traceback.print_exc() + if self.initiallySynced: + self.initiallySynced.callback(None) + self.initiallySynced = None + + listen = reactor.listenTCP(0, cyclone.web.Application(handlers=[ (r'/update', makePatchEndpoint(onPatch)), ])) @@ -258,7 +276,7 @@ class SyncedGraph(object): # these could fail if we're out of sync. One approach: # Rerequest the full state from the server, try the patch # again after that, then give up. - + log.info("%s add %s", [q[2] for q in p.delQuads], [q[2] for q in p.addQuads]) patchQuads(self._graph, p.delQuads, p.addQuads, perfect=True) self.updateOnPatch(p) self._sender.sendPatch(p).addErrback(self.sendFailed) @@ -268,6 +286,7 @@ class SyncedGraph(object): we asked for a patch to be queued and sent to the master, and that ultimately failed because of a conflict """ + print "sendFailed" #i think we should receive back all the pending patches, #do a resysnc here, #then requeue all the pending patches (minus the failing one?) after that's done. @@ -275,7 +294,10 @@ class SyncedGraph(object): def patchObject(self, context, subject, predicate, newObject): """send a patch which removes existing values for (s,p,*,c) - and adds (s,p,newObject,c). Values in other graphs are not affected""" + and adds (s,p,newObject,c). Values in other graphs are not affected. + + newObject can be None, which will remove all (subj,pred,*) statements. + """ existing = [] for spo in self._graph.triples((subject, predicate, None), @@ -284,16 +306,56 @@ class SyncedGraph(object): # what layer is supposed to cull out no-op changes? self.patch(Patch( delQuads=existing, - addQuads=[(subject, predicate, newObject, context)])) + addQuads=([(subject, predicate, newObject, context)] + if newObject is not None else []))) - def patchMapping(self, context, subject, predicate, keyPred, valuePred, newKey, newValue): + def patchMapping(self, context, subject, predicate, nodeClass, keyPred, valuePred, newKey, newValue): """ - proposed api for updating things like ?session :subSetting [ - :sub ?s; :level ?v ]. Keyboardcomposer has an implementation - already. There should be a complementary readMapping that gets - you a value since that's tricky too + creates/updates a structure like this: + + ?subject ?predicate [ + a ?nodeClass; + ?keyPred ?newKey; + ?valuePred ?newValue ] . + + There should be a complementary readMapping that gets you a + value since that's tricky too """ + with self.currentState() as graph: + adds = set([]) + for setting in graph.objects(subject, predicate): + if graph.value(setting, keyPred) == newKey: + break + else: + setting = URIRef(subject + "/map/%s" % + random.randrange(999999999)) + adds.update([ + (subject, predicate, setting, context), + (setting, RDF.type, nodeClass, context), + (setting, keyPred, newKey, context), + ]) + dels = set([]) + for prev in graph.objects(setting, valuePred): + dels.add((setting, valuePred, prev, context)) + adds.add((setting, valuePred, newValue, context)) + + if adds != dels: + self.patch(Patch(delQuads=dels, addQuads=adds)) + + def removeMappingNode(self, context, node): + """ + removes the statements with this node as subject or object, which + is the right amount of statements to remove a node that + patchMapping made. + """ + p = Patch(delQuads=[spo+(context,) for spo in + chain(self._graph.triples((None, None, node), + context=context), + self._graph.triples((node, None, None), + context=context))]) + self.patch(p) + def addHandler(self, func): """ run this (idempotent) func, noting what graph values it @@ -333,12 +395,21 @@ class SyncedGraph(object): """ a graph you can read without being in an addHandler """ + if context is not None: + raise NotImplementedError("currentState with context arg") + class Mgr(object): def __enter__(self2): - # this should be a readonly view of the existing graph - g = Graph() - for s in self._graph.triples((None, None, None), context): - g.add(s) + # this should be a readonly view of the existing + # graph, maybe with something to guard against + # writes/patches happening while reads are being + # done. Typical usage will do some reads on this graph + # before moving on to writes. + + g = ConjunctiveGraph() + for s,p,o,c in self._graph.quads((None,None,None)): + g.store.add((s,p,o), c) + g.contextsForStatement = lambda t: contextsForStatementNoWildcards(g, t) return g def __exit__(self, type, val, tb): @@ -388,7 +459,19 @@ class SyncedGraph(object): self._watchers.addPredObjWatcher(func, predicate, object) return self._graph.subjects(predicate, object) - # i find myself wanting 'patch' versions of these calls that tell + def contextsForStatement(self, triple): + """currently this needs to be in an addHandler section, but it + sets no watchers so it won't actually update if the statement + was added or dropped from contexts""" + func = self._getCurrentFunc() + return contextsForStatementNoWildcards(self._graph, triple) + + # i find myself wanting 'patch' (aka enter/leave) versions of these calls that tell # you only what results have just appeared or disappeared. I think # I'm going to be repeating that logic a lot. Maybe just for the # subjects(RDF.type, t) call + +def contextsForStatementNoWildcards(g, triple): + if None in triple: + raise NotImplementedError("no wildcards") + return rp_contextsForStatement(g, triple) diff --git a/light9/rdfdb/web/index.xhtml b/light9/rdfdb/web/index.xhtml --- a/light9/rdfdb/web/index.xhtml +++ b/light9/rdfdb/web/index.xhtml @@ -29,6 +29,8 @@ $(function(){ function collapseCuries(s) { + // this is temporary. The correct thing is to parse the quad (or receive it as a tree) and then make links to the full URIs and display curies of them + return s .replace(//g, function (match, short) { return "light9:"+short; }) diff --git a/light9/uihelpers.py b/light9/uihelpers.py --- a/light9/uihelpers.py +++ b/light9/uihelpers.py @@ -62,15 +62,11 @@ def toplevelat(name, existingtoplevel=No def savePos(ev): geo = tl.geometry() - if not isinstance(ev.widget, Tkinter.Tk): - # I think these are due to internal widget size changes, - # not the toplevel changing + + # todo: need a way to filter out the startup window sizes that + # weren't set by the user + if geo.startswith("1x1") or geo.startswith(("378x85", "378x86")): return - # this is trying to not save all the startup automatic window - # sizes. I don't have a better plan for this yet. - if graphSetTime[0] == 0 or time.time() < graphSetTime[0] + 3: - return - if geo == lastSaved[0]: return if not setOnce[0]: