pygpt-net 2.6.34__py3-none-any.whl → 2.6.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. pygpt_net/CHANGELOG.txt +7 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/chat/common.py +8 -2
  4. pygpt_net/controller/chat/handler/stream_worker.py +55 -43
  5. pygpt_net/controller/painter/common.py +13 -1
  6. pygpt_net/controller/painter/painter.py +11 -2
  7. pygpt_net/core/bridge/bridge.py +1 -5
  8. pygpt_net/core/bridge/context.py +81 -36
  9. pygpt_net/core/bridge/worker.py +3 -1
  10. pygpt_net/core/ctx/bag.py +4 -0
  11. pygpt_net/core/events/app.py +10 -17
  12. pygpt_net/core/events/base.py +17 -25
  13. pygpt_net/core/events/control.py +9 -17
  14. pygpt_net/core/events/event.py +9 -62
  15. pygpt_net/core/events/kernel.py +8 -17
  16. pygpt_net/core/events/realtime.py +8 -17
  17. pygpt_net/core/events/render.py +9 -17
  18. pygpt_net/core/render/web/body.py +394 -36
  19. pygpt_net/core/render/web/pid.py +39 -24
  20. pygpt_net/core/render/web/renderer.py +146 -40
  21. pygpt_net/data/config/config.json +4 -3
  22. pygpt_net/data/config/models.json +3 -3
  23. pygpt_net/data/css/web-blocks.css +3 -2
  24. pygpt_net/data/css/web-chatgpt.css +3 -1
  25. pygpt_net/data/css/web-chatgpt_wide.css +3 -1
  26. pygpt_net/data/locale/locale.de.ini +1 -0
  27. pygpt_net/data/locale/locale.en.ini +3 -2
  28. pygpt_net/data/locale/locale.es.ini +1 -0
  29. pygpt_net/data/locale/locale.fr.ini +1 -0
  30. pygpt_net/data/locale/locale.it.ini +1 -0
  31. pygpt_net/data/locale/locale.pl.ini +2 -1
  32. pygpt_net/data/locale/locale.uk.ini +1 -0
  33. pygpt_net/data/locale/locale.zh.ini +1 -0
  34. pygpt_net/provider/api/google/__init__.py +14 -5
  35. pygpt_net/provider/api/openai/__init__.py +13 -10
  36. pygpt_net/provider/core/config/patch.py +9 -0
  37. pygpt_net/ui/layout/chat/painter.py +63 -4
  38. pygpt_net/ui/widget/draw/painter.py +702 -106
  39. pygpt_net/ui/widget/textarea/web.py +2 -0
  40. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +9 -2
  41. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +44 -44
  42. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
  43. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
  44. {pygpt_net-2.6.34.dist-info → pygpt_net-2.6.35.dist-info}/entry_points.txt +0 -0
@@ -6,7 +6,7 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.09.01 23:00:00 #
9
+ # Updated Date: 2025.09.04 00:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
@@ -35,6 +35,8 @@ class Body:
35
35
  <!DOCTYPE html>
36
36
  <html>
37
37
  <head>
38
+ <meta charset="utf-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1">
38
40
  <style>
39
41
  """
40
42
  _HTML_P1 = """
@@ -45,6 +47,10 @@ class Body:
45
47
  <script type="text/javascript" src="qrc:///js/katex.min.js"></script>
46
48
  <script>
47
49
  const DEBUG_MODE = false;
50
+ let bridgeConnected = false;
51
+ let streamHandler;
52
+ let nodeHandler;
53
+ let nodeReplaceHandler;
48
54
  let scrollTimeout = null;
49
55
  let prevScroll = 0;
50
56
  let bridge;
@@ -67,8 +73,9 @@ class Body:
67
73
  let highlightScheduled = false;
68
74
  let pendingHighlightRoot = null;
69
75
  let pendingHighlightMath = false;
76
+ let highlightRAF = 0; // RAF id for highlight batcher
70
77
  let scrollScheduled = false;
71
-
78
+
72
79
  // Auto-follow state: when false, live stream auto-scroll is suppressed
73
80
  let autoFollow = true;
74
81
  let lastScrollTop = 0;
@@ -76,9 +83,24 @@ class Body:
76
83
  let userInteracted = false;
77
84
  const AUTO_FOLLOW_REENABLE_PX = 8; // px from bottom to re-enable auto-follow
78
85
 
86
+ // FAB thresholds
87
+ const SHOW_DOWN_THRESHOLD_PX = 0; // show "down" only when farther than this from bottom
88
+ let currentFabAction = 'none'; // tracks current FAB state to avoid redundant work
89
+
79
90
  // timers
80
91
  let tipsTimers = [];
81
92
 
93
+ // observers
94
+ let roDoc = null;
95
+ let roContainer = null;
96
+
97
+ // FAB (scroll-to-top/bottom) scheduling
98
+ let scrollFabUpdateScheduled = false;
99
+
100
+ // Streaming micro-batching config
101
+ const STREAM_MAX_PER_FRAME = 64; // defensive upper bound of operations per frame
102
+ const STREAM_EMERGENCY_COALESCE_LEN = 1500; // when queue length is high, coalesce aggressively
103
+
82
104
  // clear previous references
83
105
  function resetEphemeralDomRefs() {
84
106
  domLastCodeBlock = null;
@@ -93,6 +115,26 @@ class Body:
93
115
  tipsTimers = [];
94
116
  }
95
117
 
118
+ function teardown() {
119
+ // Cancel timers/RAF/observers to prevent background CPU usage
120
+ stopTipsTimers();
121
+ try {
122
+ if (streamRAF) {
123
+ cancelAnimationFrame(streamRAF);
124
+ streamRAF = 0;
125
+ }
126
+ if (highlightRAF) {
127
+ cancelAnimationFrame(highlightRAF);
128
+ highlightRAF = 0;
129
+ }
130
+ } catch (e) { /* ignore */ }
131
+ // Clear streaming queue to release memory immediately
132
+ streamQ.length = 0;
133
+ scrollFabUpdateScheduled = false;
134
+ scrollScheduled = false;
135
+ highlightScheduled = false;
136
+ }
137
+
96
138
  history.scrollRestoration = "manual";
97
139
  document.addEventListener('keydown', function(event) {
98
140
  if (event.ctrlKey && event.key === 'f') {
@@ -124,6 +166,60 @@ class Body:
124
166
  els.footer = document.getElementById('_footer_');
125
167
  els.loader = document.getElementById('_loader_');
126
168
  els.tips = document.getElementById('tips');
169
+ // FAB refs
170
+ els.scrollFab = document.getElementById('scrollFab');
171
+ els.scrollFabIcon = document.getElementById('scrollFabIcon');
172
+ }
173
+ function bridgeConnect() {
174
+ // Idempotent connect
175
+ if (!bridge || !bridge.chunk || typeof bridge.chunk.connect !== 'function') return false;
176
+ if (bridgeConnected) return true;
177
+
178
+ // Ensure handler exists and is stable (same identity for disconnect/connect)
179
+ if (!streamHandler) {
180
+ streamHandler = (name, html, chunk, replace, isCode) => {
181
+ appendStream(name, html, chunk, replace, isCode);
182
+ };
183
+ nodeHandler = (html) => {
184
+ appendNode(html);
185
+ };
186
+ nodeReplaceHandler = (html) => {
187
+ replaceNodes(html);
188
+ };
189
+ }
190
+ try {
191
+ bridge.chunk.connect(streamHandler);
192
+ bridge.node.connect(nodeHandler);
193
+ bridge.nodeReplace.connect(nodeReplaceHandler);
194
+ bridgeConnected = true;
195
+ return true;
196
+ } catch (e) {
197
+ log(e);
198
+ return false;
199
+ }
200
+ }
201
+
202
+ function bridgeDisconnect() {
203
+ // Idempotent disconnect
204
+ if (!bridge || !bridge.chunk || typeof bridge.chunk.disconnect !== 'function') return false;
205
+ if (!bridgeConnected) return true;
206
+
207
+ try {
208
+ bridge.chunk.disconnect(streamHandler);
209
+ bridge.node.disconnect(nodeHandler);
210
+ bridge.nodeReplace.disconnect(nodeReplaceHandler);
211
+ } catch (e) { /* ignore */ }
212
+ bridgeConnected = false;
213
+
214
+ // Stop scheduled work and release pending chunks immediately
215
+ try { if (streamRAF) { cancelAnimationFrame(streamRAF); streamRAF = 0; } } catch (e) { /* ignore */ }
216
+ streamQ.length = 0;
217
+ return true;
218
+ }
219
+
220
+ function bridgeReconnect() {
221
+ bridgeDisconnect();
222
+ return bridgeConnect();
127
223
  }
128
224
  function scheduleHighlight(root, withMath = true) {
129
225
  const scope = root && root.nodeType === 1 ? root : document;
@@ -135,29 +231,37 @@ class Body:
135
231
  if (withMath) pendingHighlightMath = true;
136
232
  if (highlightScheduled) return;
137
233
  highlightScheduled = true;
138
- requestAnimationFrame(function() {
234
+ if (highlightRAF) {
235
+ // Ensure we do not queue multiple highlight frames
236
+ cancelAnimationFrame(highlightRAF);
237
+ highlightRAF = 0;
238
+ }
239
+ highlightRAF = requestAnimationFrame(function() {
139
240
  try {
140
241
  highlightCodeInternal(pendingHighlightRoot || document, pendingHighlightMath);
141
242
  } finally {
142
243
  highlightScheduled = false;
143
244
  pendingHighlightRoot = null;
144
245
  pendingHighlightMath = false;
246
+ highlightRAF = 0;
145
247
  }
146
248
  });
147
249
  }
148
250
  function highlightCodeInternal(root, withMath) {
149
- (root || document).querySelectorAll('pre code:not(.hljs)').forEach(el => {
251
+ (root || document).querySelectorAll('pre code').forEach(el => {
252
+ try { if (el.dataset) delete el.dataset.highlighted; } catch (e) {}
150
253
  hljs.highlightElement(el);
151
254
  });
152
255
  if (withMath) {
153
256
  renderMath(root);
257
+ if (DEBUG_MODE) log("math");
154
258
  }
155
259
  if (DEBUG_MODE) log("execute highlight");
156
- restoreCollapsedCode(root);
157
260
  }
158
261
  function highlightCode(withMath = true, root = null) {
159
262
  if (DEBUG_MODE) log("queue highlight, withMath: " + withMath);
160
- scheduleHighlight(root || document, withMath);
263
+ highlightCodeInternal(root || document, withMath); // prevent blink on fast updates
264
+ // scheduleHighlight(root || document, withMath); // disabled
161
265
  }
162
266
  function hideTips() {
163
267
  if (tips_hidden) return;
@@ -228,6 +332,8 @@ class Body:
228
332
  requestAnimationFrame(function() {
229
333
  scrollScheduled = false;
230
334
  scrollToBottom(live);
335
+ // keep FAB state in sync after any programmatic scroll
336
+ scheduleScrollFabUpdate();
231
337
  });
232
338
  }
233
339
  // Force immediate scroll to bottom (pre-interaction bootstrap)
@@ -240,14 +346,14 @@ class Body:
240
346
  const el = document.scrollingElement || document.documentElement;
241
347
  const marginPx = 450;
242
348
  const behavior = (live === true) ? 'instant' : 'smooth';
243
-
349
+
244
350
  // Respect user-follow state during live updates
245
351
  if (live === true && autoFollow !== true) {
246
352
  // Keep prevScroll consistent for potential consumers
247
353
  prevScroll = el.scrollHeight;
248
354
  return;
249
355
  }
250
-
356
+
251
357
  // Allow initial auto-follow before any user interaction
252
358
  if ((live === true && userInteracted === false) || isNearBottom(marginPx) || live == false) {
253
359
  el.scrollTo({ top: el.scrollHeight, behavior });
@@ -285,7 +391,9 @@ class Body:
285
391
  element.insertAdjacentHTML('beforeend', content);
286
392
  highlightCode(true, element);
287
393
  scrollToBottom(false); // without schedule
288
- }
394
+ scheduleScrollFabUpdate();
395
+ }
396
+ clearHighlightCache();
289
397
  }
290
398
  function replaceNodes(content) {
291
399
  if (DEBUG_MODE) {
@@ -300,7 +408,9 @@ class Body:
300
408
  element.insertAdjacentHTML('beforeend', content);
301
409
  highlightCode(true, element);
302
410
  scrollToBottom(false); // without schedule
411
+ scheduleScrollFabUpdate();
303
412
  }
413
+ clearHighlightCache();
304
414
  }
305
415
  function clean() {
306
416
  if (DEBUG_MODE) {
@@ -323,6 +433,12 @@ class Body:
323
433
  }
324
434
  */
325
435
  }
436
+ function clearHighlightCache() {
437
+ const elements = document.querySelectorAll('pre code');
438
+ elements.forEach(function(el) {
439
+ try { if (el.dataset) delete el.dataset.highlighted; } catch (e) {}
440
+ });
441
+ }
326
442
  function appendExtra(id, content) {
327
443
  hideTips();
328
444
  prevScroll = 0;
@@ -413,8 +529,10 @@ class Body:
413
529
  log("STREAM END");
414
530
  }
415
531
  clearOutput();
532
+ bridgeReconnect();
416
533
  }
417
534
  function enqueueStream(name_header, content, chunk, replace = false, is_code_block = false) {
535
+ // Push incoming chunk; scheduling is done with RAF to batch DOM ops
418
536
  streamQ.push({name_header, content, chunk, replace, is_code_block});
419
537
  if (!streamRAF) {
420
538
  streamRAF = requestAnimationFrame(drainStream);
@@ -422,16 +540,53 @@ class Body:
422
540
  }
423
541
  function drainStream() {
424
542
  streamRAF = 0;
425
- while (streamQ.length) {
426
- const {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
427
- appendStream(name_header, content, chunk, replace, is_code_block);
543
+ let processed = 0;
544
+
545
+ // Emergency coalescing if queue grows too large
546
+ const shouldAggressiveCoalesce = streamQ.length >= STREAM_EMERGENCY_COALESCE_LEN;
547
+
548
+ while (streamQ.length && processed < STREAM_MAX_PER_FRAME) {
549
+ let {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
550
+
551
+ // Coalesce contiguous simple appends to reduce DOM churn
552
+ if (!replace && !content && (chunk && chunk.length > 0)) {
553
+ // Collect chunks into an array to avoid O(n^2) string concatenation
554
+ const chunks = [chunk];
555
+ while (streamQ.length) {
556
+ const next = streamQ[0];
557
+ if (!next.replace && !next.content && next.is_code_block === is_code_block && next.name_header === name_header) {
558
+ chunks.push(next.chunk);
559
+ streamQ.shift();
560
+ if (!shouldAggressiveCoalesce) {
561
+ // Light coalescing per frame is enough under normal conditions
562
+ break;
563
+ }
564
+ } else {
565
+ break;
566
+ }
567
+ }
568
+ chunk = chunks.join('');
569
+ }
570
+
571
+ applyStream(name_header, content, chunk, replace, is_code_block);
572
+ processed++;
573
+ }
574
+
575
+ // If there are remaining items re-schedule next frame
576
+ if (streamQ.length) {
577
+ streamRAF = requestAnimationFrame(drainStream);
428
578
  }
429
579
  }
580
+ // Public API: enqueue and process in the next animation frame
430
581
  function appendStream(name_header, content, chunk, replace = false, is_code_block = false) {
582
+ enqueueStream(name_header, content, chunk, replace, is_code_block);
583
+ }
584
+ // Internal: performs actual DOM updates for a single merged chunk
585
+ function applyStream(name_header, content, chunk, replace = false, is_code_block = false) {
431
586
  dropIfDetached(); // clear references to detached elements
432
587
  hideTips();
433
588
  if (DEBUG_MODE) {
434
- log("APPEND CHUNK: {" + chunk + "}, CONTENT: {"+content+"}, replace: " + replace + ", is_code_block: " + is_code_block);
589
+ log("APPLY CHUNK: {" + chunk + "}, CONTENT: {"+content+"}, replace: " + replace + ", is_code_block: " + is_code_block);
435
590
  }
436
591
  const element = getStreamContainer();
437
592
  let msg;
@@ -456,7 +611,9 @@ class Body:
456
611
  msg = box.querySelector('.msg');
457
612
  }
458
613
  if (msg) {
459
- if (replace) {
614
+ if (replace) {
615
+ domLastCodeBlock = null;
616
+ domLastParagraphBlock = null;
460
617
  msg.replaceChildren();
461
618
  if (content) {
462
619
  msg.insertAdjacentHTML('afterbegin', content);
@@ -466,17 +623,23 @@ class Body:
466
623
  doMath = false;
467
624
  }
468
625
  highlightCode(doMath, msg);
469
- domLastCodeBlock = null;
470
- domLastParagraphBlock = null;
471
626
  } else {
472
627
  if (is_code_block) {
473
- let lastCodeBlock;
474
- if (domLastCodeBlock) {
475
- lastCodeBlock = domLastCodeBlock;
476
- } else {
477
- const msgBlocks = msg.querySelectorAll('pre');
478
- if (msgBlocks.length > 0) {
479
- lastCodeBlock = msgBlocks[msgBlocks.length - 1].querySelector('code');
628
+ // Try to reuse cached last code block; fallback to cheap lastElementChild check
629
+ let lastCodeBlock = domLastCodeBlock;
630
+ if (!lastCodeBlock || !msg.contains(lastCodeBlock)) {
631
+ const last = msg.lastElementChild;
632
+ if (last && last.tagName === 'PRE') {
633
+ const codeEl = last.querySelector('code');
634
+ if (codeEl) {
635
+ lastCodeBlock = codeEl;
636
+ }
637
+ } else {
638
+ // Fallback scan only when necessary
639
+ const codes = msg.querySelectorAll('pre code');
640
+ if (codes.length > 0) {
641
+ lastCodeBlock = codes[codes.length - 1];
642
+ }
480
643
  }
481
644
  }
482
645
  if (lastCodeBlock) {
@@ -610,10 +773,12 @@ class Body:
610
773
  const outputEl = element.querySelector('.tool-output');
611
774
  if (outputEl) {
612
775
  const contentEl = outputEl.querySelector('.content');
613
- if (contentEl.style.display === 'none') {
614
- contentEl.style.display = 'block';
615
- } else {
616
- contentEl.style.display = 'none';
776
+ if (contentEl) {
777
+ if (contentEl.style.display === 'none') {
778
+ contentEl.style.display = 'block';
779
+ } else {
780
+ contentEl.style.display = 'none';
781
+ }
617
782
  }
618
783
  const toggleEl = outputEl.querySelector('.toggle-cmd-output img');
619
784
  if (toggleEl) {
@@ -660,10 +825,12 @@ class Body:
660
825
  clearStreamBefore();
661
826
  domLastCodeBlock = null;
662
827
  domLastParagraphBlock = null;
828
+ domOutputStream = null; // release handle to allow GC on old container subtree
663
829
  const element = els.appendOutput || document.getElementById('_append_output_');
664
830
  if (element) {
665
831
  element.replaceChildren();
666
832
  }
833
+ clearHighlightCache();
667
834
  }
668
835
  function clearLive() {
669
836
  const element = els.appendLive || document.getElementById('_append_live_');
@@ -736,7 +903,7 @@ class Body:
736
903
  const source = wrapper.querySelector('code');
737
904
  if (source && collapsed_idx.includes(index)) {
738
905
  source.style.display = 'none';
739
- const collapseBtn = wrapper.querySelector('.code-header-collapse');
906
+ const collapseBtn = wrapper.querySelector('code-header-collapse');
740
907
  if (collapseBtn) {
741
908
  const collapseSpan = collapseBtn.querySelector('span');
742
909
  if (collapseSpan) {
@@ -803,12 +970,122 @@ class Body:
803
970
  el.classList.add('hidden');
804
971
  }
805
972
  }
973
+
974
+ // ---------- scroll bottom ----------
975
+ function hasVerticalScroll() {
976
+ const el = document.scrollingElement || document.documentElement;
977
+ return (el.scrollHeight - el.clientHeight) > 1;
978
+ }
979
+ function distanceToBottomPx() {
980
+ const el = document.scrollingElement || document.documentElement;
981
+ return el.scrollHeight - el.clientHeight - el.scrollTop;
982
+ }
983
+ function isAtBottom(thresholdPx = 2) {
984
+ return distanceToBottomPx() <= thresholdPx;
985
+ }
986
+ function isLoaderHidden() {
987
+ const el = els.loader || document.getElementById('_loader_');
988
+ // If loader element is missing treat as hidden to avoid blocking FAB unnecessarily
989
+ return !el || el.classList.contains('hidden');
990
+ }
991
+ function updateScrollFab() {
992
+ const btn = els.scrollFab || document.getElementById('scrollFab');
993
+ const icon = els.scrollFabIcon || document.getElementById('scrollFabIcon');
994
+ if (!btn || !icon) return;
995
+
996
+ const hasScroll = hasVerticalScroll();
997
+ if (!hasScroll) {
998
+ btn.classList.remove('visible');
999
+ currentFabAction = 'none';
1000
+ return;
1001
+ }
1002
+
1003
+ const atBottom = isAtBottom();
1004
+ const dist = distanceToBottomPx();
1005
+ const loaderHidden = isLoaderHidden();
1006
+
1007
+ // Determine desired action and visibility based on requirements:
1008
+ // - Show "down" only when at least SHOW_DOWN_THRESHOLD_PX away from bottom.
1009
+ // - Show "up" only when loader-global has the 'hidden' class.
1010
+ // - Otherwise hide the FAB to prevent overlap and noise.
1011
+ let action = 'none'; // 'up' | 'down' | 'none'
1012
+ if (atBottom) {
1013
+ if (loaderHidden) {
1014
+ action = 'up';
1015
+ } else {
1016
+ action = 'none';
1017
+ }
1018
+ } else {
1019
+ if (dist >= SHOW_DOWN_THRESHOLD_PX) {
1020
+ action = 'down';
1021
+ } else {
1022
+ action = 'none';
1023
+ }
1024
+ }
1025
+
1026
+ if (action === 'none') {
1027
+ btn.classList.remove('visible');
1028
+ currentFabAction = 'none';
1029
+ return;
1030
+ }
1031
+
1032
+ // Update icon and semantics only if changed to avoid redundant 'load' events
1033
+ if (action !== currentFabAction) {
1034
+ if (action === 'up') {
1035
+ if (icon.src !== ICON_COLLAPSE) icon.src = ICON_COLLAPSE;
1036
+ btn.title = "Go to top";
1037
+ } else {
1038
+ if (icon.src !== ICON_EXPAND) icon.src = ICON_EXPAND;
1039
+ btn.title = "Go to bottom";
1040
+ }
1041
+ btn.setAttribute('aria-label', btn.title);
1042
+ currentFabAction = action;
1043
+ }
1044
+
1045
+ // Finally show
1046
+ btn.classList.add('visible');
1047
+ }
1048
+ function scheduleScrollFabUpdate() {
1049
+ if (scrollFabUpdateScheduled) return;
1050
+ scrollFabUpdateScheduled = true;
1051
+ requestAnimationFrame(function() {
1052
+ scrollFabUpdateScheduled = false;
1053
+ updateScrollFab();
1054
+ });
1055
+ }
1056
+ function scrollToTopUser() {
1057
+ // Explicit user-driven scroll disables auto-follow
1058
+ userInteracted = true;
1059
+ autoFollow = false;
1060
+ try {
1061
+ const el = document.scrollingElement || document.documentElement;
1062
+ el.scrollTo({ top: 0, behavior: 'smooth' });
1063
+ } catch (e) {
1064
+ // Fallback in environments without smooth scrolling support
1065
+ const el = document.scrollingElement || document.documentElement;
1066
+ el.scrollTop = 0;
1067
+ }
1068
+ scheduleScrollFabUpdate();
1069
+ }
1070
+ function scrollToBottomUser() {
1071
+ // User action to go to bottom re-enables auto-follow
1072
+ userInteracted = true;
1073
+ autoFollow = true;
1074
+ try {
1075
+ const el = document.scrollingElement || document.documentElement;
1076
+ el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
1077
+ } catch (e) {
1078
+ const el = document.scrollingElement || document.documentElement;
1079
+ el.scrollTop = el.scrollHeight;
1080
+ }
1081
+ scheduleScrollFabUpdate();
1082
+ }
1083
+ // ---------- end of scroll bottom ----------
1084
+
806
1085
  document.addEventListener('DOMContentLoaded', function() {
807
1086
  new QWebChannel(qt.webChannelTransport, function (channel) {
808
1087
  bridge = channel.objects.bridge;
809
- bridge.chunk.connect((name, html, chunk, replace, isCode) => {
810
- appendStream(name, html, chunk, replace, isCode);
811
- });
1088
+ bridgeConnect();
812
1089
  if (bridge.js_ready) bridge.js_ready();
813
1090
  });
814
1091
  initDomRefs();
@@ -844,12 +1121,12 @@ class Body:
844
1121
  autoFollow = false;
845
1122
  }
846
1123
  }, { passive: true });
847
-
1124
+
848
1125
  // Track scroll direction and restore auto-follow when user returns to bottom
849
1126
  window.addEventListener('scroll', function() {
850
1127
  const el = document.scrollingElement || document.documentElement;
851
1128
  const top = el.scrollTop;
852
-
1129
+
853
1130
  // User scrolled up (ignore tiny jitter)
854
1131
  if (top + 1 < lastScrollTop) {
855
1132
  autoFollow = false;
@@ -861,6 +1138,24 @@ class Body:
861
1138
  }
862
1139
  lastScrollTop = top;
863
1140
  }, { passive: true });
1141
+
1142
+ // Scroll-to-top/bottom FAB wiring
1143
+ if (els.scrollFab) {
1144
+ els.scrollFab.addEventListener('click', function(ev) {
1145
+ ev.preventDefault();
1146
+ if (isAtBottom()) {
1147
+ scrollToTopUser();
1148
+ } else {
1149
+ scrollToBottomUser();
1150
+ }
1151
+ }, { passive: false });
1152
+ }
1153
+ window.addEventListener('scroll', scheduleScrollFabUpdate, { passive: true });
1154
+ window.addEventListener('resize', scheduleScrollFabUpdate, { passive: true });
1155
+
1156
+ // Initial state
1157
+ scheduleScrollFabUpdate();
1158
+
864
1159
  container.addEventListener('click', function(event) {
865
1160
  const copyButton = event.target.closest('.code-header-copy');
866
1161
  if (copyButton) {
@@ -935,6 +1230,10 @@ class Body:
935
1230
  }
936
1231
  }
937
1232
  });
1233
+
1234
+ // Cleanup on page lifecycle changes
1235
+ window.addEventListener('pagehide', teardown, { passive: true });
1236
+ window.addEventListener('beforeunload', teardown, { passive: true });
938
1237
  });
939
1238
  setTimeout(cycleTips, 10000); // after 10 seconds
940
1239
  </script>
@@ -953,6 +1252,9 @@ class Body:
953
1252
  </div>
954
1253
  <div id="tips" class="tips"></div>
955
1254
  </div>
1255
+ <button id="scrollFab" class="scroll-fab" type="button" title="Go to top" aria-label="Go to top">
1256
+ <img id="scrollFabIcon" src="" alt="Scroll">
1257
+ </button>
956
1258
  </body>
957
1259
  </html>
958
1260
  """
@@ -1003,6 +1305,51 @@ class Body:
1003
1305
  }
1004
1306
  """
1005
1307
 
1308
+ # CSS for the scroll-to-top/bottom
1309
+ _SCROLL_FAB_CSS = """
1310
+ #scrollFab.scroll-fab {
1311
+ position: fixed;
1312
+ //left: 50%;
1313
+ right: 16px;
1314
+ bottom: 16px;
1315
+ width: 40px;
1316
+ height: 40px;
1317
+ border: none;
1318
+ background: transparent;
1319
+ padding: 0;
1320
+ margin: 0;
1321
+ display: none;
1322
+ align-items: center;
1323
+ justify-content: center;
1324
+ z-index: 2147483647;
1325
+ cursor: pointer;
1326
+ opacity: .65;
1327
+ transition: opacity .2s ease, transform .2s ease;
1328
+ //transform: translate(-50%, 0);
1329
+ will-change: transform, opacity;
1330
+ pointer-events: auto;
1331
+ -webkit-tap-highlight-color: transparent;
1332
+ }
1333
+ #scrollFab.scroll-fab.visible {
1334
+ display: inline-flex;
1335
+ }
1336
+ #scrollFab.scroll-fab:hover {
1337
+ opacity: 1;
1338
+ //transform: translate(-50%, -1px);
1339
+ }
1340
+ #scrollFab.scroll-fab img {
1341
+ width: 100%;
1342
+ height: 100%;
1343
+ display: block;
1344
+ pointer-events: none;
1345
+ }
1346
+ @media (prefers-reduced-motion: reduce) {
1347
+ #scrollFab.scroll-fab {
1348
+ transition: none;
1349
+ }
1350
+ }
1351
+ """
1352
+
1006
1353
  def __init__(self, window=None):
1007
1354
  """
1008
1355
  HTML Body
@@ -1052,9 +1399,13 @@ class Body:
1052
1399
  syntax_style = self.window.core.config.get("render.code_syntax") or "default"
1053
1400
 
1054
1401
  theme_css = self.window.controller.theme.markdown.get_web_css().replace('%fonts%', fonts_path)
1055
- parts = [self._SPINNER, theme_css,
1056
- "pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
1057
- self.highlight.get_style_defs()]
1402
+ parts = [
1403
+ self._SPINNER,
1404
+ theme_css,
1405
+ "pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
1406
+ self.highlight.get_style_defs(),
1407
+ self._SCROLL_FAB_CSS, # keep FAB styles last to ensure precedence
1408
+ ]
1058
1409
  return "\n".join(parts)
1059
1410
 
1060
1411
  def prepare_action_icons(self, ctx: CtxItem) -> str:
@@ -1309,11 +1660,18 @@ class Body:
1309
1660
  styles_css = self.prepare_styles()
1310
1661
  tips_json = self.get_all_tips()
1311
1662
 
1663
+ # Build file:// paths for FAB icons
1664
+ app_path = self.window.core.config.get_app_path().replace("\\", "/")
1665
+ expand_path = os.path.join(app_path, "data", "icons", "expand.svg").replace("\\", "/")
1666
+ collapse_path = os.path.join(app_path, "data", "icons", "collapse.svg").replace("\\", "/")
1667
+ icons_js = f';const ICON_EXPAND="file://{expand_path}";const ICON_COLLAPSE="file://{collapse_path}";'
1668
+
1312
1669
  return ''.join((
1313
1670
  self._HTML_P0,
1314
1671
  styles_css,
1315
1672
  self._HTML_P1,
1316
1673
  str(pid),
1674
+ icons_js,
1317
1675
  self._HTML_P2,
1318
1676
  tips_json,
1319
1677
  self._HTML_P3,