1 /*
2     SPDX-FileCopyrightText: 2015 Jasem Mutlaq <mutlaqja@ikarustech.com>
3 
4     SPDX-License-Identifier: GPL-2.0-or-later
5 */
6 
7 #include "horizonmanager.h"
8 
9 #include "kstars.h"
10 #include "kstarsdata.h"
11 #include "linelist.h"
12 #include "ksnotification.h"
13 #include "Options.h"
14 #include "skymap.h"
15 #include "projections/projector.h"
16 #include "skycomponents/artificialhorizoncomponent.h"
17 #include "skycomponents/skymapcomposite.h"
18 
19 #include <QStandardItemModel>
20 
21 #include <kstars_debug.h>
22 
23 #define MIN_NUMBER_POINTS 2
24 
HorizonManagerUI(QWidget * p)25 HorizonManagerUI::HorizonManagerUI(QWidget *p) : QFrame(p)
26 {
27     setupUi(this);
28 }
29 
HorizonManager(QWidget * w)30 HorizonManager::HorizonManager(QWidget *w) : QDialog(w)
31 {
32 #ifdef Q_OS_OSX
33     setWindowFlags(Qt::Tool | Qt::WindowStaysOnTopHint);
34 #endif
35     ui = new HorizonManagerUI(this);
36 
37     ui->setStyleSheet("QPushButton:checked { background-color: red; }");
38 
39     ui->addRegionB->setIcon(QIcon::fromTheme("list-add"));
40     ui->addPointB->setIcon(QIcon::fromTheme("list-add"));
41     ui->removeRegionB->setIcon(QIcon::fromTheme("list-remove"));
42     ui->toggleCeilingB->setIcon(QIcon::fromTheme("window"));
43     ui->removePointB->setIcon(QIcon::fromTheme("list-remove"));
44     ui->clearPointsB->setIcon(QIcon::fromTheme("edit-clear"));
45     ui->saveB->setIcon(QIcon::fromTheme("document-save"));
46     ui->selectPointsB->setIcon(
47         QIcon::fromTheme("snap-orthogonal"));
48 
49     ui->tipLabel->setPixmap(
50         (QIcon::fromTheme("help-hint").pixmap(64, 64)));
51 
52     ui->regionValidation->setPixmap(
53         QIcon::fromTheme("process-stop").pixmap(32, 32));
54     ui->regionValidation->setToolTip(i18n("Region is invalid."));
55     ui->regionValidation->hide();
56 
57     setWindowTitle(i18nc("@title:window", "Artificial Horizon Manager"));
58 
59     QVBoxLayout *mainLayout = new QVBoxLayout;
60     mainLayout->addWidget(ui);
61     setLayout(mainLayout);
62 
63     QDialogButtonBox *buttonBox = new QDialogButtonBox(QDialogButtonBox::Apply | QDialogButtonBox::Close);
64     mainLayout->addWidget(buttonBox);
65     connect(buttonBox, SIGNAL(rejected()), this, SLOT(reject()));
66     connect(buttonBox->button(QDialogButtonBox::Apply), SIGNAL(clicked()), this, SLOT(slotSaveChanges()));
67     connect(buttonBox->button(QDialogButtonBox::Close), SIGNAL(clicked()), this, SLOT(slotClosed()));
68 
69     selectPoints = false;
70 
71     // Set up List view
72     m_RegionsModel = new QStandardItemModel(0, 3, this);
73     m_RegionsModel->setHorizontalHeaderLabels(QStringList()
74             << i18n("Region") << i18nc("Azimuth", "Az") << i18nc("Altitude", "Alt"));
75 
76     ui->regionsList->setModel(m_RegionsModel);
77 
78     ui->pointsList->setModel(m_RegionsModel);
79     ui->pointsList->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
80     ui->pointsList->verticalHeader()->hide();
81     ui->pointsList->setColumnHidden(0, true);
82 
83     horizonComponent = KStarsData::Instance()->skyComposite()->artificialHorizon();
84 
85     // Get the list
86     const QList<ArtificialHorizonEntity *> *horizonList = horizonComponent->getHorizon().horizonList();
87 
88     for (auto &horizon : *horizonList)
89     {
90         QStandardItem *regionItem = new QStandardItem(horizon->region());
91         regionItem->setCheckable(true);
92         regionItem->setCheckState(horizon->enabled() ? Qt::Checked : Qt::Unchecked);
93 
94         if (horizon->ceiling())
95             regionItem->setData(QIcon::fromTheme("window"), Qt::DecorationRole);
96         else
97             regionItem->setData(QIcon(), Qt::DecorationRole);
98         regionItem->setData(horizon->ceiling(), Qt::UserRole);
99 
100         m_RegionsModel->appendRow(regionItem);
101 
102         SkyList *points = horizon->list()->points();
103 
104         for (auto &p : *points)
105         {
106             QList<QStandardItem *> pointsList;
107             pointsList << new QStandardItem("") << new QStandardItem(p->az().toDMSString())
108                        << new QStandardItem(p->alt().toDMSString());
109             regionItem->appendRow(pointsList);
110         }
111     }
112 
113     ui->removeRegionB->setEnabled(true);
114     ui->toggleCeilingB->setEnabled(true);
115 
116     connect(m_RegionsModel, SIGNAL(itemChanged(QStandardItem*)), this, SLOT(verifyItemValue(QStandardItem*)));
117 
118     //Connect buttons
119     connect(ui->addRegionB, SIGNAL(clicked()), this, SLOT(slotAddRegion()));
120     connect(ui->removeRegionB, SIGNAL(clicked()), this, SLOT(slotRemoveRegion()));
121     connect(ui->toggleCeilingB, SIGNAL(clicked()), this, SLOT(slotToggleCeiling()));
122 
123     connect(ui->regionsList, SIGNAL(clicked(QModelIndex)), this, SLOT(slotSetShownRegion(QModelIndex)));
124 
125     connect(ui->addPointB, SIGNAL(clicked()), this, SLOT(slotAddPoint()));
126     connect(ui->removePointB, SIGNAL(clicked()), this, SLOT(slotRemovePoint()));
127     connect(ui->clearPointsB, SIGNAL(clicked()), this, SLOT(clearPoints()));
128     connect(ui->selectPointsB, SIGNAL(clicked(bool)), this, SLOT(setSelectPoints(bool)));
129 
130     connect(ui->pointsList->selectionModel(), SIGNAL(currentChanged(QModelIndex, QModelIndex)),
131             this, SLOT(slotCurrentPointChanged(QModelIndex, QModelIndex)));
132 
133     connect(ui->saveB, SIGNAL(clicked()), this, SLOT(slotSaveChanges()));
134 
135     if (horizonList->count() > 0)
136     {
137         ui->regionsList->selectionModel()->setCurrentIndex(m_RegionsModel->index(0, 0),
138                 QItemSelectionModel::SelectCurrent);
139         showRegion(0);
140     }
141 }
142 
143 // If the user hit's the 'X', still want to remove the live preview.
closeEvent(QCloseEvent * event)144 void HorizonManager::closeEvent(QCloseEvent *event)
145 {
146     Q_UNUSED(event);
147     slotClosed();
148 }
149 
150 // This gets the live preview to be shown when the window is shown.
showEvent(QShowEvent * event)151 void HorizonManager::showEvent(QShowEvent *event)
152 {
153     QWidget::showEvent( event );
154     QStandardItem *regionItem = m_RegionsModel->item(ui->regionsList->currentIndex().row(), 0);
155     if (regionItem)
156     {
157         setupLivePreview(regionItem);
158         SkyMap::Instance()->forceUpdateNow();
159     }
160 }
161 
162 // Highlights the current point.
slotCurrentPointChanged(const QModelIndex & selected,const QModelIndex & deselected)163 void HorizonManager::slotCurrentPointChanged(const QModelIndex &selected, const QModelIndex &deselected)
164 {
165     Q_UNUSED(deselected);
166     if (livePreview.get() != nullptr &&
167             selected.row() >= 0 &&
168             selected.row() < livePreview->points()->size())
169         horizonComponent->setSelectedPreviewPoint(selected.row());
170     else
171         horizonComponent->setSelectedPreviewPoint(-1);
172     SkyMap::Instance()->forceUpdateNow();
173 }
174 
175 // Controls the UI validation check-mark, which indicates if the current
176 // region is valid or not.
setupValidation(int region)177 void HorizonManager::setupValidation(int region)
178 {
179     QStandardItem *regionItem = m_RegionsModel->item(region, 0);
180 
181     if (regionItem && regionItem->rowCount() >= MIN_NUMBER_POINTS)
182     {
183         if (validate(region))
184         {
185             ui->regionValidation->setPixmap(
186                 QIcon::fromTheme("dialog-ok").pixmap(32, 32));
187             ui->regionValidation->setEnabled(true);
188             ui->regionValidation->setToolTip(i18n("Region is valid"));
189         }
190         else
191         {
192             ui->regionValidation->setPixmap(
193                 QIcon::fromTheme("process-stop").pixmap(32, 32));
194             ui->regionValidation->setEnabled(false);
195             ui->regionValidation->setToolTip(i18n("Region is invalid."));
196         }
197 
198         ui->regionValidation->show();
199     }
200     else
201         ui->regionValidation->hide();
202 }
203 
showRegion(int regionID)204 void HorizonManager::showRegion(int regionID)
205 {
206     if (regionID < 0 || regionID >= m_RegionsModel->rowCount())
207         return;
208     else
209     {
210         ui->pointsList->setRootIndex(m_RegionsModel->index(regionID, 0));
211         ui->pointsList->setColumnHidden(0, true);
212 
213         QStandardItem *regionItem = m_RegionsModel->item(regionID, 0);
214 
215         if (regionItem->rowCount() > 0)
216             ui->pointsList->setCurrentIndex(regionItem->child(regionItem->rowCount() - 1, 0)->index());
217         else
218             // Invalid index.
219             ui->pointsList->setCurrentIndex(QModelIndex());
220 
221         setupValidation(regionID);
222 
223         ui->addPointB->setEnabled(true);
224         ui->removePointB->setEnabled(true);
225         ui->selectPointsB->setEnabled(true);
226         ui->clearPointsB->setEnabled(true);
227 
228         if (regionItem != nullptr)
229         {
230             setupLivePreview(regionItem);
231             SkyMap::Instance()->forceUpdateNow();
232         }
233     }
234 
235     ui->saveB->setEnabled(true);
236 }
237 
validate(int regionID)238 bool HorizonManager::validate(int regionID)
239 {
240     QStandardItem *regionItem = m_RegionsModel->item(regionID, 0);
241 
242     if (regionItem == nullptr || regionItem->rowCount() < MIN_NUMBER_POINTS)
243         return false;
244 
245     for (int i = 0; i < regionItem->rowCount(); i++)
246     {
247         dms az  = dms::fromString(regionItem->child(i, 1)->data(Qt::DisplayRole).toString(), true);
248         dms alt = dms::fromString(regionItem->child(i, 2)->data(Qt::DisplayRole).toString(), true);
249 
250         if (std::isnan(az.Degrees()) || std::isnan(alt.Degrees()))
251             return false;
252     }
253 
254     return true;
255 }
256 
removeEmptyRows(int regionID)257 void HorizonManager::removeEmptyRows(int regionID)
258 {
259     QStandardItem *regionItem = m_RegionsModel->item(regionID, 0);
260 
261     if (regionItem == nullptr)
262         return;
263 
264     QList<int> emptyRows;
265     for (int i = 0; i < regionItem->rowCount(); i++)
266     {
267         dms az  = dms::fromString(regionItem->child(i, 1)->data(Qt::DisplayRole).toString(), true);
268         dms alt = dms::fromString(regionItem->child(i, 2)->data(Qt::DisplayRole).toString(), true);
269 
270         if (std::isnan(az.Degrees()) || std::isnan(alt.Degrees()))
271             emptyRows.append(i);
272     }
273     std::sort(emptyRows.begin(), emptyRows.end(), [](int a, int b) -> bool
274     {
275         return a > b;
276     });
277     for (int i = 0; i < emptyRows.size(); ++i)
278         regionItem->removeRow(emptyRows[i]);
279     return;
280 }
281 
slotAddRegion()282 void HorizonManager::slotAddRegion()
283 {
284     terminateLivePreview();
285 
286     setPointSelection(false);
287 
288     QStandardItem *regionItem = new QStandardItem(i18n("Region %1", m_RegionsModel->rowCount() + 1));
289     regionItem->setCheckable(true);
290     regionItem->setCheckState(Qt::Checked);
291     m_RegionsModel->appendRow(regionItem);
292 
293     QModelIndex index = regionItem->index();
294     ui->regionsList->selectionModel()->setCurrentIndex(index, QItemSelectionModel::ClearAndSelect);
295 
296     showRegion(m_RegionsModel->rowCount() - 1);
297 }
298 
slotToggleCeiling()299 void HorizonManager::slotToggleCeiling()
300 {
301     int regionID = ui->regionsList->currentIndex().row();
302     QStandardItem *regionItem = m_RegionsModel->item(regionID, 0);
303 
304     bool turnCeilingOn = !regionItem->data(Qt::UserRole).toBool();
305     if (turnCeilingOn)
306     {
307         regionItem->setData(QIcon::fromTheme("window"), Qt::DecorationRole);
308         regionItem->setData(true, Qt::UserRole);
309     }
310     else
311     {
312         regionItem->setData(QIcon(), Qt::DecorationRole);
313         regionItem->setData(false, Qt::UserRole);
314     }
315 }
316 
slotRemoveRegion()317 void HorizonManager::slotRemoveRegion()
318 {
319     terminateLivePreview();
320 
321     setPointSelection(false);
322 
323     int regionID = ui->regionsList->currentIndex().row();
324     deleteRegion(regionID);
325 
326     if (regionID > 0)
327         showRegion(regionID - 1);
328     else if (m_RegionsModel->rowCount() == 0)
329     {
330         ui->regionValidation->hide();
331         m_RegionsModel->clear();
332     }
333 }
334 
deleteRegion(int regionID)335 void HorizonManager::deleteRegion(int regionID)
336 {
337     if (regionID == -1)
338         return;
339 
340     if (regionID < m_RegionsModel->rowCount())
341     {
342         horizonComponent->removeRegion(m_RegionsModel->item(regionID, 0)->data(Qt::DisplayRole).toString());
343         m_RegionsModel->removeRow(regionID);
344         SkyMap::Instance()->forceUpdate();
345     }
346 }
347 
slotClosed()348 void HorizonManager::slotClosed()
349 {
350     setSelectPoints(false);
351     terminateLivePreview();
352     SkyMap::Instance()->forceUpdate();
353 }
354 
slotSaveChanges()355 void HorizonManager::slotSaveChanges()
356 {
357     terminateLivePreview();
358     setPointSelection(false);
359 
360     for (int i = 0; i < m_RegionsModel->rowCount(); i++)
361     {
362         removeEmptyRows(i);
363         if (validate(i) == false)
364         {
365             KSNotification::error(i18n("%1 region is invalid.",
366                                        m_RegionsModel->item(i, 0)->data(Qt::DisplayRole).toString()));
367             return;
368         }
369     }
370 
371     for (int i = 0; i < m_RegionsModel->rowCount(); i++)
372     {
373         QStandardItem *regionItem = m_RegionsModel->item(i, 0);
374         QString regionName        = regionItem->data(Qt::DisplayRole).toString();
375 
376         horizonComponent->removeRegion(regionName);
377 
378         std::shared_ptr<LineList> list(new LineList());
379         dms az, alt;
380         std::shared_ptr<SkyPoint> p;
381 
382         for (int j = 0; j < regionItem->rowCount(); j++)
383         {
384             az  = dms::fromString(regionItem->child(j, 1)->data(Qt::DisplayRole).toString(), true);
385             alt = dms::fromString(regionItem->child(j, 2)->data(Qt::DisplayRole).toString(), true);
386             if (qIsNaN(az.Degrees()) || qIsNaN(alt.Degrees())) continue;
387 
388             p.reset(new SkyPoint());
389             p->setAz(az);
390             p->setAlt(alt);
391             p->HorizontalToEquatorial(KStarsData::Instance()->lst(), KStarsData::Instance()->geo()->lat());
392 
393             list->append(p);
394         }
395 
396         const bool ceiling = regionItem->data(Qt::UserRole).toBool();
397         horizonComponent->addRegion(regionName, regionItem->checkState() == Qt::Checked ? true : false, list, ceiling);
398     }
399 
400     horizonComponent->save();
401 
402     SkyMap::Instance()->forceUpdateNow();
403 }
404 
slotSetShownRegion(QModelIndex idx)405 void HorizonManager::slotSetShownRegion(QModelIndex idx)
406 {
407     showRegion(idx.row());
408 }
409 
410 // Copies values from the model to the livePreview, for the passed in region,
411 // and passes the livePreview to the horizonComponent, which renders the live preview.
setupLivePreview(QStandardItem * region)412 void HorizonManager::setupLivePreview(QStandardItem * region)
413 {
414     if (region == nullptr) return;
415     livePreview.reset(new LineList());
416     const int numPoints = region->rowCount();
417     for (int i = 0; i < numPoints; i++)
418     {
419         QStandardItem *azItem  = region->child(i, 1);
420         QStandardItem *altItem = region->child(i, 2);
421 
422         const dms az  = dms::fromString(azItem->data(Qt::DisplayRole).toString(), true);
423         const dms alt = dms::fromString(altItem->data(Qt::DisplayRole).toString(), true);
424         // Don't render points with bad values.
425         if (qIsNaN(az.Degrees()) || qIsNaN(alt.Degrees()))
426             continue;
427 
428         std::shared_ptr<SkyPoint> point(new SkyPoint());
429         point->setAz(az);
430         point->setAlt(alt);
431         point->HorizontalToEquatorial(KStarsData::Instance()->lst(), KStarsData::Instance()->geo()->lat());
432 
433         livePreview->append(point);
434     }
435 
436     horizonComponent->setLivePreview(livePreview);
437 }
438 
addPoint(SkyPoint * skyPoint)439 void HorizonManager::addPoint(SkyPoint *skyPoint)
440 {
441     QStandardItem *region = m_RegionsModel->item(ui->regionsList->currentIndex().row(), 0);
442     if (region == nullptr)
443         return;
444 
445     // Add the point after the current index in pointsList (row + 1).
446     // If there is no current index, or if somehow (shouldn't happen)
447     // the current index is larger than the list size, insert the point at the end
448     int row = ui->pointsList->currentIndex().row();
449     if ((row < 0) || (row >= region->rowCount()))
450         row = region->rowCount();
451     else row = row + 1;
452 
453     QList<QStandardItem *> pointsList;
454     pointsList << new QStandardItem("") << new QStandardItem("") << new QStandardItem("");
455 
456     region->insertRow(row, pointsList);
457     auto index = region->child(row, 0)->index();
458     ui->pointsList->setCurrentIndex(index);
459 
460     m_RegionsModel->setHorizontalHeaderLabels(QStringList()
461             << i18n("Region") << i18nc("Azimuth", "Az") << i18nc("Altitude", "Alt"));
462     ui->pointsList->setColumnHidden(0, true);
463     ui->pointsList->setRootIndex(region->index());
464 
465     // If a point was supplied (i.e. the user clicked on the SkyMap, as opposed to
466     // just clicking the addPoint button), then set up its coordinates.
467     if (skyPoint != nullptr)
468     {
469         QStandardItem *az  = region->child(row, 1);
470         QStandardItem *alt = region->child(row, 2);
471 
472         az->setData(skyPoint->az().toDMSString(), Qt::DisplayRole);
473         alt->setData(skyPoint->alt().toDMSString(), Qt::DisplayRole);
474 
475         setupLivePreview(region);
476         slotCurrentPointChanged(ui->pointsList->currentIndex(), ui->pointsList->currentIndex());
477     }
478 }
479 
480 // Called when the user clicks on the SkyMap to add a new point.
addSkyPoint(SkyPoint * skypoint)481 void HorizonManager::addSkyPoint(SkyPoint * skypoint)
482 {
483     if (selectPoints == false)
484         return;
485     // Make a copy.  This point wasn't staying stable in UI tests.
486     SkyPoint pt = *skypoint;
487     addPoint(&pt);
488 }
489 
490 // Called when the user clicks on the addPoint button.
slotAddPoint()491 void HorizonManager::slotAddPoint()
492 {
493     addPoint(nullptr);
494 }
495 
slotRemovePoint()496 void HorizonManager::slotRemovePoint()
497 {
498     int regionID = ui->regionsList->currentIndex().row();
499     QStandardItem *regionItem = m_RegionsModel->item(regionID, 0);
500     if (regionItem == nullptr)
501         return;
502 
503     int row = ui->pointsList->currentIndex().row();
504     if (row == -1)
505         row = regionItem->rowCount() - 1;
506     regionItem->removeRow(row);
507 
508     setupValidation(regionID);
509 
510     if (livePreview.get() && row < livePreview->points()->count())
511     {
512         livePreview->points()->takeAt(row);
513 
514         if (livePreview->points()->isEmpty())
515             terminateLivePreview();
516         else
517             SkyMap::Instance()->forceUpdateNow();
518     }
519 }
520 
clearPoints()521 void HorizonManager::clearPoints()
522 {
523     QStandardItem *regionItem = m_RegionsModel->item(ui->regionsList->currentIndex().row(), 0);
524 
525     if (regionItem)
526     {
527         regionItem->removeRows(0, regionItem->rowCount());
528 
529         horizonComponent->removeRegion(regionItem->data(Qt::DisplayRole).toString(), true);
530 
531         ui->regionValidation->hide();
532     }
533 
534     terminateLivePreview();
535 }
536 
setSelectPoints(bool enable)537 void HorizonManager::setSelectPoints(bool enable)
538 {
539     selectPoints = enable;
540     ui->selectPointsB->clearFocus();
541 }
542 
verifyItemValue(QStandardItem * item)543 void HorizonManager::verifyItemValue(QStandardItem * item)
544 {
545     bool azOK = true, altOK = true;
546 
547     if (item->column() >= 1)
548     {
549         QStandardItem *parent = item->parent();
550 
551         dms azAngle  = dms::fromString(parent->child(item->row(), 1)->data(Qt::DisplayRole).toString(), true);
552         dms altAngle = dms::fromString(parent->child(item->row(), 2)->data(Qt::DisplayRole).toString(), true);
553 
554         if (std::isnan(azAngle.Degrees()))
555             azOK = false;
556         if (std::isnan(altAngle.Degrees()))
557             altOK = false;
558 
559         if ((item->column() == 1 && azOK == false) || (item->column() == 2 && altOK == false))
560 
561         {
562             KSNotification::error(i18n("Invalid angle value: %1", item->data(Qt::DisplayRole).toString()));
563             disconnect(m_RegionsModel, SIGNAL(itemChanged(QStandardItem*)), this, SLOT(verifyItemValue(QStandardItem*)));
564             item->setData(QVariant(qQNaN()), Qt::DisplayRole);
565             connect(m_RegionsModel, SIGNAL(itemChanged(QStandardItem*)), this, SLOT(verifyItemValue(QStandardItem*)));
566             return;
567         }
568         else if (azOK && altOK)
569         {
570             setupLivePreview(item->parent());
571             setupValidation(ui->regionsList->currentIndex().row());
572             SkyMap::Instance()->forceUpdateNow();
573         }
574     }
575 }
576 
terminateLivePreview()577 void HorizonManager::terminateLivePreview()
578 {
579     if (!livePreview.get())
580         return;
581 
582     livePreview.reset();
583     horizonComponent->setLivePreview(livePreview);
584 }
585 
setPointSelection(bool enable)586 void HorizonManager::setPointSelection(bool enable)
587 {
588     selectPoints = enable;
589     ui->selectPointsB->setChecked(enable);
590 }
591