Add all wizard step UIs: workflow, images, resize, convert, compress, metadata, output
Full Adwaita widget-based layouts for all 7 wizard steps with PreferencesGroups, SwitchRows, SpinRows, ComboRows, FlowBoxes, ExpanderRows for social media presets, quality slider with named marks, metadata radio group, and output configuration.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod step_indicator;
|
mod step_indicator;
|
||||||
|
mod steps;
|
||||||
mod wizard;
|
mod wizard;
|
||||||
|
|
||||||
use gtk::prelude::*;
|
use gtk::prelude::*;
|
||||||
|
|||||||
7
pixstrip-gtk/src/steps/mod.rs
Normal file
7
pixstrip-gtk/src/steps/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod step_compress;
|
||||||
|
pub mod step_convert;
|
||||||
|
pub mod step_images;
|
||||||
|
pub mod step_metadata;
|
||||||
|
pub mod step_output;
|
||||||
|
pub mod step_resize;
|
||||||
|
pub mod step_workflow;
|
||||||
125
pixstrip-gtk/src/steps/step_compress.rs
Normal file
125
pixstrip-gtk/src/steps/step_compress.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_compress_page() -> 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();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.title("Enable Compression")
|
||||||
|
.subtitle("Reduce file size with quality control")
|
||||||
|
.active(true)
|
||||||
|
.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")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let quality_scale = gtk::Scale::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.adjustment(>k::Adjustment::new(3.0, 1.0, 5.0, 1.0, 1.0, 0.0))
|
||||||
|
.draw_value(false)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
quality_scale.set_round_digits(0);
|
||||||
|
|
||||||
|
// Add named marks
|
||||||
|
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"));
|
||||||
|
|
||||||
|
let quality_label = gtk::Label::builder()
|
||||||
|
.label("Medium - Good balance of quality and size")
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// Size estimation placeholder
|
||||||
|
let estimate_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Size Estimation")
|
||||||
|
.description("Load images to see real compression results")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let estimate_row = adw::ActionRow::builder()
|
||||||
|
.title("Estimated savings")
|
||||||
|
.subtitle("Add images to see file size comparison")
|
||||||
|
.build();
|
||||||
|
estimate_row.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic"));
|
||||||
|
|
||||||
|
estimate_group.add(&estimate_row);
|
||||||
|
content.append(&estimate_group);
|
||||||
|
|
||||||
|
// Advanced options
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced Options")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let jpeg_row = adw::SpinRow::builder()
|
||||||
|
.title("JPEG Quality")
|
||||||
|
.subtitle("1-100, higher is better quality")
|
||||||
|
.adjustment(>k::Adjustment::new(85.0, 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(3.0, 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(80.0, 1.0, 100.0, 1.0, 10.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
advanced_group.add(&jpeg_row);
|
||||||
|
advanced_group.add(&png_row);
|
||||||
|
advanced_group.add(&webp_row);
|
||||||
|
content.append(&advanced_group);
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
107
pixstrip-gtk/src/steps/step_convert.rs
Normal file
107
pixstrip-gtk/src/steps/step_convert.rs
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_convert_page() -> 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();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.title("Enable Format Conversion")
|
||||||
|
.subtitle("Convert images to a different format")
|
||||||
|
.active(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let enable_group = adw::PreferencesGroup::new();
|
||||||
|
enable_group.add(&enable_row);
|
||||||
|
content.append(&enable_group);
|
||||||
|
|
||||||
|
// Format selection
|
||||||
|
let format_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Output Format")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let formats = [
|
||||||
|
("Keep Original", "No conversion - output in same format as input"),
|
||||||
|
("JPEG", "Universal photo format, lossy compression"),
|
||||||
|
("PNG", "Lossless format, good for graphics and screenshots"),
|
||||||
|
("WebP", "Modern web format, excellent compression, wide support"),
|
||||||
|
("AVIF", "Next-gen format, best compression, growing support"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let format_flow = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::Single)
|
||||||
|
.max_children_per_line(5)
|
||||||
|
.min_children_per_line(2)
|
||||||
|
.row_spacing(8)
|
||||||
|
.column_spacing(8)
|
||||||
|
.homogeneous(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for (name, desc) in &formats {
|
||||||
|
let card = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
card.add_css_class("card");
|
||||||
|
card.set_size_request(140, 80);
|
||||||
|
|
||||||
|
let inner = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(4)
|
||||||
|
.margin_top(12)
|
||||||
|
.margin_bottom(12)
|
||||||
|
.margin_start(8)
|
||||||
|
.margin_end(8)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let name_label = gtk::Label::builder()
|
||||||
|
.label(*name)
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label(*desc)
|
||||||
|
.css_classes(["caption", "dim-label"])
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk::Justification::Center)
|
||||||
|
.max_width_chars(18)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
inner.append(&name_label);
|
||||||
|
inner.append(&desc_label);
|
||||||
|
card.append(&inner);
|
||||||
|
format_flow.append(&card);
|
||||||
|
}
|
||||||
|
|
||||||
|
format_group.add(&format_flow);
|
||||||
|
content.append(&format_group);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(600)
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Convert")
|
||||||
|
.tag("step-convert")
|
||||||
|
.child(&clamp)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
142
pixstrip-gtk/src/steps/step_images.rs
Normal file
142
pixstrip-gtk/src/steps/step_images.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_images_page() -> adw::NavigationPage {
|
||||||
|
let stack = gtk::Stack::builder()
|
||||||
|
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Empty state - drop zone
|
||||||
|
let empty_state = build_empty_state();
|
||||||
|
stack.add_named(&empty_state, Some("empty"));
|
||||||
|
|
||||||
|
// Loaded state - thumbnail grid (placeholder for now)
|
||||||
|
let loaded_state = build_loaded_state();
|
||||||
|
stack.add_named(&loaded_state, Some("loaded"));
|
||||||
|
|
||||||
|
stack.set_visible_child_name("empty");
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Add Images")
|
||||||
|
.tag("step-images")
|
||||||
|
.child(&stack)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_empty_state() -> gtk::Box {
|
||||||
|
let container = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.vexpand(true)
|
||||||
|
.hexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let drop_zone = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.vexpand(true)
|
||||||
|
.margin_top(48)
|
||||||
|
.margin_bottom(48)
|
||||||
|
.margin_start(48)
|
||||||
|
.margin_end(48)
|
||||||
|
.build();
|
||||||
|
drop_zone.add_css_class("card");
|
||||||
|
|
||||||
|
let inner = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(12)
|
||||||
|
.margin_top(48)
|
||||||
|
.margin_bottom(48)
|
||||||
|
.margin_start(64)
|
||||||
|
.margin_end(64)
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let icon = gtk::Image::builder()
|
||||||
|
.icon_name("folder-pictures-symbolic")
|
||||||
|
.pixel_size(64)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let title = gtk::Label::builder()
|
||||||
|
.label("Drop images here")
|
||||||
|
.css_classes(["title-2"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let subtitle = gtk::Label::builder()
|
||||||
|
.label("or click Browse to select files")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let browse_button = gtk::Button::builder()
|
||||||
|
.label("Browse Files")
|
||||||
|
.tooltip_text("Add image files (Ctrl+O)")
|
||||||
|
.halign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
browse_button.add_css_class("suggested-action");
|
||||||
|
browse_button.add_css_class("pill");
|
||||||
|
|
||||||
|
inner.append(&icon);
|
||||||
|
inner.append(&title);
|
||||||
|
inner.append(&subtitle);
|
||||||
|
inner.append(&browse_button);
|
||||||
|
drop_zone.append(&inner);
|
||||||
|
|
||||||
|
container.append(&drop_zone);
|
||||||
|
container
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_loaded_state() -> gtk::Box {
|
||||||
|
let container = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Vertical)
|
||||||
|
.spacing(0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Toolbar
|
||||||
|
let toolbar = gtk::Box::builder()
|
||||||
|
.orientation(gtk::Orientation::Horizontal)
|
||||||
|
.spacing(8)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(8)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let count_label = gtk::Label::builder()
|
||||||
|
.label("0 images (0 B)")
|
||||||
|
.hexpand(true)
|
||||||
|
.halign(gtk::Align::Start)
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let add_button = gtk::Button::builder()
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.tooltip_text("Add more images")
|
||||||
|
.build();
|
||||||
|
add_button.add_css_class("flat");
|
||||||
|
|
||||||
|
let select_all_button = gtk::Button::builder()
|
||||||
|
.label("Select All")
|
||||||
|
.tooltip_text("Select all images (Ctrl+A)")
|
||||||
|
.build();
|
||||||
|
select_all_button.add_css_class("flat");
|
||||||
|
|
||||||
|
toolbar.append(&count_label);
|
||||||
|
toolbar.append(&add_button);
|
||||||
|
toolbar.append(&select_all_button);
|
||||||
|
|
||||||
|
let separator = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||||
|
|
||||||
|
// Thumbnail grid placeholder
|
||||||
|
let grid_placeholder = adw::StatusPage::builder()
|
||||||
|
.title("Images will appear here")
|
||||||
|
.icon_name("image-x-generic-symbolic")
|
||||||
|
.vexpand(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
container.append(&toolbar);
|
||||||
|
container.append(&separator);
|
||||||
|
container.append(&grid_placeholder);
|
||||||
|
|
||||||
|
container
|
||||||
|
}
|
||||||
109
pixstrip-gtk/src/steps/step_metadata.rs
Normal file
109
pixstrip-gtk/src/steps/step_metadata.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_metadata_page() -> 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();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.title("Enable Metadata Handling")
|
||||||
|
.subtitle("Control what image metadata to keep or remove")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let enable_group = adw::PreferencesGroup::new();
|
||||||
|
enable_group.add(&enable_row);
|
||||||
|
content.append(&enable_group);
|
||||||
|
|
||||||
|
// Quick presets
|
||||||
|
let presets_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Metadata Mode")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let strip_all_row = adw::ActionRow::builder()
|
||||||
|
.title("Strip All")
|
||||||
|
.subtitle("Remove all metadata - smallest files, maximum privacy")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
strip_all_row.add_prefix(>k::Image::from_icon_name("user-trash-symbolic"));
|
||||||
|
let strip_all_check = gtk::CheckButton::new();
|
||||||
|
strip_all_check.set_active(true);
|
||||||
|
strip_all_row.add_suffix(&strip_all_check);
|
||||||
|
strip_all_row.set_activatable_widget(Some(&strip_all_check));
|
||||||
|
|
||||||
|
let privacy_row = adw::ActionRow::builder()
|
||||||
|
.title("Privacy Mode")
|
||||||
|
.subtitle("Strip GPS and camera serial, keep copyright")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
privacy_row.add_prefix(>k::Image::from_icon_name("security-medium-symbolic"));
|
||||||
|
let privacy_check = gtk::CheckButton::new();
|
||||||
|
privacy_check.set_group(Some(&strip_all_check));
|
||||||
|
privacy_row.add_suffix(&privacy_check);
|
||||||
|
privacy_row.set_activatable_widget(Some(&privacy_check));
|
||||||
|
|
||||||
|
let keep_all_row = adw::ActionRow::builder()
|
||||||
|
.title("Keep All")
|
||||||
|
.subtitle("Preserve all original metadata")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
keep_all_row.add_prefix(>k::Image::from_icon_name("emblem-ok-symbolic"));
|
||||||
|
let keep_all_check = gtk::CheckButton::new();
|
||||||
|
keep_all_check.set_group(Some(&strip_all_check));
|
||||||
|
keep_all_row.add_suffix(&keep_all_check);
|
||||||
|
keep_all_row.set_activatable_widget(Some(&keep_all_check));
|
||||||
|
|
||||||
|
presets_group.add(&strip_all_row);
|
||||||
|
presets_group.add(&privacy_row);
|
||||||
|
presets_group.add(&keep_all_row);
|
||||||
|
content.append(&presets_group);
|
||||||
|
|
||||||
|
// Advanced - per-category controls
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Fine-Grained Control")
|
||||||
|
.description("Choose exactly which metadata categories to strip")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let categories = [
|
||||||
|
("GPS / Location Data", "Coordinates, altitude, location name", true),
|
||||||
|
("Camera Info", "Camera model, serial number, lens info", true),
|
||||||
|
("Software / Editing", "Software used, editing history", true),
|
||||||
|
("Timestamps", "Date taken, date modified", false),
|
||||||
|
("Copyright / Author", "Copyright notice, creator name", false),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (title, subtitle, default_strip) in &categories {
|
||||||
|
let row = adw::SwitchRow::builder()
|
||||||
|
.title(format!("Strip {}", title))
|
||||||
|
.subtitle(*subtitle)
|
||||||
|
.active(*default_strip)
|
||||||
|
.build();
|
||||||
|
advanced_group.add(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
content.append(&advanced_group);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(600)
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Metadata")
|
||||||
|
.tag("step-metadata")
|
||||||
|
.child(&clamp)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
110
pixstrip-gtk/src/steps/step_output.rs
Normal file
110
pixstrip-gtk/src/steps/step_output.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_output_page() -> 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();
|
||||||
|
|
||||||
|
// Operation summary
|
||||||
|
let summary_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Operation Summary")
|
||||||
|
.description("Review your processing settings before starting")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let summary_placeholder = adw::ActionRow::builder()
|
||||||
|
.title("No operations configured")
|
||||||
|
.subtitle("Go back and configure your workflow settings")
|
||||||
|
.build();
|
||||||
|
summary_placeholder.add_prefix(>k::Image::from_icon_name("dialog-information-symbolic"));
|
||||||
|
|
||||||
|
summary_group.add(&summary_placeholder);
|
||||||
|
content.append(&summary_group);
|
||||||
|
|
||||||
|
// Output directory
|
||||||
|
let output_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Output Directory")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let output_row = adw::ActionRow::builder()
|
||||||
|
.title("Output Location")
|
||||||
|
.subtitle("processed/ (subfolder next to originals)")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
output_row.add_prefix(>k::Image::from_icon_name("folder-symbolic"));
|
||||||
|
|
||||||
|
let choose_button = gtk::Button::builder()
|
||||||
|
.icon_name("folder-open-symbolic")
|
||||||
|
.tooltip_text("Choose output folder")
|
||||||
|
.valign(gtk::Align::Center)
|
||||||
|
.build();
|
||||||
|
choose_button.add_css_class("flat");
|
||||||
|
output_row.add_suffix(&choose_button);
|
||||||
|
output_row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
|
||||||
|
let structure_row = adw::SwitchRow::builder()
|
||||||
|
.title("Preserve Directory Structure")
|
||||||
|
.subtitle("Keep subfolder hierarchy in output")
|
||||||
|
.active(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
output_group.add(&output_row);
|
||||||
|
output_group.add(&structure_row);
|
||||||
|
content.append(&output_group);
|
||||||
|
|
||||||
|
// Overwrite behavior
|
||||||
|
let overwrite_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("If Files Already Exist")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let overwrite_row = adw::ComboRow::builder()
|
||||||
|
.title("Overwrite Behavior")
|
||||||
|
.subtitle("What to do when output file already exists")
|
||||||
|
.build();
|
||||||
|
let overwrite_model = gtk::StringList::new(&[
|
||||||
|
"Ask before overwriting",
|
||||||
|
"Auto-rename with suffix",
|
||||||
|
"Always overwrite",
|
||||||
|
"Skip existing files",
|
||||||
|
]);
|
||||||
|
overwrite_row.set_model(Some(&overwrite_model));
|
||||||
|
|
||||||
|
overwrite_group.add(&overwrite_row);
|
||||||
|
content.append(&overwrite_group);
|
||||||
|
|
||||||
|
// Image count
|
||||||
|
let stats_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Batch Info")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let count_row = adw::ActionRow::builder()
|
||||||
|
.title("Images to process")
|
||||||
|
.subtitle("0 images")
|
||||||
|
.build();
|
||||||
|
count_row.add_prefix(>k::Image::from_icon_name("image-x-generic-symbolic"));
|
||||||
|
|
||||||
|
stats_group.add(&count_row);
|
||||||
|
content.append(&stats_group);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(600)
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Output & Process")
|
||||||
|
.tag("step-output")
|
||||||
|
.child(&clamp)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
189
pixstrip-gtk/src/steps/step_resize.rs
Normal file
189
pixstrip-gtk/src/steps/step_resize.rs
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
|
||||||
|
pub fn build_resize_page() -> 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();
|
||||||
|
|
||||||
|
// Enable toggle
|
||||||
|
let enable_row = adw::SwitchRow::builder()
|
||||||
|
.title("Enable Resize")
|
||||||
|
.subtitle("Resize images to new dimensions")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let enable_group = adw::PreferencesGroup::new();
|
||||||
|
enable_group.add(&enable_row);
|
||||||
|
content.append(&enable_group);
|
||||||
|
|
||||||
|
// Resize mode selector
|
||||||
|
let mode_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Resize Mode")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Width/Height mode
|
||||||
|
let width_row = adw::SpinRow::builder()
|
||||||
|
.title("Width")
|
||||||
|
.subtitle("Target width in pixels")
|
||||||
|
.adjustment(>k::Adjustment::new(1200.0, 1.0, 10000.0, 1.0, 100.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let height_row = adw::SpinRow::builder()
|
||||||
|
.title("Height")
|
||||||
|
.subtitle("Target height in pixels (0 = auto from aspect ratio)")
|
||||||
|
.adjustment(>k::Adjustment::new(0.0, 0.0, 10000.0, 1.0, 100.0, 0.0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
mode_group.add(&width_row);
|
||||||
|
mode_group.add(&height_row);
|
||||||
|
content.append(&mode_group);
|
||||||
|
|
||||||
|
// Social media presets
|
||||||
|
let presets_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Quick Dimension Presets")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let fedi_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Fediverse / Open Platforms")
|
||||||
|
.subtitle("Mastodon, Pixelfed, Bluesky, Lemmy")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let fedi_presets = [
|
||||||
|
("Mastodon Post", "1920 x 1080"),
|
||||||
|
("Mastodon Profile", "400 x 400"),
|
||||||
|
("Mastodon Header", "1500 x 500"),
|
||||||
|
("Pixelfed Post", "1080 x 1080"),
|
||||||
|
("Pixelfed Story", "1080 x 1920"),
|
||||||
|
("Bluesky Post", "1200 x 630"),
|
||||||
|
("Bluesky Profile", "400 x 400"),
|
||||||
|
("Lemmy Post", "1200 x 630"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, dims) in &fedi_presets {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(*name)
|
||||||
|
.subtitle(*dims)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
fedi_expander.add_row(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainstream_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Mainstream Platforms")
|
||||||
|
.subtitle("Instagram, YouTube, LinkedIn, Pinterest")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mainstream_presets = [
|
||||||
|
("Instagram Post", "1080 x 1080"),
|
||||||
|
("Instagram Story/Reel", "1080 x 1920"),
|
||||||
|
("Facebook Post", "1200 x 630"),
|
||||||
|
("YouTube Thumbnail", "1280 x 720"),
|
||||||
|
("LinkedIn Post", "1200 x 627"),
|
||||||
|
("Pinterest Pin", "1000 x 1500"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, dims) in &mainstream_presets {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(*name)
|
||||||
|
.subtitle(*dims)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
mainstream_expander.add_row(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Common Sizes")
|
||||||
|
.subtitle("HD, Blog, Thumbnail")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let other_presets = [
|
||||||
|
("Full HD", "1920 x 1080"),
|
||||||
|
("Blog Image", "800 wide"),
|
||||||
|
("Thumbnail", "150 x 150"),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (name, dims) in &other_presets {
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(*name)
|
||||||
|
.subtitle(*dims)
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
||||||
|
other_expander.add_row(&row);
|
||||||
|
}
|
||||||
|
|
||||||
|
presets_group.add(&fedi_expander);
|
||||||
|
presets_group.add(&mainstream_expander);
|
||||||
|
presets_group.add(&other_expander);
|
||||||
|
content.append(&presets_group);
|
||||||
|
|
||||||
|
// Basic adjustments (rotation/flip)
|
||||||
|
let adjust_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Basic Adjustments")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let rotate_row = adw::ComboRow::builder()
|
||||||
|
.title("Rotate")
|
||||||
|
.subtitle("Rotation applied after resize")
|
||||||
|
.build();
|
||||||
|
let rotate_model = gtk::StringList::new(&["None", "90 clockwise", "180", "270 clockwise", "Auto-orient (EXIF)"]);
|
||||||
|
rotate_row.set_model(Some(&rotate_model));
|
||||||
|
|
||||||
|
let flip_row = adw::ComboRow::builder()
|
||||||
|
.title("Flip")
|
||||||
|
.subtitle("Mirror the image")
|
||||||
|
.build();
|
||||||
|
let flip_model = gtk::StringList::new(&["None", "Horizontal", "Vertical"]);
|
||||||
|
flip_row.set_model(Some(&flip_model));
|
||||||
|
|
||||||
|
adjust_group.add(&rotate_row);
|
||||||
|
adjust_group.add(&flip_row);
|
||||||
|
content.append(&adjust_group);
|
||||||
|
|
||||||
|
// Advanced options
|
||||||
|
let advanced_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Advanced Options")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let algo_row = adw::ComboRow::builder()
|
||||||
|
.title("Resize Algorithm")
|
||||||
|
.subtitle("Higher quality is slower")
|
||||||
|
.build();
|
||||||
|
let algo_model = gtk::StringList::new(&["Lanczos3 (Best)", "CatmullRom", "Bilinear", "Nearest"]);
|
||||||
|
algo_row.set_model(Some(&algo_model));
|
||||||
|
|
||||||
|
let upscale_row = adw::SwitchRow::builder()
|
||||||
|
.title("Allow Upscaling")
|
||||||
|
.subtitle("Enlarge images smaller than target size")
|
||||||
|
.active(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
advanced_group.add(&algo_row);
|
||||||
|
advanced_group.add(&upscale_row);
|
||||||
|
content.append(&advanced_group);
|
||||||
|
|
||||||
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::builder()
|
||||||
|
.maximum_size(600)
|
||||||
|
.child(&scrolled)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Resize")
|
||||||
|
.tag("step-resize")
|
||||||
|
.child(&clamp)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
179
pixstrip-gtk/src/steps/step_workflow.rs
Normal file
179
pixstrip-gtk/src/steps/step_workflow.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use pixstrip_core::preset::Preset;
|
||||||
|
|
||||||
|
pub fn build_workflow_page() -> 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();
|
||||||
|
|
||||||
|
for preset in Preset::all_builtins() {
|
||||||
|
let card = build_preset_card(&preset);
|
||||||
|
builtin_flow.append(&card);
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let custom_card = build_custom_card();
|
||||||
|
let custom_flow = gtk::FlowBox::builder()
|
||||||
|
.selection_mode(gtk::SelectionMode::None)
|
||||||
|
.max_children_per_line(4)
|
||||||
|
.min_children_per_line(2)
|
||||||
|
.build();
|
||||||
|
custom_flow.append(&custom_card);
|
||||||
|
custom_group.add(&custom_flow);
|
||||||
|
content.append(&custom_group);
|
||||||
|
|
||||||
|
// User presets section (initially empty)
|
||||||
|
let user_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Your Presets")
|
||||||
|
.description("Import or save your own workflows")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let import_button = gtk::Button::builder()
|
||||||
|
.label("Import Preset")
|
||||||
|
.icon_name("document-open-symbolic")
|
||||||
|
.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();
|
||||||
|
|
||||||
|
adw::NavigationPage::builder()
|
||||||
|
.title("Choose a Workflow")
|
||||||
|
.tag("step-workflow")
|
||||||
|
.child(&clamp)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_custom_card() -> 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("applications-system-symbolic")
|
||||||
|
.pixel_size(32)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let name_label = gtk::Label::builder()
|
||||||
|
.label("Custom...")
|
||||||
|
.css_classes(["heading"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let desc_label = gtk::Label::builder()
|
||||||
|
.label("Pick your own operations")
|
||||||
|
.css_classes(["caption", "dim-label"])
|
||||||
|
.wrap(true)
|
||||||
|
.justify(gtk::Justification::Center)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
inner.append(&icon);
|
||||||
|
inner.append(&name_label);
|
||||||
|
inner.append(&desc_label);
|
||||||
|
card.append(&inner);
|
||||||
|
|
||||||
|
card
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::steps;
|
||||||
|
|
||||||
pub struct WizardState {
|
pub struct WizardState {
|
||||||
pub current_step: usize,
|
pub current_step: usize,
|
||||||
pub total_steps: usize,
|
pub total_steps: usize,
|
||||||
@@ -59,30 +61,13 @@ impl WizardState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_wizard_pages() -> Vec<adw::NavigationPage> {
|
pub fn build_wizard_pages() -> Vec<adw::NavigationPage> {
|
||||||
let steps = [
|
vec![
|
||||||
("step-workflow", "Choose a Workflow", "image-x-generic-symbolic", "Select a preset or build a custom workflow"),
|
steps::step_workflow::build_workflow_page(),
|
||||||
("step-images", "Add Images", "folder-pictures-symbolic", "Drop images here or click Browse"),
|
steps::step_images::build_images_page(),
|
||||||
("step-resize", "Resize", "view-fullscreen-symbolic", "Set output dimensions"),
|
steps::step_resize::build_resize_page(),
|
||||||
("step-convert", "Convert", "document-save-symbolic", "Choose output format"),
|
steps::step_convert::build_convert_page(),
|
||||||
("step-compress", "Compress", "system-file-manager-symbolic", "Set compression quality"),
|
steps::step_compress::build_compress_page(),
|
||||||
("step-metadata", "Metadata", "security-high-symbolic", "Control metadata privacy"),
|
steps::step_metadata::build_metadata_page(),
|
||||||
("step-output", "Output & Process", "emblem-ok-symbolic", "Review and process"),
|
steps::step_output::build_output_page(),
|
||||||
];
|
]
|
||||||
|
|
||||||
steps
|
|
||||||
.iter()
|
|
||||||
.map(|(tag, title, icon, description)| {
|
|
||||||
let status_page = adw::StatusPage::builder()
|
|
||||||
.title(*title)
|
|
||||||
.description(*description)
|
|
||||||
.icon_name(*icon)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
adw::NavigationPage::builder()
|
|
||||||
.title(*title)
|
|
||||||
.tag(*tag)
|
|
||||||
.child(&status_page)
|
|
||||||
.build()
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user