changeset 838:321fc6150ee3

subcomposer's nice currently-editing DnD box Ignore-this: f2a8542a4ab38dbe61b26c864da3bace
author drewp@bigasterisk.com
date Tue, 26 Mar 2013 07:04:22 +0000
parents f3364e9f5c03
children ff9c72cfb023
files bin/keyboardcomposer bin/subcomposer light9/Submaster.py light9/dmxchanedit.py light9/editchoice.py light9/observable.py light9/rdfdb/patch.py light9/rdfdb/syncedgraph.py light9/rdfdb/web/index.xhtml light9/uihelpers.py
diffstat 10 files changed, 494 insertions(+), 176 deletions(-) [+]
line wrap: on
line diff
--- a/bin/keyboardcomposer	Tue Mar 26 07:03:28 2013 +0000
+++ b/bin/keyboardcomposer	Tue Mar 26 07:04:22 2013 +0000
@@ -120,26 +120,13 @@
            ?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"""
--- a/bin/subcomposer	Tue Mar 26 07:03:28 2013 +0000
+++ b/bin/subcomposer	Tue Mar 26 07:04:22 2013 +0000
@@ -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 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 @@
 
 
     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 @@
     editing. If we don't have a currentSub, then we're actually
     editing a session-local sub called <session> l9:currentSub <sessionLocalSub>
 
+    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 @@
         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("<Return>", 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 @@
     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)
--- a/light9/Submaster.py	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/Submaster.py	Tue Mar 26 07:04:22 2013 +0000
@@ -9,7 +9,7 @@
 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 @@
 
     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 @@
             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 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 @@
         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 @@
         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 @@
             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))
--- a/light9/dmxchanedit.py	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/dmxchanedit.py	Tue Mar 26 07:04:22 2013 +0000
@@ -18,9 +18,8 @@
 """
 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 @@
         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 @@
                                   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 @@
         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 @@
         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 @@
 
         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)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/editchoice.py	Tue Mar 26 07:04:22 2013 +0000
@@ -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)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/observable.py	Tue Mar 26 07:04:22 2013 +0000
@@ -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)
--- a/light9/rdfdb/patch.py	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/rdfdb/patch.py	Tue Mar 26 07:04:22 2013 +0000
@@ -62,19 +62,19 @@
     @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
--- a/light9/rdfdb/syncedgraph.py	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/rdfdb/syncedgraph.py	Tue Mar 26 07:04:22 2013 +0000
@@ -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 @@
     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 @@
         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 @@
                 # 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 @@
         # 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 @@
         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 @@
 
     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 @@
         # 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 @@
         """
         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 @@
         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)
--- a/light9/rdfdb/web/index.xhtml	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/rdfdb/web/index.xhtml	Tue Mar 26 07:04:22 2013 +0000
@@ -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(/<http:\/\/www.w3.org\/2001\/XMLSchema#/g, function (match, short) { return "xsd:"+short; })
                   .replace(/<http:\/\/light9.bigasterisk.com\/(.*?)>/g, function (match, short) { return "light9:"+short; })
--- a/light9/uihelpers.py	Tue Mar 26 07:03:28 2013 +0000
+++ b/light9/uihelpers.py	Tue Mar 26 07:04:22 2013 +0000
@@ -62,15 +62,11 @@
 
     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]: