-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathftwindow_mouse.cpp
More file actions
3210 lines (2983 loc) · 143 KB
/
Copy pathftwindow_mouse.cpp
File metadata and controls
3210 lines (2983 loc) · 143 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#include "ftwindow_common.h"
#include "helptheme.h"
#include <QHBoxLayout>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QRegularExpression>
#include <QTextBrowser>
#include <QTextDocument>
// QImage::mirrored() was deprecated in Qt 6.9 in favour of flipped(). The
// native desktop kit is newer, but the WebAssembly kit is still on Qt 6.8
// (which has no flipped()), so wrap both behind a version check.
static QImage flipImage(const QImage &img, Qt::Orientations dir)
{
#if QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)
return img.flipped(dir);
#else
return img.mirrored(dir.testFlag(Qt::Horizontal), dir.testFlag(Qt::Vertical));
#endif
}
// ---------------------------------------------------------------------------
// Grouped tool buttons
// ---------------------------------------------------------------------------
// The many individual tool squares along each panel edge are organised into a
// small number of group squares. A single-member group activates its tool
// directly; a multi-member group opens a floating popup grid of its members.
// Tool ids are the original per-function indices (see the icon-drawing loops
// in ftwindow_paint.cpp); the group lists below map ids into groups.
void FtWindow::buildToolGroups()
{
// The trailing `true` marks a group as advanced-only: it is dropped below
// when the user level is Basic, so it takes no slot in the column and none
// of its tools can be reached.
m_p1Groups = {
{ "Edit", {0, 1, 8, 25}, {} }, // eraser, paint brush, taper edges, threshold
{ "Measure", {2}, {} },
{ "Transform", {3, 4, 5, 6, 22, 7, 9}, {} }, // flip H/V, shift, rotate, shear, invert, symmetrize
{ "Redimension", {10, 19, 11}, {} }, // bin, pad, crop
{ "Filter", {12, 13, 23}, {}, true }, // gabor, hessian, hough
{ "Amyloid", {14}, {}, true },
{ "Math", {15, 21}, {} }, // math calculations, average images
{ "Particles", {16, 17, 24}, {}, true }, // peak, extract, average tiles
// Single-member group: the group face is what the user hovers, so the
// group name is the tooltip that shows (it overrides the icon's own).
{ "Align image to reference", {18}, {}, true },
};
m_p2Groups = {
{ "Edit", {0, 1}, {} }, // eraser, paint brush
{ "Cross-section profile", {7}, {} },
{ "Filter", {2, 3, 4, 5}, {} }, // bandpass, directional, line, lattice
{ "Transform", {6, 8}, {} }, // rotate, symmetrize
{ "Redimension", {9}, {} }, // Fourier crop / pad
{ "Ramp", {10}, {} }, // phase ramp
{ "CTF", {11, 12}, "CTF", true }, // CTF SIM, CTF FIT (face = "CTF")
{ "Math", {13}, {} },
};
if (!m_advancedLevel) {
auto dropAdvanced = [](QVector<ToolGroup> &groups) {
groups.erase(std::remove_if(groups.begin(), groups.end(),
[](const ToolGroup &g) { return g.advanced; }),
groups.end());
};
dropAdvanced(m_p1Groups);
dropAdvanced(m_p2Groups);
}
}
// Compute, for every tool id, the on-screen rect of the slot it occupies and
// whether it is currently visible. Runs each paint; the mouse handler reads the
// results (paint always precedes any click).
void FtWindow::layoutToolSlots()
{
int hy = height() - height() / 5;
int gap = 2;
// Half again the size the squares used to be — the icons in them are drawn
// relative to the square, so they grow with it.
int btnSide = std::max(width() * 5 / 400, 20) * 3 / 2;
// …but never taller than the column of groups has room for. At this size a
// short window would otherwise push the squares off the top of the panel,
// taking the close button — half a square above them again — with them. The
// longer of the two panels' group lists sets the limit, since both columns
// share this one size, and the +3 leaves the close button its space.
const int nGroupsMax = std::max(m_p1Groups.size(), m_p2Groups.size());
if (nGroupsMax > 0) {
int fits = (hy - (nGroupsMax - 1) * gap) / (nGroupsMax + 3);
btnSide = std::max(8, std::min(btnSide, fits));
}
int offset = btnSide / 2;
m_toolBtnSide = btnSide;
m_toolBtnGap = gap;
m_toolBtnOffset = offset;
// The group squares themselves, plus the close button. Split out from the
// member placement below because the hover preview has to know where the
// squares are before the members can be placed: which group is previewed
// depends on which square the mouse is over.
auto layoutGroupFaces = [&](int panel) {
const QVector<ToolGroup> &groups = (panel == 1) ? m_p1Groups : m_p2Groups;
QRect *groupRects = (panel == 1) ? m_p1GroupRects : m_p2GroupRects;
int nG = groups.size();
int totalH = nG * btnSide + (nG - 1) * gap;
int startY = (hy - totalH) / 2;
int gx = (panel == 1) ? offset : (width() - btnSide - offset);
for (int g = 0; g < nG; g++)
groupRects[g] = QRect(gx, startY + g * (btnSide + gap), btnSide, btnSide);
// Close button: same size as a function button, sitting half a square
// above the column. Only offered while this panel has a function open.
bool funcOpen = (panel == 1) ? p1FunctionOpen() : p2FunctionOpen();
QRect &closeRect = (panel == 1) ? m_p1CloseRect : m_p2CloseRect;
closeRect = funcOpen ? QRect(gx, startY - btnSide - btnSide / 2, btnSide, btnSide)
: QRect();
};
layoutGroupFaces(1);
layoutGroupFaces(2);
// Reads the popup rect from the previous layout to tell whether the pointer
// is resting on the previewed row, so the rects are only cleared afterwards.
updateGroupHoverPreview();
m_p1PopupRect = QRect();
m_p2PopupRect = QRect();
auto layoutPanel = [&](int panel) {
const QVector<ToolGroup> &groups = (panel == 1) ? m_p1Groups : m_p2Groups;
const QRect *groupRects = (panel == 1) ? m_p1GroupRects : m_p2GroupRects;
QRect *slotRects = (panel == 1) ? m_p1BtnRects : m_toolBtnRects;
bool *slotVis = (panel == 1) ? m_p1SlotVisible : m_p2SlotVisible;
int nTools = (panel == 1) ? P1_TOOL_BUTTONS : P2_TOOL_BUTTONS;
for (int i = 0; i < nTools; i++) { slotVis[i] = false; slotRects[i] = QRect(); }
for (int g = 0; g < groups.size(); g++) {
const QRect &G = groupRects[g];
const QVector<int> &mem = groups[g].members;
bool open = (m_openMenuPanel == panel && m_openMenuGroup == g);
bool preview = (m_hoverMenuPanel == panel && m_hoverMenuGroup == g);
if ((open || preview) && mem.size() > 1) {
// Members fan out into a single floating vertical column immediately
// beside the group square and level with it, so the first member's
// icon touches the square: reaching it is one short sideways move,
// with no diagonal and no gap to fall out of. The square itself is
// left empty (drawn as a highlighted anchor in paint), and the
// group's mouse-over text is written over that first icon rather
// than between the two — see groupTipRect(). The text therefore
// costs the column no room, which is what lets it sit this high;
// hanging it below the text pushed the icons clear of the square
// on a short window, where they could not be reached at all.
//
// One column wide, and deliberately so: the icons are painted
// before the image displays are, so anything reaching past the
// narrow gutter beside the button column disappears behind panel
// 1 / panel 2. A horizontal row of six would.
int n = mem.size();
int panelW = btnSide;
int panelH = n * btnSide + (n - 1) * gap;
int px = (panel == 1) ? (G.right() + 1) : (G.left() - 1 - panelW);
int py = G.top();
// Keep the column on screen. If it will not fit below the square,
// it grows upwards from the square's foot instead of being pushed
// off it — sliding it up the window would break the very adjacency
// the layout is built around. Only a very short window gets here.
if (py + panelH > height()) py = G.bottom() + 1 - panelH;
if (py < 0) py = 0;
if (px + panelW > width()) px = width() - panelW;
if (px < 0) px = 0;
QRect popup(px, py, panelW, panelH);
if (panel == 1) m_p1PopupRect = popup; else m_p2PopupRect = popup;
for (int k = 0; k < n; k++) {
QRect cell(px, py + k * (btnSide + gap), btnSide, btnSide);
slotRects[mem[k]] = cell;
slotVis[mem[k]] = true;
}
} else if (groups[g].faceText.isEmpty()) {
slotRects[mem[0]] = G; // collapsed: face shows first member
slotVis[mem[0]] = true;
}
// else: collapsed group with a custom text face — no member icon is
// drawn here; the face is rendered in paint (drawGroupExtras).
}
};
layoutPanel(1);
layoutPanel(2);
}
QRect FtWindow::groupTipRect(int panel, int g) const
{
const QVector<ToolGroup> &groups = (panel == 1) ? m_p1Groups : m_p2Groups;
if (g < 0 || g >= groups.size()) return QRect();
const QRect &G = (panel == 1) ? m_p1GroupRects[g] : m_p2GroupRects[g];
if (G.isNull()) return QRect();
QFont ttf; ttf.setPixelSize(11);
QFontMetrics ttfm(ttf);
int ttw = ttfm.horizontalAdvance(groups[g].name) + 8;
int tth = ttfm.height() + 4;
// While this group's members are previewed, the text is written over the
// first member's icon instead of beside the group square. That way it takes
// no room of its own, which is what lets the column sit tight against the
// square: put the text between them and the icons are pushed away from the
// square by its whole height, far enough on a short window that the pointer
// cannot get across. The icon it covers is the one the pointer is on its way
// to; arriving there replaces this text with that tool's own.
const QRect &popup = (panel == 1) ? m_p1PopupRect : m_p2PopupRect;
if (m_hoverMenuPanel == panel && m_hoverMenuGroup == g && !popup.isNull()) {
QRect first(popup.left(), popup.top(), m_toolBtnSide, m_toolBtnSide);
int ttx = (panel == 1) ? first.left() : (first.right() + 1 - ttw);
int tty = first.center().y() - tth / 2;
return QRect(ttx, tty, ttw, tth);
}
int ttx = (panel == 1) ? (G.right() + 4) : (G.left() - ttw - 4);
int tty = G.center().y() - tth / 2;
return QRect(ttx, tty, ttw, tth);
}
// Which group, if any, is showing its contents on hover. Called from
// layoutToolSlots() on every paint, and mouse moves repaint, so the preview
// follows the pointer without any state of its own to keep in step.
void FtWindow::updateGroupHoverPreview()
{
// A clicked-open menu owns the space beside the column while it is up; a
// second popup there would only fight with it.
if (m_openMenuPanel != 0) {
m_hoverMenuPanel = 0;
m_hoverMenuGroup = -1;
return;
}
// A group square under the pointer wins, so that running down the column
// hands the preview from one group to the next.
for (int panel = 1; panel <= 2; panel++) {
const QVector<ToolGroup> &groups = (panel == 1) ? m_p1Groups : m_p2Groups;
const QRect *groupRects = (panel == 1) ? m_p1GroupRects : m_p2GroupRects;
for (int g = 0; g < groups.size(); g++) {
// A one-member group has nothing hidden to reveal: its square
// already shows the only icon it has.
if (groups[g].members.size() < 2) continue;
if (!groupRects[g].contains(m_mousePos)) continue;
m_hoverMenuPanel = panel;
m_hoverMenuGroup = g;
return;
}
}
// Otherwise hold the current preview open while the pointer is on the icons
// themselves: they sit outside the square that summoned them, and without
// this they would vanish from under the pointer on the way over. The column
// is laid out flush against the square, so there is nothing in between to
// fall through.
if (m_hoverMenuPanel != 0) {
const QRect &popup = (m_hoverMenuPanel == 1) ? m_p1PopupRect : m_p2PopupRect;
if (popup.contains(m_mousePos)) return;
}
m_hoverMenuPanel = 0;
m_hoverMenuGroup = -1;
}
// A shear drag works like grabbing the image and pulling that one point
// sideways: the angle returned is the one that puts the grabbed point under the
// cursor. Which axis slides follows the direction dragged in — mostly sideways
// shears the rows, mostly up or down shears the columns.
bool FtWindow::shearFromDrag(const DisplayItem &di, const QPoint &from,
const QPoint &to, double &angleDeg,
bool &vertical) const
{
if (!di.valid || di.screenRect.width() <= 0 || di.screenRect.height() <= 0
|| di.imgW <= 0 || di.imgH <= 0)
return false;
const QRectF src = m_zoom[0].visibleRect(di.imgW, di.imgH);
auto imgX = [&](int sx) {
return src.x() + (sx - di.screenRect.x())
/ (double)di.screenRect.width() * src.width();
};
auto imgY = [&](int sy) {
return src.y() + (sy - di.screenRect.y())
/ (double)di.screenRect.height() * src.height();
};
const double gx = imgX(from.x()), gy = imgY(from.y());
const double dx = imgX(to.x()) - gx, dy = imgY(to.y()) - gy;
if (std::hypot(dx, dy) < 1.0) return false; // a click, not a drag
vertical = (std::abs(dy) > std::abs(dx));
// Distance of the grabbed point from the centre line is the lever the drag
// works on, so grabbing near the edge shears gently and grabbing further in
// shears harder. On the line itself the lever would vanish and any drag at
// all would ask for 90°, so it is held at a minimum: a drag started inside
// that band behaves as though it had grabbed at the band's edge, on the side
// it did start on.
double lever = vertical ? (gx - di.imgW / 2.0) : (gy - di.imgH / 2.0);
const double minLever = 0.15 * (vertical ? di.imgW : di.imgH);
if (std::abs(lever) < minLever) lever = (lever < 0.0) ? -minLever : minLever;
angleDeg = std::atan(-(vertical ? dy : dx) / lever) * 180.0 / M_PI;
angleDeg = std::clamp(angleDeg, -SHEAR_MAX_DEG, SHEAR_MAX_DEG);
return std::abs(angleDeg) >= 0.05;
}
void FtWindow::deactivateAllP1Tools()
{
m_p1EraserActive = false; m_p1BrushActive = false;
m_shiftActive = false; m_rotateActive = false; m_shearActive = false;
m_p1TaperActive = false; m_p1SymmetrizeActive = false; m_threshActive = false;
m_binActive = false; m_mathActive = false; m_padActive = false;
m_copyActive = false; m_averageActive = false;
m_cropActive = false; m_cropDragging = false; m_cropMoving = false; m_cropHasSelection = false;
m_peakPickActive = false; m_extractActive = false; m_tileAvgActive = false;
m_alignActive = false; clearAlignDiagnostics();
m_gaborActive = false; m_hessianActive = false; m_houghActive = false;
m_amyloidActive = false; m_amyloidPlacing = 0;
m_measureActive = false; m_measurePlacing = 0; m_measureHasLine = false;
m_p1FlipHActive = false; m_p1FlipVActive = false; m_p1InvertActive = false;
}
void FtWindow::showP1ToolWidgets()
{
m_p1EraserDiameterEdit->setVisible(m_p1EraserActive);
m_p1BrushValueEdit->setVisible(m_p1BrushActive);
m_p1BrushSolidDiameterEdit->setVisible(m_p1BrushActive);
m_p1BrushDiameterEdit->setVisible(m_p1BrushActive);
m_p1TaperWidthEdit->setVisible(m_p1TaperActive);
m_applyP1TaperBtn->setVisible(m_p1TaperActive);
m_p1SymmetryEdit->setVisible(m_p1SymmetrizeActive);
m_applyP1SymmetryBtn->setVisible(m_p1SymmetrizeActive);
m_threshModeCombo->setVisible(m_threshActive);
m_threshMinEdit->setVisible(m_threshActive);
m_threshMaxEdit->setVisible(m_threshActive);
m_threshCancelBtn->setVisible(m_threshActive);
m_threshComputeBtn->setVisible(m_threshActive);
m_copySrcCombo->setVisible(m_copyActive);
m_copyTgtCombo->setVisible(m_copyActive);
m_copyCancelBtn->setVisible(m_copyActive);
m_copyDuplicateBtn->setVisible(m_copyActive);
m_averageTargetCombo->setVisible(m_averageActive);
m_averageCancelBtn->setVisible(m_averageActive);
m_averageComputeBtn->setVisible(m_averageActive);
m_padSizeCombo->setVisible(m_padActive);
m_padCustomEdit->setVisible(m_padActive);
m_padCancelBtn->setVisible(m_padActive);
m_applyPadBtn->setVisible(m_padActive);
m_binCombo->setVisible(m_binActive);
m_binKeepSizeBtn->setVisible(m_binActive);
m_applyBinBtn->setVisible(m_binActive);
m_cropTLxEdit->setVisible(m_cropActive);
m_cropTLyEdit->setVisible(m_cropActive);
m_cropBRxEdit->setVisible(m_cropActive);
m_cropBRyEdit->setVisible(m_cropActive);
m_cropCancelBtn->setVisible(m_cropActive);
m_applyCropBtn->setVisible(m_cropActive);
m_mathOutCombo->setVisible(m_mathActive);
m_mathEqualsLabel->setVisible(m_mathActive);
m_mathIn1Combo->setVisible(m_mathActive);
m_mathOpCombo->setVisible(m_mathActive);
m_mathIn2Combo->setVisible(m_mathActive);
m_mathCancelBtn->setVisible(m_mathActive);
m_mathComputeBtn->setVisible(m_mathActive);
m_peakSourceCombo->setVisible(m_peakPickActive);
m_peakThresholdSlider->setVisible(m_peakPickActive);
m_peakThresholdLabel->setVisible(m_peakPickActive);
m_peakExclLabel->setVisible(m_peakPickActive);
m_peakExclRadiusSlider->setVisible(m_peakPickActive);
m_peakCancelBtn->setVisible(m_peakPickActive);
m_peakComputeBtn->setVisible(m_peakPickActive);
m_peakShowPosBtn->setVisible(m_peakPickActive);
bool showExtract = m_extractActive && !m_peaks.empty();
m_extractSourceCombo->setVisible(showExtract);
m_extractTargetCombo->setVisible(showExtract);
m_extractSizeCombo->setVisible(showExtract);
m_extractCancelBtn->setVisible(showExtract);
m_extractComputeBtn->setVisible(showExtract);
m_tileAvgSourceCombo->setVisible(m_tileAvgActive);
m_tileAvgTargetCombo->setVisible(m_tileAvgActive);
m_tileAvgSizeCombo->setVisible(m_tileAvgActive);
m_tileAvgCancelBtn->setVisible(m_tileAvgActive);
m_tileAvgComputeBtn->setVisible(m_tileAvgActive);
m_gaborSigmaEdit->setVisible(m_gaborActive);
m_gaborLambdaEdit->setVisible(m_gaborActive);
m_gaborThetaEdit->setVisible(m_gaborActive);
m_gaborGammaEdit->setVisible(m_gaborActive);
m_gaborCancelBtn->setVisible(m_gaborActive);
m_gaborComputeBtn->setVisible(m_gaborActive);
m_hessianSigmaEdit->setVisible(m_hessianActive);
m_hessianPolarityEdit->setVisible(m_hessianActive);
m_hessianCancelBtn->setVisible(m_hessianActive);
m_hessianComputeBtn->setVisible(m_hessianActive);
m_houghSourceCombo->setVisible(m_houghActive);
m_houghTargetCombo->setVisible(m_houghActive);
m_houghElementCombo->setVisible(m_houghActive);
m_houghRadiusSlider->setVisible(houghShowsRadiusSlider());
m_houghInverseBtn->setVisible(m_houghActive);
m_houghCancelBtn->setVisible(m_houghActive);
m_houghComputeBtn->setVisible(m_houghActive);
m_amyloidRiseEdit->setVisible(m_amyloidActive);
m_amyloidTwistEdit->setVisible(m_amyloidActive);
m_amyloidMapCombo->setVisible(m_amyloidActive);
m_amyloidSizeCombo->setVisible(m_amyloidActive);
m_amyloidNoiseBtn->setVisible(m_amyloidActive);
m_amyloidNoiseEdit->setVisible(m_amyloidActive);
m_amyloidPersistEdit->setVisible(m_amyloidActive);
m_amyloidWaveEdit->setVisible(m_amyloidActive);
m_amyloidAmplEdit->setVisible(m_amyloidActive);
m_amyloidSignalBtn->setVisible(m_amyloidActive);
m_amyloidCancelBtn->setVisible(m_amyloidActive);
m_amyloidComputeBtn->setVisible(m_amyloidActive);
m_measureCancelBtn->setVisible(m_measureActive);
m_shiftCancelBtn->setVisible(m_shiftActive);
m_rotateCancelBtn->setVisible(m_rotateActive);
m_shearAngleEdit->setVisible(m_shearActive);
m_shearAxisCombo->setVisible(m_shearActive);
m_shearCancelBtn->setVisible(m_shearActive);
m_applyShearBtn->setVisible(m_shearActive);
m_alignSrcCombo->setVisible(m_alignActive);
m_alignRefCombo->setVisible(m_alignActive);
m_alignOutCombo->setVisible(m_alignActive);
m_alignCancelBtn->setVisible(m_alignActive);
m_alignShiftBtn->setVisible(m_alignActive);
m_alignRotBtn->setVisible(m_alignActive);
m_alignFullBtn->setVisible(m_alignActive);
m_alignTilesBtn->setVisible(m_alignActive);
m_alignTileSizeCombo->setVisible(m_alignActive);
}
void FtWindow::closeP1Function()
{
deactivateAllP1Tools();
m_p1Dragging = false;
m_toolDragging = false;
showP1ToolWidgets();
update();
}
void FtWindow::closeP2Function()
{
deactivateAllP2Tools();
m_p2Dragging = false;
m_toolDragging = false;
// Mirrors the cross-section tool's own deactivation cleanup.
m_crossSectionProfile.clear();
m_crossSectionPhaseProfile.clear();
showP2ToolWidgets();
update();
}
void FtWindow::onFtRotateCancel()
{
m_ftRotateActive = false;
m_p2Dragging = false;
m_ftRotateCancelBtn->hide();
update();
}
void FtWindow::activateP1Tool(int toolId)
{
switch (toolId) {
case 0: { bool was = m_p1EraserActive; deactivateAllP1Tools(); m_p1EraserActive = !was; break; }
case 1: {
bool was = m_p1BrushActive; deactivateAllP1Tools(); m_p1BrushActive = !was;
if (m_p1BrushActive && !m_image.isNull()) {
double defVal = m_imageMaxVal > 0 ? m_imageMaxVal : 1.0;
m_p1BrushValueEdit->setText(QString::number(defVal, 'g', 5));
}
break;
}
case 2: {
bool was = m_measureActive; deactivateAllP1Tools(); m_measureActive = !was;
if (!m_measureActive) { m_measurePlacing = 0; m_measureHasLine = false; }
break;
}
// Flip / invert act on every click (so two clicks still undo each other);
// the flag only toggles the parameter window carrying the help button.
case 3: {
if (m_image.isNull()) return;
bool was = m_p1FlipHActive;
deactivateAllP1Tools(); storeUndoSnapshot(tr("Flipped horizontally"));
m_image = flipImage(m_image, Qt::Horizontal);
extractImageData(); if (m_ftComputed) computeFFT();
m_p1FlipHActive = !was;
break;
}
case 4: {
if (m_image.isNull()) return;
bool was = m_p1FlipVActive;
deactivateAllP1Tools(); storeUndoSnapshot(tr("Flipped vertically"));
m_image = flipImage(m_image, Qt::Vertical);
extractImageData(); if (m_ftComputed) computeFFT();
m_p1FlipVActive = !was;
break;
}
case 5: { bool was = m_shiftActive; deactivateAllP1Tools(); m_shiftActive = !was; break; }
case 6: { bool was = m_rotateActive; deactivateAllP1Tools(); m_rotateActive = !was; break; }
case 22: { bool was = m_shearActive; deactivateAllP1Tools(); m_shearActive = !was; break; }
case 7: {
if (m_image.isNull()) return;
bool was = m_p1InvertActive;
deactivateAllP1Tools(); showP1ToolWidgets(); onInvertContrast();
m_p1InvertActive = !was;
update();
return;
}
case 8: { bool was = m_p1TaperActive; deactivateAllP1Tools(); m_p1TaperActive = !was; break; }
case 25: {
bool was = m_threshActive; deactivateAllP1Tools(); m_threshActive = !was;
// Open on whatever the histogram under panel 1 currently has marked.
if (m_threshActive) syncThresholdEdits();
break;
}
case 9: { bool was = m_p1SymmetrizeActive; deactivateAllP1Tools(); m_p1SymmetrizeActive = !was; break; }
case 10: { bool was = m_binActive; deactivateAllP1Tools(); m_binActive = !was; break; }
case 20: {
bool was = m_copyActive; deactivateAllP1Tools(); m_copyActive = !was;
if (m_copyActive) syncCopyCombos();
break;
}
case 21: {
bool was = m_averageActive; deactivateAllP1Tools(); m_averageActive = !was;
if (m_averageActive) {
m_averageResult.clear();
// Seed the include set with every buffer that holds an image, and
// aim the output at the active buffer.
for (int i = 0; i < HISTORY_SLOTS; i++)
m_averageInclude[i] = bufferInUse(i);
if (m_activeSlot >= 0 && m_activeSlot < HISTORY_SLOTS)
m_averageTargetCombo->setCurrentIndex(m_activeSlot);
}
break;
}
case 19: {
bool was = m_padActive; deactivateAllP1Tools(); m_padActive = !was;
if (m_padActive) syncPadSizeCombo();
break;
}
case 11: {
bool was = m_cropActive; deactivateAllP1Tools(); m_cropActive = !was;
if (m_cropActive) { m_cropRect = QRect(); m_cropHasSelection = false; syncCropEdits(); }
break;
}
case 12: { bool was = m_gaborActive; deactivateAllP1Tools(); m_gaborActive = !was; break; }
case 13: { bool was = m_hessianActive; deactivateAllP1Tools(); m_hessianActive = !was; break; }
case 23: {
bool was = m_houghActive; deactivateAllP1Tools(); m_houghActive = !was;
// Both open on the buffer being looked at, so the transform replaces
// its own input unless the user points the output somewhere else.
if (m_houghActive) {
m_houghAutoRadius = 0; // nothing found yet in this session of the tool
if (m_activeSlot >= 0) {
m_houghSourceCombo->setCurrentIndex(m_activeSlot);
m_houghTargetCombo->setCurrentIndex(m_activeSlot);
}
}
break;
}
case 14: {
bool was = m_amyloidActive; deactivateAllP1Tools(); m_amyloidActive = !was;
if (!m_amyloidActive) { m_amyloidPlacing = 0; }
else if (m_activeSlot < 0 || m_image.isNull()) {
int sz = m_amyloidSizeCombo->currentText().toInt();
if (sz <= 0) sz = 1024;
onCreateImageSized(sz);
}
break;
}
case 15: { bool was = m_mathActive; deactivateAllP1Tools(); m_mathActive = !was; break; }
case 16: {
bool was = m_peakPickActive; deactivateAllP1Tools(); m_peakPickActive = !was;
if (m_peakPickActive) {
// Seeding the slider is not the user moving it, so it must not set
// the automatic search going — opening the tool should not compute.
{ QSignalBlocker b(m_peakThresholdSlider);
m_peakThresholdSlider->setValue(750); }
if (m_activeSlot >= 0) m_peakSourceCombo->setCurrentIndex(m_activeSlot);
}
break;
}
case 17: {
bool was = m_extractActive; deactivateAllP1Tools(); m_extractActive = !was;
if (m_extractActive && m_activeSlot >= 0)
m_extractSourceCombo->setCurrentIndex(m_activeSlot);
break;
}
case 24: {
bool was = m_tileAvgActive; deactivateAllP1Tools(); m_tileAvgActive = !was;
if (m_tileAvgActive) {
if (m_activeSlot >= 0)
m_tileAvgSourceCombo->setCurrentIndex(m_activeSlot);
// The tile size follows the box size Extract particles last used —
// both pulldowns offer the same two sizes, and the two functions are
// normally run on the same features.
m_tileAvgSizeCombo->setCurrentIndex(m_extractSizeCombo->currentIndex());
}
break;
}
case 18: {
bool was = m_alignActive; deactivateAllP1Tools(); m_alignActive = !was;
if (m_alignActive) {
m_alignResult.clear();
// Same tile size the other two tile functions default to.
m_alignTileSizeCombo->setCurrentIndex(m_extractSizeCombo->currentIndex());
int src = (m_activeSlot >= 0) ? m_activeSlot : 0;
alignSeedSourceAndOutput(src);
// The reference is sticky: restore whichever buffer was last used as
// a reference and never retarget it automatically. On first use fall
// back to another occupied buffer, else buffer a.
if (m_alignRefSlot < 0 || m_alignRefSlot >= HISTORY_SLOTS) {
int alt = -1;
for (int i = 0; i < HISTORY_SLOTS; i++)
if (i != src && m_history[i].occupied) { alt = i; break; }
m_alignRefSlot = (alt >= 0) ? alt : 0;
}
{ // Setting it manually here should not itself be treated as a
// fresh user pick, so block the change handler.
QSignalBlocker b(m_alignRefCombo);
m_alignRefCombo->setCurrentIndex(m_alignRefSlot);
}
syncAlignCombos();
}
break;
}
default: return;
}
showP1ToolWidgets();
update();
}
void FtWindow::deactivateAllP2Tools()
{
m_eraserActive = false; m_brushActive = false;
m_bandpassActive = false; m_directionalActive = false;
m_lineFilterActive = false;
m_latticeActive = false; m_ftRotateActive = false;
m_crossSectionActive = false;
m_p2SymmetrizeActive = false;
m_ftCropActive = false; m_ftMathActive = false;
m_ctfActive = false;
m_ctfFitActive = false;
m_phaseRampActive = false;
}
void FtWindow::showP2ToolWidgets()
{
bool showFilter = m_bandpassActive || m_directionalActive;
m_smoothEdit->setVisible(showFilter);
m_bandEraseOutside->setVisible(showFilter);
m_applyBandBtn->setVisible(showFilter);
m_resetBandBtn->setVisible(m_bandpassActive);
m_brushValueEdit->setVisible(m_brushActive);
m_brushDiameterEdit->setVisible(m_brushActive);
m_eraserDiameterEdit->setVisible(m_eraserActive);
m_lineWidthEdit->setVisible(m_lineFilterActive);
m_lineDirectionEdit->setVisible(m_lineFilterActive);
m_lineOffsetEdit->setVisible(m_lineFilterActive);
m_lineEraseOutsideBtn->setVisible(m_lineFilterActive);
m_applyLineBtn->setVisible(m_lineFilterActive);
m_latticeSmoothEdit->setVisible(m_latticeActive);
m_latticeDotDiamEdit->setVisible(m_latticeActive);
m_latticeUxEdit->setVisible(m_latticeActive);
m_latticeUyEdit->setVisible(m_latticeActive);
m_latticeVxEdit->setVisible(m_latticeActive);
m_latticeVyEdit->setVisible(m_latticeActive);
if (m_latticeActive) syncLatticeVectorEdits();
m_latticeEraseOutside->setVisible(m_latticeActive);
m_latticeApplyBtn->setVisible(m_latticeActive);
m_ftRotateCancelBtn->setVisible(m_ftRotateActive);
m_crossSectionDirEdit->setVisible(m_crossSectionActive);
m_crossSectionWidthEdit->setVisible(m_crossSectionActive);
m_p2SymmetryEdit->setVisible(m_p2SymmetrizeActive);
m_applyP2SymmetryBtn->setVisible(m_p2SymmetrizeActive);
m_ftCropCombo->setVisible(m_ftCropActive);
m_ftCropKeepSizeBtn->setVisible(m_ftCropActive);
m_applyFtCropBtn->setVisible(m_ftCropActive);
m_applyFtPadBtn->setVisible(m_ftCropActive);
m_ftMathOutCombo->setVisible(m_ftMathActive);
m_ftMathEqualsLabel->setVisible(m_ftMathActive);
m_ftMathIn1Combo->setVisible(m_ftMathActive);
m_ftMathOpCombo->setVisible(m_ftMathActive);
m_ftMathIn2Combo->setVisible(m_ftMathActive);
m_ftMathConjCombo->setVisible(m_ftMathActive);
m_ftMathCancelBtn->setVisible(m_ftMathActive);
m_ftMathComputeBtn->setVisible(m_ftMathActive);
m_ctfVoltageEdit->setVisible(m_ctfActive);
m_ctfEnergySpreadEdit->setVisible(m_ctfActive);
m_ctfDefocusSpreadEdit->setVisible(m_ctfActive);
m_ctfOpenAngleEdit->setVisible(m_ctfActive);
m_ctfCsEdit->setVisible(m_ctfActive);
m_ctfDefocusEdit->setVisible(m_ctfActive);
m_ctfAstigEdit->setVisible(m_ctfActive);
m_ctfAstigAngleEdit->setVisible(m_ctfActive);
m_ctfAmpContrastEdit->setVisible(m_ctfActive);
m_ctfBeamtiltEdit->setVisible(m_ctfActive);
m_ctfBeamtiltDirEdit->setVisible(m_ctfActive);
m_ctfCancelBtn->setVisible(m_ctfActive);
m_ctfPupilBtn->setVisible(m_ctfActive);
m_ctfComplexBtn->setVisible(m_ctfActive);
m_ctfRealBtn->setVisible(m_ctfActive);
m_ctfFitVoltageEdit->setVisible(m_ctfFitActive);
m_ctfFitCsEdit->setVisible(m_ctfFitActive);
m_ctfFitInputCombo->setVisible(m_ctfFitActive);
m_ctfFitResHiEdit->setVisible(m_ctfFitActive);
m_ctfFitResLoEdit->setVisible(m_ctfFitActive);
m_ctfFitCancelBtn->setVisible(m_ctfFitActive);
m_ctfFitExecuteBtn->setVisible(m_ctfFitActive);
m_phaseRampSizeCombo->setVisible(m_phaseRampActive);
m_phaseRampDirEdit->setVisible(m_phaseRampActive);
m_phaseRampStepEdit->setVisible(m_phaseRampActive);
m_phaseRampCancelBtn->setVisible(m_phaseRampActive);
m_phaseRampComputeBtn->setVisible(m_phaseRampActive);
}
void FtWindow::activateP2Tool(int toolId)
{
switch (toolId) {
case 0: { bool was = m_eraserActive; deactivateAllP2Tools(); m_eraserActive = !was; break; }
case 1: {
bool was = m_brushActive; deactivateAllP2Tools(); m_brushActive = !was;
if (m_brushActive && m_ftComputed) {
double bv = brushValue();
m_brushValueEdit->setText(bv > 0 ? QString::number(bv, 'g', 5) : "1");
}
break;
}
case 2: { bool was = m_bandpassActive; deactivateAllP2Tools(); m_bandpassActive = !was; break; }
case 3: { bool was = m_directionalActive; deactivateAllP2Tools(); m_directionalActive = !was; break; }
case 4: { bool was = m_lineFilterActive; deactivateAllP2Tools(); m_lineFilterActive = !was; break; }
case 5: { bool was = m_latticeActive; deactivateAllP2Tools(); m_latticeActive = !was; break; }
case 6: { bool was = m_ftRotateActive; deactivateAllP2Tools(); m_ftRotateActive = !was; break; }
case 7: {
bool was = m_crossSectionActive; deactivateAllP2Tools(); m_crossSectionActive = !was;
if (m_crossSectionActive && m_ftComputed) {
syncCrossSectionDirEdit();
computeCrossSectionProfile();
} else {
m_crossSectionProfile.clear();
m_crossSectionPhaseProfile.clear();
}
break;
}
case 8: { bool was = m_p2SymmetrizeActive; deactivateAllP2Tools(); m_p2SymmetrizeActive = !was; break; }
case 9: { bool was = m_ftCropActive; deactivateAllP2Tools(); m_ftCropActive = !was; break; }
case 10: { bool was = m_phaseRampActive; deactivateAllP2Tools(); m_phaseRampActive = !was; break; }
case 11: {
bool was = m_ctfActive; deactivateAllP2Tools(); m_ctfActive = !was;
m_ctfProfile.clear();
m_ctfPhaseProfile.clear();
break;
}
case 12: {
bool was = m_ctfFitActive; deactivateAllP2Tools(); m_ctfFitActive = !was;
if (m_ctfFitActive) {
m_ctfFitHasResult = false;
if (m_activeSlot >= 0 && m_ctfFitInputCombo)
m_ctfFitInputCombo->setCurrentIndex(m_activeSlot);
// Seed the fit band from the image's own Nyquist resolution, so the
// defaults follow the pixel size instead of being fixed at 3/30 Å.
updateCtfFitResolutionDefaults();
}
break;
}
case 13: { bool was = m_ftMathActive; deactivateAllP2Tools(); m_ftMathActive = !was; break; }
default: return;
}
showP2ToolWidgets();
update();
}
// ---------------------------------------------------------------------------
// Mouse
// ---------------------------------------------------------------------------
// The manual is hosted beside the app rather than inside it — /ft-manual/ next
// to /ft/ — so its pages can be corrected and re-indexed without re-deploying
// the WASM build. Every link out of the app goes through this one base.
static const QString kManualBase = QStringLiteral("https://lbem-status.epfl.ch/ft-manual/");
void FtWindow::openManualAnchor(bool panel2, const QString &anchor)
{
const QString page = panel2 ? "manual_panel2.html" : "manual_panel1.html";
QDesktopServices::openUrl(QUrl(kManualBase + page + "#" + anchor));
}
void FtWindow::openExercise(const QString &anchor)
{
if (anchor.isEmpty()) return;
QDesktopServices::openUrl(QUrl(kManualBase + "manual_exercises.html#" + anchor));
}
void FtWindow::openToolHelp(bool panel2)
{
QString title, anchor;
if (!toolHelpInfo(panel2, title, anchor)) return;
// The Copy tool is launched from the central button strip, not the panel-1
// tool row, and is documented in the main manual's GUI-layout section rather
// than on the panel-1 tool page.
if (!panel2 && m_copyActive) {
QDesktopServices::openUrl(QUrl(kManualBase + "manual.html#gui"));
return;
}
openManualAnchor(panel2, anchor);
}
// ---------------------------------------------------------------------------
// "Find in manual" snippet search
// ---------------------------------------------------------------------------
// The Help dialog's "Find in manual" searches every manual page, not just
// manual.html, and lists each occurrence as a clickable snippet. The pages are
// downloaded rather than bundled so the search always sees the manual as
// currently published (see kManualBase above).
namespace {
struct ManualPage { const char *file; const char *title; };
struct ManualHit {
QString url; // page URL + #:~:text= directive targeting this occurrence
QString snippet; // rich-text context line, occurrence in bold
};
} // namespace
static const ManualPage kManualPages[] = {
{ "manual.html", "Main manual" },
{ "manual_panel1.html", "Panel 1 — real-space tools" },
{ "manual_panel2.html", "Panel 2 — Fourier tools" },
{ "manual_exercises.html", "Exercises" },
};
// Downloaded page HTML, kept for the whole session: the pages total ~300 kB
// (the figures live in separate files the search never fetches) and repeated
// searches shouldn't re-fetch them every time. A failed download is not
// cached, so the next search retries it.
static QHash<QString, QString> s_manualPageCache;
// Reduce a page to the plain-text blocks the browser renders, one string per
// block. A text fragment cannot match across a block boundary, so snippets and
// their prefix/suffix context must be built within a single block.
static QStringList manualTextBlocks(QString html)
{
// Content the browser doesn't render as text goes first, or styles,
// scripts and embedded image data would turn up as search hits.
html.remove(QRegularExpression(QStringLiteral("(?is)<head\\b.*?</head>")));
html.remove(QRegularExpression(QStringLiteral("(?is)<script\\b.*?</script>")));
html.remove(QRegularExpression(QStringLiteral("(?is)<style\\b.*?</style>")));
html.remove(QRegularExpression(QStringLiteral("(?is)<svg\\b.*?</svg>")));
html.remove(QRegularExpression(QStringLiteral("(?is)<img\\b[^>]*>")));
// QTextDocument supplies the full entity table and block segmentation:
// toPlainText() yields one line per block and folds to a plain
// space, which matches how the browsers' fragment matchers compare text.
QTextDocument doc;
doc.setHtml(html);
QStringList blocks;
const QStringList lines = doc.toPlainText().split(QLatin1Char('\n'));
for (const QString &line : lines) {
QString t = line;
t.replace(QChar(0xFFFC), QLatin1Char(' ')); // object-replacement chars
t = t.simplified();
if (!t.isEmpty()) blocks << t;
}
return blocks;
}
// Percent-encode one part of a text-fragment directive. '-' separates the
// prefix/suffix from the match text, so it must not survive as a literal;
// the other delimiters (',' and '&') are outside the default unreserved set
// and get encoded anyway.
static QString fragmentEncode(const QString &s)
{
return QString::fromLatin1(QUrl::toPercentEncoding(s, QByteArray(), "-"));
}
// Every occurrence of `query` in one manual page. The URL pins down WHICH
// occurrence via context words (#:~:text=prefix-,match,-suffix), so the
// browser highlights the chosen one instead of the page's first.
static QVector<ManualHit> findManualMatches(const QString &pageFile,
const QString &pageHtml,
const QString &query)
{
// Context sizes in words: enough around the match in the URL to identify
// the occurrence uniquely, a bit more in the visible snippet so the hits
// can be told apart at a glance.
const int kUrlContext = 4, kSnippetContext = 8;
QVector<ManualHit> hits;
QSet<QString> seen;
const QStringList blocks = manualTextBlocks(pageHtml);
for (qsizetype b = 0; b < blocks.size(); ++b) {
const QString &block = blocks.at(b);
int idx, from = 0;
while ((idx = int(block.indexOf(query, from, Qt::CaseInsensitive))) >= 0) {
from = idx + int(query.size());
// Fragment matches must start and end on word boundaries, so a
// query that hits mid-word is widened to whole words ("math" →
// "mathematics").
int start = idx, end = idx + int(query.size());
while (start > 0 && block.at(start - 1) != QLatin1Char(' ')) --start;
while (end < block.size() && block.at(end) != QLatin1Char(' ')) ++end;
const QString match = block.mid(start, end - start);
const QStringList before =
block.left(start).split(QLatin1Char(' '), Qt::SkipEmptyParts);
const QStringList after =
block.mid(end).split(QLatin1Char(' '), Qt::SkipEmptyParts);
QStringList pre = before.mid(qMax<qsizetype>(0, before.size() - kUrlContext));
QStringList suf = after.mid(0, kUrlContext);
// A match filling its whole block (e.g. a bare "Math" heading) has
// no same-block context, and "#:~:text=Math" alone would highlight
// the page's FIRST "Math" instead. The fragment matcher's text walk
// crosses block boundaries as whitespace, so borrow the context
// words from the neighbouring blocks.
if (pre.isEmpty() && b > 0) {
const QStringList prev =
blocks.at(b - 1).split(QLatin1Char(' '), Qt::SkipEmptyParts);
pre = prev.mid(qMax<qsizetype>(0, prev.size() - kUrlContext));
}
if (suf.isEmpty() && b + 1 < blocks.size()) {
const QStringList next =
blocks.at(b + 1).split(QLatin1Char(' '), Qt::SkipEmptyParts);
suf = next.mid(0, kUrlContext);
}
QString url = kManualBase + pageFile + QStringLiteral("#:~:text=");
if (!pre.isEmpty())
url += fragmentEncode(pre.join(QLatin1Char(' '))) + QStringLiteral("-,");
url += fragmentEncode(match);
if (!suf.isEmpty())
url += QStringLiteral(",-") + fragmentEncode(suf.join(QLatin1Char(' ')));
// Same text in the same context: the browser could not tell the
// occurrences apart either, so one entry stands for all of them.
if (seen.contains(url)) continue;
seen.insert(url);
const QStringList dpre = before.mid(qMax<qsizetype>(0, before.size() - kSnippetContext));
const QStringList dsuf = after.mid(0, kSnippetContext);
QString snip;
if (before.size() > dpre.size()) snip += QStringLiteral("… ");
if (!dpre.isEmpty()) snip += dpre.join(QLatin1Char(' ')).toHtmlEscaped() + QLatin1Char(' ');
snip += QStringLiteral("<b>") + match.toHtmlEscaped() + QStringLiteral("</b>");
if (!dsuf.isEmpty()) snip += QLatin1Char(' ') + dsuf.join(QLatin1Char(' ')).toHtmlEscaped();
if (after.size() > dsuf.size()) snip += QStringLiteral(" …");
hits.append({url, snip});
}
}
return hits;
}
// Render the finished search into the Help dialog's results pane. Every page
// that downloaded is searched; ones that didn't are reported, so a network
// failure can't silently pose as "no matches".
static void showManualSearchResults(QTextBrowser *browser, const QString &query,
const QStringList &fetchErrors,
const HelpTheme &t)
{
const int kMaxPerPage = 25;
QString out;
qsizetype total = 0;
for (const ManualPage &page : kManualPages) {
const auto it = s_manualPageCache.constFind(QLatin1String(page.file));
if (it == s_manualPageCache.constEnd()) continue;
const QVector<ManualHit> hits =
findManualMatches(QLatin1String(page.file), it.value(), query);
if (hits.isEmpty()) continue;
total += hits.size();
out += QStringLiteral("<h4 style=\"margin:10px 0 2px 0; color:%1;\">").arg(t.fg)
+ QString::fromUtf8(page.title).toHtmlEscaped()
+ QStringLiteral(" <span style=\"color:%1;\">— %2 match%3</span></h4>")
.arg(t.dim).arg(hits.size()).arg(hits.size() == 1 ? "" : "es");
const int shown = int(qMin<qsizetype>(hits.size(), kMaxPerPage));
// Half a line of air below each entry, so multi-line snippets read as
// one finding each instead of running together into a wall of text.
for (int k = 0; k < shown; ++k)
out += QStringLiteral("<p style=\"margin:0 0 8px 14px;\"><a href=\"")
+ hits[k].url + QStringLiteral("\">") + hits[k].snippet
+ QStringLiteral("</a></p>");
if (hits.size() > shown)
out += QStringLiteral("<p style=\"margin:0 0 8px 14px; color:%1;\">"
"… %2 further matches not listed — try a more "
"specific phrase</p>").arg(t.dim).arg(hits.size() - shown);
}
QString head;
if (total == 0)
head = QStringLiteral("<p style=\"color:%1;\">No matches for “%2” in the "
"manual. Try a shorter keyword, or the Search Google "
"button.</p>").arg(t.fg, query.toHtmlEscaped());
else
head = QStringLiteral("<p style=\"color:%1;\">%2 match%3 for “%4” — click "
"one to open it in the browser:</p>")
.arg(t.muted).arg(total).arg(total == 1 ? "" : "es").arg(query.toHtmlEscaped());
for (const QString &err : fetchErrors)