Search code examples
pythonmodel-view-controllerscrollbarenthoughttraitsui

Python TraitsUI - how to control scrollbar position of a 'String' trait editor/view


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.


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)