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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user