Преглед изворни кода

Add batch operations for multi-book management

- Switch BookShelf to MultiSelection for multi-select support
- Enable rubberband selection in grid view
- Add getSelectedBooks() and getSelectionCount() methods
- Add signalSelectionChanged() for tracking selection state
- Add batch action bar that appears when books are selected
- Implement batch tag assignment via popover
- Implement batch delete functionality
- Add Select All / Clear selection buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Bernardo Magri пре 1 месец
родитељ
комит
fc04955e9a
5 измењених фајлова са 226 додато и 26 уклоњено
  1. 12 6
      PLAN.md
  2. 134 0
      src/BibliothecaWindow.cpp
  3. 25 0
      src/BibliothecaWindow.hpp
  4. 42 15
      src/BookShelf.cpp
  5. 13 5
      src/BookShelf.hpp

+ 12 - 6
PLAN.md

@@ -144,13 +144,19 @@ Implemented:
 Note: Skipped prompt dialog - auto-update is simpler and non-destructive
 
 ### 3.2 Batch Operations
-**Files**: New files required
-**Status**: Not Started
+**Files**: `src/BookShelf.cpp`, `src/BookShelf.hpp`, `src/BibliothecaWindow.cpp`, `src/BibliothecaWindow.hpp`
+**Status**: COMPLETED
 
-- [ ] Multi-select mode in BookShelf
-- [ ] Batch tag assignment
-- [ ] Batch delete
-- [ ] Batch metadata edit
+Implemented:
+- [x] Multi-select mode in BookShelf (Gtk::MultiSelection + rubberband)
+- [x] Batch action bar (appears when books selected)
+- [x] Batch tag assignment via popover
+- [x] Batch delete
+- [x] Select All / Clear selection buttons
+- [x] Selection count display
+
+Not implemented (deferred):
+- [ ] Batch metadata edit (complex UI, lower priority)
 
 ### 3.3 Tag Management UI
 **Files**: New files required

+ 134 - 0
src/BibliothecaWindow.cpp

@@ -74,6 +74,12 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
     background-color: #26a269;
     color: white;
   }
+  .batch-action-bar {
+    background-color: @theme_bg_color;
+    border-radius: 8px 8px 0 0;
+    padding: 8px 16px;
+    border-top: 1px solid @borders;
+  }
 
   )CSS");
 
@@ -83,6 +89,7 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
   // Build the main views
   buildPlaceholder();
   buildToast();
+  buildBatchActionBar();
 
   m_noResults.set_orientation(Gtk::Orientation::VERTICAL);
   m_noResults.set_spacing(12);
@@ -95,6 +102,7 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
 
   m_shelf = std::make_unique<BookShelf>(m_bookList, /*coverSize*/ 180);
   m_shelf->signalBookActivated().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBookActivated));
+  m_shelf->signalSelectionChanged().connect(sigc::mem_fun(*this, &BibliothecaWindow::updateBatchActionBar));
 
   m_searchEntry.set_placeholder_text("Search your library…");
   m_searchEntry.signal_changed().connect(sigc::mem_fun(*this, &BibliothecaWindow::onSearchChanged));
@@ -134,6 +142,7 @@ BibliothecaWindow::BibliothecaWindow(DatabaseManager& db, BookList& bookList)
   m_mainBox.set_spacing(0);
   m_mainBox.append(m_searchBar);
   m_mainBox.append(m_overlay);
+  m_mainBox.append(m_batchActionRevealer);
 
   set_child(m_mainBox);
 
@@ -266,6 +275,131 @@ void BibliothecaWindow::hideToast() {
   m_toastRevealer.set_reveal_child(false);
 }
 
+void BibliothecaWindow::buildBatchActionBar() {
+  m_batchActionRevealer.set_transition_type(Gtk::RevealerTransitionType::SLIDE_UP);
+  m_batchActionRevealer.set_transition_duration(150);
+  m_batchActionRevealer.set_reveal_child(false);
+
+  m_batchActionBox.set_spacing(8);
+  m_batchActionBox.add_css_class("batch-action-bar");
+  m_batchActionBox.set_halign(Gtk::Align::CENTER);
+  m_batchActionBox.set_margin_top(8);
+  m_batchActionBox.set_margin_bottom(8);
+  m_batchActionBox.set_margin_start(12);
+  m_batchActionBox.set_margin_end(12);
+
+  m_batchSelectionLabel.set_hexpand(false);
+
+  m_batchSelectAllButton.set_label("Select All");
+  m_batchSelectAllButton.add_css_class("flat");
+  m_batchSelectAllButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBatchSelectAllClicked));
+
+  m_batchClearButton.set_label("Clear");
+  m_batchClearButton.add_css_class("flat");
+  m_batchClearButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBatchClearSelectionClicked));
+
+  m_batchTagButton.set_label("Add Tag");
+  m_batchTagButton.set_icon_name("tag-symbolic");
+  m_batchTagButton.add_css_class("suggested-action");
+  m_batchTagButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBatchTagClicked));
+
+  m_batchDeleteButton.set_label("Delete");
+  m_batchDeleteButton.set_icon_name("user-trash-symbolic");
+  m_batchDeleteButton.add_css_class("destructive-action");
+  m_batchDeleteButton.signal_clicked().connect(sigc::mem_fun(*this, &BibliothecaWindow::onBatchDeleteClicked));
+
+  // Build tag popover
+  m_tagPopoverBox.set_spacing(6);
+  m_tagPopoverBox.set_margin_top(6);
+  m_tagPopoverBox.set_margin_bottom(6);
+  m_tagPopoverBox.set_margin_start(6);
+  m_tagPopoverBox.set_margin_end(6);
+
+  m_tagPopoverEntry.set_placeholder_text("Enter tag name...");
+  m_tagPopoverEntry.signal_activate().connect([this]() {
+    m_tagPopoverApplyButton.activate();
+  });
+
+  m_tagPopoverApplyButton.set_label("Apply");
+  m_tagPopoverApplyButton.add_css_class("suggested-action");
+  m_tagPopoverApplyButton.signal_clicked().connect([this]() {
+    const auto tag = m_tagPopoverEntry.get_text();
+    if (tag.empty()) return;
+
+    auto selected = m_shelf->getSelectedBooks();
+    if (selected.empty()) return;
+
+    for (const auto& book : selected) {
+      m_db.add_tag_to_book(book.id(), tag.raw());
+    }
+
+    m_tagPopover.popdown();
+    m_tagPopoverEntry.set_text("");
+    m_bookList.loadAll();
+    showToast(Glib::ustring::compose("Tagged %1 book%2", selected.size(), selected.size() == 1 ? "" : "s"), false);
+  });
+
+  m_tagPopoverBox.append(m_tagPopoverEntry);
+  m_tagPopoverBox.append(m_tagPopoverApplyButton);
+  m_tagPopover.set_child(m_tagPopoverBox);
+  m_tagPopover.set_parent(m_batchTagButton);
+  m_tagPopover.set_position(Gtk::PositionType::TOP);
+
+  m_batchActionBox.append(m_batchSelectionLabel);
+  m_batchActionBox.append(m_batchSelectAllButton);
+  m_batchActionBox.append(m_batchClearButton);
+  m_batchActionBox.append(m_batchTagButton);
+  m_batchActionBox.append(m_batchDeleteButton);
+
+  m_batchActionRevealer.set_child(m_batchActionBox);
+}
+
+void BibliothecaWindow::updateBatchActionBar() {
+  if (!m_shelf) return;
+
+  const auto count = m_shelf->getSelectionCount();
+  const bool has_selection = count > 0;
+  const auto visible_child = m_stack.get_visible_child_name();
+
+  // Only show action bar on shelf view with selection
+  m_batchActionRevealer.set_reveal_child(has_selection && visible_child == "shelf");
+
+  if (has_selection) {
+    m_batchSelectionLabel.set_text(Glib::ustring::compose("%1 selected", count));
+  }
+}
+
+void BibliothecaWindow::onBatchTagClicked() {
+  m_tagPopoverEntry.set_text("");
+  m_tagPopover.popup();
+  m_tagPopoverEntry.grab_focus();
+}
+
+void BibliothecaWindow::onBatchDeleteClicked() {
+  auto selected = m_shelf->getSelectedBooks();
+  if (selected.empty()) return;
+
+  // Delete all selected books
+  for (const auto& book : selected) {
+    m_bookList.removeById(book.id());
+  }
+
+  showToast(Glib::ustring::compose("Deleted %1 book%2", selected.size(), selected.size() == 1 ? "" : "s"), false);
+  updateVisibleView();
+}
+
+void BibliothecaWindow::onBatchSelectAllClicked() {
+  if (m_shelf) {
+    m_shelf->selectAll();
+  }
+}
+
+void BibliothecaWindow::onBatchClearSelectionClicked() {
+  if (m_shelf) {
+    m_shelf->clearSelection();
+  }
+}
+
 void BibliothecaWindow::showPlaceholderIfEmpty() {
   updateVisibleView();
 }

+ 25 - 0
src/BibliothecaWindow.hpp

@@ -13,6 +13,8 @@
 #include <gtkmm/overlay.h>
 #include <gtkmm/revealer.h>
 #include <gtkmm/eventcontrollerkey.h>
+#include <gtkmm/popover.h>
+#include <gtkmm/entry.h>
 
 #include "BookList.hpp"
 #include "BookShelf.hpp"
@@ -37,8 +39,10 @@ private:
   void buildHeaderBar();
   void buildPlaceholder();
   void buildToast();
+  void buildBatchActionBar();
   void showPlaceholderIfEmpty();
   void updateVisibleView();
+  void updateBatchActionBar();
 
   // Toast notification
   void showToast(const Glib::ustring& message, bool is_error = false);
@@ -54,6 +58,12 @@ private:
   void onSearchToggle();
   void onSearchModeChanged();
 
+  // Batch operations
+  void onBatchTagClicked();
+  void onBatchDeleteClicked();
+  void onBatchSelectAllClicked();
+  void onBatchClearSelectionClicked();
+
   // Model (not owned)
   DatabaseManager& m_db;
   BookList& m_bookList;
@@ -89,6 +99,21 @@ private:
   std::unique_ptr<BookDetails> m_bookDetails;
   std::string m_lastQuery;
 
+  // Batch action bar widgets
+  Gtk::Revealer  m_batchActionRevealer;
+  Gtk::Box       m_batchActionBox {Gtk::Orientation::HORIZONTAL};
+  Gtk::Label     m_batchSelectionLabel;
+  Gtk::Button    m_batchTagButton;
+  Gtk::Button    m_batchDeleteButton;
+  Gtk::Button    m_batchSelectAllButton;
+  Gtk::Button    m_batchClearButton;
+
+  // Tag popover for batch tagging
+  Gtk::Popover   m_tagPopover;
+  Gtk::Box       m_tagPopoverBox {Gtk::Orientation::VERTICAL};
+  Gtk::Entry     m_tagPopoverEntry;
+  Gtk::Button    m_tagPopoverApplyButton;
+
   // Keyboard controller
   Glib::RefPtr<Gtk::EventControllerKey> m_keyController;
 };

+ 42 - 15
src/BookShelf.cpp

@@ -9,6 +9,7 @@
 #include <vector>
 #include <gtkmm/gridlayout.h>
 #include <gtkmm/listitem.h>
+#include <gtkmm/bitset.h>
 #include <gdkmm/enums.h>
 #include <glibmm/quark.h>
 #include <sigc++/connection.h>
@@ -141,13 +142,12 @@ BookShelf::BookShelf(BookList& bookList, int coverSize)
   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_selection = Gtk::MultiSelection::create(m_store);
 
   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_enable_rubberband(true);
   m_grid.set_margin(8);
   m_grid.set_min_columns(1);
 
@@ -156,14 +156,20 @@ BookShelf::BookShelf(BookList& bookList, int coverSize)
     layout->set_row_spacing(12);
   }
 
+  // Double-click activates a book (opens details)
   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());
   });
 
+  // Notify when selection changes
+  m_selection->signal_selection_changed().connect([this](guint, guint) {
+    m_signalSelectionChanged.emit();
+  });
+
   set_child(m_grid);
 
+  // Click on empty space clears selection
   m_backgroundClick = Gtk::GestureClick::create();
   m_backgroundClick->set_button(GDK_BUTTON_PRIMARY);
   m_backgroundClick->set_propagation_phase(Gtk::PropagationPhase::CAPTURE);
@@ -173,14 +179,6 @@ BookShelf::BookShelf(BookList& bookList, int coverSize)
   });
   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));
@@ -219,18 +217,47 @@ bool BookShelf::empty() const noexcept {
 }
 
 std::optional<Book> BookShelf::getSelectedBook() const {
-  auto selected = m_selection->get_selected();
-  if (selected == GTK_INVALID_LIST_POSITION)
+  auto bitset = m_selection->get_selection();
+  if (!bitset || bitset->get_size() == 0)
     return std::nullopt;
-  if (auto obj = m_store->get_item(selected))
+  // Return the first selected book
+  guint64 first = bitset->get_minimum();
+  if (first == G_MAXUINT64)
+    return std::nullopt;
+  if (auto obj = m_store->get_item(static_cast<guint>(first)))
     return obj->book();
   return std::nullopt;
 }
 
+std::vector<Book> BookShelf::getSelectedBooks() const {
+  std::vector<Book> result;
+  auto bitset = m_selection->get_selection();
+  if (!bitset)
+    return result;
+
+  const guint n_items = m_store->get_n_items();
+  for (guint i = 0; i < n_items; ++i) {
+    if (bitset->contains(i)) {
+      if (auto obj = m_store->get_item(i))
+        result.push_back(obj->book());
+    }
+  }
+  return result;
+}
+
+std::size_t BookShelf::getSelectionCount() const {
+  auto bitset = m_selection->get_selection();
+  return bitset ? bitset->get_size() : 0;
+}
+
 void BookShelf::clearSelection() {
   m_selection->unselect_all();
 }
 
+void BookShelf::selectAll() {
+  m_selection->select_all();
+}
+
 void BookShelf::refreshVisible() {
   m_selection->unselect_all();
   m_store->remove_all();

+ 13 - 5
src/BookShelf.hpp

@@ -4,7 +4,7 @@
 
 #include <gtkmm/scrolledwindow.h>
 #include <gtkmm/gridview.h>
-#include <gtkmm/singleselection.h>
+#include <gtkmm/multiselection.h>
 #include <gtkmm/signallistitemfactory.h>
 #include <gtkmm/gestureclick.h>
 #include <gtkmm/eventcontrollermotion.h>
@@ -26,17 +26,25 @@ public:
   /// Recompute the visible grid contents from the backing model.
   void reload();
 
-  // signal when a tile is activated (clicked)
+  // signal when a tile is activated (double-clicked)
   sigc::signal<void(const Book&)>& signalBookActivated() { return m_signalBookActivated; }
+  // signal when selection changes
+  sigc::signal<void()>& signalSelectionChanged() { return m_signalSelectionChanged; }
   /// 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;
 
-  /// Get the currently selected book, if any
+  /// Get the currently selected book (first if multiple), if any
   std::optional<Book> getSelectedBook() const;
+  /// Get all selected books
+  std::vector<Book> getSelectedBooks() const;
+  /// Get count of selected books
+  std::size_t getSelectionCount() const;
   /// Clear the current selection
   void clearSelection();
+  /// Select all visible books
+  void selectAll();
 
 private:
   // signal handlers
@@ -59,11 +67,11 @@ private:
   int m_coverSize = 128;
 
   Glib::RefPtr<Gio::ListStore<BookObject>> m_store;
-  Glib::RefPtr<Gtk::SingleSelection> m_selection;
+  Glib::RefPtr<Gtk::MultiSelection> 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;
+  sigc::signal<void()> m_signalSelectionChanged;
 };