diff --git a/cozy/app_controller.py b/cozy/app_controller.py index ec1fbd53..97f58a82 100644 --- a/cozy/app_controller.py +++ b/cozy/app_controller.py @@ -51,12 +51,12 @@ def __init__(self, gtk_app, main_window_builder, main_window): self.whats_new_window: WhatsNewWindow = WhatsNewWindow() - self.library_view: LibraryView = LibraryView(main_window_builder) self.app_view: AppView = AppView(main_window_builder) - self.search_view: SearchView = SearchView() - self.book_detail_view: BookDetailView = BookDetailView(main_window_builder) self.headerbar: Headerbar = Headerbar(main_window_builder) + self.library_view: LibraryView = LibraryView(main_window_builder) + self.book_detail_view: BookDetailView = BookDetailView(main_window_builder) self.media_controller: MediaController = MediaController(main_window_builder) + self.search_view: SearchView = SearchView(main_window_builder, self.headerbar) self.library_view_model = inject.instance(LibraryViewModel) self.app_view_model = inject.instance(AppViewModel) @@ -68,7 +68,7 @@ def __init__(self, gtk_app, main_window_builder, main_window): self.settings_view_model = inject.instance(SettingsViewModel) self.player = inject.instance(Player) - self._connect_popovers() + self._connect_search_button() self.search_view_model.add_listener(self._on_open_view) self.book_detail_view_model.add_listener(self._on_open_view) @@ -125,8 +125,11 @@ def open_library(self): self.library_view_model.open_library() self.app_view_model.view = View.LIBRARY_FILTER - def _connect_popovers(self): - self.headerbar.search_button.set_popover(self.search_view.popover) + def _connect_search_button(self): + self.headerbar.search_button.connect( + "notify::active", + self.search_view.on_state_changed + ) def _on_open_view(self, event, data): if event == OpenView.AUTHOR: diff --git a/cozy/extensions/set.py b/cozy/extensions/set.py index 2825909e..23711c39 100644 --- a/cozy/extensions/set.py +++ b/cozy/extensions/set.py @@ -1,16 +1,5 @@ import re -from typing import Set -def split_strings_to_set(set_to_split: Set[str]): - finished = set() - for entry in set_to_split: - results = re.split(",|;|/|&", entry) - results = { - entry.strip() - for entry in results - } - - finished.update(results) - - return finished +def split_strings_to_set(set_to_split: set[str]) -> set[str]: + return {entry.strip() for item in set_to_split for entry in re.split(",|;|/|&", item)} diff --git a/cozy/ui/delete_book_view.py b/cozy/ui/delete_book_view.py index 33743767..7271cc3f 100644 --- a/cozy/ui/delete_book_view.py +++ b/cozy/ui/delete_book_view.py @@ -1,14 +1,14 @@ from gi.repository import Adw, Gtk from cozy.ext import inject -from cozy.control.artwork_cache import ArtworkCache +from cozy.model.book import Book +from cozy.ui.widgets.book_row import BookRow class DeleteBookView(Adw.MessageDialog): main_window = inject.attr("MainWindow") - artwork_cache: ArtworkCache = inject.attr(ArtworkCache) - def __init__(self, callback, book): + def __init__(self, callback, book: Book): super().__init__( heading=_("Delete Audiobook?"), body=_("The audiobook will be removed from your disk and from Cozy's library."), @@ -22,22 +22,8 @@ def __init__(self, callback, book): self.add_response("delete", _("Remove Audiobook")) self.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE) - book_row = Adw.ActionRow( - title=book.name, - subtitle=book.author, - selectable=False, - use_markup=False, - ) - album_art = Gtk.Picture(margin_top=6, margin_bottom=6) - - paintable = self.artwork_cache.get_cover_paintable(book, 1, 48) - if paintable: - album_art.set_paintable(paintable) - book_row.add_prefix(album_art) - list_box = Gtk.ListBox(margin_top=12, css_classes=["boxed-list"]) - list_box.append(book_row) + list_box.append(BookRow(book)) self.set_extra_child(list_box) self.connect("response", callback, book) - diff --git a/cozy/ui/headerbar.py b/cozy/ui/headerbar.py index f5a01e21..6dfb3d8f 100644 --- a/cozy/ui/headerbar.py +++ b/cozy/ui/headerbar.py @@ -1,24 +1,23 @@ import logging -import gi +from gi.repository import Adw, Gtk from cozy.ext import inject from cozy.ui.widgets.progress_popover import ProgressPopover -from cozy.view_model.headerbar_view_model import HeaderbarViewModel, HeaderBarState - -from gi.repository import Adw, Gtk +from cozy.view_model.headerbar_view_model import HeaderBarState, HeaderbarViewModel log = logging.getLogger("Headerbar") -COVER_SIZE = 45 - -@Gtk.Template.from_resource('/com/github/geigi/cozy/headerbar.ui') -class Headerbar(Adw.Bin): +@Gtk.Template.from_resource("/com/github/geigi/cozy/headerbar.ui") +class Headerbar(Gtk.Box): __gtype_name__ = "Headerbar" headerbar: Adw.HeaderBar = Gtk.Template.Child() + search_bar: Gtk.SearchBar = Gtk.Template.Child() + search_entry: Gtk.SearchEntry = Gtk.Template.Child() + show_sidebar_button: Gtk.ToggleButton = Gtk.Template.Child() search_button: Gtk.MenuButton = Gtk.Template.Child() menu_button: Gtk.MenuButton = Gtk.Template.Child() @@ -34,7 +33,9 @@ def __init__(self, main_window_builder: Gtk.Builder): self.header_container: Adw.ToolbarView = main_window_builder.get_object("header_container") self.header_container.add_top_bar(self) - self.mobile_view_switcher: Adw.ViewSwitcherBar = main_window_builder.get_object("mobile_view_switcher") + self.mobile_view_switcher: Adw.ViewSwitcherBar = main_window_builder.get_object( + "mobile_view_switcher" + ) self.split_view: Adw.OverlaySplitView = main_window_builder.get_object("split_view") self.sort_stack: Adw.ViewStack = main_window_builder.get_object("sort_stack") @@ -44,6 +45,9 @@ def __init__(self, main_window_builder: Gtk.Builder): self.progress_popover = ProgressPopover() self.progress_menu_button.set_popover(self.progress_popover) + self.search_bar.connect_entry(self.search_entry) + self.search_bar.set_key_capture_widget(self.header_container) + self._headerbar_view_model: HeaderbarViewModel = inject.instance(HeaderbarViewModel) self._connect_view_model() self._connect_widgets() @@ -64,6 +68,7 @@ def _on_sort_stack_changed(self, widget, _): page = widget.props.visible_child_name self.show_sidebar_button.set_visible(page != "recent") + self.search_button.set_active(False) def _on_mobile_view(self, widget, _): if widget.props.reveal: diff --git a/cozy/ui/main_view.py b/cozy/ui/main_view.py index 24e0f1a7..489ce2b6 100644 --- a/cozy/ui/main_view.py +++ b/cozy/ui/main_view.py @@ -1,10 +1,10 @@ import logging import os -import webbrowser -from threading import Thread from collections import defaultdict +from threading import Thread +from typing import Callable -from gi.repository import Adw, Gtk, Gio, Gdk, GLib, GObject +from gi.repository import Adw, Gdk, Gio, GLib, Gtk import cozy.control.filesystem_monitor as fs_monitor import cozy.ext.inject as inject @@ -13,15 +13,13 @@ from cozy.architecture.event_sender import EventSender from cozy.architecture.singleton import Singleton from cozy.control.db import books, close_db -from cozy.db.storage import Storage from cozy.media.files import Files from cozy.media.importer import Importer, ScanStatus from cozy.media.player import Player from cozy.model.settings import Settings as SettingsModel -from cozy.view_model.settings_view_model import SettingsViewModel -from cozy.open_view import OpenView from cozy.ui.library_view import LibraryView from cozy.ui.preferences_view import PreferencesView +from cozy.view_model.settings_view_model import SettingsViewModel log = logging.getLogger("ui") @@ -126,39 +124,33 @@ def __init_actions(self): self.app.add_action(about_action) self.app.set_accels_for_action("app.about", ["F1"]) - quit_action = Gio.SimpleAction.new("quit", None) - quit_action.connect("activate", self.quit) - self.app.add_action(quit_action) - self.app.set_accels_for_action( - "app.quit", ["q", "w"]) - - pref_action = Gio.SimpleAction.new("prefs", None) - pref_action.connect("activate", self.show_prefs) - self.app.add_action(pref_action) - self.app.set_accels_for_action("app.prefs", ["comma"]) - - self.scan_action = Gio.SimpleAction.new("scan", None) - self.scan_action.connect("activate", self.scan) - self.app.add_action(self.scan_action) - - self.play_pause_action = Gio.SimpleAction.new("play_pause", None) - self.play_pause_action.connect("activate", self.play_pause) - self.app.add_action(self.play_pause_action) - self.app.set_accels_for_action("app.play_pause", ["space"]) - - # NavigationView.pop-on-escape doesn't work in some cases, so this is a hack - back_action = Gio.SimpleAction.new("back", None) - back_action.connect("activate", lambda *_: self.navigation_view.pop()) - self.app.add_action(back_action) - self.app.set_accels_for_action("app.back", ["Escape"]) - - self.hide_offline_action = Gio.SimpleAction.new_stateful("hide_offline", - None, - GLib.Variant.new_boolean( - self.application_settings.hide_offline)) + self.create_action("about", self.about) + self.create_action("quit", self.quit, ["q", "w"]) + self.create_action("prefs", self.show_prefs, ["comma"]) + self.create_action("scan", self.scan) + self.play_pause_action = self.create_action("play_pause", self.play_pause, ["space"]) + + self.hide_offline_action = Gio.SimpleAction.new_stateful( + "hide_offline", None, GLib.Variant.new_boolean(self.application_settings.hide_offline) + ) self.hide_offline_action.connect("change-state", self.__on_hide_offline) self.app.add_action(self.hide_offline_action) + def create_action( + self, + name: str, + callback: Callable[[Gio.SimpleAction, None], None], + shortcuts: list[str] | None = None, + ) -> Gio.SimpleAction: + action = Gio.SimpleAction.new(name, None) + action.connect("activate", callback) + self.app.add_action(action) + + if shortcuts: + self.app.set_accels_for_action(f"app.{name}", shortcuts) + + return action + def __init_components(self): if not self._player.loaded_book: self.block_ui_buttons(True) diff --git a/cozy/ui/search_view.py b/cozy/ui/search_view.py index 9d20a8ee..c83d2b4a 100644 --- a/cozy/ui/search_view.py +++ b/cozy/ui/search_view.py @@ -1,182 +1,120 @@ import threading -from threading import Thread +from typing import Callable, Sequence -from cozy.ext import inject -from cozy.ui.widgets.search_results import BookSearchResult, ArtistSearchResult +from gi.repository import Adw, Gtk +from cozy.ext import inject +from cozy.model.book import Book +from cozy.ui.headerbar import Headerbar +from cozy.ui.widgets.book_row import BookRow +from cozy.ui.widgets.search_results import ArtistResultRow from cozy.view_model.search_view_model import SearchViewModel -from gi.repository import Gtk, GLib +@Gtk.Template.from_resource("/com/github/geigi/cozy/search_page.ui") +class SearchView(Adw.Bin): + __gtype_name__ = "SearchView" + + stack: Gtk.Stack = Gtk.Template.Child() + search_scroller: Gtk.ScrolledWindow = Gtk.Template.Child() + start_searching_page: Adw.StatusPage = Gtk.Template.Child() + nothing_found_page: Adw.StatusPage = Gtk.Template.Child() + + book_result_box: Adw.PreferencesGroup = Gtk.Template.Child() + author_result_box: Adw.PreferencesGroup = Gtk.Template.Child() + reader_result_box: Adw.PreferencesGroup = Gtk.Template.Child() + + book_result_list: Gtk.ListBox = Gtk.Template.Child() + author_result_list: Gtk.ListBox = Gtk.Template.Child() + reader_result_list: Gtk.ListBox = Gtk.Template.Child() -# TODO: There is a lot of app logic in this class that should be in the view model. -# Ideally this class only retrieves lists of results from the view model and only handles displaying them. -class SearchView: view_model = inject.attr(SearchViewModel) + main_view = inject.attr("MainWindow") - search_thread = None - search_thread_stop = None - - def __init__(self): - self.builder = Gtk.Builder.new_from_resource("/com/github/geigi/cozy/search_popover.ui") - - self.popover = self.builder.get_object("search_popover") - - self.book_label = self.builder.get_object("book_label") - self.track_label = self.builder.get_object("track_label") - self.author_label = self.builder.get_object("author_label") - self.reader_label = self.builder.get_object("reader_label") - self.reader_box = self.builder.get_object("reader_result_box") - self.author_box = self.builder.get_object("author_result_box") - self.book_box = self.builder.get_object("book_result_box") - self.track_box = self.builder.get_object("track_result_box") - self.entry = self.builder.get_object("search_entry") - self.scroller = self.builder.get_object("search_scroller") - self.book_separator = self.builder.get_object("book_separator") - self.author_separator = self.builder.get_object("author_separator") - self.reader_separator = self.builder.get_object("reader_separator") - self.stack = self.builder.get_object("search_stack") - - self.entry.connect("search-changed", self.__on_search_changed) - - self.scroller.set_max_content_width(400) - self.scroller.set_max_content_height(600) - self.scroller.set_propagate_natural_height(True) - self.scroller.set_propagate_natural_width(True) - - self.search_thread = Thread(target=self.search, name="SearchThread") - self.search_thread_stop = threading.Event() - - self._connect_view_model() - - def _connect_view_model(self): - self.view_model.bind_to("search_open", self._on_search_open_changed) - - def search(self, user_search: str): - # we need the main context to call methods in the main thread after the search is finished - main_context = GLib.MainContext.default() - - books = list({ - book - for book - in self.view_model.books - if user_search.lower() in book.name.lower() - or user_search.lower() in book.author.lower() - or user_search.lower() in book.reader.lower() - }) - books = sorted(books, key=lambda book: book.name.lower()) - if self.search_thread_stop.is_set(): - return - main_context.invoke_full( - GLib.PRIORITY_DEFAULT, self.__on_book_search_finished, books) - - authors = sorted({ - author - for author - in self.view_model.authors - if user_search.lower() in author.lower() - }) - if self.search_thread_stop.is_set(): - return - main_context.invoke_full( - GLib.PRIORITY_DEFAULT, self.__on_author_search_finished, authors) - - readers = sorted({ - reader - for reader - in self.view_model.readers - if user_search.lower() in reader.lower() - }) - if self.search_thread_stop.is_set(): - return - main_context.invoke_full( - GLib.PRIORITY_DEFAULT, self.__on_reader_search_finished, readers) - - if len(readers) < 1 and len(authors) < 1 and len(books) < 1: - main_context.invoke_full( - GLib.PRIORITY_DEFAULT, self.stack.set_visible_child_name, "nothing") - - def __on_search_changed(self, sender): - self.search_thread_stop.set() - - # we want to avoid flickering of the search box size - # as we remove widgets and add them again - # so we get the current search popup size and set it as - # the preferred size until the search is finished - # this helps only a bit, the widgets are still flickering - self.popover.set_size_request(self.popover.get_allocated_width(), - self.popover.get_allocated_height()) - - # hide nothing found - self.stack.set_visible_child_name("main") - - # First clear the boxes - self.book_box.remove_all_children() - self.author_box.remove_all_children() - self.reader_box.remove_all_children() - - # Hide all the labels & separators - self.book_label.set_visible(False) - self.author_label.set_visible(False) - self.reader_label.set_visible(False) - self.book_separator.set_visible(False) - self.author_separator.set_visible(False) - self.reader_separator.set_visible(False) - self.track_label.set_visible(False) - - user_search = self.entry.get_text() - if user_search: - if self.search_thread.is_alive(): - self.search_thread.join(timeout=0.2) - self.search_thread_stop.clear() - self.search_thread = Thread( - target=self.search, args=(user_search,)) - self.search_thread.start() - else: - self.stack.set_visible_child_name("start") - self.popover.set_size_request(-1, -1) + search_thread: threading.Thread + + def __init__(self, main_window_builder: Gtk.Builder, headerbar: Headerbar) -> None: + super().__init__() - def _on_search_open_changed(self): - if self.view_model.search_open == False: - self.popover.popdown() + self.library_stack: Gtk.Stack = main_window_builder.get_object("library_stack") + self.library_stack.add_child(self) - def __on_book_search_finished(self, books): - if len(books) > 0: - self.stack.set_visible_child_name("main") - self.book_label.set_visible(True) - self.book_separator.set_visible(True) + self.split_view: Gtk.Stack = main_window_builder.get_object("split_view") - for book in books: - if self.search_thread_stop.is_set(): - return + self.search_bar = headerbar.search_bar + self.entry = headerbar.search_entry + self.entry.connect("search-changed", self._on_search_changed) - book_result = BookSearchResult(book, self.view_model.jump_to_book) - self.book_box.append(book_result) + self.search_thread = threading.Thread(target=self.view_model.search) - def __on_author_search_finished(self, authors): - if len(authors) > 0: - self.stack.set_visible_child_name("main") - self.author_label.set_visible(True) - self.author_separator.set_visible(True) + self.view_model.bind_to("close", self.close) + self.main_view.create_action("search", self.open, ["f"]) - for author in authors: - if self.search_thread_stop.is_set(): - return + def open(self, *_) -> None: + self.library_stack.set_visible_child(self) + self.search_bar.set_search_mode(True) + self.main_view.play_pause_action.set_enabled(False) - author_result = ArtistSearchResult(self.view_model.jump_to_author, author, True) - self.author_box.append(author_result) + def close(self) -> None: + self.library_stack.set_visible_child(self.split_view) + self.search_bar.set_search_mode(False) + self.main_view.play_pause_action.set_enabled(True) - def __on_reader_search_finished(self, readers): - if len(readers) > 0: - self.stack.set_visible_child_name("main") - self.reader_label.set_visible(True) - self.reader_separator.set_visible(True) + def on_state_changed(self, widget: Gtk.Widget, param) -> None: + if widget.get_property(param.name): + self.open() + else: + self.close() + + def _on_search_changed(self, _) -> None: + search_query = self.entry.get_text() + if not search_query: + self.stack.set_visible_child(self.start_searching_page) + return + + if self.search_thread.is_alive(): + self.search_thread.join(timeout=0.1) + + self.search_thread = threading.Thread( + target=self.view_model.search, args=(search_query, self._display_results) + ) + self.search_thread.start() - for reader in readers: - if self.search_thread_stop.is_set(): - return + def _display_results(self, books: list[Book], authors: list[str], readers: list[str]) -> None: + if not any((books, authors, readers)): + self.stack.set_visible_child(self.nothing_found_page) + return + + self.stack.set_visible_child(self.search_scroller) + self._populate_listbox( + books, self.book_result_list, self.book_result_box, self.view_model.jump_to_book + ) + self._populate_listbox( + authors, self.author_result_list, self.author_result_box, self.view_model.jump_to_author + ) + self._populate_listbox( + readers, self.reader_result_list, self.reader_result_box, self.view_model.jump_to_reader + ) + + def _populate_listbox( + self, + results: Sequence[str | Book], + listbox: Gtk.ListBox, + box: Adw.PreferencesGroup, + callback: Callable[[str | Book], None], + ) -> None: + box.set_visible(False) + listbox.remove_all() + + if not results: + return + + if isinstance(results[0], Book): + row_type = BookRow + else: + row_type = ArtistResultRow - reader_result = ArtistSearchResult(self.view_model.jump_to_reader, reader, False) - self.reader_box.append(reader_result) + for result in results: + listbox.append(row_type(result, callback)) - self.popover.set_size_request(-1, -1) + box.set_visible(True) diff --git a/cozy/ui/widgets/book_row.py b/cozy/ui/widgets/book_row.py new file mode 100644 index 00000000..9b88b338 --- /dev/null +++ b/cozy/ui/widgets/book_row.py @@ -0,0 +1,42 @@ +from typing import Callable + +from gi.repository import Adw, Gtk + +from cozy.control.artwork_cache import ArtworkCache +from cozy.ext import inject +from cozy.model.book import Book + +BOOK_ICON_SIZE = 52 + + +class BookRow(Adw.ActionRow): + _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) + + def __init__( + self, book: Book, on_click: Callable[[Book], None] | None = None + ) -> None: + super().__init__( + title=book.name, subtitle=book.author, selectable=False, use_markup=False + ) + + if on_click is not None: + self.connect("activated", lambda *_: on_click(book)) + self.set_activatable(True) + self.set_tooltip_text(_("Play this book")) + + paintable = self._artwork_cache.get_cover_paintable( + book, self.get_scale_factor(), BOOK_ICON_SIZE + ) + if paintable: + album_art = Gtk.Picture.new_for_paintable(paintable) + album_art.add_css_class("round-6") + album_art.set_overflow(True) + else: + album_art = Gtk.Image.new_from_icon_name("book-open-variant-symbolic") + album_art.set_pixel_size(BOOK_ICON_SIZE) + + album_art.set_size_request(BOOK_ICON_SIZE, BOOK_ICON_SIZE) + album_art.set_margin_top(6) + album_art.set_margin_bottom(6) + + self.add_prefix(album_art) diff --git a/cozy/ui/widgets/search_results.py b/cozy/ui/widgets/search_results.py index ea1aa641..90156252 100644 --- a/cozy/ui/widgets/search_results.py +++ b/cozy/ui/widgets/search_results.py @@ -1,131 +1,23 @@ -from gi.repository import Gtk, Gdk -import cozy.tools as tools -from cozy.control.artwork_cache import ArtworkCache -from cozy.ext import inject -from cozy.model.book import Book +from typing import Callable -MAX_BOOK_LENGTH = 80 -BOOK_ICON_SIZE = 40 +from gi.repository import Adw, Gtk -class SearchResult(Gtk.Box): - """ - This class is the base class for all search result GUI object. - It features a GTK box that is highlighted when hovered. - """ +class ArtistResultRow(Adw.ActionRow): + def __init__(self, name: str, on_click: Callable[[str], None]) -> None: + super().__init__( + title=name, + selectable=False, + activatable=True, + use_markup=False, + tooltip_text=_("Jump to {artist_name}").format(artist_name=name), + ) - def __init__(self, on_click, on_click_data): - super().__init__() + self.connect("activated", lambda *_: on_click(name)) - self.on_click = on_click - self.on_click_data = on_click_data + icon = Gtk.Image.new_from_icon_name("person-symbolic") + icon.set_pixel_size(24) + icon.set_margin_top(6) + icon.set_margin_bottom(6) - self._motion_event = Gtk.EventControllerMotion() - self._motion_event.connect("enter", self._on_enter_notify) - self._motion_event.connect("leave", self._on_leave_notify) - self.add_controller(self._motion_event) - - self._primary_gesture = Gtk.GestureClick(button=Gdk.BUTTON_PRIMARY) - self._primary_gesture.connect("pressed", self.__on_clicked) - self.add_controller(self._primary_gesture) - - self.props.margin_top = 2 - self.props.margin_bottom = 2 - - # This box contains all content - self.box = Gtk.Box() - self.box.set_orientation(Gtk.Orientation.HORIZONTAL) - self.box.set_spacing(3) - self.box.set_halign(Gtk.Align.FILL) - self.box.set_valign(Gtk.Align.CENTER) - - def _on_enter_notify(self, widget, event, *_): - """ - On enter notify add css hover class - :param widget: as Gtk.Box - :param event: as Gdk.Event - """ - self.box.add_css_class("box_hover") - - def _on_leave_notify(self, widget): - """ - On leave notify remove css hover class - :param widget: as Gtk.Box (can be None) - """ - self.box.remove_css_class("box_hover") - - def __on_clicked(self, widget, event, *_): - self.on_click(self.on_click_data) - - -class ArtistSearchResult(SearchResult): - """ - This class represents an author or reader search result. - """ - - def __init__(self, on_click, artist: str, is_author): - - super().__init__(on_click, artist) - - self.is_author = is_author - self.on_click = on_click - - title_label = Gtk.Label() - if is_author: - title_label.set_text(tools.shorten_string(artist, MAX_BOOK_LENGTH)) - self.set_tooltip_text(_("Jump to author ") + artist) - else: - title_label.set_text(tools.shorten_string(artist, MAX_BOOK_LENGTH)) - self.set_tooltip_text(_("Jump to reader ") + artist) - title_label.set_halign(Gtk.Align.START) - title_label.props.margin_top = 4 - title_label.props.margin_bottom = 4 - title_label.props.margin_start = 4 - title_label.props.margin_end = 5 - title_label.props.hexpand = True - title_label.props.hexpand_set = True - title_label.props.width_request = 100 - title_label.props.xalign = 0.0 - title_label.props.wrap = True - - self.box.append(title_label) - self.append(self.box) - - -class BookSearchResult(SearchResult): - """ - This class represents a book search result. - """ - _artwork_cache: ArtworkCache = inject.attr(ArtworkCache) - - def __init__(self, book: Book, on_click): - super().__init__(on_click, book) - - self.set_tooltip_text(_("Play this book")) - scale = self.get_scale_factor() - - paintable = self._artwork_cache.get_cover_paintable(book, scale, BOOK_ICON_SIZE) - if paintable: - img = Gtk.Image.new_from_paintable(paintable) - else: - img = Gtk.Image.new_from_icon_name("book-open-variant-symbolic") - img.props.pixel_size = BOOK_ICON_SIZE - - img.set_size_request(BOOK_ICON_SIZE, BOOK_ICON_SIZE) - - title_label = Gtk.Label() - title_label.set_text(tools.shorten_string(book.name, MAX_BOOK_LENGTH)) - title_label.set_halign(Gtk.Align.START) - title_label.props.margin_top = 4 - title_label.props.margin_bottom = 4 - title_label.props.margin_start = 4 - title_label.props.margin_end = 5 - title_label.props.hexpand = True - title_label.props.hexpand_set = True - title_label.props.width_request = 100 - title_label.props.xalign = 0.0 - title_label.props.wrap = True - - self.box.append(img) - self.box.append(title_label) - self.append(self.box) + self.add_prefix(icon) diff --git a/cozy/view_model/search_view_model.py b/cozy/view_model/search_view_model.py index c7ffdc71..cd8ee70a 100644 --- a/cozy/view_model/search_view_model.py +++ b/cozy/view_model/search_view_model.py @@ -1,13 +1,16 @@ -import cozy.ext.inject as inject +from typing import Callable -from cozy.extensions.set import split_strings_to_set -from cozy.open_view import OpenView +from gi.repository import GLib + +import cozy.ext.inject as inject from cozy.application_settings import ApplicationSettings from cozy.architecture.event_sender import EventSender from cozy.architecture.observable import Observable from cozy.control.filesystem_monitor import FilesystemMonitor +from cozy.extensions.set import split_strings_to_set from cozy.model.book import Book from cozy.model.library import Library +from cozy.open_view import OpenView class SearchViewModel(Observable, EventSender): @@ -15,61 +18,57 @@ class SearchViewModel(Observable, EventSender): _model: Library = inject.attr(Library) _application_settings: ApplicationSettings = inject.attr(ApplicationSettings) - _search_open: bool = False - def __init__(self): super().__init__() super(Observable, self).__init__() - @property - def books(self): - return self._model.books - - @property - def authors(self): + def _get_available_books(self) -> list[Book]: is_book_online = self._fs_monitor.get_book_online - show_offline_books = not self._application_settings.hide_offline - authors = { - book.author - for book - in self._model.books - if is_book_online(book) or show_offline_books + if self._application_settings.hide_offline: + return [book for book in self._model.books if is_book_online(book)] + else: + return self._model.books + + def search( + self, search_query: str, callback: Callable[[list[Book], list[str], list[str]], None] + ) -> None: + search_query = search_query.lower() + + available_books = self._get_available_books() + books = { + book + for book in available_books + if search_query in book.name.lower() + or search_query in book.author.lower() + or search_query in book.reader.lower() } - return sorted(split_strings_to_set(authors)) - - @property - def readers(self): - is_book_online = self._fs_monitor.get_book_online - show_offline_books = not self._application_settings.hide_offline - - readers = { - book.reader - for book - in self._model.books - if is_book_online(book) or show_offline_books - } + available_book_authors = split_strings_to_set({book.author for book in available_books}) + authors = {author for author in available_book_authors if search_query in author.lower()} - return sorted(split_strings_to_set(readers)) + available_book_readers = split_strings_to_set({book.reader for book in available_books}) + readers = {reader for reader in available_book_readers if search_query in reader.lower()} - @property - def search_open(self): - return self._search_open + GLib.MainContext.default().invoke_full( + GLib.PRIORITY_DEFAULT, + callback, + sorted(books, key=lambda book: book.name.lower()), + sorted(authors), + sorted(readers), + ) - @search_open.setter - def search_open(self, value): - self._search_open = value - self._notify("search_open") + def close(self) -> None: + self._notify("close") - def jump_to_book(self, book: Book): + def jump_to_book(self, book: Book) -> None: self.emit_event(OpenView.BOOK, book) - self.search_open = False + self.close() - def jump_to_author(self, author: str): + def jump_to_author(self, author: str) -> None: self.emit_event(OpenView.AUTHOR, author) - self.search_open = False + self.close() - def jump_to_reader(self, reader: str): + def jump_to_reader(self, reader: str) -> None: self.emit_event(OpenView.READER, reader) - self.search_open = False + self.close() diff --git a/data/icons/hicolor/scalable/actions/loupe-large-symbolic.svg b/data/icons/hicolor/scalable/actions/loupe-large-symbolic.svg new file mode 100644 index 00000000..f31d80d0 --- /dev/null +++ b/data/icons/hicolor/scalable/actions/loupe-large-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/ui/gresource.xml b/data/ui/gresource.xml index 51d9146d..5049a867 100644 --- a/data/ui/gresource.xml +++ b/data/ui/gresource.xml @@ -13,7 +13,7 @@ playback_speed_popover.ui preferences.ui progress_popover.ui - search_popover.ui + search_page.ui seek_bar.ui timer_popover.ui welcome.ui diff --git a/data/ui/headerbar.ui b/data/ui/headerbar.ui index 408d2f72..c0a36ae6 100644 --- a/data/ui/headerbar.ui +++ b/data/ui/headerbar.ui @@ -1,7 +1,8 @@ -