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