mod dialogs;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::process::ExitCode;
use dialogs::Modal;
use iced::keyboard::{self, key::Named};
use iced::mouse::{self, ScrollDelta};
use iced::widget::canvas::{self, Canvas, Frame, Geometry, Program};
use iced::widget::{
Space, button, column, container, image, mouse_area, opaque, pick_list, pin, row, stack, text,
text_input, tooltip,
};
use iced::{
Element, Event, Length, Point as IPoint, Rectangle, Renderer, Size, Task, Theme, window,
};
use iced_runtime::core::image::Id as ImageId;
use iced_runtime::image::Allocation;
use stitchpane_core::editor::{
AspectChoice, Button as PointerButton, Command, Cursor, Editor, Key, KeyOutcome, MenuEntry,
Modifiers,
};
use stitchpane_core::geometry::{PixelRect, Point};
use stitchpane_core::model::{Light, StrokeKind, ToneRange};
pub fn run() -> ExitCode {
let paths: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let result = iced::application(move || App::new(paths.clone()), App::update, App::view)
.title(App::title)
.subscription(App::subscription)
.exit_on_close_request(false)
.window_size((1200.0, 800.0))
.run();
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
#[derive(Clone, Debug)]
pub enum Message {
Resized(Size),
PointerDown(IPoint, PointerButton),
PointerMove(IPoint),
PointerUp(IPoint),
Scroll(IPoint, f32, f32),
Key(keyboard::Key, keyboard::Modifiers),
Modifiers(keyboard::Modifiers),
Command(Command),
CloseMenu,
FileDropped(PathBuf),
FlushDrops,
CloseRequested(window::Id),
Modal(dialogs::ModalMessage),
Picked(dialogs::Purpose, Vec<PathBuf>),
PollBackground,
FrameReady(ImageId, Option<Allocation>),
CropField(usize, String),
CropAspect(String),
CropCustom(usize, String),
Exif,
Locate,
OpenMenu(MenuKind),
ZoomMenu,
SmearChoice(String),
SmearField(usize, String),
LightField(usize, String),
DodgeKind(String),
DodgeRange(String),
DodgeField(usize, String),
}
const LIGHT_FIELDS: [&str; 7] = [
"Exposure",
"Shadows %",
"Highlights %",
"Radius",
"Black",
"Midtones",
"White",
];
fn light_fields(l: &Light) -> [String; 7] {
[
format!("{:.1}", l.exposure),
format!("{}", (l.shadows * 100.0).round()),
format!("{}", (l.highlights * 100.0).round()),
format!("{}", l.radius),
format!("{}", l.black),
format!("{:.2}", l.midtones),
format!("{}", l.white),
]
}
fn parse_light(fields: &[String; 7]) -> Option<Light> {
let mut values = [0f64; 7];
for (v, field) in values.iter_mut().zip(fields) {
*v = match field.trim().parse() {
Ok(n) => n,
Err(_) => return None,
};
}
let [
exposure,
shadows,
highlights,
radius,
black,
midtones,
white,
] = values;
Some(Light {
exposure,
shadows: shadows / 100.0,
highlights: highlights / 100.0,
radius,
black,
white,
midtones,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MenuKind {
File,
Edit,
View,
Image,
}
impl MenuKind {
const ALL: [MenuKind; 4] = [
MenuKind::File,
MenuKind::Edit,
MenuKind::View,
MenuKind::Image,
];
fn label(self) -> &'static str {
match self {
MenuKind::File => "File",
MenuKind::Edit => "Edit",
MenuKind::View => "View",
MenuKind::Image => "Image",
}
}
}
const ASPECTS: [&str; 7] = ["Free", "Original", "1:1", "4:3", "3:2", "16:9", "Custom"];
pub struct App {
editor: Editor,
frames: Frames,
canvas_size: Size,
modifiers: keyboard::Modifiers,
pointer: IPoint,
cursor: Cursor,
menu: Option<(IPoint, Vec<MenuEntry>)>,
modal: Option<Modal>,
crop_fields: [String; 4],
smear_fields: [String; 2],
light_fields: [String; 7],
dodge_fields: [String; 2],
custom_aspect: [String; 2],
window: Option<window::Id>,
exif_notice: Option<String>,
missing_notice: Option<String>,
drops: Vec<PathBuf>,
}
fn modifiers(m: keyboard::Modifiers) -> Modifiers {
Modifiers {
shift: m.shift(),
ctrl: m.control(),
alt: m.alt(),
}
}
fn point(p: IPoint) -> Point {
Point::new(f64::from(p.x), f64::from(p.y))
}
fn map_key(key: &keyboard::Key) -> Option<Key> {
Some(match key.as_ref() {
keyboard::Key::Named(Named::ArrowLeft) => Key::Left,
keyboard::Key::Named(Named::ArrowRight) => Key::Right,
keyboard::Key::Named(Named::ArrowUp) => Key::Up,
keyboard::Key::Named(Named::ArrowDown) => Key::Down,
keyboard::Key::Named(Named::Escape) => Key::Escape,
keyboard::Key::Named(Named::Enter) => Key::Enter,
keyboard::Key::Named(Named::Delete) => Key::Delete,
keyboard::Key::Named(Named::Backspace) => Key::Backspace,
keyboard::Key::Character(c) => match c {
"[" | "{" => Key::BracketLeft,
"]" | "}" => Key::BracketRight,
"+" | "=" => Key::Plus,
"-" | "_" => Key::Minus,
other => Key::Char(other.chars().next()?.to_ascii_lowercase()),
},
_ => return None,
})
}
fn interaction(cursor: Cursor) -> mouse::Interaction {
match cursor {
Cursor::Default => mouse::Interaction::Idle,
Cursor::Move => mouse::Interaction::Move,
Cursor::Grab => mouse::Interaction::Grab,
Cursor::ResizeHorizontal => mouse::Interaction::ResizingHorizontally,
Cursor::ResizeVertical => mouse::Interaction::ResizingVertically,
Cursor::ResizeDiagonal => mouse::Interaction::ResizingDiagonallyDown,
Cursor::Rotate | Cursor::Brush => mouse::Interaction::Crosshair,
}
}
impl App {
fn new(paths: Vec<PathBuf>) -> (Self, Task<Message>) {
let mut editor = Editor::new();
editor.set_background_rendering(true);
let mut app = Self {
editor,
frames: Frames::default(),
canvas_size: Size::ZERO,
modifiers: keyboard::Modifiers::default(),
pointer: IPoint::ORIGIN,
cursor: Cursor::Default,
menu: None,
modal: None,
crop_fields: Default::default(),
smear_fields: Default::default(),
light_fields: Default::default(),
dodge_fields: Default::default(),
custom_aspect: ["3".to_owned(), "4".to_owned()],
window: None,
exif_notice: None,
missing_notice: None,
drops: Vec::new(),
};
let result = match paths.as_slice() {
[] => Ok(()),
[path] => app.editor.open(path),
_ => app.editor.open_images(&paths),
};
if let Err(e) = result {
app.modal = Some(Modal::Error(e));
}
let task = app.refresh();
(app, task)
}
fn title(&self) -> String {
self.editor.title()
}
fn refresh(&mut self) -> Task<Message> {
if let Some(message) = self.editor.take_message() {
self.modal = Some(Modal::Error(message));
}
let mut task = Task::none();
if self.canvas_size.width >= 1.0 && self.canvas_size.height >= 1.0 {
let frame = self.editor.frame();
let (w, h) = frame.dimensions();
let frame = CanvasFrame {
handle: image::Handle::from_rgba(w, h, frame.into_raw()),
size: Size::new(w as f32, h as f32),
labels: self.editor.labels(),
};
task = allocate(self.frames.submit(frame));
}
self.exif_notice = self.editor.exif_notice();
let missing = self.editor.missing_sources();
self.missing_notice = match missing.as_slice() {
[] => None,
[(_, name, ..)] => Some(format!("{name} is missing")),
n => Some(format!("{} source images are missing", n.len())),
};
if let Some(crop) = self.editor.crop_tool() {
let r = crop.pixel_rect();
self.crop_fields = [r.left, r.top, r.width, r.height].map(|v| v.to_string());
}
if let Some(tool) = self.editor.smear_tool() {
self.smear_fields = [
tool.size.to_string(),
(tool.strength * 100.0).round().to_string(),
];
}
if let Some(tool) = self.editor.light_tool() {
self.light_fields = light_fields(&tool.light);
}
if let Some(tool) = self.editor.dodge_tool() {
self.dodge_fields = [
tool.size.to_string(),
(tool.strength * 100.0).round().to_string(),
];
}
task
}
fn update(&mut self, message: Message) -> Task<Message> {
let typing = matches!(
message,
Message::CropField(..)
| Message::SmearField(..)
| Message::LightField(..)
| Message::DodgeField(..)
);
if matches!(message, Message::PollBackground) && !self.editor.poll_background() {
return Task::none();
}
if let Message::FrameReady(id, allocation) = message {
return allocate(self.frames.ready(id, allocation));
}
let task = self.handle(message);
let fields = self.crop_fields.clone();
let smear_fields = self.smear_fields.clone();
let typed_light = self.light_fields.clone();
let dodge_fields = self.dodge_fields.clone();
let allocation = self.refresh();
if typing {
self.crop_fields = fields;
self.smear_fields = smear_fields;
self.light_fields = typed_light;
self.dodge_fields = dodge_fields;
}
Task::batch([task, allocation])
}
fn handle(&mut self, message: Message) -> Task<Message> {
let mods = modifiers(self.modifiers);
match message {
Message::Resized(size) => {
self.canvas_size = size;
self.editor
.set_viewport(f64::from(size.width), f64::from(size.height));
}
Message::PointerDown(p, button) => {
self.menu = None;
self.pointer = p;
if let Some(menu) = self.editor.pointer_down(point(p), button, mods) {
self.menu = Some((p, menu));
}
}
Message::PointerMove(p) => {
self.pointer = p;
self.editor.pointer_move(point(p), mods);
self.cursor = self.editor.cursor_at(point(p));
}
Message::PointerUp(p) => self.editor.pointer_up(point(p), mods),
Message::Scroll(p, dx, dy) => {
self.editor
.scroll(point(p), f64::from(dx), f64::from(dy), mods)
}
Message::Modifiers(m) => self.modifiers = m,
Message::Key(key, m) => {
self.modifiers = m;
if self.modal.is_some() {
if key == keyboard::Key::Named(Named::Escape) {
self.modal = None;
}
return Task::none();
}
if let Some(key) = map_key(&key)
&& let KeyOutcome::Command(command) = self.editor.key_press(key, modifiers(m))
{
return self.command(command);
}
}
Message::Command(command) => {
self.menu = None;
return self.command(command);
}
Message::CloseMenu => self.menu = None,
Message::FileDropped(path) => {
self.drops.push(path);
if self.drops.len() == 1 {
return Task::perform(async {}, |()| Message::FlushDrops);
}
}
Message::FlushDrops => {
let paths = std::mem::take(&mut self.drops);
if let Err(e) = self.editor.add_images(&paths, None) {
self.modal = Some(Modal::Error(e));
}
}
Message::CloseRequested(id) => {
self.window = Some(id);
if self.editor.needs_save_prompt() {
self.modal = Some(Modal::ConfirmDiscard(dialogs::AfterDiscard::Quit));
} else {
return window::close(id);
}
}
Message::Modal(message) => return dialogs::update(self, message),
Message::Picked(purpose, paths) => return dialogs::picked(self, purpose, paths),
Message::PollBackground | Message::FrameReady(..) => {}
Message::CropField(index, value) => {
if let Some(field) = self.crop_fields.get_mut(index) {
*field = value;
}
let parsed: Vec<u32> = self
.crop_fields
.iter()
.filter_map(|v| match v.trim().parse() {
Ok(n) => Some(n),
Err(_) => None,
})
.collect();
if let [l, t, w, h] = parsed[..] {
self.editor
.set_crop_rect(PixelRect::new(l, t, w.max(1), h.max(1)));
}
return Task::none();
}
Message::CropAspect(label) => {
let aspect = ASPECTS
.iter()
.position(|a| *a == label)
.and_then(|i| AspectChoice::PRESETS.get(i).copied())
.unwrap_or_else(|| self.custom_aspect());
self.editor.set_crop_aspect(aspect);
}
Message::CropCustom(index, value) => {
if let Some(field) = self.custom_aspect.get_mut(index) {
*field = value;
}
let aspect = self.custom_aspect();
self.editor.set_crop_aspect(aspect);
}
Message::Exif => {
let item = self.editor.exif_viewer_item();
return self.command(Command::ExifViewer(item));
}
Message::OpenMenu(kind) => {
let index = MenuKind::ALL.iter().position(|k| *k == kind).unwrap_or(0);
let x = 6.0 + index as f32 * 58.0;
self.menu = Some((IPoint::new(x, 0.0), self.menu_entries(kind)));
}
Message::ZoomMenu => {
let p = IPoint::new(0.0, self.canvas_size.height);
self.menu = Some((p, Editor::zoom_menu()));
}
Message::SmearChoice(label) => {
if let Some(kind) = StrokeKind::SMEAR.into_iter().find(|k| k.label() == label) {
self.editor.set_smear_kind(kind);
}
}
Message::SmearField(index, value) => {
if let Ok(v) = value.trim().parse::<f64>() {
if index == 0 {
self.editor.set_smear_size(v);
} else {
self.editor.set_smear_strength(v / 100.0);
}
}
if let Some(field) = self.smear_fields.get_mut(index) {
*field = value;
}
}
Message::LightField(index, value) => {
if let Some(field) = self.light_fields.get_mut(index) {
*field = value;
}
if let Some(light) = parse_light(&self.light_fields)
&& let Ok(()) = light.check()
{
self.editor.set_light(light);
}
}
Message::DodgeKind(label) => {
if let Some(kind) = StrokeKind::DODGE_BURN
.into_iter()
.find(|k| k.label() == label)
{
self.editor.set_dodge_kind(kind);
}
}
Message::DodgeRange(label) => {
if let Some(range) = ToneRange::ALL.into_iter().find(|r| r.label() == label) {
self.editor.set_dodge_range(range);
}
}
Message::DodgeField(index, value) => {
if let Ok(v) = value.trim().parse::<f64>() {
if index == 0 {
self.editor.set_dodge_size(v);
} else {
self.editor.set_dodge_strength(v / 100.0);
}
}
if let Some(field) = self.dodge_fields.get_mut(index) {
*field = value;
}
}
Message::Locate => {
if let Some((source, ..)) = self.editor.missing_sources().first() {
return self.command(Command::LocateSource(*source));
}
}
}
Task::none()
}
fn custom_aspect(&self) -> AspectChoice {
let [w, h] = &self.custom_aspect;
AspectChoice::Custom(
w.trim().parse().unwrap_or(1).max(1),
h.trim().parse().unwrap_or(1).max(1),
)
}
fn command(&mut self, command: Command) -> Task<Message> {
if command.needs_ui() {
dialogs::run(self, command)
} else {
self.editor.execute(command);
Task::none()
}
}
fn subscription(&self) -> iced::Subscription<Message> {
let poll = if self.editor.background_busy() {
window::frames().map(|_| Message::PollBackground)
} else {
iced::Subscription::none()
};
let events = iced::event::listen_with(|event, status, id| match event {
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if status == iced::event::Status::Ignored =>
{
Some(Message::Key(key, modifiers))
}
Event::Keyboard(keyboard::Event::ModifiersChanged(m)) => Some(Message::Modifiers(m)),
Event::Window(window::Event::FileDropped(path)) => Some(Message::FileDropped(path)),
Event::Window(window::Event::CloseRequested) => Some(Message::CloseRequested(id)),
_ => None,
});
iced::Subscription::batch([events, poll])
}
fn header(&self) -> Element<'_, Message> {
let tip = |content: Element<'static, Message>, label: String| {
tooltip(
content,
container(text(label))
.padding(6)
.style(container::rounded_box),
tooltip::Position::Bottom,
)
};
let action = |label: &'static str, command: Command| {
button(text(label).size(14)).on_press(Message::Command(command))
};
let undo = button(text("Undo").size(14)).on_press_maybe(
self.editor
.can_undo()
.then_some(Message::Command(Command::Undo)),
);
let redo = button(text("Redo").size(14)).on_press_maybe(
self.editor
.can_redo()
.then_some(Message::Command(Command::Redo)),
);
let undo_label = self
.editor
.undo_label()
.unwrap_or_else(|| "Nothing to undo".to_owned());
let redo_label = self
.editor
.redo_label()
.unwrap_or_else(|| "Nothing to redo".to_owned());
let menus = MenuKind::ALL.map(|kind| {
button(text(kind.label()).size(14))
.style(button::text)
.on_press(Message::OpenMenu(kind))
.into()
});
let mut bar = row(menus)
.spacing(2)
.padding(6)
.align_y(iced::Alignment::Center);
bar = bar
.push(Space::new().width(12))
.push(action("Open…", Command::Open))
.push(action("Add images…", Command::AddImages))
.push(button(text("Save").size(14)).on_press_maybe(
(!self.editor.is_empty()).then_some(Message::Command(Command::Save)),
))
.push(tip(undo.into(), undo_label))
.push(tip(redo.into(), redo_label))
.push(Space::new().width(Length::Fill))
.push(action("−", Command::ZoomOut))
.push(action("Fit", Command::ZoomFit))
.push(action("100%", Command::Zoom100))
.push(action("+", Command::ZoomIn));
bar.spacing(6).into()
}
fn menu_entries(&self, kind: MenuKind) -> Vec<MenuEntry> {
let entry = |label: String, command: Command| MenuEntry {
label,
command: Some(command),
};
match kind {
MenuKind::File => vec![
entry("Open… (Ctrl+O)".into(), Command::Open),
entry("Add images…".into(), Command::AddImages),
entry("Save (Ctrl+S)".into(), Command::Save),
entry("Save as… (Ctrl+Shift+S)".into(), Command::SaveAs),
entry("Export… (Ctrl+E)".into(), Command::Export),
MenuEntry {
label: String::new(),
command: None,
},
entry("Quit (Ctrl+Q)".into(), Command::Quit),
],
MenuKind::Edit => vec![
entry(
format!(
"{} (Ctrl+Z)",
self.editor.undo_label().unwrap_or_else(|| "Undo".into())
),
Command::Undo,
),
entry(
format!(
"{} (Ctrl+Shift+Z)",
self.editor.redo_label().unwrap_or_else(|| "Redo".into())
),
Command::Redo,
),
],
MenuKind::View => vec![
entry("Zoom in (Ctrl++)".into(), Command::ZoomIn),
entry("Zoom out (Ctrl+−)".into(), Command::ZoomOut),
entry("Fit (Ctrl+0)".into(), Command::ZoomFit),
entry("100% (Ctrl+1)".into(), Command::Zoom100),
entry(
if self.editor.snapping() {
"Turn snapping off".into()
} else {
"Turn snapping on".into()
},
Command::ToggleSnapping,
),
],
MenuKind::Image => vec![
entry("Canvas size…".into(), Command::CanvasSize),
entry("Set DPI…".into(), Command::SetDpi),
entry("Show EXIF metadata…".into(), Command::ExifViewer(None)),
],
}
}
fn smear_bar(&self) -> Option<Element<'_, Message>> {
let tool = self.editor.smear_tool()?;
let field = |index: usize, label: &'static str| {
row![
text(label),
text_input("", self.smear_fields.get(index).map_or("", String::as_str))
.on_input(move |v| Message::SmearField(index, v))
.width(70)
]
.spacing(4)
.align_y(iced::Alignment::Center)
};
let kinds: Vec<String> = StrokeKind::SMEAR
.iter()
.map(|k| k.label().to_owned())
.collect();
let bar = row![
text("Smear"),
pick_list(
kinds,
Some(tool.kind.label().to_owned()),
Message::SmearChoice
),
field(0, "Size"),
field(1, "Strength %"),
Space::new().width(Length::Fill),
button(text("Done"))
.style(button::success)
.on_press(Message::Command(Command::EndSmear)),
]
.spacing(10)
.padding(6)
.align_y(iced::Alignment::Center);
Some(bar.into())
}
fn light_bar(&self) -> Option<Element<'_, Message>> {
self.editor.light_tool()?;
let row_of = |title: &'static str, range: std::ops::Range<usize>| {
let mut bar = row![text(title)]
.spacing(10)
.padding(6)
.align_y(iced::Alignment::Center);
for index in range {
let label = LIGHT_FIELDS.get(index).copied().unwrap_or_default();
bar = bar.push(
row![
text(label),
text_input("", self.light_fields.get(index).map_or("", String::as_str))
.on_input(move |v| Message::LightField(index, v))
.width(60)
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
}
bar
};
let first = row_of("Light", 0..4);
let second = row_of("Levels", 4..7)
.push(Space::new().width(Length::Fill))
.push(button(text("Reset")).on_press(Message::Command(Command::ResetLight)))
.push(button(text("Cancel")).on_press(Message::Command(Command::CancelLight)))
.push(
button(text("Apply"))
.style(button::success)
.on_press(Message::Command(Command::ApplyLight)),
);
Some(column![first, second].into())
}
fn dodge_bar(&self) -> Option<Element<'_, Message>> {
let tool = self.editor.dodge_tool()?;
let field = |index: usize, label: &'static str| {
row![
text(label),
text_input("", self.dodge_fields.get(index).map_or("", String::as_str))
.on_input(move |v| Message::DodgeField(index, v))
.width(70)
]
.spacing(4)
.align_y(iced::Alignment::Center)
};
let kinds: Vec<String> = StrokeKind::DODGE_BURN
.iter()
.map(|k| k.label().to_owned())
.collect();
let ranges: Vec<String> = ToneRange::ALL
.iter()
.map(|r| r.label().to_owned())
.collect();
let bar = row![
text("Dodge and burn"),
pick_list(
kinds,
Some(tool.kind.label().to_owned()),
Message::DodgeKind
),
text("Range"),
pick_list(
ranges,
Some(tool.range.label().to_owned()),
Message::DodgeRange
),
field(0, "Size"),
field(1, "Strength %"),
Space::new().width(Length::Fill),
button(text("Done"))
.style(button::success)
.on_press(Message::Command(Command::EndDodgeBurn)),
]
.spacing(10)
.padding(6)
.align_y(iced::Alignment::Center);
Some(bar.into())
}
fn crop_bar(&self) -> Option<Element<'_, Message>> {
let crop = self.editor.crop_tool()?;
let field = |index: usize, label: &'static str| {
row![
text(label),
text_input("", self.crop_fields.get(index).map_or("", String::as_str))
.on_input(move |v| Message::CropField(index, v))
.width(70)
]
.spacing(4)
.align_y(iced::Alignment::Center)
};
let selected = AspectChoice::PRESETS
.iter()
.position(|a| *a == crop.aspect)
.and_then(|i| ASPECTS.get(i).copied())
.unwrap_or("Custom");
let aspect = pick_list(
ASPECTS.map(str::to_owned).to_vec(),
Some(selected.to_owned()),
Message::CropAspect,
);
let mut bar = row![
field(0, "Left"),
field(1, "Top"),
field(2, "Width"),
field(3, "Height"),
text("Aspect"),
aspect
]
.spacing(10)
.padding(6)
.align_y(iced::Alignment::Center);
if selected == "Custom" {
bar = bar.push(
row![
text_input("", &self.custom_aspect[0])
.on_input(|v| Message::CropCustom(0, v))
.width(50),
text(":"),
text_input("", &self.custom_aspect[1])
.on_input(|v| Message::CropCustom(1, v))
.width(50)
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
}
bar = bar
.push(Space::new().width(Length::Fill))
.push(button(text("Cancel")).on_press(Message::Command(Command::CancelCrop)))
.push(
button(text("Apply"))
.style(button::success)
.on_press(Message::Command(Command::ApplyCrop)),
);
Some(bar.into())
}
fn view(&self) -> Element<'_, Message> {
let canvas = Canvas::new(CanvasView { app: self })
.width(Length::Fill)
.height(Length::Fill);
let mut layers = stack![canvas];
if self.editor.is_empty() {
layers = layers.push(
container(text("Open an image or drop image files here").size(24))
.center(Length::Fill),
);
}
if let Some((p, entries)) = &self.menu {
layers = layers.push(
mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
.on_press(Message::CloseMenu)
.on_right_press(Message::CloseMenu),
);
let separators = entries.iter().filter(|e| e.command.is_none()).count() as f32;
let height = (entries.len() as f32 - separators) * 30.0 + separators * 9.0 + 8.0;
let x = p.x.min(self.canvas_size.width - MENU_WIDTH).max(0.0);
let y = p.y.min(self.canvas_size.height - height).max(0.0);
layers = layers.push(pin(context_menu(entries)).x(x).y(y));
}
let mut page = column![self.header()];
if let Some(bar) = self.crop_bar() {
page = page.push(bar);
}
if let Some(bar) = self.smear_bar() {
page = page.push(bar);
}
if let Some(bar) = self.light_bar() {
page = page.push(bar);
}
if let Some(bar) = self.dodge_bar() {
page = page.push(bar);
}
page = page.push(layers);
page = page.push(dialogs::status(self));
let page: Element<'_, Message> = page.into();
match &self.modal {
Some(modal) => stack![
page,
opaque(
container(dialogs::view(self, modal))
.center(Length::Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.0, 0.0, 0.0, 0.4).into()),
..container::Style::default()
})
)
]
.into(),
None => page,
}
}
}
const MENU_WIDTH: f32 = 260.0;
#[derive(Clone, Debug)]
struct CanvasFrame {
handle: image::Handle,
size: Size,
labels: Vec<(Point, String)>,
}
#[derive(Default)]
struct Frames {
shown: Option<(u64, CanvasFrame, Option<Allocation>)>,
uploading: VecDeque<(u64, CanvasFrame)>,
next: Option<CanvasFrame>,
sequence: u64,
}
const MAX_UPLOADS: usize = 3;
impl Frames {
fn submit(&mut self, frame: CanvasFrame) -> Option<image::Handle> {
if self.uploading.len() >= MAX_UPLOADS {
self.next = Some(frame);
return None;
}
let handle = frame.handle.clone();
self.uploading.push_back((self.sequence, frame));
self.sequence = self.sequence.saturating_add(1);
Some(handle)
}
fn ready(&mut self, id: ImageId, allocation: Option<Allocation>) -> Option<image::Handle> {
let index = self
.uploading
.iter()
.position(|(_, frame)| frame.handle.id() == id)?;
let (sequence, frame) = self.uploading.remove(index)?;
if self
.shown
.as_ref()
.is_none_or(|(shown, ..)| sequence > *shown)
{
self.shown = Some((sequence, frame, allocation));
}
let next = self.next.take()?;
self.submit(next)
}
fn shown(&self) -> Option<&CanvasFrame> {
self.shown.as_ref().map(|(_, frame, _)| frame)
}
}
fn allocate(handle: Option<image::Handle>) -> Task<Message> {
let Some(handle) = handle else {
return Task::none();
};
let id = handle.id();
iced_runtime::image::allocate(handle)
.collect()
.map(move |mut results| {
let allocation = match results.pop() {
Some(Ok(allocation)) => Some(allocation),
Some(Err(_)) | None => None,
};
Message::FrameReady(id, allocation)
})
}
fn context_menu(entries: &[MenuEntry]) -> Element<'_, Message> {
let mut list = column![].spacing(0).width(MENU_WIDTH);
for entry in entries {
list = match &entry.command {
None => list.push(iced::widget::rule::horizontal(1)),
Some(command) => list.push(
button(text(entry.label.clone()))
.width(Length::Fill)
.style(button::text)
.on_press(Message::Command(command.clone())),
),
};
}
container(list)
.padding(4)
.style(container::bordered_box)
.into()
}
struct CanvasView<'a> {
app: &'a App,
}
#[derive(Default)]
struct CanvasState {
size: Size,
pressed: bool,
}
impl Program<Message> for CanvasView<'_> {
type State = CanvasState;
fn update(
&self,
state: &mut CanvasState,
event: &Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<canvas::Action<Message>> {
let Event::Mouse(event) = event else {
if state.size != bounds.size() {
state.size = bounds.size();
return Some(canvas::Action::publish(Message::Resized(bounds.size())));
}
return None;
};
let inside = cursor.position_in(bounds);
let relative = cursor
.position()
.map(|p| IPoint::new(p.x - bounds.x, p.y - bounds.y));
let message = match event {
mouse::Event::ButtonPressed(mouse::Button::Left) => {
let p = inside?;
state.pressed = true;
Message::PointerDown(p, PointerButton::Primary)
}
mouse::Event::ButtonPressed(mouse::Button::Right) => {
Message::PointerDown(inside?, PointerButton::Secondary)
}
mouse::Event::CursorMoved { .. } => {
let p = if state.pressed { relative? } else { inside? };
Message::PointerMove(p)
}
mouse::Event::ButtonReleased(mouse::Button::Left) if state.pressed => {
state.pressed = false;
Message::PointerUp(relative?)
}
mouse::Event::WheelScrolled { delta } => {
let (dx, dy) = match *delta {
ScrollDelta::Lines { x, y } => (-x, -y),
ScrollDelta::Pixels { x, y } => (-x / 40.0, -y / 40.0),
};
Message::Scroll(inside?, dx, dy)
}
_ => return None,
};
Some(canvas::Action::publish(message).and_capture())
}
fn draw(
&self,
_state: &CanvasState,
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
let mut frame = Frame::new(renderer, bounds.size());
let Some(shown) = self.app.frames.shown() else {
return vec![frame.into_geometry()];
};
frame.draw_image(
Rectangle::new(IPoint::ORIGIN, shown.size),
canvas::Image::new(shown.handle.clone()),
);
for (p, label) in &shown.labels {
frame.fill_text(canvas::Text {
content: label.clone(),
position: IPoint::new(p.x as f32, p.y as f32),
color: iced::Color::WHITE,
size: 16.into(),
align_x: iced::alignment::Horizontal::Center.into(),
align_y: iced::alignment::Vertical::Center,
..canvas::Text::default()
});
}
vec![frame.into_geometry()]
}
fn mouse_interaction(
&self,
_state: &CanvasState,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
if cursor.is_over(bounds) {
interaction(self.app.cursor)
} else {
mouse::Interaction::default()
}
}
}
#[cfg(test)]
mod tests;