From fb331a0df798fa7c3f69d838612244943783ca65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaakko=20Kera=CC=88nen?= Date: Tue, 5 Jul 2016 11:27:29 +0300 Subject: [PATCH] UI|Resources|Home: Selecting .box add-on contents Added a new popup for selecting which contained add-ons are loaded when a .box is loaded. The selections are saved in the `Config.resource.selectedPackages` variable. --- .../ui/widgets/packagecontentoptionswidget.h | 38 ++ .../include/ui/widgets/packageswidget.h | 4 + .../modules/appconfig.de | 1 + .../client/src/ui/home/gamecolumnwidget.cpp | 6 +- .../src/ui/home/packagescolumnwidget.cpp | 20 +- .../widgets/packagecontentoptionswidget.cpp | 337 ++++++++++++++++++ .../client/src/ui/widgets/packageswidget.cpp | 125 ++++++- 7 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 doomsday/apps/client/include/ui/widgets/packagecontentoptionswidget.h create mode 100644 doomsday/apps/client/src/ui/widgets/packagecontentoptionswidget.cpp diff --git a/doomsday/apps/client/include/ui/widgets/packagecontentoptionswidget.h b/doomsday/apps/client/include/ui/widgets/packagecontentoptionswidget.h new file mode 100644 index 0000000000..59e88ac9b9 --- /dev/null +++ b/doomsday/apps/client/include/ui/widgets/packagecontentoptionswidget.h @@ -0,0 +1,38 @@ +/** @file packagecontentoptionswidget.h Widget for package content options. + * + * @authors Copyright (c) 2016 Jaakko Keränen + * + * @par License + * GPL: http://www.gnu.org/licenses/gpl.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. This program is distributed in the hope that it + * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. You should have received a copy of the GNU + * General Public License along with this program; if not, see: + * http://www.gnu.org/licenses + */ + +#ifndef DENG_CLIENT_UI_PACKAGECONTENTOPTIONSWIDGET_H +#define DENG_CLIENT_UI_PACKAGECONTENTOPTIONSWIDGET_H + +#include + +/** + * Widget for package content options. + */ +class PackageContentOptionsWidget : public de::GuiWidget +{ +public: + PackageContentOptionsWidget(de::String const &packageId, + de::Rule const &maxHeight, + de::String const &name = de::String()); + +private: + DENG2_PRIVATE(d) +}; + +#endif // DENG_CLIENT_UI_PACKAGECONTENTOPTIONSWIDGET_H diff --git a/doomsday/apps/client/include/ui/widgets/packageswidget.h b/doomsday/apps/client/include/ui/widgets/packageswidget.h index a4594a40f4..c189eeebcf 100644 --- a/doomsday/apps/client/include/ui/widgets/packageswidget.h +++ b/doomsday/apps/client/include/ui/widgets/packageswidget.h @@ -51,6 +51,8 @@ class PackagesWidget : public de::GuiWidget, public de::IPersistent void setFilterEditorMinimumY(de::Rule const &minY); + //void setMaximumPanelHeight(de::Rule const &maxHeight); + void setPackageStatus(IPackageStatus const &packageStatus); /** @@ -94,6 +96,8 @@ class PackagesWidget : public de::GuiWidget, public de::IPersistent de::LineEditWidget &searchTermsEditor(); + void openContentOptions(de::ui::Item const &item); + // Events. void initialize(); void update(); diff --git a/doomsday/apps/client/net.dengine.client.pack/modules/appconfig.de b/doomsday/apps/client/net.dengine.client.pack/modules/appconfig.de index ddd60738a3..6e5d1a7164 100644 --- a/doomsday/apps/client/net.dengine.client.pack/modules/appconfig.de +++ b/doomsday/apps/client/net.dengine.client.pack/modules/appconfig.de @@ -97,6 +97,7 @@ def setDefaults(d) record d.resource d.resource.iwadFolder = '' d.resource.packageFolder = '' + d.resource.selectedPackages = {} # Renderer settings. record d.render diff --git a/doomsday/apps/client/src/ui/home/gamecolumnwidget.cpp b/doomsday/apps/client/src/ui/home/gamecolumnwidget.cpp index 7b621f84bb..fc21f5c19f 100644 --- a/doomsday/apps/client/src/ui/home/gamecolumnwidget.cpp +++ b/doomsday/apps/client/src/ui/home/gamecolumnwidget.cpp @@ -289,7 +289,11 @@ DENG_GUI_PIMPL(GameColumnWidget) b.as().game().releaseDate().year(); if (!year) { - // ...or identifier. + // Playable profiles first. + if (prof1.isPlayable() && !prof2.isPlayable()) return true; + if (!prof1.isPlayable() && prof2.isPlayable()) return false; + + // Finally, based on identifier. return prof1.game().compareWithoutCase(prof2.game()) < 0; } return year < 0; diff --git a/doomsday/apps/client/src/ui/home/packagescolumnwidget.cpp b/doomsday/apps/client/src/ui/home/packagescolumnwidget.cpp index 641c563df7..b4f8052d0b 100644 --- a/doomsday/apps/client/src/ui/home/packagescolumnwidget.cpp +++ b/doomsday/apps/client/src/ui/home/packagescolumnwidget.cpp @@ -69,17 +69,20 @@ DENG_GUI_PIMPL(PackagesColumnWidget) auto *popMenu = new PopupMenuWidget; popMenu->setColorTheme(Inverted); - popMenu->items() - << new ui::SubwidgetItem(tr("Info"), ui::Down, - [this, packageId] () -> PopupWidget * { - return new PackagePopupWidget(packageId); - }); + popMenu->items() << new ui::SubwidgetItem(tr("Info"), ui::Down, + [this, packageId] () -> PopupWidget * { + return new PackagePopupWidget(packageId); + }); + if (DataBundle::packageBundleFormat(packageId) == DataBundle::Collection) { - popMenu->items() - << new ui::ActionItem(style().images().image("gear"), - tr("Select Packages")); + auto openOpts = [this] () { + packages->openContentOptions(*packages->actionItem()); + }; + popMenu->items() << new ui::ActionItem(style().images().image("gear"), + tr("Select Packages"), new CallbackAction(openOpts)); } + popMenu->items() << new ui::Item(ui::Item::Separator) << new ui::ActionItem(style().images().image("close.ring"), tr("Uninstall...")); @@ -90,6 +93,7 @@ DENG_GUI_PIMPL(PackagesColumnWidget) ScrollAreaWidget &area = self.scrollArea(); area.add(packages = new PackagesWidget("home-packages")); + //packages->setMaximumPanelHeight(self.rule().height() - self.margins().height() - rule("gap")*3); packages->setActionItems(actions); packages->rule() .setInput(Rule::Width, area.contentRule().width()) diff --git a/doomsday/apps/client/src/ui/widgets/packagecontentoptionswidget.cpp b/doomsday/apps/client/src/ui/widgets/packagecontentoptionswidget.cpp new file mode 100644 index 0000000000..bf3c27a320 --- /dev/null +++ b/doomsday/apps/client/src/ui/widgets/packagecontentoptionswidget.cpp @@ -0,0 +1,337 @@ +/** @file packagecontentoptionswidget.cpp Widget for package content options. + * + * @authors Copyright (c) 2016 Jaakko Keränen + * + * @par License + * GPL: http://www.gnu.org/licenses/gpl.html + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. This program is distributed in the hope that it + * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty + * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. You should have received a copy of the GNU + * General Public License along with this program; if not, see: + * http://www.gnu.org/licenses + */ + +#include "ui/widgets/packagecontentoptionswidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace de; + +DENG_GUI_PIMPL(PackageContentOptionsWidget) +, public ChildWidgetOrganizer::IWidgetFactory +{ + /** + * Item representing a contained package. + */ + struct Item : public ui::Item + { + bool selectedByDefault; + String containerPackageId; + String category; + + Item(String const &packageId, bool selectedByDefault, String const &containerPackageId) + : selectedByDefault(selectedByDefault) + , containerPackageId(containerPackageId) + { + setData(packageId); + + if (File const *file = PackageLoader::get().select(packageId)) + { + Record const &meta = file->objectNamespace(); + setLabel(meta.gets(Package::VAR_PACKAGE_TITLE)); + category = meta.gets(QStringLiteral("package.category"), ""); + } + else + { + setLabel(packageId); + } + + if (!selectedByDefault) + { + setLabel(label() + " " _E(s)_E(b)_E(D) "ALT"); + } + } + + String packageId() const + { + return data().toString(); + } + + DictionaryValue &conf() + { + return Config::get()["resource.selectedPackages"].value(); + } + + DictionaryValue const &conf() const + { + return Config::get()["resource.selectedPackages"].value(); + } + + bool isSelected() const + { + if (conf().contains(TextValue(containerPackageId))) + { + DictionaryValue const &sel = conf().element(TextValue(containerPackageId)) + .as(); + if (sel.contains(TextValue(packageId()))) + { + return sel.element(TextValue(packageId())).isTrue(); + } + } + return selectedByDefault; + } + + void setSelected(bool selected) + { + if (!conf().contains(TextValue(containerPackageId))) + { + conf().add(new TextValue(containerPackageId), + new DictionaryValue); + } + DictionaryValue &sel = conf().element(TextValue(containerPackageId)) + .as(); + sel.add(new TextValue(packageId()), new NumberValue(selected)); + notifyChange(); + } + + void reset() + { + conf().remove(TextValue(containerPackageId)); + notifyChange(); + } + }; + + String packageId; + LabelWidget *summary; + MenuWidget *contents; + + Impl(Public *i, String const &packageId, Rule const &maxHeight) + : Base(i) + , packageId(packageId) + { + self.add(summary = new LabelWidget); + summary->setSizePolicy(ui::Fixed, ui::Expand); + summary->setFont("small"); + summary->setTextColor("altaccent"); + summary->setAlignment(ui::AlignLeft); + + auto *label = LabelWidget::newWithText(tr("Select:"), &self); + label->setSizePolicy(ui::Expand, ui::Expand); + label->setFont("small"); + label->setAlignment(ui::AlignLeft); + + auto *allButton = new ButtonWidget; + allButton->setSizePolicy(ui::Expand, ui::Expand); + allButton->setText(tr("All")); + allButton->setFont("small"); + self.add(allButton); + + auto *noneButton = new ButtonWidget; + noneButton->setSizePolicy(ui::Expand, ui::Expand); + noneButton->setText(tr("None")); + noneButton->setFont("small"); + self.add(noneButton); + + auto *defaultsButton = new ButtonWidget; + defaultsButton->setSizePolicy(ui::Expand, ui::Expand); + defaultsButton->setText(tr("Defaults")); + defaultsButton->setFont("small"); + self.add(defaultsButton); + + contents = new MenuWidget; + contents->enableIndicatorDraw(true); + contents->setBehavior(ChildVisibilityClipping); + contents->setVirtualizationEnabled(true, rule("unit").value()*2 + + style().fonts().font("default").height().value()); + contents->layout().setRowPadding(Const(0)); + contents->organizer().setWidgetFactory(*this); + contents->setGridSize(1, ui::Filled, 0, ui::Fixed); + self.add(contents); + + // Layout. + auto &rect = self.rule(); + SequentialLayout layout(rect.left() + self.margins().left(), + rect.top() + self.margins().top()); + layout << *label << *summary << *contents; + summary->rule() + .setInput(Rule::Width, rect.width() - self.margins().width()); + contents->rule() + .setInput(Rule::Left, rect.left()) + .setInput(Rule::Width, summary->rule().width() + self.margins().left()) + .setInput(Rule::Height, OperatorRule::minimum(contents->contentHeight(), + maxHeight - self.margins().height() - + label->rule().height() - + summary->rule().height())); + + SequentialLayout(label->rule().right(), label->rule().top(), ui::Right) + << *defaultsButton << *noneButton << *allButton; + self.rule().setInput(Rule::Height, layout.height() + self.margins().bottom()); + + // Configure margins. + for (Widget *w : self.childWidgets()) + { + w->as().margins().set("dialog.gap"); + } + contents->margins().setLeftRight("gap"); + + // Actions. + defaultsButton->setActionFn([this] () + { + contents->items().forAll([] (ui::Item &item) { + if (auto *i = item.maybeAs()) i->reset(); + return LoopContinue; + }); + }); + noneButton->setActionFn([this] () + { + contents->items().forAll([] (ui::Item &item) { + if (auto *i = item.maybeAs()) i->setSelected(false); + return LoopContinue; + }); + }); + allButton->setActionFn([this] () + { + contents->items().forAll([] (ui::Item &item) { + if (auto *i = item.maybeAs()) i->setSelected(true); + return LoopContinue; + }); + }); + } + + void populate() + { + contents->items().clear(); + + File const *file = PackageLoader::get().select(packageId); + if (!file) return; + + Record const &meta = file->objectNamespace().subrecord(Package::VAR_PACKAGE); + + ArrayValue const &requires = meta.geta("requires"); + ArrayValue const &recommends = meta.geta("recommends"); + ArrayValue const &extras = meta.geta("extras"); + + auto const totalCount = requires.size() + recommends.size() + extras.size(); + auto const optionalCount = recommends.size() + extras.size(); + + summary->setText(tr("%1 package%2 (%3 optional)") + .arg(totalCount) + .arg(DENG2_PLURAL_S(totalCount)) + .arg(optionalCount)); + + makeItems(recommends, true); + makeItems(extras, false); + + // Create category headings. + QSet categories; + contents->items().forAll([&categories] (ui::Item const &i) + { + String const cat = i.as().category; + if (!cat.isEmpty()) categories.insert(cat); + return LoopContinue; + }); + for (QString cat : categories) + { + contents->items() << new ui::Item(ui::Item::Separator, cat); + } + + // Sort all the items by category. + contents->items().sort([] (ui::Item const &s, ui::Item const &t) + { + if (!s.isSeparator() && !t.isSeparator()) + { + Item const &a = s.as(); + Item const &b = t.as(); + + int const catComp = a.category.compareWithoutCase(b.category); + if (!catComp) + { + int const nameComp = a.label().compareWithoutCase(b.label()); + return nameComp < 0; + } + return catComp < 0; + } + + if (s.isSeparator() && !t.isSeparator()) + { + return s.label().compareWithoutCase(t.as().category) <= 0; + } + + if (!s.isSeparator() && t.isSeparator()) + { + return s.as().category.compareWithoutCase(t.label()) < 0; + } + + return s.label().compareWithoutCase(t.label()) < 0; + }); + } + + void makeItems(ArrayValue const &ids, bool recommended) + { + for (Value const *value : ids.elements()) + { + contents->items() << new Item(value->asText(), recommended, packageId); + } + } + +//- ChildWidgetOrganizer::IWidgetFactory ------------------------------------------------ + + GuiWidget *makeItemWidget(ui::Item const &item, GuiWidget const *) override + { + if (item.semantics() & ui::Item::Separator) + { + auto *label = new LabelWidget; + label->setFont("separator.label"); + label->setTextColor("accent"); + label->margins().setBottom("unit"); + label->setSizePolicy(ui::Fixed, ui::Expand); + label->setAlignment(ui::AlignLeft); + return label; + } + + auto *toggle = new ToggleWidget; + toggle->setSizePolicy(ui::Fixed, ui::Expand); + toggle->setAlignment(ui::AlignLeft); + toggle->set(Background()); + toggle->margins().setTopBottom("unit"); + QObject::connect(toggle, &ToggleWidget::stateChangedByUser, + [&item] (ToggleWidget::ToggleState active) + { + const_cast(item).as() + .setSelected(active == ToggleWidget::Active); + }); + return toggle; + } + + void updateItemWidget(GuiWidget &widget, ui::Item const &item) override + { + LabelWidget &label = widget.as(); + label.setText(item.label()); + + if (!(item.semantics() & ui::Item::Separator)) + { + widget.as().setActive(item.as().isSelected()); + } + } +}; + +PackageContentOptionsWidget::PackageContentOptionsWidget(String const &packageId, + Rule const &maxHeight, + String const &name) + : GuiWidget(name) + , d(new Impl(this, packageId, maxHeight)) +{ + d->populate(); +} diff --git a/doomsday/apps/client/src/ui/widgets/packageswidget.cpp b/doomsday/apps/client/src/ui/widgets/packageswidget.cpp index 560cf74aeb..e1a1ed216a 100644 --- a/doomsday/apps/client/src/ui/widgets/packageswidget.cpp +++ b/doomsday/apps/client/src/ui/widgets/packageswidget.cpp @@ -19,7 +19,9 @@ #include "ui/widgets/packageswidget.h" #include "ui/widgets/homeitemwidget.h" #include "ui/widgets/homemenuwidget.h" +#include "ui/widgets/panelbuttonwidget.h" #include "ui/widgets/packagepopupwidget.h" +#include "ui/widgets/packagecontentoptionswidget.h" #include "clientapp.h" #include @@ -117,6 +119,7 @@ DENG_GUI_PIMPL(PackagesWidget) ui::FilteredData filteredPackages { allPackages }; ui::ListData defaultActionItems; ui::Data const *actionItems = &defaultActionItems; + //IndirectRule *maxPanelHeight; bool showHidden = false; bool actionOnlyForSelection = true; @@ -318,13 +321,73 @@ DENG_GUI_PIMPL(PackagesWidget) return estimate; } + void openContentOptions() + { + DENG2_ASSERT(_item->file->target().maybeAs()); + DENG2_ASSERT(_item->file->target().maybeAs()->format() == DataBundle::Collection); + + if (!_optionsPopup) + { + _optionsPopup.reset(new PopupWidget); + _optionsPopup->setDeleteAfterDismissed(true); + _optionsPopup->setAnchorAndOpeningDirection(rule(), ui::Left); + + //_panelScroll = new ScrollAreaWidget; + //_panelScroll->enableIndicatorDraw(true); + + auto *opts = new PackageContentOptionsWidget(packageId(), root().viewHeight()); + + // Add a close button. + auto *close = new ButtonWidget; + close->setSizePolicy(ui::Expand, ui::Expand); + close->margins().set("dialog.gap"); + close->setStyleImage("close.ringless", "small"); + //close->setImageColor(style().colors().colorf("altaccent")); + close->setActionFn([this] () + { + root().setFocus(this); + _optionsPopup->close(); + }); + close->setBackgroundColor("transparent"); + close->rule() + .setInput(Rule::Right, opts->rule().right() - opts->margins().right()) + .setInput(Rule::Top, opts->rule().top() + opts->margins().top()); + + // Embed the options inside a scroll area so longer contents can be + // scrolled. + //_panelScroll->add(opts); + opts->add(close); + //_panelScroll->setContentSize(opts->rule().width(), opts->rule().height()); + opts->rule().setInput(Rule::Width, rule().width()); + //.setInput(Rule::Top, opts->contentRule().top()) + //.setInput(Rule::Left, opts->contentRule().left()); + /*_panelScroll->rule() + .setInput(Rule::Width, rule().width()) + .setInput(Rule::Height, + OperatorRule::minimum(root().viewHeight(), + opts->rule().height()));*/ + + _optionsPopup->setContent(opts);//Scroll); + add(_optionsPopup); + _optionsPopup->open(); + } + } + private: PackagesWidget &_owner; PackageItem const *_item; QList _tags; - MenuWidget *_actions; + MenuWidget *_actions = nullptr; + SafeWidgetPtr _optionsPopup; + //ScrollAreaWidget *_panelScroll = nullptr; }; +//- PackagesWidget::Pimpl Methods ------------------------------------------------------- + + /** + * Initializes the PackagesWidget private implementation. + * @param i Public instance. + */ Impl(Public *i) : Base(i) { defaultActionItems << new ui::VariantActionItem(tr("Load"), tr("Unload"), new CallbackAction([this] () @@ -353,6 +416,9 @@ DENG_GUI_PIMPL(PackagesWidget) menu->interactedItem()->notifyChange(); })); + //maxPanelHeight = new IndirectRule; + //maxPanelHeight->setSource(self.rule().height()); + self.add(menu = new HomeMenuWidget); self.add(search = new LineEditWidget); self.add(clearSearch = new ButtonWidget); @@ -396,9 +462,9 @@ DENG_GUI_PIMPL(PackagesWidget) } return filterTerms.isEmpty() || - checkTerms(item.data().toString()) || // ID - checkTerms(item.info->gets(VAR_TITLE)) || - checkTerms(item.info->gets(VAR_TAGS)); + checkTerms({ item.data().toString(), // ID + item.info->gets(VAR_TITLE), + item.info->gets(VAR_TAGS) }); }); menu->setItems(filteredPackages); menu->setBehavior(ChildVisibilityClipping); @@ -434,6 +500,7 @@ DENG_GUI_PIMPL(PackagesWidget) ~Impl() { + //releaseRef(maxPanelHeight); releaseRef(searchMinY); // Private instance deleted before child widgets. @@ -460,6 +527,10 @@ DENG_GUI_PIMPL(PackagesWidget) void populate() { + qDebug() << "Populating" << &self; + + showProgressIndicator(false); + StringList packages = App::packageLoader().findAllPackages(); // Remove from the list those packages that are no longer listed. @@ -495,7 +566,6 @@ DENG_GUI_PIMPL(PackagesWidget) } allPackages.sort(); - showProgressIndicator(false); emit self.itemCountChanged(filteredPackages.size(), allPackages.size()); } @@ -540,7 +610,6 @@ DENG_GUI_PIMPL(PackagesWidget) showHidden = filterTerms.contains(TAG_HIDDEN); if (showHidden) filterTerms.removeAll(TAG_HIDDEN); - //menu->organizer().refilter(); filteredPackages.refilter(); emit self.itemCountChanged(filteredPackages.size(), allPackages.size()); @@ -555,7 +624,7 @@ DENG_GUI_PIMPL(PackagesWidget) } } - void dataBundlesIdentified(bool) override + void dataBundlesIdentified() override { // After bundles have been refreshed, make sure the list items are up to date. if (!mainCall) @@ -573,14 +642,29 @@ DENG_GUI_PIMPL(PackagesWidget) showProgressIndicator(true); } - bool checkTerms(String const &text) const + /** + * Checks whether the filter terms can be found in the provided text strings. + * All terms must be found in at least one string, but all terms need not be found in + * every provided string. + * + * @param texts Text strings. + * + * @return @c true, if all filter terms found. + */ + bool checkTerms(StringList texts) const { for (QString const &filterTerm : filterTerms) { - if (!text.contains(filterTerm, Qt::CaseInsensitive)) + bool found = false; + for (String const &text : texts) { - return false; + if (text.contains(filterTerm, Qt::CaseInsensitive)) + { + found = true; + break; + } } + if (!found) return false; } return true; } @@ -624,6 +708,11 @@ void PackagesWidget::setFilterEditorMinimumY(Rule const &minY) changeRef(d->searchMinY, minY); } +/*void PackagesWidget::setMaximumPanelHeight(Rule const &maxHeight) +{ + //d->maxPanelHeight->setSource(maxHeight - d->search->rule().height() - rule("gap")); +}*/ + void PackagesWidget::setPackageStatus(IPackageStatus const &packageStatus) { d->packageStatus = &packageStatus; @@ -734,6 +823,17 @@ LineEditWidget &PackagesWidget::searchTermsEditor() return *d->search; } +void PackagesWidget::openContentOptions(ui::Item const &item) +{ + if (auto *widget = d->menu->organizer().itemWidget(item)) + { + if (auto *itemWidget = widget->maybeAs()) + { + itemWidget->openContentOptions(); + } + } +} + void PackagesWidget::initialize() { GuiWidget::initialize(); @@ -763,6 +863,11 @@ void PackagesWidget::update() // Update search field background opacity. d->search->setUnfocusedBackgroundOpacity(d->searchBackgroundOpacity); } + +// if (d->menu->isHidden() && DoomsdayApp::bundles().isEverythingIdentified()) +// { +// d->populate(); +// } } void PackagesWidget::operator >> (PersistentState &toState) const