use std::path::Path;
use gtk::{pango, prelude::*};
pub(crate) struct WorkspaceWidgets {
pub(crate) window: gtk::Window,
pub(crate) pages: gtk::Stack,
pub(crate) folder_stack: gtk::Stack,
pub(crate) folder_list: gtk::ListBox,
pub(crate) folder_count: gtk::Label,
pub(crate) add_folder: gtk::Button,
pub(crate) playlist_stack: gtk::Stack,
pub(crate) playlist_list: gtk::ListBox,
pub(crate) playlist_count: gtk::Label,
pub(crate) save_playlist: gtk::Button,
}
pub(crate) struct FolderRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) load: gtk::Button,
pub(crate) add: gtk::Button,
pub(crate) remove: gtk::Button,
}
pub(crate) struct PlaylistRowWidgets {
pub(crate) row: gtk::ListBoxRow,
pub(crate) load: gtk::Button,
pub(crate) update: gtk::Button,
pub(crate) remove: gtk::Button,
}
pub(crate) fn titlebar(title: &str) -> gtk::HeaderBar {
let bar = gtk::HeaderBar::new();
bar.add_css_class("kv-titlebar");
bar.set_show_title_buttons(true);
let label = gtk::Label::new(Some(title));
label.add_css_class("kv-titlebar-label");
bar.set_title_widget(Some(&label));
bar
}
pub(crate) fn build_workspace_window(parent: >k::ApplicationWindow) -> WorkspaceWidgets {
let window = gtk::Window::builder()
.title("Media workspace")
.transient_for(parent)
.modal(false)
.default_width(760)
.default_height(560)
.hide_on_close(true)
.build();
window.add_css_class("workspace-window");
window.set_titlebar(Some(&titlebar("Media workspace")));
let shell = gtk::Box::new(gtk::Orientation::Vertical, 0);
shell.add_css_class("workspace-shell");
let header = gtk::Box::new(gtk::Orientation::Horizontal, 14);
header.add_css_class("workspace-header");
let heading = gtk::Box::new(gtk::Orientation::Vertical, 3);
heading.set_hexpand(true);
let eyebrow = gtk::Label::new(Some("MEDIA WORKSPACE"));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let title = gtk::Label::new(Some("Libraries & playlists"));
title.add_css_class("workspace-title");
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(
"Keep several video folders close, switch sources, and save queues you want to revisit.",
));
detail.add_css_class("muted");
detail.set_ellipsize(pango::EllipsizeMode::End);
detail.set_xalign(0.0);
heading.append(&eyebrow);
heading.append(&title);
heading.append(&detail);
header.append(&heading);
shell.append(&header);
let pages = gtk::Stack::new();
pages.set_transition_type(gtk::StackTransitionType::Crossfade);
pages.set_transition_duration(160);
pages.set_hexpand(true);
pages.set_vexpand(true);
let switcher = gtk::StackSwitcher::new();
switcher.add_css_class("workspace-switcher");
switcher.set_halign(gtk::Align::Start);
switcher.set_stack(Some(&pages));
let navigation = gtk::Box::new(gtk::Orientation::Horizontal, 0);
navigation.add_css_class("workspace-navigation");
navigation.append(&switcher);
shell.append(&navigation);
let (folder_page, folder_stack, folder_list, folder_count, add_folder) = build_folder_page();
pages.add_titled(&folder_page, Some("folders"), "FOLDERS");
let (playlist_page, playlist_stack, playlist_list, playlist_count, save_playlist) =
build_playlist_page();
pages.add_titled(&playlist_page, Some("playlists"), "PLAYLISTS");
shell.append(&pages);
window.set_child(Some(&shell));
WorkspaceWidgets {
window,
pages,
folder_stack,
folder_list,
folder_count,
add_folder,
playlist_stack,
playlist_list,
playlist_count,
save_playlist,
}
}
fn build_folder_page() -> (gtk::Box, gtk::Stack, gtk::ListBox, gtk::Label, gtk::Button) {
let page = page_shell();
let (section, count) = section_heading(
"VIDEO LOCATIONS",
"Choose Load to switch to a folder, or Add to merge it into the current queue.",
);
page.append(§ion);
let list = workspace_list();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = empty_state(
"folder-videos-symbolic",
"No folders configured",
"Add one or more locations, then move between your collections without hunting through file dialogs.",
);
let stack = content_stack(&scroll, &empty);
page.append(&stack);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("workspace-footer");
let add_folder = action_button("folder-new-symbolic", "ADD FOLDER", "Choose a video folder");
add_folder.add_css_class("primary-workspace-action");
footer.append(&add_folder);
page.append(&footer);
(page, stack, list, count, add_folder)
}
fn build_playlist_page() -> (gtk::Box, gtk::Stack, gtk::ListBox, gtk::Label, gtk::Button) {
let page = page_shell();
let (section, count) = section_heading(
"NAMED PLAYLISTS",
"Save the current queue, then load or update it whenever you want.",
);
page.append(§ion);
let list = workspace_list();
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&list)
.build();
let empty = empty_state(
"view-list-symbolic",
"No playlists yet",
"Arrange a queue in the player and save it here as a reusable playlist.",
);
let stack = content_stack(&scroll, &empty);
page.append(&stack);
let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8);
footer.add_css_class("workspace-footer");
let save_playlist = action_button(
"document-save-symbolic",
"SAVE CURRENT QUEUE",
"Save the current queue as a named playlist",
);
save_playlist.add_css_class("primary-workspace-action");
footer.append(&save_playlist);
page.append(&footer);
(page, stack, list, count, save_playlist)
}
fn page_shell() -> gtk::Box {
let page = gtk::Box::new(gtk::Orientation::Vertical, 14);
page.add_css_class("workspace-page");
page
}
fn section_heading(eyebrow_text: &str, detail_text: &str) -> (gtk::Box, gtk::Label) {
let row = gtk::Box::new(gtk::Orientation::Horizontal, 12);
let labels = gtk::Box::new(gtk::Orientation::Vertical, 3);
labels.set_hexpand(true);
let eyebrow = gtk::Label::new(Some(eyebrow_text));
eyebrow.add_css_class("eyebrow");
eyebrow.set_xalign(0.0);
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_wrap(true);
detail.set_xalign(0.0);
labels.append(&eyebrow);
labels.append(&detail);
let count = gtk::Label::new(Some("00"));
count.add_css_class("queue-count");
count.set_valign(gtk::Align::Start);
row.append(&labels);
row.append(&count);
(row, count)
}
fn workspace_list() -> gtk::ListBox {
let list = gtk::ListBox::new();
list.add_css_class("workspace-list");
list.set_selection_mode(gtk::SelectionMode::None);
list
}
fn content_stack(content: &impl IsA<gtk::Widget>, empty: &impl IsA<gtk::Widget>) -> gtk::Stack {
let stack = gtk::Stack::new();
stack.set_vexpand(true);
stack.add_named(content, Some("content"));
stack.add_named(empty, Some("empty"));
stack.set_visible_child_name("empty");
stack
}
fn empty_state(icon_name: &str, title_text: &str, detail_text: &str) -> gtk::Box {
let empty = gtk::Box::new(gtk::Orientation::Vertical, 9);
empty.add_css_class("workspace-empty");
empty.set_halign(gtk::Align::Center);
empty.set_valign(gtk::Align::Center);
empty.set_vexpand(true);
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(32);
icon.add_css_class("workspace-empty-icon");
let title = gtk::Label::new(Some(title_text));
title.add_css_class("workspace-empty-title");
let detail = gtk::Label::new(Some(detail_text));
detail.add_css_class("muted");
detail.set_justify(gtk::Justification::Center);
detail.set_max_width_chars(56);
detail.set_wrap(true);
empty.append(&icon);
empty.append(&title);
empty.append(&detail);
empty
}
fn action_button(icon_name: &str, label: &str, tooltip: &str) -> gtk::Button {
let button = gtk::Button::new();
let content = gtk::Box::new(gtk::Orientation::Horizontal, 7);
content.append(>k::Image::from_icon_name(icon_name));
content.append(>k::Label::new(Some(label)));
button.set_child(Some(&content));
button.set_tooltip_text(Some(tooltip));
button
}
pub(crate) fn folder_row(name: &str, path: &Path, active: bool) -> FolderRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("workspace-row");
if active {
row.add_css_class("active");
}
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 11);
let icon = gtk::Image::from_icon_name(if active {
"folder-open-symbolic"
} else {
"folder-symbolic"
});
icon.set_pixel_size(22);
icon.add_css_class("workspace-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(name));
title.add_css_class("workspace-row-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_xalign(0.0);
let detail = gtk::Label::new(Some(path.to_string_lossy().as_ref()));
detail.add_css_class("muted");
detail.set_ellipsize(pango::EllipsizeMode::Middle);
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let load = gtk::Button::with_label(if active { "RELOAD" } else { "LOAD" });
load.add_css_class("workspace-row-action");
load.set_tooltip_text(Some("Replace the queue with videos from this folder"));
let add = gtk::Button::from_icon_name("list-add-symbolic");
add.add_css_class("compact-action");
add.set_tooltip_text(Some("Add this folder's videos to the current queue"));
add.update_property(&[gtk::accessible::Property::Label(
"Add folder videos to queue",
)]);
let remove = gtk::Button::from_icon_name("edit-delete-symbolic");
remove.add_css_class("compact-action");
remove.add_css_class("destructive-action");
remove.set_tooltip_text(Some("Forget this folder (videos on disk are untouched)"));
remove.update_property(&[gtk::accessible::Property::Label(
"Remove folder from workspace",
)]);
content.append(&icon);
content.append(&labels);
content.append(&load);
content.append(&add);
content.append(&remove);
row.set_child(Some(&content));
FolderRowWidgets {
row,
load,
add,
remove,
}
}
pub(crate) fn playlist_row(name: &str, item_count: usize) -> PlaylistRowWidgets {
let row = gtk::ListBoxRow::new();
row.add_css_class("workspace-row");
row.set_activatable(false);
row.set_selectable(false);
let content = gtk::Box::new(gtk::Orientation::Horizontal, 11);
let icon = gtk::Image::from_icon_name("view-list-symbolic");
icon.set_pixel_size(22);
icon.add_css_class("workspace-row-icon");
let labels = gtk::Box::new(gtk::Orientation::Vertical, 2);
labels.set_hexpand(true);
let title = gtk::Label::new(Some(name));
title.add_css_class("workspace-row-title");
title.set_ellipsize(pango::EllipsizeMode::End);
title.set_xalign(0.0);
let noun = if item_count == 1 { "VIDEO" } else { "VIDEOS" };
let detail = gtk::Label::new(Some(&format!("{item_count:02} {noun}")));
detail.add_css_class("muted");
detail.set_xalign(0.0);
labels.append(&title);
labels.append(&detail);
let load = gtk::Button::with_label("LOAD");
load.add_css_class("workspace-row-action");
load.set_tooltip_text(Some("Replace the queue with this playlist"));
let update = gtk::Button::from_icon_name("view-refresh-symbolic");
update.add_css_class("compact-action");
update.set_tooltip_text(Some("Update this playlist from the current queue"));
update.update_property(&[gtk::accessible::Property::Label(
"Update playlist from current queue",
)]);
let remove = gtk::Button::from_icon_name("edit-delete-symbolic");
remove.add_css_class("compact-action");
remove.add_css_class("destructive-action");
remove.set_tooltip_text(Some("Delete this saved playlist"));
remove.update_property(&[gtk::accessible::Property::Label("Delete playlist")]);
content.append(&icon);
content.append(&labels);
content.append(&load);
content.append(&update);
content.append(&remove);
row.set_child(Some(&content));
PlaylistRowWidgets {
row,
load,
update,
remove,
}
}
pub(crate) fn clear_list(list: >k::ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
}
pub(crate) fn set_count(label: >k::Label, count: usize) {
label.set_text(&format!("{count:02}"));
}