import curses import os import datetime as dt import threading import traceback as tb import blc2 from blc2.functions.audio import Audio from blc2.functions.scene import Scene from blc2.functions.chaser import Chaser from blc2.functions.chaserstep import ChaserStep from blc2.constants import SCENE, CHASER, AUDIO, MANUAL, INHERIT from blc2.topology import Fixture from blc2.workspace import Workspace from .globals import CURSES_LOCK from .input.tabcomp import Input from .channelbank import ChannelBank from .chaserview import ChaserView from .pager import Pager from .dialog import askyesnocancel from .audioview import AudioView from .render import Renderer from .dummy import DummyOutput def wrap_curses(f): def inner(*args, **kwargs): return curses.wrapper(lambda stdscr: f(*args, stdscr, **kwargs)) return inner __version__ = "v0.0.1" class Interface: def _compute_sizes(self, height, width): hb = height // 2 ht = height - hb wr = width // 2 wl = width - wr - 1 chasers = [] if self.chaser_views: current = 0 cw = (wl // len(self.chaser_views)) - 1 for i in range(len(self.chaser_views)): curw = (wl - current) if (i+1) == len(self.chaser_views) else cw chasers.append(((hb, curw), (ht, current))) current += curw + 1 return ( ((ht, width), (0, 0),), ((4, wr), (height-4, wl+1),), ((hb - 4, wr), (ht, wl+1),), *chasers ) def _resize(self): ## FIXME self._actual_resize() self._actual_resize() def _actual_resize(self): 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))): f1(*a1) f2(*a2) @wrap_curses def main(self, stdscr): height, width = stdscr.getmaxyx() self.stdscr = stdscr (cbd, cbp), (ind, inp), (pgd, pgp) = self._compute_sizes(height, width) self.channel_bank = ChannelBank(*cbp, *cbd) self.input = Input(*inp, *ind) self.input.context = self.context_base self.pager = Pager(*pgp, *pgd) todisp = [ "Welcome to BLC2!", "Currently running library %s, interface %s" % (blc2.__version__, __version__), "", ] if self._w_created: todisp.append("Created a new workspace") else: todisp.append("Loaded workspace \"%s\" from \"%s\"" % (self.w.name, self.path)) todisp.append("Authored by %s, last modified at %s" % (self.w.author, self.w.modified.strftime("%Y-%m-%d %H:%M:%S"))) self.pager.display_many(todisp) self.input.main(self._resize) def handle_show(self, f): with self.w_lock: if f.type == SCENE: if not isinstance(self.channel_bank, ChannelBank): (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx()) self.channel_bank = ChannelBank(*yx, *hw) self.channel_bank.set_scope(f.scope) self.channel_bank.set_values(f.values.items()) elif f.type == AUDIO: if not isinstance(self.channel_bank, AudioView): (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx()) self.channel_bank = AudioView(*yx, *hw) self.channel_bank.audio = f self.channel_bank.title = f.type + ' "%s"' % f.name self.renderer.clear_hold() self.renderer.hold(f.values) def handle_enter(self, fid): with self.w_lock: if fid is None: if not self.chaser_views: return "No chaser loaded" f = self.chaser_views[0].chaser else: if fid not in self.w.functions: return "No such function" f = self.w.functions[fid] if self.current_cv is not None: self.current_cv.highlight = False if self.chaser is not None: cv = [c for c in self.chaser_views if c.chaser == self.chaser][0].selected self.chaser_stack.append((self.chaser, cv)) self.chaser = None if f.type == SCENE: self.primitive = f self.handle_show(f) self.channel_bank.highlight = True self.input.context = self.context_scene elif f.type == CHASER: self.chaser = f cv = [c for c in self.chaser_views if c.chaser == f] if not cv: if len(self.chaser_views) == 2: self.chaser_views[1].set_chaser(f, None) else: self.base_add(f.id) self.current_cv = self.chaser_views[-1] else: cv = cv[0] self.current_cv = cv self.current_cv.highlight = True self.input.context = self.context_chaser elif f.type == AUDIO: self.primitive = f self.handle_show(f) self.channel_bank.highlight = True self.input.context = self.context_audio else: ## FIXME return "No other types allowed yet" def base_add(self, fid): with self.w_lock: if fid not in self.w.functions: return "No such function" f = self.w.functions[fid] if f.type != CHASER: return "Can only add chasers!" with CURSES_LOCK: if True in (c.chaser.id for c in self.chaser_views): return "Already added!" elif len(self.chaser_views) == 2: ## FIXME? return "Can only use two for now" self.chaser_views.append(None) ld, lp = self._compute_sizes(*self.stdscr.getmaxyx())[-1] self.chaser_views[-1] = ChaserView(*lp, *ld) self._resize() self.chaser_views[-1].set_chaser(f, None) def base_remove(self, n, index=True): with self.w_lock: if not index: n = [j for j, i in enumerate(self.chaser_views) if i.chaser.id == n] if not n: return "No such chaser loaded" n = n[0] if n >= len(self.chaser_views) or n < 0: return "Index out of range" with CURSES_LOCK: self.chaser_views[n].win.erase() self.chaser_views[n].win.refresh() self.chaser_views.pop(n) self._resize() def base_delete(self, fid): with self.w_lock: if fid not in self.w.functions: return "No such function" f = self.w.functions[fid] f.delete() def base_new(self, name, cls): with self.w_lock: f = cls(self.w, name=name) self.handle_enter(f.id) def _gather_channels(self, cr): with self.w_lock: ## Gather the affected channels channels = [] for f in self.w.fixtures.values(): for s, e in cr[0]: if (s <= f.id <= e) or (e == -1 and f.id >= s): break else: continue for n, c in enumerate(f.channels, 1): for s, e in cr[1]: if (s <= n <= e) or (e == -1 and n >= s): channels.append(c) break return channels def scene_set(self, cr, v): with self.w_lock: channels = self._gather_channels(cr) ## Set the values self.primitive.update({c: v for c in channels}) ## Update the display self.channel_bank.set_scope(self.primitive.scope) if v is not None: self.channel_bank.set_values(((c, v) for c in channels)) self.renderer.clear_hold() self.renderer.hold(self.primitive.values) def scene_clear(self, cr): self.scene_set(cr, None) def scene_exit(self): self.primitive = None self.handle_exit() def audio_exit(self): self.primitive = None self.handle_exit() def handle_exit(self): for c in self.chaser_views: c.highlight = False if self.current_cv is not None: self.current_cv.selected = None self.channel_bank.highlight = False if self.chaser_stack: ## Load the previous chaser c, sel = self.chaser_stack.pop(-1) if True not in (True for i in self.chaser_views if i.chaser.id == c.id): ## We need to load it ## Check if we have one for the current chaser try: idx = [i.chaser.id for i in self.chaser_views].index(self.chaser.id) except (ValueError, AttributeError): ## We don't have it if len(self.chaser_views) == 2: ## We don't have room for it, just replace the last one self.chaser_views[1].set_chaser(c, sel) else: self.base_add(c.id) self.current_cv = self.chaser_views[-1] else: self.chaser_views[idx].set_chaser(c, sel) self.current_cv = self.chaser_views[idx] self.current_cv.highlight = True self.current_cv.selected = sel self.input.context = self.context_chaser self.chaser = c else: if isinstance(self.channel_bank, AudioView): (hw, yx), *_ = self._compute_sizes(*self.stdscr.getmaxyx()) self.channel_bank = ChannelBank(*yx, *hw) self.channel_bank.set_scope(()) self.channel_bank.title = "Channels" self.input.context = self.context_base self.chaser = None self.current_cv = None self.renderer.clear_hold() def base_save(self, path=None): with self.w_lock: if path is not None: self.path = path if self.path is None: return "No path set" self.w.modified = dt.datetime.now() self.w.save(self.path) return "Saved to "+self.path def base_quit(self): if askyesnocancel(self.stdscr, "Really exit?"): quit() else: self._resize() def list_fixtures(self): with self.w_lock: td = ["FIXTURES:"] for f in sorted(self.w.fixtures.values(), key=lambda a: a.id): td.append("- %03d: %s" % (f.id, f.name)) for c in f.channels: td.append(" %03d-%03d: %s" % (f.id, c.id, c.name)) self.pager.display_many(td, split=True) def list_functions(self, typ): with self.w_lock: td = [typ.upper()+"S:"] for f in sorted(self.w.functions.values(), key=lambda a: a.id): if f.type == typ: td.append("- %03d: %s" % (f.id, f.name)) self.pager.display_many(td, split=True) def page(self): self.pager.user_page() def page_clear(self): self.pager.clear() def help(self, name, commands, help_): if name is None: dname = "MODE HELP:" else: dname = name.upper() + " COMMAND:" todisp = [dname, ""] if commands: if name is None: todisp.append("Available commands:") else: todisp.append("Available forms:") todisp.extend(("- "+i for i in commands)) todisp.append("") todisp.extend(help_.split('\n')) self.pager.display_many(todisp, split=True) def chaser_select(self, num): with self.w_lock: if num < 1 or num > len(self.current_cv.chaser.steps): return "Out of range" self.current_cv.selected = num - 1 s = self.current_cv.chaser.steps[num-1] if s.function is not None and s.function.type in (SCENE, AUDIO,): self.handle_show(s.function) def chaser_edit(self, num=None): with self.w_lock: if num is not None: r = self.chaser_select(num) if r is not None: return r elif self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser.steps[self.current_cv.selected] if c.function is None: return "No function on step" self.handle_enter(c.function.id) def chaser_delete(self, num=None): with self.w_lock: if num is not None: r = self.chaser_select(num) if r is not None: return r elif self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] with CURSES_LOCK: if askyesnocancel(self.stdscr, "Really delete step %d?" % (s.index+1), resize=self._resize): sel = s.index s.delete() if not c.steps: sel = None else: sel = max(0, sel-1) self.current_cv.set_chaser(c, sel) self._resize() def chaser_fade(self, t, out=False): with self.w_lock: if self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] if out: s.fade_out = t else: s.fade_in = t self.current_cv.set_chaser(c, s.index) def chaser_duration(self, t): with self.w_lock: if self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] if s.duration_mode != MANUAL: s.duration_mode = MANUAL s.duration = t self.current_cv.set_chaser(c, s.index) def chaser_unset(self): with self.w_lock: if self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] s.duration_mode = INHERIT self.current_cv.set_chaser(c, s.index) def chaser_new(self, index, name, fid=None): with self.w_lock: if fid is not None: if fid not in self.w.functions: return "No such function" f = self.w.functions[fid] if f.type not in (SCENE, AUDIO, CHASER): return "Invalid function" else: f = None c = self.current_cv.chaser s = ChaserStep(c, index=index, name=name, function=f) self.current_cv.set_chaser(c, s.index) self.chaser_edit(s.index+1) def chaser_new_new(self, index, name, fname, type_): with self.w_lock: f = type_(self.w, name=fname) return self.chaser_new(index, name, fid=f.id) def chaser_rename(self, name): with self.w_lock: if self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] s.name = name self.current_cv.set_chaser(c, s.index) def chaser_bind(self, fid): with self.w_lock: if self.current_cv.selected is None: return "No step selected" c = self.current_cv.chaser s = c.steps[self.current_cv.selected] if fid not in self.w.functions: return "No such function" f = self.w.functions[fid] if f.type not in (SCENE, AUDIO, CHASER): return "Invalid function" s.function = f self.current_cv.set_chaser(c, s.index) self.chaser_select(s.index+1) def chaser_move(self, a, b = None): with self.w_lock: cursel = self.current_cv.selected if cursel is not None: cursel = self.current_cv.chaser.steps[cursel] if b is not None: sel = a - 1 if sel < 0 or sel >= len(self.current_cv.chaser.steps): return "Invalid selection" to = b - 1 else: to = a - 1 if self.current_cv.selected is None: return "No step selected" sel = self.current_cv.selected if to < 0 or to >= len(self.current_cv.chaser.steps): return "Invalid destination" c = self.current_cv.chaser s = c.steps[sel] s.index = to self.current_cv.set_chaser(c, cursel.index if cursel is not None else None) def primitive_rename(self, name): with self.w_lock: self.primitive.name = name self.channel_bank.title = self.primitive.type + ' "%s"' % name def chaser_rename_self(self, name): with self.w_lock: c = self.current_cv.chaser c.name = name self.current_cv.set_chaser(c, self.current_cv.selected) def audio_fade(self, t, out=False): with self.w_lock: if out: self.primitive.fade_out = t else: self.primitive.fade_in = t self.channel_bank.audio = self.primitive def audio_filename(self, fname): with self.w_lock: self.primitive.filename = fname self.channel_bank.audio = self.primitive def _render_callback(self, t, values): if not self.rendering: return with self.w_lock, CURSES_LOCK: syx = self.input.win.getyx() self.channel_bank.set_values(values) self.channel_bank.title = "LIVE: %7.2fs" % t for d, cv in zip(self.renderer._data, self.chaser_views): cv.selected = d.steps[-1].index if d.steps else None self.input.win.move(*syx) def base_run(self): if not self.chaser_views: return "No chasers loaded" self.channel_bank.set_scope(self._channels) self.channel_bank.title = "LIVE: %7.2fs" % 0 self.channel_bank.highlight = True self.rendering = True self.renderer.set_functions(*((cv.chaser, cv.chaser.get_data()) for cv in self.chaser_views)) self.renderer.start() self.input.context = self.context_run return "Started running" def run_exit(self): with self.w_lock: self.rendering = False self.renderer.stop() self.channel_bank.title = "Channels" self.channel_bank.highlight = False self.channel_bank.set_scope(()) self.input.context = self.context_base for cv in self.chaser_views: cv.selected = None def run_jump(self, n, p, index): if not index: n = [j for j, i in enumerate(self.chaser_views) if i.chaser.id == n] if not n: return "No such chaser loaded" n = n[0] if n >= len(self.chaser_views) or n < 0: return "Chaser index out of range" if p is not None and (p < 1 or p > len(self.chaser_views[n].chaser.steps)): return "Step index out of range" self.renderer.advance((n, (p-1) if p is not None else p)) def __init__(self, path, output): ## Have to do most of the actual initialization in the main method, as curses isn't ## ready yet. self.channel_bank = None self.input = None self.pager = None self.stdscr = None self.primitive = None self.chaser = None self.current_cv = None self.path = path self._w_created = False if path is None or not os.path.isfile(path): self.w = Workspace("", "", 0, dt.datetime.now()) self._w_created = True else: self.w = Workspace.load(path) self.w_lock = threading.RLock() self.chaser_views = [] self.chaser_stack = [] self.context_base = Input.parse_context(( ("edit $num", self.handle_enter), ("edit", lambda: self.handle_enter(None)), ("delete $num", self.base_delete), ("new scene $quoted_string", lambda n: self.base_new(n, Scene)), ("new chaser $quoted_string", lambda n: self.base_new(n, Chaser)), ("new audio $quoted_string", lambda n: self.base_new(n, Audio)), ("add $num", self.base_add), ("subtract $letter", lambda n: self.base_remove(n, True)), ("subtract $num", lambda n: self.base_remove(n, False)), ("run", self.base_run), ("write", self.base_save), ("write $quoted_string", self.base_save), ("list fixtures", self.list_fixtures), ("list scenes", lambda: self.list_functions(SCENE)), ("list chasers", lambda: self.list_functions(CHASER)), ("list audio", lambda: self.list_functions(AUDIO)), ("pager page", self.page), ("pager clear", self.page_clear), ("quit", self.base_quit), ), { None: "This is the base edit mode for editing functions.", "edit": "Edit the specified function.", "delete": "Delete the specified function.", "new": "Create a new function of the given type.", "save": "Save the workspace. The path is implicitly the one loaded from if not given.", "list": "List available fixtures or functions.", "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.", "quit": "Exit BLC.", "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.", "add": "Add a chaser to the display. Currently a limit of 2 visible.", }, self.help) self.context_chaser = Input.parse_context(( ("choose $num", self.chaser_select), ("edit", self.chaser_edit), ("edit $num", self.chaser_edit), ("delete", self.chaser_delete), ("delete $num", self.chaser_delete), ("append $quoted_string", lambda n: self.chaser_new(-1, n)), ("append $quoted_string from $num", lambda n, s: self.chaser_new(-1, n, s)), ("append $quoted_string from new scene $quoted_string", lambda n, s: self.chaser_new_new(-1, n, s, Scene)), ("append $quoted_string from new audio $quoted_string", lambda n, s: self.chaser_new_new(-1, n, s, Audio)), ("new $num $quoted_string", self.chaser_new), ("new $num $quoted_string from $num", self.chaser_new), ("new $num $quoted_string from new scene $quoted_string", lambda i, n, s: self.chaser_new_new(i, n, s, Scene)), ("new $num $quoted_string from new audio $quoted_string", lambda i, n, s: self.chaser_new_new(i, n, s, Audio)), ("rename $quoted_string", self.chaser_rename), ("rename chaser $quoted_string", self.chaser_rename_self), ("move $num to $num", self.chaser_move), ("move $num", self.chaser_move), ("set fade in $time", self.chaser_fade), ("set fade out $time", lambda t: self.chaser_fade(t, True)), ("set length $time", self.chaser_duration), ("unbind", self.chaser_unset), ("bind $num", self.chaser_bind), ("list fixtures", self.list_fixtures), ("list scenes", lambda: self.list_functions(SCENE)), ("list chasers", lambda: self.list_functions(CHASER)), ("list audio", lambda: self.list_functions(AUDIO)), ("pager page", self.page), ("pager clear", self.page_clear), ("quit", self.handle_exit), ), { 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.", "edit": "Edit the given step.", "select": "Select a step.", "fade": "Change the fade durations for the selected step.", "length": "Set the duration for the selected step.", "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.", "delete": "Remove the given step.", "rename": "Rename the current step.", "append": "Create a new step at the end of the chaser. See 'new' for details.", "unset": "Unset the duration of the selected step, inheriting the step's duration from its function.", "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.", "move": "Move the given step to the given position", "bind": "Bind the step to the given function.", "quit": "Return to the previous mode.", }, self.help) self.context_scene = Input.parse_context(( ("set $channel_range to $value", self.scene_set), ("reset $channel_range", self.scene_clear), ("list fixtures", self.list_fixtures), ("list scenes", lambda: self.list_functions(SCENE)), ("list chasers", lambda: self.list_functions(CHASER)), ("list audio", lambda: self.list_functions(AUDIO)), ("rename $quoted_string", self.primitive_rename), ("pager page", self.page), ("pager clear", self.page_clear), ("quit", self.scene_exit), ), { None: "This mode is for editing scene primitives for fixed lighting.", "set": "Set the given channel range to the given value (0 <= value <= 255).", "reset": "Remove the given channel range from the scene", "list": "List available fixtures or functions.", "pager": "Control the pager. In page mode, arrow keys, page up/down, home/end, and j/k can be used to scroll, q exits.", "quit": "Return to the previous mode.", }, self.help) self.context_audio = Input.parse_context(( ("list fixtures", self.list_fixtures), ("list scenes", lambda: self.list_functions(SCENE)), ("list chasers", lambda: self.list_functions(CHASER)), ("rename $quoted_string", self.primitive_rename), ("filename $quoted_string", self.audio_filename), ("fade in $time", self.audio_fade), ("fade out $time", lambda t: self.audio_fade(t, True)), ("quit", self.audio_exit), )) self.context_run = Input.parse_context(( ("quit", self.run_exit), ("jump $letter to $num", lambda n, p: self.run_jump(n, p, True)), ("jump $num to $num", lambda n, p: self.run_jump(n, p, False)), ("advance $letter", lambda n: self.run_jump(n, None, True)), ("advance $num", lambda n: self.run_jump(n, None, False)), ("advance", lambda: self.run_jump(0, None, True)), ("badvance", lambda: self.run_jump(1, None, True)), )) self.output = output self.renderer = Renderer(self.w, self.w_lock, self.output, self._render_callback) self.rendering = False self._channels = sum((tuple(f.channels) for f in sorted(self.w.fixtures.values(), key=lambda i: i.id)), ())