1 /* ============================================================
2 *
3 * This file is a part of digiKam project
4 * https://www.digikam.org
5 *
6 * Date : 2021-03-20
7 * Description : a tool to export images to iNaturalist web service
8 *
9 * Copyright (C) 2021 by Joerg Lohse <joergmlpts at gmail dot com>
10 *
11 * This program is free software; you can redistribute it
12 * and/or modify it under the terms of the GNU General
13 * Public License as published by the Free Software Foundation;
14 * either version 2, or (at your option) any later version.
15 *
16 * This program is distributed in the hope that it will be useful,
17 * but WITHOUT ANY WARRANTY; without even the implied warranty of
18 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 * GNU General Public License for more details.
20 *
21 * ============================================================ */
22
23 #include "inatsuggest.h"
24
25 // Qt includes
26
27 #include <QLabel>
28 #include <QHeaderView>
29
30 // KDE includes
31
32 #include <klocalizedstring.h>
33
34 // Local includes
35
36 #include "inattalker.h"
37 #include "inatutils.h"
38
39 namespace DigikamGenericINatPlugin
40 {
41
42 enum
43 {
44 ITEM_PHOTO_IDX = 0,
45 ITEM_NAME_IDX = 1
46 };
47
48 struct TaxonAndFlags
49 {
TaxonAndFlagsDigikamGenericINatPlugin::TaxonAndFlags50 explicit TaxonAndFlags(const Taxon& taxon,
51 bool visuallySimilar = false,
52 bool seenNearby = false)
53 : m_taxon (taxon),
54 m_seenNearby (seenNearby),
55 m_visuallySimilar(visuallySimilar)
56 {
57 }
58
59 Taxon m_taxon;
60 bool m_seenNearby;
61 bool m_visuallySimilar;
62 };
63
64 struct Completions
65 {
CompletionsDigikamGenericINatPlugin::Completions66 explicit Completions(bool fromVision)
67 : m_fromVision(fromVision)
68 {
69 }
70
71 Taxon m_commonAncestor;
72 QList<TaxonAndFlags> m_taxa;
73 bool m_fromVision;
74 };
75
76 // ----------------------------------------------------------------------------
77
78 class Q_DECL_HIDDEN SuggestTaxonCompletion::Private
79 {
80 public:
81
Private()82 Private()
83 : editor (nullptr),
84 talker (nullptr),
85 popup (nullptr),
86 fromVision(false)
87 {
88 }
89
90 TaxonEdit* editor;
91 INatTalker* talker;
92 QTreeWidget* popup;
93 bool fromVision;
94 QVector<Taxon> completionTaxa;
95 QTimer timer;
96 QHash<QUrl, QTreeWidgetItem*> url2item;
97 };
98
SuggestTaxonCompletion(TaxonEdit * const parent)99 SuggestTaxonCompletion::SuggestTaxonCompletion(TaxonEdit* const parent)
100 : QObject(parent),
101 d (new Private)
102 {
103 d->editor = parent;
104 d->popup = new QTreeWidget;
105 d->popup->setWindowFlags(Qt::Popup);
106 d->popup->setFocusPolicy(Qt::NoFocus);
107 d->popup->setFocusProxy(parent);
108 d->popup->setMouseTracking(true);
109
110 d->popup->setUniformRowHeights(true);
111 d->popup->setRootIsDecorated(false);
112 d->popup->setEditTriggers(QTreeWidget::NoEditTriggers);
113 d->popup->setSelectionBehavior(QTreeWidget::SelectRows);
114 d->popup->setFrameStyle(QFrame::Box | QFrame::Plain);
115 d->popup->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
116 d->popup->header()->hide();
117
118 d->popup->installEventFilter(this);
119
120 connect(d->popup, SIGNAL(itemPressed(QTreeWidgetItem*,int)),
121 this, SLOT(slotDoneCompletion()));
122
123 d->timer.setSingleShot(true);
124 d->timer.setInterval(500);
125 connect(&d->timer, SIGNAL(timeout()), SLOT(slotAutoSuggest()));
126
127 connect(d->editor, SIGNAL(textEdited(QString)),
128 SLOT(slotTextEdited(QString)));
129 }
130
~SuggestTaxonCompletion()131 SuggestTaxonCompletion::~SuggestTaxonCompletion()
132 {
133 delete d->popup;
134 delete d;
135 }
136
slotTextEdited(const QString &)137 void SuggestTaxonCompletion::slotTextEdited(const QString&)
138 {
139 emit signalTaxonDeselected();
140 d->timer.start();
141 }
142
setTalker(INatTalker * const inatTalker)143 void SuggestTaxonCompletion::setTalker(INatTalker* const inatTalker)
144 {
145 d->talker = inatTalker;
146
147 connect(d->talker, SIGNAL(signalTaxonAutoCompletions(AutoCompletions)),
148 this, SLOT(slotTaxonAutoCompletions(AutoCompletions)));
149
150 connect(d->talker, SIGNAL(signalComputerVisionResults(ImageScores)),
151 this, SLOT(slotComputerVisionResults(ImageScores)));
152
153 connect(d->editor, SIGNAL(inFocus()),
154 this, SLOT(slotInFocus()));
155
156 connect(d->talker, SIGNAL(signalLoadUrlSucceeded(QUrl,QByteArray)),
157 this, SLOT(slotImageLoaded(QUrl,QByteArray)));
158 }
159
slotInFocus()160 void SuggestTaxonCompletion::slotInFocus()
161 {
162 emit signalTaxonDeselected();
163 d->timer.start();
164 }
165
eventFilter(QObject * obj,QEvent * ev)166 bool SuggestTaxonCompletion::eventFilter(QObject* obj, QEvent* ev)
167 {
168 if (obj != d->popup)
169 {
170 return false;
171 }
172
173 if (ev->type() == QEvent::MouseButtonPress)
174 {
175 d->popup->hide();
176 d->editor->setFocus();
177
178 return true;
179 }
180
181 if (ev->type() == QEvent::KeyPress)
182 {
183 bool consumed = false;
184 int key = static_cast<QKeyEvent*>(ev)->key();
185
186 switch (key)
187 {
188 case Qt::Key_Enter:
189 case Qt::Key_Return:
190 {
191 slotDoneCompletion();
192 consumed = true;
193 break;
194 }
195
196 case Qt::Key_Escape:
197 {
198 d->editor->setFocus();
199 d->popup->hide();
200 consumed = true;
201 break;
202 }
203
204 case Qt::Key_Up:
205 case Qt::Key_Down:
206 case Qt::Key_Home:
207 case Qt::Key_End:
208 case Qt::Key_PageUp:
209 case Qt::Key_PageDown:
210 {
211 break;
212 }
213
214 default:
215 {
216 d->editor->setFocus();
217 d->editor->event(ev);
218 d->popup->hide();
219 break;
220 }
221 }
222
223 return consumed;
224 }
225
226 return false;
227 }
228
taxon2Item(const Taxon & taxon,QTreeWidgetItem * item,const QString & info)229 void SuggestTaxonCompletion::taxon2Item(const Taxon& taxon,
230 QTreeWidgetItem* item,
231 const QString& info)
232 {
233 QString htmlText = taxon.htmlName() + QLatin1String("<br/>") +
234 taxon.commonName() +
235 QLatin1String("<br/><font color=\"#74ac00\">") +
236 info + QLatin1String("</font>");
237 d->popup->setItemWidget(item, ITEM_NAME_IDX, new QLabel(htmlText));
238
239 // photo
240
241 const QUrl& photoUrl = taxon.squareUrl();
242
243 if (!photoUrl.isEmpty())
244 {
245 d->url2item.insert(photoUrl, item);
246 d->talker->loadUrl(photoUrl);
247 }
248 }
249
showCompletion(const Completions & choices)250 void SuggestTaxonCompletion::showCompletion(const Completions& choices)
251 {
252 d->popup->setUpdatesEnabled(false);
253 d->popup->clear();
254 d->popup->setIconSize(QSize(75, 75));
255 d->fromVision = choices.m_fromVision;
256 int columns = choices.m_taxa.isEmpty() ? 1 : 2;
257 d->popup->setColumnCount(columns);
258 d->url2item.clear();
259
260 if (choices.m_commonAncestor.isValid())
261 {
262 const Taxon& taxon = choices.m_commonAncestor;
263
264 Q_ASSERT(choices.m_fromVision);
265
266 auto item = new QTreeWidgetItem(d->popup);
267 taxon2Item(taxon, item, i18n("We're pretty sure it's in this %1.",
268 localizedTaxonomicRank(taxon.rank())));
269 }
270
271 for (const auto& choice : choices.m_taxa)
272 {
273 QString info;
274
275 if (choice.m_visuallySimilar && choice.m_seenNearby)
276 {
277 info = i18n("Visually Similar") + QLatin1String(" / ") +
278 i18n("Seen Nearby");
279 }
280 else if (choice.m_visuallySimilar)
281 {
282 info = i18n("Visually Similar");
283 }
284 else if (choice.m_seenNearby)
285 {
286 info = i18n("Seen Nearby");
287 }
288
289 auto item = new QTreeWidgetItem(d->popup);
290 taxon2Item(choice.m_taxon, item, info);
291 }
292
293 if (choices.m_taxa.isEmpty())
294 {
295 auto item = new QTreeWidgetItem(d->popup);
296 QFont font = item->font(0);
297 font.setBold(true);
298 item->setForeground(0, QColor(Qt::red));
299 item->setText(0, i18n("invalid name"));
300 item->setFont(0, font);
301 }
302
303 d->popup->setCurrentItem(d->popup->topLevelItem(0));
304
305 for (int i = 0 ; i < columns ; ++i)
306 {
307 d->popup->resizeColumnToContents(i);
308 }
309
310 d->popup->setUpdatesEnabled(true);
311 d->popup->setMinimumWidth(d->editor->width());
312 d->popup->move(d->editor->mapToGlobal(QPoint(0, d->editor->height())));
313 d->popup->setFocus();
314 d->popup->show();
315 }
316
slotDoneCompletion()317 void SuggestTaxonCompletion::slotDoneCompletion()
318 {
319 d->timer.stop();
320 d->url2item.clear();
321 d->popup->hide();
322 d->editor->setFocus();
323
324 if (d->completionTaxa.count() == 0)
325 {
326 return;
327 }
328
329 QTreeWidgetItem* const item = d->popup->currentItem();
330
331 if (item)
332 {
333 int idx = item->treeWidget()->indexOfTopLevelItem(item);
334
335 if (idx < d->completionTaxa.count())
336 {
337 const Taxon& taxon = d->completionTaxa[idx];
338
339 if (taxon.commonName().isEmpty())
340 {
341 d->editor->setText(taxon.name());
342 }
343 else
344 {
345 // combine scientific name and common name
346
347 d->editor->setText(taxon.name() + QLatin1String(" (") +
348 taxon.commonName() + QLatin1String(")"));
349 }
350
351 QMetaObject::invokeMethod(d->editor, "returnPressed");
352
353 emit signalTaxonSelected(taxon, d->fromVision);
354 }
355 }
356 }
357
getText() const358 QString SuggestTaxonCompletion::getText() const
359 {
360 QString str = d->editor->text().simplified();
361
362 // When we have "scientific name (common name)" we only
363 // send the scientific name to auto-completion.
364
365 int idx = str.indexOf(QLatin1String(" ("));
366
367 if (idx >= 0)
368 {
369 str.truncate(idx);
370 }
371
372 return str;
373 }
374
slotAutoSuggest()375 void SuggestTaxonCompletion::slotAutoSuggest()
376 {
377 QString str = getText();
378
379 if (str.count() > 0)
380 {
381 d->talker->taxonAutoCompletions(str);
382 }
383 else
384 {
385 emit signalComputerVision();
386 }
387 }
388
slotPreventSuggest()389 void SuggestTaxonCompletion::slotPreventSuggest()
390 {
391 d->timer.stop();
392 }
393
slotTaxonAutoCompletions(const AutoCompletions & taxa)394 void SuggestTaxonCompletion::slotTaxonAutoCompletions(const AutoCompletions& taxa)
395 {
396 if (getText() != taxa.first)
397 {
398 return;
399 }
400
401 Completions completions(false);
402
403 d->completionTaxa.clear();
404
405 for (auto taxon : taxa.second)
406 {
407 completions.m_taxa << TaxonAndFlags(taxon);
408 d->completionTaxa.append(taxon);
409 }
410
411 showCompletion(completions);
412 }
413
slotComputerVisionResults(const ImageScores & scores)414 void SuggestTaxonCompletion::slotComputerVisionResults(const ImageScores& scores)
415 {
416 if (!d->editor->text().simplified().isEmpty())
417 {
418 return;
419 }
420
421 Completions completions(true);
422
423 d->completionTaxa.clear();
424
425 for (auto score : scores.second)
426 {
427 if (score.getTaxon().ancestors().isEmpty())
428 {
429 Q_ASSERT(!completions.m_commonAncestor.isValid());
430
431 completions.m_commonAncestor = score.getTaxon();
432 }
433 else
434 {
435 completions.m_taxa << TaxonAndFlags(score.getTaxon(),
436 score.visuallySimilar(),
437 score.seenNearby());
438 }
439
440 d->completionTaxa.append(score.getTaxon());
441 }
442
443 showCompletion(completions);
444 }
445
slotImageLoaded(const QUrl & url,const QByteArray & data)446 void SuggestTaxonCompletion::slotImageLoaded(const QUrl& url, const QByteArray& data)
447 {
448 if (d->url2item.contains(url))
449 {
450 QTreeWidgetItem* const item = d->url2item[url];
451 QImage image;
452 image.loadFromData(data);
453 QIcon icon(QPixmap::fromImage(image));
454 item->setIcon(ITEM_PHOTO_IDX, icon);
455 d->popup->resizeColumnToContents(ITEM_PHOTO_IDX);
456 d->popup->resizeColumnToContents(ITEM_NAME_IDX);
457 }
458 }
459
460 } // namespace DigikamGenericINatPlugin
461