From ef311e55aecdc3e67acca240f02b37b75207a1ea Mon Sep 17 00:00:00 2001 From: rdbende Date: Sat, 2 Dec 2023 21:18:39 +0100 Subject: [PATCH 01/20] Move search to main view instead of the popover --- cozy/app_controller.py | 15 +- cozy/ui/delete_book_view.py | 22 +-- cozy/ui/headerbar.py | 11 +- cozy/ui/search_view.py | 263 +++++++++++---------------- cozy/ui/widgets/search_results.py | 142 ++------------- cozy/view_model/search_view_model.py | 72 +++++--- data/ui/gresource.xml | 2 +- data/ui/headerbar.ui | 27 ++- data/ui/main_window.ui | 259 +++++++++++++------------- data/ui/search_page.ui | 79 ++++++++ data/ui/search_popover.ui | 209 --------------------- data/ui/style.css | 3 + 12 files changed, 431 insertions(+), 673 deletions(-) create mode 100644 data/ui/search_page.ui delete mode 100644 data/ui/search_popover.ui 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/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..b23861b8 100644 --- a/cozy/ui/headerbar.py +++ b/cozy/ui/headerbar.py @@ -6,7 +6,7 @@ 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 gi.repository import Adw, Gtk, GObject log = logging.getLogger("Headerbar") @@ -14,11 +14,14 @@ @Gtk.Template.from_resource('/com/github/geigi/cozy/headerbar.ui') -class Headerbar(Adw.Bin): +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() @@ -44,6 +47,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 +70,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/search_view.py b/cozy/ui/search_view.py index 9d20a8ee..ee1c74d9 100644 --- a/cozy/ui/search_view.py +++ b/cozy/ui/search_view.py @@ -1,182 +1,127 @@ 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.widgets.book_row import BookRow +from cozy.ui.widgets.search_results import ArtistResultRow from cozy.view_model.search_view_model import SearchViewModel +from cozy.ui.headerbar import Headerbar -from gi.repository import Gtk, GLib +@Gtk.Template.from_resource("/com/github/geigi/cozy/search_page.ui") +class SearchView(Adw.Bin): + __gtype_name__ = "SearchView" -# 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) + 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() - 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() + book_result_box: Adw.PreferencesGroup = Gtk.Template.Child() + author_result_box: Adw.PreferencesGroup = Gtk.Template.Child() + reader_result_box: Adw.PreferencesGroup = Gtk.Template.Child() - 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) + book_result_list: Gtk.ListBox = Gtk.Template.Child() + author_result_list: Gtk.ListBox = Gtk.Template.Child() + reader_result_list: Gtk.ListBox = Gtk.Template.Child() - 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") + view_model = inject.attr(SearchViewModel) - def __on_search_changed(self, sender): - self.search_thread_stop.set() + search_thread: threading.Thread + search_thread_stop: threading.Event - # 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) + 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, name="SearchThread") + self.search_thread_stop = threading.Event() - 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) - for author in authors: - if self.search_thread_stop.is_set(): - return + def close(self) -> None: + self.library_stack.set_visible_child(self.split_view) + self.search_bar.set_search_mode(False) - author_result = ArtistSearchResult(self.view_model.jump_to_author, author, True) - self.author_box.append(author_result) + def on_state_changed(self, widget: Gtk.Widget, param) -> None: + if widget.get_property(param.name): + self.library_stack.set_visible_child(self) + else: + self.close() - 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_search_changed(self, _) -> None: + self.search_thread_stop.set() + + search_query = self.entry.get_text() + if search_query: + if self.search_thread.is_alive(): + self.search_thread.join(timeout=0.2) + + self.search_thread_stop.clear() + self.search_thread = threading.Thread( + target=self.view_model.search, + args=(search_query, self._display_results, self.search_thread_stop), + ) + self.search_thread.start() + else: + self.stack.set_visible_child(self.start_searching_page) + + def _display_results(self, books: list[Book], authors: list[str], readers: list[str]) -> None: + if any((books, authors, readers)): + self.stack.set_visible_child(self.search_scroller) + self._populate_listbox( + books, + self.book_result_list, + self.book_result_box, + BookRow, + self.view_model.jump_to_book, + ) + self._populate_listbox( + authors, + self.author_result_list, + self.author_result_box, + ArtistResultRow, + self.view_model.jump_to_author, + ) + self._populate_listbox( + readers, + self.reader_result_list, + self.reader_result_box, + ArtistResultRow, + self.view_model.jump_to_reader, + ) + else: + self.stack.set_visible_child(self.nothing_found_page) + + def _populate_listbox( + self, + results: Sequence[str | Book], + listbox: Gtk.ListBox, + box: Adw.PreferencesGroup, + row_type: type[BookRow | ArtistResultRow], + callback: Callable[[str | Book], None], + ) -> None: + box.set_visible(False) + listbox.remove_all() + + if not results: + return - for reader in readers: - if self.search_thread_stop.is_set(): - return + box.set_visible(True) - reader_result = ArtistSearchResult(self.view_model.jump_to_reader, reader, False) - self.reader_box.append(reader_result) + for result in results: + if self.search_thread_stop.is_set(): + return - self.popover.set_size_request(-1, -1) + listbox.append(row_type(result, callback)) diff --git a/cozy/ui/widgets/search_results.py b/cozy/ui/widgets/search_results.py index ea1aa641..4be1456e 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") + f" {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("account-symbolic") + icon.set_pixel_size(36) + 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..a77b6ce0 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): @@ -31,13 +34,10 @@ def authors(self): 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 + book.author for book in self._model.books if is_book_online(book) or show_offline_books } - return sorted(split_strings_to_set(authors)) + return split_strings_to_set(authors) @property def readers(self): @@ -45,31 +45,57 @@ def readers(self): 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 + book.reader for book in self._model.books if is_book_online(book) or show_offline_books } - return sorted(split_strings_to_set(readers)) + return split_strings_to_set(readers) - @property - def search_open(self): - return self._search_open + def search(self, search_query: str, callback: Callable[[], None], thread_event): + search_query = search_query.lower() + + # We need the main context to call methods in the main thread after the search is finished + main_context = GLib.MainContext.default() + + books = { + book + for book in self.books + if search_query in book.name.lower() + or search_query in book.author.lower() + or search_query in book.reader.lower() + } + + if thread_event.is_set(): + return + + authors = {author for author in self.authors if search_query in author.lower()} + + if thread_event.is_set(): + return + + readers = {reader for reader in self.readers if search_query in reader.lower()} + + if thread_event.is_set(): + return + + main_context.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): + self._notify("close") def jump_to_book(self, book: Book): self.emit_event(OpenView.BOOK, book) - self.search_open = False + self.close() def jump_to_author(self, author: str): self.emit_event(OpenView.AUTHOR, author) - self.search_open = False + self.close() def jump_to_reader(self, reader: str): self.emit_event(OpenView.READER, reader) - self.search_open = False + self.close() diff --git a/data/ui/gresource.xml b/data/ui/gresource.xml index 05828d20..545ff030 100644 --- a/data/ui/gresource.xml +++ b/data/ui/gresource.xml @@ -14,7 +14,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 @@ -