A GOOD MILESTONE : Refactor title bar and toolbar typography, improve tooltip styles, and enhance selection transform functionality

- Updated typography constants for menu bar and tooltips to improve readability and consistency.
- Adjusted button padding and widths in the title bar for better layout.
- Replaced tooltip implementation with a new balloon tooltip style for improved visual feedback.
- Enhanced selection transform logic to support rotation and resizing, ensuring accurate hit testing and bounds calculations.
- Added tests for selection clearing and transformed bounds to ensure functionality and prevent regressions.
- Introduced a new module for managing selection transform dirty bounds during interactive previews.
This commit is contained in:
2026-07-18 20:31:31 +03:00
parent b2c88e5471
commit a2846d8b3a
16 changed files with 1361 additions and 526 deletions
+310 -204
View File
@@ -12,6 +12,8 @@
//! These sections are optimized for 4K performance. Modifying them
//! may cause rendering regressions or performance degradation.
mod transform_dirty;
use crate::ai_chat::{self, AiChatState, ChatMessage, SYSTEM_PROMPT};
use crate::dialogs;
use crate::dock::manager::DockDragState;
@@ -22,6 +24,7 @@ use crate::panels;
use crate::selection;
use crate::selection::{CropState, SelectionTransform, TransformHandle};
use crate::theme::ThemeState;
use self::transform_dirty::preview_bounds;
use hcie_engine_api::{
BlendMode, BrushStyle, BrushTip, Engine, FilterType, LayerStyle, Tool, ZOOM_MAX, ZOOM_MIN,
};
@@ -382,6 +385,12 @@ pub struct IcedDocument {
pub transform_original: Option<SelectionTransform>,
/// Source pixels and placement removed when a move transform began, for exact Cancel restore.
pub transform_source: Option<SelectionTransform>,
/// Original active-layer pixels for the transform's single history transaction.
pub transform_layer_baseline: Option<Vec<u8>>,
/// Original engine selection mask restored when a transform is cancelled.
pub transform_selection_baseline: Option<Vec<u8>>,
/// Composite snapshot without the floating transform, used as the base for each preview frame.
pub transform_preview_base: Option<Vec<u8>>,
/// Internal clipboard for copy/paste operations
pub internal_clipboard: Option<SelectionTransform>,
/// Selection mask (alpha channel) for the current selection
@@ -1053,91 +1062,173 @@ fn is_vector_shape_tool(tool: Tool) -> bool {
)
}
/// Composite a floating selection transform's pixels back onto the active layer.
/// Clears mask-covered alpha values without creating an engine history entry.
///
/// The transform supports translation, uniform scaling (via `size / pixel-dims`),
/// and rotation (in radians). Each destination pixel is sampled from the
/// source transform buffer by inverting the affine transform. Source pixels
/// with zero alpha are skipped so transparent gaps do not overwrite the layer.
/// **Purpose:** Starts a move transaction while deferring history creation until Apply, avoiding
/// the engine's standalone `Clear Selection` snapshot used by the Delete command.
///
/// This mirrors egui's `apply_transform_to_layer` but adds rotation support
/// (egui only handled scale + translation).
/// **Logic & Workflow:** Visits one mask byte per canvas pixel and clears only the corresponding
/// layer alpha byte when coverage is non-zero, matching `Engine::clear_selection_pixels`.
///
/// **Arguments:** `pixels` is active-layer RGBA data and `mask` is one-byte selection coverage.
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates `pixels`; performs no allocation,
/// engine call, or history operation.
fn clear_masked_pixels_without_history(pixels: &mut [u8], mask: &[u8]) {
for (index, coverage) in mask.iter().copied().enumerate() {
let alpha_index = index * 4 + 3;
if coverage > 0 && alpha_index < pixels.len() {
pixels[alpha_index] = 0;
}
}
}
/// Restores a rectangular region from a stable transform preview baseline.
///
/// **Purpose:** Removes the prior floating preview without restoring the full canvas per pointer
/// event. **Logic & Workflow:** Copies each row in the clipped exclusive rectangle from `source`
/// to `destination`. **Arguments:** Buffers are full-canvas RGBA data, `canvas_width` is their row
/// stride, and `bounds` is `[x0, y0, x1, y1]`. **Returns:** Nothing.
/// **Side Effects / Dependencies:** Mutates only `destination` rows inside `bounds`; no allocation.
fn restore_preview_region(
destination: &mut [u8],
source: &[u8],
canvas_width: u32,
bounds: [u32; 4],
) {
let row_bytes = (bounds[2] - bounds[0]) as usize * 4;
for y in bounds[1]..bounds[3] {
let start = (y as usize * canvas_width as usize + bounds[0] as usize) * 4;
let end = start + row_bytes;
if end <= destination.len() && end <= source.len() {
destination[start..end].copy_from_slice(&source[start..end]);
}
}
}
/// Applies a floating transform and records exactly one caller-labelled layer snapshot.
///
/// **Purpose:** Commits move and paste transforms atomically using the same inverse-affine sampler
/// as preview. **Logic & Workflow:** Reads the current non-preview layer, composites the transform,
/// installs final pixels, and pushes the supplied original baseline only when pixels changed.
///
/// **Arguments:** `engine` is the API boundary, `tr` is floating geometry, canvas dimensions define
/// raster clipping, `before_pixels` is the transaction baseline, and `history_name` labels undo.
/// **Returns:** Nothing. **Side Effects / Dependencies:** Mutates the active layer and may append one
/// engine history snapshot; emits DEBUG timing and transaction-boundary logs.
fn apply_transform_to_layer(
engine: &mut hcie_engine_api::Engine,
tr: &selection::SelectionTransform,
canvas_w: u32,
canvas_h: u32,
before_pixels: Vec<u8>,
history_name: &str,
) {
let started = std::time::Instant::now();
if tr.is_empty() || canvas_w == 0 || canvas_h == 0 {
return;
}
let Some(mut dest) = engine.get_active_layer_pixels() else {
return;
};
let src_w = tr.width as f32;
let src_h = tr.height as f32;
let scale_x = if src_w > 0.0 { tr.size.x / src_w } else { 1.0 };
let scale_y = if src_h > 0.0 { tr.size.y / src_h } else { 1.0 };
let px = tr.pos.x.round() as f32;
let py = tr.pos.y.round() as f32;
// Destination footprint in canvas pixels.
let dst_w = tr.size.x.round().max(1.0) as i32;
let dst_h = tr.size.y.round().max(1.0) as i32;
let cos = tr.rotation.cos();
let sin = tr.rotation.sin();
let has_rotation = tr.rotation.abs() > 1e-4;
// Center of the destination box in canvas space.
let dst_cx = px + dst_w as f32 / 2.0;
let dst_cy = py + dst_h as f32 / 2.0;
for dy in 0..dst_h {
for dx in 0..dst_w {
let dst_x = px + dx as f32;
let dst_y = py + dy as f32;
if dst_x < 0.0 || dst_x >= canvas_w as f32 || dst_y < 0.0 || dst_y >= canvas_h as f32 {
continue;
}
// Map destination pixel back into source texture space.
let (sx, sy) = if has_rotation {
// Vector from dst center, inverse-rotate, then map into src space.
let rel_x = dst_x - dst_cx;
let rel_y = dst_y - dst_cy;
let inv_x = rel_x * cos + rel_y * sin;
let inv_y = -rel_x * sin + rel_y * cos;
let center_src_x = src_w / 2.0;
let center_src_y = src_h / 2.0;
(
(inv_x / scale_x + center_src_x),
(inv_y / scale_y + center_src_y),
)
} else {
let nx = dx as f32 / scale_x.max(1e-6);
let ny = dy as f32 / scale_y.max(1e-6);
(nx, ny)
};
let sxi = sx.round() as i32;
let syi = sy.round() as i32;
if sxi < 0 || sxi >= tr.width as i32 || syi < 0 || syi >= tr.height as i32 {
continue;
}
let src_idx = (syi as u32 * tr.width + sxi as u32) as usize * 4;
if src_idx + 3 >= tr.pixels.len() || tr.pixels[src_idx + 3] == 0 {
continue;
}
let dst_idx = (dst_y as u32 * canvas_w + dst_x as u32) as usize * 4;
if dst_idx + 3 < dest.len() {
dest[dst_idx..dst_idx + 4].copy_from_slice(&tr.pixels[src_idx..src_idx + 4]);
}
}
let active_idx = engine.active_layer_index();
let bounds = selection::transform::composite_transform(&mut dest, canvas_w, canvas_h, tr);
log::debug!(
"[TransformApply] begin history_name={history_name:?}, active_layer={active_idx}, bounds={bounds:?}"
);
if before_pixels != dest {
engine.set_active_layer_pixels(dest);
engine.push_active_layer_snapshot(before_pixels, active_idx, history_name);
}
log::debug!(
"[TransformApply] end history_name={history_name:?}, elapsed_us={}",
started.elapsed().as_micros()
);
}
engine.set_active_layer_pixels(dest);
#[cfg(test)]
mod transform_transaction_tests {
use super::apply_transform_to_layer;
use crate::selection::SelectionTransform;
/// Verifies that move commit creates one correctly named history boundary.
///
/// **Purpose:** Prevents reintroduction of a preliminary `Clear Selection` snapshot before the
/// final move. **Logic & Workflow:** Starts from a transparent layer, applies one floating pixel
/// with an explicit original baseline, and checks history growth and description.
/// **Arguments & Returns:** No arguments or return value; assertions report transaction errors.
/// **Side Effects / Dependencies:** Uses an in-memory engine only and performs no I/O.
#[test]
fn move_commit_pushes_one_named_snapshot() {
let mut engine = hcie_engine_api::Engine::new(4, 4);
let baseline = engine.get_active_layer_pixels().unwrap();
let history_before = engine.history_len();
let transform = SelectionTransform {
pixels: vec![255, 0, 0, 255],
width: 1,
height: 1,
pos: iced::Vector::new(2.0, 1.0),
size: iced::Vector::new(1.0, 1.0),
rotation: 0.0,
};
apply_transform_to_layer(&mut engine, &transform, 4, 4, baseline, "Move Selection");
assert_eq!(engine.history_len(), history_before + 1);
assert_eq!(
engine.history_description(history_before).as_deref(),
Some("Move Selection")
);
}
}
/// Renders the current floating selection and publishes only its old/new union as dirty.
///
/// **Purpose:** Keeps interactive transforms responsive while retaining the protected RefCell
/// dirty-region accumulator and shader partial-upload path.
///
/// **Logic & Workflow:** Restores only the previous transformed AABB from the stable base, invokes
/// the shared inverse-affine compositor for the current AABB, unions both with any pending dirty
/// region, and updates the shader Arc in place when uniquely owned. A full-buffer Arc clone is used
/// only when Iced still owns the prior frame's immutable Arc.
///
/// **Arguments:** `doc` contains transform/composite state and `previous_bounds` identifies stale
/// preview pixels. **Returns:** Nothing. **Side Effects / Dependencies:** Mutates preview buffers,
/// the dirty-region RefCell, and render generation; logs bounds and elapsed time at DEBUG level.
fn render_transform_preview(doc: &mut IcedDocument, previous_bounds: Option<[u32; 4]>) {
let started = std::time::Instant::now();
let canvas_width = doc.engine.canvas_width();
let canvas_height = doc.engine.canvas_height();
if let (Some(base), Some(bounds)) = (doc.transform_preview_base.as_ref(), previous_bounds) {
restore_preview_region(&mut doc.composite_raw, base, canvas_width, bounds);
}
let current_bounds = doc.selection_transform.as_ref().and_then(|transform| {
selection::transform::composite_transform(
&mut doc.composite_raw,
canvas_width,
canvas_height,
transform,
)
});
let changed_bounds = match (previous_bounds, current_bounds) {
(Some(previous), Some(current)) => Some(union_regions(Some(previous), current)),
(Some(bounds), None) | (None, Some(bounds)) => Some(bounds),
(None, None) => None,
};
if let Some(bounds) = changed_bounds {
let pending = doc.dirty_region.replace(None);
let upload_bounds = union_regions(pending, bounds);
doc.dirty_region.replace(Some(upload_bounds));
if let Some(shader_pixels) = Arc::get_mut(&mut doc.composite_pixels) {
restore_preview_region(shader_pixels, &doc.composite_raw, canvas_width, upload_bounds);
} else {
doc.composite_pixels = Arc::new(doc.composite_raw.clone());
}
doc.render_generation = doc.render_generation.wrapping_add(1);
log::debug!(
"[TransformPreview] previous={previous_bounds:?}, current={current_bounds:?}, upload={upload_bounds:?}, elapsed_us={}",
started.elapsed().as_micros()
);
}
}
impl HcieIcedApp {
@@ -1206,6 +1297,9 @@ impl HcieIcedApp {
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
@@ -1242,8 +1336,9 @@ impl HcieIcedApp {
last_cursor_pos: None,
canvas_cursor_pos: None,
active_dialog: ActiveDialog::None,
active_tool_slot: 0,
active_tool_slot: crate::sidebar::slot_for_tool(hcie_engine_api::Tool::Brush).unwrap_or(0),
sub_tools_open: false,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
slot_last_used: vec![0; 20],
dialog_input: String::new(),
dialog_input2: String::new(),
@@ -1331,7 +1426,6 @@ impl HcieIcedApp {
vector_drag_session: None,
bool_shape_a: None,
bool_shape_b: None,
sidebar_expanded: settings.panel_layout.sidebar_expanded,
};
if let Some(panel) = app
@@ -2267,30 +2361,34 @@ impl HcieIcedApp {
h,
);
if !tr.is_empty() {
// Clear pixels within the selection area (not the
// whole layer), then drop the selection mask.
if let Some(mut layer_pixels) =
doc.engine.get_active_layer_pixels()
{
let cw = w as usize;
for y in 0..h as usize {
for x in 0..cw {
let midx = y * cw + x;
if midx < mask_data.len() && mask_data[midx] > 0 {
let pidx = midx * 4;
if pidx + 3 < layer_pixels.len() {
layer_pixels[pidx..pidx + 4].fill(0);
}
}
}
}
doc.engine.set_active_layer_pixels(layer_pixels);
}
let Some(original_layer) = doc.engine.get_active_layer_pixels()
else {
return Task::none();
};
let mut cut_layer = original_layer.clone();
clear_masked_pixels_without_history(&mut cut_layer, &mask_data);
log::debug!(
"[TransformMove] begin atomic transaction bounds={:?}, selected_pixels={}",
preview_bounds(&tr, w, h),
mask_data.iter().filter(|coverage| **coverage > 0).count()
);
doc.engine.set_active_layer_pixels(cut_layer);
doc.engine.selection_clear();
doc.selection_mask = Some(mask_data);
doc.selection_mask = Some(mask_data.clone());
doc.selection_mask_dirty.set(true);
doc.transform_source = Some(tr.clone());
doc.transform_layer_baseline = Some(original_layer);
doc.transform_selection_baseline = Some(mask_data);
doc.selection_transform = Some(tr);
doc.transform_drag_handle = TransformHandle::Move;
doc.transform_drag_start = Some((canvas_x, canvas_y));
// Force a composite refresh so the cleared source
// area is visible immediately.
doc.engine.mark_composite_dirty();
self.refresh_composite_if_needed();
let doc = &mut self.documents[self.active_doc];
doc.transform_preview_base = Some(doc.composite_raw.clone());
render_transform_preview(doc, None);
}
}
}
@@ -2964,10 +3062,6 @@ impl HcieIcedApp {
// - Style exists → clone and toggle enabled flag
let new_style = match existing {
None => {
println!(
"LayerStyleToggle: Style {} not found, creating new",
disc
);
match disc {
"DropShadow" => LayerStyle::DropShadow {
enabled: true,
@@ -3078,19 +3172,11 @@ impl HcieIcedApp {
| LayerStyle::PatternOverlay { enabled, .. }
| LayerStyle::Stroke { enabled, .. } => {
*enabled = !*enabled;
println!(
"LayerStyleToggle: Style {} found, toggling to {}",
disc, *enabled
);
}
}
cloned
}
};
println!(
"LayerStyleToggle: Calling update_layer_style with new_style: {:?}",
new_style
);
self.documents[self.active_doc]
.engine
.update_layer_style(layer_id, new_style);
@@ -4361,7 +4447,7 @@ impl HcieIcedApp {
.filter_map(|i| engine.history_description(i).map(|d| (i, d)))
.collect();
let history_current = engine.history_current();
self.documents.push(IcedDocument {
let doc = IcedDocument {
engine,
composite_pixels: Arc::new(composite_raw.clone()),
composite_raw,
@@ -4384,12 +4470,15 @@ impl HcieIcedApp {
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
selection_bounds: None,
selection_history: Vec::new(),
selection_history_marker: 0,
selection_history: Vec::new(),
selection_history_marker: 0,
crop_state: CropState::default(),
lasso_points: Vec::new(),
polygon_points: Vec::new(),
@@ -4405,8 +4494,17 @@ impl HcieIcedApp {
selected_vector_shape: None,
vector_cycle_index: 0,
vector_edit_handle: hcie_engine_api::VectorEditHandle::None,
});
self.active_doc = self.documents.len() - 1;
};
if self.show_welcome && self.documents.len() == 1 {
// Replace the placeholder empty document created for the
// welcome screen instead of appending a second tab.
self.documents = vec![doc];
self.active_doc = 0;
} else {
self.documents.push(doc);
self.active_doc = self.documents.len() - 1;
}
self.show_welcome = false;
self.active_dialog = ActiveDialog::None;
}
@@ -4673,29 +4771,9 @@ impl HcieIcedApp {
doc.quick_mask = !doc.quick_mask;
}
Message::ClearPixels => {
let mask = self.documents[self.active_doc]
self.documents[self.active_doc]
.engine
.get_selection_mask()
.map(ToOwned::to_owned);
if let (Some(mask), Some(mut pixels)) = (
mask,
self.documents[self.active_doc]
.engine
.get_active_layer_pixels(),
) {
for (index, alpha) in mask.into_iter().enumerate() {
if alpha != 0 && index * 4 + 3 < pixels.len() {
pixels[index * 4..index * 4 + 4].fill(0);
}
}
self.documents[self.active_doc]
.engine
.set_active_layer_pixels(pixels);
} else {
self.documents[self.active_doc]
.engine
.clear_active_layer_pixels();
}
.clear_selection_pixels();
self.documents[self.active_doc].modified = true;
self.refresh_composite_if_needed();
}
@@ -4711,6 +4789,12 @@ impl HcieIcedApp {
}
Message::TransformDragMove(x, y) => {
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let previous_preview_bounds = doc
.selection_transform
.as_ref()
.and_then(|tr| preview_bounds(tr, canvas_w, canvas_h));
if let (Some(start), Some(ref mut tr)) =
(doc.transform_drag_start, &mut doc.selection_transform)
{
@@ -4722,54 +4806,33 @@ impl HcieIcedApp {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::TopLeft => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y + dy);
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y - dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::TopRight => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y - dy);
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::BottomLeft => {
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y + dy);
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::BottomRight => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Top => {
tr.pos = iced::Vector::new(tr.pos.x, tr.pos.y + dy);
tr.size = iced::Vector::new(tr.size.x, tr.size.y - dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Bottom => {
tr.size = iced::Vector::new(tr.size.x, tr.size.y + dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Left => {
tr.pos = iced::Vector::new(tr.pos.x + dx, tr.pos.y);
tr.size = iced::Vector::new(tr.size.x - dx, tr.size.y);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Right => {
tr.size = iced::Vector::new(tr.size.x + dx, tr.size.y);
TransformHandle::TopLeft
| TransformHandle::TopRight
| TransformHandle::BottomLeft
| TransformHandle::BottomRight
| TransformHandle::Top
| TransformHandle::Bottom
| TransformHandle::Left
| TransformHandle::Right => {
tr.resize_from_delta(doc.transform_drag_handle, dx, dy);
doc.transform_drag_start = Some((x, y));
}
TransformHandle::Rotate => {
// Calculate rotation from center
let center_x = tr.pos.x + tr.size.x / 2.0;
let center_y = tr.pos.y + tr.size.y / 2.0;
let angle = (y - center_y).atan2(x - center_x);
tr.rotation = angle;
if let Some(original) = doc.transform_original.as_ref() {
tr.rotation = selection::transform::rotation_from_drag(
original,
start,
(x, y),
);
}
}
TransformHandle::None => {}
}
tr.sanitize_geometry(self.modifiers.shift());
}
let doc = &mut self.documents[self.active_doc];
render_transform_preview(doc, previous_preview_bounds);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
Message::TransformDragEnd => {
let doc = &mut self.documents[self.active_doc];
@@ -4779,39 +4842,70 @@ impl HcieIcedApp {
Message::TransformApply => {
let tr_taken = self.documents[self.active_doc].selection_transform.take();
if let Some(tr) = tr_taken {
let canvas_w = self.documents[self.active_doc].engine.canvas_width();
let canvas_h = self.documents[self.active_doc].engine.canvas_height();
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
let was_move = doc.transform_source.is_some();
let before_pixels = doc
.transform_layer_baseline
.take()
.or_else(|| doc.engine.get_active_layer_pixels());
let Some(before_pixels) = before_pixels else {
return Task::none();
};
apply_transform_to_layer(
&mut self.documents[self.active_doc].engine,
&mut doc.engine,
&tr,
canvas_w,
canvas_h,
before_pixels,
if was_move {
"Move Selection"
} else {
"Transform Apply"
},
);
self.documents[self.active_doc].engine.selection_clear();
self.documents[self.active_doc].selection_bounds = None;
self.documents[self.active_doc].selection_mask = None;
self.documents[self.active_doc].selection_mask_dirty.set(true);
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
self.documents[self.active_doc].transform_drag_start = None;
self.documents[self.active_doc].transform_original = None;
self.documents[self.active_doc].transform_source = None;
self.documents[self.active_doc].modified = true;
doc.engine.selection_clear();
doc.selection_bounds = None;
doc.selection_mask = None;
doc.selection_mask_dirty.set(true);
doc.transform_drag_handle = TransformHandle::None;
doc.transform_drag_start = None;
doc.transform_original = None;
doc.transform_source = None;
doc.transform_selection_baseline = None;
doc.transform_preview_base = None;
doc.modified = true;
self.refresh_composite_if_needed();
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
}
Message::TransformCancel => {
let doc = &mut self.documents[self.active_doc];
if let Some(source) = doc.transform_source.take() {
let width = doc.engine.canvas_width();
let height = doc.engine.canvas_height();
apply_transform_to_layer(&mut doc.engine, &source, width, height);
log::debug!(
"[TransformCancel] begin restore_layer={}, restore_selection={}",
doc.transform_source.is_some() && doc.transform_layer_baseline.is_some(),
doc.transform_selection_baseline.is_some()
);
let original_layer = doc.transform_layer_baseline.take();
if doc.transform_source.is_some() {
if let Some(original_layer) = original_layer {
doc.engine.set_active_layer_pixels(original_layer);
}
}
if let Some(mask) = doc.transform_selection_baseline.take() {
doc.engine.set_selection_mask(mask.clone());
doc.selection_mask = Some(mask);
doc.selection_mask_dirty.set(true);
}
doc.selection_transform = None;
doc.transform_source = None;
doc.transform_original = None;
doc.transform_drag_start = None;
doc.transform_drag_handle = TransformHandle::None;
doc.transform_preview_base = None;
self.refresh_composite_if_needed();
log::debug!("[TransformCancel] end without history snapshot");
}
// ── Vector tools ──────────────────────────────
@@ -6204,30 +6298,31 @@ impl HcieIcedApp {
transform.height,
);
doc.internal_clipboard = Some(transform);
if let Some(mut pixels) = doc.engine.get_active_layer_pixels() {
for (index, alpha) in mask.into_iter().enumerate() {
if alpha != 0 && index * 4 + 3 < pixels.len() {
pixels[index * 4..index * 4 + 4].fill(0);
}
}
doc.engine.set_active_layer_pixels(pixels);
doc.modified = true;
}
// Record an undoable snapshot of the source pixels before
// clearing them, then clear the selected region via the engine.
doc.engine.clear_selection_pixels();
doc.modified = true;
}
}
self.refresh_composite_if_needed();
}
Message::PasteImage => {
let doc = &self.documents[self.active_doc];
// Check internal clipboard first
if let Some(ref transform) = doc.internal_clipboard {
if let Some(transform) = self.documents[self.active_doc].internal_clipboard.clone()
{
// Enter transform mode with the clipboard content
let mut placed = transform.clone();
let mut placed = transform;
placed.pos.x += 10.0;
placed.pos.y += 10.0;
self.documents[self.active_doc].selection_transform = Some(placed);
self.documents[self.active_doc].transform_drag_handle = TransformHandle::None;
let doc = &mut self.documents[self.active_doc];
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform = Some(placed);
doc.transform_drag_handle = TransformHandle::None;
render_transform_preview(doc, None);
return Task::perform(async {}, |_| Message::CompositeRefresh);
}
@@ -6270,10 +6365,17 @@ impl HcieIcedApp {
}
Message::ImagePasted(Ok(Some((pixels, w, h)))) => {
log::info!("Pasted image: {}x{}", w, h);
let canvas_w = self.documents[self.active_doc].engine.canvas_width();
let canvas_h = self.documents[self.active_doc].engine.canvas_height();
self.documents[self.active_doc].selection_transform =
let doc = &mut self.documents[self.active_doc];
let canvas_w = doc.engine.canvas_width();
let canvas_h = doc.engine.canvas_height();
doc.transform_layer_baseline = doc.engine.get_active_layer_pixels();
doc.transform_selection_baseline =
doc.engine.get_selection_mask().map(ToOwned::to_owned);
doc.transform_source = None;
doc.transform_preview_base = Some(doc.composite_raw.clone());
doc.selection_transform =
selection::clipboard::centered_transform(pixels, w, h, canvas_w, canvas_h);
render_transform_preview(doc, None);
}
Message::ImagePasted(Ok(None)) => {
log::info!("No image in clipboard");
@@ -6761,6 +6863,9 @@ impl HcieIcedApp {
transform_drag_start: None,
transform_original: None,
transform_source: None,
transform_layer_baseline: None,
transform_selection_baseline: None,
transform_preview_base: None,
internal_clipboard: None,
selection_mask: None,
selection_mask_dirty: std::cell::Cell::new(false),
@@ -7446,6 +7551,7 @@ impl HcieIcedApp {
);
// Toolbox — 36px strip (single column) or 72px (2 columns) on the left edge
log::info!("[view] sidebar_expanded={}", self.sidebar_expanded);
let toolbox = crate::sidebar::view(
&self.tool_state,
&self.fg_color,