BibliothecaWindow.cpp 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. #include "BibliothecaWindow.hpp"
  2. #include "BookImport.hpp"
  3. #include <gtkmm/filedialog.h>
  4. #include <gtkmm/filefilter.h>
  5. #include <giomm/file.h>
  6. #include <giomm/liststore.h>
  7. #include <giomm/asyncresult.h>
  8. #include <glibmm/main.h>
  9. #include <glibmm/markup.h>
  10. #include <thread>
  11. #include <iostream>
  12. BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
  13. : m_db(db),
  14. m_bookList(bookList) {
  15. set_title("Bibliotheca");
  16. set_default_size(900, 650);
  17. buildHeaderBar();
  18. set_titlebar(m_headerBar);
  19. auto css = Gtk::CssProvider::create();
  20. css->load_from_data(R"CSS(
  21. gridview {
  22. min-width: 280px;
  23. }
  24. gridviewchild {
  25. background-color: transparent;
  26. }
  27. gridviewchild:selected {
  28. background-color: transparent;
  29. box-shadow: none;
  30. }
  31. gridviewchild > listitem,
  32. gridviewchild > listitem > * {
  33. background-color: transparent;
  34. border: none;
  35. }
  36. gridviewchild:selected > listitem,
  37. gridviewchild:selected > listitem > * {
  38. background-color: transparent;
  39. box-shadow: none;
  40. border: none;
  41. }
  42. .book-tile {
  43. padding: 4px;
  44. border-radius: 10px;
  45. transition: all 120ms ease-in-out;
  46. min-width: 160px;
  47. border: 2px solid transparent;
  48. background-color: transparent;
  49. }
  50. .book-tile.book-tile-hover {
  51. border: 2px solid rgba(120, 180, 255, 0.55);
  52. border-radius: 16px;
  53. background-color: rgba(120, 180, 255, 0.12);
  54. }
  55. .toast-box {
  56. background-color: @theme_bg_color;
  57. border-radius: 8px;
  58. padding: 8px 12px;
  59. margin: 12px;
  60. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  61. }
  62. .toast-box.toast-error {
  63. background-color: #c01c28;
  64. color: white;
  65. }
  66. .toast-box.toast-success {
  67. background-color: #26a269;
  68. color: white;
  69. }
  70. )CSS");
  71. Gtk::StyleContext::add_provider_for_display(
  72. Gdk::Display::get_default(), css, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
  73. // Build the main views
  74. buildPlaceholder();
  75. buildToast();
  76. m_noResults.set_orientation(Gtk::Orientation::VERTICAL);
  77. m_noResults.set_spacing(12);
  78. m_noResults.set_halign(Gtk::Align::CENTER);
  79. m_noResults.set_valign(Gtk::Align::CENTER);
  80. m_noResultsLabel.set_wrap(true);
  81. m_noResultsLabel.set_xalign(0.5);
  82. m_noResultsLabel.set_use_markup(true);
  83. m_noResults.append(m_noResultsLabel);
  84. m_shelf = std::make_unique<BookShelf>(m_bookList, /*coverSize*/ 180);
  85. m_shelf->signalBookActivated().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBookActivated));
  86. m_searchEntry.set_placeholder_text("Search your library…");
  87. m_searchEntry.signal_changed().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchChanged));
  88. m_searchEntry.signal_stop_search().connect([this]() {
  89. m_searchBar.property_search_mode_enabled() = false;
  90. });
  91. m_searchBar.set_child(m_searchEntry);
  92. m_searchBar.set_show_close_button(true);
  93. m_searchBar.connect_entry(m_searchEntry);
  94. m_stack.set_transition_type(Gtk::StackTransitionType::CROSSFADE);
  95. m_stack.set_transition_duration(150);
  96. m_stack.set_hexpand(true);
  97. m_stack.set_vexpand(true);
  98. m_stack.add(m_placeholder, "empty");
  99. m_stack.add(*m_shelf, "shelf");
  100. m_bookDetails = std::make_unique<BookDetails>(m_db, m_bookList);
  101. m_bookDetails->signalBookRemoved().connect([this]() {
  102. // Navigate back to shelf when displayed book is removed
  103. m_stack.set_visible_child("shelf");
  104. });
  105. m_stack.add(*m_bookDetails, "details");
  106. m_stack.add(m_noResults, "noresults");
  107. m_stack.property_visible_child().signal_changed().connect([this]() {
  108. auto visible_child = m_stack.get_visible_child_name();
  109. m_backButton.set_visible(visible_child == "details");
  110. m_searchButton.set_visible(visible_child != "details");
  111. m_addBookButton.set_visible(visible_child != "details");
  112. });
  113. // Use overlay to show toast on top of content
  114. m_overlay.set_child(m_stack);
  115. m_overlay.add_overlay(m_toastRevealer);
  116. m_mainBox.set_spacing(0);
  117. m_mainBox.append(m_searchBar);
  118. m_mainBox.append(m_overlay);
  119. set_child(m_mainBox);
  120. m_searchBar.set_key_capture_widget(*this);
  121. m_searchBar.property_search_mode_enabled().signal_changed().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchModeChanged));
  122. // Start with placeholder or shelf depending on DB contents
  123. updateVisibleView();
  124. // If the list resets (e.g., first app load), choose the right view
  125. m_bookList.signalReset().connect([this] { updateVisibleView(); });
  126. m_bookList.signalBookAdded().connect([this](const Book&) { updateVisibleView(); });
  127. m_bookList.signalBookUpdated().connect([this](const Book&) { updateVisibleView(); });
  128. m_bookList.signalBookRemoved().connect([this](const std::string&) { updateVisibleView(); });
  129. m_bookList.signalBulkChanged().connect([this] { updateVisibleView(); });
  130. }
  131. void BibliothecaWindow::buildHeaderBar() {
  132. m_backButton.set_icon_name("go-previous-symbolic");
  133. m_backButton.set_has_frame(false);
  134. m_backButton.add_css_class("flat");
  135. m_backButton.set_tooltip_text("Back");
  136. m_backButton.signal_clicked().connect([this]() {
  137. m_stack.set_visible_child("shelf");
  138. // Restore search filter when returning from details
  139. if (!m_lastQuery.empty()) {
  140. m_shelf->setFilter(m_lastQuery);
  141. updateVisibleView();
  142. }
  143. });
  144. m_headerBar.pack_start(m_backButton);
  145. m_searchButton.set_icon_name("system-search-symbolic");
  146. m_searchButton.set_has_frame(false);
  147. m_searchButton.add_css_class("flat");
  148. m_searchButton.set_tooltip_text("Search (Ctrl+F)");
  149. m_searchButton.signal_toggled().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchToggle));
  150. m_headerBar.pack_start(m_searchButton);
  151. // Add (+) button on the right
  152. m_addBookButton.set_icon_name("list-add-symbolic");
  153. m_addBookButton.set_has_frame(false);
  154. m_addBookButton.set_tooltip_text("Add books to your library");
  155. m_addBookButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onAddBookClicked));
  156. m_headerBar.pack_end(m_addBookButton);
  157. }
  158. void BibliothecaWindow::buildPlaceholder() {
  159. // Centered vertical box
  160. m_placeholder.set_valign(Gtk::Align::CENTER);
  161. m_placeholder.set_halign(Gtk::Align::CENTER);
  162. m_placeholder.set_spacing(12);
  163. m_placeholder.set_margin_top(24);
  164. m_placeholder.set_margin_bottom(24);
  165. m_placeholder.set_margin_start(24);
  166. m_placeholder.set_margin_end(24);
  167. // Icon (symbolic; safe across themes)
  168. m_placeholderIcon.set_from_icon_name("folder-open-symbolic");
  169. m_placeholderIcon.set_pixel_size(64);
  170. // Text
  171. m_placeholderLabel.set_use_markup(true);
  172. m_placeholderLabel.set_wrap(true);
  173. m_placeholderLabel.set_xalign(0.5);
  174. m_placeholderLabel.set_markup(
  175. "<span size='large' weight='bold'>Your Library is Empty</span>\n"
  176. "<small>Click the '+' button to add your first book.</small>"
  177. );
  178. m_placeholder.append(m_placeholderIcon);
  179. m_placeholder.append(m_placeholderLabel);
  180. }
  181. void BibliothecaWindow::buildToast() {
  182. m_toastRevealer.set_transition_type(Gtk::RevealerTransitionType::SLIDE_UP);
  183. m_toastRevealer.set_transition_duration(200);
  184. m_toastRevealer.set_halign(Gtk::Align::CENTER);
  185. m_toastRevealer.set_valign(Gtk::Align::END);
  186. m_toastRevealer.set_reveal_child(false);
  187. m_toastBox.set_spacing(8);
  188. m_toastBox.add_css_class("toast-box");
  189. m_toastLabel.set_hexpand(true);
  190. m_toastCloseButton.set_icon_name("window-close-symbolic");
  191. m_toastCloseButton.add_css_class("flat");
  192. m_toastCloseButton.add_css_class("circular");
  193. m_toastCloseButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::hideToast));
  194. m_toastBox.append(m_toastLabel);
  195. m_toastBox.append(m_toastCloseButton);
  196. m_toastRevealer.set_child(m_toastBox);
  197. }
  198. void BibliothecaWindow::showToast(const Glib::ustring& message, bool is_error) {
  199. // Cancel any pending hide timeout
  200. if (m_toastTimeout.connected()) {
  201. m_toastTimeout.disconnect();
  202. }
  203. m_toastLabel.set_text(message);
  204. // Update styling
  205. m_toastBox.remove_css_class("toast-error");
  206. m_toastBox.remove_css_class("toast-success");
  207. m_toastBox.add_css_class(is_error ? "toast-error" : "toast-success");
  208. m_toastRevealer.set_reveal_child(true);
  209. // Auto-hide after 4 seconds
  210. m_toastTimeout = Glib::signal_timeout().connect([this]() {
  211. hideToast();
  212. return false; // Don't repeat
  213. }, 4000);
  214. }
  215. void BibliothecaWindow::hideToast() {
  216. if (m_toastTimeout.connected()) {
  217. m_toastTimeout.disconnect();
  218. }
  219. m_toastRevealer.set_reveal_child(false);
  220. }
  221. void BibliothecaWindow::showPlaceholderIfEmpty() {
  222. updateVisibleView();
  223. }
  224. void BibliothecaWindow::updateVisibleView() {
  225. const auto query = m_searchEntry.get_text();
  226. if (!query.empty()) {
  227. if (m_shelf->empty()) {
  228. auto escaped = Glib::Markup::escape_text(query);
  229. m_noResultsLabel.set_markup(
  230. Glib::ustring::compose(
  231. "<span size='large' weight='bold'>No matches</span>\n"
  232. "<small>No books match \"%1\".</small>", escaped));
  233. m_stack.set_visible_child("noresults");
  234. } else {
  235. m_stack.set_visible_child("shelf");
  236. }
  237. return;
  238. }
  239. if (m_bookList.size() == 0)
  240. m_stack.set_visible_child("empty");
  241. else
  242. m_stack.set_visible_child("shelf");
  243. }
  244. void BibliothecaWindow::onSearchChanged() {
  245. const auto text = m_searchEntry.get_text();
  246. m_lastQuery = text.raw();
  247. m_shelf->setFilter(m_lastQuery);
  248. updateVisibleView();
  249. }
  250. void BibliothecaWindow::onSearchToggle() {
  251. const bool active = m_searchButton.get_active();
  252. if (m_searchBar.property_search_mode_enabled().get_value() != active)
  253. m_searchBar.property_search_mode_enabled() = active;
  254. if (active) {
  255. m_searchEntry.grab_focus();
  256. m_searchEntry.select_region(0, -1);
  257. }
  258. }
  259. void BibliothecaWindow::onSearchModeChanged() {
  260. const bool enabled = m_searchBar.property_search_mode_enabled().get_value();
  261. if (m_searchButton.get_active() != enabled)
  262. m_searchButton.set_active(enabled);
  263. if (enabled) {
  264. m_searchEntry.grab_focus();
  265. m_searchEntry.select_region(0, -1);
  266. } else if (!m_searchEntry.get_text().empty()) {
  267. // Only clear search when explicitly closing search mode (not when navigating)
  268. // Check if we're on the shelf view - only clear if user is closing search there
  269. if (m_stack.get_visible_child_name() == "shelf" ||
  270. m_stack.get_visible_child_name() == "noresults") {
  271. m_searchEntry.set_text("");
  272. }
  273. }
  274. }
  275. void BibliothecaWindow::onAddBookClicked() {
  276. auto dialog = Gtk::FileDialog::create();
  277. dialog->set_title("Add books");
  278. // Build filters via a Gio::ListStore (gtkmm-4.18 API)
  279. auto epub = Gtk::FileFilter::create();
  280. epub->set_name("EPUB books");
  281. epub->add_suffix("epub");
  282. auto pdf = Gtk::FileFilter::create();
  283. pdf->set_name("PDF books");
  284. pdf->add_suffix("pdf");
  285. auto any = Gtk::FileFilter::create();
  286. any->set_name("All files");
  287. any->add_pattern("*");
  288. auto filterStore = Gio::ListStore<Gtk::FileFilter>::create();
  289. filterStore->append(epub);
  290. filterStore->append(pdf);
  291. filterStore->append(any);
  292. dialog->set_filters(filterStore);
  293. // Let user pick multiple files
  294. dialog->open_multiple(
  295. *this,
  296. [this, dialog](const Glib::RefPtr<Gio::AsyncResult>& res) {
  297. std::vector<Glib::RefPtr<Gio::File>> files;
  298. try {
  299. files = dialog->open_multiple_finish(res);
  300. } catch (const Glib::Error& e) {
  301. // User cancelled or error
  302. if (e.code() != 2) { // 2 = GTK_DIALOG_ERROR_DISMISSED
  303. showToast("Failed to open file dialog", true);
  304. }
  305. return;
  306. }
  307. if (files.empty()) return;
  308. // Hash and create Book objects off the GTK thread
  309. std::thread([this, files = std::move(files)]() {
  310. struct ImportedBook {
  311. Book book;
  312. bool is_duplicate;
  313. };
  314. std::vector<ImportedBook> imported;
  315. std::vector<std::string> errors;
  316. imported.reserve(files.size());
  317. for (const auto& f : files) {
  318. try {
  319. const auto path = f->get_path();
  320. const auto id = sha256_file(path);
  321. // Check if this book already exists (by hash)
  322. bool is_duplicate = m_bookList.findById(id).has_value();
  323. auto resOpt = import_book_assets(path, id); // may throw -> catch already present
  324. ImportResult res = resOpt.value_or(ImportResult{});
  325. // Build the Book with extracted metadata
  326. Book b(
  327. id,
  328. !res.title.empty() ? res.title : Glib::path_get_basename(path),
  329. res.author,
  330. path,
  331. res.coverPngPath
  332. );
  333. // Preload cover into texture (or defer to BookTile which already calls load_cover_from_disk)
  334. b.load_cover_from_disk();
  335. imported.push_back({std::move(b), is_duplicate});
  336. } catch (const std::exception& e) {
  337. errors.push_back(Glib::path_get_basename(f->get_path()) + ": " + e.what());
  338. std::cerr << "Import failed: " << e.what() << "\n";
  339. }
  340. }
  341. // Push to model on the GTK main loop
  342. Glib::signal_idle().connect_once([this, imported = std::move(imported), errors = std::move(errors)]() mutable {
  343. std::vector<Book> books_to_add;
  344. size_t new_count = 0;
  345. size_t duplicate_count = 0;
  346. for (auto& item : imported) {
  347. if (item.is_duplicate) {
  348. duplicate_count++;
  349. } else {
  350. new_count++;
  351. }
  352. books_to_add.push_back(std::move(item.book));
  353. }
  354. if (!books_to_add.empty()) {
  355. m_bookList.upsertMany(books_to_add);
  356. updateVisibleView();
  357. }
  358. // Show toast with import results
  359. const size_t error_count = errors.size();
  360. if (error_count == 0 && new_count > 0 && duplicate_count == 0) {
  361. showToast(Glib::ustring::compose("Added %1 book%2", new_count, new_count == 1 ? "" : "s"), false);
  362. } else if (error_count == 0 && new_count == 0 && duplicate_count > 0) {
  363. showToast(Glib::ustring::compose("%1 book%2 already in library", duplicate_count, duplicate_count == 1 ? "" : "s"), false);
  364. } else if (error_count == 0 && new_count > 0 && duplicate_count > 0) {
  365. showToast(Glib::ustring::compose("Added %1 new, %2 duplicate%3", new_count, duplicate_count, duplicate_count == 1 ? "" : "s"), false);
  366. } else if (new_count == 0 && duplicate_count == 0 && error_count > 0) {
  367. showToast(Glib::ustring::compose("Failed to import %1 file%2", error_count, error_count == 1 ? "" : "s"), true);
  368. } else if ((new_count > 0 || duplicate_count > 0) && error_count > 0) {
  369. showToast(Glib::ustring::compose("Added %1, %2 failed", new_count + duplicate_count, error_count), true);
  370. }
  371. });
  372. }).detach();
  373. }
  374. );
  375. }
  376. void BibliothecaWindow::onBookActivated(const Book& book) {
  377. m_bookDetails->set_book(&book);
  378. m_stack.set_visible_child("details");
  379. }