mod compose;
mod crop;
mod keys;
mod overlay;
mod pointer;
mod smear;
pub mod view;
use std::borrow::Cow;
use std::path::{Path, PathBuf};
pub use compose::{CanvasChoice, Preset, PrintUnit, pixels_for_print_size, proportional};
pub use crop::{AspectChoice, CropTool};
pub use keys::{Key, KeyOutcome, Modifiers};
pub use pointer::{Button, Cursor};
pub use smear::SmearTool;
use view::View;
use crate::codec::{self, EncodeOptions, Format};
use crate::metadata::{ExifEntry, ExifGroup};
use crate::model::{
Curve, Feather, Filter, ItemId, Orientation, ProjectState, SourceId, Step, TransitionId,
TransitionKind,
};
use crate::panic_free;
use crate::panic_free::Clamped;
use crate::project::Project;
use crate::project_file;
use crate::render::{Renderer, SourceProblem};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Edge {
Left,
Right,
Top,
Bottom,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Selection {
None,
Item(ItemId),
Transition(TransitionId),
FeatherEdge(ItemId, Edge),
}
impl Selection {
pub fn item(self) -> Option<ItemId> {
match self {
Selection::Item(id) | Selection::FeatherEdge(id, _) => Some(id),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ZOrder {
Front,
Forward,
Backward,
Back,
}
impl ZOrder {
pub fn label(self) -> &'static str {
match self {
ZOrder::Front => "Bring to front",
ZOrder::Forward => "Bring forward",
ZOrder::Backward => "Send backward",
ZOrder::Back => "Send to back",
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Command {
Open,
AddImages,
Save,
SaveAs,
Export,
CanvasSize,
SetDpi,
Resize(ItemId),
PrintSize(ItemId),
RotateBy(ItemId),
Feather(ItemId),
TransitionProperties(TransitionId),
ExifViewer(Option<ItemId>),
LocateSource(SourceId),
Undo,
Redo,
ZoomIn,
ZoomOut,
ZoomFit,
Zoom100,
Crop(ItemId),
ApplyCrop,
CancelCrop,
Smear(ItemId),
EndSmear,
RemoveItem(ItemId),
ToggleSnapping,
ZOrder(ItemId, ZOrder),
QuarterTurns(ItemId, i32),
AddSeam([ItemId; 2], Orientation),
AddCrossfade([ItemId; 2]),
SwitchTransition(TransitionId),
RemoveTransition(TransitionId),
}
impl Command {
pub fn needs_ui(&self) -> bool {
matches!(
self,
Command::Open
| Command::AddImages
| Command::Save
| Command::SaveAs
| Command::Export
| Command::CanvasSize
| Command::SetDpi
| Command::Resize(_)
| Command::PrintSize(_)
| Command::RotateBy(_)
| Command::Feather(_)
| Command::TransitionProperties(_)
| Command::ExifViewer(_)
| Command::LocateSource(_)
)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct MenuEntry {
pub label: String,
pub command: Option<Command>,
}
impl MenuEntry {
fn new(label: impl Into<String>, command: Command) -> Self {
Self {
label: label.into(),
command: Some(command),
}
}
fn separator() -> Self {
Self {
label: String::new(),
command: None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ExportSettings {
pub path: PathBuf,
pub format: Format,
pub quality: u8,
pub metadata: Option<ItemId>,
pub groups: Vec<ExifGroup>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum AdjustTarget {
Move(ItemId),
Resize(ItemId),
Rotate(ItemId),
Seam(TransitionId),
Feather(ItemId),
}
pub struct Editor {
project: Project,
path: Option<PathBuf>,
renderer: Renderer,
view: View,
selection: Selection,
crop: Option<CropTool>,
smear: Option<ItemId>,
smear_settings: smear::SmearSettings,
hover: Option<crate::geometry::Point>,
gesture: Option<pointer::Gesture>,
pending: Option<(AdjustTarget, Step)>,
snapping: bool,
frame_cache: Option<overlay::FrameCache>,
background: Option<(overlay::BackgroundKey, image::RgbaImage)>,
message: Option<String>,
last_folder: Option<PathBuf>,
}
impl Default for Editor {
fn default() -> Self {
Self::new()
}
}
impl Editor {
pub fn new() -> Self {
Self::with_project(Project::new(), None)
}
fn with_project(project: Project, path: Option<PathBuf>) -> Self {
let mut editor = Self {
project,
path,
renderer: Renderer::new(),
view: View::default(),
selection: Selection::None,
crop: None,
smear: None,
smear_settings: smear::SmearSettings::default(),
hover: None,
gesture: None,
pending: None,
snapping: true,
frame_cache: None,
background: None,
message: None,
last_folder: None,
};
editor.fit_view();
editor
}
fn replace_project(&mut self, project: Project, path: Option<PathBuf>) {
let viewport = self.view.viewport;
let snapping = self.snapping;
let last_folder = self.last_folder.take();
let smear_settings = self.smear_settings;
let background = self.renderer.background();
*self = Self::with_project(project, path);
self.snapping = snapping;
self.last_folder = last_folder;
self.smear_settings = smear_settings;
self.renderer.set_background(background);
self.set_viewport(viewport.0, viewport.1);
}
pub fn set_background_rendering(&mut self, enabled: bool) {
self.renderer.set_background(enabled);
}
pub fn background_busy(&self) -> bool {
self.renderer.busy()
}
pub fn poll_background(&mut self) -> bool {
let arrived = self.renderer.poll();
if arrived {
self.frame_cache = None;
}
arrived
}
pub fn dialog_folder(&self) -> Option<&Path> {
self.last_folder.as_deref()
}
pub fn remember_folder(&mut self, file: &Path) {
let Ok(file) = std::path::absolute(file) else {
return;
};
if let Some(folder) = file.parent() {
self.last_folder = Some(folder.to_path_buf());
}
}
pub fn project(&self) -> &Project {
&self.project
}
pub fn path(&self) -> Option<&Path> {
self.path.as_deref()
}
pub fn view(&self) -> &View {
&self.view
}
pub fn selection(&self) -> Selection {
self.selection
}
pub fn select(&mut self, selection: Selection) {
if selection != self.selection {
self.commit_pending();
self.selection = selection;
}
}
pub fn crop_tool(&self) -> Option<&CropTool> {
self.crop.as_ref()
}
pub fn snapping(&self) -> bool {
self.snapping
}
pub fn is_empty(&self) -> bool {
self.project.state().items.is_empty()
}
pub fn take_message(&mut self) -> Option<String> {
self.message.take()
}
pub fn is_dirty(&self) -> bool {
self.project.is_dirty() || self.pending.is_some()
}
pub fn title(&self) -> String {
let name = self
.path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "Untitled".to_owned());
let dirty = if self.is_dirty() { "*" } else { "" };
format!("{dirty}{name} — {}", crate::APP_NAME)
}
pub fn display_state(&self) -> Cow<'_, ProjectState> {
let preview = self
.gesture
.as_ref()
.and_then(pointer::Gesture::preview)
.or(self.pending.as_ref().map(|(_, step)| step));
let mut state = match preview {
Some(step) => {
let mut state = self.project.state().clone();
match state.apply(step) {
Ok(()) => Cow::Owned(state),
Err(_) => Cow::Borrowed(self.project.state()),
}
}
None => Cow::Borrowed(self.project.state()),
};
if let Some(tool) = &self.crop
&& let Some(index) = state.items.iter().position(|i| i.id == tool.item)
{
let items = &mut state.to_mut().items;
if let Some(item) = panic_free::remove_at(items, index) {
items.push(crop::with_crop(&item, tool.limits));
}
}
state
}
pub fn apply(&mut self, step: Step) -> bool {
self.record(step)
}
pub fn set_view(&mut self, zoom: f64, pan: crate::geometry::Point) {
self.view.zoom = zoom.clamped(view::MIN_ZOOM, view::MAX_ZOOM);
self.view.pan = pan;
self.view.fit = false;
}
pub fn render_canvas(&mut self) -> image::RgbaImage {
let state = self.display_state().into_owned();
self.renderer.render(&state)
}
fn record(&mut self, step: Step) -> bool {
self.commit_pending();
match self.project.apply(step) {
Ok(()) => {
self.after_change();
true
}
Err(e) => {
self.message = Some(e.to_string());
false
}
}
}
pub fn commit_pending(&mut self) {
if let Some((_, step)) = self.pending.take()
&& let Err(e) = self.project.apply(step)
{
self.message = Some(e.to_string());
}
self.after_change();
}
fn after_change(&mut self) {
let state = self.project.state();
let valid = match self.selection {
Selection::None => true,
Selection::Item(id) | Selection::FeatherEdge(id, _) => state.item(id).is_some(),
Selection::Transition(id) => state.transition(id).is_some(),
};
if !valid {
self.selection = Selection::None;
}
if let Some(crop) = &self.crop
&& state.item(crop.item).is_none()
{
self.crop = None;
}
if self.smear.is_some_and(|item| state.item(item).is_none()) {
self.smear = None;
}
if self.view.fit {
self.fit_view();
}
}
pub fn set_viewport(&mut self, width: f64, height: f64) {
self.view.viewport = (width.max(1.0), height.max(1.0));
if self.view.fit {
self.fit_view();
}
}
fn fit_view(&mut self) {
let canvas = self.project.state().canvas_rect();
self.view.fit_to(canvas);
}
pub fn execute(&mut self, command: Command) -> bool {
self.commit_pending();
match command {
Command::Undo => self.undo(),
Command::Redo => self.redo(),
Command::ZoomIn => {
let c = self.view.viewport_center();
self.view.zoom_by(view::ZOOM_STEP, c);
true
}
Command::ZoomOut => {
let c = self.view.viewport_center();
self.view.zoom_by(1.0 / view::ZOOM_STEP, c);
true
}
Command::ZoomFit => {
self.fit_view();
true
}
Command::Zoom100 => {
let c = self.view.viewport_center();
self.view.set_zoom(1.0, c);
true
}
Command::Crop(item) => {
self.smear = None;
self.start_crop(item)
}
Command::Smear(item) => self.start_smear(item),
Command::EndSmear => {
self.smear = None;
true
}
Command::RemoveItem(item) => self.record(Step::RemoveItem { item }),
Command::ApplyCrop => self.apply_crop(),
Command::CancelCrop => {
self.crop = None;
true
}
Command::ToggleSnapping => {
self.snapping = !self.snapping;
true
}
Command::ZOrder(item, order) => self.z_order(item, order),
Command::QuarterTurns(item, turns) => self.quarter_turns(item, turns),
Command::AddSeam(items, orientation) => self.add_seam(items, orientation),
Command::AddCrossfade(items) => self.add_transition(
items,
TransitionKind::Crossfade {
ratio: DEFAULT_CROSSFADE,
},
),
Command::SwitchTransition(id) => self.switch_transition(id),
Command::RemoveTransition(id) => self.record(Step::RemoveTransition { id }),
other => {
self.message = Some(format!("{other:?} needs a dialog"));
false
}
}
}
pub fn can_undo(&self) -> bool {
self.pending.is_some() || self.project.can_undo()
}
pub fn can_redo(&self) -> bool {
self.pending.is_none() && self.project.can_redo()
}
pub fn undo_label(&self) -> Option<String> {
match &self.pending {
Some((_, step)) => Some(format!("Undo {}", step.label())),
None => self.project.undo_label(),
}
}
pub fn redo_label(&self) -> Option<String> {
if self.pending.is_some() {
return None;
}
self.project.redo_label()
}
pub fn undo(&mut self) -> bool {
self.commit_pending();
self.crop = None;
let done = self.project.undo();
self.after_change();
done
}
pub fn redo(&mut self) -> bool {
self.commit_pending();
self.crop = None;
let done = self.project.redo();
self.after_change();
done
}
fn z_order(&mut self, item: ItemId, order: ZOrder) -> bool {
let state = self.project.state();
let Some(from) = state.item_index(item) else {
return false;
};
let Some(last) = state.items.len().checked_sub(1) else {
return false;
};
let index = match order {
ZOrder::Front => last,
ZOrder::Forward => from.saturating_add(1).min(last),
ZOrder::Backward => from.saturating_sub(1),
ZOrder::Back => 0,
};
if index == from {
return true;
}
self.record(Step::SetZOrder { item, index })
}
fn quarter_turns(&mut self, item: ItemId, turns: i32) -> bool {
let Some(current) = self.project.state().item(item) else {
return false;
};
let degrees = (current.rotation + 90.0 * f64::from(turns)).rem_euclid(360.0);
self.record(Step::RotateItem { item, degrees })
}
pub fn rotate_to(&mut self, item: ItemId, degrees: f64) -> bool {
let degrees = round_tenth(degrees).rem_euclid(360.0);
self.record(Step::RotateItem { item, degrees })
}
fn add_seam(&mut self, items: [ItemId; 2], orientation: Orientation) -> bool {
let Some(overlap) = self.project.state().overlap(items[0], items[1]) else {
self.message = Some("the images do not overlap".to_owned());
return false;
};
let extent = match orientation {
Orientation::Horizontal => overlap.width,
Orientation::Vertical => overlap.height,
};
self.add_transition(
items,
TransitionKind::GradientSeam {
orientation,
offset: 0.0,
width: (extent / 2.0).round(),
curve: Curve::Linear,
},
)
}
fn add_transition(&mut self, items: [ItemId; 2], kind: TransitionKind) -> bool {
if self.project.state().overlap(items[0], items[1]).is_none() {
self.message = Some("the images do not overlap".to_owned());
return false;
}
let id = self.project.state().next_transition_id();
let added = self.record(Step::AddTransition { id, items, kind });
if added {
self.selection = Selection::Transition(id);
}
added
}
fn switch_transition(&mut self, id: TransitionId) -> bool {
let state = self.project.state();
let Some(t) = state.transition(id).copied() else {
return false;
};
let kind = match t.kind {
TransitionKind::GradientSeam { .. } => TransitionKind::Crossfade {
ratio: DEFAULT_CROSSFADE,
},
TransitionKind::Crossfade { .. } => {
let overlap = state.overlap(t.items[0], t.items[1]);
TransitionKind::GradientSeam {
orientation: Orientation::Horizontal,
offset: 0.0,
width: overlap.map(|o| (o.width / 2.0).round()).unwrap_or(10.0),
curve: Curve::Linear,
}
}
};
self.record(Step::EditTransition { id, kind })
}
pub fn edit_transition(&mut self, id: TransitionId, kind: TransitionKind) -> bool {
if self.project.state().transition(id).map(|t| t.kind) == Some(kind) {
return true;
}
self.record(Step::EditTransition { id, kind })
}
pub fn set_feather(&mut self, item: ItemId, feather: Option<Feather>) -> bool {
let feather = feather.filter(Feather::any_edge);
self.record(Step::SetFeather { item, feather })
}
pub fn default_feather(&self, item: ItemId) -> Feather {
let state = self.project.state();
let current = state.item(item);
current.and_then(|i| i.feather).unwrap_or_else(|| Feather {
left: false,
right: false,
top: false,
bottom: false,
width: current
.map(|i| (f64::from(i.width.min(i.height)) * 0.1).round().max(1.0))
.unwrap_or(10.0),
curve: Curve::Linear,
})
}
pub fn resize_item(&mut self, item: ItemId, width: u32, height: u32, filter: Filter) -> bool {
let Some(current) = self.project.state().item(item).copied() else {
return false;
};
self.record(Step::ResizeItem {
item,
x: current.x,
y: current.y,
width,
height,
filter,
})
}
pub fn set_dpi(&mut self, dpi: Option<f64>) -> bool {
if self.project.state().canvas.dpi == dpi {
return true;
}
self.record(Step::SetDpi { dpi })
}
pub fn set_print_size(
&mut self,
item: ItemId,
width: f64,
height: f64,
unit: PrintUnit,
dpi: f64,
filter: Filter,
) -> bool {
let width = pixels_for_print_size(width, unit, dpi);
let height = pixels_for_print_size(height, unit, dpi);
self.record(Step::SetPrintSize {
item,
width,
height,
filter,
dpi,
})
}
pub fn set_canvas_size(&mut self, width: u32, height: u32, dpi: Option<f64>) -> bool {
let current = self.project.state().canvas_rect();
let (offset_x, offset_y) = (-current.x as i32, -current.y as i32);
self.record(Step::ResizeCanvas {
width,
height,
offset_x,
offset_y,
dpi,
})
}
pub fn strip_metadata(&mut self, item: ItemId) -> bool {
self.record(Step::StripMetadata { item })
}
pub fn open(&mut self, path: &Path) -> Result<(), String> {
let path = std::path::absolute(path).map_err(|e| e.to_string())?;
let is_project = path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case(project_file::EXTENSION));
if is_project {
let project = project_file::load(&path).map_err(|e| e.to_string())?;
self.replace_project(project, Some(path.clone()));
self.remember_folder(&path);
Ok(())
} else {
self.open_images(&[path])
}
}
pub fn open_images(&mut self, paths: &[PathBuf]) -> Result<(), String> {
let choice = if paths.len() == 1 {
None
} else {
Some(CanvasChoice::SideBySide)
};
self.new_composition(paths, choice)
}
pub fn needs_save_prompt(&self) -> bool {
self.is_dirty() && !self.is_empty()
}
pub fn save(&mut self) -> Result<(), String> {
let path = self.path.clone().ok_or("the project has no file yet")?;
self.save_as(&path)
}
pub fn save_as(&mut self, path: &Path) -> Result<(), String> {
self.commit_pending();
let mut path = std::path::absolute(path).map_err(|e| e.to_string())?;
if !path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case(project_file::EXTENSION))
{
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(".");
name.push(project_file::EXTENSION);
path.set_file_name(name);
}
project_file::save(&mut self.project, &path).map_err(|e| e.to_string())?;
self.remember_folder(&path);
self.path = Some(path);
Ok(())
}
pub fn item_name(&self, item: ItemId) -> String {
let state = self.project.state();
state
.item(item)
.and_then(|i| state.sources.get(&i.source))
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| item.to_string())
}
pub fn items_with_exif(&mut self) -> Vec<ItemId> {
let state = self.project.state().clone();
state
.items
.iter()
.filter(|item| !item.metadata_stripped)
.filter(|item| {
state
.sources
.get(&item.source)
.is_some_and(|p| match self.renderer.source(p) {
Ok(source) => source.exif.is_some(),
Err(_) => false,
})
})
.map(|item| item.id)
.collect()
}
pub fn exif_entries(&mut self, item: ItemId) -> Vec<ExifEntry> {
self.exif_of(item)
.map(|e| e.entries().to_vec())
.unwrap_or_default()
}
pub fn exif_groups(&mut self, item: ItemId) -> Vec<ExifGroup> {
self.exif_of(item).map(|e| e.groups()).unwrap_or_default()
}
fn exif_of(&mut self, item: ItemId) -> Option<crate::metadata::Exif> {
let state = self.project.state();
let item = state.item(item)?;
if item.metadata_stripped {
return None;
}
let path = state.sources.get(&item.source)?.clone();
match self.renderer.source(&path) {
Ok(source) => source.exif.clone(),
Err(_) => None,
}
}
pub fn exif_notice(&mut self) -> Option<String> {
if let Some(item) = self.selection.item() {
return (!self.exif_entries(item).is_empty())
.then(|| "Contains EXIF metadata".to_owned());
}
match self.items_with_exif().len() {
0 => None,
1 if self.project.state().items.len() == 1 => Some("Contains EXIF metadata".to_owned()),
1 => Some("1 image contains EXIF metadata".to_owned()),
n => Some(format!("{n} images contain EXIF metadata")),
}
}
pub fn exif_viewer_item(&mut self) -> Option<ItemId> {
let with_exif = self.items_with_exif();
self.selection
.item()
.filter(|i| with_exif.contains(i))
.or_else(|| with_exif.first().copied())
}
pub fn missing_sources(&mut self) -> Vec<(SourceId, String, SourceProblem)> {
let state = self.project.state().clone();
self.renderer
.problems(&state)
.into_iter()
.map(|(id, problem)| {
let name = state
.sources
.get(&id)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(id, name, problem)
})
.collect()
}
pub fn relink(&mut self, source: SourceId, path: &Path) -> bool {
let Ok(path) = std::path::absolute(path) else {
return false;
};
self.remember_folder(&path);
self.renderer.forget(&path);
if let Some(old) = self.project.state().sources.get(&source).cloned() {
self.renderer.forget(&old);
}
self.record(Step::RelinkSource { source, path })
}
pub fn export_defaults(&mut self, path: &Path) -> ExportSettings {
let with_exif = self.items_with_exif();
let metadata = match with_exif.as_slice() {
[only] if self.project.state().items.len() == 1 => Some(*only),
_ => None,
};
ExportSettings {
path: path.to_path_buf(),
format: Format::from_path(path).unwrap_or(Format::Png),
quality: codec::DEFAULT_QUALITY,
metadata,
groups: ExifGroup::ALL
.into_iter()
.filter(|g| g.exported_by_default())
.collect(),
}
}
pub fn export_overwrites_source(&self, path: &Path) -> bool {
let Ok(path) = std::path::absolute(path) else {
return false;
};
let path = project_file::normalize(&path);
self.project
.state()
.sources
.values()
.any(|s| project_file::normalize(s) == path)
}
pub fn export(&mut self, settings: &ExportSettings) -> Result<(), String> {
self.commit_pending();
let state = self.project.state().clone();
if state.items.is_empty() {
return Err("there is nothing to export".to_owned());
}
if let Some((_, name, _)) = self.missing_sources().first() {
return Err(format!("the source image {name} is missing"));
}
let pixels = self.renderer.render(&state);
let exif = settings.metadata.and_then(|item| {
self.exif_of(item)?.export(
&settings.groups,
pixels.width(),
pixels.height(),
state.canvas.dpi,
)
});
let options = EncodeOptions {
format: settings.format,
quality: settings.quality,
dpi: state.canvas.dpi,
exif: exif.filter(|_| settings.format.supports_exif()),
};
codec::write(&pixels, &settings.path, &options).map_err(|e| e.to_string())?;
self.remember_folder(&settings.path);
Ok(())
}
pub fn status_text(&self) -> String {
format!("{} · {}", self.canvas_text(), self.zoom_text())
}
pub fn canvas_text(&self) -> String {
let state = self.display_state();
let (w, h) = state.canvas_size();
let mut text = format!("{w} × {h} px");
if let Some(dpi) = state.canvas.dpi {
text.push_str(&format!(" · {} DPI", format_number(dpi)));
}
text
}
pub fn zoom_text(&self) -> String {
format!("zoom {}%", (self.view.zoom * 100.0).round())
}
pub fn zoom_menu() -> Vec<MenuEntry> {
vec![
MenuEntry::new("Zoom in", Command::ZoomIn),
MenuEntry::new("Zoom out", Command::ZoomOut),
MenuEntry::new("Fit", Command::ZoomFit),
MenuEntry::new("100%", Command::Zoom100),
]
}
}
pub const DEFAULT_CROSSFADE: f64 = 0.5;
fn round_tenth(v: f64) -> f64 {
(v * 10.0).round() / 10.0
}
pub fn format_number(v: f64) -> String {
if (v - v.round()).abs() < 1e-9 {
format!("{}", v.round() as i64)
} else {
format!("{v:.1}")
}
}