diff --git a/qcodes/widgets/display.py b/qcodes/widgets/display.py index fab1dfb15fe..6a705dbb2cd 100644 --- a/qcodes/widgets/display.py +++ b/qcodes/widgets/display.py @@ -1,28 +1,34 @@ +"""Helper for adding content stored in a file to a jupyter notebook.""" import os from pkg_resources import resource_string from IPython.display import display, Javascript, HTML +# Originally I implemented this using regular open() and read(), so it +# could use relative paths from the importing file. +# +# But for distributable packages, pkg_resources.resource_string is the +# best way to load data files, because it works even if the package is +# in an egg or zip file. See: +# http://pythonhosted.org/setuptools/setuptools.html#accessing-data-files-at-runtime + def display_auto(qcodes_path, file_type=None): - ''' - Display some javascript, css, or html content in the notebook - from a package-relative file path. Will use the file extension - to determine file type unless overridden by file_type - - qcodes_path: the path to the target file within the qcodes package - - file_type: optionally override the file extension to determine - what type of file this is - ''' - - # Originally I implemented this using regular open() and read(), so it - # could use relative paths from the importing file. - # - # But for distributable packages, pkg_resources.resource_string is the - # best way to load data files, because it works even if the package is - # in an egg or zip file. See: - # http://pythonhosted.org/setuptools/setuptools.html#accessing-data-files-at-runtime + """ + Display some javascript, css, or html content in a jupyter notebook. + + Content comes from a package-relative file path. Will use the file + extension to determine file type unless overridden by file_type + + Args: + qcodes_path (str): the path to the target file within the qcodes + package, like 'widgets/widgets.js' + + file_type (Optional[str]): Override the file extension to determine + what type of file this is. Case insensitive, supported values + are 'js', 'css', and 'html' + """ contents = resource_string('qcodes', qcodes_path).decode('utf-8') + if file_type is None: ext = os.path.splitext(qcodes_path)[1].lower() elif 'js' in file_type.lower(): diff --git a/qcodes/widgets/widgets.css b/qcodes/widgets/widgets.css index 8a197fe9ea9..ab4204ae5e4 100644 --- a/qcodes/widgets/widgets.css +++ b/qcodes/widgets/widgets.css @@ -9,10 +9,37 @@ box-shadow: 0 0 12px 1px rgba(87, 87, 87, 0.2); } -.qcodes-output-header { +.qcodes-output-header { float: right; } +.qcodes-highlight { + animation: pulse 1s linear; + background-color: #fa4; +} + +@keyframes pulse { + 0% { + background-color: #f00; + } + 100% { + background-color: #fa4; + } +} + +.qcodes-process-list { + float: left; + max-width: 780px; + margin: 3px 5px 3px 10px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.qcodes-output-view[qcodes-state=minimized] .qcodes-process-list { + max-width: 300px; +} + .qcodes-output-view span { padding: 2px 6px 3px 12px; } diff --git a/qcodes/widgets/widgets.js b/qcodes/widgets/widgets.js index 8188d64e52b..0e99c2169ff 100644 --- a/qcodes/widgets/widgets.js +++ b/qcodes/widgets/widgets.js @@ -56,11 +56,14 @@ require([ var SubprocessView = UpdateView.extend({ render: function() { - var me = window.SPVIEW = this; + var me = this; me._interval = 0; me._minimize = ''; me._restore = ''; + // max lines of output to show + me.maxOutputLength = 500; + // in case there is already an outputView present, // like from before restarting the kernel $('.qcodes-output-view').not(me.$el).remove(); @@ -70,7 +73,8 @@ require([ .attr('qcodes-state', 'docked') .html( '
' + - '' + + '
' + + '' + '' + '' + '' + @@ -83,8 +87,11 @@ require([ me.clearButton = me.$el.find('.qcodes-clear-output'); me.minButton = me.$el.find('.qcodes-minimize'); me.outputArea = me.$el.find('pre'); - me.subprocessList = me.$el.find('span'); + me.subprocessList = me.$el.find('.qcodes-process-list'); me.abortButton = me.$el.find('.qcodes-abort-loop'); + me.processLinesButton = me.$el.find('.qcodes-processlines') + + me.outputLines = []; me.clearButton.click(function() { me.outputArea.html(''); @@ -95,6 +102,12 @@ require([ me.send({abort: true}); }); + me.processLinesButton.click(function() { + // toggle multiline process list display + me.subprocessesMultiline = !me.subprocessesMultiline; + me.showSubprocesses(); + }); + me.$el.find('.js-state').click(function() { var oldState = me.$el.attr('qcodes-state'), state = this.className.substr(this.className.indexOf('qcodes')) @@ -112,53 +125,100 @@ require([ me.$el.attr('qcodes-state', state); if(state === 'floated') { - me.$el.draggable().css({ - left: window.innerWidth - me.$el.width() - 15, - top: window.innerHeight - me.$el.height() - 10 - }); + me.$el + .draggable({stop: function() { me.clipBounds(); }}) + .css({ + left: window.innerWidth - me.$el.width() - 15, + top: window.innerHeight - me.$el.height() - 10 + }); } - }); - $(window).resize(function() { - if(me.$el.attr('qcodes-state') === 'floated') { - var position = me.$el.position(), - minVis = 20, - maxLeft = window.innerWidth - minVis, - maxTop = window.innerHeight - minVis; - - if(position.left > maxLeft) me.$el.css('left', maxLeft); - if(position.top > maxTop) me.$el.css('top', maxTop); - } + // any previous highlighting is + me.$el.removeClass('qcodes-highlight'); }); + $(window) + .off('resize.qcodes') + .on('resize.qcodes', function() {me.clipBounds();}); + me.update(); }, + clipBounds: function() { + var me = this; + if(me.$el.attr('qcodes-state') === 'floated') { + var bounds = me.$el[0].getBoundingClientRect(), + minVis = 40, + maxLeft = window.innerWidth - minVis, + minLeft = minVis - bounds.width, + maxTop = window.innerHeight - minVis; + + if(bounds.left > maxLeft) me.$el.css('left', maxLeft); + else if(bounds.left < minLeft) me.$el.css('left', minLeft); + + if(bounds.top > maxTop) me.$el.css('top', maxTop); + else if(bounds.top < 0) me.$el.css('top', 0); + console.log(bounds); + } + }, + display: function(message) { + var me = this; if(message) { - var initialScroll = this.outputArea.scrollTop(); - this.outputArea.scrollTop(this.outputArea.prop('scrollHeight')); - var scrollBottom = this.outputArea.scrollTop(); - - if(this.$el.attr('qcodes-state') === 'minimized') { - this.$el.find('.qcodes-docked').click(); - // always scroll to the bottom if we're restoring - // because of a new message - initialScroll = scrollBottom; + var initialScroll = me.outputArea.scrollTop(); + me.outputArea.scrollTop(me.outputArea.prop('scrollHeight')); + var scrollBottom = me.outputArea.scrollTop(); + + if(me.$el.attr('qcodes-state') === 'minimized') { + // if we add text and the box is minimized, highlight the + // title bar to alert the user that there are new messages. + // remove then add the class, so we get the animation again + // if it's already highlighted + me.$el.removeClass('qcodes-highlight'); + setTimeout(function(){ + me.$el.addClass('qcodes-highlight'); + }, 0); + } + + var newLines = message.split('\n'), + out = me.outputLines, + outLen = out.length; + if(outLen) out[outLen - 1] += newLines[0]; + else out.push(newLines[0]); + + for(var i = 1; i < newLines.length; i++) { + out.push(newLines[i]); } - this.outputArea.append(message); - this.clearButton.removeClass('disabled'); + if(out.length > me.maxOutputLength) { + out.splice(0, out.length - me.maxOutputLength + 1, + '<<< Output clipped >>>'); + } + + me.outputArea.text(out.join('\n')); + me.clearButton.removeClass('disabled'); // if we were scrolled to the bottom initially, make sure // we stay that way. - this.outputArea.scrollTop(initialScroll === scrollBottom ? - this.outputArea.prop('scrollHeight') : initialScroll); + me.outputArea.scrollTop(initialScroll === scrollBottom ? + me.outputArea.prop('scrollHeight') : initialScroll); } - var processes = this.model.get('_processes') || 'No subprocesses'; - this.abortButton.toggleClass('disabled', processes.indexOf('Measurement')===-1); - this.subprocessList.text(processes); + var processes = me.model.get('_processes'); + me.abortButton.toggleClass('disabled', processes.indexOf('Measurement')===-1); + me._processes = processes; + me.showSubprocesses(); + }, + + showSubprocesses: function() { + var me = this, + replacer = me.subprocessesMultiline ? '
' : ', ', + processes = (me._processes || '').replace(/\n/g, '>' + replacer + '<'); + + if(processes) processes = '<' + processes + '>'; + else processes = 'No subprocesses'; + + me.subprocessList.html(processes); } }); manager.WidgetManager.register_widget_view('SubprocessView', SubprocessView); diff --git a/qcodes/widgets/widgets.py b/qcodes/widgets/widgets.py index f685b8f92dc..2f53b974293 100644 --- a/qcodes/widgets/widgets.py +++ b/qcodes/widgets/widgets.py @@ -1,3 +1,4 @@ +"""Qcodes-specific widgets for jupyter notebook.""" from IPython.display import display from ipywidgets import widgets from multiprocessing import active_children @@ -7,28 +8,34 @@ from .display import display_auto from qcodes.loops import MP_NAME, halt_bg - display_auto('widgets/widgets.js') display_auto('widgets/widgets.css') class UpdateWidget(widgets.DOMWidget): - ''' - Execute a callable periodically, and display its return in the output area - - fn - the callable (with no parameters) to execute - interval - the period, in seconds - can be changed later by setting the interval attribute - interval=0 or the halt() method disables updates. - first_call - do we call the update function immediately (default, True), - or only after the first interval? - ''' + + """ + Execute a callable periodically, and display its return in the output area. + + The Javascript portion of this is in widgets.js with the same name. + + Args: + fn (callable): To be called (with no parameters) periodically. + + interval (number): The call period, in seconds. Can be changed later + by setting the ``interval`` attribute. ``interval=0`` or the + ``halt()`` method disables updates. + + first_call (bool): Whether to call the update function immediately + or only after the first interval. Default True. + """ + _view_name = Unicode('UpdateView', sync=True) # see widgets.js _message = Unicode(sync=True) interval = Float(sync=True) - def __init__(self, fn, interval, first_call=True, **kwargs): - super().__init__(**kwargs) + def __init__(self, fn, interval, first_call=True): + super().__init__() self._fn = fn self.interval = interval @@ -43,25 +50,60 @@ def __init__(self, fn, interval, first_call=True, **kwargs): self.do_update({}, []) def do_update(self, content=None, buffers=None): + """ + Execute the callback and send its return value to the notebook. + + Args: + content: required by DOMWidget, unused + buffers: required by DOMWidget, unused + """ self._message = str(self._fn()) def halt(self): + """ + Stop future updates. + + Keeps a record of the interval so we can ``restart()`` later. + You can also restart by explicitly setting ``self.interval`` to a + positive value. + """ if self.interval: self.previous_interval = self.interval self.interval = 0 def restart(self, **kwargs): + """ + Reinstate updates with the most recent interval. + + TODO: why did I include kwargs? + """ if self.interval != self.previous_interval: self.interval = self.previous_interval class HiddenUpdateWidget(UpdateWidget): - ''' - A variant on UpdateWidget that hides its section of the output area - Just lets the front end periodically execute code - that takes care of its own display. - by default, first_call is False here, unlike UpdateWidget - ''' + + """ + A variant on UpdateWidget that hides its section of the output area. + + The Javascript portion of this is in widgets.js with the same name. + + Just lets the front end periodically execute code that takes care of its + own display. By default, first_call is False here, unlike UpdateWidget, + because it is assumed this widget is created to update something that + has been displayed by other means. + + Args: + fn (callable): To be called (with no parameters) periodically. + + interval (number): The call period, in seconds. Can be changed later + by setting the ``interval`` attribute. ``interval=0`` or the + ``halt()`` method disables updates. + + first_call (bool): Whether to call the update function immediately + or only after the first interval. Default False. + """ + _view_name = Unicode('HiddenUpdateView', sync=True) # see widgets.js def __init__(self, *args, first_call=False, **kwargs): @@ -69,25 +111,46 @@ def __init__(self, *args, first_call=False, **kwargs): def get_subprocess_widget(): - ''' - convenience function to get a singleton SubprocessWidget - and restart it if it has been halted - ''' + """ + Convenience function to get a singleton SubprocessWidget. + + Restarts widget updates if it has been halted. + + Returns: + SubprocessWidget + """ if SubprocessWidget.instance is None: - return SubprocessWidget() + w = SubprocessWidget() + else: + w = SubprocessWidget.instance + + w.restart() - return SubprocessWidget.instance + return w def show_subprocess_widget(): + """Display the subprocess widget, creating it if needed.""" display(get_subprocess_widget()) class SubprocessWidget(UpdateWidget): - ''' - Display the subprocess outputs collected by the StreamQueue - in a box in the notebook window - ''' + + """ + Display subprocess output in a box in the jupyter notebook window. + + Output is collected from each process's stdout and stderr by the + ``StreamQueue`` and read periodically from the main process, triggered + by Javascript. + + The Javascript portion of this is in widgets.js with the same name. + + Args: + interval (number): The call period, in seconds. Can be changed later + by setting the ``interval`` attribute. ``interval=0`` or the + ``halt()`` method disables updates. Default 0.5. + """ + _view_name = Unicode('SubprocessView', sync=True) # see widgets.js _processes = Unicode(sync=True) @@ -109,6 +172,16 @@ def __init__(self, interval=0.5): super().__init__(fn=None, interval=interval) def do_update(self, content=None, buffers=None): + """ + Update the information to be displayed in the widget. + + Send any new messages to the notebook, and update the list of + active processes. + + Args: + content: required by DOMWidget, unused + buffers: required by DOMWidget, unused + """ self._message = self.stream_queue.get() loops = [] @@ -116,11 +189,12 @@ def do_update(self, content=None, buffers=None): for p in active_children(): if getattr(p, 'name', '') == MP_NAME: - loops.append(str(p)) + # take off the <> on the ends, just to shorten the names + loops.append(str(p)[1:-1]) else: - others.append(str(p)) + others.append(str(p)[1:-1]) - self._processes = ', '.join(others + loops) + self._processes = '\n'.join(loops + others) if content.get('abort'): halt_bg(timeout=self.abort_timeout, traceback=False)