reverse-scroll chat: start at bottom, lazy-load on scroll up

- Model shows last 40 blocks initially, positioned at end
- Scrolling near top triggers loadEarlier() which prepends 30 blocks
- Scroll position maintained after prepend via contentY adjustment
- All blocks still parsed (for searching later) but only tail rendered
- Reduced initial render cost significantly
This commit is contained in:
Levi Neely 2026-07-16 06:46:47 +02:00
parent f1a045077b
commit f122a69bce
3 changed files with 59 additions and 12 deletions

View File

@ -81,25 +81,33 @@ void ChatBlockModel::setRawText(const QString &text)
emit rawTextChanged();
}
static constexpr int MAX_VISIBLE_BLOCKS = 80;
static constexpr int INITIAL_BLOCKS = 40;
static constexpr int LOAD_MORE_COUNT = 30;
void ChatBlockModel::reparse()
{
QList<ChatBlock> allBlocks = parseBlocks(m_rawText);
m_allBlocks = parseBlocks(m_rawText);
// Only keep the last N blocks to avoid rendering hundreds of delegates
QList<ChatBlock> newBlocks;
if (allBlocks.size() > MAX_VISIBLE_BLOCKS) {
newBlocks = allBlocks.mid(allBlocks.size() - MAX_VISIBLE_BLOCKS);
// Determine visible window: last INITIAL_BLOCKS (or expand if already showing more)
int desiredStart;
if (m_blocks.isEmpty()) {
// Initial load: show last N blocks
desiredStart = qMax(0, m_allBlocks.size() - INITIAL_BLOCKS);
} else {
newBlocks = allBlocks;
// Streaming update: keep the same start offset, just extend the tail
desiredStart = m_visibleStart;
// But if allBlocks shrank (session switch), reset
if (desiredStart >= m_allBlocks.size())
desiredStart = qMax(0, m_allBlocks.size() - INITIAL_BLOCKS);
}
m_visibleStart = desiredStart;
QList<ChatBlock> newBlocks = m_allBlocks.mid(m_visibleStart);
// Incremental update: only change what differs
int commonCount = qMin(m_blocks.size(), newBlocks.size());
int firstDiff = commonCount; // index of first different block
int firstDiff = commonCount;
// Find first block that changed (usually the last one during streaming)
for (int i = 0; i < commonCount; ++i) {
if (m_blocks[i].content != newBlocks[i].content ||
m_blocks[i].type != newBlocks[i].type ||
@ -122,7 +130,7 @@ void ChatBlockModel::reparse()
emit dataChanged(index(i), index(i));
}
// Append new blocks
// Append new blocks at end
if (newBlocks.size() > m_blocks.size()) {
beginInsertRows(QModelIndex(), m_blocks.size(), newBlocks.size() - 1);
for (int i = m_blocks.size(); i < newBlocks.size(); ++i) {
@ -132,6 +140,26 @@ void ChatBlockModel::reparse()
}
}
bool ChatBlockModel::loadEarlier()
{
if (m_visibleStart <= 0)
return false; // already showing everything
int loadCount = qMin(LOAD_MORE_COUNT, m_visibleStart);
int newStart = m_visibleStart - loadCount;
QList<ChatBlock> prepend = m_allBlocks.mid(newStart, loadCount);
beginInsertRows(QModelIndex(), 0, loadCount - 1);
for (int i = loadCount - 1; i >= 0; --i) {
m_blocks.prepend(prepend[i]);
}
m_visibleStart = newStart;
endInsertRows();
return true;
}
QList<ChatBlock> ChatBlockModel::parseBlocks(const QString &text) const
{
QList<ChatBlock> blocks;

View File

@ -61,6 +61,7 @@ public:
Q_INVOKABLE QString highlightedHtml(const QString &code, const QString &language) const;
Q_INVOKABLE void copyToClipboard(const QString &text) const;
Q_INVOKABLE bool loadEarlier(); // returns true if more blocks were loaded
signals:
void rawTextChanged();
@ -71,8 +72,10 @@ private:
QVariantList parseSubBlocks(const QString &content) const;
QString m_rawText;
QList<ChatBlock> m_blocks;
mutable QHash<QString, QString> m_highlightCache; // key: code+lang, value: html
QList<ChatBlock> m_allBlocks; // all parsed blocks
QList<ChatBlock> m_blocks; // visible window (tail of m_allBlocks)
int m_visibleStart = 0; // index into m_allBlocks where visible window starts
mutable QHash<QString, QString> m_highlightCache;
};
#endif // CHAT_BLOCK_MODEL_H

View File

@ -371,13 +371,29 @@ ApplicationWindow {
spacing: 8
cacheBuffer: 200
model: chatModel
verticalLayoutDirection: ListView.TopToBottom
property bool _scrolling: false
Component.onCompleted: {
Qt.callLater(function() { chatList.positionViewAtEnd() })
}
onContentYChanged: {
if (_scrolling) return
let bottom = contentHeight - height
parent.atBottom = (bottom <= 0 || contentY >= bottom - 4)
// Load earlier messages when scrolled near top
if (contentY < 100 && contentHeight > height) {
let oldHeight = contentHeight
if (chatModel.loadEarlier()) {
// Maintain scroll position after prepend
Qt.callLater(function() {
chatList.contentY += (chatList.contentHeight - oldHeight)
})
}
}
}
onContentHeightChanged: {
if (parent.atBottom && !_scrolling) {