amharc 0.2.0

A cinematic GTK4 and GStreamer desktop video player with a floating widget mode
Documentation
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: &gtk::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(&section);

    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(&section);

    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(&gtk::Image::from_icon_name(icon_name));
    content.append(&gtk::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: &gtk::ListBox) {
    while let Some(child) = list.first_child() {
        list.remove(&child);
    }
}

pub(crate) fn set_count(label: &gtk::Label, count: usize) {
    label.set_text(&format!("{count:02}"));
}