RichTextCtrl strange behaviour with word wrap and navigation keys

A while ago I wrote an application which allows the user to create and edit journal entries. It uses a RichTextCtrl and has facilities for highlighting hash-tags, spelling mistakes, and search text, using a number of styles. The highlighting is updated dynamically as the user edits the entry.

Originally I used handlers for EVT_RICHTEXT_CHARACTER, EVT_RICHTEXT_CONTENT_INSERTED and EVT_RICHTEXT_CONTENT_DELETED to update the highlighting. The problem was that updating the highlighting for each event was taking too long when keyboard autorepeat was triggered. Typically this would happen when holding down the backspace key to delete a chunk of text. This would cause the events to be buffered so that when the key was released, the events would continue to be processed, usually resulting in too many characters being deleted.

Recently I replaced those event handlers with a handler for EVT_KEY_UP so that the highlighting would only be triggered when a key was released. This improved performance so that autorepeat was much less of a problem.

However, I noticed a strange behaviour in long lines that are soft wrapped by the RichTextCtrl. For example, if you position the caret and the end of a wrapped line and then press the right arrow key, the caret momentarily moves to the start of the next line, but then immediately jumps back to the end of the previous line. The reverse doesn’t apply - if you click to put the caret at the start of the second wrapped line and then press the left arrow key, the caret simply moves to the end of the first wrapped line. Another case where it does happen is when the caret is at the start of the first line and you press the down arrow key.

By a process of elimination I discovered that the problem occurs when the EVT_KEY_UP event handler causes the RichTextCtrl’s SetStyle() method to be called.

Below is a minimal application which reproduces the problem on wxPython 4.2.2 gtk3 (phoenix) wxWidgets 3.2.6 + Python 3.12.3 + Linux Mint 22.

import wx
import wx.lib.colourdb
import wx.richtext as rt

COLOURS = wx.lib.colourdb.getColourList()[:20]
TEXT = ', '.join(COLOURS).lower()


class MyFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent)
        self.SetSize((300, 200))
        self.SetTitle("Test Navigation Keys")
        self.main_panel = wx.Panel(self, wx.ID_ANY)
        main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.rtc = rt.RichTextCtrl(self.main_panel, wx.ID_ANY)
        main_sizer.Add(self.rtc, 1, wx.EXPAND, 0)
        self.main_panel.SetSizer(main_sizer)
        self.Layout()
        self.rtc.SetValue(TEXT)
        self.rtc.Bind(wx.EVT_KEY_UP, self.OnKeyUp)
        attr = wx.TextAttr(wx.BLUE)
        self.blue_style = rt.RichTextAttr(attr)
        self.updateHighlighting()


    def OnKeyUp(self, event):
        self.updateHighlighting()
        event.Skip()


    def updateHighlighting(self):
        self.rtc.SetStyle(0, 4, self.blue_style)


if __name__ == "__main__":
    app = wx.App()
    frame = MyFrame(None)
    frame.Show()
    app.MainLoop()

Screenshot at 2024-09-22 13-22-50

Fortunately I found a workaround when I realised that the highlighting doesn’t need to be updated when the caret is simply moved by the navigation keys (including Left-Arrow, Right-Arrow, Up-Arrow, Down-Arrow, Home, End, Page-Up and Page-Down).

Here is a modified example which uses the workaround: word_wrap_and_nav_keys_3.py (1.4 KB)

Does anybody have any ideas why calling SetStyle() in an EVT_KEY_UP handler should cause this behaviour? Does it also happen on Windows?

With soft-broken lines, the end of the line before the break is the same position in the text as the beginning of the continuation line. When you press the right arrow key at the end of the line, and the cursor goes to the beginning of the next line, you are not changing the position as returned by GetCaretPosition.

Code that naively reads GetCaretPosition and then tries to restore it later with SetCaretPosition will have to choose one or the other. I figure that’s what the showAtLineStart parameter is for (haven’t tried it). The default is false, which fits with what we’re seeing.

This is all speculation, but I imagine that’s what’s happening in SetStyle.
It’s a bug and you should report it.

Thanks for your comments, your speculation seems reasonable to me. I plan on reporting it but have a few other issues I need to sort first.

I agree. Equivalent behavior when SetStyle is called:

>>>  self.rtc.SetCaretPosition(self.rtc.CaretPosition)

need to be modified to:

>>> self.rtc.SetCaretPosition(self.rtc.CaretPosition,
...                           showAtLineStart=self.rtc.CaretAtLineStart)

After doing some more research I came to the conclusion that the behaviour is occurring in wxWidgets rather than wxPython, so I have raised the following issue: https://github.com/wxWidgets/wxWidgets/issues/24871

I added a more basic wxPython example to demonstrate the underlying problem:

import wx
import wx.lib.colourdb
import wx.richtext as rt

COLOURS = wx.lib.colourdb.getColourList()[:10]
TEXT = ', '.join(COLOURS).lower()

class MyFrame(wx.Frame):
    def __init__(self, parent):
        wx.Frame.__init__(self, parent)
        self.SetSize((300, 200))
        self.SetTitle("RichTextCtrl + SetStyle()")
        self.main_panel = wx.Panel(self, wx.ID_ANY)
        main_sizer = wx.BoxSizer(wx.VERTICAL)
        self.rtc = rt.RichTextCtrl(self.main_panel, wx.ID_ANY)
        main_sizer.Add(self.rtc, 1, wx.EXPAND, 0)
        button = wx.Button(self.main_panel, wx.ID_ANY, "Set Style")
        main_sizer.Add(button, 0, wx.ALIGN_CENTRE, 0)
        self.main_panel.SetSizer(main_sizer)
        self.rtc.SetValue(TEXT)

        # Move caret to start of second (wrapped) line
        self.rtc.SetCaretPosition(43, showAtLineStart=True)

        self.Bind(wx.EVT_BUTTON, self.OnButton, button)
        attr = wx.TextAttr(wx.RED, wx.BLUE)
        self.blue_style = rt.RichTextAttr(attr)

    def OnButton(self, event):
        self.rtc.SetStyle(0, 4, self.blue_style)
        self.rtc.SetFocus()

if __name__ == "__main__":
    app = wx.App()
    frame = MyFrame(None)
    frame.Show()
    app.MainLoop()

When the code is run, the caret is placed at the start of the second line. When the “Set Style” button is pressed, SetStyle() is called and the caret jumps to the end of the first line.