Loading...
Searching...
No Matches
StartScreen.hpp
1#pragma once
2#include <score/application/GUIApplicationContext.hpp>
3#include <score/model/Skin.hpp>
4#include <score/tools/ThreadPool.hpp>
5#include <score/widgets/MessageBox.hpp>
6#include <score/widgets/Pixmap.hpp>
7
8#include <core/document/DefaultLibrary.hpp>
9#include <core/document/DocumentTemplates.hpp>
10#include <core/document/OnlineExamples.hpp>
11#include <core/document/ProjectInfo.hpp>
12#include <core/presenter/AboutWidget.hpp>
13#include <core/view/QRecentFilesMenu.h>
14
15#include <QApplication>
16#include <QCloseEvent>
17#include <QDate>
18#include <QDesktopServices>
19#include <QDir>
20#include <QFileInfo>
21#include <QFontMetrics>
22#include <QLayout>
23#include <QLineEdit>
24#include <QHBoxLayout>
25#include <QKeyEvent>
26#include <QLabel>
27#include <QListWidget>
28#include <QMouseEvent>
29#include <QNetworkAccessManager>
30#include <QNetworkReply>
31#include <QNetworkRequest>
32#include <QPainter>
33#include <QPainterPath>
34#include <QPixmap>
35#include <QLocale>
36#include <QPlainTextEdit>
37#include <QPointer>
38#include <QMainWindow>
39#include <QMenu>
40#include <QMessageBox>
41#include <QScreen>
42#include <QScrollArea>
43#include <QSettings>
44#include <QStackedWidget>
45#include <QTextLayout>
46#include <QVBoxLayout>
47#include <QVersionNumber>
48
49#include <score_git_info.hpp>
50
51#include <algorithm>
52#include <functional>
53#include <map>
54#include <set>
55#include <memory>
56#include <optional>
57#include <vector>
58#include <verdigris>
59
60namespace score
61{
62namespace
63{
64template <typename OnSuccess, typename OnError>
65class HTTPGet final : public QNetworkAccessManager
66{
67public:
68 explicit HTTPGet(QUrl url, OnSuccess on_success, OnError on_error) noexcept
69 : m_callback{std::move(on_success)}
70 , m_error{std::move(on_error)}
71 {
72 connect(this, &QNetworkAccessManager::finished, this, [this](QNetworkReply* reply) {
73 if(reply->error())
74 {
75 qDebug() << reply->errorString();
76 m_error();
77 }
78 else
79 {
80 m_callback(reply->readAll());
81 }
82
83 reply->deleteLater();
84 this->deleteLater();
85 });
86
87 QNetworkRequest req{std::move(url)};
88 req.setRawHeader("User-Agent", "curl/7.35.0");
89 req.setAttribute(QNetworkRequest::HttpPipeliningAllowedAttribute, true);
90 req.setAttribute(
91 QNetworkRequest::RedirectPolicyAttribute,
92 QNetworkRequest::UserVerifiedRedirectPolicy);
93 req.setAttribute(QNetworkRequest::Http2AllowedAttribute, true);
94
95 auto reply = get(req);
96 connect(reply, &QNetworkReply::redirected, reply, &QNetworkReply::redirectAllowed);
97 }
98
99private:
100 OnSuccess m_callback;
101 OnError m_error;
102};
103
104// Brand colors of the start screen. They match the splash artwork and the
105// start screen icon set (see src/lib/resources/icons_svg/readme.md), and are
106// deliberately independent from the editor skin.
107namespace StartScreenColors
108{
109static const QColor Panel{
110 0x21, 0x1f, 0x1f, 205}; // translucent: the artwork shows through
111static const QColor Card{"#2b2929"};
112static const QColor CardDark{"#161514"};
113static const QColor Scrim{0x16, 0x15, 0x14, 232};
114static const QColor Outline{"#3d3a3a"};
115static const QColor Text{"#f0f0f0"};
116static const QColor Muted{"#8a8a8a"};
117static const QColor Hover{"#03C3DD"};
118static const QColor Accent{"#f6a019"};
119static const QColor Version{"#0092CF"};
120}
121
122// Crops an image to the given aspect and scales it to exactly `size`.
123QPixmap coverPixmap(const QImage& img, QSize size)
124{
125 if(img.isNull())
126 return {};
127 QImage scaled
128 = img.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation);
129 const QRect crop{
130 (scaled.width() - size.width()) / 2, (scaled.height() - size.height()) / 2,
131 size.width(), size.height()};
132 return QPixmap::fromImage(scaled.copy(crop));
133}
134
135// Word-wrapped text, eliding the last line that fits.
136void drawWrappedText(QPainter& p, const QRect& rect, const QString& text)
137{
138 const QFontMetrics fm{p.font()};
139 const int lineSpacing = fm.lineSpacing();
140 int y = rect.top();
141
142 QTextLayout layout{text, p.font()};
143 layout.beginLayout();
144 for(;;)
145 {
146 QTextLine line = layout.createLine();
147 if(!line.isValid())
148 break;
149 line.setLineWidth(rect.width());
150
151 if(y + 2 * lineSpacing <= rect.bottom() + 1)
152 {
153 line.draw(&p, QPointF{qreal(rect.left()), qreal(y)});
154 y += lineSpacing;
155 }
156 else
157 {
158 p.drawText(
159 QRect{rect.left(), y, rect.width(), lineSpacing}, Qt::AlignLeft | Qt::AlignTop,
160 fm.elidedText(text.mid(line.textStart()), Qt::ElideRight, rect.width()));
161 break;
162 }
163 }
164 layout.endLayout();
165}
166}
167
175class InteractiveLabel : public QWidget
176{
177 W_OBJECT(InteractiveLabel)
178
179public:
181 const QFont& font, const QString& text, const QString& url,
182 QWidget* parent = nullptr);
183
184 void setOpenExternalLink(bool val) { m_openExternalLink = val; }
185 void setPixmaps(const QPixmap& pixmap, const QPixmap& pixmapOn);
186
187 void disableInteractivity();
188 void setActiveColor(const QColor& c);
189 void setInactiveColor(const QColor& c);
190 void setElideMode(Qt::TextElideMode mode) { m_elideMode = mode; }
191 void setLeftPadding(int px) { m_leftPadding = px; }
193 void setIconColumn(int px) { m_iconColumn = px; }
194 void setItemHeight(int px);
195
196 void setCheckable(bool b) { m_checkable = b; }
197 void setCheckedBackground(const QColor& c) { m_checkedBackground = c; }
198 void setChecked(bool b);
199 bool isChecked() const noexcept { return m_checked; }
200
201 void setText(const QString& text);
202 const QString& text() const noexcept { return m_title; }
203 const QString& url() const noexcept { return m_url; }
204
205 void labelPressed(const QString& file) W_SIGNAL(labelPressed, file)
206 void hovered(bool state) W_SIGNAL(hovered, state)
207
208 QSize sizeHint() const override;
209 QSize minimumSizeHint() const override;
210
211protected:
212 void paintEvent(QPaintEvent* event) override;
213 void enterEvent(QEnterEvent* event) override;
214 void leaveEvent(QEvent* event) override;
215 void mousePressEvent(QMouseEvent* event) override;
216 void mouseReleaseEvent(QMouseEvent* event) override;
217
218private:
219 bool highlighted() const noexcept { return m_checked || (m_interactive && m_hovered); }
220 int iconWidth() const noexcept { return m_pixmap.isNull() ? 0 : m_iconColumn; }
221
222 QFont m_font;
223 QString m_title;
224 QString m_url;
225
226 QPixmap m_pixmap;
227 QPixmap m_pixmapOn;
228
229 QColor m_activeColor{StartScreenColors::Hover};
230 QColor m_inactiveColor{StartScreenColors::Text};
231 QColor m_checkedBackground;
232
233 Qt::TextElideMode m_elideMode{Qt::ElideRight};
234 int m_leftPadding{4};
235 int m_iconColumn{34};
236 int m_height{30};
237
238 bool m_openExternalLink{};
239 bool m_interactive{true};
240 bool m_hovered{};
241 bool m_pressed{};
242 bool m_checkable{};
243 bool m_checked{};
244};
245
246InteractiveLabel::InteractiveLabel(
247 const QFont& font, const QString& title, const QString& url, QWidget* parent)
248 : QWidget{parent}
249 , m_font(font)
250 , m_title(title)
251 , m_url(url)
252{
253 setCursor(score::Skin::instance().CursorPointingHand);
254 setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
255 setFixedHeight(m_height);
256}
257
258void InteractiveLabel::setPixmaps(const QPixmap& pixmap, const QPixmap& pixmapOn)
259{
260 m_pixmap = pixmap;
261 m_pixmapOn = pixmapOn;
262 // Never let a big icon overflow the label
263 const int h = std::max(
264 int(m_pixmap.height() / m_pixmap.devicePixelRatio()),
265 int(m_pixmapOn.height() / m_pixmapOn.devicePixelRatio()));
266 if(h + 4 > m_height)
267 setItemHeight(h + 4);
268 updateGeometry();
269}
270
271void InteractiveLabel::setItemHeight(int px)
272{
273 m_height = px;
274 setFixedHeight(px);
275 updateGeometry();
276}
277
278void InteractiveLabel::disableInteractivity()
279{
280 m_interactive = false;
281 setCursor(score::Skin::instance().CursorPointer);
282 update();
283}
284
285void InteractiveLabel::setActiveColor(const QColor& c)
286{
287 m_activeColor = c;
288 update();
289}
290
291void InteractiveLabel::setInactiveColor(const QColor& c)
292{
293 m_inactiveColor = c;
294 update();
295}
296
297void InteractiveLabel::setChecked(bool b)
298{
299 if(m_checked == b)
300 return;
301 m_checked = b;
302 update();
303}
304
305void InteractiveLabel::setText(const QString& text)
306{
307 m_title = text;
308 updateGeometry();
309 update();
310}
311
312QSize InteractiveLabel::sizeHint() const
313{
314 int w = m_leftPadding + iconWidth();
315 if(!m_title.isEmpty())
316 w += QFontMetrics{m_font}.horizontalAdvance(m_title) + 6;
317 return {w, m_height};
318}
319
320QSize InteractiveLabel::minimumSizeHint() const
321{
322 // Allow the layout to squeeze us: the text gets elided.
323 int w = m_leftPadding + iconWidth();
324 if(!m_title.isEmpty())
325 w += 40;
326 return {w, m_height};
327}
328
330static QPixmap tintedPixmap(const QPixmap& source, const QColor& color)
331{
332 if(source.isNull())
333 return source;
334 static std::map<std::pair<qint64, QRgb>, QPixmap> cache;
335 const auto key = std::make_pair(source.cacheKey(), color.rgba());
336 if(auto it = cache.find(key); it != cache.end())
337 return it->second;
338
339 QPixmap res{source.size()};
340 res.setDevicePixelRatio(source.devicePixelRatio());
341 res.fill(Qt::transparent);
342 {
343 QPainter p{&res};
344 p.drawPixmap(0, 0, source);
345 p.setCompositionMode(QPainter::CompositionMode_SourceIn);
346 p.fillRect(res.rect(), color);
347 }
348 return cache.emplace(key, res).first->second;
349}
350
351void InteractiveLabel::paintEvent(QPaintEvent* event)
352{
353 QPainter painter(this);
354 painter.setRenderHint(QPainter::Antialiasing, true);
355 painter.setRenderHint(QPainter::TextAntialiasing, true);
356 painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
357
358 const bool hl = highlighted();
359
360 if(m_checked && m_checkedBackground.isValid())
361 {
362 painter.fillRect(rect(), m_checkedBackground);
363 painter.fillRect(QRect{0, 0, 3, height()}, m_activeColor);
364 }
365
366 QRectF textRect = rect().adjusted(m_leftPadding, 0, -4, 0);
367
368 // The icon always takes the color of the text, whatever the state
369 const QColor color = hl ? m_activeColor : m_inactiveColor;
370 const QPixmap pm = tintedPixmap(m_pixmap, color);
371 if(!pm.isNull())
372 {
373 const qreal w = pm.width() / pm.devicePixelRatio();
374 const qreal h = pm.height() / pm.devicePixelRatio();
375 // Icons of different sizes share the same column, centered on the same axis
376 painter.drawPixmap(
377 QPointF{textRect.x() + (m_iconColumn - 8 - w) / 2., (height() - h) / 2.}, pm);
378 textRect.setX(textRect.x() + m_iconColumn);
379 }
380
381 if(!m_title.isEmpty())
382 {
383 painter.setPen(QPen{color});
384 painter.setFont(m_font);
385 const QString txt
386 = QFontMetrics{m_font}.elidedText(m_title, m_elideMode, int(textRect.width()));
387 painter.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, txt);
388 }
389}
390
391void InteractiveLabel::enterEvent(QEnterEvent* event)
392{
393 m_hovered = true;
394 if(m_interactive)
395 {
396 update();
397 hovered(true);
398 }
399}
400
401void InteractiveLabel::leaveEvent(QEvent* event)
402{
403 m_hovered = false;
404 m_pressed = false;
405 if(m_interactive)
406 {
407 update();
408 hovered(false);
409 }
410}
411
412void InteractiveLabel::mousePressEvent(QMouseEvent* event)
413{
414 if(!m_interactive || event->button() != Qt::LeftButton)
415 return QWidget::mousePressEvent(event);
416 m_pressed = true;
417 event->accept();
418}
419
420void InteractiveLabel::mouseReleaseEvent(QMouseEvent* event)
421{
422 if(!m_pressed || event->button() != Qt::LeftButton)
423 return QWidget::mouseReleaseEvent(event);
424 m_pressed = false;
425 event->accept();
426
427 if(!rect().contains(event->pos()))
428 return;
429
430 if(m_checkable)
431 setChecked(true);
432
433 if(m_openExternalLink)
434 QDesktopServices::openUrl(QUrl(m_url));
435 else
436 labelPressed(m_url);
437}
438
442class ThumbnailPopup final : public QWidget
443{
444public:
445 static constexpr int Width = 264;
446 static constexpr int ThumbHeight = 158;
447 static constexpr int Margin = 6;
448 static constexpr int DescriptionLines = 2;
449
450 ThumbnailPopup(const QFont& titleFont, const QFont& subFont, QWidget* parent)
451 : QWidget{parent}
452 , m_titleFont{titleFont}
453 , m_subFont{subFont}
454 {
455 setAttribute(Qt::WA_TransparentForMouseEvents);
456 hide();
457 }
458
459 void setContent(
460 const QImage& thumbnail, const QString& title, const QString& author,
461 const QString& description, const QString& path)
462 {
463 m_thumbnail = coverPixmap(thumbnail, {Width - 2 * Margin, ThumbHeight});
464 m_title = title;
465 m_author = author;
466 m_description = description.simplified();
467 m_path = path;
468
469 const QFontMetrics tm{m_titleFont}, sm{m_subFont};
470 int h = 2 * Margin + tm.height();
471 if(!m_thumbnail.isNull())
472 h += ThumbHeight + Margin;
473 if(!m_author.isEmpty())
474 h += sm.height();
475 if(!m_description.isEmpty())
476 h += 4 + DescriptionLines * sm.lineSpacing();
477 if(!m_path.isEmpty())
478 h += 4 + sm.height();
479 resize(Width, h);
480 update();
481 }
482
484 void showFor(QWidget* anchor, const QRect& bounds)
485 {
486 const QRect a{anchor->mapTo(parentWidget(), QPoint{0, 0}), anchor->size()};
487 int x = a.left() - width() - 12;
488 if(x < bounds.left())
489 x = a.right() + 12;
490 if(x + width() > bounds.right())
491 x = bounds.right() - width();
492 int y = a.center().y() - height() / 2;
493 y = std::clamp(y, bounds.top(), std::max(bounds.top(), bounds.bottom() - height()));
494 move(x, y);
495 raise();
496 show();
497 }
498
499protected:
500 void paintEvent(QPaintEvent*) override
501 {
502 QPainter p{this};
503 p.setRenderHint(QPainter::Antialiasing, true);
504 p.setRenderHint(QPainter::SmoothPixmapTransform, true);
505
506 QPainterPath path;
507 path.addRoundedRect(QRectF{rect()}.adjusted(0.5, 0.5, -0.5, -0.5), 4, 4);
508 p.fillPath(path, StartScreenColors::CardDark);
509 p.setPen(QPen{StartScreenColors::Outline});
510 p.drawPath(path);
511
512 const int textX = Margin + 2;
513 const int textW = width() - 2 * Margin - 4;
514 const QFontMetrics tm{m_titleFont}, sm{m_subFont};
515 int y = Margin;
516 if(!m_thumbnail.isNull())
517 {
518 p.drawPixmap(Margin, y, m_thumbnail);
519 y += ThumbHeight + Margin;
520 }
521
522 p.setPen(StartScreenColors::Text);
523 p.setFont(m_titleFont);
524 p.drawText(
525 QRect{textX, y, textW, tm.height()}, Qt::AlignLeft | Qt::AlignVCenter,
526 tm.elidedText(m_title, Qt::ElideMiddle, textW));
527 y += tm.height();
528
529 p.setFont(m_subFont);
530 if(!m_author.isEmpty())
531 {
532 p.setPen(StartScreenColors::Hover);
533 p.drawText(
534 QRect{textX, y, textW, sm.height()}, Qt::AlignLeft | Qt::AlignVCenter,
535 sm.elidedText(m_author, Qt::ElideRight, textW));
536 y += sm.height();
537 }
538 if(!m_description.isEmpty())
539 {
540 y += 4;
541 p.setPen(StartScreenColors::Text);
542 const QRect descRect{textX, y, textW, DescriptionLines * sm.lineSpacing()};
543 p.save();
544 p.setClipRect(descRect);
545 p.drawText(
546 descRect, Qt::AlignLeft | Qt::AlignTop | Qt::TextWordWrap, m_description);
547 p.restore();
548 y += descRect.height();
549 }
550 if(!m_path.isEmpty())
551 {
552 y += 4;
553 p.setPen(StartScreenColors::Muted);
554 p.drawText(
555 QRect{textX, y, textW, sm.height()}, Qt::AlignLeft | Qt::AlignVCenter,
556 sm.elidedText(m_path, Qt::ElideMiddle, textW));
557 }
558 }
559
560private:
561 QFont m_titleFont;
562 QFont m_subFont;
563 QPixmap m_thumbnail;
564 QString m_title;
565 QString m_author;
566 QString m_description;
567 QString m_path;
568};
569
579class CategoryDrawer final : public QWidget
580{
581public:
583 const QFont& font, const QString& title, int count, int depth, QWidget* parent)
584 : QWidget{parent}
585 , m_font{font}
586 , m_title{title}
587 , m_count{count}
588 , m_depth{depth}
589 {
590 m_font.setCapitalization(depth == 0 ? QFont::AllUppercase : QFont::MixedCase);
591 m_font.setBold(true);
592
593 auto lay = new QVBoxLayout{this};
594 lay->setContentsMargins(depth > 0 ? Step : 0, 0, 0, 0);
595 lay->setSpacing(Gap);
596
597 m_header = new QWidget{this};
598 m_header->setFixedHeight(BandHeight);
599 m_header->setCursor(score::Skin::instance().CursorPointingHand);
600 m_header->installEventFilter(this);
601 lay->addWidget(m_header);
602
603 m_body = new QWidget{this};
604 m_bodyLayout = new QVBoxLayout{m_body};
605 m_bodyLayout->setContentsMargins(0, 0, 0, 0);
606 m_bodyLayout->setSpacing(Gap);
607 lay->addWidget(m_body);
608 }
609
610 QVBoxLayout* body() const noexcept { return m_bodyLayout; }
611 QWidget* header() const noexcept { return m_header; }
612
613 bool expanded() const noexcept { return m_expanded; }
614 void setExpanded(bool e)
615 {
616 m_expanded = e;
617 m_body->setVisible(e);
618 m_header->update();
619 }
620
621 void setCount(int c)
622 {
623 m_count = c;
624 m_header->update();
625 }
626
627 static constexpr int BandHeight = 24;
629 static constexpr int Gap = 8;
631 static constexpr int Step = 16;
632
633protected:
634 bool eventFilter(QObject* obj, QEvent* ev) override
635 {
636 if(obj != m_header)
637 return QWidget::eventFilter(obj, ev);
638
639 switch(ev->type())
640 {
641 case QEvent::Paint: {
642 QPainter p{m_header};
643 p.setRenderHint(QPainter::Antialiasing, true);
644 const int w = m_header->width();
645 const int h = m_header->height();
646
647 p.setPen(Qt::NoPen);
648 p.setBrush(m_depth == 0 ? StartScreenColors::Outline : StartScreenColors::Card);
649 p.drawRoundedRect(QRectF{0, 0, qreal(w), qreal(h)}, 3, 3);
650 if(m_depth == 0)
651 {
652 p.setBrush(StartScreenColors::Accent);
653 p.drawRoundedRect(QRectF{0, 0, 3, qreal(h)}, 1.5, 1.5);
654 }
655
656 // A subsection's triangle sits under its section's title.
657 const qreal cy = h / 2.;
658 constexpr qreal tx = TextX - Step;
659 QPainterPath tri;
660 if(m_expanded)
661 {
662 tri.moveTo(tx - 5, cy - 3);
663 tri.lineTo(tx + 5, cy - 3);
664 tri.lineTo(tx, cy + 3);
665 }
666 else
667 {
668 tri.moveTo(tx - 3, cy - 5);
669 tri.lineTo(tx + 3, cy);
670 tri.lineTo(tx - 3, cy + 5);
671 }
672 tri.closeSubpath();
673 p.fillPath(
674 tri, m_hovered ? StartScreenColors::Hover : StartScreenColors::Muted);
675
676 // Capitals and digits centred on the band.
677 const QFontMetrics fm{m_font};
678 const int baseline = (h + fm.capHeight()) / 2;
679 const QString count = QString::number(m_count);
680 const int countWidth = fm.horizontalAdvance(count);
681 const QString title = fm.elidedText(
682 m_title, Qt::ElideRight, std::max(0, w - TextX - CountGap - countWidth - 8));
683
684 p.setFont(m_font);
685 p.setPen(m_hovered ? StartScreenColors::Hover : StartScreenColors::Text);
686 p.drawText(QPointF(TextX, baseline), title);
687
688 QFont countFont = m_font;
689 countFont.setBold(false);
690 p.setFont(countFont);
691 p.setPen(StartScreenColors::Muted);
692 p.drawText(
693 QPointF(TextX + fm.horizontalAdvance(title) + CountGap, baseline), count);
694 return true;
695 }
696 case QEvent::Enter:
697 m_hovered = true;
698 m_header->update();
699 return false;
700 case QEvent::Leave:
701 m_hovered = false;
702 m_header->update();
703 return false;
704 // On press: a plain QWidget ignores it, and the release goes to the parent.
705 case QEvent::MouseButtonPress:
706 setExpanded(!m_expanded);
707 return true;
708 default:
709 return QWidget::eventFilter(obj, ev);
710 }
711 }
712
713private:
715 static constexpr int TextX = 26;
716 static constexpr int CountGap = 8;
717
718 QFont m_font;
719 QString m_title;
720 int m_count{};
721 int m_depth{};
722 QWidget* m_header{};
723 QWidget* m_body{};
724 QVBoxLayout* m_bodyLayout{};
725 bool m_expanded{true};
726 bool m_hovered{};
727};
728
729class ExampleCard final : public QWidget
730{
731public:
732 static constexpr int Width = 190;
733 static constexpr int ThumbHeight = 119;
734 static constexpr int Height = ThumbHeight + 66;
735
737 const QFont& titleFont, const QFont& subFont, const QString& title,
738 const QString& subtitle, const QString& path, QWidget* parent)
739 : QWidget{parent}
740 , m_titleFont{titleFont}
741 , m_subFont{subFont}
742 , m_title{title}
743 , m_subtitle{subtitle}
744 , m_path{path}
745 {
746 setFixedSize(Width, Height);
747 setCursor(score::Skin::instance().CursorPointingHand);
748 setToolTip(QDir::toNativeSeparators(path));
749 }
750
751 const QString& path() const noexcept { return m_path; }
752
753 void setDownloadSize(qint64 bytes)
754 {
755 setBadge(
756 bytes > 0 ? QLocale{}.formattedDataSize(bytes) : QString{},
757 StartScreenColors::Muted);
758 }
759
761 void setBadge(const QString& text, const QColor& color)
762 {
763 m_badge = text;
764 m_badgeColor = color;
765 update();
766 }
767 const QString& badge() const noexcept { return m_badge; }
768
769 void setThumbnailImage(const QImage& img)
770 {
771 m_thumbnail = coverPixmap(img, {Width, ThumbHeight});
772 update();
773 }
774
775 void setInfo(const ProjectInfo::Info& info)
776 {
777 if(!info.name.isEmpty())
778 m_title = info.name;
779 if(!info.author.isEmpty())
780 m_subtitle = info.author;
781 m_description = info.description.simplified();
782 m_thumbnail = coverPixmap(info.thumbnail, {Width, ThumbHeight});
783 QString tip = m_title;
784 if(!info.author.isEmpty())
785 tip += "\n" + info.author;
786 if(!info.description.isEmpty())
787 tip += "\n\n" + info.description;
788 tip += "\n\n" + QDir::toNativeSeparators(m_path);
789 setToolTip(tip);
790 update();
791 }
792
794 void setKeywords(const QString& k) { m_keywords = k; }
795
797 bool matches(const QStringList& words) const
798 {
799 for(const auto& w : words)
800 {
801 if(!m_title.contains(w, Qt::CaseInsensitive)
802 && !m_subtitle.contains(w, Qt::CaseInsensitive)
803 && !m_description.contains(w, Qt::CaseInsensitive)
804 && !m_keywords.contains(w, Qt::CaseInsensitive)
805 && !QFileInfo{m_path}.fileName().contains(w, Qt::CaseInsensitive))
806 return false;
807 }
808 return true;
809 }
810
812 std::function<void(const QString& path, Qt::KeyboardModifiers mods)> onActivated;
814 std::function<void(const QPoint& globalPos)> onContextMenu;
815
816protected:
817 void paintEvent(QPaintEvent*) override
818 {
819 QPainter p{this};
820 p.setRenderHint(QPainter::Antialiasing, true);
821 p.setRenderHint(QPainter::SmoothPixmapTransform, true);
822
823 QPainterPath path;
824 path.addRoundedRect(QRectF{rect()}.adjusted(0.5, 0.5, -0.5, -0.5), 4, 4);
825 p.fillPath(path, StartScreenColors::Card);
826
827 // Thumbnail area
828 p.save();
829 p.setClipPath(path);
830 const QRect thumbRect{0, 0, Width, ThumbHeight};
831 if(!m_thumbnail.isNull())
832 {
833 p.drawPixmap(thumbRect, m_thumbnail);
834 }
835 else
836 {
837 p.fillRect(thumbRect, StartScreenColors::CardDark);
838 static const QPixmap placeholder
839 = score::get_pixmap(":/icons/load_examples_off.png");
840 const qreal w = placeholder.width() / placeholder.devicePixelRatio();
841 const qreal h = placeholder.height() / placeholder.devicePixelRatio();
842 p.setOpacity(0.35);
843 p.drawPixmap(QPointF{(Width - w) / 2., (ThumbHeight - h) / 2.}, placeholder);
844 p.setOpacity(1.);
845 }
846
847 if(!m_badge.isEmpty() && !m_hovered)
848 {
849 p.setFont(m_subFont);
850 const QFontMetrics fm{m_subFont};
851 const int w = fm.horizontalAdvance(m_badge) + 10;
852 const QRect box{Width - w - 6, 6, w, fm.height() + 4};
853 p.fillRect(box, StartScreenColors::Scrim);
854 p.setPen(m_badgeColor);
855 p.drawText(box, Qt::AlignCenter, m_badge);
856 }
857
858 if(m_hovered && !m_description.isEmpty())
859 {
860 p.fillRect(thumbRect, StartScreenColors::Scrim);
861 p.setPen(StartScreenColors::Text);
862 p.setFont(m_subFont);
863 drawWrappedText(p, thumbRect.adjusted(8, 7, -8, -7), m_description);
864 }
865 p.restore();
866
867 p.setPen(QPen{m_hovered ? StartScreenColors::Hover : StartScreenColors::Outline, 1});
868 p.drawPath(path);
869
870 const int textX = 8;
871 const int textW = Width - 16;
872 p.setPen(m_hovered ? StartScreenColors::Hover : StartScreenColors::Text);
873 p.setFont(m_titleFont);
874 const QFontMetrics tm{m_titleFont};
875 p.drawText(
876 QRect{textX, ThumbHeight + 6, textW, tm.height()},
877 Qt::AlignLeft | Qt::AlignVCenter, tm.elidedText(m_title, Qt::ElideRight, textW));
878
879 p.setPen(StartScreenColors::Muted);
880 p.setFont(m_subFont);
881 const QFontMetrics sm{m_subFont};
882 p.drawText(
883 QRect{textX, ThumbHeight + 6 + tm.height(), textW, sm.height()},
884 Qt::AlignLeft | Qt::AlignVCenter,
885 sm.elidedText(m_subtitle, Qt::ElideRight, textW));
886
887 // Full path, so that the user knows which file is going to be opened
888 p.setOpacity(0.7);
889 p.drawText(
890 QRect{textX, ThumbHeight + 8 + tm.height() + sm.height(), textW, sm.height()},
891 Qt::AlignLeft | Qt::AlignVCenter,
892 sm.elidedText(QDir::toNativeSeparators(m_path), Qt::ElideMiddle, textW));
893 p.setOpacity(1.);
894 }
895
896 void enterEvent(QEnterEvent*) override
897 {
898 m_hovered = true;
899 update();
900 }
901 void leaveEvent(QEvent*) override
902 {
903 m_hovered = false;
904 m_pressed = false;
905 update();
906 }
907 void mousePressEvent(QMouseEvent* e) override
908 {
909 if(e->button() == Qt::LeftButton)
910 {
911 m_pressed = true;
912 e->accept();
913 }
914 }
915 void mouseReleaseEvent(QMouseEvent* e) override
916 {
917 const bool activate
918 = m_pressed && e->button() == Qt::LeftButton && rect().contains(e->pos());
919 m_pressed = false;
920 if(activate && onActivated)
921 onActivated(m_path, e->modifiers());
922 }
923 void contextMenuEvent(QContextMenuEvent* e) override
924 {
925 if(!onContextMenu)
926 return QWidget::contextMenuEvent(e);
927 e->accept();
928 onContextMenu(e->globalPos());
929 }
930
931private:
932 QFont m_titleFont;
933 QFont m_subFont;
934 QString m_title;
935 QString m_subtitle;
936 QString m_description;
937 QString m_keywords;
938 QString m_badge;
939 QColor m_badgeColor{StartScreenColors::Muted};
940 QString m_path;
941 QPixmap m_thumbnail;
942 bool m_hovered{};
943 bool m_pressed{};
944};
945
948class FilterEdit final : public QLineEdit
949{
950public:
951 using QLineEdit::QLineEdit;
952
954 QStringList words() const { return text().simplified().split(' ', Qt::SkipEmptyParts); }
955
956protected:
957 void keyPressEvent(QKeyEvent* e) override
958 {
959 if(e->key() == Qt::Key_Escape && !text().isEmpty())
960 {
961 clear();
962 e->accept();
963 return;
964 }
965 QLineEdit::keyPressEvent(e);
966 if(e->key() == Qt::Key_Up || e->key() == Qt::Key_Down)
967 e->accept();
968 }
969};
970
998class CardFlowLayout final : public QLayout
999{
1000public:
1001 explicit CardFlowLayout(QWidget* parent, int spacing)
1002 : QLayout{parent}
1003 , m_spacing{spacing}
1004 {
1005 setContentsMargins(0, 0, 0, 0);
1006 }
1007 ~CardFlowLayout() override
1008 {
1009 while(auto item = takeAt(0))
1010 delete item;
1011 }
1012
1013 void addItem(QLayoutItem* item) override { m_items.push_back(item); }
1014 int count() const override { return int(m_items.size()); }
1015 QLayoutItem* itemAt(int i) const override
1016 {
1017 return i >= 0 && i < count() ? m_items[i] : nullptr;
1018 }
1019 QLayoutItem* takeAt(int i) override
1020 {
1021 if(i < 0 || i >= count())
1022 return nullptr;
1023 auto item = m_items[i];
1024 m_items.erase(m_items.begin() + i);
1025 return item;
1026 }
1027
1028 Qt::Orientations expandingDirections() const override { return {}; }
1029 bool hasHeightForWidth() const override { return true; }
1030 int heightForWidth(int width) const override
1031 {
1032 return arrange(QRect{0, 0, width, 0}, false);
1033 }
1034 void setGeometry(const QRect& rect) override
1035 {
1036 QLayout::setGeometry(rect);
1037 arrange(rect, true);
1038 }
1039 QSize sizeHint() const override { return minimumSize(); }
1040 QSize minimumSize() const override
1041 {
1042 QSize size;
1043 for(auto* item : m_items)
1044 size = size.expandedTo(item->minimumSize());
1045 const auto m = contentsMargins();
1046 return size + QSize{m.left() + m.right(), m.top() + m.bottom()};
1047 }
1048
1049private:
1051 int arrange(const QRect& rect, bool apply) const
1052 {
1053 const auto m = contentsMargins();
1054 const QRect area = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom());
1055 int x = area.x();
1056 int y = area.y();
1057 int rowHeight = 0;
1058 for(auto* item : m_items)
1059 {
1060 if(item->isEmpty())
1061 continue;
1062 const QSize size = item->sizeHint();
1063 if(x > area.x() && x + size.width() > area.x() + area.width())
1064 {
1065 x = area.x();
1066 y += rowHeight + m_spacing;
1067 rowHeight = 0;
1068 }
1069 if(apply)
1070 item->setGeometry(QRect{QPoint{x, y}, size});
1071 x += size.width() + m_spacing;
1072 rowHeight = std::max(rowHeight, size.height());
1073 }
1074 return y + rowHeight - rect.y() + m.bottom();
1075 }
1076
1077 std::vector<QLayoutItem*> m_items;
1078 int m_spacing{};
1079};
1080
1081class StartScreen : public QWidget
1082{
1083 W_OBJECT(StartScreen)
1084public:
1085 StartScreen(const QPointer<QRecentFilesMenu>& recentFiles, QWidget* parent = nullptr);
1086 ~StartScreen() override;
1087
1088 void openNewDocument() W_SIGNAL(openNewDocument)
1089 void openFile(const QString& file) W_SIGNAL(openFile, file)
1091 void openTemplate(const QString& file) W_SIGNAL(openTemplate, file)
1092 void openFileDialog() W_SIGNAL(openFileDialog)
1093 void loadCrashedSession() W_SIGNAL(loadCrashedSession)
1095 void joinSession() W_SIGNAL(joinSession)
1096 void exitApp() W_SIGNAL(exitApp)
1097
1099 void addLoadCrashedSession();
1101 void addJoinSession();
1103 void dismiss();
1105 void reopen();
1106
1107 static constexpr int Width = 880;
1108 static constexpr int Height = 640;
1109 static constexpr int HeaderHeight = 220;
1110 static constexpr int NavWidth = 190;
1111 static constexpr int MaxRecentFiles = 8;
1112
1113protected:
1114 void paintEvent(QPaintEvent* event) override;
1115 bool eventFilter(QObject* watched, QEvent* event) override;
1116 void keyPressEvent(QKeyEvent* event) override;
1117 void closeEvent(QCloseEvent* event) override;
1118 void showEvent(QShowEvent* event) override;
1119 void hideEvent(QHideEvent* event) override;
1120
1121private:
1122 struct Link
1123 {
1124 QString text;
1125 QString url;
1126 QString icon; // base name in :/icons, without the _on / _off suffix
1127 QString tooltip;
1128 };
1129
1130 QWidget* createHeader();
1131 QWidget* createNavigation();
1134 int addPage(const QString& name, const QString& icon, std::function<QWidget*()> make);
1135 QWidget* createHomePage(const QPointer<QRecentFilesMenu>& recentFiles);
1137 void refreshRecentFiles();
1138 QWidget* createTemplatesPage();
1139 QWidget* createAboutPage();
1141 QWidget* createExamplesPage(bool online);
1143 QWidget* createCardsPage(
1144 const QString& title, const QString& hint, const QString& emptyHint,
1145 const std::vector<DocumentTemplate>& docs,
1146 std::function<void(const QString&)> onActivated, const Link& more);
1147 QWidget* createLinksPage(const QString& title, const std::vector<Link>& links);
1148 struct ExampleEntry
1149 {
1150 QString name;
1151 QString category;
1152 QString path;
1153 const OnlineExample* online{};
1154 };
1155
1157 QWidget* makeCardGrid(
1158 const std::vector<ExampleEntry>& docs, std::vector<ExampleCard*>& cards,
1159 QWidget* parent);
1160 std::vector<ExampleEntry> gatherExamples() const;
1163 void openOnlineExample(const OnlineExample& ex, bool direct, bool openPage);
1164 void showOnlineExampleMenu(const OnlineExample& ex, const QPoint& globalPos);
1166 void replaceLocalCopy(const OnlineExample& ex);
1168 std::set<QString> m_replaceAsked;
1169 void refreshExamplesPage();
1170 void refreshExamplesPage(int index, bool online);
1171 void requestThumbnail(const OnlineExample& ex, ExampleCard* card);
1172
1173 int addPage(const QString& name, const QString& icon, QWidget* page);
1174 void setCurrentPage(int index);
1175
1176 QLabel* makeSectionTitle(const QString& text, QWidget* parent);
1177 QLabel* makeHint(const QString& text, QWidget* parent);
1179 FilterEdit* makeTitleWithFilter(const QString& title, QVBoxLayout* lay, QWidget* page);
1180 InteractiveLabel* makeItem(
1181 const QString& text, const QString& icon, const QString& url, QWidget* parent);
1183 InteractiveLabel* makeAccentItem(
1184 const QString& text, const QString& icon, const QString& url, QWidget* parent);
1185 InteractiveLabel* makeExternalLink(const Link& link, QWidget* parent);
1187 InteractiveLabel* makeScoreItem(
1188 const QString& text, const QString& icon, const QString& path, bool asTemplate,
1189 QWidget* parent);
1190
1191 // Wraps the emission of the choice signals: once the user chose something,
1192 // closing the window must not additionally open a new document.
1193 template <typename F>
1194 void choose(F&& emitSignal)
1195 {
1196 if(m_actionTaken)
1197 return;
1198 m_actionTaken = true;
1199 emitSignal();
1200 }
1201
1203 void openExample(const QString& path, bool direct, bool openPage);
1204 void requestInfo(const QString& path);
1206 void requestInfo(const QString& path, ExampleCard& card);
1207 void onInfoLoaded(const QString& path, const std::optional<ProjectInfo::Info>& info);
1208 void showPreview(InteractiveLabel* label);
1209
1210 void checkForNewVersion();
1211 void showUpdateAvailable(const QString& version);
1212
1216 QWidget*
1217 makeLibraryNotice(QWidget* parent, const QMargins& margins, bool linksToTemplates);
1218 void updateLibraryNotices();
1219 void onLibraryChanged();
1220 void rebuildTemplatesPage();
1221
1222 struct LibraryNotice
1223 {
1224 QPointer<QWidget> box;
1225 QPointer<QLabel> hint;
1226 QPointer<InteractiveLabel> action;
1227 bool linksToTemplates{};
1228 };
1229 std::vector<LibraryNotice> m_libraryNotices;
1232 bool m_libraryInstalled{};
1233 bool m_libraryViewRegistered{};
1234
1235 QFont m_navFont;
1236 QFont m_sectionFont;
1237 QFont m_itemFont;
1238 QFont m_smallFont;
1239 QFont m_versionFont;
1240
1241 QPixmap m_background;
1242 QPixmap m_logo;
1243
1244 QStackedWidget* m_pages{};
1245 std::vector<InteractiveLabel*> m_navItems;
1246 std::vector<std::function<QWidget*()>> m_pageFactories;
1247 QVBoxLayout* m_navLayout{};
1248
1249 InteractiveLabel* m_updateLabel{};
1250 InteractiveLabel* m_crashLabel{};
1251 InteractiveLabel* m_joinLabel{};
1252 QPointer<QRecentFilesMenu> m_recentFiles;
1253 QWidget* m_recentPage{};
1254 QVBoxLayout* m_recentList{};
1255 int m_templatesPage{};
1256 int m_examplesPage{};
1257 int m_onlineExamplesPage{};
1258 OnlineExamples m_online;
1259 ThumbnailPopup* m_preview{};
1260
1261 std::map<QString, std::optional<ProjectInfo::Info>> m_infos;
1262 std::vector<ExampleCard*> m_cards;
1263
1264 bool m_firstRun{};
1265 bool m_actionTaken{};
1266};
1267
1268StartScreen::StartScreen(const QPointer<QRecentFilesMenu>& recentFiles, QWidget* parent)
1269 : QWidget(parent)
1270{
1271 auto& skin = score::Skin::instance();
1272 setCursor(skin.CursorPointer);
1273 setFocusPolicy(Qt::StrongFocus);
1274 setMinimumSize(Width, Height);
1275 // Covers the whole main window, and follows its size.
1276 if(parent)
1277 {
1278 parent->installEventFilter(this);
1279 setGeometry(parent->rect());
1280 }
1281
1282 {
1283 QSettings s;
1284 m_firstRun = !s.value("score/StartScreenSeen", false).toBool();
1285 s.setValue("score/StartScreenSeen", true);
1286 }
1287
1288 m_libraryState = DefaultLibrary::instance().state();
1289 connect(
1290 &DefaultLibrary::instance(), &DefaultLibrary::changed, this,
1291 &StartScreen::onLibraryChanged);
1292
1293 // px, not pt: pt would shrink on macOS' 72 DPI.
1294 m_navFont = QFont("Montserrat");
1295 m_navFont.setPixelSize(16);
1296 m_navFont.setWeight(QFont::DemiBold);
1297 m_sectionFont = QFont("Montserrat");
1298 m_sectionFont.setPixelSize(13);
1299 m_sectionFont.setWeight(QFont::Medium);
1300 m_sectionFont.setCapitalization(QFont::AllUppercase);
1301 m_sectionFont.setLetterSpacing(QFont::PercentageSpacing, 108);
1302 m_itemFont = QFont("Ubuntu");
1303 m_itemFont.setPixelSize(16);
1304 m_itemFont.setHintingPreference(QFont::HintingPreference::PreferFullHinting);
1305 m_itemFont.setStyleStrategy(QFont::PreferAntialias);
1306 m_smallFont = QFont("Ubuntu");
1307 m_smallFont.setPixelSize(13);
1308 m_smallFont.setHintingPreference(QFont::HintingPreference::PreferFullHinting);
1309 m_smallFont.setStyleStrategy(QFont::PreferAntialias);
1310 m_versionFont = QFont("Ubuntu");
1311 m_versionFont.setPixelSize(19);
1312 m_versionFont.setWeight(QFont::Light);
1313 m_versionFont.setHintingPreference(QFont::HintingPreference::PreferFullHinting);
1314 m_versionFont.setStyleStrategy(QFont::PreferAntialias);
1315
1316 m_background = score::get_pixmap(":/startscreen/startscreen-background.png");
1317 m_logo = score::get_pixmap(":/startscreen/startscreen-logo.png");
1318
1319 auto mainLayout = new QVBoxLayout{this};
1320 mainLayout->setContentsMargins(0, 0, 0, 0);
1321 mainLayout->setSpacing(0);
1322
1323 mainLayout->addWidget(createHeader());
1324
1325 auto body = new QHBoxLayout;
1326 body->setContentsMargins(0, 0, 0, 0);
1327 body->setSpacing(0);
1328
1329 m_pages = new QStackedWidget{this};
1330 body->addWidget(createNavigation());
1331 body->addWidget(m_pages, 1);
1332 mainLayout->addLayout(body, 1);
1333
1334 addPage(tr("Home"), "home", createHomePage(recentFiles));
1335 m_templatesPage
1336 = addPage(tr("Templates"), "new_file", [this] { return createTemplatesPage(); });
1337 m_examplesPage = addPage(
1338 tr("Local examples"), "load_examples",
1339 [this] { return createExamplesPage(false); });
1340 m_onlineExamplesPage = addPage(
1341 tr("Online examples"), "net_session",
1342 [this] { return createExamplesPage(true); });
1343
1344 m_online.loadCache();
1345 connect(&m_online, &OnlineExamples::updated, this, [this] {
1346 refreshExamplesPage();
1347 });
1348 m_online.refresh();
1349 addPage(
1350 tr("Learn"), "learn",
1351 createLinksPage(
1352 tr("Learn ossia score"),
1353 {{tr("Quick start guide"), "https://ossia.io/score-docs/quick-start.html",
1354 "version", tr("The recommended starting point if you are new to score")},
1355 {tr("Video tutorials"),
1356 "https://www.youtube.com/"
1357 "watch?v=R-3d8K6gQkw&list=PLIHLSiZpIa6YoY1_aW1yetDgZ7tZcxfEC",
1358 "tutorials",
1359 {}},
1360 {tr("Documentation"), "https://ossia.io/score-docs/", "learn", {}},
1361 {tr("Process reference"), "https://ossia.io/score-docs/processes.html", "learn", {}},
1362 {tr("Device reference"), "https://ossia.io/score-docs/devices.html", "learn", {}},
1363 {tr("Web version"), "https://ossia.io/score-web/", "tutorials", {}},
1364 }));
1365 addPage(
1366 tr("Community"), "community",
1367 createLinksPage(
1368 tr("Get involved"),
1369 {{tr("GitHub Discussions"), "https://github.com/ossia/score/discussions",
1370 "forum", tr("Ask questions and share what you do with score")},
1371 {tr("Discord Chat"), "https://discord.gg/8Hzm4UduaS", "chat", {}},
1372 {tr("Report a bug or suggest a feature"),
1373 "https://github.com/ossia/score/issues",
1374 "new_file",
1375 {}},
1376 {tr("Support ossia score through the Suite SAT"), "https://suite.sat.qc.ca/en",
1377 "contribute",
1378 tr("ossia score is free software: donations fund its development")},
1379 {tr("Donate on Open Collective"), "https://opencollective.com/ossia",
1380 "contribute", tr("ossia score is free software: donations fund its development")},
1381 {tr("Contributing"), "https://ossia.io/score-docs/development", "contribute",
1382 tr("Come implement your dream feature!")},
1383 }));
1384 addPage(tr("About"), "about", [this] { return createAboutPage(); });
1385
1386 m_navLayout->addStretch();
1387
1388#if !defined(__EMSCRIPTEN__)
1389 auto exitLabel = makeItem(tr("Exit"), "exit", "", this);
1390 exitLabel->setLeftPadding(20);
1391 exitLabel->setItemHeight(40);
1392 connect(exitLabel, &InteractiveLabel::labelPressed, this, [this] {
1393 choose([this] { exitApp(); });
1394 });
1395 m_navLayout->addWidget(exitLabel);
1396#endif
1397
1398 // Created last so that it is above every page
1399 m_preview = new ThumbnailPopup{m_itemFont, m_smallFont, this};
1400
1401 setCurrentPage(0);
1402 updateLibraryNotices();
1403
1404 checkForNewVersion();
1405}
1406
1407StartScreen::~StartScreen()
1408{
1409 if(std::exchange(m_libraryViewRegistered, false))
1410 DefaultLibrary::instance().removeView();
1411}
1412
1413QWidget* StartScreen::createHeader()
1414{
1415 // The artwork itself is painted in paintEvent; this widget only hosts the
1416 // controls that sit on top of it.
1417 auto header = new QWidget{this};
1418 header->setFixedHeight(HeaderHeight);
1419 auto lay = new QHBoxLayout{header};
1420 lay->setContentsMargins(0, 10, 10, 0);
1421 lay->setSpacing(12);
1422 lay->addStretch();
1423
1424 m_updateLabel = new InteractiveLabel{
1425 m_navFont, {}, "https://github.com/ossia/score/releases/latest/", header};
1426 m_updateLabel->setOpenExternalLink(true);
1427 m_updateLabel->setPixmaps(
1428 score::get_pixmap(":/icons/version_off.png"),
1429 score::get_pixmap(":/icons/version_on.png"));
1430 m_updateLabel->setInactiveColor(StartScreenColors::Accent);
1431 m_updateLabel->hide();
1432 lay->addWidget(m_updateLabel, 0, Qt::AlignTop);
1433
1434 auto closeLabel = new InteractiveLabel{m_navFont, {}, "", header};
1435 closeLabel->setPixmaps(
1436 score::get_pixmap(":/icons/close_window_off.png"),
1437 score::get_pixmap(":/icons/close_window_on.png"));
1438 closeLabel->setLeftPadding(0);
1439 closeLabel->setIconColumn(32);
1440 closeLabel->setFixedWidth(32);
1441 closeLabel->setToolTip(tr("Close this window and start with an empty score"));
1442 connect(closeLabel, &InteractiveLabel::labelPressed, this, [this] {
1443 choose([this] { openNewDocument(); });
1444 });
1445 lay->addWidget(closeLabel, 0, Qt::AlignTop);
1446
1447 return header;
1448}
1449
1450QWidget* StartScreen::createNavigation()
1451{
1452 auto nav = new QWidget{this};
1453 nav->setFixedWidth(NavWidth);
1454 m_navLayout = new QVBoxLayout{nav};
1455 // The first entry starts exactly where the page panel starts
1456 m_navLayout->setContentsMargins(0, 0, 0, 16);
1457 m_navLayout->setSpacing(4);
1458 return nav;
1459}
1460
1461int StartScreen::addPage(const QString& name, const QString& icon, QWidget* page)
1462{
1463 const int index = addPage(name, icon, std::function<QWidget*()>{});
1464 m_pages->widget(index)->layout()->addWidget(page);
1465 return index;
1466}
1467
1468int StartScreen::addPage(
1469 const QString& name, const QString& icon, std::function<QWidget*()> make)
1470{
1471 // A host widget per page; the content is added to it now or on first display
1472 auto host = new QWidget;
1473 auto hostLayout = new QVBoxLayout{host};
1474 hostLayout->setContentsMargins(0, 0, 0, 0);
1475 const int index = m_pages->addWidget(host);
1476 m_pageFactories.resize(index + 1);
1477 m_pageFactories[index] = std::move(make);
1478
1479 auto item = makeItem(name, icon, "", this);
1480 item->setCheckable(true);
1481 item->setCheckedBackground(StartScreenColors::Panel);
1482 item->setLeftPadding(20);
1483 item->setItemHeight(44);
1484 connect(item, &InteractiveLabel::labelPressed, this, [this, index] {
1485 setCurrentPage(index);
1486 });
1487
1488 m_navItems.push_back(item);
1489 m_navLayout->addWidget(item);
1490 return index;
1491}
1492
1493void StartScreen::setCurrentPage(int index)
1494{
1495 if(index >= 0 && index < int(m_pageFactories.size()))
1496 {
1497 if(auto make = std::exchange(m_pageFactories[index], {}))
1498 m_pages->widget(index)->layout()->addWidget(make());
1499 }
1500 m_pages->setCurrentIndex(index);
1501 for(int i = 0; i < int(m_navItems.size()); i++)
1502 m_navItems[i]->setChecked(i == index);
1503 if(m_preview)
1504 m_preview->hide();
1505}
1506
1507QLabel* StartScreen::makeSectionTitle(const QString& text, QWidget* parent)
1508{
1509 auto label = new QLabel{text, parent};
1510 label->setFont(m_sectionFont);
1511 QPalette pal = label->palette();
1512 pal.setColor(QPalette::WindowText, StartScreenColors::Muted);
1513 label->setPalette(pal);
1514 return label;
1515}
1516
1517QLabel* StartScreen::makeHint(const QString& text, QWidget* parent)
1518{
1519 auto hint = new QLabel{text, parent};
1520 hint->setFont(m_itemFont);
1521 hint->setWordWrap(true);
1522 QPalette pal = hint->palette();
1523 pal.setColor(QPalette::WindowText, StartScreenColors::Muted);
1524 hint->setPalette(pal);
1525 return hint;
1526}
1527
1528FilterEdit*
1529StartScreen::makeTitleWithFilter(const QString& title, QVBoxLayout* lay, QWidget* page)
1530{
1531 auto row = new QHBoxLayout;
1532 row->setContentsMargins(0, 0, 8, 0);
1533 row->addWidget(makeSectionTitle(title, page));
1534 row->addStretch();
1535
1536 auto filter = new FilterEdit{page};
1537 filter->setFont(m_smallFont);
1538 filter->setPlaceholderText(tr("Filter"));
1539 filter->setClearButtonEnabled(true);
1540 filter->setFixedWidth(220);
1541 row->addWidget(filter);
1542
1543 lay->addLayout(row);
1544 return filter;
1545}
1546
1547InteractiveLabel* StartScreen::makeItem(
1548 const QString& text, const QString& icon, const QString& url, QWidget* parent)
1549{
1550 auto label = new InteractiveLabel{m_itemFont, text, url, parent};
1551 if(!icon.isEmpty())
1552 {
1553 label->setPixmaps(
1554 score::get_pixmap(QString(":/icons/%1_off.png").arg(icon)),
1555 score::get_pixmap(QString(":/icons/%1_on.png").arg(icon)));
1556 }
1557 return label;
1558}
1559
1560InteractiveLabel* StartScreen::makeAccentItem(
1561 const QString& text, const QString& icon, const QString& url, QWidget* parent)
1562{
1563 // Accent text at rest; hovering behaves like every other item
1564 auto label = makeItem(text, icon, url, parent);
1565 label->setInactiveColor(StartScreenColors::Accent);
1566 return label;
1567}
1568
1569InteractiveLabel* StartScreen::makeExternalLink(const Link& link, QWidget* parent)
1570{
1571 auto label = makeItem(link.text, link.icon, link.url, parent);
1572 label->setOpenExternalLink(true);
1573 label->setToolTip(link.tooltip.isEmpty() ? link.url : link.tooltip);
1574 return label;
1575}
1576
1577InteractiveLabel* StartScreen::makeScoreItem(
1578 const QString& text, const QString& icon, const QString& path, bool asTemplate,
1579 QWidget* parent)
1580{
1581 auto label = makeItem(text, icon, path, parent);
1582 label->setElideMode(Qt::ElideMiddle);
1583 if(asTemplate)
1584 {
1585 connect(label, &InteractiveLabel::labelPressed, this, [this](const QString& file) {
1586 choose([&] { openTemplate(file); });
1587 });
1588 }
1589 else
1590 {
1591 connect(label, &InteractiveLabel::labelPressed, this, [this](const QString& file) {
1592 choose([&] { openFile(file); });
1593 });
1594 }
1595 connect(label, &InteractiveLabel::hovered, this, [this, label](bool on) {
1596 if(on)
1597 showPreview(label);
1598 else
1599 m_preview->hide();
1600 });
1601
1602 requestInfo(path);
1603 return label;
1604}
1605
1606QWidget* StartScreen::createHomePage(const QPointer<QRecentFilesMenu>& recentFiles)
1607{
1608 auto page = new QWidget;
1609 auto lay = new QHBoxLayout{page};
1610 lay->setContentsMargins(28, 24, 28, 24);
1611 lay->setSpacing(32);
1612
1613 const QString firstRunScore = firstRunDocumentTemplate();
1614
1615 // Left column: create
1616 {
1617 auto col = new QVBoxLayout;
1618 col->setSpacing(6);
1619 col->addWidget(makeSectionTitle(tr("Start"), page));
1620
1621 auto newLabel = makeItem(tr("New empty score"), "new_file", "", page);
1622 connect(newLabel, &InteractiveLabel::labelPressed, this, [this] {
1623 choose([this] { openNewDocument(); });
1624 });
1625 col->addWidget(newLabel);
1626
1627 auto templatesLabel = makeItem(tr("Start from a template"), "new_file", "", page);
1628 connect(templatesLabel, &InteractiveLabel::labelPressed, this, [this] {
1629 setCurrentPage(m_templatesPage);
1630 });
1631 col->addWidget(templatesLabel);
1632
1633 auto openLabel = makeItem(tr("Open a score file"), "load", "", page);
1634 connect(openLabel, &InteractiveLabel::labelPressed, this, [this] {
1635 choose([this] { openFileDialog(); });
1636 });
1637 col->addWidget(openLabel);
1638
1639 m_joinLabel
1640 = makeItem(tr("Join a collaborative session..."), "net_session", "", page);
1641 m_joinLabel->setToolTip(tr("Connect to a score session hosted on another computer"));
1642 m_joinLabel->hide();
1643 connect(m_joinLabel, &InteractiveLabel::labelPressed, this, [this] {
1644 choose([this] { joinSession(); });
1645 });
1646 col->addWidget(m_joinLabel);
1647
1648 col->addWidget(makeLibraryNotice(page, {0, 18, 0, 0}, true));
1649
1650 col->addSpacing(18);
1651 col->addWidget(makeSectionTitle(tr("New to ossia score?"), page));
1652
1653 if(!firstRunScore.isEmpty())
1654 {
1655 if(m_firstRun)
1656 {
1657 col->addWidget(makeHint(
1658 tr("Welcome! This guided score shows you around the interface and "
1659 "the main concepts in a few minutes."),
1660 page));
1661 }
1662 auto guided = makeAccentItem(
1663 tr("Open the demo project"), "version", firstRunScore, page);
1664 connect(guided, &InteractiveLabel::labelPressed, this, [this](const QString& f) {
1665 choose([&] { openTemplate(f); });
1666 });
1667 col->addWidget(guided);
1668
1669 col->addWidget(makeExternalLink(
1670 {tr("Read the quick start guide"),
1671 "https://ossia.io/score-docs/quick-start.html",
1672 "version",
1673 {}},
1674 page));
1675 }
1676 else
1677 {
1678 col->addWidget(makeHint(
1679 tr("Start with the quick start guide: it walks you through the "
1680 "interface and your first score."),
1681 page));
1682 auto guide = makeAccentItem(
1683 tr("Read the quick start guide"), "version",
1684 "https://ossia.io/score-docs/quick-start.html", page);
1685 guide->setOpenExternalLink(true);
1686 col->addWidget(guide);
1687 }
1688
1689 col->addStretch();
1690 lay->addLayout(col, 1);
1691 }
1692
1693 // Right column: open
1694 {
1695 auto col = new QVBoxLayout;
1696 col->setSpacing(6);
1697 col->addWidget(makeSectionTitle(tr("Recent"), page));
1698
1699 m_recentFiles = recentFiles;
1700 m_recentPage = page;
1701 m_recentList = new QVBoxLayout;
1702 m_recentList->setSpacing(6);
1703 col->addLayout(m_recentList);
1704 refreshRecentFiles();
1705
1706 col->addSpacing(6);
1707
1708 m_crashLabel = makeAccentItem(tr("Restore last session"), "reload_crash", "", page);
1709 m_crashLabel->setToolTip(
1710 tr("score did not exit cleanly last time: reopen the documents that were open"));
1711 m_crashLabel->hide();
1712 connect(m_crashLabel, &InteractiveLabel::labelPressed, this, [this] {
1713 choose([this] { loadCrashedSession(); });
1714 });
1715 col->addWidget(m_crashLabel);
1716
1717 col->addStretch();
1718 lay->addLayout(col, 1);
1719 }
1720
1721 return page;
1722}
1723
1724void StartScreen::refreshRecentFiles()
1725{
1726 if(m_preview)
1727 m_preview->hide();
1728 // deleteLater: the entry that was clicked may still be on the stack.
1729 while(auto item = m_recentList->takeAt(0))
1730 {
1731 if(auto w = item->widget())
1732 {
1733 w->hide();
1734 w->deleteLater();
1735 }
1736 delete item;
1737 }
1738
1739 int shown = 0;
1740 if(m_recentFiles)
1741 {
1742 for(const auto& action : m_recentFiles->actions())
1743 {
1744 if(shown++ >= MaxRecentFiles)
1745 break;
1746
1747 // Read again: the file may have been saved with a new thumbnail since.
1748 const QString path = action->data().toString();
1749 if(auto it = m_infos.find(path); it != m_infos.end() && it->second)
1750 m_infos.erase(it);
1751 m_recentList->addWidget(makeScoreItem(
1752 QFileInfo{path}.fileName(), "load", path, false, m_recentPage));
1753 }
1754 }
1755
1756 if(shown == 0)
1757 {
1758 auto none = makeItem(tr("No recent scores yet"), "", "", m_recentPage);
1759 none->disableInteractivity();
1760 none->setInactiveColor(StartScreenColors::Muted);
1761 m_recentList->addWidget(none);
1762 }
1763}
1764
1765QWidget* StartScreen::createTemplatesPage()
1766{
1767 auto page = new QWidget;
1768 auto lay = new QVBoxLayout{page};
1769 lay->setContentsMargins(0, 0, 0, 0);
1770 lay->setSpacing(0);
1771
1772 // Same side margins as the page below it.
1773 lay->addWidget(makeLibraryNotice(page, {28, 20, 28, 0}, false));
1774
1775 lay->addWidget(createCardsPage(
1776 tr("Templates"),
1777 tr("A template opens as a new untitled score with devices, processes and a "
1778 "structure already in place."),
1779 tr("No templates are installed yet. Templates are .score files in the "
1780 "Templates folder of your user library (%1) or of an installed package.")
1781 .arg(QDir::toNativeSeparators(libraryRootPath())),
1782 availableDocumentTemplates(),
1783 [this](const QString& path) { choose([&] { openTemplate(path); }); },
1784 {tr("Learn how to write your own templates"),
1785 "https://ossia.io/score-docs/",
1786 "learn",
1787 {}}), 1);
1788
1789 updateLibraryNotices();
1790 return page;
1791}
1792
1793QWidget* StartScreen::createAboutPage()
1794{
1795 // Shared with Help > About
1797 style.sectionFont = m_sectionFont;
1798 style.itemFont = m_itemFont;
1799 style.smallFont = m_smallFont;
1800 style.text = StartScreenColors::Text;
1801 style.muted = StartScreenColors::Muted;
1802 style.hover = StartScreenColors::Hover;
1803 style.version = StartScreenColors::Version;
1804 style.outline = StartScreenColors::Outline;
1805
1806 auto page = new QWidget;
1807 auto lay = new QVBoxLayout{page};
1808 lay->setContentsMargins(28, 20, 28, 16);
1809 lay->addWidget(new score::AboutWidget{style, page});
1810 return page;
1811}
1812
1813QWidget* StartScreen::createExamplesPage(bool online)
1814{
1815 std::vector<ExampleEntry> docs;
1816 for(auto& doc : gatherExamples())
1817 if((doc.online != nullptr) == online)
1818 docs.push_back(std::move(doc));
1819
1820 const QString title = online ? tr("Online examples") : tr("Local examples");
1821 if(docs.empty())
1822 {
1823 return createCardsPage(
1824 title, {},
1825 online
1826 ? tr("The examples of the documentation site show here once their list "
1827 "has been downloaded. Check your internet connection.")
1828 : tr("No example scores are installed yet. Examples are .score files in "
1829 "the Examples folder of your user library (%1) or of an installed "
1830 "package.")
1831 .arg(QDir::toNativeSeparators(libraryRootPath())),
1832 {}, {},
1833 {tr("More examples online"),
1834 "https://ossia.io/score-docs/examples",
1835 "load_examples",
1836 {}});
1837 }
1838
1839 auto page = new QWidget;
1840 auto lay = new QVBoxLayout{page};
1841 lay->setContentsMargins(28, 24, 28, 16);
1842 lay->setSpacing(8);
1843 auto filter = makeTitleWithFilter(title, lay, page);
1844 lay->addWidget(makeHint(
1845 online ? tr("Each example is downloaded into your library the first time it "
1846 "is opened, then opens as a new, untitled score. Ctrl-click opens "
1847 "the example file itself, Alt-click does not open its web page.")
1848 : tr("Each example opens as a new, untitled score. Ctrl-click opens the "
1849 "example file itself, Alt-click does not open its web page."),
1850 page));
1851 auto noMatch = makeHint(tr("Nothing matches this filter."), page);
1852 noMatch->hide();
1853 lay->addWidget(noMatch);
1854
1855 auto scroll = new QScrollArea{page};
1856 scroll->setFrameShape(QFrame::NoFrame);
1857 scroll->setWidgetResizable(true);
1858 scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
1859 scroll->viewport()->setAutoFillBackground(false);
1860
1861 auto container = new QWidget;
1862 container->setAutoFillBackground(false);
1863 auto containerLay = new QVBoxLayout{container};
1864 // Bands reach the scroll bar; the first one is a row's gap from the top.
1865 containerLay->setContentsMargins(
1867 containerLay->setSpacing(CategoryDrawer::Gap);
1868
1869 // Two levels at most; anything deeper folds into its parent.
1870 std::map<QString, std::map<QString, std::vector<ExampleEntry>>> tree;
1871 for(const auto& doc : docs)
1872 {
1873 auto parts = doc.category.split('/', Qt::SkipEmptyParts);
1874 for(auto& p : parts)
1875 p = p.trimmed();
1876 const QString top = parts.empty() ? tr("Uncategorized") : parts.front();
1877 const QString sub = parts.size() > 1 ? parts[1] : QString{};
1878 tree[top][sub].push_back(doc);
1879 }
1880
1881 struct Drawer
1882 {
1883 CategoryDrawer* drawer{};
1884 std::vector<ExampleCard*> cards;
1885 bool expanded{};
1886 };
1887 auto drawers = std::make_shared<std::vector<Drawer>>();
1888 auto cards = std::make_shared<std::vector<ExampleCard*>>();
1889
1890 // Every drawer starts open.
1891 for(const auto& [top, subs] : tree)
1892 {
1893 int total = 0;
1894 for(const auto& [sub, list] : subs)
1895 total += list.size();
1896
1897 auto drawer = new CategoryDrawer{m_sectionFont, top, total, 0, container};
1898 containerLay->addWidget(drawer);
1899 const auto topCards = cards->size();
1900
1901 for(const auto& [sub, list] : subs)
1902 {
1903 if(sub.isEmpty())
1904 {
1905 drawer->body()->addWidget(makeCardGrid(list, *cards, drawer));
1906 }
1907 else
1908 {
1909 auto inner
1910 = new CategoryDrawer{m_itemFont, sub, int(list.size()), 1, drawer};
1911 const auto innerCards = cards->size();
1912 inner->body()->addWidget(makeCardGrid(list, *cards, inner));
1913 drawer->body()->addWidget(inner);
1914 drawers->push_back({inner, {cards->begin() + innerCards, cards->end()}});
1915 }
1916 }
1917 drawers->push_back({drawer, {cards->begin() + topCards, cards->end()}});
1918 }
1919
1920 // While filtering, every drawer with a match is open; clearing the filter
1921 // puts back the drawers the user had opened.
1922 connect(
1923 filter, &QLineEdit::textChanged, container,
1924 [filter, noMatch, drawers, cards, filtering = false]() mutable {
1925 const auto words = filter->words();
1926 if(!words.empty() && !filtering)
1927 for(auto& d : *drawers)
1928 d.expanded = d.drawer->expanded();
1929
1930 for(auto card : *cards)
1931 card->setVisible(card->matches(words));
1932
1933 bool any = false;
1934 for(auto& d : *drawers)
1935 {
1936 const int count = std::count_if(
1937 d.cards.begin(), d.cards.end(), [](auto* c) { return !c->isHidden(); });
1938 d.drawer->setCount(count);
1939 d.drawer->setVisible(count > 0);
1940 if(!words.empty())
1941 d.drawer->setExpanded(count > 0);
1942 else if(filtering)
1943 d.drawer->setExpanded(d.expanded);
1944 any |= count > 0;
1945 }
1946
1947 filtering = !words.empty();
1948 noMatch->setVisible(!any);
1949 });
1950
1951 containerLay->addStretch();
1952 scroll->setWidget(container);
1953 lay->addWidget(scroll, 1);
1954
1955 lay->addWidget(makeExternalLink(
1956 {tr("More examples online"), "https://ossia.io/score-docs/examples",
1957 "load_examples", {}},
1958 page));
1959 return page;
1960}
1961
1962std::vector<StartScreen::ExampleEntry> StartScreen::gatherExamples() const
1963{
1964 std::vector<ExampleEntry> out;
1965
1966 for(const auto& doc : availableExampleDocuments())
1967 out.push_back({doc.name, doc.category, doc.path, nullptr});
1968
1969 // A downloaded example is listed on both pages: online, its entry tells that
1970 // there is a copy, and whether the online version changed since.
1971 for(const auto& ex : m_online.examples())
1972 {
1973 QString category = ex.section;
1974 if(!ex.group.isEmpty())
1975 category += " / " + ex.group;
1976 out.push_back({ex.name, category, OnlineExamples::localPath(ex), &ex});
1977 }
1978
1979 return out;
1980}
1981
1982void StartScreen::openOnlineExample(const OnlineExample& ex, bool direct, bool openPage)
1983{
1984 if(m_actionTaken)
1985 return;
1986
1987 if(openPage && !ex.page.isEmpty())
1988 {
1989 if(const QUrl url{ex.page}; url.isValid() && !url.scheme().isEmpty())
1990 QDesktopServices::openUrl(url);
1991 }
1992
1993 m_online.install(ex, [this, direct](QString path) {
1994 if(path.isEmpty())
1995 {
1996 score::warning(
1997 this, tr("Download failed"),
1998 tr("This example could not be downloaded. Check your connection and "
1999 "try again."));
2000 return;
2001 }
2002 choose([&] { direct ? openFile(path) : openTemplate(path); });
2003 });
2004}
2005
2006void StartScreen::showOnlineExampleMenu(const OnlineExample& ex, const QPoint& globalPos)
2007{
2008 if(m_actionTaken)
2009 return;
2010 m_preview->hide();
2011
2012 const bool local = !OnlineExamples::localPath(ex).isEmpty();
2013
2014 // Not exec(): its event loop would keep the card's contextMenuEvent on the
2015 // stack, while a new manifest or an installed library rebuilds the cards.
2016 // The menu belongs to the start screen and its actions hold a copy of the
2017 // example, so that they still work when the card is gone. They run once the
2018 // menu has returned to the event loop, as they may ask with a dialog.
2019 auto menu = new QMenu{this};
2020 menu->setAttribute(Qt::WA_DeleteOnClose);
2021 const auto add = [this, menu](const QString& text, auto&& f) {
2022 auto action = menu->addAction(text);
2023 connect(action, &QAction::triggered, this, std::forward<decltype(f)>(f),
2024 Qt::QueuedConnection);
2025 return action;
2026 };
2027
2028 add(local ? tr("Open your copy as a new score") : tr("Download and open as a new score"),
2029 [this, ex] { openOnlineExample(ex, false, false); });
2030 add(local ? tr("Open the example file") : tr("Download and open the example file"),
2031 [this, ex] { openOnlineExample(ex, true, false); });
2032 if(!ex.page.isEmpty())
2033 {
2034 add(tr("Open the web page"), [ex] {
2035 if(const QUrl url{ex.page}; url.isValid() && !url.scheme().isEmpty())
2036 QDesktopServices::openUrl(url);
2037 });
2038 }
2039 if(local)
2040 {
2041 menu->addSeparator();
2042 if(m_online.isDownloading(ex))
2043 {
2044 menu->addAction(tr("Downloading"))->setEnabled(false);
2045 }
2046 else
2047 {
2048 add(OnlineExamples::changedOnline(ex) ? tr("Download the new online version")
2049 : tr("Download the online version again"),
2050 [this, ex] { replaceLocalCopy(ex); });
2051 }
2052 }
2053 menu->popup(globalPos);
2054}
2055
2056void StartScreen::replaceLocalCopy(const OnlineExample& ex)
2057{
2058 // Asked again while the dialog below or the download is under way.
2059 if(m_online.isDownloading(ex) || m_replaceAsked.contains(ex.id))
2060 return;
2061
2062 const auto path = OnlineExamples::localPath(ex);
2063 if(!path.isEmpty())
2064 {
2065 const bool folder = ex.format == QLatin1String{"zip"};
2066 const auto copy = folder ? OnlineExamples::installFolder(ex) : path;
2067
2068 m_replaceAsked.insert(ex.id);
2069 const auto answer = score::question(
2070 this, tr("Download again"),
2071 (folder ? tr("Replace your copy of %1 with the online version? Changes and "
2072 "files added to its folder are lost.\n\n%2")
2073 : tr("Replace your copy of %1 with the online version? Changes to "
2074 "it are lost.\n\n%2"))
2075 .arg(ex.name, QDir::toNativeSeparators(copy)));
2076 m_replaceAsked.erase(ex.id);
2077 if(answer != QMessageBox::Yes)
2078 return;
2079 }
2080
2081 m_online.install(ex, [self = QPointer{this}](const QString& path) {
2082 if(!self)
2083 return;
2084 if(path.isEmpty())
2085 {
2086 score::warning(
2087 self, tr("Download failed"),
2088 tr("The example could not be downloaded. Your copy is unchanged."));
2089 return;
2090 }
2091 self->m_infos.erase(path);
2092 self->refreshExamplesPage();
2093 }, true);
2094}
2095
2096void StartScreen::requestThumbnail(const OnlineExample& ex, ExampleCard* card)
2097{
2098 new HTTPGet{
2099 QUrl{ex.image},
2100 [card = QPointer{card}](const QByteArray& data) {
2101 if(!card)
2102 return;
2103 if(QImage img; img.loadFromData(data))
2104 card->setThumbnailImage(img);
2105 },
2106 [] { }};
2107}
2108
2109void StartScreen::refreshExamplesPage()
2110{
2111 // An online example that got installed shows on the local page too.
2112 refreshExamplesPage(m_examplesPage, false);
2113 refreshExamplesPage(m_onlineExamplesPage, true);
2114}
2115
2116void StartScreen::refreshExamplesPage(int index, bool online)
2117{
2118 // Not yet built: it will use the new list when it first opens.
2119 if(index <= 0 || m_pageFactories[index])
2120 return;
2121
2122 auto host = m_pages->widget(index);
2123 QString filter;
2124 bool focused{};
2125 if(auto edit = host->findChild<QLineEdit*>())
2126 {
2127 filter = edit->text();
2128 focused = edit->hasFocus();
2129 }
2130
2131 std::erase_if(m_cards, [host](auto* card) { return host->isAncestorOf(card); });
2132 qDeleteAll(host->findChildren<QWidget*>(Qt::FindDirectChildrenOnly));
2133 host->layout()->addWidget(createExamplesPage(online));
2134
2135 if(auto edit = host->findChild<QLineEdit*>())
2136 {
2137 edit->setText(filter);
2138 if(focused)
2139 edit->setFocus();
2140 }
2141}
2142
2143QWidget* StartScreen::makeCardGrid(
2144 const std::vector<ExampleEntry>& docs, std::vector<ExampleCard*>& cards,
2145 QWidget* parent)
2146{
2147 auto holder = new QWidget{parent};
2148 holder->setAutoFillBackground(false);
2149 auto grid = new CardFlowLayout{holder, 14};
2150
2151 for(const auto& doc : docs)
2152 {
2153 auto card = new ExampleCard{m_itemFont, m_smallFont, doc.name, {},
2154 doc.path, holder};
2155
2156 if(const auto* online = doc.online)
2157 {
2158 ProjectInfo::Info info;
2159 info.name = online->name;
2160 info.description = online->description;
2161 info.url = online->page;
2162 card->setInfo(info);
2163
2164 QString tip = online->name;
2165 if(!online->description.isEmpty())
2166 tip += "\n\n" + online->description;
2167 if(doc.path.isEmpty())
2168 {
2169 card->setDownloadSize(online->size);
2170 }
2171 else
2172 {
2173 const bool changed = OnlineExamples::changedOnline(*online);
2174 card->setBadge(
2175 changed ? tr("Updated online") : tr("In your library"),
2176 changed ? StartScreenColors::Accent : StartScreenColors::Muted);
2177 tip += "\n\n";
2178 if(changed)
2179 tip += tr("Changed online. Right-click to download it again.") + "\n";
2180 tip += QDir::toNativeSeparators(doc.path);
2181 }
2182 card->setToolTip(tip);
2183 if(!online->image.isEmpty())
2184 requestThumbnail(*online, card);
2185
2186 card->onContextMenu = [this, ex = *online](const QPoint& pos) {
2187 showOnlineExampleMenu(ex, pos);
2188 };
2189
2190 card->onActivated
2191 = [this, ex = *online](const QString&, Qt::KeyboardModifiers mods) {
2192 openOnlineExample(
2193 ex, mods & Qt::ControlModifier, !(mods & Qt::AltModifier));
2194 };
2195 }
2196 else
2197 {
2198 card->onActivated = [this](const QString& path, Qt::KeyboardModifiers mods) {
2199 openExample(path, mods & Qt::ControlModifier, !(mods & Qt::AltModifier));
2200 };
2201 requestInfo(doc.path, *card);
2202 }
2203
2204 card->setKeywords(doc.category);
2205 grid->addWidget(card);
2206 m_cards.push_back(card);
2207 cards.push_back(card);
2208 }
2209 return holder;
2210}
2211
2212QWidget* StartScreen::createCardsPage(
2213 const QString& title, const QString& hint, const QString& emptyHint,
2214 const std::vector<DocumentTemplate>& docs,
2215 std::function<void(const QString&)> onActivated, const Link& more)
2216{
2217 auto page = new QWidget;
2218 auto lay = new QVBoxLayout{page};
2219 lay->setContentsMargins(28, 24, 28, 16);
2220 lay->setSpacing(8);
2221
2222 if(docs.empty())
2223 {
2224 lay->addWidget(makeSectionTitle(title, page));
2225 lay->addWidget(makeHint(emptyHint, page));
2226 }
2227 else
2228 {
2229 auto filter = makeTitleWithFilter(title, lay, page);
2230 lay->addWidget(makeHint(hint, page));
2231 auto noMatch = makeHint(tr("Nothing matches this filter."), page);
2232 noMatch->hide();
2233 lay->addWidget(noMatch);
2234
2235 auto scroll = new QScrollArea{page};
2236 scroll->setFrameShape(QFrame::NoFrame);
2237 scroll->setWidgetResizable(true);
2238 scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
2239 scroll->viewport()->setAutoFillBackground(false);
2240
2241 auto container = new QWidget;
2242 container->setAutoFillBackground(false);
2243 auto grid = new CardFlowLayout{container, 14};
2244 grid->setContentsMargins(0, 4, 8, 4);
2245 std::vector<ExampleCard*> cards;
2246
2247 for(const auto& doc : docs)
2248 {
2249 const QString subtitle = !doc.category.isEmpty() ? doc.category
2250 : doc.source == "library" ? tr("User library")
2251 : doc.source;
2252 auto card = new ExampleCard{m_itemFont, m_smallFont, doc.name,
2253 subtitle, doc.path, container};
2254 card->onActivated = [onActivated](const QString& path, Qt::KeyboardModifiers) {
2255 onActivated(path);
2256 };
2257 card->setKeywords(doc.source);
2258 grid->addWidget(card);
2259 m_cards.push_back(card);
2260 cards.push_back(card);
2261 requestInfo(doc.path, *card);
2262 }
2263
2264 connect(
2265 filter, &QLineEdit::textChanged, container,
2266 [filter, noMatch, cards = std::move(cards)] {
2267 const auto words = filter->words();
2268 bool any = false;
2269 for(auto card : cards)
2270 {
2271 const bool m = card->matches(words);
2272 card->setVisible(m);
2273 any |= m;
2274 }
2275 noMatch->setVisible(!any);
2276 });
2277
2278 scroll->setWidget(container);
2279 lay->addWidget(scroll, 1);
2280 }
2281
2282 lay->addWidget(makeExternalLink(more, page));
2283
2284 if(docs.empty())
2285 lay->addStretch();
2286
2287 return page;
2288}
2289
2290QWidget*
2291StartScreen::createLinksPage(const QString& title, const std::vector<Link>& links)
2292{
2293 auto page = new QWidget;
2294 auto lay = new QVBoxLayout{page};
2295 lay->setContentsMargins(28, 24, 28, 24);
2296 lay->setSpacing(6);
2297
2298 lay->addWidget(makeSectionTitle(title, page));
2299 for(const auto& link : links)
2300 lay->addWidget(makeExternalLink(link, page));
2301 lay->addStretch();
2302
2303 return page;
2304}
2305
2306void StartScreen::openExample(const QString& path, bool direct, bool openPage)
2307{
2308 if(m_actionTaken)
2309 return;
2310
2311 if(openPage)
2312 {
2313 // The information is normally already loaded in the background; if the
2314 // user was faster than the parser, read it now.
2315 std::optional<ProjectInfo::Info> info;
2316 if(auto it = m_infos.find(path); it != m_infos.end() && it->second)
2317 info = it->second;
2318 else
2319 info = ProjectInfo::peek(path);
2320
2321 if(info && !info->url.isEmpty())
2322 {
2323 if(const QUrl url{info->url}; url.isValid() && !url.scheme().isEmpty())
2324 QDesktopServices::openUrl(url);
2325 }
2326 }
2327
2328 choose([&] { direct ? openFile(path) : openTemplate(path); });
2329}
2330
2331void StartScreen::requestInfo(const QString& path)
2332{
2333 if(m_infos.find(path) != m_infos.end())
2334 return;
2335 m_infos.emplace(path, std::nullopt);
2336
2337 // Parsing a big .score file can take a moment: do it off the GUI thread.
2338 score::TaskPool::instance().post([path, self = QPointer{this}] {
2339 auto info = ProjectInfo::peek(path);
2340 QMetaObject::invokeMethod(
2341 QCoreApplication::instance(), [self, path, info = std::move(info)] {
2342 if(self)
2343 self->onInfoLoaded(path, info);
2344 }, Qt::QueuedConnection);
2345 });
2346}
2347
2348void StartScreen::requestInfo(const QString& path, ExampleCard& card)
2349{
2350 if(auto it = m_infos.find(path); it != m_infos.end())
2351 {
2352 if(it->second)
2353 card.setInfo(*it->second);
2354 return;
2355 }
2356 requestInfo(path);
2357}
2358
2359void StartScreen::onInfoLoaded(
2360 const QString& path, const std::optional<ProjectInfo::Info>& info)
2361{
2362 m_infos[path] = info;
2363 if(!info)
2364 return;
2365
2366 for(auto card : m_cards)
2367 if(card->path() == path)
2368 card->setInfo(*info);
2369}
2370
2371void StartScreen::showPreview(InteractiveLabel* label)
2372{
2373 const QString& path = label->url();
2374 ProjectInfo::Info info;
2375 if(auto it = m_infos.find(path); it != m_infos.end() && it->second)
2376 info = *it->second;
2377
2378 m_preview->setContent(
2379 info.thumbnail, info.name.isEmpty() ? label->text() : info.name, info.author,
2380 info.description, QDir::toNativeSeparators(path));
2381 // Not Width x Height: the start screen follows the main window's size.
2382 m_preview->showFor(label, m_pages->geometry().adjusted(8, 8, -8, -8));
2383}
2384
2386{
2387 m_crashLabel->show();
2388 update();
2389}
2390
2392{
2393 m_joinLabel->show();
2394 update();
2395}
2396
2398{
2399 m_actionTaken = true;
2400 close();
2401}
2402
2404{
2405 m_actionTaken = false;
2406 refreshRecentFiles();
2407 show();
2408 raise();
2409 setFocus();
2410}
2411
2412void StartScreen::checkForNewVersion()
2413{
2414#if defined(__EMSCRIPTEN__)
2415 return;
2416#else
2417 // The request itself is asynchronous (QNetworkAccessManager); the reply is
2418 // handled on the GUI thread and only then touches the widgets.
2419 auto& tp = score::ThreadPool::instance();
2420 auto t = tp.acquireThread();
2421 QMetaObject::invokeMethod(t, [t, self = QPointer{this}] {
2422 auto getLastVersion = new HTTPGet{
2423 QUrl("https://ossia.io/score-last-version.txt"), [self](const QByteArray& data) {
2424 const auto version = QString::fromUtf8(data.simplified());
2425 if(QVersionNumber::fromString(version)
2426 > QVersionNumber::fromString(SCORE_TAG_NO_V))
2427 {
2428 QMetaObject::invokeMethod(QCoreApplication::instance(), [self, version] {
2429 if(self)
2430 self->showUpdateAvailable(version);
2431 });
2432 }
2433 }, [] { }};
2434 connect(getLastVersion, &QObject::destroyed, t, [] {
2435 QMetaObject::invokeMethod(QCoreApplication::instance(), [] {
2436 auto& tp = score::ThreadPool::instance();
2437 tp.releaseThread();
2438 });
2439 });
2440 });
2441#endif
2442}
2443
2444void StartScreen::showUpdateAvailable(const QString& version)
2445{
2446 m_updateLabel->setText(
2447 tr("New version %1 is available, click to update").arg(version));
2448 m_updateLabel->setToolTip(tr("Open the download page in your browser"));
2449 m_updateLabel->show();
2450}
2451
2452QWidget* StartScreen::makeLibraryNotice(
2453 QWidget* parent, const QMargins& margins, bool linksToTemplates)
2454{
2455 auto box = new QWidget{parent};
2456 auto lay = new QVBoxLayout{box};
2457 lay->setContentsMargins(margins);
2458 lay->setSpacing(6);
2459 lay->addWidget(makeSectionTitle(tr("Default library"), box));
2460
2461 auto hint = makeHint({}, box);
2462 lay->addWidget(hint);
2463
2464 auto action = makeAccentItem({}, "library", "", box);
2465 connect(action, &InteractiveLabel::labelPressed, this, [this] {
2466 if(m_libraryInstalled)
2467 setCurrentPage(m_templatesPage);
2468 else
2469 DefaultLibrary::instance().install();
2470 });
2471 lay->addWidget(action);
2472
2473 box->hide();
2474 m_libraryNotices.push_back({box, hint, action, linksToTemplates});
2475 return box;
2476}
2477
2478void StartScreen::onLibraryChanged()
2479{
2480 const auto previous = std::exchange(m_libraryState, DefaultLibrary::instance().state());
2482 && m_libraryState == DefaultLibrary::State::Current)
2483 {
2484 m_libraryInstalled = true;
2485 // The templates and examples it brings are on disk now.
2486 rebuildTemplatesPage();
2487 refreshExamplesPage();
2488 }
2489 else if(m_libraryState != DefaultLibrary::State::Current)
2490 {
2491 m_libraryInstalled = false;
2492 }
2493 updateLibraryNotices();
2494}
2495
2496void StartScreen::updateLibraryNotices()
2497{
2499 const auto& lib = DefaultLibrary::instance();
2500 const auto state = m_libraryState;
2501
2502 QString hint;
2503 QString action;
2504 switch(state)
2505 {
2506 case State::Current:
2507 if(m_libraryInstalled)
2508 {
2509 hint = tr("The default library is installed.");
2510 action = tr("Show the templates");
2511 }
2512 break;
2513 case State::Missing:
2514 hint = tr("The default library is not installed. It provides the templates, "
2515 "presets and media used by the examples.");
2516 action = lib.downloadSize() > 0
2517 ? tr("Install the default library (%1)")
2518 .arg(QLocale{}.formattedDataSize(lib.downloadSize()))
2519 : tr("Install the default library");
2520 break;
2521 case State::UpdateAvailable:
2522 hint = tr("A new version of the default library is available.");
2523 action = lib.downloadSize() > 0
2524 ? tr("Update the default library (%1)")
2525 .arg(QLocale{}.formattedDataSize(lib.downloadSize()))
2526 : tr("Update the default library");
2527 break;
2528 case State::Installing:
2529 hint = lib.progress() >= 0.
2530 ? tr("Downloading the default library: %1%")
2531 .arg(int(lib.progress() * 100.))
2532 : tr("Downloading the default library");
2533 break;
2534 case State::Failed:
2535 hint = lib.error().isEmpty()
2536 ? tr("The default library could not be installed.")
2537 : tr("The default library could not be installed: %1").arg(lib.error());
2538 action = tr("Try again");
2539 break;
2540 }
2541
2542 const bool installAction = state == State::Missing || state == State::UpdateAvailable
2543 || state == State::Failed;
2544 if(installAction && !lib.canInstall())
2545 {
2546 hint += " " + tr("Install it from the Packages settings.");
2547 action.clear();
2548 }
2549
2550 std::erase_if(m_libraryNotices, [](const LibraryNotice& n) { return !n.box; });
2551 for(auto& n : m_libraryNotices)
2552 {
2553 const bool showAction
2554 = !action.isEmpty() && (n.linksToTemplates || !m_libraryInstalled);
2555 n.hint->setText(hint);
2556 n.action->setText(action);
2557 n.action->setVisible(showAction);
2558 n.box->setVisible(!hint.isEmpty());
2559 }
2560}
2561
2562void StartScreen::rebuildTemplatesPage()
2563{
2564 // Not yet built: it will find the new templates when it first opens.
2565 if(m_templatesPage <= 0 || m_pageFactories[m_templatesPage])
2566 return;
2567
2568 auto host = m_pages->widget(m_templatesPage);
2569 std::erase_if(m_cards, [host](auto* card) { return host->isAncestorOf(card); });
2570 for(auto* w : host->findChildren<QWidget*>(Qt::FindDirectChildrenOnly))
2571 {
2572 w->hide();
2573 w->deleteLater();
2574 }
2575 host->layout()->addWidget(createTemplatesPage());
2576}
2577
2578void StartScreen::paintEvent(QPaintEvent* event)
2579{
2580 QPainter painter(this);
2581 painter.setRenderHint(QPainter::Antialiasing, true);
2582 painter.setRenderHint(QPainter::TextAntialiasing, true);
2583 painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
2584
2585 // The decorations cover the window, anchored bottom-left where they gather;
2586 // the overflow is cropped at the top or the right.
2587 painter.fillRect(rect(), Qt::black);
2588 if(!m_background.isNull())
2589 {
2590 const QSizeF logical = m_background.deviceIndependentSize();
2591 const qreal cover
2592 = std::max(width() / logical.width(), height() / logical.height());
2593 const QSizeF size = logical * cover;
2594 painter.drawPixmap(
2595 QRectF{QPointF{0, height() - size.height()}, size}, m_background,
2596 m_background.rect());
2597 }
2598
2599 // Scaled up, the decorations reach into the header: fade them out behind the
2600 // logo, the tagline and the version.
2601 {
2602 QLinearGradient fade{QPointF{0, 0}, QPointF{0, qreal(HeaderHeight)}};
2603 fade.setColorAt(0, QColor{0, 0, 0, 255});
2604 fade.setColorAt(0.92, QColor{0, 0, 0, 255});
2605 fade.setColorAt(1, QColor{0, 0, 0, 0});
2606 painter.fillRect(QRect{0, 0, width(), HeaderHeight}, fade);
2607 }
2608
2609 // The logo and tagline keep the scale they have at the minimum size, so that
2610 // they line up with HeaderHeight.
2611 qreal scale = 1.;
2612 if(!m_logo.isNull())
2613 {
2614 const QSizeF logical = m_logo.deviceIndependentSize();
2615 scale = std::max(Width / logical.width(), Height / logical.height());
2616 painter.drawPixmap(QRectF{QPointF{}, logical * scale}, m_logo, m_logo.rect());
2617 }
2618
2619 // The version sits under the tagline, which is part of the artwork
2620 painter.setFont(m_versionFont);
2621 painter.setPen(QPen{StartScreenColors::Version});
2622 {
2623 // Branch builds carry the branch name: keep it inside the header
2624 const qreal x = 217 * scale;
2625 const QFontMetrics fm{m_versionFont};
2626 painter.drawText(
2627 QPointF(x, 188 * scale), fm.elidedText(
2628 QCoreApplication::applicationVersion(),
2629 Qt::ElideMiddle, int(width() - x - 60)));
2630 }
2631
2632 // Dim the artwork behind the navigation so that its text stays readable
2633 painter.fillRect(
2634 QRect{0, HeaderHeight, NavWidth, height() - HeaderHeight}, QColor{0, 0, 0, 120});
2635
2636 // Page panel. The selected navigation item paints itself with the panel
2637 // color so that it visually connects to the page.
2638 painter.fillRect(
2639 QRect{NavWidth, HeaderHeight, width() - NavWidth, height() - HeaderHeight},
2640 StartScreenColors::Panel);
2641}
2642
2643void StartScreen::showEvent(QShowEvent* event)
2644{
2645 QWidget::showEvent(event);
2646 if(!std::exchange(m_libraryViewRegistered, true))
2647 DefaultLibrary::instance().addView();
2648 if(auto* p = parentWidget())
2649 setGeometry(p->rect());
2650 raise();
2651 setFocus();
2652}
2653
2654void StartScreen::hideEvent(QHideEvent* event)
2655{
2656 QWidget::hideEvent(event);
2657 if(std::exchange(m_libraryViewRegistered, false))
2658 DefaultLibrary::instance().removeView();
2659}
2660
2661bool StartScreen::eventFilter(QObject* watched, QEvent* event)
2662{
2663 if(watched == parentWidget() && event->type() == QEvent::Resize)
2664 setGeometry(parentWidget()->rect());
2665 return QWidget::eventFilter(watched, event);
2666}
2667
2668void StartScreen::keyPressEvent(QKeyEvent* event)
2669{
2670 switch(event->key())
2671 {
2672 case Qt::Key_Escape:
2673 choose([this] { openNewDocument(); });
2674 event->accept();
2675 return;
2676 case Qt::Key_Left:
2677 case Qt::Key_Up:
2678 setCurrentPage(
2679 (m_pages->currentIndex() + m_pages->count() - 1) % m_pages->count());
2680 event->accept();
2681 return;
2682 case Qt::Key_Right:
2683 case Qt::Key_Down:
2684 setCurrentPage((m_pages->currentIndex() + 1) % m_pages->count());
2685 event->accept();
2686 return;
2687 default:
2688 QWidget::keyPressEvent(event);
2689 }
2690}
2691
2692void StartScreen::closeEvent(QCloseEvent* event)
2693{
2694 // Closed by other means (e.g. the window manager): make sure that the user
2695 // does not end up with an empty application window.
2696 choose([this] { openNewDocument(); });
2697 QWidget::closeEvent(event);
2698}
2699}
Version, partners and third-party licenses of ossia score.
Definition AboutWidget.hpp:18
The window shown when score starts without a document.
Definition StartScreen.hpp:999
A score presented as a card: thumbnail, name, author.
Definition StartScreen.hpp:580
static constexpr int Gap
Vertical space between two rows of the list.
Definition StartScreen.hpp:629
static constexpr int Step
How far a subsection steps right of its section.
Definition StartScreen.hpp:631
State
Definition DefaultLibrary.hpp:33
@ Current
Installed, and no newer version is known.
@ Installing
A download or an extraction is under way.
Definition StartScreen.hpp:730
void setBadge(const QString &text, const QColor &color)
A short text in the top right corner of the thumbnail.
Definition StartScreen.hpp:761
void setKeywords(const QString &k)
Extra text the filter looks into, e.g. the category.
Definition StartScreen.hpp:794
bool matches(const QStringList &words) const
Every one of words is in the name, author, description, path or keywords.
Definition StartScreen.hpp:797
std::function< void(const QPoint &globalPos)> onContextMenu
Right click; the card has no menu without it.
Definition StartScreen.hpp:814
std::function< void(const QString &path, Qt::KeyboardModifiers mods)> onActivated
Ctrl (Cmd on macOS): the file is opened itself; Alt: no web page.
Definition StartScreen.hpp:812
Definition StartScreen.hpp:949
QStringList words() const
The words typed, each of which a card has to match.
Definition StartScreen.hpp:954
A clickable label with an optional icon and a hover state.
Definition StartScreen.hpp:176
void setIconColumn(int px)
Width reserved for the icon, so that texts align whatever the icon size.
Definition StartScreen.hpp:193
void refresh()
Emits updated() only if the manifest changed.
Definition OnlineExamples.cpp:303
void loadCache()
Synchronous, no network.
Definition OnlineExamples.cpp:281
static bool changedOnline(const OnlineExample &ex)
Definition OnlineExamples.cpp:201
static QString localPath(const OnlineExample &ex)
Empty until the example has been installed.
Definition OnlineExamples.cpp:172
static QString installFolder(const OnlineExample &ex)
Definition OnlineExamples.cpp:147
void install(const OnlineExample &ex, std::function< void(QString)> done, bool replace=false)
Definition OnlineExamples.cpp:447
Definition StartScreen.hpp:1082
void joinSession()
Join a collaborative session hosted by another instance of score.
void addLoadCrashedSession()
Shows the "Restore last session" entry.
Definition StartScreen.hpp:2385
void dismiss()
Closes the start screen without opening anything (something else took over).
Definition StartScreen.hpp:2397
void addJoinSession()
Shows the "Join a collaborative session" entry.
Definition StartScreen.hpp:2391
void reopen()
Shows the start screen again after an action that led nowhere (cancelled dialog......
Definition StartScreen.hpp:2403
void openTemplate(const QString &file)
Opens the file as a new untitled document (templates, examples).
Preview of a score shown next to a hovered entry: thumbnail, name, author.
Definition StartScreen.hpp:443
void showFor(QWidget *anchor, const QRect &bounds)
Shows the popup next to anchor, inside bounds (parent coordinates).
Definition StartScreen.hpp:484
Utilities for OSSIA data structures.
Definition DeviceInterface.hpp:35
Base toolkit upon which the software is built.
Definition Application.cpp:83
QString libraryRootPath()
Root of the user library, from the settings.
Definition DocumentTemplates.cpp:27
void setCursor(Qt::CursorShape c)
setCursor sets the cursor safely.
Definition ClearLayout.cpp:8
QString firstRunDocumentTemplate()
The guided score for first-time users, or an empty string.
Definition DocumentTemplates.cpp:163
STL namespace.
Definition ObjectMatches.hpp:6
Definition AboutWidget.hpp:21
QFont smallFont
Captions, license text.
Definition AboutWidget.hpp:24
QFont sectionFont
Tabs (uppercase)
Definition AboutWidget.hpp:22
QFont itemFont
Regular text.
Definition AboutWidget.hpp:23
One entry of assets/scores/index.json.
Definition OnlineExamples.hpp:16