| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- #include "BibliothecaWindow.hpp"
- #include "BookImport.hpp"
- #include <gtkmm/filedialog.h>
- #include <gtkmm/filefilter.h>
- #include <giomm/file.h>
- #include <giomm/liststore.h>
- #include <giomm/asyncresult.h>
- #include <glibmm/main.h>
- #include <glibmm/markup.h>
- #include <thread>
- #include <iostream>
- BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
- : m_db(db),
- m_bookList(bookList) {
- set_title("Bibliotheca");
- set_default_size(900, 650);
- buildHeaderBar();
- set_titlebar(m_headerBar);
- auto css = Gtk::CssProvider::create();
- css->load_from_data(R"CSS(
- gridview {
- min-width: 280px;
- }
- gridviewchild {
- background-color: transparent;
- }
- gridviewchild:selected {
- background-color: transparent;
- box-shadow: none;
- }
- gridviewchild > listitem,
- gridviewchild > listitem > * {
- background-color: transparent;
- border: none;
- }
- gridviewchild:selected > listitem,
- gridviewchild:selected > listitem > * {
- background-color: transparent;
- box-shadow: none;
- border: none;
- }
- .book-tile {
- padding: 4px;
- border-radius: 10px;
- transition: all 120ms ease-in-out;
- min-width: 160px;
- border: 2px solid transparent;
- background-color: transparent;
- }
- .book-tile.book-tile-hover {
- border: 2px solid rgba(120, 180, 255, 0.55);
- border-radius: 16px;
- background-color: rgba(120, 180, 255, 0.12);
- }
- .toast-box {
- background-color: @theme_bg_color;
- border-radius: 8px;
- padding: 8px 12px;
- margin: 12px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
- }
- .toast-box.toast-error {
- background-color: #c01c28;
- color: white;
- }
- .toast-box.toast-success {
- background-color: #26a269;
- color: white;
- }
- )CSS");
- Gtk::StyleContext::add_provider_for_display(
- Gdk::Display::get_default(), css, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
- // Build the main views
- buildPlaceholder();
- buildToast();
- m_noResults.set_orientation(Gtk::Orientation::VERTICAL);
- m_noResults.set_spacing(12);
- m_noResults.set_halign(Gtk::Align::CENTER);
- m_noResults.set_valign(Gtk::Align::CENTER);
- m_noResultsLabel.set_wrap(true);
- m_noResultsLabel.set_xalign(0.5);
- m_noResultsLabel.set_use_markup(true);
- m_noResults.append(m_noResultsLabel);
- m_shelf = std::make_unique<BookShelf>(m_bookList, /*coverSize*/ 180);
- m_shelf->signalBookActivated().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBookActivated));
- m_searchEntry.set_placeholder_text("Search your library…");
- m_searchEntry.signal_changed().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchChanged));
- m_searchEntry.signal_stop_search().connect([this]() {
- m_searchBar.property_search_mode_enabled() = false;
- });
- m_searchBar.set_child(m_searchEntry);
- m_searchBar.set_show_close_button(true);
- m_searchBar.connect_entry(m_searchEntry);
- m_stack.set_transition_type(Gtk::StackTransitionType::CROSSFADE);
- m_stack.set_transition_duration(150);
- m_stack.set_hexpand(true);
- m_stack.set_vexpand(true);
- m_stack.add(m_placeholder, "empty");
- m_stack.add(*m_shelf, "shelf");
- m_bookDetails = std::make_unique<BookDetails>(m_db, m_bookList);
- m_bookDetails->signalBookRemoved().connect([this]() {
- // Navigate back to shelf when displayed book is removed
- m_stack.set_visible_child("shelf");
- });
- m_stack.add(*m_bookDetails, "details");
- m_stack.add(m_noResults, "noresults");
- m_stack.property_visible_child().signal_changed().connect([this]() {
- auto visible_child = m_stack.get_visible_child_name();
- m_backButton.set_visible(visible_child == "details");
- m_searchButton.set_visible(visible_child != "details");
- m_addBookButton.set_visible(visible_child != "details");
- });
- // Use overlay to show toast on top of content
- m_overlay.set_child(m_stack);
- m_overlay.add_overlay(m_toastRevealer);
- m_mainBox.set_spacing(0);
- m_mainBox.append(m_searchBar);
- m_mainBox.append(m_overlay);
- set_child(m_mainBox);
- m_searchBar.set_key_capture_widget(*this);
- m_searchBar.property_search_mode_enabled().signal_changed().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchModeChanged));
- // Start with placeholder or shelf depending on DB contents
- updateVisibleView();
- // If the list resets (e.g., first app load), choose the right view
- m_bookList.signalReset().connect([this] { updateVisibleView(); });
- m_bookList.signalBookAdded().connect([this](const Book&) { updateVisibleView(); });
- m_bookList.signalBookUpdated().connect([this](const Book&) { updateVisibleView(); });
- m_bookList.signalBookRemoved().connect([this](const std::string&) { updateVisibleView(); });
- m_bookList.signalBulkChanged().connect([this] { updateVisibleView(); });
- }
- void BibliothecaWindow::buildHeaderBar() {
- m_backButton.set_icon_name("go-previous-symbolic");
- m_backButton.set_has_frame(false);
- m_backButton.add_css_class("flat");
- m_backButton.set_tooltip_text("Back");
- m_backButton.signal_clicked().connect([this]() {
- m_stack.set_visible_child("shelf");
- // Restore search filter when returning from details
- if (!m_lastQuery.empty()) {
- m_shelf->setFilter(m_lastQuery);
- updateVisibleView();
- }
- });
- m_headerBar.pack_start(m_backButton);
- m_searchButton.set_icon_name("system-search-symbolic");
- m_searchButton.set_has_frame(false);
- m_searchButton.add_css_class("flat");
- m_searchButton.set_tooltip_text("Search (Ctrl+F)");
- m_searchButton.signal_toggled().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchToggle));
- m_headerBar.pack_start(m_searchButton);
- // Add (+) button on the right
- m_addBookButton.set_icon_name("list-add-symbolic");
- m_addBookButton.set_has_frame(false);
- m_addBookButton.set_tooltip_text("Add books to your library");
- m_addBookButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onAddBookClicked));
- m_headerBar.pack_end(m_addBookButton);
- }
- void BibliothecaWindow::buildPlaceholder() {
- // Centered vertical box
- m_placeholder.set_valign(Gtk::Align::CENTER);
- m_placeholder.set_halign(Gtk::Align::CENTER);
- m_placeholder.set_spacing(12);
- m_placeholder.set_margin_top(24);
- m_placeholder.set_margin_bottom(24);
- m_placeholder.set_margin_start(24);
- m_placeholder.set_margin_end(24);
- // Icon (symbolic; safe across themes)
- m_placeholderIcon.set_from_icon_name("folder-open-symbolic");
- m_placeholderIcon.set_pixel_size(64);
- // Text
- m_placeholderLabel.set_use_markup(true);
- m_placeholderLabel.set_wrap(true);
- m_placeholderLabel.set_xalign(0.5);
- m_placeholderLabel.set_markup(
- "<span size='large' weight='bold'>Your Library is Empty</span>\n"
- "<small>Click the '+' button to add your first book.</small>"
- );
- m_placeholder.append(m_placeholderIcon);
- m_placeholder.append(m_placeholderLabel);
- }
- void BibliothecaWindow::buildToast() {
- m_toastRevealer.set_transition_type(Gtk::RevealerTransitionType::SLIDE_UP);
- m_toastRevealer.set_transition_duration(200);
- m_toastRevealer.set_halign(Gtk::Align::CENTER);
- m_toastRevealer.set_valign(Gtk::Align::END);
- m_toastRevealer.set_reveal_child(false);
- m_toastBox.set_spacing(8);
- m_toastBox.add_css_class("toast-box");
- m_toastLabel.set_hexpand(true);
- m_toastCloseButton.set_icon_name("window-close-symbolic");
- m_toastCloseButton.add_css_class("flat");
- m_toastCloseButton.add_css_class("circular");
- m_toastCloseButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::hideToast));
- m_toastBox.append(m_toastLabel);
- m_toastBox.append(m_toastCloseButton);
- m_toastRevealer.set_child(m_toastBox);
- }
- void BibliothecaWindow::showToast(const Glib::ustring& message, bool is_error) {
- // Cancel any pending hide timeout
- if (m_toastTimeout.connected()) {
- m_toastTimeout.disconnect();
- }
- m_toastLabel.set_text(message);
- // Update styling
- m_toastBox.remove_css_class("toast-error");
- m_toastBox.remove_css_class("toast-success");
- m_toastBox.add_css_class(is_error ? "toast-error" : "toast-success");
- m_toastRevealer.set_reveal_child(true);
- // Auto-hide after 4 seconds
- m_toastTimeout = Glib::signal_timeout().connect([this]() {
- hideToast();
- return false; // Don't repeat
- }, 4000);
- }
- void BibliothecaWindow::hideToast() {
- if (m_toastTimeout.connected()) {
- m_toastTimeout.disconnect();
- }
- m_toastRevealer.set_reveal_child(false);
- }
- void BibliothecaWindow::showPlaceholderIfEmpty() {
- updateVisibleView();
- }
- void BibliothecaWindow::updateVisibleView() {
- const auto query = m_searchEntry.get_text();
- if (!query.empty()) {
- if (m_shelf->empty()) {
- auto escaped = Glib::Markup::escape_text(query);
- m_noResultsLabel.set_markup(
- Glib::ustring::compose(
- "<span size='large' weight='bold'>No matches</span>\n"
- "<small>No books match \"%1\".</small>", escaped));
- m_stack.set_visible_child("noresults");
- } else {
- m_stack.set_visible_child("shelf");
- }
- return;
- }
- if (m_bookList.size() == 0)
- m_stack.set_visible_child("empty");
- else
- m_stack.set_visible_child("shelf");
- }
- void BibliothecaWindow::onSearchChanged() {
- const auto text = m_searchEntry.get_text();
- m_lastQuery = text.raw();
- m_shelf->setFilter(m_lastQuery);
- updateVisibleView();
- }
- void BibliothecaWindow::onSearchToggle() {
- const bool active = m_searchButton.get_active();
- if (m_searchBar.property_search_mode_enabled().get_value() != active)
- m_searchBar.property_search_mode_enabled() = active;
- if (active) {
- m_searchEntry.grab_focus();
- m_searchEntry.select_region(0, -1);
- }
- }
- void BibliothecaWindow::onSearchModeChanged() {
- const bool enabled = m_searchBar.property_search_mode_enabled().get_value();
- if (m_searchButton.get_active() != enabled)
- m_searchButton.set_active(enabled);
- if (enabled) {
- m_searchEntry.grab_focus();
- m_searchEntry.select_region(0, -1);
- } else if (!m_searchEntry.get_text().empty()) {
- // Only clear search when explicitly closing search mode (not when navigating)
- // Check if we're on the shelf view - only clear if user is closing search there
- if (m_stack.get_visible_child_name() == "shelf" ||
- m_stack.get_visible_child_name() == "noresults") {
- m_searchEntry.set_text("");
- }
- }
- }
- void BibliothecaWindow::onAddBookClicked() {
- auto dialog = Gtk::FileDialog::create();
- dialog->set_title("Add books");
- // Build filters via a Gio::ListStore (gtkmm-4.18 API)
- auto epub = Gtk::FileFilter::create();
- epub->set_name("EPUB books");
- epub->add_suffix("epub");
- auto pdf = Gtk::FileFilter::create();
- pdf->set_name("PDF books");
- pdf->add_suffix("pdf");
- auto any = Gtk::FileFilter::create();
- any->set_name("All files");
- any->add_pattern("*");
- auto filterStore = Gio::ListStore<Gtk::FileFilter>::create();
- filterStore->append(epub);
- filterStore->append(pdf);
- filterStore->append(any);
- dialog->set_filters(filterStore);
- // Let user pick multiple files
- dialog->open_multiple(
- *this,
- [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& res) {
- std::vector<Glib::RefPtr<Gio::File>> files;
- try {
- files = dialog->open_multiple_finish(res);
- } catch (const Glib::Error& e) {
- // User cancelled or error
- if (e.code() != 2) { // 2 = GTK_DIALOG_ERROR_DISMISSED
- showToast("Failed to open file dialog", true);
- }
- return;
- }
- if (files.empty()) return;
- // Hash and create Book objects off the GTK thread
- std::thread([this, files = std::move(files)]() {
- struct ImportedBook {
- Book book;
- bool is_duplicate;
- };
- std::vector<ImportedBook> imported;
- std::vector<std::string> errors;
- imported.reserve(files.size());
- for (const auto& f : files) {
- try {
- const auto path = f->get_path();
- const auto id = sha256_file(path);
- // Check if this book already exists (by hash)
- bool is_duplicate = m_bookList.findById(id).has_value();
- auto resOpt = import_book_assets(path, id); // may throw -> catch already present
- ImportResult res = resOpt.value_or(ImportResult{});
- // Build the Book with extracted metadata
- Book b(
- id,
- !res.title.empty() ? res.title : Glib::path_get_basename(path),
- res.author,
- path,
- res.coverPngPath
- );
- // Preload cover into texture (or defer to BookTile which already calls load_cover_from_disk)
- b.load_cover_from_disk();
- imported.push_back({std::move(b), is_duplicate});
- } catch (const std::exception& e) {
- errors.push_back(Glib::path_get_basename(f->get_path()) + ": " + e.what());
- std::cerr << "Import failed: " << e.what() << "\n";
- }
- }
- // Push to model on the GTK main loop
- Glib::signal_idle().connect_once([this, imported = std::move(imported), errors = std::move(errors)]() mutable {
- std::vector<Book> books_to_add;
- size_t new_count = 0;
- size_t duplicate_count = 0;
- for (auto& item : imported) {
- if (item.is_duplicate) {
- duplicate_count++;
- } else {
- new_count++;
- }
- books_to_add.push_back(std::move(item.book));
- }
- if (!books_to_add.empty()) {
- m_bookList.upsertMany(books_to_add);
- updateVisibleView();
- }
- // Show toast with import results
- const size_t error_count = errors.size();
- if (error_count == 0 && new_count > 0 && duplicate_count == 0) {
- showToast(Glib::ustring::compose("Added %1 book%2", new_count, new_count == 1 ? "" : "s"), false);
- } else if (error_count == 0 && new_count == 0 && duplicate_count > 0) {
- showToast(Glib::ustring::compose("%1 book%2 already in library", duplicate_count, duplicate_count == 1 ? "" : "s"), false);
- } else if (error_count == 0 && new_count > 0 && duplicate_count > 0) {
- showToast(Glib::ustring::compose("Added %1 new, %2 duplicate%3", new_count, duplicate_count, duplicate_count == 1 ? "" : "s"), false);
- } else if (new_count == 0 && duplicate_count == 0 && error_count > 0) {
- showToast(Glib::ustring::compose("Failed to import %1 file%2", error_count, error_count == 1 ? "" : "s"), true);
- } else if ((new_count > 0 || duplicate_count > 0) && error_count > 0) {
- showToast(Glib::ustring::compose("Added %1, %2 failed", new_count + duplicate_count, error_count), true);
- }
- });
- }).detach();
- }
- );
- }
- void BibliothecaWindow::onBookActivated(const Book& book) {
- m_bookDetails->set_book(&book);
- m_stack.set_visible_child("details");
- }
|