Skip to content

chore(textedit): add fastwrap - #615

Merged
ceccopierangiolieugenio merged 6 commits into
mainfrom
602-ttktextwrap-has-very-slow-performance-for-large-documents
May 4, 2026
Merged

chore(textedit): add fastwrap#615
ceccopierangiolieugenio merged 6 commits into
mainfrom
602-ttktextwrap-has-very-slow-performance-for-large-documents

Conversation

@ceccopierangiolieugenio

Copy link
Copy Markdown
Owner

No description provided.

@slook

slook commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

It works, but after when Quit Nicotine+ always crash IndexError exception...

user@ThinkPadT420:~/Git/slook/nicotine-plus$ ./nicotine --tui
Using TTk executable: /home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk
Loaded TTk 0.49.0-a0


                                   Traceback (most recent call last):
  File "/usr/lib/python3.11/threading.py", line 1038, in _bootstrap_inner
    self.run()
  File "/usr/lib/python3.11/threading.py", line 975, in run
    self._target(*self._args, **self._kwargs)
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/ttk.py", line 189, in mainloop
    self._mainloop()
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/ttk.py", line 236, in _mainloop
    raise e
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/timer_unix.py", line 68, in run
    self.timeout.emit()
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/signal.py", line 187, in emit
    slot(*args[sl], **kwargs)
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/ttk.py", line 362, in _time_event
    total = TTkHelper.paintAll()
            ^^^^^^^^^^^^^^^^^^^^
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/helper.py", line 205, in paintAll
    widget.paintEvent(canvas)
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkWidgets/texedit.py", line 1063, in paintEvent
    subLines = self._textWrap.screenRows(oy, h, prefetch=h)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkGui/textwrap1.py", line 371, in screenRows
    ranges = self._wrapLine(line, self._textDocument._dataLines[line])
                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
IndexError: list index out of range

I think this happens because I have destroy() method in my Chatter() module which calls self.chat_view.document().clear() on program quit event.

@ceccopierangiolieugenio
ceccopierangiolieugenio marked this pull request as draft April 2, 2026 08:50
@ceccopierangiolieugenio

Copy link
Copy Markdown
Owner Author

I added a quick fix, anyway, I am still reviewing the PR.

@slook

slook commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

No even after applying fix 0726665 the crash happen after exit because of document().clear()

INFO:(TTk (Draw)) ttk.py:182 FPS: 0 (29 Bps)
INFO:(Thread-1 (_sigWinChThreaded)) ttk.py:373 Resize: w:238, h:61
DEBUG:(Thread-2 (mainloop)) ttk.py:194 
DEBUG:(Thread-2 (mainloop)) ttk.py:195          ████████╗            ████████╗    
DEBUG:(Thread-2 (mainloop)) ttk.py:196          ╚══██╔══╝            ╚══██╔══╝    
DEBUG:(Thread-2 (mainloop)) ttk.py:197             ██║  ▄▄  ▄ ▄▄ ▄▄▖▄▖  ██║ █ ▗▖  
DEBUG:(Thread-2 (mainloop)) ttk.py:198     ▞▀▚ ▖▗  ██║ █▄▄█ █▀▘  █ █ █  ██║ █▟▘   
DEBUG:(Thread-2 (mainloop)) ttk.py:199     ▙▄▞▐▄▟  ██║ ▀▄▄▖ █    █ ▝ █  ██║ █ ▀▄  
DEBUG:(Thread-2 (mainloop)) ttk.py:200     ▌    ▐  ╚═╝                  ╚═╝       
DEBUG:(Thread-2 (mainloop)) ttk.py:201       ▚▄▄▘                                 
DEBUG:(Thread-2 (mainloop)) ttk.py:202 
DEBUG:(Thread-2 (mainloop)) ttk.py:203   Version: 0.49.0-a0
DEBUG:(Thread-2 (mainloop)) ttk.py:204 
DEBUG:(Thread-2 (mainloop)) ttk.py:205 Starting Main Loop...
DEBUG:(Thread-2 (mainloop)) ttk.py:206 screen = (os.terminal_size(columns=238, lines=61))
INFO:(TTk (Draw)) ttk.py:182 FPS: 10 (1928 Bps)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54 Traceback (most recent call last):
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/./nicotine", line 24, in <module>
ERROR:(MainThread) ttk.py:54 sys.exit(load_module())
ERROR:(MainThread) ttk.py:54 ^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/./nicotine", line 20, in load_module
ERROR:(MainThread) ttk.py:54 return pynicotine.run()
ERROR:(MainThread) ttk.py:54 ^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/__init__.py", line 224, in run
ERROR:(MainThread) ttk.py:54 exit_code = application.run(ci_mode, isolated_mode)
ERROR:(MainThread) ttk.py:54 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/ttktui/__init__.py", line 45, in run
ERROR:(MainThread) ttk.py:54 return Application(ci_mode, isolated_mode).run()
ERROR:(MainThread) ttk.py:54 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/ttktui/application.py", line 92, in run
ERROR:(MainThread) ttk.py:54 while events.process_thread_events():
ERROR:(MainThread) ttk.py:54 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/events.py", line 333, in process_thread_events
ERROR:(MainThread) ttk.py:54 self.emit(event.event_name, *event.args, **event.kwargs)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/events.py", line 276, in emit
ERROR:(MainThread) ttk.py:54 raise error
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/events.py", line 268, in emit
ERROR:(MainThread) ttk.py:54 function(*args, **kwargs)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/ttktui/privatechat.py", line 93, in quit
ERROR:(MainThread) ttk.py:54 super().destroy()
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/ttktui/widgets/pages.py", line 61, in destroy
ERROR:(MainThread) ttk.py:54 page.destroy()
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/ttktui/widgets/chatter.py", line 191, in destroy
ERROR:(MainThread) ttk.py:54 self.chat_view.document().clear()
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkGui/textdocument.py", line 242, in clear
ERROR:(MainThread) ttk.py:54 self.setText(self._default_init_text)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkGui/textdocument.py", line 254, in setText
ERROR:(MainThread) ttk.py:54 self.contentsChanged.emit()
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/signal.py", line 187, in emit
ERROR:(MainThread) ttk.py:54 slot(*args[sl], **kwargs)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkWidgets/texedit.py", line 765, in _documentChanged
ERROR:(MainThread) ttk.py:54 self.viewChanged.emit()
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkCore/signal.py", line 187, in emit
ERROR:(MainThread) ttk.py:54 slot(*args[sl], **kwargs)
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkWidgets/texedit.py", line 815, in _pushCursor
ERROR:(MainThread) ttk.py:54 x,y = self._textWrap.dataToScreenPosition(
ERROR:(MainThread) ttk.py:54       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54   File "/home/user/Git/slook/nicotine-plus/pynicotine/external/pyTermTk/TermTk/TTkGui/textwrap1.py", line 379, in dataToScreenPosition
ERROR:(MainThread) ttk.py:54 ranges = self._wrapLine(line, self._textDocument._dataLines[line])
ERROR:(MainThread) ttk.py:54 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
ERROR:(MainThread) ttk.py:54 
ERROR:(MainThread) ttk.py:54 IndexError
ERROR:(MainThread) ttk.py:54 : 
ERROR:(MainThread) ttk.py:54 list index out of range

It looks like it could also happen any time document().clear() is called.

Comment thread libs/pyTermTk/TermTk/TTkWidgets/texedit.py
@ceccopierangiolieugenio

Copy link
Copy Markdown
Owner Author

I improved thread safety, still I am reviewing the code because I think I made the fastwrap a bit overcomplicated.

# Lower bound before any sample is collected.
return processedRows + rem
avgRowsPerLine = processedRows / self._processedLines
return processedRows + max(rem, int(rem * avgRowsPerLine))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The returned number needs to be exactly accurate, or there needs to be another function that can force a full wrap of the entire document in order to obtain the actual total number of wrapped lines.

This accuracy is required to implement a "follow" feature (like the TTkLogViewer has), where it auto-scrolls whenever a new line is appended. If it is off-by-one or more then the auto-following stops when it shouldn't.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fastwrap cannot be accurate because it works in chunks and the full size is estimated,
I guess you want to display a growing log dynamically with wrap.
I can add a full wrap api, consider that if the size change the wrap must be recalculated.
What is your usecase?
I think that different wrapping routine can be implemented based on the use cases.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My use case is a chat interface, so new messages are appended to the bottom and have to be shown immediately when they arrive (i.e. "follow" mode, except when scrolling up to view the old messages in the chat history).

For this purpose I made a compound widget that is basically a TTkTextEdit and a TTkLineEdit together...
https://github.com/slook/nicotine-plus/blob/ttk/pynicotine/ttktui/widgets/chatter.py

git clone https://github.com/slook/nicotine-plus.git -b ttk
cd nicotine-plus
./nicotine --tui
image

This same Chatter() compound widget class is used for the "Chat Rooms" as well as "Private Chats". It is a read-only use case so I consider changing the implementation to something more like the LogViewer console widget.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late reply,
Those days I am more lazy than busy.

I realised that I don't have a proper scrolling api.
what if, I allow to mark tags, or define blocks and include some api to

  • scroll to line
  • scroll to tag
  • scroll to paragraph
  • scroll to block
    Allowing the scroll to align to Top,Center,Bottom

So, each message can be a single block and you can scroll the page to have the required message at the bottom aligned to its size.

But, we need to think about infinite scrolling as well, I think eventually you don't want to load the full chat history in the memory.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no need for any apology on your part, since my feature request is primarily intended to be for your information only from my perspective in a real world use case, and I only offer my findings because I know that rendering performance is something that you are interested in relevant for your project, but not due to any sort of urgent need on my part. I was originally expecting that I was going to need to write my own widget entirely from scratch (but I probably wouldn't have much motivation to do it just for the sake of my own project unless others could benefit from such effort). Thank you for opening this PR.

Marking tags is something that I do want to do (to have username colors per online status and hotspots for each user context menu), yet I did not think that wrapping would become relevant for tagging as well as scrolling. I suppose tackling this at the same time could indeed be helpful way to assist the wanted scrolling position as well, without the need for worrying about the scroll offset having to be completely accurate.

As to the infinite scrolling idea, this is also something I would like to have too. Having previously attempted to do this in Gtk (by jerry-rigging its get_page_increment() method and its "edge-reached" signal), I learned that this is a complicated aspect to deal with.

In this particular case the old message data is not always saved into a file so everything has to remain in a buffer anyway, so that same buffer might also become applicable to the tags. Even when there is a log file, it is not practical to load all of it so there is a need for a maximum limit of lines to store in the memory and the capability to dynamically prune it somehow. At present, attempting to delete/offload lines from the beginning results in some kind of IndexError which is actually what led me to investigate this code path in the first place.

Even without adding custom tags, there is already at least a Start, Insertion and an End which could serve the essential purposes of being able to jump to those points, wherever they might happen to be located at any point in time.

@slook

slook commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

I think I made the fastwrap a bit overcomplicated

Yes it could be simpler. It looks like you have tried to use estimates to optimize the initial loading and resizing, whereas the main areas of concern were freezing during navigation, editing and appending. A couple of seconds to make the proper calculations at startup isn't really a big deal.

@ceccopierangiolieugenio
ceccopierangiolieugenio force-pushed the 602-ttktextwrap-has-very-slow-performance-for-large-documents branch from 687703d to 165103c Compare April 25, 2026 09:36
ceccopierangiolieugenio and others added 5 commits May 3, 2026 10:49
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <copilot@github.com>
@slook

slook commented May 4, 2026

Copy link
Copy Markdown
Contributor

Testing this new FastWrap engine in nicotine-plus/ttk that you have got it working pretty well as it is similar performance to VimWrap. This is what my auto follow function looks like with it now...

    def load(self):

        self.follow = True
        self.chat_view.setLineWrapMode(ttk.TTkK.WidgetWidth, wrapEngine=ttk.TTkK.WrapEngine.FastWrap)
        self.chat_view.document().contentsChanged.connect(self.scroll_bottom)

    def scroll_bottom(self, force=False, ot=6):

        if not self.follow:
            return

        _, h = self.chat_view.size()
        ox, oy = self.chat_view.viewport().getViewOffsets()
        l = self.chat_view.textEditView()._textWrap.size()

        if force or oy + ot >= l - h:
            # FIXME: Slightly wrong position with VimWrap, or FastWrap we
            # need to move to the end position explicitly (which is always
            # off by 2 screen rows for some reason)
            # FIXME: self.chat_view.ensureCursorVisible()
            self.chat_view.viewport().viewMoveTo(ox, l + 2)

... so this is getting simpler to use than VimWrap was, while we don't have a follow mode in the library widget yet.

I guess the ensureCursorVisible() is unaware of the wrapping so it still using the unwrapped count, and for some reason the slight correction being needed for the viewMoveTo() call seems to indicate the line count is old after textCursor().insertText() or a wrong subtraction by -1 is happening in more than one place.

Note I am not using append() because sometimes old messages from a file will be prepended to the start (like to do infinite scrolling) and inserting new messages to the end avoids using snapshots which are not needed (since my chat_view is not editable), but maybe my implementation is wrong so I could change it to work differently.

@ceccopierangiolieugenio

Copy link
Copy Markdown
Owner Author

I will add scrolling api in the text editor and also a follow mode.
I need doublecheck ensureCursorVisible.
In the fastwrap the scroll size is not predictable so the texteditor realign the page at any wrap change, for this reason it cannot be used reliably.
I will add a following mode and scrolling api soon to simplify the process.

@ceccopierangiolieugenio

Copy link
Copy Markdown
Owner Author

anyway, I was thinking to modify the vimwrap to allows it to snap at the bottom and maybe increase its wrapping size to allows for a smoother scrolling.

@slook

slook commented May 4, 2026

Copy link
Copy Markdown
Contributor

Both of the engines work efficiently and perhaps the scrolling isn't expected to be smooth during the first navigation of the document until all the positions are known when all the integer values could be discovered and remembered by the engine forever so that after the second time then the view offset for the scroll bar handle becomes more precise and predictable.

Maybe just as an idea it could be handy if there was a way to have a callable method like from the FullWrap engine that can calculate all of its positions on demand (or in a background thread?) for use when switching to one of the lazy engines, in cases where absolute precision is desired at the expense of waiting for a longer time opening the document. Edit: Actually, it seems like it already is working something like this so the current behaviour is satisfactory enough.

The main negative breakage is when the scroll bar handle suddenly jumps to another place while it is being dragged, so it would be nice to avoid that if possible but really it isn't too bad, considering that it is not normally needed to navigate within pages of such a large document. In my use case the most important operations are go to the exact start and end locations, and then always follow the end when the scroll is at the bottom.

@ceccopierangiolieugenio

ceccopierangiolieugenio commented May 4, 2026

Copy link
Copy Markdown
Owner Author

Yes, the nav bar jumping is something I was thinking about, I noticed that other editors have the same issue (i.e. vscode) because they use some wrapping estimation as well,
but as long as the jump does not happen on the main window and the experience is not affected I am ok with this, maybe I can check if I can define a better prediction routine.
So far I am only worried about unexpected jumping in the editor itself.
If I manage to implement my idea of extended window in the VIM wrapper, I think that it can be used probably as the best compromise.
I think I will push the current changes and work on the improvements on another PR.
It is more important now to work on the navigation api.

@ceccopierangiolieugenio
ceccopierangiolieugenio marked this pull request as ready for review May 4, 2026 19:54
@ceccopierangiolieugenio
ceccopierangiolieugenio merged commit 925c9cd into main May 4, 2026
11 checks passed
@ceccopierangiolieugenio
ceccopierangiolieugenio deleted the 602-ttktextwrap-has-very-slow-performance-for-large-documents branch May 4, 2026 19:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TTkTextWrap has very slow performance for large documents

2 participants