view src/gui/pages/anime_list.cc @ 327:b5d6c27c308f

anime: refactor Anime::SeriesSeason to Season class ToLocalString has also been altered to take in both season and year because lots of locales actually treat formatting seasons differently! most notably is Russian which adds a suffix at the end to notate seasons(??)
author Paper <paper@paper.us.eu.org>
date Thu, 13 Jun 2024 01:49:18 -0400
parents 5d3c9b31aa6e
children 886f66775f31
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 <QStylePainter>
#include <QStyledItemDelegate>
#include <QThreadPool>
#include <QRunnable>
#include <QTreeView>

#include <set>

AnimeListPageUpdateEntryThread::AnimeListPageUpdateEntryThread(QObject* parent) : QThread(parent) {}

void AnimeListPageUpdateEntryThread::AddToQueue(int id) {
	const std::lock_guard<std::mutex> guard(queue_mutex_);
	queue_.push(id);
}

/* processes the queue... */
void AnimeListPageUpdateEntryThread::run() {
	queue_mutex_.lock();
	while (!queue_.empty() && !isInterruptionRequested()) {
		int id = queue_.front();

		/* unlock the mutex for a long blocking operation, so items
		 * can be added without worry */
		queue_mutex_.unlock();
		Services::UpdateAnimeEntry(id);
		queue_mutex_.lock();

		queue_.pop();
	}
	queue_mutex_.unlock();

	emit NeedRefresh();
}

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: return Strings::ToQString(Translate::ToLocalString(list[index.row()].GetSeason()));
				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("-");
					return Strings::ToQString(Time::GetSecondsAsRelativeString(Time::GetSystemTime() - list[index.row()].GetUserTimeUpdated()));
				}
				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()].GetStartedDate().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) {
	/* this ought to just add to the thread's buffer. */
	if (update_entry_thread_.isRunning())
		update_entry_thread_.requestInterruption();

	update_entry_thread_.AddToQueue(id);
	update_entry_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)
			continue;
		animes.insert(&Anime::db.items[anime->GetId()]);
	}

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

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

			dialog->show();
			dialog->raise();
			dialog->activateWindow();
			connect(dialog, &InformationDialog::finished, dialog, &InformationDialog::deleteLater);
		}
	});
	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 = Anime::db.items[source_model->GetAnimeFromIndex(index)->GetId()];

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

	dialog->show();
	dialog->raise();
	dialog->activateWindow();
	connect(dialog, &InformationDialog::finished, dialog, &InformationDialog::deleteLater);
}

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]);
	});

	connect(&update_entry_thread_, &AnimeListPageUpdateEntryThread::NeedRefresh, this, &AnimeListPage::Refresh);

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