blc2 - Python library and frontend for running theatrical lighting and SFX. Written for the English 2041F fall 2019 theatre production of "The Cenci".

git clone https://benconnors.ca/git-repos/blc2

Log | Files | Refs

interface.py (39959B) - raw


      1 import curses
      2 import os
      3 import datetime as dt
      4 import itertools
      5 import threading
      6 import traceback as tb
      7 
      8 import blc2 
      9 
     10 from blc2.functions.audio import Audio
     11 from blc2.functions.scene import Scene
     12 from blc2.functions.chaser import Chaser
     13 from blc2.functions.chaserstep import ChaserStep
     14 from blc2.functions.join import Join
     15 from blc2.constants import SCENE, CHASER, AUDIO, MANUAL, INHERIT, INFTY, CHASERSTEP, JOIN, ONESHOT, LOOP, RANDOM
     16 
     17 from blc2.topology import Fixture
     18 
     19 from blc2.workspace import Workspace
     20 
     21 from .globals import CURSES_LOCK 
     22 from .input.tabcomp import Input 
     23 from .channelbank import ChannelBank
     24 from .chaserview import ChaserView
     25 from .pager import Pager
     26 from .dialog import askyesnocancel
     27 from .audioview import AudioView
     28 from .render import Renderer
     29 from .dummy import DummyOutput
     30 
     31 def wrap_curses(f):
     32     def inner(*args, **kwargs):
     33         return curses.wrapper(lambda stdscr: f(*args, stdscr, **kwargs))
     34     return inner
     35 
     36 __version__ = "v0.1.0"
     37 
     38 class Interface:
     39     def _compute_sizes(self, height, width):
     40         hb = height // 2
     41         ht = height - hb
     42 
     43         wr = width // 2
     44         wl = width - wr - 1
     45 
     46         chasers = []
     47         if self.chaser_views:
     48             current = 0
     49             cw = (wl // len(self.chaser_views)) - 1
     50             for i in range(len(self.chaser_views)):
     51                 curw = (wl - current) if (i+1) == len(self.chaser_views) else cw
     52                 chasers.append(((hb, curw), (ht, current)))
     53                 current += curw + 1
     54 
     55         return (
     56             ((ht, width), (0, 0),),
     57             ((4, wr), (height-4, wl+1),), 
     58             ((hb - 4, wr), (ht, wl+1),),
     59             *chasers
     60         )
     61 
     62     def _resize(self):
     63         ## FIXME
     64         try:
     65             self._actual_resize()
     66             self._actual_resize()
     67         except:
     68             self.stdscr.addstr(0, 0, "Too small")
     69             self.stdscr.refresh()
     70             return False
     71         return True
     72 
     73     def _actual_resize(self):
     74         for (a1, a2), (f1, f2) in zip(self._compute_sizes(*self.stdscr.getmaxyx()), ((self.channel_bank.set_dim, self.channel_bank.set_pos), (self.input.set_dim, self.input.set_pos), (self.pager.set_dim, self.pager.set_pos), *((c.set_dim, c.set_pos) for c in self.chaser_views))):
     75             f1(*a1)
     76             f2(*a2)
     77 
     78     @wrap_curses
     79     def main(self, stdscr):
     80         height, width = stdscr.getmaxyx()
     81         self.stdscr = stdscr
     82         (cbd, cbp), (ind, inp), (pgd, pgp) = self._compute_sizes(height, width)
     83         self.channel_bank = ChannelBank(*cbp, *cbd)
     84         self.input = Input(*inp, *ind)
     85         self.input.context = self.context_base
     86         self.pager = Pager(*pgp, *pgd)
     87 
     88         todisp = [
     89             "Welcome to BLC2!",
     90             "Currently running library %s, interface %s" % (blc2.__version__, __version__),
     91             "",
     92         ]
     93 
     94         if self._w_created:
     95             todisp.append("Created a new workspace")
     96         else:
     97             todisp.append("Loaded workspace \"%s\" from \"%s\"" % (self.w.name, self.path))
     98             todisp.append("Authored by %s, last modified at %s" % (self.w.author, self.w.modified.strftime("%Y-%m-%d %H:%M:%S")))
     99 
    100         self.pager.display_many(todisp)
    101 
    102         if not self.output.ok:
    103             self.pager.display_many(("WARNING: Output is not OK!",), True)
    104 
    105         self.verify_audio()
    106 
    107         self.input.main(self._resize)
    108 
    109     def verify_audio(self):
    110         def gather_audio(func):
    111             if func.type == AUDIO:
    112                 return (func,)
    113             elif func.type in (CHASER, JOIN):
    114                 return tuple(itertools.chain(*(gather_audio(i) for i in func.steps)))
    115             elif func.type == CHASERSTEP and func.function is not None:
    116                 return gather_audio(func.function)
    117 
    118             return ()
    119 
    120         f = None
    121         bad = []
    122         with self.w_lock:
    123             funcs = set()
    124             if f is None:
    125                 for func in self.w.functions.values():
    126                     funcs.update(gather_audio(func))
    127             else:
    128                 funcs = gather_audio(f)
    129 
    130             for f in sorted(funcs, key=lambda i: i.id):
    131                 if not os.path.isfile(f.filename):
    132                     bad.append("- %3d \"%s\"" % (f.id, f.filename))
    133 
    134             if bad:
    135                 self.pager.display_many(["MISSING AUDIO FILES:"] + bad)
    136             else:
    137                 self.pager.display_many(("No missing audio files!",))
    138 
    139     def handle_show(self, f):
    140         with self.w_lock:
    141             if f is None or f.type == SCENE:
    142                 if not isinstance(self.channel_bank, ChannelBank):
    143                     (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx())
    144                     self.channel_bank = ChannelBank(*yx, *hw)
    145                 if f is None:
    146                     return
    147                 self.channel_bank.set_scope(f.scope)
    148                 self.channel_bank.set_values(f.values.items())
    149             elif f.type == AUDIO:
    150                 if not isinstance(self.channel_bank, AudioView):
    151                     (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx())
    152                     self.channel_bank = AudioView(*yx, *hw)
    153                 self.channel_bank.audio = f
    154             self.channel_bank.title = f.type + ' "%s"' % f.name
    155             self.renderer.clear_hold()
    156             if f.type == SCENE:
    157                 self.renderer.hold(f.values)
    158 
    159     def handle_enter(self, fid):
    160         with self.w_lock:
    161             if fid is None:
    162                 if not self.chaser_views:
    163                     return "No chaser loaded"
    164                 f = self.chaser_views[0].chaser
    165             else:
    166                 if fid not in self.w.functions:
    167                     return "No such function"
    168                 f = self.w.functions[fid]
    169 
    170             if self.current_cv is not None:
    171                 self.current_cv.highlight = False
    172 
    173             if self.chaser is not None:
    174                 cv = [c for c in self.chaser_views if c.chaser == self.chaser][0].selected
    175                 self.chaser_stack.append((self.chaser, cv))
    176                 self.chaser = None
    177 
    178             if f.type == SCENE:
    179                 self.primitive = f
    180                 self.handle_show(f)
    181                 self.channel_bank.highlight = True
    182                 self.input.context = self.context_scene
    183             elif f.type in (CHASER, JOIN):
    184                 self.chaser = f
    185                 cv = [c for c in self.chaser_views if c.chaser == f]
    186                 if not cv:
    187                     if len(self.chaser_views) == 2:
    188                         self.chaser_views[1].set_chaser(f, None)
    189                     else:
    190                         self.base_add(f.id)
    191                     self.current_cv = self.chaser_views[-1]
    192                 else:
    193                     cv = cv[0]
    194                     self.current_cv = cv
    195                 self.current_cv.highlight = True
    196                 if f.type == CHASER:
    197                     self.input.context = self.context_chaser
    198                 elif f.type == JOIN:
    199                     self.input.context = self.context_join
    200             elif f.type == AUDIO:
    201                 self.primitive = f
    202                 self.handle_show(f)
    203                 self.channel_bank.highlight = True
    204                 self.input.context = self.context_audio
    205             else:
    206                 ## FIXME
    207                 return "No other types allowed yet"
    208 
    209     def base_add(self, fid):
    210         with self.w_lock:
    211             if fid not in self.w.functions:
    212                 return "No such function"
    213             f = self.w.functions[fid]
    214             if f.type not in (CHASER, JOIN):
    215                 return "Can only add chasers!"
    216             with CURSES_LOCK:
    217                 if True in (c.chaser.id for c in self.chaser_views):
    218                     return "Already added!"
    219                 elif len(self.chaser_views) == 2:
    220                     ## FIXME?
    221                     return "Can only use two for now"
    222                 self.chaser_views.append(None)
    223                 ld, lp = self._compute_sizes(*self.stdscr.getmaxyx())[-1]
    224                 self.chaser_views[-1] = ChaserView(*lp, *ld)
    225                 self._resize()
    226                 self.chaser_views[-1].set_chaser(f, None)
    227 
    228     def base_remove(self, n, index=True):
    229         with self.w_lock:
    230             if not index:
    231                 n = [j for j, i in enumerate(self.chaser_views) if i.chaser.id == n]
    232                 if not n:
    233                     return "No such chaser loaded"
    234                 n = n[0]
    235             if n >= len(self.chaser_views) or n < 0:
    236                 return "Index out of range"
    237             with CURSES_LOCK:
    238                 self.chaser_views[n].win.erase()
    239                 self.chaser_views[n].win.refresh()
    240             self.chaser_views.pop(n)
    241             self._resize()
    242 
    243     def base_delete(self, fid):
    244         with self.w_lock:
    245             if fid not in self.w.functions:
    246                 return "No such function"
    247             f = self.w.functions[fid]
    248             f.delete()
    249 
    250     def base_new(self, name, cls):
    251         with self.w_lock:
    252             f = cls(self.w, name=name)
    253             self.handle_enter(f.id)
    254 
    255     def _gather_channels(self, cr):
    256         with self.w_lock:
    257             ## Gather the affected channels
    258             channels = []
    259             for f in self.w.fixtures.values():
    260                 for s, e in cr[0]:
    261                     if (s <= f.id <= e) or (e == -1 and f.id >= s):
    262                         break
    263                 else:
    264                     continue
    265 
    266                 for n, c in enumerate(f.channels):
    267                     for s, e in cr[1]:
    268                         if (s <= n <= e) or (e == -1 and n >= s):
    269                             channels.append(c)
    270                             break
    271 
    272             return channels
    273 
    274     def scene_set(self, cr, v):
    275         with self.w_lock:
    276             channels = self._gather_channels(cr)
    277 
    278             ## Set the values
    279             self.primitive.update({c: v for c in channels})
    280 
    281             ## Update the display
    282             self.channel_bank.set_scope(self.primitive.scope)
    283             if v is not None:
    284                 self.channel_bank.set_values(((c, v) for c in channels))
    285             self.renderer.clear_hold()
    286             self.renderer.hold(self.primitive.values)
    287 
    288     def scene_edit(self, cr, force = False):
    289         with self.w_lock:
    290 
    291             channels = self._gather_channels(cr)
    292             if not force:
    293                 channels = [c for c in channels if c in self.primitive.scope]
    294             else:
    295                 self.primitive.update({c: 0 for c in channels if c not in self.primitive.scope})
    296 
    297             if not channels:
    298                 return "No channels given"
    299 
    300             curses.curs_set(0)
    301             values = self.primitive.values
    302 
    303             self.channel_bank.set_active(channels, True)
    304 
    305             while True:
    306                 self.channel_bank.set_scope(self.primitive.scope)
    307                 self.channel_bank.set_values(((c, v) for c, v in values.items()))
    308                 self.renderer.clear_hold()
    309                 self.renderer.hold(self.primitive.values)
    310 
    311                 l = self.pager.win.getch()
    312                 delta = 0
    313 
    314                 if l == curses.KEY_RESIZE:
    315                     self._resize()
    316                 elif l == ord('q'):
    317                     break
    318                 elif l in (ord('j'), curses.KEY_DOWN):
    319                     delta = -5
    320                 elif l in (ord('k'), curses.KEY_UP):
    321                     delta = 5
    322                 elif l == ord('h'):
    323                     delta = -1
    324                 elif l == ord('l'):
    325                     delta = 1
    326                 elif l == curses.KEY_NPAGE:
    327                     delta = 25
    328                 elif l == curses.KEY_PPAGE:
    329                     delta = -25
    330                 elif l == curses.KEY_HOME:
    331                     delta = 255
    332                 elif l == curses.KEY_END:
    333                     delta = -255
    334 
    335                 if delta != 0:
    336                     for c in channels:
    337                         values[c] = max(0, min(255, values[c]+delta))
    338                         self.primitive.update(values)
    339 
    340             self.channel_bank.set_active(channels, False)
    341 
    342             curses.curs_set(1)
    343 
    344     def scene_clear(self, cr):
    345         self.scene_set(cr, None)
    346 
    347     def scene_exit(self):
    348         self.primitive = None
    349         
    350         self.handle_exit()
    351 
    352     def audio_exit(self):
    353         self.primitive = None
    354 
    355         self.handle_exit()
    356 
    357     def handle_exit(self):
    358         for c in self.chaser_views:
    359             c.highlight = False
    360 
    361         if self.current_cv is not None:
    362             self.current_cv.selected = None
    363 
    364         self.channel_bank.highlight = False
    365 
    366         if self.chaser_stack:
    367             ## Load the previous chaser
    368             c, sel = self.chaser_stack.pop(-1)
    369 
    370             if True not in (True for i in self.chaser_views if i.chaser.id == c.id):
    371                 ## We need to load it
    372                 ## Check if we have one for the current chaser
    373                 try:
    374                     idx = [i.chaser.id for i in self.chaser_views].index(self.chaser.id)
    375                 except (ValueError, AttributeError):
    376                     ## We don't have it
    377                     if len(self.chaser_views) == 2:
    378                         ## We don't have room for it, just replace the last one
    379                         self.chaser_views[1].set_chaser(c, sel)
    380                     else:
    381                         self.base_add(c.id)
    382                     self.current_cv = self.chaser_views[-1]
    383                 else:
    384                     self.chaser_views[idx].set_chaser(c, sel)
    385                     self.current_cv = self.chaser_views[idx]
    386             
    387             self.current_cv.highlight = True
    388             self.current_cv.selected = sel
    389             if c.type == CHASER:
    390                 self.input.context = self.context_chaser
    391             else:
    392                 self.input.context = self.context_join
    393             self.chaser = c
    394         else:
    395             if isinstance(self.channel_bank, AudioView):
    396                 (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx())
    397                 self.channel_bank = ChannelBank(*yx, *hw)
    398             self.channel_bank.set_scope(())
    399             self.channel_bank.title = "Channels"
    400 
    401             self.input.context = self.context_base
    402             self.chaser = None
    403             self.current_cv = None
    404             self.renderer.clear_hold()
    405 
    406     def base_save(self, path=None):
    407         with self.w_lock:
    408             if path is not None:
    409                 self.path = path
    410             if self.path is None:
    411                 return "No path set"
    412             self.w.modified = dt.datetime.now()
    413             self.w.save(self.path)
    414 
    415             return "Saved to "+self.path
    416 
    417     def base_quit(self):
    418         if askyesnocancel(self.stdscr, "Really exit?"):
    419             quit()
    420         else:
    421             self._resize()
    422 
    423     def list_fixtures(self):
    424         with self.w_lock:
    425             td = ["FIXTURES:"]
    426             for f in sorted(self.w.fixtures.values(), key=lambda a: a.id):
    427                 td.append("- %03d: %s" % (f.id, f.name))
    428                 for c in f.channels:
    429                     td.append("  %03d-%03d: %s" % (f.id, c.id, c.name))
    430             self.pager.display_many(td, split=True)
    431 
    432     def list_functions(self, typ):
    433         with self.w_lock:
    434             td = [typ.upper()+"S:"]
    435             for f in sorted(self.w.functions.values(), key=lambda a: a.id):
    436                 if f.type == typ:
    437                     td.append("- %03d: %s" % (f.id, f.name))
    438             self.pager.display_many(td, split=True)
    439 
    440     def page(self):
    441         self.pager.user_page()
    442 
    443     def page_clear(self):
    444         self.pager.clear()
    445 
    446     def help(self, name, commands, help_):
    447         if name is None:
    448             dname = "MODE HELP:"
    449         else:
    450             dname = name.upper() + " COMMAND:"
    451 
    452         todisp = [dname, ""]
    453         if commands:
    454             if name is None:
    455                 todisp.append("Available commands:")
    456             else:
    457                 todisp.append("Available forms:")
    458             todisp.extend(("- "+i for i in commands))
    459             todisp.append("")
    460         
    461         todisp.extend(help_.split('\n'))
    462 
    463         self.pager.display_many(todisp, split=True)
    464 
    465     def chaser_select(self, num):
    466         with self.w_lock:
    467             if num < 1 or num > len(self.current_cv.chaser.steps):
    468                 return "Out of range"
    469             
    470             self.current_cv.selected = num - 1
    471             s = self.current_cv.chaser.steps[num-1]
    472             if (s.type == CHASERSTEP and s.function is not None and s.function.type in (SCENE, AUDIO,)) or s.type in (SCENE, AUDIO,):
    473                 self.handle_show(s.function if s.type == CHASERSTEP else s)
    474 
    475     def chaser_edit(self, num=None):
    476         with self.w_lock:
    477             if num is not None:
    478                 r = self.chaser_select(num)
    479                 if r is not None:
    480                     return r
    481             elif self.current_cv.selected is None:
    482                 return "No step selected"
    483 
    484             c = self.current_cv.chaser.steps[self.current_cv.selected]
    485             if c.type == CHASERSTEP and c.function is None:
    486                 return "No function on step"
    487             
    488             self.handle_enter(c.function.id if c.type == CHASERSTEP else c.id)
    489 
    490     def chaser_delete(self, num=None):
    491         with self.w_lock:
    492             if num is not None:
    493                 r = self.chaser_select(num)
    494                 if r is not None:
    495                     return r
    496             elif self.current_cv.selected is None:
    497                 return "No step selected"
    498 
    499             c = self.current_cv.chaser
    500             s = c.steps[self.current_cv.selected]
    501             sel = self.current_cv.selected
    502             with CURSES_LOCK:
    503                 if c.type == CHASER:
    504                     if askyesnocancel(self.stdscr, "Really delete step %d?" % (s.index+1), resize=self._resize):
    505                         sel = s.index
    506                         s.delete()
    507                     else:
    508                         return
    509                 else:
    510                     c.delete_step(s)
    511                         
    512                 if not c.steps:
    513                     sel = None
    514                 else:
    515                     sel = max(0, sel-1)
    516                 self.current_cv.set_chaser(c, sel)
    517                 self._resize()
    518 
    519     def chaser_fade(self, t, out=False):
    520         with self.w_lock:
    521             if self.current_cv.selected is None:
    522                 return "No step selected"
    523             c = self.current_cv.chaser
    524             s = c.steps[self.current_cv.selected]
    525             if out:
    526                 s.fade_out = t
    527             else:
    528                 s.fade_in = t
    529             self.current_cv.set_chaser(c, s.index)
    530 
    531     def chaser_duration(self, t):
    532         with self.w_lock:
    533             if self.current_cv.selected is None:
    534                 return "No step selected"
    535             c = self.current_cv.chaser
    536             s = c.steps[self.current_cv.selected]
    537             if s.duration_mode != MANUAL:
    538                 s.duration_mode = MANUAL
    539             s.duration = t
    540             self.current_cv.set_chaser(c, s.index)
    541 
    542     def chaser_unset(self):
    543         with self.w_lock:
    544             if self.current_cv.selected is None:
    545                 return "No step selected"
    546             c = self.current_cv.chaser
    547             s = c.steps[self.current_cv.selected]
    548             s.duration_mode = INHERIT
    549             self.current_cv.set_chaser(c, s.index)
    550 
    551     def chaser_new(self, index, name, fid=None):
    552         with self.w_lock:
    553             if fid is not None:
    554                 if fid not in self.w.functions:
    555                     return "No such function"
    556                 f = self.w.functions[fid]
    557                 if f.type not in (SCENE, AUDIO, CHASER, JOIN):
    558                     return "Invalid function"
    559             else:
    560                 f = None
    561             
    562             c = self.current_cv.chaser
    563 
    564             if c.type == CHASER:
    565                 if name is None and f is not None:
    566                     name = "Step \"%s\"" % f.name
    567                 s = ChaserStep(c, index=index, name=name, function=f)
    568                 self.current_cv.set_chaser(c, s.index)
    569             else:
    570                 if f.id in (i.id for i in c.steps):
    571                     return "Already added"
    572                 c.add_step(f)
    573                 self.current_cv.set_chaser(c, len(c.steps)-1)
    574             #self.chaser_edit(s.index+1)
    575 
    576     def chaser_new_new(self, index, name, fname, type_):
    577         with self.w_lock:
    578             if name is None and f is not None:
    579                 name = "Step \"%s\"" % f.name
    580 
    581             f = type_(self.w, name=fname)
    582 
    583             return self.chaser_new(index, name, fid=f.id)
    584 
    585     def chaser_rename(self, name):
    586         with self.w_lock:
    587             if self.current_cv.selected is None:
    588                 return "No step selected"
    589             c = self.current_cv.chaser
    590             s = c.steps[self.current_cv.selected]
    591             s.name = name
    592             
    593             self.current_cv.set_chaser(c, s.index)
    594 
    595     def chaser_bind(self, fid):
    596         with self.w_lock:
    597             if self.current_cv.selected is None:
    598                 return "No step selected"
    599             c = self.current_cv.chaser
    600             s = c.steps[self.current_cv.selected]
    601 
    602             if fid not in self.w.functions:
    603                 return "No such function"
    604             f = self.w.functions[fid]
    605             if f.type not in (SCENE, AUDIO, CHASER, JOIN):
    606                 return "Invalid function"
    607 
    608             s.function = f
    609 
    610             self.current_cv.set_chaser(c, s.index)
    611             self.chaser_select(s.index+1)
    612     
    613     def chaser_move(self, a, b = None):
    614         with self.w_lock:
    615             cursel = self.current_cv.selected 
    616             if cursel is not None:
    617                 cursel = self.current_cv.chaser.steps[cursel]
    618             if b is not None:
    619                 sel = a - 1
    620                 if sel < 0 or sel >= len(self.current_cv.chaser.steps):
    621                     return "Invalid selection"
    622                 to = b - 1
    623             else:
    624                 to = a - 1
    625                 if self.current_cv.selected is None:
    626                     return "No step selected"
    627                 sel = self.current_cv.selected
    628             if to < 0 or to >= len(self.current_cv.chaser.steps):
    629                 return "Invalid destination"
    630             c = self.current_cv.chaser
    631             s = c.steps[sel]
    632             s.index = to
    633             
    634             self.current_cv.set_chaser(c, cursel.index if cursel is not None else None)
    635 
    636     def primitive_rename(self, name):
    637         with self.w_lock:
    638             self.primitive.name = name
    639             self.channel_bank.title = self.primitive.type + ' "%s"' % name
    640 
    641     def chaser_rename_self(self, name):
    642         with self.w_lock:
    643             c = self.current_cv.chaser
    644             c.name = name
    645 
    646             self.current_cv.set_chaser(c, self.current_cv.selected)
    647 
    648     def audio_fade(self, t, out=False):
    649         with self.w_lock:
    650             if out:
    651                 self.primitive.fade_out = t
    652             else:
    653                 self.primitive.fade_in = t
    654             self.channel_bank.audio = self.primitive
    655 
    656     def audio_filename(self, fname):
    657         with self.w_lock:
    658             self.primitive.filename = fname
    659             self.channel_bank.audio = self.primitive
    660 
    661     def _render_callback(self, t, values):
    662         if not self.rendering:
    663             return
    664         with self.w_lock, CURSES_LOCK:
    665             syx = self.input.win.getyx()
    666 
    667             self.channel_bank.set_values(values)
    668             self.channel_bank.title = "LIVE: %7.2fs" % t
    669 
    670             for d, cv in zip(self.renderer._data, (i for i in self.chaser_views if i.chaser.type == CHASER)):
    671                 cv.selected = d.steps[-1].index if d.steps else None
    672 
    673             self.input.win.move(*syx)
    674 
    675     def base_run(self):
    676         if not self.chaser_views:
    677             return "No chasers loaded"
    678 
    679         self.channel_bank.set_scope(self._channels)
    680         self.channel_bank.title = "LIVE: %7.2fs" % 0
    681         self.channel_bank.highlight = True
    682 
    683         self.rendering = True
    684         self.renderer.set_functions(*((cv.chaser, cv.chaser.get_data(cv.selected)) for cv in self.chaser_views if cv.chaser.type == CHASER))
    685         self.renderer.start()
    686 
    687         self.input.context = self.context_run
    688 
    689         return "Started running"
    690 
    691     def chaser_run(self):
    692         self.current_cv.highlight = False
    693         self.renderer.clear_hold()
    694         self.chaser_stack.append([c.selected for c in self.chaser_views])
    695         self.handle_show(None)
    696         self.base_run()
    697 
    698     def run_exit(self):
    699         self.rendering = False
    700         self.renderer.stop()
    701 
    702         with self.w_lock:
    703             self.channel_bank.title = "Channels"
    704             self.channel_bank.highlight = False
    705             self.channel_bank.set_scope(())
    706 
    707             if self.chaser_stack:
    708                 for i, c in zip(self.chaser_stack.pop(-1), self.chaser_views):
    709                     c.selected = i
    710                 self.current_cv.highlight = True
    711                 if self.current_cv.selected is not None:
    712                     s = self.current_cv.chaser.steps[self.current_cv.selected]
    713                     if s.function is not None and s.function.type in (SCENE, AUDIO,):
    714                         self.handle_show(s.function)
    715                 self.input.context = self.context_chaser
    716             else:
    717                 self.input.context = self.context_base
    718 
    719                 for cv in self.chaser_views:
    720                     cv.selected = None
    721 
    722     def run_jump(self, n, p, index):
    723         if not index:
    724             n = [j for j, i in enumerate(self.chaser_views) if i.chaser.id == n]
    725             if not n:
    726                 return "No such chaser loaded"
    727             n = n[0]
    728         if n >= len(self.chaser_views) or n < 0:
    729             return "Chaser index out of range"
    730         if p is not None and (p < 1 or p > len(self.chaser_views[n].chaser.steps)):
    731             return "Step index out of range"
    732         self.renderer.advance((n, (p-1) if p is not None else p))
    733 
    734     def run_advance_all(self):
    735         for n in range(len(self.chaser_views)):
    736             self.renderer.advance((n, None))
    737 
    738     def current_status(self):
    739         self.pager.display_many((
    740             "CURRENT STATUS",
    741             "Output is %sOK: %s" % ("" if self.output.ok else "NOT ", self.output.status),
    742         ), True)
    743 
    744     def chaser_info(self, n):
    745         with self.w_lock:
    746             if n is None:
    747                 n = self.current_cv.selected 
    748                 if n is None:
    749                     return "No step selected"
    750             else:
    751                 n -= 1
    752                 if n < 0 or n >= len(self.current_cv.chaser.steps):
    753                     return "Invalid step"
    754             f = self.current_cv.chaser.steps[n]
    755             self.pager.display_many((
    756                 f.name,
    757                 "-  Fade in: %7.3f" % (f.fade_in/1000),
    758                 "- Duration: " + (("%7.3f" % (f.duration/1000)) if f.duration != INFTY else "infty"),
    759                 "- Fade out: %7.3f" % (f.fade_out/1000),
    760             ))
    761 
    762     def chaser_mode(self, mode):
    763         with self.w_lock:
    764             self.current_cv.chaser.advance_mode = mode
    765             self.current_cv.set_chaser(self.current_cv.chaser, self.current_cv.selected)
    766 
    767     def base_copy(self, name, num):
    768         with self.w_lock:
    769             ## TODO: Implement this in BLC
    770             if num not in self.w.functions:
    771                 return "No such function"
    772             f = self.w.functions[num]
    773             if f.type != SCENE:
    774                 return "Can only close scenes"
    775             f2 = Scene(self.w, name=name, values=f.values)
    776             self.handle_enter(f2.id)
    777 
    778     def __init__(self, path, output):
    779         ## Have to do most of the actual initialization in the main method, as curses isn't
    780         ## ready yet.
    781         self.channel_bank = None
    782         self.input = None
    783         self.pager = None
    784         self.stdscr = None
    785 
    786         self.primitive = None
    787         self.chaser = None
    788         self.current_cv = None
    789 
    790         self.path = path
    791         self._w_created = False
    792         if path is None or not os.path.isfile(path):
    793             self.w = Workspace("", "", 0, dt.datetime.now())
    794             self._w_created = True
    795         else:
    796             self.w = Workspace.load(path)
    797 
    798         self.w_lock = threading.RLock()
    799 
    800         self.chaser_views = []
    801 
    802         self.chaser_stack = []
    803 
    804         self.context_base = Input.parse_context((
    805             ("edit $num", self.handle_enter),
    806             ("edit", lambda: self.handle_enter(None)),
    807             ("delete $num", self.base_delete),
    808             ("new scene $quoted_string", lambda n: self.base_new(n, Scene)),
    809             ("new chaser $quoted_string", lambda n: self.base_new(n, Chaser)),
    810             ("new audio $quoted_string", lambda n: self.base_new(n, Audio)),
    811             ("new join $quoted_string", lambda n: self.base_new(n, Join)),
    812 
    813             ("add $num", self.base_add),
    814             ("subtract $letter", lambda n: self.base_remove(n, True)),
    815             ("subtract $num", lambda n: self.base_remove(n, False)),
    816 
    817             ("run", self.base_run),
    818 
    819             ("write", self.base_save),
    820             ("write $quoted_string", self.base_save),
    821 
    822             ("list fixtures", self.list_fixtures),
    823             ("list scenes", lambda: self.list_functions(SCENE)),
    824             ("list chasers", lambda: self.list_functions(CHASER)),
    825             ("list audio", lambda: self.list_functions(AUDIO)),
    826             ("list joins", lambda: self.list_functions(JOIN)),
    827 
    828             ("new scene $quoted_string from $num", self.base_copy),
    829 
    830             ("currentstatus", self.current_status),
    831 
    832             ("pager page", self.page), 
    833             ("pager clear", self.page_clear),
    834             ("verify audio", self.verify_audio),
    835             ("quit", self.base_quit),
    836         ), {
    837             None: "This is the base edit mode for editing functions.",
    838             "edit": "Edit the specified function.",
    839             "delete": "Delete the specified function.",
    840             "new": "Create a new function of the given type.",
    841             "write": "Save the workspace. The path is implicitly the one loaded from if not given.",
    842             "list": "List available fixtures or functions.",
    843             "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.",
    844             "quit": "Exit BLC.",
    845             "remove": "Remove the specified chaser from the display. If a letter is used, remove the chaser at the given position, where 'a' is the left-most chaser and so forth. If a number is used, treat it as a chaser ID.",
    846             "add": "Add a chaser to the display. Currently a limit of 2 visible.",
    847             "currentstatus" : "Display information about the system's current status."
    848         }, self.help)
    849 
    850         self.context_chaser = Input.parse_context((
    851             ("choose $num", self.chaser_select),
    852 
    853             ("edit", self.chaser_edit),
    854             ("edit $num", self.chaser_edit),
    855 
    856             ("delete", self.chaser_delete),
    857             ("delete $num", self.chaser_delete),
    858 
    859             ("append $quoted_string", lambda n: self.chaser_new(-1, n)),
    860             ("append $quoted_string from $num", lambda n, s: self.chaser_new(-1, n, s)),
    861             ("append $quoted_string from new scene $quoted_string", lambda n, s: self.chaser_new_new(-1, n, s, Scene)),
    862             ("append $quoted_string from new audio $quoted_string", lambda n, s: self.chaser_new_new(-1, n, s, Audio)),
    863 
    864             ("append", lambda: self.chaser_new(-1, "")),
    865             ("append from $num", lambda s: self.chaser_new(-1, "", s)),
    866             ("append from new scene $quoted_string", lambda s: self.chaser_new_new(-1, "", s, Scene)),
    867             ("append from new audio $quoted_string", lambda s: self.chaser_new_new(-1, "", s, Audio)),
    868 
    869             ("new $num $quoted_string", self.chaser_new),
    870             ("new $num $quoted_string from $num", self.chaser_new),
    871             ("new $num $quoted_string from new scene $quoted_string", lambda i, n, s: self.chaser_new_new(i, n, s, Scene)),
    872             ("new $num $quoted_string from new audio $quoted_string", lambda i, n, s: self.chaser_new_new(i, n, s, Audio)),
    873 
    874             ("rename $quoted_string", self.chaser_rename),
    875             ("rename chaser $quoted_string", self.chaser_rename_self),
    876             ("move $num to $num", self.chaser_move),
    877             ("move $num", self.chaser_move),
    878             ("set fade in $time", self.chaser_fade),
    879             ("set fade out $time", lambda t: self.chaser_fade(t, True)),
    880             ("set mode oneshot", lambda: self.chaser_mode(ONESHOT)),
    881             ("set mode loop", lambda: self.chaser_mode(LOOP)),
    882             ("set mode random", lambda: self.chaser_mode(RANDOM)),
    883             ("set length $time", self.chaser_duration),
    884             ("unbind", self.chaser_unset),
    885             ("bind $num", self.chaser_bind),
    886 
    887             ("list fixtures", self.list_fixtures),
    888             ("list scenes", lambda: self.list_functions(SCENE)),
    889             ("list chasers", lambda: self.list_functions(CHASER)),
    890             ("list audio", lambda: self.list_functions(AUDIO)),
    891             ("list joins", lambda: self.list_functions(JOIN)),
    892 
    893             ("info", lambda: self.chaser_info(None)),
    894             ("info $num", self.chaser_info),
    895 
    896             ("trailer", self.chaser_run),
    897 
    898             ("pager page", self.page), 
    899             ("pager clear", self.page_clear),
    900             ("verify audio", self.verify_audio),
    901             ("quit", self.handle_exit),
    902         ), {
    903             None: "This mode is for editing chasers. All functions (excepting select) which take a number as an argument may be called without to act on the currently selected step.",
    904             "edit": "Edit the given step.",
    905             "select": "Select a step.",
    906             "fade": "Change the fade durations for the selected step.",
    907             "length": "Set the duration for the selected step.",
    908             "new": "Create a new step at the given position with the given name. Can also create a new function to use for the scene. Immediately enters edit mode for that step.",
    909             "delete": "Remove the given step.",
    910             "rename": "Rename the current step.",
    911             "append": "Create a new step at the end of the chaser. See 'new' for details.",
    912             "unset": "Unset the duration of the selected step, inheriting the step's duration from its function.",
    913             "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.",
    914             "move": "Move the given step to the given position",
    915             "bind": "Bind the step to the given function.",
    916             "quit": "Return to the previous mode.", 
    917             "trailer": "Preview the chaser in run mode.",
    918         }, self.help)
    919 
    920         self.context_scene = Input.parse_context((
    921             ("set $channel_range to $value", self.scene_set),
    922             ("clear $channel_range", self.scene_clear),
    923 
    924             ("edit $channel_range", self.scene_edit),
    925             ("edit $channel_range force", lambda cr: self.scene_edit(cr, True)),
    926 
    927             ("list fixtures", self.list_fixtures),
    928             ("list scenes", lambda: self.list_functions(SCENE)),
    929             ("list chasers", lambda: self.list_functions(CHASER)),
    930             ("list audio", lambda: self.list_functions(AUDIO)),
    931             ("list joins", lambda: self.list_functions(JOIN)),
    932 
    933             ("rename $quoted_string", self.primitive_rename),
    934 
    935             ("pager page", self.page), 
    936             ("pager clear", self.page_clear),
    937             ("quit", self.scene_exit),
    938         ), {
    939             None: "This mode is for editing scene primitives for fixed lighting.",
    940             "set": "Set the given channel range to the given value (0 <= value <= 255).",
    941             "reset": "Remove the given channel range from the scene",
    942             "list": "List available fixtures or functions.",
    943             "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.",
    944             "edit": "Live edit scenes using the arrow keys, page up/down, home/end, and h/j/k/l. If \"force\" is used, all matching channels are edited, instead of just the matching ones already in the scope.",
    945             "quit": "Return to the previous mode.",
    946         }, self.help)
    947 
    948         self.context_audio = Input.parse_context((
    949             ("list fixtures", self.list_fixtures),
    950             ("list scenes", lambda: self.list_functions(SCENE)),
    951             ("list chasers", lambda: self.list_functions(CHASER)),
    952 
    953             ("rename $quoted_string", self.primitive_rename),
    954 
    955             ("filename $quoted_string", self.audio_filename),
    956             ("set fade in $time", self.audio_fade),
    957             ("set fade out $time", lambda t: self.audio_fade(t, True)),
    958 
    959             ("pager page", self.page), 
    960             ("pager clear", self.page_clear),
    961             ("verify audio", self.verify_audio),
    962             ("quit", self.audio_exit),
    963         ), {
    964             None: "This mode is for editing audio primitives for single audio files.",
    965             "list": "List available fixtures or functions.",
    966             "rename": "Rename the function.",
    967             "filename": "Set the filename for the function.",
    968             "fade": "Set fade times for the function. Note that unlike lighting, fade out is done during the audio's run.",
    969             "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.",
    970             "quit": "Return to the previous mode.",
    971         }, self.help)
    972 
    973         self.context_run = Input.parse_context((
    974             ("quit", self.run_exit),
    975 
    976             ("jump $letter to $num", lambda n, p: self.run_jump(n, p, True)),
    977             ("jump $num to $num", lambda n, p: self.run_jump(n, p, False)),
    978 
    979             ("advance $letter", lambda n: self.run_jump(n, None, True)),
    980             ("advance $num", lambda n: self.run_jump(n, None, False)),
    981             ("advance", lambda: self.run_jump(0, None, True)),
    982             ("badvance", lambda: self.run_jump(1, None, True)),
    983             ("", lambda: self.run_jump(0, None, True)),
    984 
    985             ("everythingadvance", self.run_advance_all),
    986 
    987             ("currentstatus", self.current_status),
    988         ), {
    989             None: "This mode is for running a show or previewing a chaser.",
    990             "quit": "Return to the previous mode.",
    991             "jump": "Jump the given chaser to the given location.",
    992             "advance": "Advance a chaser a single step. If no letter is given, this is the first from the left.",
    993             "badvance": "Advance the second chaser from the left a single step.",
    994             "everythingadvance": "Advance all chasers a single step each.",
    995             "currentstatus" : "Display information about the system's current status."
    996         }, self.help)
    997 
    998         self.context_join = Input.parse_context((
    999             ("choose $num", self.chaser_select),
   1000 
   1001             ("edit", self.chaser_edit),
   1002             ("edit $num", self.chaser_edit),
   1003 
   1004             ("delete", self.chaser_delete),
   1005             ("delete $num", self.chaser_delete),
   1006 
   1007             ("add $num", lambda s: self.chaser_new(-1, "", s)),
   1008 
   1009             ("rename $quoted_string", self.chaser_rename_self),
   1010 
   1011             ("list fixtures", self.list_fixtures),
   1012             ("list scenes", lambda: self.list_functions(SCENE)),
   1013             ("list chasers", lambda: self.list_functions(CHASER)),
   1014             ("list audio", lambda: self.list_functions(AUDIO)),
   1015             ("list joins", lambda: self.list_functions(JOIN)),
   1016 
   1017             ("pager page", self.page), 
   1018             ("pager clear", self.page_clear),
   1019             ("verify audio", self.verify_audio),
   1020             ("quit", self.handle_exit),
   1021         ))
   1022 
   1023         self.output = output
   1024         self.renderer = Renderer(self.w, self.w_lock, self.output, self._render_callback)
   1025         self.rendering = False
   1026 
   1027         self._channels = sum((tuple(f.channels) for f in sorted(self.w.fixtures.values(), key=lambda i: i.id)), ())