- Add detailed_mode to AppState, derived from skill_level setting - Expand advanced option sections by default in Detailed mode (resize, convert, compress, watermark steps) - Fix high contrast to use HighContrast GTK theme - Add completion sound via canberra-gtk-play
628 lines
21 KiB
Rust
628 lines
21 KiB
Rust
use adw::prelude::*;
|
|
use gtk::glib;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use crate::app::AppState;
|
|
use pixstrip_core::types::QualityPreset;
|
|
|
|
pub fn build_compress_page(state: &AppState) -> adw::NavigationPage {
|
|
let scrolled = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let content = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(12)
|
|
.margin_top(12)
|
|
.margin_bottom(12)
|
|
.margin_start(24)
|
|
.margin_end(24)
|
|
.build();
|
|
|
|
let cfg = state.job_config.borrow();
|
|
|
|
// Enable toggle
|
|
let enable_row = adw::SwitchRow::builder()
|
|
.title("Enable Compression")
|
|
.subtitle("Reduce file size with quality control")
|
|
.active(cfg.compress_enabled)
|
|
.build();
|
|
|
|
let enable_group = adw::PreferencesGroup::new();
|
|
enable_group.add(&enable_row);
|
|
content.append(&enable_group);
|
|
|
|
// Quality slider
|
|
let quality_group = adw::PreferencesGroup::builder()
|
|
.title("Quality Level")
|
|
.description("Higher quality means larger files. This sets the overall quality target.")
|
|
.build();
|
|
|
|
let initial_val = match cfg.quality_preset {
|
|
QualityPreset::WebOptimized => 1.0,
|
|
QualityPreset::Low => 2.0,
|
|
QualityPreset::Medium => 3.0,
|
|
QualityPreset::High => 4.0,
|
|
QualityPreset::Maximum => 5.0,
|
|
};
|
|
|
|
let quality_scale = gtk::Scale::builder()
|
|
.orientation(gtk::Orientation::Horizontal)
|
|
.adjustment(>k::Adjustment::new(initial_val, 1.0, 5.0, 1.0, 1.0, 0.0))
|
|
.draw_value(false)
|
|
.hexpand(true)
|
|
.build();
|
|
quality_scale.set_round_digits(0);
|
|
|
|
quality_scale.add_mark(1.0, gtk::PositionType::Bottom, Some("Web"));
|
|
quality_scale.add_mark(2.0, gtk::PositionType::Bottom, Some("Low"));
|
|
quality_scale.add_mark(3.0, gtk::PositionType::Bottom, Some("Medium"));
|
|
quality_scale.add_mark(4.0, gtk::PositionType::Bottom, Some("High"));
|
|
quality_scale.add_mark(5.0, gtk::PositionType::Bottom, Some("Maximum"));
|
|
quality_scale.update_property(&[
|
|
gtk::accessible::Property::Label("Compression quality, from Web Optimized to Maximum"),
|
|
]);
|
|
|
|
let quality_label = gtk::Label::builder()
|
|
.label(quality_description(initial_val as u32))
|
|
.css_classes(["dim-label"])
|
|
.margin_top(4)
|
|
.build();
|
|
|
|
let quality_box = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(4)
|
|
.margin_top(8)
|
|
.margin_bottom(8)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
quality_box.append(&quality_scale);
|
|
quality_box.append(&quality_label);
|
|
|
|
quality_group.add(&quality_box);
|
|
content.append(&quality_group);
|
|
|
|
// Compression preview - Squoosh-style split comparison
|
|
let preview_group = adw::PreferencesGroup::builder()
|
|
.title("Quality Preview")
|
|
.description("Drag the divider to compare original and compressed")
|
|
.build();
|
|
|
|
// Size labels above the preview
|
|
let size_box = gtk::CenterBox::builder()
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.margin_bottom(4)
|
|
.build();
|
|
let original_size_label = gtk::Label::builder()
|
|
.label("Original")
|
|
.css_classes(["dim-label", "caption"])
|
|
.halign(gtk::Align::Start)
|
|
.build();
|
|
let compressed_size_label = gtk::Label::builder()
|
|
.label("Compressed")
|
|
.css_classes(["dim-label", "caption"])
|
|
.halign(gtk::Align::End)
|
|
.build();
|
|
size_box.set_start_widget(Some(&original_size_label));
|
|
size_box.set_end_widget(Some(&compressed_size_label));
|
|
|
|
// Drawing area for the split preview
|
|
let divider_pos = Rc::new(RefCell::new(0.5f64)); // 0.0 to 1.0
|
|
let original_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
|
|
let compressed_pixbuf: Rc<RefCell<Option<gtk::gdk_pixbuf::Pixbuf>>> = Rc::new(RefCell::new(None));
|
|
let dragging = Rc::new(RefCell::new(false));
|
|
|
|
let preview_drawing = gtk::DrawingArea::builder()
|
|
.height_request(250)
|
|
.hexpand(true)
|
|
.build();
|
|
preview_drawing.update_property(&[
|
|
gtk::accessible::Property::Label("Compression quality comparison. Drag the vertical divider to compare original and compressed image."),
|
|
]);
|
|
|
|
// Draw function
|
|
{
|
|
let dp = divider_pos.clone();
|
|
let orig = original_pixbuf.clone();
|
|
let comp = compressed_pixbuf.clone();
|
|
preview_drawing.set_draw_func(move |_drawing, cr, width, height| {
|
|
let w = width as f64;
|
|
let h = height as f64;
|
|
|
|
// Background
|
|
cr.set_source_rgba(0.2, 0.2, 0.2, 1.0);
|
|
let _ = cr.paint();
|
|
|
|
let pos = *dp.borrow();
|
|
let divider_x = w * pos;
|
|
|
|
// Helper to draw a pixbuf scaled to fit the area
|
|
let draw_pixbuf = |cr: >k::cairo::Context,
|
|
pixbuf: >k::gdk_pixbuf::Pixbuf,
|
|
clip_x: f64,
|
|
clip_w: f64| {
|
|
let tw = pixbuf.width() as f64;
|
|
let th = pixbuf.height() as f64;
|
|
let scale = (w / tw).min(h / th);
|
|
let ox = (w - tw * scale) / 2.0;
|
|
let oy = (h - th * scale) / 2.0;
|
|
|
|
cr.save().unwrap();
|
|
cr.rectangle(clip_x, 0.0, clip_w, h);
|
|
cr.clip();
|
|
cr.translate(ox, oy);
|
|
cr.scale(scale, scale);
|
|
cr.set_source_pixbuf(pixbuf, 0.0, 0.0);
|
|
let _ = cr.paint();
|
|
cr.restore().unwrap();
|
|
};
|
|
|
|
// Draw original on left side
|
|
if let Some(ref pb) = *orig.borrow() {
|
|
draw_pixbuf(cr, pb, 0.0, divider_x);
|
|
}
|
|
|
|
// Draw compressed on right side
|
|
if let Some(ref pb) = *comp.borrow() {
|
|
draw_pixbuf(cr, pb, divider_x, w - divider_x);
|
|
}
|
|
|
|
// Draw divider line
|
|
cr.set_source_rgba(1.0, 1.0, 1.0, 0.9);
|
|
cr.set_line_width(2.0);
|
|
cr.move_to(divider_x, 0.0);
|
|
cr.line_to(divider_x, h);
|
|
let _ = cr.stroke();
|
|
|
|
// Draw handle circle
|
|
cr.arc(divider_x, h / 2.0, 12.0, 0.0, std::f64::consts::TAU);
|
|
cr.set_source_rgba(1.0, 1.0, 1.0, 0.95);
|
|
let _ = cr.fill_preserve();
|
|
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
|
|
cr.set_line_width(1.5);
|
|
let _ = cr.stroke();
|
|
|
|
// Draw arrows on handle
|
|
cr.set_source_rgba(0.3, 0.3, 0.3, 1.0);
|
|
cr.set_line_width(2.0);
|
|
// Left arrow
|
|
cr.move_to(divider_x - 4.0, h / 2.0);
|
|
cr.line_to(divider_x - 8.0, h / 2.0);
|
|
let _ = cr.stroke();
|
|
// Right arrow
|
|
cr.move_to(divider_x + 4.0, h / 2.0);
|
|
cr.line_to(divider_x + 8.0, h / 2.0);
|
|
let _ = cr.stroke();
|
|
|
|
// Labels on each side
|
|
cr.set_source_rgba(1.0, 1.0, 1.0, 0.7);
|
|
cr.set_font_size(11.0);
|
|
if divider_x > 60.0 {
|
|
cr.move_to(8.0, 16.0);
|
|
let _ = cr.show_text("Original");
|
|
}
|
|
if w - divider_x > 80.0 {
|
|
cr.move_to(divider_x + 8.0, 16.0);
|
|
let _ = cr.show_text("Compressed");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Drag gesture for the divider
|
|
let drag_gesture = gtk::GestureDrag::new();
|
|
{
|
|
let dp = divider_pos.clone();
|
|
let dr = dragging.clone();
|
|
let drawing = preview_drawing.clone();
|
|
drag_gesture.connect_drag_begin(move |_, x, _| {
|
|
let w = drawing.width() as f64;
|
|
let current = *dp.borrow() * w;
|
|
// Only start drag if near the divider
|
|
if (x - current).abs() < 30.0 {
|
|
*dr.borrow_mut() = true;
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let dp = divider_pos.clone();
|
|
let dr = dragging.clone();
|
|
let drawing = preview_drawing.clone();
|
|
drag_gesture.connect_drag_update(move |gesture, offset_x, _| {
|
|
if !*dr.borrow() {
|
|
return;
|
|
}
|
|
if let Some((start_x, _)) = gesture.start_point() {
|
|
let w = drawing.width() as f64;
|
|
if w > 0.0 {
|
|
let new_pos = ((start_x + offset_x) / w).clamp(0.05, 0.95);
|
|
*dp.borrow_mut() = new_pos;
|
|
drawing.queue_draw();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let dr = dragging.clone();
|
|
drag_gesture.connect_drag_end(move |_, _, _| {
|
|
*dr.borrow_mut() = false;
|
|
});
|
|
}
|
|
preview_drawing.add_controller(drag_gesture);
|
|
|
|
// Click gesture to set divider position directly
|
|
let click_gesture = gtk::GestureClick::new();
|
|
{
|
|
let dp = divider_pos.clone();
|
|
let drawing = preview_drawing.clone();
|
|
click_gesture.connect_released(move |_, _, x, _| {
|
|
let w = drawing.width() as f64;
|
|
if w > 0.0 {
|
|
*dp.borrow_mut() = (x / w).clamp(0.05, 0.95);
|
|
drawing.queue_draw();
|
|
}
|
|
});
|
|
}
|
|
preview_drawing.add_controller(click_gesture);
|
|
|
|
let preview_frame = gtk::Frame::builder()
|
|
.halign(gtk::Align::Fill)
|
|
.build();
|
|
preview_frame.set_child(Some(&preview_drawing));
|
|
|
|
preview_group.add(&size_box);
|
|
preview_group.add(&preview_frame);
|
|
|
|
// "No image loaded" placeholder
|
|
let no_image_label = gtk::Label::builder()
|
|
.label("Add images first to see compression preview")
|
|
.css_classes(["dim-label"])
|
|
.halign(gtk::Align::Center)
|
|
.margin_top(4)
|
|
.build();
|
|
preview_group.add(&no_image_label);
|
|
|
|
content.append(&preview_group);
|
|
|
|
// Advanced options in expander
|
|
let advanced_group = adw::PreferencesGroup::builder()
|
|
.title("Advanced Options")
|
|
.build();
|
|
|
|
let advanced_expander = adw::ExpanderRow::builder()
|
|
.title("Per-Format Quality")
|
|
.subtitle("Fine-tune quality for each format individually")
|
|
.expanded(state.detailed_mode)
|
|
.build();
|
|
|
|
let jpeg_row = adw::SpinRow::builder()
|
|
.title("JPEG Quality")
|
|
.subtitle("1-100, higher is better quality")
|
|
.adjustment(>k::Adjustment::new(cfg.jpeg_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
|
|
.build();
|
|
|
|
let png_row = adw::SpinRow::builder()
|
|
.title("PNG Compression Level")
|
|
.subtitle("1-6, higher is slower but smaller")
|
|
.adjustment(>k::Adjustment::new(cfg.png_level as f64, 1.0, 6.0, 1.0, 1.0, 0.0))
|
|
.build();
|
|
|
|
let webp_row = adw::SpinRow::builder()
|
|
.title("WebP Quality")
|
|
.subtitle("1-100, higher is better quality")
|
|
.adjustment(>k::Adjustment::new(cfg.webp_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
|
|
.build();
|
|
|
|
let avif_row = adw::SpinRow::builder()
|
|
.title("AVIF Quality")
|
|
.subtitle("1-100, higher is better quality")
|
|
.adjustment(>k::Adjustment::new(cfg.avif_quality as f64, 1.0, 100.0, 1.0, 10.0, 0.0))
|
|
.build();
|
|
|
|
let progressive_row = adw::SwitchRow::builder()
|
|
.title("Progressive JPEG")
|
|
.subtitle("Loads gradually, slightly larger files")
|
|
.active(cfg.progressive_jpeg)
|
|
.build();
|
|
|
|
let webp_effort_row = adw::SpinRow::builder()
|
|
.title("WebP Encoding Effort")
|
|
.subtitle("0-6, higher is slower but smaller files")
|
|
.adjustment(>k::Adjustment::new(cfg.webp_effort as f64, 0.0, 6.0, 1.0, 1.0, 0.0))
|
|
.build();
|
|
|
|
let avif_speed_row = adw::SpinRow::builder()
|
|
.title("AVIF Encoding Speed")
|
|
.subtitle("1-10, lower is slower but better compression")
|
|
.adjustment(>k::Adjustment::new(cfg.avif_speed as f64, 1.0, 10.0, 1.0, 1.0, 0.0))
|
|
.build();
|
|
|
|
advanced_expander.add_row(&jpeg_row);
|
|
advanced_expander.add_row(&progressive_row);
|
|
advanced_expander.add_row(&png_row);
|
|
advanced_expander.add_row(&webp_row);
|
|
advanced_expander.add_row(&webp_effort_row);
|
|
advanced_expander.add_row(&avif_row);
|
|
advanced_expander.add_row(&avif_speed_row);
|
|
|
|
advanced_group.add(&advanced_expander);
|
|
content.append(&advanced_group);
|
|
|
|
drop(cfg);
|
|
|
|
// Wire signals
|
|
{
|
|
let jc = state.job_config.clone();
|
|
enable_row.connect_active_notify(move |row| {
|
|
jc.borrow_mut().compress_enabled = row.is_active();
|
|
});
|
|
}
|
|
|
|
// Helper to load preview with a given quality preset
|
|
let update_preview = {
|
|
let files = state.loaded_files.clone();
|
|
let orig_pb = original_pixbuf.clone();
|
|
let comp_pb = compressed_pixbuf.clone();
|
|
let drawing = preview_drawing.clone();
|
|
let orig_label = original_size_label.clone();
|
|
let comp_label = compressed_size_label.clone();
|
|
let no_img_label = no_image_label.clone();
|
|
let jc = state.job_config.clone();
|
|
|
|
Rc::new(move || {
|
|
let loaded = files.borrow();
|
|
if loaded.is_empty() {
|
|
no_img_label.set_visible(true);
|
|
return;
|
|
}
|
|
no_img_label.set_visible(false);
|
|
|
|
// Pick the first image as sample
|
|
let sample_path = loaded[0].clone();
|
|
let cfg = jc.borrow();
|
|
let preset = cfg.quality_preset;
|
|
drop(cfg);
|
|
|
|
let orig_pb = orig_pb.clone();
|
|
let comp_pb = comp_pb.clone();
|
|
let drawing = drawing.clone();
|
|
let orig_label = orig_label.clone();
|
|
let comp_label = comp_label.clone();
|
|
|
|
// Load and compress in a background thread
|
|
let (tx, rx) = std::sync::mpsc::channel::<PreviewResult>();
|
|
|
|
std::thread::spawn(move || {
|
|
let result = generate_preview(&sample_path, preset);
|
|
let _ = tx.send(result);
|
|
});
|
|
|
|
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
|
match rx.try_recv() {
|
|
Ok(result) => {
|
|
match result {
|
|
PreviewResult::Success {
|
|
original_bytes,
|
|
compressed_bytes,
|
|
original_size,
|
|
compressed_size,
|
|
} => {
|
|
if let Ok(pb) = load_pixbuf_from_bytes(&original_bytes) {
|
|
*orig_pb.borrow_mut() = Some(pb);
|
|
}
|
|
if let Ok(pb) = load_pixbuf_from_bytes(&compressed_bytes) {
|
|
*comp_pb.borrow_mut() = Some(pb);
|
|
}
|
|
|
|
orig_label.set_label(&format!(
|
|
"Original: {}",
|
|
format_size(original_size)
|
|
));
|
|
let savings = if original_size > 0 {
|
|
((1.0 - compressed_size as f64 / original_size as f64) * 100.0)
|
|
as i32
|
|
} else {
|
|
0
|
|
};
|
|
comp_label.set_label(&format!(
|
|
"Compressed: {} (-{}%)",
|
|
format_size(compressed_size),
|
|
savings
|
|
));
|
|
|
|
drawing.queue_draw();
|
|
}
|
|
PreviewResult::Error(_) => {
|
|
orig_label.set_label("Original");
|
|
comp_label.set_label("Preview unavailable");
|
|
}
|
|
}
|
|
glib::ControlFlow::Break
|
|
}
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => glib::ControlFlow::Continue,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => glib::ControlFlow::Break,
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
// Trigger initial preview load
|
|
{
|
|
let up = update_preview.clone();
|
|
glib::idle_add_local_once(move || {
|
|
up();
|
|
});
|
|
}
|
|
|
|
{
|
|
let jc = state.job_config.clone();
|
|
let label = quality_label;
|
|
let up = update_preview;
|
|
quality_scale.connect_value_changed(move |scale| {
|
|
let val = scale.value().round() as u32;
|
|
let mut c = jc.borrow_mut();
|
|
c.quality_preset = match val {
|
|
1 => QualityPreset::WebOptimized,
|
|
2 => QualityPreset::Low,
|
|
3 => QualityPreset::Medium,
|
|
4 => QualityPreset::High,
|
|
_ => QualityPreset::Maximum,
|
|
};
|
|
label.set_label(&quality_description(val));
|
|
drop(c);
|
|
up();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
jpeg_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().jpeg_quality = row.value() as u8;
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
png_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().png_level = row.value() as u8;
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
webp_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().webp_quality = row.value() as u8;
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
avif_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().avif_quality = row.value() as u8;
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
progressive_row.connect_active_notify(move |row| {
|
|
jc.borrow_mut().progressive_jpeg = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
webp_effort_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().webp_effort = row.value() as u8;
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
avif_speed_row.connect_value_notify(move |row| {
|
|
jc.borrow_mut().avif_speed = row.value() as u8;
|
|
});
|
|
}
|
|
|
|
scrolled.set_child(Some(&content));
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(600)
|
|
.child(&scrolled)
|
|
.build();
|
|
|
|
adw::NavigationPage::builder()
|
|
.title("Compress")
|
|
.tag("step-compress")
|
|
.child(&clamp)
|
|
.build()
|
|
}
|
|
|
|
fn quality_description(val: u32) -> String {
|
|
match val {
|
|
1 => "Web Optimized - ~70-80% smaller files. Noticeable quality loss. Best for thumbnails and web previews.".into(),
|
|
2 => "Low - ~50-60% smaller files. Some visible quality loss. Good for email attachments and quick sharing.".into(),
|
|
3 => "Medium - ~30-40% smaller files. Good balance of quality and size. Recommended for most uses.".into(),
|
|
4 => "High - ~15-25% smaller files. Minimal quality loss. Good for printing and high-quality output.".into(),
|
|
_ => "Maximum - ~5-10% smaller files. Best possible quality, largest files. Archival and professional use.".into(),
|
|
}
|
|
}
|
|
|
|
enum PreviewResult {
|
|
Success {
|
|
original_bytes: Vec<u8>,
|
|
compressed_bytes: Vec<u8>,
|
|
original_size: u64,
|
|
compressed_size: u64,
|
|
},
|
|
Error(#[allow(dead_code)] String),
|
|
}
|
|
|
|
fn generate_preview(path: &std::path::Path, preset: QualityPreset) -> PreviewResult {
|
|
let original_size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
|
|
|
let img = match image::open(path) {
|
|
Ok(img) => img,
|
|
Err(e) => return PreviewResult::Error(e.to_string()),
|
|
};
|
|
|
|
// Scale down for preview to avoid large memory usage
|
|
let preview_img = if img.width() > 800 || img.height() > 800 {
|
|
img.resize(800, 800, image::imageops::FilterType::Triangle)
|
|
} else {
|
|
img
|
|
};
|
|
|
|
// Encode original as PNG for lossless reference
|
|
let mut orig_buf = Vec::new();
|
|
let orig_cursor = std::io::Cursor::new(&mut orig_buf);
|
|
if preview_img
|
|
.write_to(orig_cursor, image::ImageFormat::Png)
|
|
.is_err()
|
|
{
|
|
return PreviewResult::Error("Failed to encode original".into());
|
|
}
|
|
|
|
// Encode compressed as JPEG at the preset quality
|
|
let quality = preset.jpeg_quality();
|
|
let mut comp_buf = Vec::new();
|
|
let comp_cursor = std::io::Cursor::new(&mut comp_buf);
|
|
|
|
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(comp_cursor, quality);
|
|
let rgb = preview_img.to_rgb8();
|
|
if image::ImageEncoder::write_image(
|
|
encoder,
|
|
rgb.as_raw(),
|
|
rgb.width(),
|
|
rgb.height(),
|
|
image::ExtendedColorType::Rgb8,
|
|
)
|
|
.is_err()
|
|
{
|
|
return PreviewResult::Error("JPEG compression failed".into());
|
|
}
|
|
|
|
let compressed_size = comp_buf.len() as u64;
|
|
|
|
PreviewResult::Success {
|
|
original_bytes: orig_buf,
|
|
compressed_bytes: comp_buf,
|
|
original_size,
|
|
compressed_size,
|
|
}
|
|
}
|
|
|
|
fn load_pixbuf_from_bytes(bytes: &[u8]) -> Result<gtk::gdk_pixbuf::Pixbuf, String> {
|
|
let stream = gtk::gio::MemoryInputStream::from_bytes(&glib::Bytes::from(bytes));
|
|
gtk::gdk_pixbuf::Pixbuf::from_stream(
|
|
&stream,
|
|
gtk::gio::Cancellable::NONE,
|
|
)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn format_size(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
format!("{} B", bytes)
|
|
} else if bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
} else {
|
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
}
|
|
}
|