view src/gui/pages/anime_list.cc @ 250:c130f47f6f48

*: many many changes e.g. the search page is actually implemented now!
author Paper <paper@paper.us.eu.org>
date Sun, 04 Feb 2024 21:17:17 -0500
parents 4d461ef7d424
children 862d0d8619f6
line wrap: on
line source

/**
 * anime_list.cpp: defines the anime list page
 * and widgets.
 *
 * much of this file is based around
 * Qt's original QTabWidget implementation, because
 * I needed a somewhat native way to create a tabbed
 * widget with only one subwidget that worked exactly
 * like a native tabbed widget.
 **/
#include "gui/pages/anime_list.h"
#include "core/anime.h"
#include "core/anime_db.h"
#include "core/session.h"
#include "core/strings.h"
#include "core/time.h"
#include "gui/dialog/information.h"
#include "gui/translate/anime.h"
#include "services/services.h"

#include <QDate>
#include <QDebug>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QMenu>
#include <QProgressBar>
#include <QShortcut>
#include <QTreeView>
#include <QStylePainter>
#include <QStyledItemDelegate>
#include <QThread>

#include <set>

AnimeListPageSortFilter::AnimeListPageSortFilter(QObject* parent) : QSortFilterProxyModel(parent) {
}

bool AnimeListPageSortFilter::lessThan(const QModelIndex& l, const QModelIndex& r) const {
	QVariant left = sourceModel()->data(l, sortRole());
	QVariant right = sourceModel()->data(r, sortRole());

	switch (left.userType()) {
		case QMetaType::Int:
		case QMetaType::UInt:
		case QMetaType::LongLong:
		case QMetaType::ULongLong: return left.toInt() < right.toInt();
		case QMetaType::QDate: return left.toDate() < right.toDate();
		case QMetaType::QString:
		default: return QString::compare(left.toString(), right.toString(), Qt::CaseInsensitive) < 0;
	}
}

/* -------------------------------------------------- */

AnimeListPageModel::AnimeListPageModel(QObject* parent, Anime::ListStatus _status) : QAbstractListModel(parent) {
	status = _status;
	return;
}

int AnimeListPageModel::rowCount(const QModelIndex& parent) const {
	return list.size();
	(void)(parent);
}

int AnimeListPageModel::columnCount(const QModelIndex& parent) const {
	return NB_COLUMNS;
	(void)(parent);
}

QVariant AnimeListPageModel::headerData(const int section, const Qt::Orientation orientation, const int role) const {
	if (role == Qt::DisplayRole) {
		switch (section) {
			case AL_TITLE: return tr("Anime title");
			case AL_PROGRESS: return tr("Progress");
			case AL_EPISODES: return tr("Episodes");
			case AL_TYPE: return tr("Type");
			case AL_SCORE: return tr("Score");
			case AL_SEASON: return tr("Season");
			case AL_STARTED: return tr("Date started");
			case AL_COMPLETED: return tr("Date completed");
			case AL_NOTES: return tr("Notes");
			case AL_AVG_SCORE: return tr("Average score");
			case AL_UPDATED: return tr("Last updated");
			default: return {};
		}
	} else if (role == Qt::TextAlignmentRole) {
		switch (section) {
			case AL_TITLE:
			case AL_NOTES: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
			case AL_PROGRESS:
			case AL_EPISODES:
			case AL_TYPE:
			case AL_SCORE:
			case AL_AVG_SCORE: return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
			case AL_SEASON:
			case AL_STARTED:
			case AL_COMPLETED:
			case AL_UPDATED: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
			default: return QAbstractListModel::headerData(section, orientation, role);
		}
	}
	return QAbstractListModel::headerData(section, orientation, role);
}

QVariant AnimeListPageModel::data(const QModelIndex& index, int role) const {
	if (!index.isValid())
		return QVariant();
	switch (role) {
		case Qt::DisplayRole:
			switch (index.column()) {
				case AL_TITLE: return Strings::ToQString(list[index.row()].GetUserPreferredTitle());
				case AL_PROGRESS:
					return QString::number(list[index.row()].GetUserProgress()) + "/" +
						   QString::number(list[index.row()].GetEpisodes());
				case AL_EPISODES: return list[index.row()].GetEpisodes();
				case AL_SCORE: return Strings::ToQString(list[index.row()].GetUserPresentableScore());
				case AL_TYPE: return Strings::ToQString(Translate::ToString(list[index.row()].GetFormat()));
				case AL_SEASON: {
					std::optional<unsigned int> year = list[index.row()].GetAirDate().GetYear();
					if (!year)
						return "Unknown Unknown";
					return Strings::ToQString(Translate::ToLocalString(list[index.row()].GetSeason()) + " " + Strings::ToUtf8String(year.value()));
				}
				case AL_AVG_SCORE: return QString::number(list[index.row()].GetAudienceScore()) + "%";
				case AL_STARTED: return list[index.row()].GetUserDateStarted().GetAsQDate();
				case AL_COMPLETED: return list[index.row()].GetUserDateCompleted().GetAsQDate();
				case AL_UPDATED: {
					if (list[index.row()].GetUserTimeUpdated() == 0)
						return QString("-");
					Time::Duration duration(Time::GetSystemTime() - list[index.row()].GetUserTimeUpdated());
					return Strings::ToQString(duration.AsRelativeString());
				}
				case AL_NOTES: return Strings::ToQString(list[index.row()].GetUserNotes());
				default: return "";
			}
			break;
		case Qt::UserRole:
			switch (index.column()) {
				case AL_PROGRESS: return list[index.row()].GetUserProgress();
				case AL_SCORE: return list[index.row()].GetUserScore();
				case AL_TYPE: return static_cast<int>(list[index.row()].GetFormat());
				case AL_SEASON: return list[index.row()].GetAirDate().GetAsQDate();
				case AL_AVG_SCORE: return list[index.row()].GetAudienceScore();
				case AL_UPDATED: return QVariant::fromValue(list[index.row()].GetUserTimeUpdated());
				default: return data(index, Qt::DisplayRole);
			}
			break;
		case Qt::TextAlignmentRole:
			switch (index.column()) {
				case AL_TITLE:
				case AL_NOTES: return QVariant(Qt::AlignLeft | Qt::AlignVCenter);
				case AL_PROGRESS:
				case AL_EPISODES:
				case AL_TYPE:
				case AL_SCORE:
				case AL_AVG_SCORE: return QVariant(Qt::AlignCenter | Qt::AlignVCenter);
				case AL_SEASON:
				case AL_STARTED:
				case AL_COMPLETED:
				case AL_UPDATED: return QVariant(Qt::AlignRight | Qt::AlignVCenter);
				default: break;
			}
			break;
	}
	return QVariant();
}

Anime::Anime* AnimeListPageModel::GetAnimeFromIndex(QModelIndex index) {
	return &list.at(index.row());
}

void AnimeListPageModel::RefreshList() {
	/* equivalent to hasChildren()... */
	if (!rowCount(index(0))) {
		beginInsertRows(QModelIndex(), 0, 0);
		endInsertRows();
	}

	beginResetModel();

	list.clear();

	for (const auto& a : Anime::db.items) {
		if (a.second.IsInUserList() && a.second.GetUserStatus() == status) {
			list.push_back(a.second);
		}
	}

	endResetModel();
}

/* ----------------------------------------------------------------- */

int AnimeListPage::VisibleColumnsCount() const {
	int count = 0;

	for (int i = 0, end = tree_view->header()->count(); i < end; i++) {
		if (!tree_view->isColumnHidden(i))
			count++;
	}

	return count;
}

void AnimeListPage::SetColumnDefaults() {
	tree_view->setColumnHidden(AnimeListPageModel::AL_SEASON, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_TYPE, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_UPDATED, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_PROGRESS, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_SCORE, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_TITLE, false);
	tree_view->setColumnHidden(AnimeListPageModel::AL_EPISODES, true);
	tree_view->setColumnHidden(AnimeListPageModel::AL_AVG_SCORE, true);
	tree_view->setColumnHidden(AnimeListPageModel::AL_STARTED, true);
	tree_view->setColumnHidden(AnimeListPageModel::AL_COMPLETED, true);
	tree_view->setColumnHidden(AnimeListPageModel::AL_UPDATED, true);
	tree_view->setColumnHidden(AnimeListPageModel::AL_NOTES, true);
}

void AnimeListPage::UpdateAnime(int id) {
	QThread* thread = QThread::create([this, id] {
		Services::UpdateAnimeEntry(id);
	});

	connect(thread, &QThread::finished, this, &AnimeListPage::Refresh);
	connect(thread, &QThread::finished, thread, &QThread::deleteLater);

	thread->start();
}

void AnimeListPage::RemoveAnime(int id) {
	Anime::Anime& anime = Anime::db.items[id];
	anime.RemoveFromUserList();
	Refresh();
}

void AnimeListPage::DisplayColumnHeaderMenu() {
	QMenu* menu = new QMenu(this);
	menu->setAttribute(Qt::WA_DeleteOnClose);
	menu->setTitle(tr("Column visibility"));
	menu->setToolTipsVisible(true);

	for (int i = 0; i < AnimeListPageModel::NB_COLUMNS; i++) {
		if (i == AnimeListPageModel::AL_TITLE)
			continue;
		const auto column_name =
			sort_models[tab_bar->currentIndex()]->headerData(i, Qt::Horizontal, Qt::DisplayRole).toString();

		QAction* action = menu->addAction(column_name, this, [this, i](const bool checked) {
			if (!checked && (VisibleColumnsCount() <= 1))
				return;

			tree_view->setColumnHidden(i, !checked);

			if (checked && (tree_view->columnWidth(i) <= 5))
				tree_view->resizeColumnToContents(i);

			// SaveSettings();
		});

		action->setCheckable(true);
		action->setChecked(!tree_view->isColumnHidden(i));
	}

	menu->addSeparator();
	menu->addAction(tr("Reset to defaults"), this, [this]() {
		for (int i = 0, count = tree_view->header()->count(); i < count; ++i) {
			SetColumnDefaults();
		}
		// SaveSettings();
	});
	menu->popup(QCursor::pos());
}

void AnimeListPage::DisplayListMenu() {
	QMenu* menu = new QMenu(this);
	menu->setAttribute(Qt::WA_DeleteOnClose);
	menu->setToolTipsVisible(true);

	AnimeListPageModel* source_model =
		reinterpret_cast<AnimeListPageModel*>(sort_models[tab_bar->currentIndex()]->sourceModel());
	const QItemSelection selection =
		sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());

	std::set<Anime::Anime*> animes;
	for (const auto& index : selection.indexes()) {
		if (!index.isValid())
			continue;
		Anime::Anime* anime = source_model->GetAnimeFromIndex(index);
		if (anime)
			animes.insert(anime);
	}

	menu->addAction(tr("Information"), [this, animes] {
		for (auto& anime : animes) {
			InformationDialog* dialog = new InformationDialog(*anime, [this, anime] {
				UpdateAnime(anime->GetId());
			}, InformationDialog::PAGE_MAIN_INFO, this);

			dialog->show();
			dialog->raise();
			dialog->activateWindow();
		}
	});
	menu->addSeparator();
	menu->addAction(tr("Edit"), [this, animes] {
		for (auto& anime : animes) {
			InformationDialog* dialog = new InformationDialog(*anime, [this, anime] {
				UpdateAnime(anime->GetId());
			}, InformationDialog::PAGE_MY_LIST, this);

			dialog->show();
			dialog->raise();
			dialog->activateWindow();
		}
	});
	menu->addAction(tr("Delete from list..."), [this, animes] {
		for (auto& anime : animes) {
			RemoveAnime(anime->GetId());
		}
	});
	menu->popup(QCursor::pos());
}

void AnimeListPage::ItemDoubleClicked() {
	/* throw out any other garbage */
	const QItemSelection selection =
		sort_models[tab_bar->currentIndex()]->mapSelectionToSource(tree_view->selectionModel()->selection());
	if (!selection.indexes().first().isValid()) {
		return;
	}

	AnimeListPageModel* source_model =
		reinterpret_cast<AnimeListPageModel*>(sort_models[tab_bar->currentIndex()]->sourceModel());

	const QModelIndex index = source_model->index(selection.indexes().first().row());
	Anime::Anime* anime = source_model->GetAnimeFromIndex(index);

	InformationDialog* dialog = new InformationDialog(*anime, [this, anime] {
		UpdateAnime(anime->GetId());
	}, InformationDialog::PAGE_MAIN_INFO, this);

	dialog->show();
	dialog->raise();
	dialog->activateWindow();
}

void AnimeListPage::RefreshList() {
	for (unsigned int i = 0; i < sort_models.size(); i++)
		reinterpret_cast<AnimeListPageModel*>(sort_models[i]->sourceModel())->RefreshList();
}

void AnimeListPage::RefreshTabs() {
	for (unsigned int i = 0; i < sort_models.size(); i++)
		tab_bar->setTabText(i, Strings::ToQString(Translate::ToString(Anime::ListStatuses[i])) + " (" +
								   QString::number(Anime::db.GetListsAnimeAmount(Anime::ListStatuses[i])) + ")");
}

void AnimeListPage::Refresh() {
	RefreshList();
	RefreshTabs();
}

/* -------- QTabWidget replication begin --------- */

void AnimeListPage::InitBasicStyle(QStyleOptionTabWidgetFrame* option) const {
	if (!option)
		return;

	option->initFrom(this);
	option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
	option->shape = QTabBar::RoundedNorth;
	option->tabBarRect = tab_bar->geometry();
}

void AnimeListPage::InitStyle(QStyleOptionTabWidgetFrame* option) const {
	if (!option)
		return;

	InitBasicStyle(option);

	QSize t(0, tree_view->frameWidth());
	if (tab_bar->isVisibleTo(this)) {
		t = tab_bar->sizeHint();
		t.setWidth(width());
	}

	option->tabBarSize = t;

	QRect selected_tab_rect = tab_bar->tabRect(tab_bar->currentIndex());
	selected_tab_rect.moveTopLeft(selected_tab_rect.topLeft() + option->tabBarRect.topLeft());
	option->selectedTabRect = selected_tab_rect;

	option->lineWidth = style()->pixelMetric(QStyle::PM_DefaultFrameWidth, nullptr, this);
}

void AnimeListPage::SetupLayout() {
	QStyleOptionTabWidgetFrame option;
	InitStyle(&option);

	QRect tabRect = style()->subElementRect(QStyle::SE_TabWidgetTabBar, &option, this);
	tabRect.setLeft(tabRect.left() + 1);
	panelRect = style()->subElementRect(QStyle::SE_TabWidgetTabPane, &option, this);
	QRect contentsRect = style()->subElementRect(QStyle::SE_TabWidgetTabContents, &option, this);

	tab_bar->setGeometry(tabRect);
	tree_view->parentWidget()->setGeometry(contentsRect);
}

void AnimeListPage::paintEvent(QPaintEvent*) {
	QStylePainter p(this);

	QStyleOptionTabWidgetFrame opt;
	InitStyle(&opt);
	opt.rect = panelRect;
	p.drawPrimitive(QStyle::PE_FrameTabWidget, opt);
}

void AnimeListPage::resizeEvent(QResizeEvent* e) {
	QWidget::resizeEvent(e);
	SetupLayout();
}

void AnimeListPage::showEvent(QShowEvent*) {
	SetupLayout();
	Refresh();
}

/* --------- QTabWidget replication end ---------- */

AnimeListPage::AnimeListPage(QWidget* parent) : QWidget(parent) {
	/* Tab bar */
	tab_bar = new QTabBar(this);
	tab_bar->setExpanding(false);
	tab_bar->setDrawBase(false);

	/* Tree view... */
	QWidget* tree_widget = new QWidget(this);
	tree_view = new QTreeView(tree_widget);
	tree_view->setUniformRowHeights(true);
	tree_view->setAllColumnsShowFocus(false);
	tree_view->setAlternatingRowColors(true);
	tree_view->setSortingEnabled(true);
	tree_view->setSelectionMode(QAbstractItemView::ExtendedSelection);
	tree_view->setItemsExpandable(false);
	tree_view->setRootIsDecorated(false);
	tree_view->setContextMenuPolicy(Qt::CustomContextMenu);
	tree_view->setFrameShape(QFrame::NoFrame);

	for (unsigned int i = 0; i < sort_models.size(); i++) {
		tab_bar->addTab(Strings::ToQString(Translate::ToString(Anime::ListStatuses[i])) + " (" +
						QString::number(Anime::db.GetListsAnimeAmount(Anime::ListStatuses[i])) + ")");
		sort_models[i] = new AnimeListPageSortFilter(tree_view);
		sort_models[i]->setSourceModel(new AnimeListPageModel(this, Anime::ListStatuses[i]));
		sort_models[i]->setSortRole(Qt::UserRole);
		sort_models[i]->setSortCaseSensitivity(Qt::CaseInsensitive);
	}

	tree_view->setModel(sort_models[0]);

	/* Set column widths */
	tree_view->setColumnWidth(AnimeListPageModel::AL_TITLE, 300);
	tree_view->setColumnWidth(AnimeListPageModel::AL_PROGRESS, 200);
	tree_view->setColumnWidth(AnimeListPageModel::AL_SCORE, 50);
	tree_view->setColumnWidth(AnimeListPageModel::AL_AVG_SCORE, 55);
	tree_view->setColumnWidth(AnimeListPageModel::AL_TYPE, 65);
	tree_view->setColumnWidth(AnimeListPageModel::AL_SEASON, 95);
	tree_view->setColumnWidth(AnimeListPageModel::AL_STARTED, 90);
	tree_view->setColumnWidth(AnimeListPageModel::AL_COMPLETED, 90);
	tree_view->setColumnWidth(AnimeListPageModel::AL_UPDATED, 100);
	tree_view->setColumnWidth(AnimeListPageModel::AL_NOTES, 100);

	QHBoxLayout* layout = new QHBoxLayout(tree_widget);
	layout->addWidget(tree_view);
	layout->setContentsMargins(0, 0, 0, 0);

	/* Double click stuff */
	connect(tree_view, &QAbstractItemView::doubleClicked, this, &AnimeListPage::ItemDoubleClicked);
	connect(tree_view, &QWidget::customContextMenuRequested, this, &AnimeListPage::DisplayListMenu);

	/* Enter & return keys */
	connect(new QShortcut(Qt::Key_Return, tree_view, nullptr, nullptr, Qt::WidgetShortcut), &QShortcut::activated, this,
			&AnimeListPage::ItemDoubleClicked);

	connect(new QShortcut(Qt::Key_Enter, tree_view, nullptr, nullptr, Qt::WidgetShortcut), &QShortcut::activated, this,
			&AnimeListPage::ItemDoubleClicked);

	tree_view->header()->setStretchLastSection(false);
	tree_view->header()->setContextMenuPolicy(Qt::CustomContextMenu);
	connect(tree_view->header(), &QWidget::customContextMenuRequested, this, &AnimeListPage::DisplayColumnHeaderMenu);

	connect(tab_bar, &QTabBar::currentChanged, this, [this](int index) {
		if (sort_models[index])
			tree_view->setModel(sort_models[index]);
	});

	SetColumnDefaults();
	setFocusPolicy(Qt::TabFocus);
	setFocusProxy(tab_bar);
}