first commit
Some checks are pending
spot-quality / ci-check (push) Waiting to run
spot-quality / shellcheck (push) Waiting to run

This commit is contained in:
Théo Barnouin 2024-11-13 16:41:51 +01:00
commit 15cf412840
255 changed files with 47845 additions and 0 deletions

View file

@ -0,0 +1,14 @@
#[allow(clippy::module_inception)]
mod playlist_details;
mod playlist_details_model;
mod playlist_header;
mod playlist_headerbar;
pub use playlist_details::*;
pub use playlist_details_model::*;
use glib::StaticType;
pub fn expose_widgets() {
playlist_headerbar::PlaylistHeaderBarWidget::static_type();
}

View file

@ -0,0 +1,52 @@
using Gtk 4.0;
using Adw 1;
template $PlaylistDetailsWidget : Adw.Bin {
Box {
orientation: vertical;
vexpand: true;
hexpand: true;
$PlaylistHeaderBarWidget headerbar {
}
$ScrollingHeaderWidget scrolling_header {
[header]
WindowHandle {
Adw.Clamp {
maximum-size: 900;
Adw.Squeezer {
valign: center;
homogeneous: false;
transition-type: crossfade;
switch-threshold-policy: natural;
$PlaylistHeaderWidget header_widget {
}
$PlaylistHeaderWidget header_mobile {
orientation: "vertical";
spacing: "12";
}
}
styles [
"playlist_details__clamp",
]
}
}
Adw.ClampScrollable {
maximum-size: 900;
ListView tracks {
}
}
styles [
"container",
]
}
}
}

View file

@ -0,0 +1,326 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use std::rc::Rc;
use super::playlist_header::PlaylistHeaderWidget;
use super::playlist_headerbar::PlaylistHeaderBarWidget;
use super::PlaylistDetailsModel;
use crate::app::components::{
Component, EventListener, Playlist, PlaylistModel, ScrollingHeaderWidget,
};
use crate::app::dispatch::Worker;
use crate::app::loader::ImageLoader;
use crate::app::state::{PlaybackEvent, SelectionEvent};
use crate::app::{AppEvent, BrowserEvent};
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playlist_details.ui")]
pub struct PlaylistDetailsWidget {
#[template_child]
pub headerbar: TemplateChild<PlaylistHeaderBarWidget>,
#[template_child]
pub scrolling_header: TemplateChild<ScrollingHeaderWidget>,
#[template_child]
pub header_widget: TemplateChild<PlaylistHeaderWidget>,
#[template_child]
pub header_mobile: TemplateChild<PlaylistHeaderWidget>,
#[template_child]
pub tracks: TemplateChild<gtk::ListView>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistDetailsWidget {
const NAME: &'static str = "PlaylistDetailsWidget";
type Type = super::PlaylistDetailsWidget;
type ParentType = libadwaita::Bin;
fn class_init(klass: &mut Self::Class) {
klass.bind_template();
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaylistDetailsWidget {
fn constructed(&self) {
self.parent_constructed();
self.header_mobile.set_centered();
self.header_widget.set_grows_automatically();
self.header_widget
.entry()
.bind_property("text", self.header_mobile.entry(), "text")
.flags(glib::BindingFlags::BIDIRECTIONAL)
.build();
}
}
impl WidgetImpl for PlaylistDetailsWidget {}
impl BinImpl for PlaylistDetailsWidget {}
}
glib::wrapper! {
pub struct PlaylistDetailsWidget(ObjectSubclass<imp::PlaylistDetailsWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl PlaylistDetailsWidget {
fn new() -> Self {
glib::Object::new()
}
fn playlist_tracks_widget(&self) -> &gtk::ListView {
self.imp().tracks.as_ref()
}
fn connect_bottom_edge<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().scrolling_header.connect_bottom_edge(f);
}
fn set_header_visible(&self, visible: bool) {
let widget = self.imp();
widget.headerbar.set_title_visible(true);
if visible {
widget.headerbar.add_classes(&["flat"]);
} else {
widget.headerbar.remove_classes(&["flat"]);
}
}
fn connect_header(&self) {
self.set_header_visible(false);
self.imp().scrolling_header.connect_header_visibility(
clone!(@weak self as _self => move |visible| {
_self.set_header_visible(visible);
}),
);
}
fn set_loaded(&self) {
self.imp()
.scrolling_header
.add_css_class("container--loaded");
}
fn set_editing(&self, editing: bool) {
self.imp().header_widget.set_editing(editing);
self.imp().header_mobile.set_editing(editing);
self.imp().headerbar.set_editing(editing);
}
fn set_editable(&self, editing: bool) {
self.imp().headerbar.set_editable(editing);
}
fn set_info(&self, playlist: &str, owner: &str) {
self.imp().header_widget.set_info(playlist, owner);
self.imp().header_mobile.set_info(playlist, owner);
self.imp().headerbar.set_title(Some(playlist));
}
fn set_playing(&self, is_playing: bool) {
self.imp().header_widget.set_playing(is_playing);
self.imp().header_mobile.set_playing(is_playing);
}
fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().header_widget.set_artwork(art);
self.imp().header_mobile.set_artwork(art);
}
fn connect_owner_clicked<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_owner_clicked(f.clone());
self.imp().header_mobile.connect_owner_clicked(f);
}
pub fn connect_edit<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().headerbar.connect_edit(f);
}
pub fn connect_cancel<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp()
.headerbar
.connect_cancel(clone!(@weak self as _self => move || {
_self.imp().header_widget.reset_playlist_name();
_self.imp().header_mobile.reset_playlist_name();
f();
}));
}
pub fn connect_play<F>(&self, f: F)
where
F: Fn() + Clone + 'static,
{
self.imp().header_widget.connect_play(f.clone());
self.imp().header_mobile.connect_play(f);
}
pub fn connect_done<F>(&self, f: F)
where
F: Fn(String) + 'static,
{
self.imp()
.headerbar
.connect_ok(clone!(@weak self as _self => move || {
let s = _self.imp().header_widget.get_edited_playlist_name();
f(s);
}));
}
pub fn connect_go_back<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().headerbar.connect_go_back(f);
}
}
pub struct PlaylistDetails {
model: Rc<PlaylistDetailsModel>,
worker: Worker,
widget: PlaylistDetailsWidget,
children: Vec<Box<dyn EventListener>>,
}
impl PlaylistDetails {
pub fn new(model: Rc<PlaylistDetailsModel>, worker: Worker) -> Self {
if model.get_playlist_info().is_none() {
model.load_playlist_info();
}
let widget = PlaylistDetailsWidget::new();
let playlist = Box::new(Playlist::new(
widget.playlist_tracks_widget().clone(),
model.clone(),
worker.clone(),
));
widget.set_editable(model.is_playlist_editable());
widget.connect_header();
widget.connect_bottom_edge(clone!(@weak model => move || {
model.load_more_tracks();
}));
widget.connect_owner_clicked(clone!(@weak model => move || model.view_owner()));
widget.connect_edit(clone!(@weak model => move || {
model.enable_selection();
}));
widget.connect_cancel(clone!(@weak model => move || model.disable_selection()));
widget.connect_done(clone!(@weak model => move |n| {
model.disable_selection();
model.update_playlist_details(n);
}));
widget.connect_play(clone!(@weak model => move || model.toggle_play_playlist()));
widget.connect_go_back(clone!(@weak model => move || model.go_back()));
Self {
model,
worker,
widget,
children: vec![playlist],
}
}
fn update_details(&self) {
if let Some(info) = self.model.get_playlist_info() {
let title = &info.title[..];
let owner = &info.owner.display_name[..];
let art_url = info.art.as_ref();
self.widget.set_info(title, owner);
if let Some(art_url) = art_url.cloned() {
let widget = self.widget.downgrade();
self.worker.send_local_task(async move {
let pixbuf = ImageLoader::new()
.load_remote(&art_url[..], "jpg", 320, 320)
.await;
if let (Some(widget), Some(ref pixbuf)) = (widget.upgrade(), pixbuf) {
widget.set_artwork(pixbuf);
widget.set_loaded();
}
});
} else {
self.widget.set_loaded();
}
}
}
fn update_playing(&self, is_playing: bool) {
if !self.model.playlist_is_playing() || !self.model.is_playing() {
self.widget.set_playing(false);
return;
}
self.widget.set_playing(is_playing);
}
fn set_editing(&self, editable: bool) {
if !self.model.is_playlist_editable() {
return;
}
self.widget.set_editing(editable);
}
}
impl Component for PlaylistDetails {
fn get_root_widget(&self) -> &gtk::Widget {
self.widget.upcast_ref()
}
fn get_children(&mut self) -> Option<&mut Vec<Box<dyn EventListener>>> {
Some(&mut self.children)
}
}
impl EventListener for PlaylistDetails {
fn on_event(&mut self, event: &AppEvent) {
match event {
AppEvent::BrowserEvent(BrowserEvent::PlaylistDetailsLoaded(id))
if id == &self.model.id =>
{
self.update_details();
self.update_playing(true);
}
AppEvent::SelectionEvent(SelectionEvent::SelectionModeChanged(editing)) => {
self.set_editing(*editing);
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackPaused) => {
self.update_playing(false);
}
AppEvent::PlaybackEvent(PlaybackEvent::PlaybackResumed) => {
self.update_playing(true);
}
_ => {}
}
self.broadcast_event(event);
}
}

View file

@ -0,0 +1,242 @@
use gio::prelude::*;
use gio::SimpleActionGroup;
use std::cell::Ref;
use std::ops::Deref;
use std::rc::Rc;
use crate::api::SpotifyApiError;
use crate::app::components::{labels, PlaylistModel};
use crate::app::models::*;
use crate::app::state::SelectionContext;
use crate::app::state::{BrowserAction, PlaybackAction, SelectionAction, SelectionState};
use crate::app::AppState;
use crate::app::{ActionDispatcher, AppAction, AppModel, BatchQuery, SongsSource};
pub struct PlaylistDetailsModel {
pub id: String,
app_model: Rc<AppModel>,
dispatcher: Box<dyn ActionDispatcher>,
}
impl PlaylistDetailsModel {
pub fn new(id: String, app_model: Rc<AppModel>, dispatcher: Box<dyn ActionDispatcher>) -> Self {
Self {
id,
app_model,
dispatcher,
}
}
pub fn state(&self) -> Ref<'_, AppState> {
self.app_model.get_state()
}
pub fn is_playlist_editable(&self) -> bool {
let state = self.app_model.get_state();
state.logged_user.playlists.iter().any(|p| p.id == self.id)
}
pub fn get_playlist_info(&self) -> Option<impl Deref<Target = PlaylistDescription> + '_> {
self.app_model.map_state_opt(|s| {
s.browser
.playlist_details_state(&self.id)?
.playlist
.as_ref()
})
}
pub fn is_playing(&self) -> bool {
self.state().playback.is_playing()
}
pub fn playlist_is_playing(&self) -> bool {
matches!(
self.app_model.get_state().playback.current_source(),
Some(SongsSource::Playlist(ref id)) if id == &self.id)
}
pub fn toggle_play_playlist(&self) {
if let Some(playlist) = self.get_playlist_info() {
if !self.playlist_is_playing() {
if self.state().playback.is_shuffled() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::ToggleShuffle));
}
let id_of_first_song = playlist.songs.songs[0].id.as_str();
self.play_song_at(0, id_of_first_song);
return;
}
if self.state().playback.is_playing() {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Pause));
} else {
self.dispatcher
.dispatch(AppAction::PlaybackAction(PlaybackAction::Play));
}
}
}
pub fn load_playlist_info(&self) {
let api = self.app_model.get_spotify();
let id = self.id.clone();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let playlist = api.get_playlist(&id).await;
match playlist {
Ok(playlist) => {
Ok(BrowserAction::SetPlaylistDetails(Box::new(playlist)).into())
}
Err(SpotifyApiError::BadStatus(400, _))
| Err(SpotifyApiError::BadStatus(404, _)) => {
Ok(BrowserAction::NavigationPop.into())
}
Err(e) => Err(e),
}
});
}
pub fn load_more_tracks(&self) -> Option<()> {
let last_batch = self.song_list_model().last_batch()?;
let query = BatchQuery {
source: SongsSource::Playlist(self.id.clone()),
batch: last_batch,
};
let id = self.id.clone();
let next_query = query.next()?;
debug!("next_query = {:?}", &next_query);
let loader = self.app_model.get_batch_loader();
self.dispatcher.dispatch_async(Box::pin(async move {
loader
.query(next_query, |_s, song_batch| {
BrowserAction::AppendPlaylistTracks(id, Box::new(song_batch)).into()
})
.await
}));
Some(())
}
pub fn update_playlist_details(&self, title: String) {
let api = self.app_model.get_spotify();
let id = self.id.clone();
self.dispatcher
.call_spotify_and_dispatch(move || async move {
let playlist = api.update_playlist_details(&id, title.clone()).await;
match playlist {
Ok(_) => Ok(AppAction::UpdatePlaylistName(PlaylistSummary { id, title })),
Err(e) => Err(e),
}
});
}
pub fn view_owner(&self) {
if let Some(playlist) = self.get_playlist_info() {
let owner = &playlist.owner.id;
self.dispatcher
.dispatch(AppAction::ViewUser(owner.to_owned()));
}
}
pub fn disable_selection(&self) {
self.dispatcher.dispatch(AppAction::CancelSelection);
}
pub fn go_back(&self) {
self.dispatcher
.dispatch(BrowserAction::NavigationPop.into());
}
}
impl PlaylistModel for PlaylistDetailsModel {
fn song_list_model(&self) -> SongListModel {
self.state()
.browser
.playlist_details_state(&self.id)
.expect("illegal attempt to read playlist_details_state")
.songs
.clone()
}
fn is_paused(&self) -> bool {
!self.state().playback.is_playing()
}
fn current_song_id(&self) -> Option<String> {
self.state().playback.current_song_id()
}
fn play_song_at(&self, pos: usize, id: &str) {
let source = SongsSource::Playlist(self.id.clone());
let batch = self.song_list_model().song_batch_for(pos);
if let Some(batch) = batch {
self.dispatcher
.dispatch(PlaybackAction::LoadPagedSongs(source, batch).into());
self.dispatcher
.dispatch(PlaybackAction::Load(id.to_string()).into());
}
}
fn actions_for(&self, id: &str) -> Option<gio::ActionGroup> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let group = SimpleActionGroup::new();
for view_artist in song.make_artist_actions(self.dispatcher.box_clone(), None) {
group.add_action(&view_artist);
}
group.add_action(&song.make_album_action(self.dispatcher.box_clone(), None));
group.add_action(&song.make_link_action(None));
group.add_action(&song.make_queue_action(self.dispatcher.box_clone(), None));
Some(group.upcast())
}
fn menu_for(&self, id: &str) -> Option<gio::MenuModel> {
let song = self.song_list_model().get(id)?;
let song = song.description();
let menu = gio::Menu::new();
menu.append(Some(&*labels::VIEW_ALBUM), Some("song.view_album"));
for artist in song.artists.iter() {
menu.append(
Some(&labels::more_from_label(&artist.name)),
Some(&format!("song.view_artist_{}", artist.id)),
);
}
menu.append(Some(&*labels::COPY_LINK), Some("song.copy_link"));
menu.append(Some(&*labels::ADD_TO_QUEUE), Some("song.queue"));
Some(menu.upcast())
}
fn select_song(&self, id: &str) {
let song = self.song_list_model().get(id);
if let Some(song) = song {
self.dispatcher
.dispatch(SelectionAction::Select(vec![song.into_description()]).into());
}
}
fn deselect_song(&self, id: &str) {
self.dispatcher
.dispatch(SelectionAction::Deselect(vec![id.to_string()]).into());
}
fn enable_selection(&self) -> bool {
self.dispatcher
.dispatch(AppAction::EnableSelection(if self.is_playlist_editable() {
SelectionContext::EditablePlaylist(self.id.clone())
} else {
SelectionContext::Playlist
}));
true
}
fn selection(&self) -> Option<Box<dyn Deref<Target = SelectionState> + '_>> {
Some(Box::new(self.app_model.map_state(|s| &s.selection)))
}
}

View file

@ -0,0 +1,84 @@
using Gtk 4.0;
template $PlaylistHeaderWidget : Box {
valign: start;
vexpand: false;
margin-start: 6;
margin-end: 6;
margin-bottom: 6;
Box playlist_image_box {
overflow: hidden;
halign: center;
margin-top: 18;
margin-start: 6;
margin-bottom: 6;
Image playlist_art {
width-request: 160;
height-request: 160;
icon-name: "emblem-music-symbolic";
}
styles [
"card",
]
}
Box playlist_info {
hexpand: true;
valign: center;
orientation: vertical;
spacing: 6;
margin-start: 18;
Entry playlist_label_entry {
hexpand: false;
halign: start;
editable: false;
can-focus: false;
placeholder-text: "Playlist Title";
styles [
"title-1",
"playlist__title-entry",
"playlist__title-entry--ro",
]
}
LinkButton author_button {
receives-default: true;
halign: start;
valign: center;
has-frame: false;
Label author_button_label {
hexpand: true;
vexpand: true;
label: "Artist";
ellipsize: middle;
}
styles [
"title-4",
]
}
}
Button play_button {
margin-end: 6;
receives-default: true;
halign: center;
valign: center;
tooltip-text: "Play";
icon-name: "media-playback-start-symbolic";
styles [
"circular",
"play__button",
]
}
styles [
"playlist__header",
]
}

View file

@ -0,0 +1,32 @@
.playlist__header .title-4 label {
color: @window_fg_color;
font-weight: bold;
text-decoration: none;
}
.playlist__header .title-4:hover {
border-radius: 6px;
background-image: image(alpha(currentColor, 0.08));
}
clamp.playlist_details__clamp {
background-color: @view_bg_color;
box-shadow: inset 0px -1px 0px @borders;
}
headerbar.playlist_details__headerbar {
transition: background-color .3s ease;
}
headerbar.flat.playlist_details__headerbar windowtitle {
opacity: 0;
}
headerbar.playlist_details__headerbar windowtitle {
transition: opacity .3s ease;
opacity: 1;
}
.playlist_details__headerbar.flat {
background-color: @view_bg_color;
}

View file

@ -0,0 +1,190 @@
use crate::app::components::display_add_css_provider;
use gettextrs::gettext;
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::{glib, CompositeTemplate};
const CSS_RO_ENTRY: &str = "playlist__title-entry--ro";
mod imp {
use glib::{ParamSpec, Properties};
use std::cell::RefCell;
use super::*;
#[derive(Debug, Default, CompositeTemplate, Properties)]
#[template(resource = "/dev/alextren/Spot/components/playlist_header.ui")]
#[properties(wrapper_type = super::PlaylistHeaderWidget)]
pub struct PlaylistHeaderWidget {
#[template_child]
pub playlist_label_entry: TemplateChild<gtk::Entry>,
#[template_child]
pub playlist_image_box: TemplateChild<gtk::Box>,
#[template_child]
pub playlist_art: TemplateChild<gtk::Image>,
#[template_child]
pub playlist_info: TemplateChild<gtk::Box>,
#[template_child]
pub author_button: TemplateChild<gtk::LinkButton>,
#[template_child]
pub author_button_label: TemplateChild<gtk::Label>,
#[template_child]
pub play_button: TemplateChild<gtk::Button>,
#[property(get, set, name = "original-entry-text")]
pub original_entry_text: RefCell<String>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistHeaderWidget {
const NAME: &'static str = "PlaylistHeaderWidget";
type Type = super::PlaylistHeaderWidget;
type ParentType = gtk::Box;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
display_add_css_provider(resource!("/components/playlist_header.css"));
obj.init_template();
}
}
impl ObjectImpl for PlaylistHeaderWidget {
fn properties() -> &'static [ParamSpec] {
Self::derived_properties()
}
fn set_property(&self, id: usize, value: &glib::Value, pspec: &glib::ParamSpec) {
self.derived_set_property(id, value, pspec);
}
fn property(&self, id: usize, pspec: &glib::ParamSpec) -> glib::Value {
self.derived_property(id, pspec)
}
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for PlaylistHeaderWidget {}
impl BoxImpl for PlaylistHeaderWidget {}
}
glib::wrapper! {
pub struct PlaylistHeaderWidget(ObjectSubclass<imp::PlaylistHeaderWidget>) @extends gtk::Widget, gtk::Box;
}
impl Default for PlaylistHeaderWidget {
fn default() -> Self {
Self::new()
}
}
impl PlaylistHeaderWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_owner_clicked<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().author_button.connect_activate_link(move |_| {
f();
glib::signal::Inhibit(true)
});
}
pub fn connect_play<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().play_button.connect_clicked(move |_| f());
}
pub fn reset_playlist_name(&self) {
self.imp()
.playlist_label_entry
.set_text(&self.original_entry_text());
}
pub fn get_edited_playlist_name(&self) -> String {
self.imp().playlist_label_entry.text().to_string()
}
pub fn set_artwork(&self, art: &gdk_pixbuf::Pixbuf) {
self.imp().playlist_art.set_from_pixbuf(Some(art));
}
pub fn set_info(&self, playlist: &str, owner: &str) {
let widget = self.imp();
self.set_original_entry_text(playlist);
widget.playlist_label_entry.set_text(playlist);
widget
.playlist_label_entry
.set_placeholder_text(Some(playlist));
widget.author_button_label.set_label(owner);
}
pub fn set_playing(&self, is_playing: bool) {
let playback_icon = if is_playing {
"media-playback-pause-symbolic"
} else {
"media-playback-start-symbolic"
};
let translated_tooltip = if is_playing {
gettext("Pause")
} else {
gettext("Play")
};
let tooltip_text = Some(translated_tooltip.as_str());
self.imp().play_button.set_icon_name(playback_icon);
self.imp().play_button.set_tooltip_text(tooltip_text);
}
pub fn set_centered(&self) {
let widget = self.imp();
widget.playlist_info.set_halign(gtk::Align::Center);
widget.play_button.set_margin_end(0);
widget.playlist_info.set_margin_start(0);
widget.playlist_image_box.set_margin_start(0);
widget.playlist_label_entry.set_xalign(0.5);
widget.author_button.set_halign(gtk::Align::Center);
}
pub fn set_editing(&self, editing: bool) {
let widget = self.imp();
widget.playlist_label_entry.set_can_focus(editing);
widget.playlist_label_entry.set_editable(editing);
if editing {
widget.playlist_label_entry.remove_css_class(CSS_RO_ENTRY);
} else {
widget.playlist_label_entry.add_css_class(CSS_RO_ENTRY);
}
}
pub fn entry(&self) -> &gtk::Entry {
self.imp().playlist_label_entry.as_ref()
}
pub fn set_grows_automatically(&self) {
let entry: &gtk::Entry = &self.imp().playlist_label_entry;
entry
.bind_property("text", entry, "width-chars")
.transform_to(|_, text: &str| Some(text.len() as i32))
.flags(glib::BindingFlags::DEFAULT | glib::BindingFlags::SYNC_CREATE)
.build();
}
}

View file

@ -0,0 +1,77 @@
using Gtk 4.0;
using Adw 1;
template $PlaylistHeaderBarWidget : Adw.Bin {
[root]
Overlay overlay {
hexpand: true;
Adw.HeaderBar main_header {
show-end-title-buttons: true;
Button go_back {
receives-default: true;
halign: start;
valign: center;
icon-name: "go-previous-symbolic";
has-frame: false;
}
[title]
Adw.WindowTitle title {
visible: false;
title: "Spot";
}
[end]
Button edit {
icon-name: "document-edit-symbolic";
}
styles [
"playlist_details__headerbar",
]
}
[overlay]
Adw.HeaderBar edition_header {
show-end-title-buttons: false;
show-start-title-buttons: false;
visible: false;
styles [
"selection-mode",
]
Button cancel {
receives-default: true;
halign: start;
valign: center;
/* Translators: Exit playlist edition */
label: _("Cancel");
}
[title]
Separator {
styles [
"spacer",
]
}
[end]
Button ok {
valign: center;
/* Translators: Finish playlist edition */
label: _("Done");
styles [
"suggested-action",
]
}
}
}
}

View file

@ -0,0 +1,164 @@
use gtk::prelude::*;
use gtk::subclass::prelude::*;
use gtk::CompositeTemplate;
use libadwaita::subclass::prelude::BinImpl;
mod imp {
use super::*;
#[derive(Debug, Default, CompositeTemplate)]
#[template(resource = "/dev/alextren/Spot/components/playlist_headerbar.ui")]
pub struct PlaylistHeaderBarWidget {
#[template_child]
pub main_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub edition_header: TemplateChild<libadwaita::HeaderBar>,
#[template_child]
pub go_back: TemplateChild<gtk::Button>,
#[template_child]
pub title: TemplateChild<libadwaita::WindowTitle>,
#[template_child]
pub edit: TemplateChild<gtk::Button>,
#[template_child]
pub ok: TemplateChild<gtk::Button>,
#[template_child]
pub cancel: TemplateChild<gtk::Button>,
#[template_child]
pub overlay: TemplateChild<gtk::Overlay>,
}
#[glib::object_subclass]
impl ObjectSubclass for PlaylistHeaderBarWidget {
const NAME: &'static str = "PlaylistHeaderBarWidget";
type Type = super::PlaylistHeaderBarWidget;
type ParentType = libadwaita::Bin;
type Interfaces = (gtk::Buildable,);
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
}
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
obj.init_template();
}
}
impl ObjectImpl for PlaylistHeaderBarWidget {}
impl BuildableImpl for PlaylistHeaderBarWidget {
fn add_child(&self, builder: &gtk::Builder, child: &glib::Object, type_: Option<&str>) {
if Some("root") == type_ {
self.parent_add_child(builder, child, type_);
} else {
self.main_header
.set_title_widget(child.downcast_ref::<gtk::Widget>());
}
}
}
impl WidgetImpl for PlaylistHeaderBarWidget {}
impl BinImpl for PlaylistHeaderBarWidget {}
impl WindowImpl for PlaylistHeaderBarWidget {}
}
glib::wrapper! {
pub struct PlaylistHeaderBarWidget(ObjectSubclass<imp::PlaylistHeaderBarWidget>) @extends gtk::Widget, libadwaita::Bin;
}
impl Default for PlaylistHeaderBarWidget {
fn default() -> Self {
Self::new()
}
}
impl PlaylistHeaderBarWidget {
pub fn new() -> Self {
glib::Object::new()
}
pub fn connect_edit<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().edit.connect_clicked(move |_| f());
}
pub fn connect_ok<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().ok.connect_clicked(move |_| f());
}
pub fn connect_cancel<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().cancel.connect_clicked(move |_| f());
}
pub fn connect_go_back<F>(&self, f: F)
where
F: Fn() + 'static,
{
self.imp().go_back.connect_clicked(move |_| f());
}
pub fn bind_to_leaflet(&self, leaflet: &libadwaita::Leaflet) {
leaflet
.bind_property(
"folded",
&*self.imp().main_header,
"show-start-title-buttons",
)
.build();
leaflet.notify("folded");
}
pub fn set_can_go_back(&self, can_go_back: bool) {
self.imp().go_back.set_visible(can_go_back);
}
pub fn set_editable(&self, editable: bool) {
self.imp().edit.set_visible(editable);
}
pub fn set_editing(&self, editing: bool) {
if editing {
self.imp().edition_header.set_visible(true);
} else {
self.imp().edition_header.set_visible(false);
}
}
pub fn add_classes(&self, classes: &[&str]) {
for &class in classes {
self.add_css_class(class);
}
}
pub fn remove_classes(&self, classes: &[&str]) {
for &class in classes {
self.remove_css_class(class);
}
}
pub fn set_title_visible(&self, visible: bool) {
self.imp().title.set_visible(visible);
}
pub fn set_title(&self, title: Option<&str>) {
self.imp().title.set_visible(title.is_some());
if let Some(title) = title {
self.imp().title.set_title(title);
}
}
}