|
|
@@ -1,76 +1,225 @@
|
|
|
#include "BibliothecaWindow.hpp"
|
|
|
-#include "Book.hpp"
|
|
|
+#include "BookImport.hpp"
|
|
|
+
|
|
|
#include <gtkmm/filedialog.h>
|
|
|
#include <gtkmm/filefilter.h>
|
|
|
#include <giomm/file.h>
|
|
|
-#include <giomm/cancellable.h>
|
|
|
-#include <glibmm/error.h>
|
|
|
#include <giomm/liststore.h>
|
|
|
#include <giomm/asyncresult.h>
|
|
|
+#include <glibmm/main.h>
|
|
|
+#include <glibmm/markup.h>
|
|
|
+
|
|
|
+#include <thread>
|
|
|
#include <iostream>
|
|
|
-#include <vector>
|
|
|
|
|
|
-BibliothecaWindow::BibliothecaWindow() {
|
|
|
- // Window properties
|
|
|
+BibliothecaWindow::BibliothecaWindow(BookList& bookList)
|
|
|
+ : m_bookList(bookList) {
|
|
|
+
|
|
|
set_title("Bibliotheca");
|
|
|
- set_default_size(800, 600);
|
|
|
+ set_default_size(900, 650);
|
|
|
|
|
|
- // Configure HeaderBar
|
|
|
- m_addBookButton.set_icon_name("list-add-symbolic");
|
|
|
- m_headerBar.pack_end(m_addBookButton);
|
|
|
+ buildHeaderBar();
|
|
|
set_titlebar(m_headerBar);
|
|
|
|
|
|
- // Confiure Button
|
|
|
- m_addBookButton.set_icon_name("list-add-symbolic");
|
|
|
- m_addBookButton.set_tooltip_text("Add books to your library");
|
|
|
- m_addBookButton.set_has_frame(false);
|
|
|
-
|
|
|
-
|
|
|
- // Create the placeholder for the empty library view
|
|
|
- auto image = Gtk::make_managed<Gtk::Image>();
|
|
|
- auto display = Gdk::Display::get_default();
|
|
|
- auto theme = Gtk::IconTheme::get_for_display(display);
|
|
|
- const auto want = "folder-open-symbolic";
|
|
|
- if (theme && theme->has_icon(want)) {
|
|
|
- image->set_from_icon_name(want);
|
|
|
- } else {
|
|
|
- // very safe fallback bundled with GTK
|
|
|
- image->set_from_icon_name("image-missing");
|
|
|
+ auto css = Gtk::CssProvider::create();
|
|
|
+ css->load_from_data(R"CSS(
|
|
|
+ gridview {
|
|
|
+ min-width: 280px;
|
|
|
+ }
|
|
|
+ gridviewchild {
|
|
|
+ background-color: transparent;
|
|
|
}
|
|
|
- image->set_pixel_size(64);
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+
|
|
|
+ )CSS");
|
|
|
+
|
|
|
+ Gtk::StyleContext::add_provider_for_display(
|
|
|
+ Gdk::Display::get_default(), css, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
|
|
|
+
|
|
|
+ // Build the main views
|
|
|
+ buildPlaceholder();
|
|
|
|
|
|
- auto label = Gtk::make_managed<Gtk::Label>();
|
|
|
- label->set_use_markup(true);
|
|
|
- label->set_markup("<span size='large' weight='bold'>Your Library is Empty</span>\n"
|
|
|
- "<small>Click the ‘+’ button to add your first book.</small>");
|
|
|
- label->set_wrap(true);
|
|
|
- label->set_xalign(0.5); // horizontal centering
|
|
|
- label->set_justify(Gtk::Justification::CENTER);
|
|
|
+ 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_stack.add(m_noResults, "noresults");
|
|
|
+
|
|
|
+ m_mainBox.set_spacing(0);
|
|
|
+ m_mainBox.append(m_searchBar);
|
|
|
+ m_mainBox.append(m_stack);
|
|
|
+
|
|
|
+ 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_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);
|
|
|
+}
|
|
|
|
|
|
- m_placeholder.set_orientation(Gtk::Orientation::VERTICAL);
|
|
|
+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.append(*image);
|
|
|
- m_placeholder.append(*label);
|
|
|
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);
|
|
|
|
|
|
- // Set the placeholder as the main content for now
|
|
|
- set_child(m_placeholder);
|
|
|
+ // 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_addBookButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onAddBookClicked));
|
|
|
+ m_placeholder.append(m_placeholderIcon);
|
|
|
+ m_placeholder.append(m_placeholderLabel);
|
|
|
+}
|
|
|
+
|
|
|
+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()) {
|
|
|
+ m_searchEntry.set_text("");
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
void BibliothecaWindow::onAddBookClicked() {
|
|
|
auto dialog = Gtk::FileDialog::create();
|
|
|
dialog->set_title("Add books");
|
|
|
|
|
|
- // Filters
|
|
|
+ // Build filters via a Gio::ListStore (gtkmm-4.18 API)
|
|
|
auto epub = Gtk::FileFilter::create();
|
|
|
epub->set_name("EPUB books");
|
|
|
epub->add_suffix("epub");
|
|
|
@@ -83,32 +232,83 @@ void BibliothecaWindow::onAddBookClicked() {
|
|
|
any->set_name("All files");
|
|
|
any->add_pattern("*");
|
|
|
|
|
|
- // Build a ListStore<Gtk::FileFilter> for set_filters()
|
|
|
- auto store = Gio::ListStore<Gtk::FileFilter>::create();
|
|
|
- store->append(epub);
|
|
|
- store->append(pdf);
|
|
|
- store->append(any);
|
|
|
- dialog->set_filters(store);
|
|
|
+ auto filterStore = Gio::ListStore<Gtk::FileFilter>::create();
|
|
|
+ filterStore->append(epub);
|
|
|
+ filterStore->append(pdf);
|
|
|
+ filterStore->append(any);
|
|
|
+ dialog->set_filters(filterStore);
|
|
|
|
|
|
- // Async open: single callback, finish inside it
|
|
|
+ // 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 {
|
|
|
- auto files = dialog->open_multiple_finish(res);
|
|
|
- if (files.empty()) return;
|
|
|
+ files = dialog->open_multiple_finish(res);
|
|
|
+ } catch (const Glib::Error& e) {
|
|
|
+ std::cerr << "File dialog error: " << e.what() << "\n";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (files.empty()) return;
|
|
|
+
|
|
|
+ // Hash and create Book objects off the GTK thread
|
|
|
+ std::thread([this, files = std::move(files)]() {
|
|
|
+ std::vector<Book> imported;
|
|
|
+ imported.reserve(files.size());
|
|
|
|
|
|
for (const auto& f : files) {
|
|
|
- m_books.addBook(f->get_path());
|
|
|
+ try {
|
|
|
+ const auto path = f->get_path();
|
|
|
+ const auto id = sha256_file(path);
|
|
|
+ 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));
|
|
|
+ } catch (const std::exception& e) {
|
|
|
+ std::cerr << "Import failed: " << e.what() << "\n";
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // TODO: populate your model and swap placeholder -> shelf view here
|
|
|
- // set_child(m_shelfView);
|
|
|
- } catch (const Glib::Error& e) {
|
|
|
- std::cerr << "File dialog error: " << e.what() << "\n";
|
|
|
- }
|
|
|
+ // Push to model on the GTK main loop
|
|
|
+ Glib::signal_idle().connect_once([this, books = std::move(imported)]() mutable {
|
|
|
+ if (!books.empty()) {
|
|
|
+ m_bookList.upsertMany(books);
|
|
|
+ updateVisibleView();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }).detach();
|
|
|
}
|
|
|
);
|
|
|
}
|
|
|
|
|
|
-
|
|
|
+void BibliothecaWindow::onBookActivated(const Book& book) {
|
|
|
+ const auto& path = book.filePath();
|
|
|
+ if (path.empty()) {
|
|
|
+ std::cerr << "Activated book has no file path: " << book.title() << "\n";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ auto file = Gio::File::create_for_path(path);
|
|
|
+ if (!file->query_exists()) {
|
|
|
+ std::cerr << "Book file is missing: " << path << "\n";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // Ask the desktop to open the file with the user's default application.
|
|
|
+ const auto uri = Glib::filename_to_uri(path);
|
|
|
+ Gio::AppInfo::launch_default_for_uri(uri);
|
|
|
+ } catch (const Glib::Error& e) {
|
|
|
+ std::cerr << "Failed to open book '" << book.title() << "': "
|
|
|
+ << e.what() << "\n";
|
|
|
+ }
|
|
|
+}
|