Kaynağa Gözat

major refactoring

Bernardo Magri 2 ay önce
ebeveyn
işleme
3768ec787f
16 değiştirilmiş dosya ile 1429 ekleme ve 100 silme
  1. 6 2
      meson.build
  2. 4 0
      shell.nix
  3. 257 57
      src/BibliothecaWindow.cpp
  4. 57 6
      src/BibliothecaWindow.hpp
  5. 25 25
      src/Book.cpp
  6. 1 1
      src/Book.hpp
  7. 246 0
      src/BookImport.cpp
  8. 20 0
      src/BookImport.hpp
  9. 118 0
      src/BookList.cpp
  10. 72 0
      src/BookList.hpp
  11. 288 0
      src/BookShelf.cpp
  12. 64 0
      src/BookShelf.hpp
  13. 166 0
      src/BookTile.cpp
  14. 45 0
      src/BookTile.hpp
  15. 16 5
      src/DatabaseManager.hpp
  16. 44 4
      src/main.cpp

+ 6 - 2
meson.build

@@ -5,11 +5,15 @@ project('bibliotheca', 'cpp',
 gtkmm = dependency('gtkmm-4.0')
 sqlite = dependency('sqlite3')
 openssl = dependency('openssl')
+tinyxml2 = dependency('tinyxml2')
+libzip   = dependency('libzip')
+poppler  = dependency('poppler-glib')
 
 src = ['src/main.cpp', 'src/BibliothecaWindow.cpp', 'src/Book.cpp',
-'src/DatabaseManager.cpp', 'src/Sha256.cpp']
+'src/DatabaseManager.cpp', 'src/Sha256.cpp', 'src/BookList.cpp',
+'src/BookShelf.cpp', 'src/BookTile.cpp', 'src/BookImport.cpp']
 
 executable('bibliotheca',
   src,
-  dependencies : [gtkmm, sqlite, openssl],
+  dependencies : [gtkmm, sqlite, openssl, tinyxml2, libzip, poppler],
   install : true)

+ 4 - 0
shell.nix

@@ -11,6 +11,10 @@ pkgs.mkShell {
     gtkmm4
     sqlite
     openssl
+    tinyxml-2
+    libzip
+    poppler 
+    flatpak-builder
 
     # runtime data GTK needs
     adwaita-icon-theme

+ 257 - 57
src/BibliothecaWindow.cpp

@@ -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";
+  }
+}

+ 57 - 6
src/BibliothecaWindow.hpp

@@ -1,16 +1,67 @@
 #pragma once
 
-#include <gtkmm.h>
+#include <gtkmm/window.h>
+#include <gtkmm/headerbar.h>
+#include <gtkmm/button.h>
+#include <gtkmm/togglebutton.h>
+#include <gtkmm/box.h>
+#include <gtkmm/stack.h>
+#include <gtkmm/image.h>
+#include <gtkmm/label.h>
+#include <gtkmm/searchbar.h>
+#include <gtkmm/searchentry.h>
 
-class BibliothecaWindow : public Gtk::ApplicationWindow {
+#include "BookList.hpp"
+#include "BookShelf.hpp"
+
+// Forward-declare the hash helper you already have.
+std::string sha256_file(const std::string& path);
+
+/**
+ * @brief Top-level GTK window that wires the data model to the visual shelf.
+ *
+ * BibliothecaWindow owns the high-level widgets (header bar, search UI,
+ * placeholder states) and orchestrates updates coming from @ref BookList.
+ */
+class BibliothecaWindow : public Gtk::Window {
 public:
-  BibliothecaWindow();
+  explicit BibliothecaWindow(BookList& bookList);
+
+private:
+  // UI setup helpers
+  void buildHeaderBar();
+  void buildPlaceholder();
+  void showPlaceholderIfEmpty();
+  void updateVisibleView();
 
-protected:
+  // Callbacks
   void onAddBookClicked();
+  void onBookActivated(const Book& book);
+  void onSearchChanged();
+  void onSearchToggle();
+  void onSearchModeChanged();
 
+  // Model (not owned)
+  BookList& m_bookList;
+
+  // UI
   Gtk::HeaderBar m_headerBar;
   Gtk::Button    m_addBookButton;
-  Gtk::Box       m_placeholder; 
-};
+  Gtk::ToggleButton m_searchButton;
 
+  Gtk::Box       m_mainBox {Gtk::Orientation::VERTICAL};
+  Gtk::SearchBar m_searchBar;
+  Gtk::SearchEntry m_searchEntry;
+  Gtk::Stack     m_stack;
+
+  Gtk::Box       m_placeholder {Gtk::Orientation::VERTICAL};
+  Gtk::Image     m_placeholderIcon;
+  Gtk::Label     m_placeholderLabel;
+
+  Gtk::Box       m_noResults {Gtk::Orientation::VERTICAL};
+  Gtk::Label     m_noResultsLabel;
+
+  // Owned shelf view
+  std::unique_ptr<BookShelf> m_shelf;
+  std::string m_lastQuery;
+};

+ 25 - 25
src/Book.cpp

@@ -10,11 +10,11 @@ Book::Book(std::string_view idSha256,
            std::string_view author,
            std::string_view filePath,
            std::string_view coverPath)
-  : m_id(idSha256),
-    m_title(title),
-    m_author(author),
-    m_filePath(filePath),
-    m_coverPath(coverPath) {}
+	: m_id(idSha256),
+	  m_title(title),
+	  m_author(author),
+	  m_filePath(filePath),
+	  m_coverPath(coverPath) {}
 
 Book::Book() = default;
 Book::Book(const Book&) = default;
@@ -24,35 +24,35 @@ Book& Book::operator=(Book&&) noexcept = default;
 Book::~Book() = default;
 
 bool Book::operator<(const Book& other) const noexcept {
-  if (m_title != other.m_title)   return m_title  < other.m_title;
-  if (m_author != other.m_author) return m_author < other.m_author;
-  return m_id < other.m_id;
+	if (m_title != other.m_title)   return m_title  < other.m_title;
+	if (m_author != other.m_author) return m_author < other.m_author;
+	return m_id < other.m_id;
 }
 
 const Glib::RefPtr<Gdk::Texture>& Book::cover() const noexcept {
-  return m_cover;
+	return m_cover;
 }
 
 void Book::set_cover(const Glib::RefPtr<Gdk::Texture>& tex) {
-  m_cover = tex;
+	m_cover = tex;
 }
 
 void Book::set_cover(Glib::RefPtr<Gdk::Texture>&& tex) {
-  m_cover = std::move(tex);
+	m_cover = std::move(tex);
 }
 
-void Book::load_cover_from_disk(const std::string& fallback_icon_name) {
-  // Try to load the cover from disk first
-  if (!m_coverPath.empty()) {
-    try {
-      auto tex = Gdk::Texture::create_from_filename(m_coverPath);
-      if (tex) {
-        m_cover = tex;
-        return;
-      }
-    } catch (const Glib::Error& e) {
-      std::cerr << "Failed to load cover from " << m_coverPath
-                << ": " << e.what() << std::endl;
-    }
-  }
+void Book::load_cover_from_disk() {
+	// Try to load the cover from disk first
+	if (!m_coverPath.empty()) {
+		try {
+			auto tex = Gdk::Texture::create_from_filename(m_coverPath);
+			if (tex) {
+				m_cover = tex;
+				return;
+			}
+		} catch (const Glib::Error& e) {
+			std::cerr << "Failed to load cover from " << m_coverPath
+					  << ": " << e.what() << std::endl;
+		}
+	}
 }

+ 1 - 1
src/Book.hpp

@@ -54,7 +54,7 @@ public:
   void set_cover(const Glib::RefPtr<Gdk::Texture>& tex);
   void set_cover(Glib::RefPtr<Gdk::Texture>&& tex);
 	
-  void load_cover_from_disk(const std::string& fallback_icon_name = "image-x-generic-symbolic");
+  void load_cover_from_disk();
 	
 
   // -----------------------------------------------------------------------

+ 246 - 0
src/BookImport.cpp

@@ -0,0 +1,246 @@
+#include "BookImport.hpp"
+
+#include <giomm/file.h>
+#include <glibmm/miscutils.h>
+#include <glibmm/ustring.h>
+
+#include <tinyxml2.h>
+#include <zip.h>            // libzip
+#include <poppler.h>        // poppler-glib (C API)
+#include <cairo.h>
+
+#include <fstream>
+#include <vector>
+#include <stdexcept>
+#include <algorithm>
+
+// --------------------- utils ---------------------
+
+static std::string to_uri(const std::string& path) {
+  return Glib::filename_to_uri(path);
+}
+
+std::string ensure_covers_dir() {
+  auto base = Glib::build_filename(Glib::get_user_data_dir(), "bibliotheca");
+  auto dir  = Glib::build_filename(base, "covers");
+  auto f    = Gio::File::create_for_path(dir);
+  try {
+    auto t = f->query_file_type();
+    if (t == Gio::FileType::DIRECTORY) return dir;
+    if (t != Gio::FileType::UNKNOWN) throw std::runtime_error("covers path is not a dir");
+  } catch (...) { /* not found, will create */ }
+
+  try {
+    f->make_directory_with_parents();
+  } catch (const Gio::Error& e) {
+    if (e.code() != Gio::Error::EXISTS) throw;
+  }
+  return dir;
+}
+
+static std::string lower_ext(const std::string& p) {
+  auto dot = p.find_last_of('.');
+  std::string ext = (dot == std::string::npos) ? "" : p.substr(dot + 1);
+  std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
+  return ext;
+}
+
+static bool is_pdf(const std::string& p)  { return lower_ext(p) == "pdf"; }
+static bool is_epub(const std::string& p) { return lower_ext(p) == "epub"; }
+
+// Save bytes to file
+static void write_file(const std::string& out, const std::vector<unsigned char>& bytes) {
+  std::ofstream ofs(out, std::ios::binary);
+  ofs.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
+}
+
+// --------------------- EPUB (libzip + tinyxml2) ---------------------
+
+// Read a whole file from zip into memory
+static std::vector<unsigned char> zip_read_all(zip* z, const std::string& name) {
+  zip_stat_t st{};
+  if (zip_stat(z, name.c_str(), 0, &st) != 0) throw std::runtime_error("zip_stat failed for " + name);
+  zip_file* zf = zip_fopen(z, name.c_str(), 0);
+  if (!zf) throw std::runtime_error("zip_fopen failed for " + name);
+  std::vector<unsigned char> buf(static_cast<std::size_t>(st.size));
+  zip_fread(zf, buf.data(), st.size);
+  zip_fclose(zf);
+  return buf;
+}
+
+static std::string read_text(zip* z, const std::string& name) {
+  auto bytes = zip_read_all(z, name);
+  return std::string(reinterpret_cast<const char*>(bytes.data()), bytes.size());
+}
+
+struct EpubPaths { std::string opfPath; std::string rootDir; };
+static EpubPaths epub_find_opf(zip* z) {
+  auto xml = read_text(z, "META-INF/container.xml");
+  tinyxml2::XMLDocument doc;
+  if (doc.Parse(xml.c_str(), xml.size()) != tinyxml2::XML_SUCCESS)
+    throw std::runtime_error("bad container.xml");
+  auto* rootfiles = doc.FirstChildElement("container")
+                       ->FirstChildElement("rootfiles");
+  auto* rootfile  = rootfiles ? rootfiles->FirstChildElement("rootfile") : nullptr;
+  if (!rootfile) throw std::runtime_error("no rootfile");
+  auto* full = rootfile->FindAttribute("full-path");
+  if (!full) throw std::runtime_error("rootfile missing full-path");
+  std::string opf = full->Value();
+  // root dir for relative hrefs
+  auto slash = opf.find_last_of('/');
+  std::string rootDir = (slash == std::string::npos) ? "" : opf.substr(0, slash + 1);
+  return {opf, rootDir};
+}
+
+struct EpubMeta { std::string title; std::string author; std::string coverHref; };
+static EpubMeta epub_parse_opf(zip* z, const EpubPaths& paths) {
+  auto opfXml = read_text(z, paths.opfPath);
+  tinyxml2::XMLDocument doc;
+  if (doc.Parse(opfXml.c_str(), opfXml.size()) != tinyxml2::XML_SUCCESS)
+    throw std::runtime_error("bad OPF");
+
+  auto* pkg = doc.FirstChildElement("package");
+  if (!pkg) throw std::runtime_error("opf: no <package>");
+
+  auto* meta = pkg->FirstChildElement("metadata");
+  std::string title, author, coverId;
+
+  if (meta) {
+    if (auto* t = meta->FirstChildElement("dc:title"))   if (t->GetText()) title  = t->GetText();
+    if (auto* a = meta->FirstChildElement("dc:creator")) if (a->GetText()) author = a->GetText();
+
+    // EPUB2 style: <meta name="cover" content="idref"/>
+    for (auto* m = meta->FirstChildElement("meta"); m; m = m->NextSiblingElement("meta")) {
+      const char* name = m->Attribute("name");
+      const char* cont = m->Attribute("content");
+      if (name && cont && std::string(name) == "cover") { coverId = cont; break; }
+    }
+  }
+
+  auto* manifest = pkg->FirstChildElement("manifest");
+  std::string coverHref;
+  if (manifest) {
+    for (auto* item = manifest->FirstChildElement("item"); item; item = item->NextSiblingElement("item")) {
+      const char* id  = item->Attribute("id");
+      const char* href= item->Attribute("href");
+      const char* props = item->Attribute("properties");
+      if (!href) continue;
+
+      // EPUB3 cover item
+      if (props && std::string(props).find("cover-image") != std::string::npos) {
+        coverHref = href; break;
+      }
+      // EPUB2 by id from <meta name="cover">
+      if (!coverHref.empty()) continue;
+      if (id && coverId == id) coverHref = href;
+    }
+  }
+
+  // Normalize path
+  if (!coverHref.empty() && !paths.rootDir.empty())
+    coverHref = paths.rootDir + coverHref;
+
+  return {title, author, coverHref};
+}
+
+static std::optional<ImportResult> import_epub(const std::string& filePath,
+                                               const std::string& bookIdHex,
+                                               const std::string& coversDir) {
+  int err = 0;
+  zip* z = zip_open(filePath.c_str(), ZIP_RDONLY, &err);
+  if (!z) throw std::runtime_error("cannot open epub as zip");
+
+  ImportResult result;
+  try {
+    auto paths = epub_find_opf(z);
+    auto meta  = epub_parse_opf(z, paths);
+    result.title  = meta.title;
+    result.author = meta.author;
+
+    if (!meta.coverHref.empty()) {
+      auto bytes = zip_read_all(z, meta.coverHref);
+      std::string ext = lower_ext(meta.coverHref);
+      // Always write PNG (gdk can load many types, but normalize is nice). If already png, write as-is.
+      // For simplicity we just write raw bytes; if it's jpg/svg/png it will be fine for Texture::create_from_filename.
+      std::string out = Glib::build_filename(coversDir, bookIdHex + "." + (ext.empty() ? "png" : ext));
+      write_file(out, bytes);
+      result.coverPngPath = out;
+    }
+  } catch (...) {
+    zip_close(z);
+    throw;
+  }
+  zip_close(z);
+  return result;
+}
+
+// --------------------- PDF (poppler-glib + cairo) ---------------------
+
+static std::optional<ImportResult> import_pdf(const std::string& filePath,
+                                              const std::string& bookIdHex,
+                                              const std::string& coversDir) {
+  GError* gerr = nullptr;
+  // Poppler expects a URI
+  auto* doc = poppler_document_new_from_file(to_uri(filePath).c_str(), /*password*/nullptr, &gerr);
+  if (!doc) {
+    std::string message = "poppler open failed";
+    if (gerr) {
+      message = gerr->message ? gerr->message : message;
+      g_error_free(gerr);
+    }
+    throw std::runtime_error(std::move(message));
+  }
+
+  ImportResult result;
+  // Metadata
+  {
+    char* t = poppler_document_get_title(doc);
+    char* a = poppler_document_get_author(doc);
+    if (t) { result.title  = t; g_free(t); }
+    if (a) { result.author = a; g_free(a); }
+  }
+
+  // Render page 0 to a PNG
+  if (poppler_document_get_n_pages(doc) > 0) {
+    PopplerPage* page = poppler_document_get_page(doc, 0);
+    double wpt=0, hpt=0; poppler_page_get_size(page, &wpt, &hpt);
+
+    // scale to 1000px height (nice thumbnails); keep aspect
+    const double target_h = 1000.0;
+    const double scale = (hpt > 0) ? (target_h / hpt) : 1.0;
+    int width  = std::max(1, static_cast<int>(wpt * scale));
+    int height = std::max(1, static_cast<int>(hpt * scale));
+
+    cairo_surface_t* surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
+    cairo_t* cr = cairo_create(surf);
+    cairo_scale(cr, scale, scale);
+    poppler_page_render(page, cr);
+    cairo_surface_flush(surf);
+
+    std::string out = Glib::build_filename(coversDir, bookIdHex + ".png");
+    cairo_status_t st = cairo_surface_write_to_png(surf, out.c_str());
+    if (st == CAIRO_STATUS_SUCCESS) {
+      result.coverPngPath = out;
+    }
+
+    cairo_destroy(cr);
+    cairo_surface_destroy(surf);
+    g_object_unref(page);
+  }
+
+  g_object_unref(doc);
+  return result;
+}
+
+// --------------------- main dispatcher ---------------------
+
+std::optional<ImportResult> import_book_assets(const std::string& filePath,
+                                               const std::string& bookIdHex) {
+  const auto coversDir = ensure_covers_dir();
+
+  if (is_epub(filePath)) return import_epub(filePath, bookIdHex, coversDir);
+  if (is_pdf(filePath))  return import_pdf(filePath,  bookIdHex, coversDir);
+
+  // Unknown type — return empty result so callers can still add the file.
+  return ImportResult{};
+}

+ 20 - 0
src/BookImport.hpp

@@ -0,0 +1,20 @@
+#pragma once
+#include "Book.hpp"
+#include <string>
+#include <optional>
+
+/// Result payload returned by import helpers.
+struct ImportResult {
+  std::string title;
+  std::string author;
+  std::string coverPngPath; // path to extracted/ rendered PNG (empty if none)
+};
+
+/// Ensure ~/.local/share/bibliotheca/covers exists, return path to it.
+std::string ensure_covers_dir();
+
+/// Given a file path + precomputed book id (sha256 hex),
+/// extract metadata and render/ extract a cover into covers dir.
+/// Returns ImportResult on success (may have empty fields).
+std::optional<ImportResult> import_book_assets(const std::string& filePath,
+                                               const std::string& bookIdHex);

+ 118 - 0
src/BookList.cpp

@@ -0,0 +1,118 @@
+#include "BookList.hpp"
+
+#include <algorithm>
+#include <glibmm/main.h>
+#include <sigc++/signal.h>
+
+BookList::BookList(DatabaseManager& database)
+	: m_database(database) {}
+
+// -------------------------------------------------------------------
+// Load
+// -------------------------------------------------------------------
+void BookList::loadAll() {
+	std::lock_guard<std::mutex> lock(m_mutex);
+	m_books = m_database.load_all_books();
+	m_signalReset.emit();
+	m_signalBulkChanged.emit();
+}
+
+// -------------------------------------------------------------------
+// CRUD
+// -------------------------------------------------------------------
+bool BookList::upsert(const Book& book) {
+	if (!m_database.upsert_book(book))
+		return false;
+
+	std::lock_guard<std::mutex> lock(m_mutex);
+	int idx = indexOfUnlocked(book.id());
+	if (idx < 0) {
+		m_books.push_back(book);
+		m_signalBookAdded.emit(book);
+	} else {
+		m_books[static_cast<std::size_t>(idx)] = book;
+		m_signalBookUpdated.emit(book);
+	}
+	return true;
+}
+
+bool BookList::removeById(const std::string& id) {
+	if (!m_database.remove_book(id))
+		return false;
+
+	std::lock_guard<std::mutex> lock(m_mutex);
+	int idx = indexOfUnlocked(id);
+	if (idx >= 0) {
+		const std::string removedId = m_books[static_cast<std::size_t>(idx)].id();
+		m_books.erase(m_books.begin() + idx);
+		m_signalBookRemoved.emit(removedId);
+		return true;
+	}
+	return false;
+}
+
+std::optional<Book> BookList::findById(const std::string& id) const {
+	std::lock_guard<std::mutex> lock(m_mutex);
+	int idx = indexOfUnlocked(id);
+	if (idx < 0) return std::nullopt;
+	return m_books[static_cast<std::size_t>(idx)];
+}
+
+int BookList::indexOf(const std::string& id) const {
+	std::lock_guard<std::mutex> lock(m_mutex);
+	return indexOfUnlocked(id);
+}
+
+int BookList::indexOfUnlocked(const std::string& id) const {
+	auto it = std::find_if(m_books.begin(), m_books.end(),
+						   [&](const Book& b) { return b.id() == id; });
+	if (it == m_books.end()) return -1;
+	return static_cast<int>(std::distance(m_books.begin(), it));
+}
+
+// -------------------------------------------------------------------
+// Bulk
+// -------------------------------------------------------------------
+bool BookList::upsertMany(const std::vector<Book>& items) {
+	bool any = false;
+	for (const auto& b : items)
+		any = m_database.upsert_book(b) || any;
+
+	if (!any) return false;
+
+	{
+		std::lock_guard<std::mutex> lock(m_mutex);
+		for (const auto& b : items) {
+			int idx = indexOfUnlocked(b.id());
+			if (idx < 0)
+				m_books.push_back(b);
+			else
+				m_books[static_cast<std::size_t>(idx)] = b;
+		}
+	}
+
+	m_signalBulkChanged.emit();
+	return true;
+}
+
+// -------------------------------------------------------------------
+// Enqueue versions (safe from worker threads)
+// -------------------------------------------------------------------
+void BookList::upsertEnqueue(Book book) {
+	Glib::signal_idle().connect_once([this, book = std::move(book)]() mutable {
+		upsert(book);
+	});
+}
+
+void BookList::removeByIdEnqueue(std::string id) {
+	Glib::signal_idle().connect_once([this, id = std::move(id)]() mutable {
+		removeById(id);
+	});
+}
+
+void BookList::loadAllEnqueue() {
+	Glib::signal_idle().connect_once([this]() {
+		loadAll();
+	});
+}
+

+ 72 - 0
src/BookList.hpp

@@ -0,0 +1,72 @@
+#pragma once
+
+#include "Book.hpp"
+#include "DatabaseManager.hpp"
+
+#include <vector>
+#include <string>
+#include <optional>
+#include <mutex>
+#include <sigc++/signal.h>
+
+/**
+ * @brief Thread-aware, signal-emitting container of @ref Book objects.
+ *
+ * BookList keeps an in-memory vector of books in sync with @ref DatabaseManager,
+ * broadcasts fine-grained signals when the collection changes, and offers
+ * helper methods that are safe to call from background threads (the *_enqueue
+ * variants marshal work back onto the main loop).
+ */
+class BookList {
+public:
+	explicit BookList(DatabaseManager& database);
+
+	// ---- Loading ----
+	/// Load all rows from the database, replacing the in-memory vector.
+	void loadAll();
+	const std::vector<Book>& getBooks() const noexcept { return m_books; }
+	std::size_t size() const noexcept { return m_books.size(); }
+
+	// ---- CRUD ----
+	/// Insert or update (by Book::id) and emit the relevant signal.
+	bool upsert(const Book& book);
+	/// Remove by id; returns false when the book was not present.
+	bool removeById(const std::string& id);
+
+	std::optional<Book> findById(const std::string& id) const;
+	int indexOf(const std::string& id) const; // -1 if not found
+
+	// ---- Bulk operations ----
+	bool upsertMany(const std::vector<Book>& items);
+
+	// ---- Worker-thread safe enqueue versions ----
+	void upsertEnqueue(Book book);
+	void removeByIdEnqueue(std::string id);
+	void loadAllEnqueue();
+
+	// ---- Signals ----
+	/// Emitted after a new book enters the vector.
+	sigc::signal<void(const Book&)>&          signalBookAdded()   { return m_signalBookAdded; }
+	/// Emitted after a book already present changes.
+	sigc::signal<void(const Book&)>&          signalBookUpdated() { return m_signalBookUpdated; }
+	/// Emitted after a book is removed from the vector.
+	sigc::signal<void(const std::string&)>&   signalBookRemoved() { return m_signalBookRemoved; }
+	/// Emitted when the vector is cleared or reloaded wholesale.
+	sigc::signal<void()>&                     signalReset()       { return m_signalReset; }
+	/// Emitted after batch operations that significantly mutate the list.
+	sigc::signal<void()>&                     signalBulkChanged() { return m_signalBulkChanged; }
+
+private:
+	DatabaseManager& m_database;
+	std::vector<Book> m_books;
+	mutable std::mutex m_mutex;
+
+	int indexOfUnlocked(const std::string& id) const;
+
+	// ---- Signals ----
+	sigc::signal<void(const Book&)>          m_signalBookAdded;
+	sigc::signal<void(const Book&)>          m_signalBookUpdated;
+	sigc::signal<void(const std::string&)>   m_signalBookRemoved;
+	sigc::signal<void()>                     m_signalReset;
+	sigc::signal<void()>                     m_signalBulkChanged;
+};

+ 288 - 0
src/BookShelf.cpp

@@ -0,0 +1,288 @@
+#include "BookShelf.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <memory>
+#include <string>
+#include <tuple>
+#include <utility>
+#include <vector>
+#include <gtkmm/gridlayout.h>
+#include <gtkmm/listitem.h>
+#include <gdkmm/enums.h>
+#include <glibmm/quark.h>
+#include <sigc++/connection.h>
+
+namespace {
+
+const Glib::Quark kSelectionConnQuark("bibliotheca-booktile-selection-conn");
+
+std::string ascii_lower(const std::string& input) {
+  std::string out;
+  out.reserve(input.size());
+  for (unsigned char ch : input)
+    out.push_back(static_cast<char>(std::tolower(ch)));
+  return out;
+}
+
+double substring_score(const std::string& haystack, const std::string& needle) {
+  if (needle.empty())
+    return 0.0;
+  auto pos = haystack.find(needle);
+  if (pos == std::string::npos)
+    return -1.0;
+  return 200.0 - static_cast<double>(pos);
+}
+
+double subsequence_score(const std::string& haystack, const std::string& needle) {
+  if (needle.empty())
+    return 0.0;
+  std::size_t first = std::string::npos;
+  std::size_t last = 0;
+  std::size_t cursor = 0;
+  for (char c : needle) {
+    auto pos = haystack.find(c, cursor);
+    if (pos == std::string::npos)
+      return -1.0;
+    if (first == std::string::npos)
+      first = pos;
+    last = pos;
+    cursor = pos + 1;
+  }
+  const auto span = static_cast<double>(last - first + 1);
+  const auto density = static_cast<double>(needle.size()) / span;
+  return 80.0 * density - static_cast<double>(first);
+}
+
+double field_score(const std::string& field, const std::string& pattern) {
+  if (pattern.empty())
+    return 0.0;
+  auto lower = ascii_lower(field);
+  auto score = substring_score(lower, pattern);
+  if (score >= 0.0)
+    return score;
+  return subsequence_score(lower, pattern);
+}
+
+double book_score(const Book& book, const std::string& pattern) {
+  if (pattern.empty())
+    return 0.0;
+  double best = -1.0;
+  best = std::max(best, field_score(book.title(), pattern));
+  best = std::max(best, field_score(book.author(), pattern));
+  best = std::max(best, field_score(book.filePath(), pattern));
+  best = std::max(best, field_score(book.id(), pattern));
+  return best;
+}
+
+} // namespace
+
+class BookShelf::BookObject : public Glib::Object {
+public:
+  using Ptr = Glib::RefPtr<BookObject>;
+
+  static Ptr create(const Book& book) {
+    return Glib::make_refptr_for_instance<BookObject>(new BookObject(book));
+  }
+
+  const Book& book() const noexcept { return m_book; }
+  void set_book(const Book& book) { m_book = book; }
+
+private:
+  explicit BookObject(const Book& book)
+  : Glib::ObjectBase("BookObject"),
+    Glib::Object(),
+    m_book(book) {}
+
+  Book m_book;
+};
+
+BookShelf::BookShelf(BookList& bookList, int coverSize)
+: m_bookList(bookList), m_coverSize(coverSize) {
+  set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
+  set_hexpand(true);
+  set_vexpand(true);
+
+  m_store = Gio::ListStore<BookObject>::create();
+
+  auto factory = Gtk::SignalListItemFactory::create();
+  factory->signal_setup().connect(sigc::mem_fun(*this, &BookShelf::onFactorySetup));
+  factory->signal_bind().connect(sigc::mem_fun(*this, &BookShelf::onFactoryBind));
+  factory->signal_unbind().connect(sigc::mem_fun(*this, &BookShelf::onFactoryUnbind));
+
+  m_selection = Gtk::SingleSelection::create(m_store);
+  m_selection->set_can_unselect(true);
+
+  m_grid.set_factory(factory);
+  m_grid.set_model(m_selection);
+  m_grid.set_single_click_activate(false);
+  m_grid.set_enable_rubberband(false);
+  m_grid.set_margin(8);
+  m_grid.set_min_columns(1);
+
+  if (auto layout = std::dynamic_pointer_cast<Gtk::GridLayout>(m_grid.get_layout_manager())) {
+    layout->set_column_spacing(12);
+    layout->set_row_spacing(12);
+  }
+
+  m_grid.signal_activate().connect([this](guint position) {
+    m_selection->select_item(position, true);
+    if (auto obj = m_store->get_item(position))
+      m_signalBookActivated.emit(obj->book());
+  });
+
+  set_child(m_grid);
+
+  m_backgroundClick = Gtk::GestureClick::create();
+  m_backgroundClick->set_button(GDK_BUTTON_PRIMARY);
+  m_backgroundClick->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
+  m_backgroundClick->signal_pressed().connect([this](int, double x, double y) {
+    if (auto widget = m_grid.pick(x, y, Gtk::PickFlags::DEFAULT); !widget || widget == &m_grid)
+      m_selection->unselect_all();
+  });
+  m_grid.add_controller(m_backgroundClick);
+
+  m_motion = Gtk::EventControllerMotion::create();
+  m_motion->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
+  m_motion->signal_motion().connect([this](double x, double y) {
+    if (auto widget = m_grid.pick(x, y, Gtk::PickFlags::DEFAULT); !widget || widget == &m_grid)
+      m_selection->unselect_all();
+  });
+  m_grid.add_controller(m_motion);
+
+  m_bookList.signalBookAdded().connect(sigc::mem_fun(*this, &BookShelf::onBookAdded));
+  m_bookList.signalBookUpdated().connect(sigc::mem_fun(*this, &BookShelf::onBookUpdated));
+  m_bookList.signalBookRemoved().connect(sigc::mem_fun(*this, &BookShelf::onBookRemoved));
+  m_bookList.signalReset().connect(sigc::mem_fun(*this, &BookShelf::onReset));
+  m_bookList.signalBulkChanged().connect(sigc::mem_fun(*this, &BookShelf::reload));
+
+  reload();
+}
+
+void BookShelf::reload() {
+  refreshVisible();
+}
+
+void BookShelf::addTile(const Book&) {
+  refreshVisible();
+}
+
+void BookShelf::updateTile(const Book&) {
+  refreshVisible();
+}
+
+void BookShelf::removeTile(const std::string&) {
+  refreshVisible();
+}
+
+void BookShelf::setFilter(const std::string& query) {
+  auto lowered = ascii_lower(query);
+  if (lowered == m_filter)
+    return;
+  m_filter = std::move(lowered);
+  refreshVisible();
+}
+
+bool BookShelf::empty() const noexcept {
+  return m_store->get_n_items() == 0;
+}
+
+void BookShelf::refreshVisible() {
+  m_selection->unselect_all();
+  m_store->remove_all();
+
+  const auto& books = m_bookList.getBooks();
+  if (m_filter.empty()) {
+    for (const auto& book : books)
+      m_store->append(BookObject::create(book));
+    return;
+  }
+
+  struct Candidate {
+    const Book* book;
+    double score;
+  };
+
+  std::vector<Candidate> matches;
+  matches.reserve(books.size());
+  for (const auto& book : books) {
+    const double score = book_score(book, m_filter);
+    if (score >= 0.0)
+      matches.push_back({&book, score});
+  }
+
+  std::stable_sort(matches.begin(), matches.end(), [](const Candidate& a, const Candidate& b) {
+    if (a.score == b.score)
+      return a.book->title() < b.book->title();
+    return a.score > b.score;
+  });
+
+  for (const auto& match : matches)
+    m_store->append(BookObject::create(*match.book));
+}
+
+void BookShelf::onFactorySetup(const Glib::RefPtr<Gtk::ListItem>& item) {
+  auto tile = Gtk::make_managed<BookTile>(Book{}, m_coverSize);
+  item->set_child(*tile);
+}
+
+void BookShelf::onFactoryBind(const Glib::RefPtr<Gtk::ListItem>& item) {
+  auto* tile = dynamic_cast<BookTile*>(item->get_child());
+  if (!tile) return;
+
+  const int pos = item->get_position();
+  if (pos < 0) return;
+
+  auto obj = m_store->get_item(static_cast<guint>(pos));
+  if (!obj) {
+    tile->setBook(Book{});
+    tile->setSelected(false);
+    if (auto data = static_cast<sigc::connection*>(item->steal_data(kSelectionConnQuark))) {
+      data->disconnect();
+      delete data;
+    }
+    return;
+  }
+
+  tile->setBook(obj->book());
+  tile->setSelected(item->get_selected());
+
+  if (auto data = static_cast<sigc::connection*>(item->steal_data(kSelectionConnQuark))) {
+    data->disconnect();
+    delete data;
+  }
+
+  Gtk::ListItem* rawItem = item.get();
+  auto connection = new sigc::connection(
+    item->property_selected().signal_changed().connect([tile, rawItem]() {
+      if (rawItem)
+        tile->setSelected(rawItem->get_selected());
+      else
+        tile->setSelected(false);
+    })
+  );
+
+  item->set_data(kSelectionConnQuark, connection, [](void* data) {
+    auto conn = static_cast<sigc::connection*>(data);
+    if (!conn) return;
+    conn->disconnect();
+    delete conn;
+  });
+}
+
+void BookShelf::onFactoryUnbind(const Glib::RefPtr<Gtk::ListItem>& item) {
+  if (auto* tile = dynamic_cast<BookTile*>(item->get_child())) {
+    tile->setBook(Book{});
+    tile->setSelected(false);
+  }
+
+  if (auto data = static_cast<sigc::connection*>(item->steal_data(kSelectionConnQuark))) {
+    data->disconnect();
+    delete data;
+  }
+}
+
+void BookShelf::onBookAdded(const Book& b)   { addTile(b); }
+void BookShelf::onBookUpdated(const Book& b) { updateTile(b); }
+void BookShelf::onBookRemoved(const std::string& id) { removeTile(id); }
+void BookShelf::onReset() { reload(); }

+ 64 - 0
src/BookShelf.hpp

@@ -0,0 +1,64 @@
+#pragma once
+#include "BookList.hpp"
+#include "BookTile.hpp"
+
+#include <gtkmm/scrolledwindow.h>
+#include <gtkmm/gridview.h>
+#include <gtkmm/singleselection.h>
+#include <gtkmm/signallistitemfactory.h>
+#include <gtkmm/gestureclick.h>
+#include <gtkmm/eventcontrollermotion.h>
+#include <giomm/liststore.h>
+
+/**
+ * @brief GridView-based widget that renders and filters the user's library.
+ *
+ * BookShelf mirrors the books held in @ref BookList into a Gio::ListStore,
+ * drives a @ref Gtk::GridView factory to build @ref BookTile instances,
+ * and exposes a fuzzy filtering API plus activation signal for the rest
+ * of the UI.
+ */
+class BookShelf : public Gtk::ScrolledWindow {
+public:
+  explicit BookShelf(BookList& bookList, int coverSize = 128);
+
+  // force rebuild (e.g., on reset)
+  /// Recompute the visible grid contents from the backing model.
+  void reload();
+
+  // signal when a tile is activated (clicked)
+  sigc::signal<void(const Book&)>& signalBookActivated() { return m_signalBookActivated; }
+  /// Apply a fuzzy search query (empty string restores the full collection).
+  void setFilter(const std::string& query);
+  const std::string& filter() const noexcept { return m_filter; }
+  bool empty() const noexcept;
+
+private:
+  // signal handlers
+  void onBookAdded(const Book& b);
+  void onBookUpdated(const Book& b);
+  void onBookRemoved(const std::string& id);
+  void onReset();
+
+  // helpers
+  void addTile(const Book&);
+  void updateTile(const Book&);
+  void removeTile(const std::string&);
+  void onFactorySetup(const Glib::RefPtr<Gtk::ListItem>& item);
+  void onFactoryBind(const Glib::RefPtr<Gtk::ListItem>& item);
+  void onFactoryUnbind(const Glib::RefPtr<Gtk::ListItem>& item);
+  void refreshVisible();
+
+  class BookObject;
+  BookList& m_bookList;
+  int m_coverSize = 128;
+
+  Glib::RefPtr<Gio::ListStore<BookObject>> m_store;
+  Glib::RefPtr<Gtk::SingleSelection> m_selection;
+  Gtk::GridView m_grid;
+  Glib::RefPtr<Gtk::GestureClick> m_backgroundClick;
+  Glib::RefPtr<Gtk::EventControllerMotion> m_motion;
+  std::string m_filter;
+
+  sigc::signal<void(const Book&)> m_signalBookActivated;
+};

+ 166 - 0
src/BookTile.cpp

@@ -0,0 +1,166 @@
+#include "BookTile.hpp"
+#include <gtkmm/stylecontext.h>
+#include <gtkmm/cssprovider.h>
+#include <gdkmm/display.h>
+#include <gtkmm/icontheme.h>
+#include <gtkmm/eventcontrollermotion.h>
+#include <gdkmm/memorytexturebuilder.h>
+#include <glibmm/bytes.h>
+#include <algorithm>
+#include <map>
+#include <vector>
+
+namespace {
+
+Glib::RefPtr<Gdk::Texture> make_placeholder_texture(int width, int height) {
+  using Key = std::pair<int, int>;
+  static std::map<Key, Glib::RefPtr<Gdk::Texture>> cache;
+  const Key key{width, height};
+
+  if (auto it = cache.find(key); it != cache.end())
+    return it->second;
+
+  const int stride = width * 4;
+  std::vector<guint8> pixels(static_cast<std::size_t>(stride) * height);
+
+  auto put_pixel = [&](int x, int y, guint8 r, guint8 g, guint8 b, guint8 a = 255) {
+    const auto idx = static_cast<std::size_t>(y) * stride + static_cast<std::size_t>(x) * 4;
+    pixels[idx + 0] = r;
+    pixels[idx + 1] = g;
+    pixels[idx + 2] = b;
+    pixels[idx + 3] = a;
+  };
+
+  const guint8 bg_r = 0x2e, bg_g = 0x34, bg_b = 0x40;
+  const guint8 spine_r = 0x5e, spine_g = 0x81, spine_b = 0xac;
+  const guint8 band_r = 0xb4, band_g = 0x88, band_b = 0xea;
+
+  for (int y = 0; y < height; ++y) {
+    for (int x = 0; x < width; ++x) {
+      guint8 r = bg_r, g = bg_g, b = bg_b;
+
+      if (x < width / 6) {
+        r = spine_r; g = spine_g; b = spine_b;
+      } else if (x > width / 2 && x < width / 2 + width / 6) {
+        r = band_r; g = band_g; b = band_b;
+      }
+
+      if (y < height / 12 || y > height - height / 12) {
+        r = static_cast<guint8>(std::min<int>(r + 20, 255));
+        g = static_cast<guint8>(std::min<int>(g + 20, 255));
+        b = static_cast<guint8>(std::min<int>(b + 20, 255));
+      }
+
+      put_pixel(x, y, r, g, b);
+    }
+  }
+
+  auto bytes = Glib::Bytes::create(pixels.data(), pixels.size());
+  auto builder = Gdk::MemoryTextureBuilder::create();
+  builder->set_format(Gdk::MemoryFormat::R8G8B8A8);
+  builder->set_stride(stride);
+  builder->set_width(width);
+  builder->set_height(height);
+  builder->set_bytes(bytes);
+  auto texture = builder->build();
+  cache[key] = texture;
+  return texture;
+}
+
+} // namespace
+
+BookTile::BookTile(const Book& book, int coverSize)
+: Gtk::Box(Gtk::Orientation::VERTICAL),
+  m_book(book),
+  m_coverSize(coverSize) {
+  set_spacing(4);
+  set_margin_top(4);
+  set_margin_bottom(4);
+  set_margin_start(4);
+  set_margin_end(4);
+
+  // Fixed width so FlowBox knows how to pack columns
+  // Add some padding to account for margins
+  set_size_request(m_coverSize + 10, -1);
+  set_hexpand(false);
+  set_halign(Gtk::Align::CENTER);
+  
+  add_css_class("book-tile");
+
+  m_cover.set_content_fit(Gtk::ContentFit::SCALE_DOWN);
+  m_cover.set_can_shrink(true);
+  m_cover.set_size_request(m_coverSize, m_coverSize);
+  m_cover.set_halign(Gtk::Align::CENTER);
+
+  // clamp title
+  m_title.set_wrap(true);
+  m_title.set_wrap_mode(Pango::WrapMode::WORD_CHAR);
+  m_title.set_xalign(0.5);
+  m_title.set_width_chars(15);
+  m_title.set_max_width_chars(18);
+  m_title.set_lines(2);
+  m_title.set_ellipsize(Pango::EllipsizeMode::END);
+
+  m_hover = Gtk::EventControllerMotion::create();
+  m_hover->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
+  m_hover->signal_enter().connect([this](double, double) {
+    add_css_class("book-tile-hover");
+  });
+  m_hover->signal_leave().connect([this]() {
+    remove_css_class("book-tile-hover");
+  });
+  add_controller(m_hover);
+
+  // Click to "activate"
+  m_click = Gtk::GestureClick::create();
+  m_click->set_button(0);
+  m_click->set_propagation_phase(Gtk::PropagationPhase::BUBBLE);
+  m_click->signal_released().connect([this](int, double, double){
+    onActivated();
+  });
+  add_controller(m_click);
+
+  append(m_cover);
+  append(m_title);
+
+  rebuild();
+}
+
+
+void BookTile::setBook(const Book& book) {
+  m_book = book;
+  rebuild();
+}
+
+void BookTile::setSelected(bool selected) {
+  if (m_selected == selected)
+    return;
+
+  m_selected = selected;
+  if (selected)
+    add_css_class("book-tile-selected");
+  else
+    remove_css_class("book-tile-selected");
+}
+
+void BookTile::rebuild() {
+  if (!m_book.cover()) {
+    const_cast<Book&>(m_book).load_cover_from_disk();
+  }
+
+  auto tex = m_book.cover();
+  if (!tex) {
+    tex = make_placeholder_texture(m_coverSize, m_coverSize);
+  }
+
+  m_cover.set_paintable(tex);
+
+  m_cover.set_size_request(m_coverSize, m_coverSize);
+  m_title.set_text(m_book.title().empty()
+                   ? Glib::path_get_basename(m_book.filePath())
+                   : m_book.title());
+}
+
+void BookTile::onActivated() {
+  m_signalActivated.emit(m_book);
+}

+ 45 - 0
src/BookTile.hpp

@@ -0,0 +1,45 @@
+#pragma once
+#include "Book.hpp"
+#include <gtkmm/box.h>
+#include <gtkmm/picture.h>
+#include <gtkmm/label.h>
+#include <gtkmm/overlay.h>
+#include <gtkmm/gestureclick.h>
+#include <gtkmm/eventcontrollermotion.h>
+
+/**
+ * @brief Compact widget that renders a single Book cover and title.
+ *
+ * BookTile is used by @ref BookShelf's GridView factory; it presents the
+ * cover art (texture if already loaded, otherwise lazy load from disk) and
+ * a caption, and forwards click gestures via @ref signalActivated().
+ */
+class BookTile : public Gtk::Box {
+public:
+  BookTile(const Book& book, int coverSize = 128);
+
+  void setBook(const Book& book); // for updates
+  const Book& getBook() const noexcept { return m_book; }
+
+  void setSelected(bool selected);
+
+  /// Emitted when the tile is clicked (single click).
+  sigc::signal<void(const Book&)>& signalActivated() { return m_signalActivated; }
+
+private:
+  void rebuild();
+  void onActivated();
+
+  Book m_book;
+  int m_coverSize = 128;
+
+  Gtk::Overlay m_overlay;
+  Gtk::Picture m_cover;
+  Gtk::Label   m_title;
+
+  Glib::RefPtr<Gtk::GestureClick> m_click;
+  Glib::RefPtr<Gtk::EventControllerMotion> m_hover;
+  bool m_selected = false;
+
+  sigc::signal<void(const Book&)> m_signalActivated;
+};

+ 16 - 5
src/DatabaseManager.hpp

@@ -6,27 +6,38 @@
 #include <mutex>
 #include <optional>
 
+/**
+ * @brief Thin RAII wrapper around the SQLite database backing the library.
+ *
+ * The manager owns the connection, exposes a handful of high-level CRUD helpers,
+ * and hides statement preparation / busy handling from the rest of the app.
+ * All public member functions are thread-safe: a global mutex guards access so
+ * background metadata extraction can update the database safely.
+ */
 class DatabaseManager {
 public:
+  /// Open (or create) the database stored at @p db_path.
   explicit DatabaseManager(std::string db_path);
   ~DatabaseManager();
 
-  // non-copyable, movable if you want (omitted for brevity)
   DatabaseManager(const DatabaseManager&) = delete;
   DatabaseManager& operator=(const DatabaseManager&) = delete;
 
-  // schema
+  /// Create the books table if it does not exist; idempotent.
   void ensure_schema();
 
-  // CRUD
-  bool upsert_book(const Book& b);             // insert or replace by id
+  /// Insert or replace an existing book row using the book's id as the key.
+  bool upsert_book(const Book& b);
+  /// Remove a book row by id; returns false if the row did not exist.
   bool remove_book(const std::string& id);
+  /// Retrieve a single book by id.
   std::optional<Book> get_book(const std::string& id) const;
+  /// Load every book currently stored in the database.
   std::vector<Book>   load_all_books() const;
 
 private:
   static int busy_handler(void* /*opaque*/, int prev_sleep_ms);
-  void prepare_statements();                   // optional: cache prepared stmts
+  void prepare_statements();                   
 
   std::string db_path_;
   mutable std::mutex mtx_;

+ 44 - 4
src/main.cpp

@@ -1,7 +1,47 @@
-#include <gtkmm.h>
+#include <gtkmm/application.h>
+#include "DatabaseManager.hpp"
+#include "BookList.hpp"
 #include "BibliothecaWindow.hpp"
+#include <giomm/file.h>
+#include <giomm/error.h>
+#include <iostream>
 
-int main(int argc, char* argv[]) {
-  auto app = Gtk::Application::create("com.example.bibliotheca");
-  return app->make_window_and_run<BibliothecaWindow>(argc, argv);
+static std::string ensureDataDir() {
+  auto dir = Glib::build_filename(Glib::get_user_data_dir(), "bibliotheca");
+  auto f   = Gio::File::create_for_path(dir);
+
+  // If it exists but isn’t a directory, that’s a real error.
+  try {
+    auto type = f->query_file_type(); // Gio::FileType
+    if (type == Gio::FileType::DIRECTORY) return dir;
+    if (type != Gio::FileType::UNKNOWN) {
+      throw std::runtime_error("Path exists but is not a directory: " + dir);
+    }
+  } catch (const Gio::Error&) {
+    // query_file_type() may throw if it doesn't exist—ignore and proceed to create
+  }
+
+  // Create the directory; ignore “already exists”.
+  try {
+    f->make_directory_with_parents();
+  } catch (const Gio::Error& e) {
+    if (e.code() != Gio::Error::EXISTS) {
+      std::cerr << "Failed to create data dir: " << e.what() << "\n";
+      throw; // or pick a fallback path
+    }
+  }
+  return dir;
+}
+
+int main(int argc, char** argv) {
+  auto app = Gtk::Application::create("eu.bernardomagri.bibliotheca");
+
+  const auto dataDir = ensureDataDir();
+  const auto dbPath  = Glib::build_filename(dataDir, "bibliotheca.sqlite");
+
+  DatabaseManager db{dbPath};
+  BookList bookList{db};
+  bookList.loadAll();
+
+  return app->make_window_and_run<BibliothecaWindow>(argc, argv, bookList);
 }