Skip to content

Commit 8c45791

Browse files
committed
fix: improve streaming UX, stop button, and scroll synchronization
Streaming Text Insertion: - Disable widget updates during text insertion to prevent auto-scroll - Append using separate QTextCursor on document (doesn't affect visible cursor) - Only auto-scroll to bottom if user was already at bottom (watching stream) - Preserve user's scroll position when they scroll up to read earlier content - Qt version compatibility for QTextCursor.End enum (Qt5/Qt6) Stop Button Fix: - Add cancellation check inside streaming generator consumption loop - Check cancel_request() on each character/chunk during streaming - Raise TranslationCanceled immediately when stop is requested - Apply to both single translation and batch mode streaming - Fixes infinite 'Stopping...' state with large merged translations Scroll Synchronization: - Replace pixel-based scroll sync with line-number (block) synchronization - Get firstVisibleBlock() number and sync to same block in other editors - Calculate exact scroll position using block geometry and cursorRect() - Block scrollbar signals during sync to prevent feedback loop - Works correctly during streaming when translation is incomplete - Works with word wrap enabled/disabled - Eliminates lag/gap between original and translation scroll positions
1 parent 9042c76 commit 8c45791

2 files changed

Lines changed: 70 additions & 4 deletions

File tree

advanced.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
QPlainTextEdit, QPushButton, QSplitter, QLabel, QThread, QLineEdit,
77
QGridLayout, QProgressBar, pyqtSignal, pyqtSlot, QPixmap, QEvent,
88
QStackedWidget, QSpacerItem, QTabWidget, QCheckBox,
9-
QComboBox, QSizePolicy)
9+
QComboBox, QSizePolicy, QTextCursor)
1010
from calibre.constants import __version__ # type: ignore
1111
from calibre.gui2 import I # type: ignore
1212
from calibre.utils.localization import _ # type: ignore
@@ -973,11 +973,44 @@ def layout_review(self):
973973
self.review_splitter.setSizes(_size)
974974

975975
def synchronizeScrollbars(editors):
976+
"""Sync scroll position by line number, not pixels or percentage.
977+
Works correctly during streaming and with word wrap."""
976978
for editor in editors:
977979
for other_editor in editors:
978980
if editor != other_editor:
981+
def create_sync_handler(source, target):
982+
def sync_by_line_number():
983+
# Get first visible line number in source
984+
source_block = source.firstVisibleBlock()
985+
line_number = source_block.blockNumber()
986+
987+
# Block signals to prevent feedback loop
988+
target_scrollbar = target.verticalScrollBar()
989+
target_scrollbar.blockSignals(True)
990+
991+
# Get the block at same line number in target
992+
target_doc = target.document()
993+
target_block = target_doc.findBlockByNumber(line_number)
994+
995+
if target_block.isValid():
996+
# Calculate exact scroll position for the block
997+
# Use block's vertical position to set scrollbar directly
998+
target_cursor = QTextCursor(target_block)
999+
target_rect = target.cursorRect(target_cursor)
1000+
1001+
# Get the current top position of the block
1002+
block_top = target_rect.top()
1003+
1004+
# Set scrollbar to show this block at top of viewport
1005+
current_scroll = target.verticalScrollBar().value()
1006+
target.verticalScrollBar().setValue(current_scroll + block_top)
1007+
1008+
# Re-enable signals
1009+
target_scrollbar.blockSignals(False)
1010+
return sync_by_line_number
1011+
9791012
editor.verticalScrollBar().valueChanged.connect(
980-
other_editor.verticalScrollBar().setValue)
1013+
create_sync_handler(editor, other_editor))
9811014
synchronizeScrollbars((raw_text, original_text, translation_text))
9821015

9831016
translation_text.cursorPositionChanged.connect(
@@ -1139,7 +1172,31 @@ def streaming_translation(data):
11391172
elif isinstance(data, Paragraph):
11401173
self.table.setCurrentItem(self.table.item(data.row, 0))
11411174
else:
1142-
translation_text.insertPlainText(data)
1175+
# Check if user is at bottom (watching stream)
1176+
scrollbar = translation_text.verticalScrollBar()
1177+
was_at_bottom = scrollbar.value() >= scrollbar.maximum() - 10
1178+
1179+
# Disable updates to prevent auto-scroll/flicker
1180+
translation_text.setUpdatesEnabled(False)
1181+
saved_position = scrollbar.value()
1182+
1183+
# Append to document using cursor at end
1184+
doc = translation_text.document()
1185+
cursor = QTextCursor(doc)
1186+
end_position = getattr(QTextCursor.MoveOperation, 'End', None) or QTextCursor.End
1187+
cursor.movePosition(end_position)
1188+
cursor.insertText(data)
1189+
1190+
# Restore scroll position before re-enabling updates
1191+
if not was_at_bottom:
1192+
scrollbar.setValue(saved_position)
1193+
1194+
# Re-enable updates - this triggers repaint
1195+
translation_text.setUpdatesEnabled(True)
1196+
1197+
# Scroll to bottom only if user was watching
1198+
if was_at_bottom:
1199+
scrollbar.setValue(scrollbar.maximum())
11431200
self.trans_worker.streaming.connect(streaming_translation)
11441201

11451202
def modify_translation():

lib/translation.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,14 +169,23 @@ def translate_paragraph(self, paragraph):
169169
temp = ''
170170
clear = True
171171
for char in translation:
172+
# Check for cancellation during streaming
173+
if self.cancel_request():
174+
raise TranslationCanceled(_('Translation canceled.'))
172175
if clear:
173176
self.streaming('')
174177
clear = False
175178
self.streaming(char)
176179
time.sleep(0.05)
177180
temp += char
178181
else:
179-
temp = ''.join([char for char in translation])
182+
# For batch mode, still check cancellation periodically
183+
temp_chars = []
184+
for char in translation:
185+
if self.cancel_request():
186+
raise TranslationCanceled(_('Translation canceled.'))
187+
temp_chars.append(char)
188+
temp = ''.join(temp_chars)
180189
translation = temp
181190
translation = self.glossary.restore(translation)
182191
paragraph.translation = translation.strip()

0 commit comments

Comments
 (0)