Skip to main content

blockworx/tools/
new_image.rs

1use std::time::Duration;
2
3use std::sync::mpsc::{Receiver, TryRecvError};
4
5use blockworx_doc::block_model::Asset;
6use blockworx_geom::{Pos2, Rect, WorldPx};
7
8use crate::theme::Style;
9use crate::{
10    grid::GRID_SIZE,
11    theme::Role,
12    tools::{
13        names::ToolName,
14        resize_block::ResizeBlock,
15        tool::{Action, ToolTrait},
16    },
17    widget::drawing::Drawing,
18};
19use blockworx_paint::{Canvas, Cursor, Event, Interaction, Waker};
20
21/// Drag-to-create a free-floating [`Image`](blockworx_doc::block_model::Image):
22/// the same gesture as [`NewArea`](crate::tools::NewArea), previewing the
23/// fit box as it is dragged. On release (if larger than one grid cell) a file
24/// dialog filtered to images (`.svg`/`.png`) opens off the UI thread so the
25/// canvas keeps repainting while it is up (a background thread natively, an
26/// async task on the web); the chosen file's contents arrive through a channel
27/// and become the image on the UI thread, fitted to the gesture's box.
28/// Cancelling the dialog (or picking an invalid image) creates nothing.
29///
30/// A *drop* off the tool cluster places the image at its own size around the
31/// drop point instead, so [`Placement::Centered`] reaches this tool from the
32/// drag-out path rather than from a click.
33pub enum NewImage {
34    Idle,
35    Dragging {
36        start: Pos2,
37    },
38    /// Waiting on the off-thread file dialog. `rx` carries the picked image (or
39    /// `None` if cancelled or unreadable); `placement` records how to size the
40    /// image once it arrives.
41    Pending {
42        rx: Receiver<Option<Asset>>,
43        placement: Placement,
44    },
45}
46
47/// How to size an image once it is picked: a drag fixes the box outright,
48/// while a drop defers to the image's intrinsic size around the drop point.
49pub(crate) enum Placement {
50    Box(Rect),
51    Centered(Pos2),
52}
53
54impl ToolTrait for NewImage {
55    fn name(&self) -> ToolName {
56        ToolName::NewImage
57    }
58    fn widget<C: Canvas>(
59        &mut self,
60        data: &mut Drawing,
61        interaction: &Interaction,
62        painter: &mut Style<'_, C>,
63    ) -> Option<Action> {
64        crate::widget::display::widget(data, interaction, painter);
65        painter.set_cursor(Cursor::Crosshair);
66        match self {
67            NewImage::Idle => {
68                if let Some(Event::DragStarted { pos }) = interaction.event {
69                    *self = NewImage::Dragging { start: pos };
70                }
71            }
72            NewImage::Dragging { start } => {
73                if let Some(Event::Dragging { pos, .. }) = interaction.event {
74                    let rect = Rect::from_two_pos(*start, pos);
75                    painter.rect(
76                        rect,
77                        WorldPx::ZERO,
78                        Role::Transparent,
79                        (1.0, Role::ImageDragBox),
80                    );
81                } else if let Some(Event::DragStopped { pos }) = interaction.event {
82                    let candidate = Rect::from_two_pos(*start, pos);
83                    if candidate.width() > GRID_SIZE && candidate.height() > GRID_SIZE {
84                        *self = spawn_dialog(painter, Placement::Box(candidate));
85                    } else {
86                        *self = NewImage::Idle;
87                    }
88                }
89            }
90            NewImage::Pending { rx, placement } => match rx.try_recv() {
91                Ok(picked) => {
92                    let action = picked
93                        .map(|image| import_image(data, painter, placement, &image))
94                        .unwrap_or_default();
95                    *self = NewImage::Idle;
96                    return Some(action);
97                }
98                Err(TryRecvError::Empty) => {
99                    painter.request_repaint_after(Duration::from_millis(100));
100                }
101                Err(TryRecvError::Disconnected) => *self = NewImage::Idle,
102            },
103        }
104        None
105    }
106}
107
108/// Open the image file dialog and return the `Pending` state that polls it.
109fn spawn_dialog<C: Canvas>(painter: &Style<'_, C>, placement: Placement) -> NewImage {
110    NewImage::Pending {
111        rx: spawn_image_dialog(painter.waker()),
112        placement,
113    }
114}
115
116/// Open the image file dialog off the UI thread and return the channel its
117/// result arrives on: the chosen file read into an [`Asset`] (or `None` if
118/// cancelled or unreadable). `waker` keeps the canvas repainting while the
119/// dialog is up and brings a frame once the answer lands. Shared by the image
120/// and icon tools.
121#[cfg(not(target_arch = "wasm32"))]
122pub(crate) fn spawn_image_dialog(waker: Waker) -> Receiver<Option<Asset>> {
123    let (tx, rx) = std::sync::mpsc::channel();
124    let opening = waker.clone();
125    std::thread::spawn(move || {
126        let image = rfd::FileDialog::new()
127            .add_filter("Image", &["svg", "png"])
128            .pick_file()
129            .and_then(|path| read_image(&path));
130        let _ = tx.send(image);
131        waker.wake();
132    });
133    opening.wake();
134    rx
135}
136
137/// Web variant of [`spawn_image_dialog`]: there is no filesystem or thread, so
138/// the async `rfd` dialog runs as a `spawn_local` task and reads the picked
139/// file's bytes directly from its handle (branching SVG/PNG on the file name).
140#[cfg(target_arch = "wasm32")]
141pub(crate) fn spawn_image_dialog(waker: Waker) -> Receiver<Option<Asset>> {
142    let (tx, rx) = std::sync::mpsc::channel();
143    let opening = waker.clone();
144    wasm_bindgen_futures::spawn_local(async move {
145        let image = if let Some(file) = rfd::AsyncFileDialog::new()
146            .add_filter("Image", &["svg", "png"])
147            .pick_file()
148            .await
149        {
150            let is_png = file.file_name().to_ascii_lowercase().ends_with(".png");
151            let bytes = file.read().await;
152            if is_png {
153                Some(Asset::Png(bytes.into()))
154            } else {
155                String::from_utf8(bytes)
156                    .ok()
157                    .map(|text| Asset::Svg(text.into_bytes().into()))
158            }
159        } else {
160            None
161        };
162        let _ = tx.send(image);
163        waker.wake();
164    });
165    opening.wake();
166    rx
167}
168
169/// Create the image from the picked `image` sized per `placement`, leaving it
170/// selected. Probing the image rejects an invalid file before it becomes a
171/// image (and primes the painter's image cache); a `Centered` placement uses
172/// the intrinsic size so the default box matches the image's aspect.
173fn import_image<C: Canvas>(
174    data: &mut Drawing,
175    painter: &mut Style<'_, C>,
176    placement: &Placement,
177    image: &Asset,
178) -> Action {
179    if let Some(intrinsic) = painter.image_intrinsic_size(image) {
180        if !crate::edit::lower::asset_within_limit(image, "the picked image") {
181            return Action::default();
182        }
183        let placed = match placement {
184            Placement::Box(rect) => crate::edit::assets::Placement::Box(*rect),
185            Placement::Centered(center) => crate::edit::assets::Placement::Centered {
186                center: *center,
187                intrinsic,
188            },
189        };
190        let shape = data.add_image(placed, image);
191        return Action::SwitchTool(ResizeBlock::Selected { shape }.into());
192    }
193    tracing::error!("Failed to import image: not a valid image");
194    Action::default()
195}
196
197/// Read `path` into an [`Asset`], branching on its extension: `.png` loads as
198/// raw bytes, anything else is read as SVG text. `None` if the file can't be
199/// read.
200#[cfg(not(target_arch = "wasm32"))]
201fn read_image(path: &std::path::Path) -> Option<Asset> {
202    let is_png = path
203        .extension()
204        .is_some_and(|ext| ext.eq_ignore_ascii_case("png"));
205    if is_png {
206        std::fs::read(path)
207            .ok()
208            .map(|bytes| Asset::Png(bytes.into()))
209    } else {
210        std::fs::read_to_string(path)
211            .ok()
212            .map(|text| Asset::Svg(text.into_bytes().into()))
213    }
214}