- Workflow page accepts .pixstrip-preset file drops - Dropped preset files are imported and applied to current config - Saved to user presets automatically on successful import
429 lines
14 KiB
Rust
429 lines
14 KiB
Rust
use adw::prelude::*;
|
|
use pixstrip_core::preset::Preset;
|
|
use pixstrip_core::operations::*;
|
|
use crate::app::{AppState, JobConfig, MetadataMode};
|
|
|
|
pub fn build_workflow_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();
|
|
|
|
// Built-in presets section
|
|
let builtin_group = adw::PreferencesGroup::builder()
|
|
.title("Built-in Workflows")
|
|
.description("Select a preset to get started quickly")
|
|
.build();
|
|
|
|
let builtin_flow = gtk::FlowBox::builder()
|
|
.selection_mode(gtk::SelectionMode::Single)
|
|
.max_children_per_line(4)
|
|
.min_children_per_line(2)
|
|
.row_spacing(8)
|
|
.column_spacing(8)
|
|
.homogeneous(true)
|
|
.build();
|
|
|
|
let builtins = Preset::all_builtins();
|
|
for preset in &builtins {
|
|
let card = build_preset_card(preset);
|
|
builtin_flow.append(&card);
|
|
}
|
|
|
|
// When a preset card is activated, apply it to JobConfig and advance
|
|
{
|
|
let jc = state.job_config.clone();
|
|
builtin_flow.connect_child_activated(move |flow, child| {
|
|
let idx = child.index() as usize;
|
|
if let Some(preset) = builtins.get(idx) {
|
|
apply_preset_to_config(&mut jc.borrow_mut(), preset);
|
|
}
|
|
flow.activate_action("win.next-step", None).ok();
|
|
});
|
|
}
|
|
|
|
builtin_group.add(&builtin_flow);
|
|
content.append(&builtin_group);
|
|
|
|
// Custom workflow section
|
|
let custom_group = adw::PreferencesGroup::builder()
|
|
.title("Custom Workflow")
|
|
.description("Choose which operations to include, then click Next")
|
|
.build();
|
|
|
|
let resize_check = adw::SwitchRow::builder()
|
|
.title("Resize")
|
|
.subtitle("Scale images to new dimensions")
|
|
.active(state.job_config.borrow().resize_enabled)
|
|
.build();
|
|
|
|
let adjustments_check = adw::SwitchRow::builder()
|
|
.title("Adjustments")
|
|
.subtitle("Rotate, flip, brightness, contrast, effects")
|
|
.active(state.job_config.borrow().adjustments_enabled)
|
|
.build();
|
|
|
|
let convert_check = adw::SwitchRow::builder()
|
|
.title("Convert")
|
|
.subtitle("Change image format (JPEG, PNG, WebP, AVIF)")
|
|
.active(state.job_config.borrow().convert_enabled)
|
|
.build();
|
|
|
|
let compress_check = adw::SwitchRow::builder()
|
|
.title("Compress")
|
|
.subtitle("Reduce file size with quality control")
|
|
.active(state.job_config.borrow().compress_enabled)
|
|
.build();
|
|
|
|
let metadata_check = adw::SwitchRow::builder()
|
|
.title("Metadata")
|
|
.subtitle("Strip or modify EXIF, GPS, camera data")
|
|
.active(state.job_config.borrow().metadata_enabled)
|
|
.build();
|
|
|
|
let watermark_check = adw::SwitchRow::builder()
|
|
.title("Watermark")
|
|
.subtitle("Add text or image overlay")
|
|
.active(state.job_config.borrow().watermark_enabled)
|
|
.build();
|
|
|
|
let rename_check = adw::SwitchRow::builder()
|
|
.title("Rename")
|
|
.subtitle("Rename files with prefix, suffix, or template")
|
|
.active(state.job_config.borrow().rename_enabled)
|
|
.build();
|
|
|
|
custom_group.add(&resize_check);
|
|
custom_group.add(&adjustments_check);
|
|
custom_group.add(&convert_check);
|
|
custom_group.add(&compress_check);
|
|
custom_group.add(&metadata_check);
|
|
custom_group.add(&watermark_check);
|
|
custom_group.add(&rename_check);
|
|
|
|
// Wire custom operation toggles to job config
|
|
{
|
|
let jc = state.job_config.clone();
|
|
resize_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().resize_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
adjustments_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().adjustments_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
convert_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().convert_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
compress_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().compress_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
metadata_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().metadata_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
watermark_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().watermark_enabled = row.is_active();
|
|
});
|
|
}
|
|
{
|
|
let jc = state.job_config.clone();
|
|
rename_check.connect_active_notify(move |row| {
|
|
jc.borrow_mut().rename_enabled = row.is_active();
|
|
});
|
|
}
|
|
|
|
content.append(&custom_group);
|
|
|
|
// User presets section
|
|
let user_group = adw::PreferencesGroup::builder()
|
|
.title("Your Presets")
|
|
.description("Import or save your own workflows")
|
|
.build();
|
|
|
|
// Show saved user presets
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
if let Ok(presets) = store.list() {
|
|
for preset in &presets {
|
|
if !preset.is_custom {
|
|
continue;
|
|
}
|
|
let row = adw::ActionRow::builder()
|
|
.title(&preset.name)
|
|
.subtitle(&preset.description)
|
|
.activatable(true)
|
|
.build();
|
|
row.add_prefix(>k::Image::from_icon_name(&preset.icon));
|
|
|
|
// Export button
|
|
let export_btn = gtk::Button::builder()
|
|
.icon_name("document-save-as-symbolic")
|
|
.tooltip_text("Export preset")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
export_btn.add_css_class("flat");
|
|
let preset_for_export = preset.clone();
|
|
export_btn.connect_clicked(move |btn| {
|
|
let p = preset_for_export.clone();
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title("Export Preset")
|
|
.initial_name(&format!("{}.pixstrip-preset", p.name))
|
|
.modal(true)
|
|
.build();
|
|
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
|
|
dialog.save(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result
|
|
&& let Some(path) = file.path()
|
|
{
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
let _ = store.export_to_file(&p, &path);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
row.add_suffix(&export_btn);
|
|
|
|
// Delete button
|
|
let delete_btn = gtk::Button::builder()
|
|
.icon_name("user-trash-symbolic")
|
|
.tooltip_text("Delete preset")
|
|
.valign(gtk::Align::Center)
|
|
.build();
|
|
delete_btn.add_css_class("flat");
|
|
delete_btn.add_css_class("error");
|
|
let pname = preset.name.clone();
|
|
let row_ref = row.clone();
|
|
let group_ref = user_group.clone();
|
|
delete_btn.connect_clicked(move |_| {
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
let _ = store.delete(&pname);
|
|
group_ref.remove(&row_ref);
|
|
});
|
|
row.add_suffix(&delete_btn);
|
|
|
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
let jc = state.job_config.clone();
|
|
let p = preset.clone();
|
|
row.connect_activated(move |r| {
|
|
apply_preset_to_config(&mut jc.borrow_mut(), &p);
|
|
r.activate_action("win.next-step", None).ok();
|
|
});
|
|
|
|
user_group.add(&row);
|
|
}
|
|
}
|
|
|
|
let import_button = gtk::Button::builder()
|
|
.label("Import Preset")
|
|
.icon_name("document-open-symbolic")
|
|
.action_name("win.import-preset")
|
|
.build();
|
|
import_button.add_css_class("flat");
|
|
user_group.add(&import_button);
|
|
content.append(&user_group);
|
|
|
|
scrolled.set_child(Some(&content));
|
|
|
|
let clamp = adw::Clamp::builder()
|
|
.maximum_size(800)
|
|
.child(&scrolled)
|
|
.build();
|
|
|
|
// Drop target for .pixstrip-preset files
|
|
let drop_target = gtk::DropTarget::new(gtk::gio::File::static_type(), gtk::gdk::DragAction::COPY);
|
|
let jc_drop = state.job_config.clone();
|
|
drop_target.connect_drop(move |_target, value, _x, _y| {
|
|
if let Ok(file) = value.get::<gtk::gio::File>() {
|
|
if let Some(path) = file.path() {
|
|
if path.extension().and_then(|e| e.to_str()) == Some("pixstrip-preset") {
|
|
let store = pixstrip_core::storage::PresetStore::new();
|
|
if let Ok(preset) = store.import_from_file(&path) {
|
|
apply_preset_to_config(&mut jc_drop.borrow_mut(), &preset);
|
|
let _ = store.save(&preset);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
false
|
|
});
|
|
clamp.add_controller(drop_target);
|
|
|
|
adw::NavigationPage::builder()
|
|
.title("Choose a Workflow")
|
|
.tag("step-workflow")
|
|
.child(&clamp)
|
|
.build()
|
|
}
|
|
|
|
fn apply_preset_to_config(cfg: &mut JobConfig, preset: &Preset) {
|
|
// Resize
|
|
match &preset.resize {
|
|
Some(ResizeConfig::ByWidth(w)) => {
|
|
cfg.resize_enabled = true;
|
|
cfg.resize_width = *w;
|
|
cfg.resize_height = 0;
|
|
cfg.allow_upscale = false;
|
|
}
|
|
Some(ResizeConfig::ByHeight(h)) => {
|
|
cfg.resize_enabled = true;
|
|
cfg.resize_width = 0;
|
|
cfg.resize_height = *h;
|
|
cfg.allow_upscale = false;
|
|
}
|
|
Some(ResizeConfig::FitInBox { max, allow_upscale }) => {
|
|
cfg.resize_enabled = true;
|
|
cfg.resize_width = max.width;
|
|
cfg.resize_height = max.height;
|
|
cfg.allow_upscale = *allow_upscale;
|
|
}
|
|
Some(ResizeConfig::Exact(dims)) => {
|
|
cfg.resize_enabled = true;
|
|
cfg.resize_width = dims.width;
|
|
cfg.resize_height = dims.height;
|
|
cfg.allow_upscale = true;
|
|
}
|
|
None => {
|
|
cfg.resize_enabled = false;
|
|
}
|
|
}
|
|
|
|
// Convert
|
|
match &preset.convert {
|
|
Some(ConvertConfig::SingleFormat(fmt)) => {
|
|
cfg.convert_enabled = true;
|
|
cfg.convert_format = Some(*fmt);
|
|
}
|
|
Some(_) => {
|
|
cfg.convert_enabled = true;
|
|
cfg.convert_format = None;
|
|
}
|
|
None => {
|
|
cfg.convert_enabled = false;
|
|
cfg.convert_format = None;
|
|
}
|
|
}
|
|
|
|
// Compress
|
|
match &preset.compress {
|
|
Some(CompressConfig::Preset(q)) => {
|
|
cfg.compress_enabled = true;
|
|
cfg.quality_preset = *q;
|
|
}
|
|
Some(CompressConfig::Custom { jpeg_quality, png_level, webp_quality, .. }) => {
|
|
cfg.compress_enabled = true;
|
|
if let Some(jq) = jpeg_quality {
|
|
cfg.jpeg_quality = *jq;
|
|
}
|
|
if let Some(pl) = png_level {
|
|
cfg.png_level = *pl;
|
|
}
|
|
if let Some(wq) = webp_quality {
|
|
cfg.webp_quality = *wq as u8;
|
|
}
|
|
}
|
|
None => {
|
|
cfg.compress_enabled = false;
|
|
}
|
|
}
|
|
|
|
// Metadata
|
|
match &preset.metadata {
|
|
Some(MetadataConfig::StripAll) => {
|
|
cfg.metadata_enabled = true;
|
|
cfg.metadata_mode = MetadataMode::StripAll;
|
|
}
|
|
Some(MetadataConfig::Privacy) => {
|
|
cfg.metadata_enabled = true;
|
|
cfg.metadata_mode = MetadataMode::Privacy;
|
|
}
|
|
Some(MetadataConfig::KeepAll) => {
|
|
cfg.metadata_enabled = true;
|
|
cfg.metadata_mode = MetadataMode::KeepAll;
|
|
}
|
|
Some(MetadataConfig::Custom { strip_gps, strip_camera, strip_software, strip_timestamps, strip_copyright }) => {
|
|
cfg.metadata_enabled = true;
|
|
cfg.metadata_mode = MetadataMode::Custom;
|
|
cfg.strip_gps = *strip_gps;
|
|
cfg.strip_camera = *strip_camera;
|
|
cfg.strip_software = *strip_software;
|
|
cfg.strip_timestamps = *strip_timestamps;
|
|
cfg.strip_copyright = *strip_copyright;
|
|
}
|
|
None => {
|
|
cfg.metadata_enabled = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_preset_card(preset: &Preset) -> gtk::Box {
|
|
let card = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(8)
|
|
.halign(gtk::Align::Center)
|
|
.valign(gtk::Align::Start)
|
|
.build();
|
|
card.add_css_class("card");
|
|
card.set_size_request(180, 120);
|
|
|
|
let inner = gtk::Box::builder()
|
|
.orientation(gtk::Orientation::Vertical)
|
|
.spacing(4)
|
|
.margin_top(16)
|
|
.margin_bottom(16)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.halign(gtk::Align::Center)
|
|
.valign(gtk::Align::Center)
|
|
.vexpand(true)
|
|
.build();
|
|
|
|
let icon = gtk::Image::builder()
|
|
.icon_name(&preset.icon)
|
|
.pixel_size(32)
|
|
.build();
|
|
|
|
let name_label = gtk::Label::builder()
|
|
.label(&preset.name)
|
|
.css_classes(["heading"])
|
|
.build();
|
|
|
|
let desc_label = gtk::Label::builder()
|
|
.label(&preset.description)
|
|
.css_classes(["caption", "dim-label"])
|
|
.wrap(true)
|
|
.justify(gtk::Justification::Center)
|
|
.max_width_chars(20)
|
|
.build();
|
|
|
|
inner.append(&icon);
|
|
inner.append(&name_label);
|
|
inner.append(&desc_label);
|
|
card.append(&inner);
|
|
|
|
card
|
|
}
|
|
|