//! Exported clips: local MP4 files that never expire automatically. use clipbin_core::library::{format_ago, format_bytes, format_length}; use gpui::{ AnyElement, ClickEvent, Context, FontWeight, IntoElement, Pixels, canvas, div, prelude::*, px, }; use crate::{ app::ClipbinApp, components::{ButtonStyle, button, chip, count, empty_state, page_header}, screens::{corner, thumbnail}, theme, }; pub fn render(app: &ClipbinApp, cx: &mut Context) -> AnyElement { let clips = &app.library.clips; let total: u64 = clips.iter().map(|clip| clip.size_bytes).sum(); let header = page_header( "Clips", if clips.is_empty() { "Clips you save are kept until you delete them.".to_owned() } else { format!( "{} · {} · kept until you delete them", count(clips.len(), "clip"), format_bytes(total) ) }, vec![ button( "open-clips-folder", ButtonStyle::Secondary, Some("folder-open"), Some("Open folder"), ) .on_click(cx.listener(|app, _: &ClickEvent, _, cx| { app.service.open_clip(None); cx.notify(); })) .into_any_element(), ], ); let failed_actions = (app.failed_exports > 0).then(|| { div() .flex() .items_center() .gap_2() .child(format!( "{} failed exports · source footage kept", app.failed_exports )) .child( button("retry-exports", ButtonStyle::Secondary, None, Some("Retry")).on_click( cx.listener(|app, _: &ClickEvent, _, cx| { app.service.retry_exports(); cx.notify(); }), ), ) .child( button( "release-exports", ButtonStyle::Ghost, None, Some("Release protection"), ) .on_click(cx.listener(|app, _: &ClickEvent, _, cx| { app.service.release_exports(); cx.notify(); })), ) }); if clips.is_empty() { return div() .flex() .flex_col() .gap_6() .child(header) .children(failed_actions) .child(empty_state( "scissors", "No clips yet", "Press the clip hotkey in game to save the last two minutes, or select a range on a session's timeline.", )) .into_any_element(); } let now = app.now(); let cards = clips.iter().map(|clip| { let id = clip.id; div() .flex() .flex_col() .min_w_0() .gap_3() .child( thumbnail(&clip.game, app.playback.clip_thumbnails.get(&clip.id)).child( corner() .bottom_2p5() .right_2p5() .child(chip(format_length(clip.length))), ), ) .child( div() .flex() .flex_col() .min_w_0() .gap_2() .px_1() .child( div() .flex() .flex_col() .min_w_0() .gap_0p5() .child( div() .text_base() .font_weight(FontWeight::SEMIBOLD) .text_color(theme::foreground()) .child(label(clip.game.clone(), px(24.))), ) .child(div().text_xs().text_color(theme::subtle()).child(label( format!( "{} · {}", format_ago(clip.created, now), format_bytes(clip.size_bytes) ), px(18.), ))), ) .child( div() .flex() .items_center() .justify_end() .gap_2() .flex_shrink_0() .child( button(("open-clip", id), ButtonStyle::Ghost, Some("play"), None) .flex_shrink_0() .rounded_full() .on_click(cx.listener(move |app, _: &ClickEvent, _, cx| { app.service.open_clip(Some(id)); cx.notify(); })), ) .child( button( ("delete-clip", id), ButtonStyle::Ghost, None, Some("Delete"), ) .flex_shrink_0() .on_click(cx.listener( move |app, _: &ClickEvent, _, cx| { app.service.delete_clip(id); cx.notify(); }, )), ), ), ) }); div() .flex() .flex_col() .gap_6() .child(header) .children(failed_actions) .child( div() .grid() .grid_cols(3) .gap_x_4() .gap_y_8() .children(cards), ) .into_any_element() } // GPUI 0.2.2 caches nowrap widths and mutates text runs during remeasurement. // Shape once at the final card bounds to retain both ellipsis and font styling. fn label(text: String, height: Pixels) -> impl IntoElement { canvas( move |bounds, window, cx| { let style = window.text_style(); let font_size = style.font_size.to_pixels(window.rem_size()); let line_height = style .line_height .to_pixels(font_size.into(), window.rem_size()); let text: gpui::SharedString = text.replace(['\n', '\r'], " ").into(); let mut runs = vec![style.to_run(text.len())]; let text = cx .text_system() .line_wrapper(style.font(), font_size) .truncate_line(text, bounds.size.width, "…", &mut runs); ( window .text_system() .shape_line(text, font_size, &runs, None), line_height, ) }, |bounds, (line, line_height), window, cx| { let _ = line.paint(bounds.origin, line_height, window, cx); }, ) .w_full() .h(height) .overflow_hidden() }