summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--interface/__main__.py10
-rwxr-xr-xinterface/channelbank.py119
-rw-r--r--interface/globals.py3
-rw-r--r--interface/input/__init__.py0
-rwxr-xr-xinterface/input/parsers.py165
-rwxr-xr-xinterface/input/tabcomp.py247
-rw-r--r--interface/interface.py223
-rwxr-xr-xinterface/pager.py149
8 files changed, 874 insertions, 42 deletions
diff --git a/interface/__main__.py b/interface/__main__.py
new file mode 100644
index 0000000..a8c2738
--- /dev/null
+++ b/interface/__main__.py
@@ -0,0 +1,10 @@
+import datetime as dt
+import os
+import sys
+
+from .interface import Interface
+
+if len(sys.argv) > 2:
+ raise ValueError("Usage: %s [workspace file]" % sys.argv[0])
+
+Interface(sys.argv[1] if len(sys.argv) == 2 else None).main()
diff --git a/interface/channelbank.py b/interface/channelbank.py
index c9d971f..c7c7409 100755
--- a/interface/channelbank.py
+++ b/interface/channelbank.py
@@ -1,5 +1,7 @@
import curses
+from .globals import CURSES_LOCK
+
_progress = [" "]
for i in range(8):
pos = i // 2
@@ -30,13 +32,15 @@ class ChannelView:
height = 3
def _refresh_channel(self):
- self.win.addstr(0, 0, "%03d.%03d" % (self.c.f.id, self.c.id), curses.A_UNDERLINE if self._active else 0)
- self.win.noutrefresh()
+ with CURSES_LOCK:
+ self.win.addstr(0, 0, "%03d.%03d" % (self.c.f.id, self.c.id), curses.A_UNDERLINE if self._active else 0)
+ self.win.refresh()
def _refresh_value(self):
- self.win.addstr(1, 0, get_progress(self._value))
- self.win.addstr(1, 4, "%03d" % self._value, (curses.A_ITALIC|curses.A_BOLD if self._held else 0))
- self.win.noutrefresh()
+ with CURSES_LOCK:
+ self.win.addstr(1, 0, get_progress(self._value))
+ self.win.addstr(1, 4, "%03d" % self._value, (curses.A_ITALIC|curses.A_BOLD if self._held else 0))
+ self.win.refresh()
def refresh(self):
self._refresh_value()
@@ -75,8 +79,9 @@ class ChannelView:
def set_pos(self, y, x):
self.y = self.y
self.x = self.x
- self.win.noutrefresh()
- self.win.mvwin(y, x)
+ with CURSES_LOCK:
+ self.win.refresh()
+ self.win.mvwin(y, x)
self.refresh()
def __init__(self, root, c, y, x, value=0, held=False, active=False):
@@ -89,7 +94,8 @@ class ChannelView:
self.x = x
self.root = root
- self.win = root.subpad(self.height, self.width, self.y, self.x)
+ with CURSES_LOCK:
+ self.win = root.subpad(self.height, self.width, self.y, self.x)
self._refresh_value()
self._refresh_channel()
@@ -100,45 +106,50 @@ class ChannelBank:
raise NotImplementedError("Screen size to small")
- if (height, width) != (self._height, self._width):
- self.win.erase()
- self.win.noutrefresh()
+ with CURSES_LOCK:
+ if (height, width) != (self._height, self._width):
+ self.win.erase()
+ self.win.refresh()
- self.win.resize(height, width)
- self.win.redrawwin()
+ self.win.resize(height, width)
+ self.win.redrawwin()
- self._height = height
- self._width = width
- self._refresh_scope()
- self.win.border()
- self.win.noutrefresh()
+ self._height = height
+ self._width = width
+ self._refresh_scope()
+ self._put_title()
+ self.win.refresh()
def _refresh_scope(self):
- self.win.erase()
- ncv = {}
- cols = (self._width-4)//ChannelView.width
- with open("out.txt", 'w+') as f:
- f.write(str(cols)+'\n')
- self._sscope = sorted(self.scope, key=lambda a: (a.f.id, a.id))
- for n, c in enumerate(self._sscope):
- row = (n // cols)*ChannelView.height + 1
- col = (n % cols)*ChannelView.width + 2
- if c in self._cv:
- active = self._cv[c].active
- held = self._cv[c].held
- value = self._cv[c].value
- else:
- active = False
- held = False
- value = 0
- ncv[c] = ChannelView(self.win, c, row, col, value=value, active=active, held=held)
- self._cv = ncv
+ with CURSES_LOCK:
+ self.win.erase()
+ ncv = {}
+ cols = (self._width-4)//ChannelView.width
+ with open("out.txt", 'w+') as f:
+ f.write(str(cols)+'\n')
+ self._sscope = sorted(self.scope, key=lambda a: (a.f.id, a.id))
+ for n, c in enumerate(self._sscope):
+ row = (n // cols)*ChannelView.height + 1
+ col = (n % cols)*ChannelView.width + 2
+ if c in self._cv:
+ active = self._cv[c].active
+ held = self._cv[c].held
+ value = self._cv[c].value
+ else:
+ active = False
+ held = False
+ value = 0
+ ncv[c] = ChannelView(self.win, c, row, col, value=value, active=active, held=held)
+ self._cv = ncv
+ self._put_title()
+ self.win.refresh()
def set_pos(self, y, x):
if (y, x) != (self._y, self._x):
- self.win.mvwin(y, x)
- self.win.border()
- self.win.noutrefresh()
+ with CURSES_LOCK:
+ self.win.mvwin(y, x)
+ self._put_title()
+ self.win.refresh()
def set_active(self, channels, v=True):
for c in channels:
@@ -151,12 +162,36 @@ class ChannelBank:
def set_values(self, cv):
for c, v in cv:
self._cv[c].value = v
+
+ def set_scope(self, scope):
+ self.scope = frozenset(scope)
+ self._refresh_scope()
+
+ @property
+ def title(self):
+ return self._title
+
+ def _put_title(self):
+ self.win.border()
+ pos = min(self._width-2-len(self._title), (3*self._width)//4 - (len(self._title) // 2))
+ self.win.addstr(self._height-1, pos, self._title)
+
+ @title.setter
+ def title(self, v):
+ if v != self._title:
+ with CURSES_LOCK:
+ self._title = v
+ self.win.border()
+ self._put_title()
+ self.win.refresh()
def __init__(self, y, x, height, width, scope=frozenset()):
- self.win = curses.newwin(height, width, y, x)
- self.win.keypad(True)
+ with CURSES_LOCK:
+ self.win = curses.newwin(height, width, y, x)
+ self.win.keypad(True)
self.scope = frozenset(scope)
self._sscope = []
+ self._title = "Channels"
self._cv = {}
self._height = height
self._width = width
diff --git a/interface/globals.py b/interface/globals.py
new file mode 100644
index 0000000..64e70a6
--- /dev/null
+++ b/interface/globals.py
@@ -0,0 +1,3 @@
+import threading
+
+CURSES_LOCK = threading.RLock()
diff --git a/interface/input/__init__.py b/interface/input/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/interface/input/__init__.py
diff --git a/interface/input/parsers.py b/interface/input/parsers.py
new file mode 100755
index 0000000..5e53e03
--- /dev/null
+++ b/interface/input/parsers.py
@@ -0,0 +1,165 @@
+def parse_interval(s):
+ if not s:
+ return None, s
+ dash = False
+ init_s = s
+ buff = ""
+ r = [-1, -1]
+ i = 0
+ while s:
+ if not dash and s[0] == '-':
+ if buff:
+ r[0] = int(buff)
+ buff = ""
+ dash = True
+ elif s[0] in "0123456789":
+ buff += s[0]
+ else:
+ break
+ s = s[1:]
+ i += 1
+
+ if buff:
+ r[1 if dash else 0] = int(buff)
+ elif not buff and not dash:
+ return None, s, None
+
+ if not dash:
+ r[1] = r[0]
+
+ return tuple(r), s, init_s[:i]
+
+def parse_range(s):
+ if not s:
+ return None, s, None
+
+ rs = []
+ disp = []
+ tc = False
+ while s:
+ r, s, d = parse_interval(s)
+ if r is None:
+ break
+ tc = False
+ rs.append(r)
+ disp.append(d)
+ if s and s[0] == ',':
+ s = s[1:]
+ tc = True
+ else:
+ break
+
+ if not rs:
+ return None, s, None
+ return tuple(rs), s, ", ".join(disp) + (", " if tc else "")
+
+def parse_channelrange(s):
+ if not s:
+ return None, s, None
+
+ f, s, d1 = parse_range(s)
+ if f is None:
+ return None, s, None
+ elif not s or s[0] != ';':
+ return (f, ((-1,-1),)), s, d1
+
+
+ s = s[1:]
+ c, s, d2 = parse_range(s)
+ if c is None:
+ return (f, ((-1, -1),)), s, d1 + "; "
+
+ return (f, c), s, d1+"; "+d2
+
+def parse_value(s):
+ if not s:
+ return None, s
+
+ buff = ""
+ while s:
+ if s[0] in "0123456789" and int(buff+s[0]) < 256:
+ buff += s[0]
+ s = s[1:]
+ else:
+ break
+
+ return (None if not buff else int(buff)), s, buff
+
+def parse_num(s):
+ if not s:
+ return None, s
+
+ buff = ""
+ while s:
+ if s[0] in "0123456789":
+ buff += s[0]
+ s = s[1:]
+ else:
+ break
+
+ return (None if not buff else int(buff)), s, buff
+
+def make_parse_letter(letter, display):
+ def inner(s):
+ if not s or s[0] != letter:
+ return None, s, None
+ return True, s[1:], display
+
+ return inner
+
+def parse_null(s):
+ return True, s, ""
+
+def parse_string(s):
+ if not s:
+ return None, s, None
+
+ buff = ""
+ while s:
+ if s[0] != ' ':
+ buff += s[0]
+ s = s[1:]
+ else:
+ if not buff:
+ return None, s, None
+ break
+
+ return buff, s, buff
+
+def parse_quotedstring(s):
+ if not s:
+ return None, s, None
+
+ if s[0] != "'":
+ return None, s, None
+ s = s[1:]
+
+ buff = ""
+ bs = False
+ while s:
+ if s[0] == '\\':
+ buff += s[0]
+ bs = not bs
+ elif s[0] == "'":
+ if bs:
+ buff += s[0]
+ bs = False
+ else:
+ s = s[1:]
+ break
+ else:
+ buff += s[0]
+ bs = False
+ s = s[1:]
+ else:
+ return buff, s, "'"+buff
+ return buff, s, "'"+buff+"'"
+
+PARSE_MAP = {
+ "$channel_range": parse_channelrange,
+ "$value": parse_value,
+ "$null": parse_null,
+ "$string": parse_string,
+ "$num": parse_num,
+ "$quoted_string": parse_quotedstring,
+}
diff --git a/interface/input/tabcomp.py b/interface/input/tabcomp.py
new file mode 100755
index 0000000..330c948
--- /dev/null
+++ b/interface/input/tabcomp.py
@@ -0,0 +1,247 @@
+#!/usr/bin/env python3
+
+import curses
+import threading
+
+from .parsers import PARSE_MAP, parse_null, make_parse_letter
+from ..globals import CURSES_LOCK
+
+class _Node:
+ def __repr__(self):
+ return self.path
+
+ def __init__(self, parent, parse, var: bool = False, f = None, path = ""):
+ self.f = f
+ self.parent = parent
+ self.parse = parse
+ self.var = var
+
+ self.children = []
+ self.name = path
+ if parent is not None:
+ parent.children.append(self)
+ self.path = parent.path + (' ' if parent.path else "") + path
+ else:
+ self.path = path
+
+#options_help = {
+# "test": "This is a test command",
+# "set": "Set a channel range to the given value",
+# "reset": "Reset the given channels, or all",
+#}
+#
+#root = parse_options([(o.split(' '), f) for o, f in options_list])
+#def iter_possible(start):
+# if None in start.children:
+# yield start
+# if [i for i in start.children if i is not None]:
+# for c in start.children:
+# if c is None:
+# continue
+# yield from iter_possible(c)
+#
+#help_map = {}
+#for s in root.children:
+# if s is None:
+# continue
+# help_map[s.path] = list(iter_possible(s))
+# print("===", s.path.upper())
+# print((options_help[s.path] if s.path in options_help else "No help given"))
+# for p in help_map[s.path]:
+#
+# print('-', p)
+
+options_list = (
+ ("test potato $value one two", lambda *args: ('1')),
+ ("test potato two", lambda *args: ('2')),
+ ("test walnut alpha", lambda *args: ('3')),
+ ("test walnut alpha beta", lambda *args: ('4')),
+ ("set $channel_range to $value", lambda cr, v: ("Channel range %s at %s" % (repr(cr), repr(v)))),
+ ("reset $channel_range", lambda cr: ("reset "+repr(cr))),
+ ("reset", lambda: ("reset all")),
+)
+
+class Input:
+ @staticmethod
+ def parse_context(ctx, parent=None):
+ if parent is None:
+ parent = _Node(None, parse_null, None)
+
+ start = {}
+ for i, f in ctx:
+ if isinstance(i, str):
+ i = i.split(' ')
+ if i[0] not in start:
+ start[i[0]] = []
+ start[i[0]].append((i, f))
+
+ for s, ols in start.items():
+ n = _Node(parent, PARSE_MAP[s] if s[0] == '$' else make_parse_letter(s[0], s),
+ var=(s[0] == '$'), path=s)
+ ols = [(ol[1:], f) for ol, f in ols]
+ for l, f in ols:
+ if not l:
+ n.f = f
+ n.children.append(None)
+ break
+ ols = [i for i in ols if i[0]]
+ if ols:
+ Input.parse_context(ols, parent=n)
+
+ return parent
+
+ def set_dim(self, height, width):
+ if height < 4 or width < 10:
+ raise ValueError("Size too small")
+
+ with self._lock:
+ if (height, width) != (self._height, self._width):
+ self.win.erase()
+ self.win.noutrefresh()
+
+ self.win.resize(height, width)
+ self.win.redrawwin()
+
+ self._height = height
+ self._width = width
+ self.win.border()
+ self._redraw()
+ self.win.noutrefresh()
+
+ def set_pos(self, y, x):
+ with self._lock:
+ if (y, x) != (self._y, self._x):
+ self.win.mvwin(y, x)
+ self._y = y
+ self._x = x
+ self.win.border()
+ self.win.noutrefresh()
+
+ def _redraw(self):
+ with self._lock:
+ if len(self._l2) > self._width-2:
+ l2 = self._l2[:self._width-3] + '…'
+ else:
+ l2 = self._l2
+
+ if len(self._l1) > self._width-5:
+ l1 = ">> …" + self._l1[::-1][:self._width-6][::-1]
+ else:
+ l1 = ">> " + self._l1
+ self.win.addstr(1, 1, ' '*(self._width-2))
+ self.win.addstr(2, 1, ' '*(self._width-2))
+ self.win.addstr(1, 1, l1)
+ self.win.addstr(2, 1, l2, curses.A_ITALIC)
+ self.win.move(1, len(l1)+1)
+ self.win.refresh()
+
+ @property
+ def context(self):
+ return self._context
+
+ @context.setter
+ def context(self, ctx):
+ with self._ctx_lock:
+ self._context = ctx
+ self._ctx_changed = True
+ with CURSES_LOCK:
+ self._l1 = ""
+ self._l2 = ""
+ self._redraw()
+
+ def main(self, resize=None):
+ """Run the input loop.
+
+ If `resize` is given, it will be called should the terminal be resized.
+ """
+ with self._ctx_lock:
+ current = self._context
+ ## In the format:
+ ## (input, parsed, display, is variable?)
+ path = [["", True, "", False]]
+ while True:
+ with self._ctx_lock:
+ self._l1 = "".join((i[2] for i in path if i[2]))
+ with CURSES_LOCK:
+ self._redraw()
+ l = self.win.getch()
+ with self._ctx_lock:
+ if l == curses.KEY_RESIZE:
+ if resize is not None:
+ resize()
+ continue
+ if self._context is None:
+ continue
+ elif self._ctx_changed:
+ path = path[:1]
+ self._ctx_changed = False
+ self._l2 = ""
+ if l in (127, curses.KEY_BACKSPACE): ## Backspace
+ if current == self._context:
+ continue
+ e = path[-1]
+ e[0] = e[0][:-1]
+ if not e[0]:
+ path.pop(-1)
+ current = current.parent
+ continue
+ e[1], _, e[2] = current.parse(e[0])
+ elif l in (10, curses.KEY_ENTER) and None in current.children: ## Enter
+ ret = current.f(*(i[1] for i in path if i[3]))
+ self._l2 = "OK" if ret is None else str(ret)
+ path = path[:1]
+ current = self._context
+ else:
+ e = path[-1]
+ s = e[0] + chr(l)
+ parsed, s, display = current.parse(s)
+ if parsed is None and s: ## Invalid input
+ self._l2 = "Expected \"%s\"" % current.name
+ continue
+ e[1], e[2] = parsed, display
+ if not s: ## We're still working on this one
+ e[0] += chr(l)
+ continue
+ ## We're done with this one, the only remaining option is to move on
+ for n in current.children:
+ if n is None:
+ continue
+ parsed, cs, display = n.parse(s)
+ if not cs:
+ ## Found it
+ if current.parent is not None:
+ e[2] = e[2] + ' '
+ current = n
+ e = [s, parsed, display, n.var]
+ path.append(e)
+ break
+ else:
+ self._l2 = "Available: %s" % ", ".join((("ENTER" if n is None else '"'+n.name+'"') for n in current.children))
+
+ def __init__(self, y, x, height, width):
+ with CURSES_LOCK:
+ self.win = curses.newwin(height, width, y, x)
+ self.win.keypad(True)
+ self._lock = threading.RLock()
+ self._height = height
+ self._width = width
+ self._y = -1
+ self._x = -1
+ self._l1 = ""
+ self._l2 = ""
+ self.set_pos(y, x)
+ self.set_dim(height, width)
+
+ self._ctx_lock = threading.RLock()
+ self._context = None
+ self._ctx_changed = False
+
+ self._redraw()
+
+def main2(stdscr):
+ w = Input(0, 0, 4, 100)
+ w.context = Input.parse_context(options_list)
+ w.main()
+
+if __name__ == "__main__":
+ curses.wrapper(main2)
diff --git a/interface/interface.py b/interface/interface.py
new file mode 100644
index 0000000..c9f2a6e
--- /dev/null
+++ b/interface/interface.py
@@ -0,0 +1,223 @@
+import curses
+import os
+import datetime as dt
+import threading
+
+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
+
+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 .pager import Pager
+
+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:
+ @staticmethod
+ def _compute_sizes(height, width):
+ hb = height // 2
+ ht = height - hb
+
+ wr = width // 2
+ wl = width - wr - 1
+
+ return (
+ (ht, width), (0, 0),
+ (4, wr), (height-4, wl+1),
+ (hb - 4, wr), (ht, wl+1),
+ )
+
+ def _resize(self):
+ for a, f 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)):
+ f(*a)
+
+ @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 lib %s, int %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 base_edit(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 != SCENE:
+ ## FIXME
+ return "Can only edit scenes so far"
+ with CURSES_LOCK:
+ self.primitive = f
+ self.channel_bank.set_scope(f.scope)
+ self.channel_bank.set_values(f.values.items())
+ self.channel_bank.title = f.type + ' "%s"' % f.name
+ self.input.context = self.context_scene
+
+ 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_scene(self, name):
+ with self.w_lock:
+ f = Scene(self.w, name=name)
+ self.base_edit(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))
+
+ def scene_clear(self, cr):
+ self.scene_set(cr, None)
+
+ def scene_exit(self):
+ self.input.context = self.context_base
+ self.primitive = None
+ self.channel_bank.set_scope(())
+ self.channel_bank.title = "Channels"
+
+ 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):
+ quit()
+
+ 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; %3dc: %s" % (f.id, len(f.channels), f.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 __init__(self, path):
+ ## 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.chasers = []
+
+ 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.context_base = Input.parse_context((
+ ("edit $num", self.base_edit),
+ ("delete $num", self.base_delete),
+ ("new scene $quoted_string", self.base_new_scene),
+
+ ("save", self.base_save),
+ ("save $quoted_string", self.base_save),
+
+ ("list fixtures", self.list_fixtures),
+ ("list scenes", lambda: self.list_functions(SCENE)),
+
+ ("page", self.page),
+ ("clrpage", self.page_clear),
+ ("quit", self.base_quit),
+ ))
+
+ 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)),
+
+ ("page", self.page),
+ ("clrpage", self.page_clear),
+ ("quit", self.scene_exit),
+ ))
diff --git a/interface/pager.py b/interface/pager.py
new file mode 100755
index 0000000..2203e52
--- /dev/null
+++ b/interface/pager.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+
+import curses
+import threading
+
+from .globals import CURSES_LOCK
+
+class Pager:
+ def set_dim(self, height, width):
+ if height < 4 or width < 10:
+ raise ValueError("Size too small")
+
+ with self._lock:
+ if (height, width) != (self._height, self._width):
+ self.win.erase()
+ self.win.noutrefresh()
+
+ self.win.resize(height, width)
+ self.win.redrawwin()
+
+ self._height = height
+ self._width = width
+ self._regen_actual()
+ self._redraw()
+
+ def set_pos(self, y, x):
+ with self._lock:
+ if (y, x) != (self._y, self._x):
+ self.win.mvwin(y, x)
+ self._y = y
+ self._x = x
+ self.win.noutrefresh()
+
+ def _redraw(self):
+ with self._lock, CURSES_LOCK:
+ start = max(0, self._bottom_a - self._height + 2)
+ end = self._bottom_a
+ todisp = self._actual[start:end]
+ if len(todisp) < self._height-2 and len(todisp) < len(self._actual):
+ end = self._bottom_a+self._height-2-len(todisp)
+ todisp += self._actual[self._bottom_a:end]
+ self.win.erase()
+ self.win.border()
+ if start > 0:
+ self.win.addch(0, self._width//2, '⯅')
+ for n, l in enumerate(todisp, 1):
+ self.win.addstr(n, 1, l)
+ if end < len(self._actual):
+ self.win.addch(self._height-1, self._width//2, '⯆')
+ self.win.refresh()
+
+ def _split_line(self, l):
+ with self._lock:
+ lines = []
+
+ while len(l) >= self._width-2:
+ lines.append(l[:self._width-3] + '…')
+ l = "⮡ "+l[self._width-3:]
+ lines.append(l)
+
+ return lines
+
+ def _regen_actual(self):
+ with self._lock:
+ self._actual = []
+ for n, l in enumerate(self._lines):
+ self._actual += self._split_line(l)
+ if n == self._bottom:
+ self._bottom_a = len(self._actual)
+ self._bottom_a = max(self._bottom_a, self._height-2)
+
+ def display_many(self, s, split=False):
+ with self._lock:
+ if split:
+ self._lines.append("")
+ self._actual.append("")
+ for l in s:
+ self._lines.append(l)
+ self._actual += self._split_line(l)
+ self._bottom = len(self._lines) - 1
+ self._bottom_a = len(self._actual)
+ self._redraw()
+
+ def display(self, s):
+ self.display_many((s, ))
+
+ def user_page(self, refresh=None):
+ curses.curs_set(0)
+
+ while True:
+ l = self.win.getch()
+
+ with self._lock:
+ if l == curses.KEY_RESIZE:
+ if refresh is not None:
+ refresh()
+ elif l == ord('q'):
+ break
+ elif l in (ord('k'), curses.KEY_UP):
+ if self._bottom_a > self._height-2:
+ self._bottom_a -= 1
+ self._redraw()
+ elif l in (ord('j'), curses.KEY_DOWN):
+ if self._bottom_a < len(self._actual):
+ self._bottom_a += 1
+ self._redraw()
+ elif l == curses.KEY_NPAGE:
+ if self._bottom_a < len(self._actual):
+ self._bottom_a = min(len(self._actual), self._bottom_a + self._height - 2)
+ self._redraw()
+ elif l == curses.KEY_PPAGE:
+ if self._bottom_a > 0:
+ self._bottom_a = max(self._height - 2, self._bottom_a - self._height + 2)
+ self._redraw()
+ elif l == curses.KEY_HOME:
+ if self._bottom_a > self._height - 2:
+ self._bottom_a = self._height - 2
+ self._redraw()
+ elif l == curses.KEY_END:
+ if self._bottom_a < len(self._actual):
+ self._bottom_a = len(self._actual)
+ self._redraw()
+
+ curses.curs_set(1)
+
+ def clear(self):
+ with self._lock:
+ self._lines = []
+ self._actual = []
+ self._bottom_a = 0
+ self._bottom = -1
+ self._redraw()
+
+ def __init__(self, y, x, height, width):
+ with CURSES_LOCK:
+ self.win = curses.newwin(height, width, y, x)
+ self.win.keypad(True)
+ self._lock = threading.RLock()
+ self._height = height
+ self._width = width
+ self._y = -1
+ self._x = -1
+ self._lines = []
+ self._actual = []
+ self._bottom = -1
+ self._bottom_a = 0
+
+ self.set_pos(y, x)
+ self.set_dim(height, width)