pygpt-net 2.6.33__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 (64) hide show
  1. pygpt_net/CHANGELOG.txt +14 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/controller/assistant/batch.py +14 -4
  4. pygpt_net/controller/assistant/files.py +1 -0
  5. pygpt_net/controller/assistant/store.py +195 -1
  6. pygpt_net/controller/camera/camera.py +1 -1
  7. pygpt_net/controller/chat/common.py +58 -48
  8. pygpt_net/controller/chat/handler/stream_worker.py +55 -43
  9. pygpt_net/controller/config/placeholder.py +95 -75
  10. pygpt_net/controller/dialogs/confirm.py +3 -1
  11. pygpt_net/controller/media/media.py +11 -3
  12. pygpt_net/controller/painter/common.py +243 -13
  13. pygpt_net/controller/painter/painter.py +11 -2
  14. pygpt_net/core/assistants/files.py +18 -0
  15. pygpt_net/core/bridge/bridge.py +1 -5
  16. pygpt_net/core/bridge/context.py +81 -36
  17. pygpt_net/core/bridge/worker.py +3 -1
  18. pygpt_net/core/camera/camera.py +31 -402
  19. pygpt_net/core/camera/worker.py +430 -0
  20. pygpt_net/core/ctx/bag.py +4 -0
  21. pygpt_net/core/events/app.py +10 -17
  22. pygpt_net/core/events/base.py +17 -25
  23. pygpt_net/core/events/control.py +9 -17
  24. pygpt_net/core/events/event.py +9 -62
  25. pygpt_net/core/events/kernel.py +8 -17
  26. pygpt_net/core/events/realtime.py +8 -17
  27. pygpt_net/core/events/render.py +9 -17
  28. pygpt_net/core/filesystem/url.py +3 -0
  29. pygpt_net/core/render/web/body.py +454 -40
  30. pygpt_net/core/render/web/pid.py +39 -24
  31. pygpt_net/core/render/web/renderer.py +146 -40
  32. pygpt_net/core/text/utils.py +3 -0
  33. pygpt_net/data/config/config.json +4 -3
  34. pygpt_net/data/config/models.json +3 -3
  35. pygpt_net/data/config/settings.json +10 -5
  36. pygpt_net/data/css/web-blocks.css +3 -2
  37. pygpt_net/data/css/web-chatgpt.css +3 -1
  38. pygpt_net/data/css/web-chatgpt_wide.css +3 -1
  39. pygpt_net/data/locale/locale.de.ini +9 -7
  40. pygpt_net/data/locale/locale.en.ini +10 -6
  41. pygpt_net/data/locale/locale.es.ini +9 -7
  42. pygpt_net/data/locale/locale.fr.ini +9 -7
  43. pygpt_net/data/locale/locale.it.ini +9 -7
  44. pygpt_net/data/locale/locale.pl.ini +9 -7
  45. pygpt_net/data/locale/locale.uk.ini +9 -7
  46. pygpt_net/data/locale/locale.zh.ini +9 -7
  47. pygpt_net/item/assistant.py +13 -1
  48. pygpt_net/provider/api/google/__init__.py +46 -28
  49. pygpt_net/provider/api/openai/__init__.py +13 -10
  50. pygpt_net/provider/api/openai/store.py +45 -1
  51. pygpt_net/provider/core/config/patch.py +9 -0
  52. pygpt_net/provider/llms/google.py +4 -0
  53. pygpt_net/ui/dialog/assistant_store.py +213 -203
  54. pygpt_net/ui/layout/chat/input.py +3 -3
  55. pygpt_net/ui/layout/chat/painter.py +63 -4
  56. pygpt_net/ui/widget/draw/painter.py +715 -104
  57. pygpt_net/ui/widget/option/combo.py +5 -1
  58. pygpt_net/ui/widget/textarea/input.py +273 -3
  59. pygpt_net/ui/widget/textarea/web.py +2 -0
  60. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/METADATA +16 -2
  61. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/RECORD +64 -63
  62. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/LICENSE +0 -0
  63. {pygpt_net-2.6.33.dist-info → pygpt_net-2.6.35.dist-info}/WHEEL +0 -0
  64. {pygpt_net-2.6.33.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,11 +73,34 @@ 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
 
79
+ // Auto-follow state: when false, live stream auto-scroll is suppressed
80
+ let autoFollow = true;
81
+ let lastScrollTop = 0;
82
+ // Tracks whether user has performed any scroll-related interaction
83
+ let userInteracted = false;
84
+ const AUTO_FOLLOW_REENABLE_PX = 8; // px from bottom to re-enable auto-follow
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
+
72
90
  // timers
73
91
  let tipsTimers = [];
74
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
+
75
104
  // clear previous references
76
105
  function resetEphemeralDomRefs() {
77
106
  domLastCodeBlock = null;
@@ -86,6 +115,26 @@ class Body:
86
115
  tipsTimers = [];
87
116
  }
88
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
+
89
138
  history.scrollRestoration = "manual";
90
139
  document.addEventListener('keydown', function(event) {
91
140
  if (event.ctrlKey && event.key === 'f') {
@@ -117,6 +166,60 @@ class Body:
117
166
  els.footer = document.getElementById('_footer_');
118
167
  els.loader = document.getElementById('_loader_');
119
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();
120
223
  }
121
224
  function scheduleHighlight(root, withMath = true) {
122
225
  const scope = root && root.nodeType === 1 ? root : document;
@@ -128,29 +231,37 @@ class Body:
128
231
  if (withMath) pendingHighlightMath = true;
129
232
  if (highlightScheduled) return;
130
233
  highlightScheduled = true;
131
- 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() {
132
240
  try {
133
241
  highlightCodeInternal(pendingHighlightRoot || document, pendingHighlightMath);
134
242
  } finally {
135
243
  highlightScheduled = false;
136
244
  pendingHighlightRoot = null;
137
245
  pendingHighlightMath = false;
246
+ highlightRAF = 0;
138
247
  }
139
248
  });
140
249
  }
141
250
  function highlightCodeInternal(root, withMath) {
142
- (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) {}
143
253
  hljs.highlightElement(el);
144
254
  });
145
255
  if (withMath) {
146
256
  renderMath(root);
257
+ if (DEBUG_MODE) log("math");
147
258
  }
148
259
  if (DEBUG_MODE) log("execute highlight");
149
- restoreCollapsedCode(root);
150
260
  }
151
261
  function highlightCode(withMath = true, root = null) {
152
262
  if (DEBUG_MODE) log("queue highlight, withMath: " + withMath);
153
- scheduleHighlight(root || document, withMath);
263
+ highlightCodeInternal(root || document, withMath); // prevent blink on fast updates
264
+ // scheduleHighlight(root || document, withMath); // disabled
154
265
  }
155
266
  function hideTips() {
156
267
  if (tips_hidden) return;
@@ -214,28 +325,43 @@ class Body:
214
325
  return distanceToBottom <= marginPx;
215
326
  }
216
327
  function scheduleScroll(live = false) {
328
+ // Skip scheduling live auto-scroll when user disabled follow
329
+ if (live === true && autoFollow !== true) return;
217
330
  if (scrollScheduled) return;
218
331
  scrollScheduled = true;
219
332
  requestAnimationFrame(function() {
220
333
  scrollScheduled = false;
221
334
  scrollToBottom(live);
335
+ // keep FAB state in sync after any programmatic scroll
336
+ scheduleScrollFabUpdate();
222
337
  });
223
338
  }
339
+ // Force immediate scroll to bottom (pre-interaction bootstrap)
340
+ function forceScrollToBottomImmediate() {
341
+ const el = document.scrollingElement || document.documentElement;
342
+ el.scrollTop = el.scrollHeight; // no behavior, no RAF, deterministic
343
+ prevScroll = el.scrollHeight;
344
+ }
224
345
  function scrollToBottom(live = false) {
225
346
  const el = document.scrollingElement || document.documentElement;
226
347
  const marginPx = 450;
227
- let behavior = 'instant';
228
- if (live == true) {
229
- behavior = 'instant';
230
- } else {
231
- behavior = 'smooth';
348
+ const behavior = (live === true) ? 'instant' : 'smooth';
349
+
350
+ // Respect user-follow state during live updates
351
+ if (live === true && autoFollow !== true) {
352
+ // Keep prevScroll consistent for potential consumers
353
+ prevScroll = el.scrollHeight;
354
+ return;
232
355
  }
233
- if (isNearBottom(marginPx) || live == false) {
356
+
357
+ // Allow initial auto-follow before any user interaction
358
+ if ((live === true && userInteracted === false) || isNearBottom(marginPx) || live == false) {
234
359
  el.scrollTo({ top: el.scrollHeight, behavior });
235
360
  }
236
361
  prevScroll = el.scrollHeight;
237
362
  }
238
363
  function appendToInput(content) {
364
+ userInteracted = false;
239
365
  const element = els.appendInput || document.getElementById('_append_input_');
240
366
  if (element) {
241
367
  element.insertAdjacentHTML('beforeend', content);
@@ -265,7 +391,9 @@ class Body:
265
391
  element.insertAdjacentHTML('beforeend', content);
266
392
  highlightCode(true, element);
267
393
  scrollToBottom(false); // without schedule
268
- }
394
+ scheduleScrollFabUpdate();
395
+ }
396
+ clearHighlightCache();
269
397
  }
270
398
  function replaceNodes(content) {
271
399
  if (DEBUG_MODE) {
@@ -280,12 +408,15 @@ class Body:
280
408
  element.insertAdjacentHTML('beforeend', content);
281
409
  highlightCode(true, element);
282
410
  scrollToBottom(false); // without schedule
411
+ scheduleScrollFabUpdate();
283
412
  }
413
+ clearHighlightCache();
284
414
  }
285
415
  function clean() {
286
416
  if (DEBUG_MODE) {
287
417
  log("-- CLEAN DOM --");
288
418
  }
419
+ userInteracted = false;
289
420
  const el = els.nodes || document.getElementById('_nodes_');
290
421
  if (el) {
291
422
  el.replaceChildren();
@@ -302,6 +433,12 @@ class Body:
302
433
  }
303
434
  */
304
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
+ }
305
442
  function appendExtra(id, content) {
306
443
  hideTips();
307
444
  prevScroll = 0;
@@ -381,16 +518,21 @@ class Body:
381
518
  if (DEBUG_MODE) {
382
519
  log("STREAM BEGIN");
383
520
  }
521
+ userInteracted = false;
384
522
  clearOutput();
385
- scheduleScroll();
523
+ // Ensure initial auto-follow baseline before any chunks overflow
524
+ forceScrollToBottomImmediate();
525
+ scheduleScroll(); // keep existing logic
386
526
  }
387
527
  function endStream() {
388
528
  if (DEBUG_MODE) {
389
529
  log("STREAM END");
390
530
  }
391
531
  clearOutput();
532
+ bridgeReconnect();
392
533
  }
393
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
394
536
  streamQ.push({name_header, content, chunk, replace, is_code_block});
395
537
  if (!streamRAF) {
396
538
  streamRAF = requestAnimationFrame(drainStream);
@@ -398,16 +540,53 @@ class Body:
398
540
  }
399
541
  function drainStream() {
400
542
  streamRAF = 0;
401
- while (streamQ.length) {
402
- const {name_header, content, chunk, replace, is_code_block} = streamQ.shift();
403
- 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);
404
578
  }
405
579
  }
580
+ // Public API: enqueue and process in the next animation frame
406
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) {
407
586
  dropIfDetached(); // clear references to detached elements
408
587
  hideTips();
409
588
  if (DEBUG_MODE) {
410
- 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);
411
590
  }
412
591
  const element = getStreamContainer();
413
592
  let msg;
@@ -432,7 +611,9 @@ class Body:
432
611
  msg = box.querySelector('.msg');
433
612
  }
434
613
  if (msg) {
435
- if (replace) {
614
+ if (replace) {
615
+ domLastCodeBlock = null;
616
+ domLastParagraphBlock = null;
436
617
  msg.replaceChildren();
437
618
  if (content) {
438
619
  msg.insertAdjacentHTML('afterbegin', content);
@@ -442,17 +623,23 @@ class Body:
442
623
  doMath = false;
443
624
  }
444
625
  highlightCode(doMath, msg);
445
- domLastCodeBlock = null;
446
- domLastParagraphBlock = null;
447
626
  } else {
448
627
  if (is_code_block) {
449
- let lastCodeBlock;
450
- if (domLastCodeBlock) {
451
- lastCodeBlock = domLastCodeBlock;
452
- } else {
453
- const msgBlocks = msg.querySelectorAll('pre');
454
- if (msgBlocks.length > 0) {
455
- 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
+ }
456
643
  }
457
644
  }
458
645
  if (lastCodeBlock) {
@@ -481,7 +668,12 @@ class Body:
481
668
  }
482
669
  }
483
670
  }
484
- scheduleScroll(true);
671
+ // Initial auto-follow until first user interaction
672
+ if (userInteracted === false) {
673
+ forceScrollToBottomImmediate();
674
+ } else {
675
+ scheduleScroll(true);
676
+ }
485
677
  }
486
678
  function nextStream() {
487
679
  hideTips();
@@ -581,10 +773,12 @@ class Body:
581
773
  const outputEl = element.querySelector('.tool-output');
582
774
  if (outputEl) {
583
775
  const contentEl = outputEl.querySelector('.content');
584
- if (contentEl.style.display === 'none') {
585
- contentEl.style.display = 'block';
586
- } else {
587
- 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
+ }
588
782
  }
589
783
  const toggleEl = outputEl.querySelector('.toggle-cmd-output img');
590
784
  if (toggleEl) {
@@ -631,10 +825,12 @@ class Body:
631
825
  clearStreamBefore();
632
826
  domLastCodeBlock = null;
633
827
  domLastParagraphBlock = null;
828
+ domOutputStream = null; // release handle to allow GC on old container subtree
634
829
  const element = els.appendOutput || document.getElementById('_append_output_');
635
830
  if (element) {
636
831
  element.replaceChildren();
637
832
  }
833
+ clearHighlightCache();
638
834
  }
639
835
  function clearLive() {
640
836
  const element = els.appendLive || document.getElementById('_append_live_');
@@ -707,7 +903,7 @@ class Body:
707
903
  const source = wrapper.querySelector('code');
708
904
  if (source && collapsed_idx.includes(index)) {
709
905
  source.style.display = 'none';
710
- const collapseBtn = wrapper.querySelector('.code-header-collapse');
906
+ const collapseBtn = wrapper.querySelector('code-header-collapse');
711
907
  if (collapseBtn) {
712
908
  const collapseSpan = collapseBtn.querySelector('span');
713
909
  if (collapseSpan) {
@@ -774,12 +970,122 @@ class Body:
774
970
  el.classList.add('hidden');
775
971
  }
776
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
+
777
1085
  document.addEventListener('DOMContentLoaded', function() {
778
1086
  new QWebChannel(qt.webChannelTransport, function (channel) {
779
1087
  bridge = channel.objects.bridge;
780
- bridge.chunk.connect((name, html, chunk, replace, isCode) => {
781
- appendStream(name, html, chunk, replace, isCode);
782
- });
1088
+ bridgeConnect();
783
1089
  if (bridge.js_ready) bridge.js_ready();
784
1090
  });
785
1091
  initDomRefs();
@@ -808,6 +1114,48 @@ class Body:
808
1114
  removeClassFromMsg(id, 'msg-highlight');
809
1115
  }
810
1116
  });
1117
+ // Wheel up disables auto-follow immediately (works even at absolute bottom)
1118
+ document.addEventListener('wheel', function(ev) {
1119
+ userInteracted = true;
1120
+ if (ev.deltaY < 0) {
1121
+ autoFollow = false;
1122
+ }
1123
+ }, { passive: true });
1124
+
1125
+ // Track scroll direction and restore auto-follow when user returns to bottom
1126
+ window.addEventListener('scroll', function() {
1127
+ const el = document.scrollingElement || document.documentElement;
1128
+ const top = el.scrollTop;
1129
+
1130
+ // User scrolled up (ignore tiny jitter)
1131
+ if (top + 1 < lastScrollTop) {
1132
+ autoFollow = false;
1133
+ } else if (!autoFollow) {
1134
+ const distanceToBottom = el.scrollHeight - el.clientHeight - top;
1135
+ if (distanceToBottom <= AUTO_FOLLOW_REENABLE_PX) {
1136
+ autoFollow = true;
1137
+ }
1138
+ }
1139
+ lastScrollTop = top;
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
+
811
1159
  container.addEventListener('click', function(event) {
812
1160
  const copyButton = event.target.closest('.code-header-copy');
813
1161
  if (copyButton) {
@@ -882,6 +1230,10 @@ class Body:
882
1230
  }
883
1231
  }
884
1232
  });
1233
+
1234
+ // Cleanup on page lifecycle changes
1235
+ window.addEventListener('pagehide', teardown, { passive: true });
1236
+ window.addEventListener('beforeunload', teardown, { passive: true });
885
1237
  });
886
1238
  setTimeout(cycleTips, 10000); // after 10 seconds
887
1239
  </script>
@@ -900,6 +1252,9 @@ class Body:
900
1252
  </div>
901
1253
  <div id="tips" class="tips"></div>
902
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>
903
1258
  </body>
904
1259
  </html>
905
1260
  """
@@ -950,6 +1305,51 @@ class Body:
950
1305
  }
951
1306
  """
952
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
+
953
1353
  def __init__(self, window=None):
954
1354
  """
955
1355
  HTML Body
@@ -999,9 +1399,13 @@ class Body:
999
1399
  syntax_style = self.window.core.config.get("render.code_syntax") or "default"
1000
1400
 
1001
1401
  theme_css = self.window.controller.theme.markdown.get_web_css().replace('%fonts%', fonts_path)
1002
- parts = [self._SPINNER, theme_css,
1003
- "pre { color: #fff; }" if syntax_style in self._syntax_dark else "pre { color: #000; }",
1004
- 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
+ ]
1005
1409
  return "\n".join(parts)
1006
1410
 
1007
1411
  def prepare_action_icons(self, ctx: CtxItem) -> str:
@@ -1093,7 +1497,7 @@ class Body:
1093
1497
  <video class="video-player" controls>
1094
1498
  <source src="{path}" type="video/{ext[1:]}">
1095
1499
  </video>
1096
- <p><a href="{url}" class="title">{elide_filename(basename)}</a></p>
1500
+ <p><a href="bridge://play_video/{url}" class="title">{elide_filename(basename)}</a></p>
1097
1501
  </div>
1098
1502
  '''
1099
1503
  return f'<div class="extra-src-img-box" title="{url}"><div class="img-outer"><div class="img-wrapper"><a href="{url}"><img src="{path}" class="image"></a></div><a href="{url}" class="title">{elide_filename(basename)}</a></div></div><br/>'
@@ -1239,6 +1643,9 @@ class Body:
1239
1643
  def get_html(self, pid: int) -> str:
1240
1644
  """
1241
1645
  Build webview HTML code (fast path, minimal allocations)
1646
+
1647
+ :param pid: process ID
1648
+ :return: HTML code
1242
1649
  """
1243
1650
  cfg_get = self.window.core.config.get
1244
1651
  style = cfg_get("theme.style", "blocks")
@@ -1253,11 +1660,18 @@ class Body:
1253
1660
  styles_css = self.prepare_styles()
1254
1661
  tips_json = self.get_all_tips()
1255
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
+
1256
1669
  return ''.join((
1257
1670
  self._HTML_P0,
1258
1671
  styles_css,
1259
1672
  self._HTML_P1,
1260
1673
  str(pid),
1674
+ icons_js,
1261
1675
  self._HTML_P2,
1262
1676
  tips_json,
1263
1677
  self._HTML_P3,