Question

I'm using Traits 4 to build a simple interactive GUI application. This application will display a timestamped log of events in a dedicated part of the GUI. This log is currently stored as a String trait.

The default editor (or View? Not sure on the exact nomenclature) for a String trait is a scrollable multi-line display widget. When the internal string value is changed, the widget updates to display the new value. If the length of the content exceeds the viewable size of the widget, then a scrollbar appears to allow the user to scroll up and down, across the entire value.

It appears that when the widget refreshes and a vertical scrollbar is then visible (content exceeds widget size), the view resets to the start of the value (top), and the scrollbar returns to the top also, obscuring the final part of the value.

In my application I wish the latest event in the log (at the bottom) to always be displayed after a value refresh. But because the view resets to the top of the value, it doesn't follow the latest entry and the user must constantly scroll to the bottom after each refresh. This is unusable in this form.

Is there a simple way to configure this trait's editor/View to scroll from the bottom?

If not, how would one go about writing a custom editor/View for this String trait? Would it be necessary to write a new view from scratch using wx/qt4 primitives, or is there some way to derive a new view from the existing one and override only the parts that are needed to implement the desired functionality?

Here's some example code that demonstrates the problem:

# from https://svn.enthought.com/enthought/ticket/1619 - broken SSL cert
from threading import Thread
from time import sleep
from enthought.traits.api import *
from enthought.traits.ui.api import View, Item, ButtonEditor

class TextDisplay(HasTraits):
    string =  String()

    view= View( Item('string',show_label=False, springy=True, style='custom' ))


class CaptureThread(Thread):
    def run(self):
        self.display.string = 'Camera started\n' + self.display.string
        n_img = 0
        while not self.wants_abort:
            sleep(.5)
            n_img += 1
            self.display.string += '%d image captured\n' % n_img
        self.display.string += 'Camera stopped\n'

class Camera(HasTraits):
    start_stop_capture = Button()
    display = Instance(TextDisplay)
    capture_thread = Instance(CaptureThread)

    view = View( Item('start_stop_capture', show_label=False ))

    def _start_stop_capture_fired(self):
        if self.capture_thread and self.capture_thread.isAlive():
            self.capture_thread.wants_abort = True
        else:
            self.capture_thread = CaptureThread()
            self.capture_thread.wants_abort = False
            self.capture_thread.display = self.display
            self.capture_thread.start()

class MainWindow(HasTraits):
    display = Instance(TextDisplay, ())

    camera = Instance(Camera)

    def _camera_default(self):
        return Camera(display=self.display)

    view = View('display', 'camera', style="custom", resizable=True)


if __name__ == '__main__':
    MainWindow().configure_traits()

Click the "Start stop capture" button multiple times until the view has filled, and you'll observe that subsequent refreshes reset the scrollbar position to the top of the view.

Was it helpful?

Solution

I needed something similar a few years ago. You can find what I came up with here: https://svn.enthought.com/enthought/wiki/OutputStream

The OutputStream class has a file-like interface to a string. In particular, you add to the string with its write method. The default view of an OutputStream is a multiline text field. It has a handler that uses the appropriate toolkit method to move the cursor to the end of the string whenever it is changed. Two different demos of its use, output_stream_demo.py and output_stream_demo2.py, are given in the wiki page.

You'll probably want to drop the enthought namespace in the imports. That is, change

from enthought.traits.api import ...
from enthought.traits.ui.api import ...
from enthought.etsconfig.api import ETSConfig

to

from traits.api import ...
from traitsui.api import ...
from traits.etsconfig.api import ETSConfig

Update, to address a comment:

Apparently the "readonly" style of a TextEditor in the Qt backend uses a QLabel for the text field instead of a QTextEdit, and a QLabel doesn't provide the moveCursor method. The following modification to the handler provides a way to force the text field to be read-only while still using style="custom".

def _get_editor(uiinfo, name):
    ui = uiinfo.ui
    if ui is None:
        return None
    for ed in ui._editors:
        if ed.name == name:
            return ed
    return None


class _OutputStreamViewHandler(Handler):

    def init(self, uiinfo):
        if ETSConfig.toolkit == 'qt4':
            ed = _get_editor(uiinfo, 'text')
            if ed is not None:
                # Make the text field read-only.
                ed.control.setReadOnly(True)
        return True

    def object_text_changed(self, uiinfo):
        ed = _get_editor(uiinfo, 'text')
        if ed is None:
            return

        if ETSConfig.toolkit == 'wx':
            # With wx, the control is a TextCtrl instance.
            ed.control.SetInsertionPointEnd()
        elif ETSConfig.toolkit == 'qt4':
            # With qt4, the control is a PyQt4.QtGui.QTextEdit instance.
            from PyQt4.QtGui import QTextCursor
            ed.control.moveCursor(QTextCursor.End)
Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top