Compare commits
No commits in common. "a45d674ed738d961d7db0545ac136039246a82d6" and "6162adfa9fc2e2f0e7bc827a6aea89a664752742" have entirely different histories.
a45d674ed7
...
6162adfa9f
|
|
@ -82,14 +82,6 @@ pub enum ClipboardContent {
|
|||
/// Notes: (start_time, note, velocity, duration) — times relative to selection start
|
||||
notes: Vec<(f64, u8, u8, f64)>,
|
||||
},
|
||||
/// Raw pixel data from a raster layer selection.
|
||||
/// Pixels are sRGB-encoded premultiplied RGBA, `width × height × 4` bytes —
|
||||
/// the same in-memory format as `RasterKeyframe::raw_pixels`.
|
||||
RasterPixels {
|
||||
pixels: Vec<u8>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClipboardContent {
|
||||
|
|
@ -184,9 +176,6 @@ impl ClipboardContent {
|
|||
// No IDs to regenerate, just clone
|
||||
(ClipboardContent::MidiNotes { notes: notes.clone() }, id_map)
|
||||
}
|
||||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
||||
(ClipboardContent::RasterPixels { pixels: pixels.clone(), width: *width, height: *height }, id_map)
|
||||
}
|
||||
ClipboardContent::Shapes { shapes } => {
|
||||
// Regenerate shape IDs
|
||||
let new_shapes: Vec<Shape> = shapes
|
||||
|
|
@ -268,62 +257,6 @@ impl ClipboardManager {
|
|||
None
|
||||
}
|
||||
|
||||
/// Copy raster pixels to the system clipboard as an image.
|
||||
///
|
||||
/// `pixels` must be sRGB-encoded premultiplied RGBA (`w × h × 4` bytes).
|
||||
/// Converts to straight-alpha RGBA8 for arboard. Silently ignores errors
|
||||
/// (arboard is a temporary integration point and will be replaced).
|
||||
pub fn try_set_raster_image(&mut self, pixels: &[u8], width: u32, height: u32) {
|
||||
let Some(system) = self.system.as_mut() else { return };
|
||||
// Unpremultiply: sRGB-premul → straight RGBA8 for the system clipboard.
|
||||
let straight: Vec<u8> = pixels.chunks_exact(4).flat_map(|p| {
|
||||
let a = p[3];
|
||||
if a == 0 {
|
||||
[0u8, 0, 0, 0]
|
||||
} else {
|
||||
let inv = 255.0 / a as f32;
|
||||
[
|
||||
(p[0] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[1] as f32 * inv).round().min(255.0) as u8,
|
||||
(p[2] as f32 * inv).round().min(255.0) as u8,
|
||||
a,
|
||||
]
|
||||
}
|
||||
}).collect();
|
||||
let img = arboard::ImageData {
|
||||
width: width as usize,
|
||||
height: height as usize,
|
||||
bytes: std::borrow::Cow::Owned(straight),
|
||||
};
|
||||
let _ = system.set_image(img);
|
||||
}
|
||||
|
||||
/// Try to read an image from the system clipboard.
|
||||
///
|
||||
/// Returns sRGB-encoded premultiplied RGBA pixels on success, or `None` if
|
||||
/// no image is available. Silently ignores errors.
|
||||
pub fn try_get_raster_image(&mut self) -> Option<(Vec<u8>, u32, u32)> {
|
||||
let img = self.system.as_mut()?.get_image().ok()?;
|
||||
let width = img.width as u32;
|
||||
let height = img.height as u32;
|
||||
// Premultiply: straight RGBA8 → sRGB-premul.
|
||||
let premul: Vec<u8> = img.bytes.chunks_exact(4).flat_map(|p| {
|
||||
let a = p[3];
|
||||
if a == 0 {
|
||||
[0u8, 0, 0, 0]
|
||||
} else {
|
||||
let scale = a as f32 / 255.0;
|
||||
[
|
||||
(p[0] as f32 * scale).round() as u8,
|
||||
(p[1] as f32 * scale).round() as u8,
|
||||
(p[2] as f32 * scale).round() as u8,
|
||||
a,
|
||||
]
|
||||
}
|
||||
}).collect();
|
||||
Some((premul, width, height))
|
||||
}
|
||||
|
||||
/// Check if there's content available to paste
|
||||
pub fn has_content(&mut self) -> bool {
|
||||
if self.internal.is_some() {
|
||||
|
|
|
|||
|
|
@ -8,82 +8,6 @@ use std::collections::HashSet;
|
|||
use uuid::Uuid;
|
||||
use vello::kurbo::{Affine, BezPath};
|
||||
|
||||
/// Shape of a raster pixel selection, in canvas pixel coordinates.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RasterSelection {
|
||||
/// Axis-aligned rectangle: (x0, y0, x1, y1), x1 >= x0, y1 >= y0.
|
||||
Rect(i32, i32, i32, i32),
|
||||
/// Closed freehand lasso polygon.
|
||||
Lasso(Vec<(i32, i32)>),
|
||||
}
|
||||
|
||||
impl RasterSelection {
|
||||
/// Bounding box as (x0, y0, x1, y1).
|
||||
pub fn bounding_rect(&self) -> (i32, i32, i32, i32) {
|
||||
match self {
|
||||
Self::Rect(x0, y0, x1, y1) => (*x0, *y0, *x1, *y1),
|
||||
Self::Lasso(pts) => {
|
||||
let x0 = pts.iter().map(|p| p.0).min().unwrap_or(0);
|
||||
let y0 = pts.iter().map(|p| p.1).min().unwrap_or(0);
|
||||
let x1 = pts.iter().map(|p| p.0).max().unwrap_or(0);
|
||||
let y1 = pts.iter().map(|p| p.1).max().unwrap_or(0);
|
||||
(x0, y0, x1, y1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if the given canvas pixel is inside the selection.
|
||||
pub fn contains_pixel(&self, px: i32, py: i32) -> bool {
|
||||
match self {
|
||||
Self::Rect(x0, y0, x1, y1) => px >= *x0 && px < *x1 && py >= *y0 && py < *y1,
|
||||
Self::Lasso(pts) => point_in_polygon(px, py, pts),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Even-odd point-in-polygon test for integer coordinates.
|
||||
fn point_in_polygon(px: i32, py: i32, polygon: &[(i32, i32)]) -> bool {
|
||||
let n = polygon.len();
|
||||
if n < 3 { return false; }
|
||||
let mut inside = false;
|
||||
let mut j = n - 1;
|
||||
for i in 0..n {
|
||||
let (xi, yi) = (polygon[i].0 as f64, polygon[i].1 as f64);
|
||||
let (xj, yj) = (polygon[j].0 as f64, polygon[j].1 as f64);
|
||||
let x = px as f64;
|
||||
let y = py as f64;
|
||||
if ((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// A pasted or cut selection that floats above the canvas until committed.
|
||||
///
|
||||
/// While a floating selection is alive `raw_pixels` on the target keyframe is
|
||||
/// left in a "pre-composite" state (hole punched for cut, unchanged for copy).
|
||||
/// The floating pixels are rendered as an overlay. Committing composites them
|
||||
/// into `raw_pixels` and records a `RasterStrokeAction` for undo.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RasterFloatingSelection {
|
||||
/// sRGB-encoded premultiplied RGBA, width × height × 4 bytes.
|
||||
pub pixels: Vec<u8>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Top-left position in canvas pixel coordinates.
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
/// Which raster layer and keyframe this float belongs to.
|
||||
pub layer_id: Uuid,
|
||||
pub time: f64,
|
||||
/// Snapshot of `raw_pixels` before the cut/paste was initiated, used for
|
||||
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
||||
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
||||
pub canvas_before: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Tracks the most recently selected thing(s) across the entire document.
|
||||
///
|
||||
/// Lightweight overlay on top of per-domain selection state. Tells consumers
|
||||
|
|
@ -145,16 +69,6 @@ pub struct Selection {
|
|||
|
||||
/// Currently selected clip instances
|
||||
selected_clip_instances: Vec<Uuid>,
|
||||
|
||||
/// Active raster pixel selection (marquee or lasso outline).
|
||||
/// Transient UI state — not persisted.
|
||||
#[serde(skip)]
|
||||
pub raster_selection: Option<RasterSelection>,
|
||||
|
||||
/// Floating raster selection waiting to be committed or cancelled.
|
||||
/// Transient UI state — not persisted.
|
||||
#[serde(skip)]
|
||||
pub raster_floating: Option<RasterFloatingSelection>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
|
|
@ -165,8 +79,6 @@ impl Selection {
|
|||
selected_edges: HashSet::new(),
|
||||
selected_faces: HashSet::new(),
|
||||
selected_clip_instances: Vec::new(),
|
||||
raster_selection: None,
|
||||
raster_floating: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -390,8 +302,6 @@ impl Selection {
|
|||
self.selected_edges.clear();
|
||||
self.selected_faces.clear();
|
||||
self.selected_clip_instances.clear();
|
||||
self.raster_selection = None;
|
||||
self.raster_floating = None;
|
||||
}
|
||||
|
||||
/// Check if selection is empty
|
||||
|
|
|
|||
|
|
@ -41,8 +41,6 @@ pub enum Tool {
|
|||
Erase,
|
||||
/// Smudge tool - smudge/blend raster pixels
|
||||
Smudge,
|
||||
/// Lasso select tool - freehand selection on raster layers
|
||||
SelectLasso,
|
||||
}
|
||||
|
||||
/// Region select mode
|
||||
|
|
@ -77,17 +75,6 @@ pub enum ToolState {
|
|||
points: Vec<crate::raster_layer::StrokePoint>,
|
||||
},
|
||||
|
||||
/// Drawing a freehand lasso selection on a raster layer
|
||||
DrawingRasterLasso {
|
||||
points: Vec<(i32, i32)>,
|
||||
},
|
||||
|
||||
/// Drawing a rectangular marquee selection on a raster layer
|
||||
DrawingRasterMarquee {
|
||||
start: (i32, i32),
|
||||
current: (i32, i32),
|
||||
},
|
||||
|
||||
/// Dragging selected objects
|
||||
DraggingSelection {
|
||||
start_pos: Point,
|
||||
|
|
@ -237,7 +224,6 @@ impl Tool {
|
|||
Tool::Split => "Split",
|
||||
Tool::Erase => "Erase",
|
||||
Tool::Smudge => "Smudge",
|
||||
Tool::SelectLasso => "Lasso Select",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,7 +245,6 @@ impl Tool {
|
|||
Tool::Split => "split.svg",
|
||||
Tool::Erase => "erase.svg",
|
||||
Tool::Smudge => "smudge.svg",
|
||||
Tool::SelectLasso => "lasso.svg",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,9 +272,29 @@ impl Tool {
|
|||
match layer_type {
|
||||
None | Some(LayerType::Vector) => Tool::all(),
|
||||
Some(LayerType::Audio) | Some(LayerType::Video) => &[Tool::Select, Tool::Split],
|
||||
Some(LayerType::Raster) => &[Tool::Select, Tool::SelectLasso, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper],
|
||||
Some(LayerType::Raster) => &[Tool::Select, Tool::Draw, Tool::Erase, Tool::Smudge, Tool::Eyedropper],
|
||||
_ => &[Tool::Select],
|
||||
}
|
||||
}
|
||||
|
||||
/// Get keyboard shortcut hint
|
||||
pub fn shortcut_hint(self) -> &'static str {
|
||||
match self {
|
||||
Tool::Select => "V",
|
||||
Tool::Draw => "P",
|
||||
Tool::Transform => "Q",
|
||||
Tool::Rectangle => "R",
|
||||
Tool::Ellipse => "E",
|
||||
Tool::PaintBucket => "B",
|
||||
Tool::Eyedropper => "I",
|
||||
Tool::Line => "L",
|
||||
Tool::Polygon => "G",
|
||||
Tool::BezierEdit => "A",
|
||||
Tool::Text => "T",
|
||||
Tool::RegionSelect => "S",
|
||||
Tool::Split => "C",
|
||||
Tool::Erase => "X",
|
||||
Tool::Smudge => "U",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ impl CustomCursor {
|
|||
Tool::Split => CustomCursor::Select, // Reuse select cursor for now
|
||||
Tool::Erase => CustomCursor::Draw, // Reuse draw cursor for raster erase
|
||||
Tool::Smudge => CustomCursor::Draw, // Reuse draw cursor for raster smudge
|
||||
Tool::SelectLasso => CustomCursor::Select, // Reuse select cursor for lasso
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,10 +94,6 @@ pub enum AppAction {
|
|||
ToolBezierEdit,
|
||||
ToolText,
|
||||
ToolRegionSelect,
|
||||
ToolErase,
|
||||
ToolSmudge,
|
||||
ToolSelectLasso,
|
||||
ToolSplit,
|
||||
|
||||
// === Global shortcuts ===
|
||||
TogglePlayPause,
|
||||
|
|
@ -146,8 +142,7 @@ impl AppAction {
|
|||
Self::ToolSelect | Self::ToolDraw | Self::ToolTransform |
|
||||
Self::ToolRectangle | Self::ToolEllipse | Self::ToolPaintBucket |
|
||||
Self::ToolEyedropper | Self::ToolLine | Self::ToolPolygon |
|
||||
Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect |
|
||||
Self::ToolErase | Self::ToolSmudge | Self::ToolSelectLasso | Self::ToolSplit => "Tools",
|
||||
Self::ToolBezierEdit | Self::ToolText | Self::ToolRegionSelect => "Tools",
|
||||
|
||||
Self::TogglePlayPause | Self::CancelAction |
|
||||
Self::ToggleDebugOverlay => "Global",
|
||||
|
|
@ -239,10 +234,6 @@ impl AppAction {
|
|||
Self::ToolBezierEdit => "Bezier Edit Tool",
|
||||
Self::ToolText => "Text Tool",
|
||||
Self::ToolRegionSelect => "Region Select Tool",
|
||||
Self::ToolErase => "Erase Tool",
|
||||
Self::ToolSmudge => "Smudge Tool",
|
||||
Self::ToolSelectLasso => "Lasso Select Tool",
|
||||
Self::ToolSplit => "Split Tool",
|
||||
Self::TogglePlayPause => "Toggle Play/Pause",
|
||||
Self::CancelAction => "Cancel / Escape",
|
||||
Self::ToggleDebugOverlay => "Toggle Debug Overlay",
|
||||
|
|
@ -280,7 +271,6 @@ impl AppAction {
|
|||
Self::ToolRectangle, Self::ToolEllipse, Self::ToolPaintBucket,
|
||||
Self::ToolEyedropper, Self::ToolLine, Self::ToolPolygon,
|
||||
Self::ToolBezierEdit, Self::ToolText, Self::ToolRegionSelect,
|
||||
Self::ToolErase, Self::ToolSmudge, Self::ToolSelectLasso, Self::ToolSplit,
|
||||
Self::TogglePlayPause, Self::CancelAction, Self::ToggleDebugOverlay,
|
||||
#[cfg(debug_assertions)]
|
||||
Self::ToggleTestMode,
|
||||
|
|
@ -425,30 +415,6 @@ impl AppAction {
|
|||
}
|
||||
}
|
||||
|
||||
/// Return the `AppAction` that activates the given tool, if one exists.
|
||||
/// `Tool::Split` has no tool-shortcut action (it's triggered via the menu).
|
||||
pub fn tool_app_action(tool: lightningbeam_core::tool::Tool) -> Option<AppAction> {
|
||||
use lightningbeam_core::tool::Tool;
|
||||
Some(match tool {
|
||||
Tool::Select => AppAction::ToolSelect,
|
||||
Tool::Draw => AppAction::ToolDraw,
|
||||
Tool::Transform => AppAction::ToolTransform,
|
||||
Tool::Rectangle => AppAction::ToolRectangle,
|
||||
Tool::Ellipse => AppAction::ToolEllipse,
|
||||
Tool::PaintBucket => AppAction::ToolPaintBucket,
|
||||
Tool::Eyedropper => AppAction::ToolEyedropper,
|
||||
Tool::Line => AppAction::ToolLine,
|
||||
Tool::Polygon => AppAction::ToolPolygon,
|
||||
Tool::BezierEdit => AppAction::ToolBezierEdit,
|
||||
Tool::Text => AppAction::ToolText,
|
||||
Tool::RegionSelect => AppAction::ToolRegionSelect,
|
||||
Tool::Erase => AppAction::ToolErase,
|
||||
Tool::Smudge => AppAction::ToolSmudge,
|
||||
Tool::SelectLasso => AppAction::ToolSelectLasso,
|
||||
Tool::Split => AppAction::ToolSplit,
|
||||
})
|
||||
}
|
||||
|
||||
// === Default bindings ===
|
||||
|
||||
/// Build the complete default bindings map from the current hardcoded shortcuts
|
||||
|
|
@ -494,10 +460,6 @@ pub fn all_defaults() -> HashMap<AppAction, Option<Shortcut>> {
|
|||
defaults.insert(AppAction::ToolBezierEdit, Some(Shortcut::new(ShortcutKey::A, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolText, Some(Shortcut::new(ShortcutKey::T, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolRegionSelect, Some(Shortcut::new(ShortcutKey::S, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolErase, Some(Shortcut::new(ShortcutKey::X, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolSmudge, Some(Shortcut::new(ShortcutKey::U, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolSelectLasso, Some(Shortcut::new(ShortcutKey::F, nc, ns, na)));
|
||||
defaults.insert(AppAction::ToolSplit, Some(Shortcut::new(ShortcutKey::C, nc, ns, na)));
|
||||
|
||||
// Global shortcuts
|
||||
defaults.insert(AppAction::TogglePlayPause, Some(Shortcut::new(ShortcutKey::Space, nc, ns, na)));
|
||||
|
|
|
|||
|
|
@ -324,7 +324,6 @@ mod tool_icons {
|
|||
pub static SPLIT: &[u8] = include_bytes!("../../../src/assets/split.svg");
|
||||
pub static ERASE: &[u8] = include_bytes!("../../../src/assets/erase.svg");
|
||||
pub static SMUDGE: &[u8] = include_bytes!("../../../src/assets/smudge.svg");
|
||||
pub static LASSO: &[u8] = include_bytes!("../../../src/assets/lasso.svg");
|
||||
}
|
||||
|
||||
/// Embedded focus icon SVGs
|
||||
|
|
@ -396,7 +395,6 @@ impl ToolIconCache {
|
|||
Tool::Split => tool_icons::SPLIT,
|
||||
Tool::Erase => tool_icons::ERASE,
|
||||
Tool::Smudge => tool_icons::SMUDGE,
|
||||
Tool::SelectLasso => tool_icons::LASSO,
|
||||
};
|
||||
if let Some(texture) = rasterize_svg(svg_data, tool.icon_file(), 180, ctx) {
|
||||
self.icons.insert(tool, texture);
|
||||
|
|
@ -773,8 +771,6 @@ struct EditorApp {
|
|||
/// Count of in-flight graph preset loads — keeps the repaint loop alive
|
||||
/// until the audio thread sends GraphPresetLoaded events for all of them
|
||||
pending_graph_loads: std::sync::Arc<std::sync::atomic::AtomicU32>,
|
||||
/// Set by raster select tools when a new interaction requires committing the floating selection
|
||||
commit_raster_floating_if_any: bool,
|
||||
/// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane next frame
|
||||
pending_node_group: bool,
|
||||
/// Set by MenuAction::Ungroup when focus is Nodes — consumed by node graph pane next frame
|
||||
|
|
@ -1050,7 +1046,6 @@ impl EditorApp {
|
|||
audio_event_rx,
|
||||
audio_events_pending: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
pending_graph_loads: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
commit_raster_floating_if_any: false,
|
||||
pending_node_group: false,
|
||||
pending_node_ungroup: false,
|
||||
audio_sample_rate,
|
||||
|
|
@ -1813,175 +1808,11 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Raster pixel helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Extract the pixels covered by `sel` from `raw_pixels`.
|
||||
/// Returns (pixels, width, height) in sRGB-premul RGBA format.
|
||||
/// For a Lasso selection pixels outside the polygon are zeroed (alpha=0).
|
||||
fn extract_raster_selection(
|
||||
raw_pixels: &[u8],
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
sel: &lightningbeam_core::selection::RasterSelection,
|
||||
) -> (Vec<u8>, u32, u32) {
|
||||
use lightningbeam_core::selection::RasterSelection;
|
||||
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||
let x0 = x0.max(0) as u32;
|
||||
let y0 = y0.max(0) as u32;
|
||||
let x1 = (x1 as u32).min(canvas_w);
|
||||
let y1 = (y1 as u32).min(canvas_h);
|
||||
let w = x1.saturating_sub(x0);
|
||||
let h = y1.saturating_sub(y0);
|
||||
let mut out = vec![0u8; (w * h * 4) as usize];
|
||||
for row in 0..h {
|
||||
for col in 0..w {
|
||||
let cx = x0 + col;
|
||||
let cy = y0 + row;
|
||||
let inside = match sel {
|
||||
RasterSelection::Rect(..) => true,
|
||||
RasterSelection::Lasso(_) => sel.contains_pixel(cx as i32, cy as i32),
|
||||
};
|
||||
if inside {
|
||||
let src = ((cy * canvas_w + cx) * 4) as usize;
|
||||
let dst = ((row * w + col) * 4) as usize;
|
||||
out[dst..dst + 4].copy_from_slice(&raw_pixels[src..src + 4]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, w, h)
|
||||
}
|
||||
|
||||
/// Erase pixels covered by `sel` in `raw_pixels` (set alpha=0, rgb=0).
|
||||
fn erase_raster_selection(
|
||||
raw_pixels: &mut [u8],
|
||||
canvas_w: u32,
|
||||
canvas_h: u32,
|
||||
sel: &lightningbeam_core::selection::RasterSelection,
|
||||
) {
|
||||
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||
let x0 = x0.max(0) as u32;
|
||||
let y0 = y0.max(0) as u32;
|
||||
let x1 = (x1 as u32).min(canvas_w);
|
||||
let y1 = (y1 as u32).min(canvas_h);
|
||||
for cy in y0..y1 {
|
||||
for cx in x0..x1 {
|
||||
if sel.contains_pixel(cx as i32, cy as i32) {
|
||||
let idx = ((cy * canvas_w + cx) * 4) as usize;
|
||||
raw_pixels[idx..idx + 4].fill(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Porter-Duff "over" composite of `src` onto `dst` at canvas offset `(ox, oy)`.
|
||||
/// Both buffers are sRGB-encoded premultiplied RGBA.
|
||||
fn composite_over(
|
||||
dst: &mut [u8], dst_w: u32, dst_h: u32,
|
||||
src: &[u8], src_w: u32, src_h: u32,
|
||||
ox: i32, oy: i32,
|
||||
) {
|
||||
for row in 0..src_h {
|
||||
let dy = oy + row as i32;
|
||||
if dy < 0 || dy >= dst_h as i32 { continue; }
|
||||
for col in 0..src_w {
|
||||
let dx = ox + col as i32;
|
||||
if dx < 0 || dx >= dst_w as i32 { continue; }
|
||||
let si = ((row * src_w + col) * 4) as usize;
|
||||
let di = ((dy as u32 * dst_w + dx as u32) * 4) as usize;
|
||||
let sa = src[si + 3] as u32;
|
||||
if sa == 0 { continue; }
|
||||
let da = dst[di + 3] as u32;
|
||||
// out_a = src_a + dst_a * (255 - src_a) / 255
|
||||
let out_a = sa + da * (255 - sa) / 255;
|
||||
dst[di + 3] = out_a as u8;
|
||||
if out_a > 0 {
|
||||
for c in 0..3 {
|
||||
// premul over: out = src + dst*(1-src_a/255)
|
||||
// v is in [0, 255²], so one /255 brings it back to [0, 255]
|
||||
let v = src[si + c] as u32 * 255
|
||||
+ dst[di + c] as u32 * (255 - sa);
|
||||
dst[di + c] = (v / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit a floating raster selection: composite it into the keyframe's
|
||||
/// `raw_pixels` and record a `RasterStrokeAction` for undo.
|
||||
/// Clears `selection.raster_floating` and `selection.raster_selection`.
|
||||
/// No-op if there is no floating selection.
|
||||
fn commit_raster_floating(&mut self) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::actions::RasterStrokeAction;
|
||||
|
||||
let Some(float) = self.selection.raster_floating.take() else { return };
|
||||
self.selection.raster_selection = None;
|
||||
|
||||
let document = self.action_executor.document_mut();
|
||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||
|
||||
Self::composite_over(
|
||||
&mut kf.raw_pixels, kf.width, kf.height,
|
||||
&float.pixels, float.width, float.height,
|
||||
float.x, float.y,
|
||||
);
|
||||
let canvas_after = kf.raw_pixels.clone();
|
||||
let w = kf.width;
|
||||
let h = kf.height;
|
||||
|
||||
let action = RasterStrokeAction::new(
|
||||
float.layer_id, float.time,
|
||||
float.canvas_before, canvas_after,
|
||||
w, h,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("commit_raster_floating: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel a floating raster selection: restore the canvas from the
|
||||
/// pre-cut/paste snapshot. No undo entry is created.
|
||||
fn cancel_raster_floating(&mut self) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
let Some(float) = self.selection.raster_floating.take() else { return };
|
||||
self.selection.raster_selection = None;
|
||||
|
||||
let document = self.action_executor.document_mut();
|
||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||
kf.raw_pixels = float.canvas_before;
|
||||
}
|
||||
|
||||
/// Copy the current selection to the clipboard
|
||||
fn clipboard_copy_selection(&mut self) {
|
||||
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
// Raster selection takes priority when on a raster layer
|
||||
if let (Some(layer_id), Some(raster_sel)) = (
|
||||
self.active_layer_id,
|
||||
self.selection.raster_selection.as_ref(),
|
||||
) {
|
||||
let document = self.action_executor.document();
|
||||
if let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) {
|
||||
if let Some(kf) = rl.keyframe_at(self.playback_time) {
|
||||
let (pixels, w, h) = Self::extract_raster_selection(
|
||||
&kf.raw_pixels, kf.width, kf.height, raster_sel,
|
||||
);
|
||||
self.clipboard_manager.try_set_raster_image(&pixels, w, h);
|
||||
self.clipboard_manager.copy(ClipboardContent::RasterPixels {
|
||||
pixels, width: w, height: h,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check what's selected: clip instances take priority, then shapes
|
||||
if !self.selection.clip_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
|
|
@ -2054,43 +1885,6 @@ impl EditorApp {
|
|||
|
||||
/// Delete the current selection (for cut and delete operations)
|
||||
fn clipboard_delete_selection(&mut self) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::actions::RasterStrokeAction;
|
||||
|
||||
// Raster: commit any floating selection first, then erase the marquee region
|
||||
if let (Some(layer_id), Some(raster_sel)) = (
|
||||
self.active_layer_id,
|
||||
self.selection.raster_selection.clone(),
|
||||
) {
|
||||
let document = self.action_executor.document();
|
||||
if matches!(document.get_layer(&layer_id), Some(AnyLayer::Raster(_))) {
|
||||
// Committing a floating selection before erasing ensures any
|
||||
// prior paste is baked in before we punch the new hole.
|
||||
self.commit_raster_floating();
|
||||
|
||||
let document = self.action_executor.document_mut();
|
||||
if let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&layer_id) {
|
||||
if let Some(kf) = rl.keyframe_at_mut(self.playback_time) {
|
||||
let canvas_before = kf.raw_pixels.clone();
|
||||
Self::erase_raster_selection(
|
||||
&mut kf.raw_pixels, kf.width, kf.height, &raster_sel,
|
||||
);
|
||||
let canvas_after = kf.raw_pixels.clone();
|
||||
let w = kf.width;
|
||||
let h = kf.height;
|
||||
let action = RasterStrokeAction::new(
|
||||
layer_id, self.playback_time,
|
||||
canvas_before, canvas_after, w, h,
|
||||
);
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Raster erase failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selection.raster_selection = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if !self.selection.clip_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
|
|
@ -2180,26 +1974,12 @@ impl EditorApp {
|
|||
use lightningbeam_core::clipboard::ClipboardContent;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
// Resolve content from all sources:
|
||||
// 1. Internal cache (ClipboardContent, any type)
|
||||
// 2. System clipboard JSON (LIGHTNINGBEAM_CLIPBOARD: prefix)
|
||||
// 3. System clipboard image — only attempted when the active layer is raster,
|
||||
// since non-raster layers have no way to consume raw pixel data
|
||||
let active_is_raster = self.active_layer_id
|
||||
.and_then(|id| self.action_executor.document().get_layer(&id))
|
||||
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)));
|
||||
let content = match self.clipboard_manager.paste() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let content = self.clipboard_manager.paste().or_else(|| {
|
||||
if active_is_raster {
|
||||
self.clipboard_manager.try_get_raster_image()
|
||||
.map(|(pixels, width, height)| ClipboardContent::RasterPixels { pixels, width, height })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let Some(content) = content else { return };
|
||||
|
||||
// Regenerate IDs for the paste (no-op for RasterPixels)
|
||||
// Regenerate IDs for the paste
|
||||
let (new_content, _id_map) = content.with_regenerated_ids();
|
||||
|
||||
match new_content {
|
||||
|
|
@ -2321,48 +2101,6 @@ impl EditorApp {
|
|||
ClipboardContent::MidiNotes { .. } => {
|
||||
// MIDI notes are pasted directly in the piano roll pane, not here
|
||||
}
|
||||
ClipboardContent::RasterPixels { pixels, width, height } => {
|
||||
let Some(layer_id) = self.active_layer_id else { return };
|
||||
let document = self.action_executor.document();
|
||||
let Some(AnyLayer::Raster(rl)) = document.get_layer(&layer_id) else { return };
|
||||
let Some(kf) = rl.keyframe_at(self.playback_time) else { return };
|
||||
|
||||
// Paste position: top-left of the current raster selection if any,
|
||||
// otherwise the canvas origin.
|
||||
let (paste_x, paste_y) = self.selection.raster_selection
|
||||
.as_ref()
|
||||
.map(|s| { let (x0, y0, _, _) = s.bounding_rect(); (x0, y0) })
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
// Snapshot canvas before for undo on commit / restore on cancel.
|
||||
let canvas_before = kf.raw_pixels.clone();
|
||||
let canvas_w = kf.width;
|
||||
let canvas_h = kf.height;
|
||||
drop(kf); // release immutable borrow before taking mutable
|
||||
|
||||
// Commit any pre-existing floating selection before creating a new one.
|
||||
self.commit_raster_floating();
|
||||
|
||||
use lightningbeam_core::selection::{RasterFloatingSelection, RasterSelection};
|
||||
self.selection.raster_floating = Some(RasterFloatingSelection {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
x: paste_x,
|
||||
y: paste_y,
|
||||
layer_id,
|
||||
time: self.playback_time,
|
||||
canvas_before,
|
||||
});
|
||||
// Update the marquee to show the floating selection bounds.
|
||||
self.selection.raster_selection = Some(RasterSelection::Rect(
|
||||
paste_x,
|
||||
paste_y,
|
||||
paste_x + width as i32,
|
||||
paste_y + height as i32,
|
||||
));
|
||||
let _ = (canvas_w, canvas_h); // used only to satisfy borrow checker above
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5466,7 +5204,6 @@ impl eframe::App for EditorApp {
|
|||
pending_graph_loads: &self.pending_graph_loads,
|
||||
clipboard_consumed: &mut clipboard_consumed,
|
||||
keymap: &self.keymap,
|
||||
commit_raster_floating_if_any: &mut self.commit_raster_floating_if_any,
|
||||
pending_node_group: &mut self.pending_node_group,
|
||||
pending_node_ungroup: &mut self.pending_node_ungroup,
|
||||
#[cfg(debug_assertions)]
|
||||
|
|
@ -5749,11 +5486,6 @@ impl eframe::App for EditorApp {
|
|||
// Reset playback time to 0 when entering a clip
|
||||
self.playback_time = 0.0;
|
||||
}
|
||||
if self.commit_raster_floating_if_any {
|
||||
self.commit_raster_floating_if_any = false;
|
||||
self.commit_raster_floating();
|
||||
}
|
||||
|
||||
if pending_exit_clip {
|
||||
if let Some(entry) = self.editing_context.pop() {
|
||||
self.selection.clear();
|
||||
|
|
@ -5863,10 +5595,6 @@ impl eframe::App for EditorApp {
|
|||
(AppAction::ToolBezierEdit, Tool::BezierEdit),
|
||||
(AppAction::ToolText, Tool::Text),
|
||||
(AppAction::ToolRegionSelect, Tool::RegionSelect),
|
||||
(AppAction::ToolErase, Tool::Erase),
|
||||
(AppAction::ToolSmudge, Tool::Smudge),
|
||||
(AppAction::ToolSelectLasso, Tool::SelectLasso),
|
||||
(AppAction::ToolSplit, Tool::Split),
|
||||
];
|
||||
for &(action, tool) in tool_map {
|
||||
if self.keymap.action_pressed(action, i) {
|
||||
|
|
@ -5894,13 +5622,9 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
}
|
||||
|
||||
// Escape key: cancel floating raster selection or revert uncommitted region selection
|
||||
// Escape key: revert uncommitted region selection
|
||||
if !wants_keyboard && ctx.input(|i| self.keymap.action_pressed(keymap::AppAction::CancelAction, i)) {
|
||||
if self.selection.raster_floating.is_some() {
|
||||
self.cancel_raster_floating();
|
||||
} else if self.selection.raster_selection.is_some() {
|
||||
self.selection.raster_selection = None;
|
||||
} else if self.region_selection.is_some() {
|
||||
if self.region_selection.is_some() {
|
||||
Self::revert_region_selection(
|
||||
&mut self.region_selection,
|
||||
&mut self.action_executor,
|
||||
|
|
|
|||
|
|
@ -44,62 +44,6 @@ pub enum ShortcutKey {
|
|||
}
|
||||
|
||||
impl ShortcutKey {
|
||||
/// Convert to the corresponding `egui::Key`.
|
||||
///
|
||||
/// Note: we maintain our own `ShortcutKey` enum rather than using `egui::Key` directly
|
||||
/// because `egui::Key` only implements `serde::{Serialize, Deserialize}` behind the
|
||||
/// `serde` cargo feature, which we do not enable for egui. Enabling it would couple
|
||||
/// our persisted config format to egui's internal variant names, which could change
|
||||
/// between egui version upgrades and silently break user keybind files. `ShortcutKey`
|
||||
/// gives us a stable, self-owned serialization surface. The tradeoff is this one
|
||||
/// exhaustive mapping; the display and input-matching methods below both delegate to
|
||||
/// `egui::Key` so there is no further duplication.
|
||||
pub fn to_egui_key(self) -> egui::Key {
|
||||
match self {
|
||||
Self::A => egui::Key::A, Self::B => egui::Key::B, Self::C => egui::Key::C,
|
||||
Self::D => egui::Key::D, Self::E => egui::Key::E, Self::F => egui::Key::F,
|
||||
Self::G => egui::Key::G, Self::H => egui::Key::H, Self::I => egui::Key::I,
|
||||
Self::J => egui::Key::J, Self::K => egui::Key::K, Self::L => egui::Key::L,
|
||||
Self::M => egui::Key::M, Self::N => egui::Key::N, Self::O => egui::Key::O,
|
||||
Self::P => egui::Key::P, Self::Q => egui::Key::Q, Self::R => egui::Key::R,
|
||||
Self::S => egui::Key::S, Self::T => egui::Key::T, Self::U => egui::Key::U,
|
||||
Self::V => egui::Key::V, Self::W => egui::Key::W, Self::X => egui::Key::X,
|
||||
Self::Y => egui::Key::Y, Self::Z => egui::Key::Z,
|
||||
Self::Num0 => egui::Key::Num0, Self::Num1 => egui::Key::Num1,
|
||||
Self::Num2 => egui::Key::Num2, Self::Num3 => egui::Key::Num3,
|
||||
Self::Num4 => egui::Key::Num4, Self::Num5 => egui::Key::Num5,
|
||||
Self::Num6 => egui::Key::Num6, Self::Num7 => egui::Key::Num7,
|
||||
Self::Num8 => egui::Key::Num8, Self::Num9 => egui::Key::Num9,
|
||||
Self::F1 => egui::Key::F1, Self::F2 => egui::Key::F2,
|
||||
Self::F3 => egui::Key::F3, Self::F4 => egui::Key::F4,
|
||||
Self::F5 => egui::Key::F5, Self::F6 => egui::Key::F6,
|
||||
Self::F7 => egui::Key::F7, Self::F8 => egui::Key::F8,
|
||||
Self::F9 => egui::Key::F9, Self::F10 => egui::Key::F10,
|
||||
Self::F11 => egui::Key::F11, Self::F12 => egui::Key::F12,
|
||||
Self::ArrowUp => egui::Key::ArrowUp, Self::ArrowDown => egui::Key::ArrowDown,
|
||||
Self::ArrowLeft => egui::Key::ArrowLeft, Self::ArrowRight => egui::Key::ArrowRight,
|
||||
Self::Comma => egui::Key::Comma, Self::Minus => egui::Key::Minus,
|
||||
Self::Equals => egui::Key::Equals, Self::Plus => egui::Key::Plus,
|
||||
Self::BracketLeft => egui::Key::OpenBracket,
|
||||
Self::BracketRight => egui::Key::CloseBracket,
|
||||
Self::Semicolon => egui::Key::Semicolon, Self::Quote => egui::Key::Quote,
|
||||
Self::Period => egui::Key::Period, Self::Slash => egui::Key::Slash,
|
||||
Self::Backtick => egui::Key::Backtick,
|
||||
Self::Space => egui::Key::Space, Self::Escape => egui::Key::Escape,
|
||||
Self::Enter => egui::Key::Enter, Self::Tab => egui::Key::Tab,
|
||||
Self::Backspace => egui::Key::Backspace, Self::Delete => egui::Key::Delete,
|
||||
Self::Home => egui::Key::Home, Self::End => egui::Key::End,
|
||||
Self::PageUp => egui::Key::PageUp, Self::PageDown => egui::Key::PageDown,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short human-readable name for this key (e.g. "A", "F1", "Delete").
|
||||
/// Delegates to `egui::Key::name()` so the strings stay consistent with
|
||||
/// what egui itself would display.
|
||||
pub fn display_name(self) -> &'static str {
|
||||
self.to_egui_key().name()
|
||||
}
|
||||
|
||||
/// Try to convert an egui Key to a ShortcutKey
|
||||
pub fn from_egui_key(key: egui::Key) -> Option<Self> {
|
||||
Some(match key {
|
||||
|
|
@ -146,16 +90,6 @@ impl Shortcut {
|
|||
Self { key, ctrl, shift, alt }
|
||||
}
|
||||
|
||||
/// Short hint string suitable for tool tooltips (e.g. "F", "Ctrl+S").
|
||||
pub fn hint_text(&self) -> String {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
if self.ctrl { parts.push("Ctrl"); }
|
||||
if self.shift { parts.push("Shift"); }
|
||||
if self.alt { parts.push("Alt"); }
|
||||
parts.push(self.key.display_name());
|
||||
parts.join("+")
|
||||
}
|
||||
|
||||
/// Convert to muda Accelerator
|
||||
pub fn to_muda_accelerator(&self) -> Accelerator {
|
||||
let mut modifiers = Modifiers::empty();
|
||||
|
|
@ -264,7 +198,84 @@ impl Shortcut {
|
|||
return false;
|
||||
}
|
||||
|
||||
input.key_pressed(self.key.to_egui_key())
|
||||
// Check key
|
||||
let key = match self.key {
|
||||
ShortcutKey::A => egui::Key::A,
|
||||
ShortcutKey::B => egui::Key::B,
|
||||
ShortcutKey::C => egui::Key::C,
|
||||
ShortcutKey::D => egui::Key::D,
|
||||
ShortcutKey::E => egui::Key::E,
|
||||
ShortcutKey::F => egui::Key::F,
|
||||
ShortcutKey::G => egui::Key::G,
|
||||
ShortcutKey::H => egui::Key::H,
|
||||
ShortcutKey::I => egui::Key::I,
|
||||
ShortcutKey::J => egui::Key::J,
|
||||
ShortcutKey::K => egui::Key::K,
|
||||
ShortcutKey::L => egui::Key::L,
|
||||
ShortcutKey::M => egui::Key::M,
|
||||
ShortcutKey::N => egui::Key::N,
|
||||
ShortcutKey::O => egui::Key::O,
|
||||
ShortcutKey::P => egui::Key::P,
|
||||
ShortcutKey::Q => egui::Key::Q,
|
||||
ShortcutKey::R => egui::Key::R,
|
||||
ShortcutKey::S => egui::Key::S,
|
||||
ShortcutKey::T => egui::Key::T,
|
||||
ShortcutKey::U => egui::Key::U,
|
||||
ShortcutKey::V => egui::Key::V,
|
||||
ShortcutKey::W => egui::Key::W,
|
||||
ShortcutKey::X => egui::Key::X,
|
||||
ShortcutKey::Y => egui::Key::Y,
|
||||
ShortcutKey::Z => egui::Key::Z,
|
||||
ShortcutKey::Num0 => egui::Key::Num0,
|
||||
ShortcutKey::Num1 => egui::Key::Num1,
|
||||
ShortcutKey::Num2 => egui::Key::Num2,
|
||||
ShortcutKey::Num3 => egui::Key::Num3,
|
||||
ShortcutKey::Num4 => egui::Key::Num4,
|
||||
ShortcutKey::Num5 => egui::Key::Num5,
|
||||
ShortcutKey::Num6 => egui::Key::Num6,
|
||||
ShortcutKey::Num7 => egui::Key::Num7,
|
||||
ShortcutKey::Num8 => egui::Key::Num8,
|
||||
ShortcutKey::Num9 => egui::Key::Num9,
|
||||
ShortcutKey::F1 => egui::Key::F1,
|
||||
ShortcutKey::F2 => egui::Key::F2,
|
||||
ShortcutKey::F3 => egui::Key::F3,
|
||||
ShortcutKey::F4 => egui::Key::F4,
|
||||
ShortcutKey::F5 => egui::Key::F5,
|
||||
ShortcutKey::F6 => egui::Key::F6,
|
||||
ShortcutKey::F7 => egui::Key::F7,
|
||||
ShortcutKey::F8 => egui::Key::F8,
|
||||
ShortcutKey::F9 => egui::Key::F9,
|
||||
ShortcutKey::F10 => egui::Key::F10,
|
||||
ShortcutKey::F11 => egui::Key::F11,
|
||||
ShortcutKey::F12 => egui::Key::F12,
|
||||
ShortcutKey::ArrowUp => egui::Key::ArrowUp,
|
||||
ShortcutKey::ArrowDown => egui::Key::ArrowDown,
|
||||
ShortcutKey::ArrowLeft => egui::Key::ArrowLeft,
|
||||
ShortcutKey::ArrowRight => egui::Key::ArrowRight,
|
||||
ShortcutKey::Comma => egui::Key::Comma,
|
||||
ShortcutKey::Minus => egui::Key::Minus,
|
||||
ShortcutKey::Equals => egui::Key::Equals,
|
||||
ShortcutKey::Plus => egui::Key::Plus,
|
||||
ShortcutKey::BracketLeft => egui::Key::OpenBracket,
|
||||
ShortcutKey::BracketRight => egui::Key::CloseBracket,
|
||||
ShortcutKey::Semicolon => egui::Key::Semicolon,
|
||||
ShortcutKey::Quote => egui::Key::Quote,
|
||||
ShortcutKey::Period => egui::Key::Period,
|
||||
ShortcutKey::Slash => egui::Key::Slash,
|
||||
ShortcutKey::Backtick => egui::Key::Backtick,
|
||||
ShortcutKey::Space => egui::Key::Space,
|
||||
ShortcutKey::Escape => egui::Key::Escape,
|
||||
ShortcutKey::Enter => egui::Key::Enter,
|
||||
ShortcutKey::Tab => egui::Key::Tab,
|
||||
ShortcutKey::Backspace => egui::Key::Backspace,
|
||||
ShortcutKey::Delete => egui::Key::Delete,
|
||||
ShortcutKey::Home => egui::Key::Home,
|
||||
ShortcutKey::End => egui::Key::End,
|
||||
ShortcutKey::PageUp => egui::Key::PageUp,
|
||||
ShortcutKey::PageDown => egui::Key::PageDown,
|
||||
};
|
||||
|
||||
input.key_pressed(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -943,7 +954,39 @@ impl MenuSystem {
|
|||
parts.push("Alt");
|
||||
}
|
||||
|
||||
parts.push(shortcut.key.display_name());
|
||||
let key_name = match shortcut.key {
|
||||
ShortcutKey::A => "A", ShortcutKey::B => "B", ShortcutKey::C => "C",
|
||||
ShortcutKey::D => "D", ShortcutKey::E => "E", ShortcutKey::F => "F",
|
||||
ShortcutKey::G => "G", ShortcutKey::H => "H", ShortcutKey::I => "I",
|
||||
ShortcutKey::J => "J", ShortcutKey::K => "K", ShortcutKey::L => "L",
|
||||
ShortcutKey::M => "M", ShortcutKey::N => "N", ShortcutKey::O => "O",
|
||||
ShortcutKey::P => "P", ShortcutKey::Q => "Q", ShortcutKey::R => "R",
|
||||
ShortcutKey::S => "S", ShortcutKey::T => "T", ShortcutKey::U => "U",
|
||||
ShortcutKey::V => "V", ShortcutKey::W => "W", ShortcutKey::X => "X",
|
||||
ShortcutKey::Y => "Y", ShortcutKey::Z => "Z",
|
||||
ShortcutKey::Num0 => "0", ShortcutKey::Num1 => "1", ShortcutKey::Num2 => "2",
|
||||
ShortcutKey::Num3 => "3", ShortcutKey::Num4 => "4", ShortcutKey::Num5 => "5",
|
||||
ShortcutKey::Num6 => "6", ShortcutKey::Num7 => "7", ShortcutKey::Num8 => "8",
|
||||
ShortcutKey::Num9 => "9",
|
||||
ShortcutKey::F1 => "F1", ShortcutKey::F2 => "F2", ShortcutKey::F3 => "F3",
|
||||
ShortcutKey::F4 => "F4", ShortcutKey::F5 => "F5", ShortcutKey::F6 => "F6",
|
||||
ShortcutKey::F7 => "F7", ShortcutKey::F8 => "F8", ShortcutKey::F9 => "F9",
|
||||
ShortcutKey::F10 => "F10", ShortcutKey::F11 => "F11", ShortcutKey::F12 => "F12",
|
||||
ShortcutKey::ArrowUp => "Up", ShortcutKey::ArrowDown => "Down",
|
||||
ShortcutKey::ArrowLeft => "Left", ShortcutKey::ArrowRight => "Right",
|
||||
ShortcutKey::Comma => ",", ShortcutKey::Minus => "-",
|
||||
ShortcutKey::Equals => "=", ShortcutKey::Plus => "+",
|
||||
ShortcutKey::BracketLeft => "[", ShortcutKey::BracketRight => "]",
|
||||
ShortcutKey::Semicolon => ";", ShortcutKey::Quote => "'",
|
||||
ShortcutKey::Period => ".", ShortcutKey::Slash => "/",
|
||||
ShortcutKey::Backtick => "`",
|
||||
ShortcutKey::Space => "Space", ShortcutKey::Escape => "Esc",
|
||||
ShortcutKey::Enter => "Enter", ShortcutKey::Tab => "Tab",
|
||||
ShortcutKey::Backspace => "Backspace", ShortcutKey::Delete => "Del",
|
||||
ShortcutKey::Home => "Home", ShortcutKey::End => "End",
|
||||
ShortcutKey::PageUp => "PgUp", ShortcutKey::PageDown => "PgDn",
|
||||
};
|
||||
parts.push(key_name);
|
||||
|
||||
parts.join("+")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -278,9 +278,6 @@ pub struct SharedPaneState<'a> {
|
|||
pub clipboard_consumed: &'a mut bool,
|
||||
/// Remappable keyboard shortcut manager
|
||||
pub keymap: &'a crate::keymap::KeymapManager,
|
||||
/// Set by raster selection tools when they need main to commit the floating
|
||||
/// selection before starting a new interaction.
|
||||
pub commit_raster_floating_if_any: &'a mut bool,
|
||||
/// Set by MenuAction::Group when focus is Nodes — consumed by node graph pane
|
||||
pub pending_node_group: &'a mut bool,
|
||||
/// Set by MenuAction::Group (ungroup variant) when focus is Nodes — consumed by node graph pane
|
||||
|
|
|
|||
|
|
@ -4372,68 +4372,6 @@ impl StagePane {
|
|||
/// Handle raster stroke tool input (Draw/Erase/Smudge on a raster layer).
|
||||
///
|
||||
/// Computes GPU dab lists for each drag event and stores them in
|
||||
/// Commit any live floating raster selection into `raw_pixels` right now,
|
||||
/// synchronously. Must be called before capturing `buffer_before` for a
|
||||
/// new brush stroke or before starting a new marquee/lasso drag, so the
|
||||
/// GPU canvas and undo snapshots are based on the fully-composited canvas.
|
||||
///
|
||||
/// Unlike the async `commit_raster_floating_if_any` flag (used for tool
|
||||
/// switches detected in main.rs), this path is needed for in-canvas
|
||||
/// interactions where the commit must happen *before* other per-frame work.
|
||||
fn commit_raster_floating_now(shared: &mut SharedPaneState) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::actions::RasterStrokeAction;
|
||||
use lightningbeam_core::selection::RasterFloatingSelection;
|
||||
|
||||
let Some(float): Option<RasterFloatingSelection> =
|
||||
shared.selection.raster_floating.take()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
shared.selection.raster_selection = None;
|
||||
|
||||
let document = shared.action_executor.document_mut();
|
||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||
|
||||
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels.
|
||||
for row in 0..float.height {
|
||||
let dy = float.y + row as i32;
|
||||
if dy < 0 || dy >= kf.height as i32 { continue; }
|
||||
for col in 0..float.width {
|
||||
let dx = float.x + col as i32;
|
||||
if dx < 0 || dx >= kf.width as i32 { continue; }
|
||||
let si = ((row * float.width + col) * 4) as usize;
|
||||
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
|
||||
let sa = float.pixels[si + 3] as u32;
|
||||
if sa == 0 { continue; }
|
||||
let da = kf.raw_pixels[di + 3] as u32;
|
||||
let out_a = sa + da * (255 - sa) / 255;
|
||||
kf.raw_pixels[di + 3] = out_a as u8;
|
||||
if out_a > 0 {
|
||||
for c in 0..3 {
|
||||
let v = float.pixels[si + c] as u32 * 255
|
||||
+ kf.raw_pixels[di + c] as u32 * (255 - sa);
|
||||
kf.raw_pixels[di + c] = (v / 255).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let canvas_after = kf.raw_pixels.clone();
|
||||
let (w, h) = (kf.width, kf.height);
|
||||
let action = RasterStrokeAction::new(
|
||||
float.layer_id, float.time,
|
||||
float.canvas_before, canvas_after,
|
||||
w, h,
|
||||
);
|
||||
if let Err(e) = shared.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("commit_raster_floating_now: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `self.pending_raster_dabs` for dispatch by `VelloCallback::prepare()`.
|
||||
///
|
||||
/// The actual pixel rendering happens on the GPU (compute shader). The CPU
|
||||
|
|
@ -4490,10 +4428,6 @@ impl StagePane {
|
|||
// Mouse down: capture buffer_before, start stroke, compute first dab
|
||||
// ----------------------------------------------------------------
|
||||
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
|
||||
// Commit any floating selection synchronously so buffer_before and
|
||||
// the GPU canvas initial upload see the fully-composited canvas.
|
||||
Self::commit_raster_floating_now(shared);
|
||||
|
||||
let (doc_width, doc_height) = {
|
||||
let doc = shared.action_executor.document();
|
||||
(doc.width as u32, doc.height as u32)
|
||||
|
|
@ -4675,181 +4609,6 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rectangular marquee selection tool for raster layers.
|
||||
fn handle_raster_select_tool(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
response: &egui::Response,
|
||||
world_pos: egui::Vec2,
|
||||
shared: &mut SharedPaneState,
|
||||
) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::selection::RasterSelection;
|
||||
use lightningbeam_core::tool::ToolState;
|
||||
|
||||
let Some(layer_id) = *shared.active_layer_id else { return };
|
||||
let doc = shared.action_executor.document();
|
||||
let Some(kf) = doc.get_layer(&layer_id).and_then(|l| {
|
||||
if let AnyLayer::Raster(rl) = l { rl.keyframe_at(*shared.playback_time) } else { None }
|
||||
}) else { return };
|
||||
let (canvas_w, canvas_h) = (kf.width as i32, kf.height as i32);
|
||||
|
||||
if self.rsp_drag_started(response) {
|
||||
Self::commit_raster_floating_now(shared);
|
||||
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
||||
*shared.tool_state = ToolState::DrawingRasterMarquee {
|
||||
start: (px, py),
|
||||
current: (px, py),
|
||||
};
|
||||
}
|
||||
|
||||
if self.rsp_dragged(response) {
|
||||
if let ToolState::DrawingRasterMarquee { start, ref mut current } = *shared.tool_state {
|
||||
let (px, py) = (world_pos.x as i32, world_pos.y as i32);
|
||||
*current = (px, py);
|
||||
let (x0, x1) = (start.0.min(px).max(0), start.0.max(px).min(canvas_w));
|
||||
let (y0, y1) = (start.1.min(py).max(0), start.1.max(py).min(canvas_h));
|
||||
shared.selection.raster_selection = Some(RasterSelection::Rect(x0, y0, x1, y1));
|
||||
}
|
||||
}
|
||||
|
||||
if self.rsp_drag_stopped(response) {
|
||||
if let ToolState::DrawingRasterMarquee { start, current } = *shared.tool_state {
|
||||
let (x0, x1) = (start.0.min(current.0).max(0), start.0.max(current.0).min(canvas_w));
|
||||
let (y0, y1) = (start.1.min(current.1).max(0), start.1.max(current.1).min(canvas_h));
|
||||
shared.selection.raster_selection = if x1 > x0 && y1 > y0 {
|
||||
Some(RasterSelection::Rect(x0, y0, x1, y1))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
}
|
||||
|
||||
if self.rsp_clicked(response) {
|
||||
// A click with no drag: commit float (clicked() fires on release, so
|
||||
// drag_started() may not have fired) then clear the selection.
|
||||
Self::commit_raster_floating_now(shared);
|
||||
shared.selection.raster_selection = None;
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
|
||||
let _ = (ui, canvas_h);
|
||||
}
|
||||
|
||||
/// Freehand lasso selection tool for raster layers.
|
||||
fn handle_raster_lasso_tool(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
response: &egui::Response,
|
||||
world_pos: egui::Vec2,
|
||||
shared: &mut SharedPaneState,
|
||||
) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
use lightningbeam_core::selection::RasterSelection;
|
||||
use lightningbeam_core::tool::ToolState;
|
||||
|
||||
let Some(layer_id) = *shared.active_layer_id else { return };
|
||||
if !shared.action_executor.document()
|
||||
.get_layer(&layer_id)
|
||||
.map_or(false, |l| matches!(l, AnyLayer::Raster(_)))
|
||||
{ return; }
|
||||
|
||||
if self.rsp_drag_started(response) {
|
||||
Self::commit_raster_floating_now(shared);
|
||||
let pt = (world_pos.x as i32, world_pos.y as i32);
|
||||
*shared.tool_state = ToolState::DrawingRasterLasso { points: vec![pt] };
|
||||
}
|
||||
|
||||
if self.rsp_dragged(response) {
|
||||
if let ToolState::DrawingRasterLasso { ref mut points } = *shared.tool_state {
|
||||
let pt = (world_pos.x as i32, world_pos.y as i32);
|
||||
if let Some(&last) = points.last() {
|
||||
let (dx, dy) = (pt.0 - last.0, pt.1 - last.1);
|
||||
if dx * dx + dy * dy >= 9 {
|
||||
points.push(pt);
|
||||
}
|
||||
}
|
||||
if points.len() >= 2 {
|
||||
shared.selection.raster_selection = Some(RasterSelection::Lasso(points.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.rsp_drag_stopped(response) {
|
||||
if let ToolState::DrawingRasterLasso { ref points } = *shared.tool_state {
|
||||
shared.selection.raster_selection = if points.len() >= 3 {
|
||||
Some(RasterSelection::Lasso(points.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
|
||||
if self.rsp_clicked(response) {
|
||||
shared.selection.raster_selection = None;
|
||||
*shared.tool_state = ToolState::Idle;
|
||||
}
|
||||
|
||||
let _ = ui;
|
||||
}
|
||||
|
||||
/// Animated "marching ants" dashed outline along a closed screen-space polygon.
|
||||
/// `phase` advances over time to animate the dashes.
|
||||
fn draw_marching_ants(painter: &egui::Painter, pts: &[egui::Pos2], phase: f32) {
|
||||
if pts.len() < 2 { return; }
|
||||
let n = pts.len();
|
||||
let mut d = phase.rem_euclid(8.0); // 4px on, 4px off
|
||||
for i in 0..n {
|
||||
let (a, b) = (pts[i], pts[(i + 1) % n]);
|
||||
let seg = a.distance(b);
|
||||
if seg < 0.5 { continue; }
|
||||
let dir = (b - a) / seg;
|
||||
let mut t = 0.0f32;
|
||||
while t < seg {
|
||||
let rem = if d < 4.0 { 4.0 - d } else { 8.0 - d };
|
||||
let dl = rem.min(seg - t);
|
||||
if d < 4.0 {
|
||||
let p0 = a + dir * t;
|
||||
let p1 = a + dir * (t + dl);
|
||||
painter.line_segment([p0, p1], egui::Stroke::new(2.5, egui::Color32::WHITE));
|
||||
painter.line_segment([p0, p1], egui::Stroke::new(1.5, egui::Color32::BLACK));
|
||||
}
|
||||
d = (d + dl).rem_euclid(8.0);
|
||||
t += dl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw marching ants around a canvas-space rect converted to screen space.
|
||||
fn draw_marching_ants_rect(
|
||||
painter: &egui::Painter,
|
||||
rect_min: egui::Pos2,
|
||||
x0: i32, y0: i32, x1: i32, y1: i32,
|
||||
zoom: f32, pan: egui::Vec2, phase: f32,
|
||||
) {
|
||||
let s = |cx: i32, cy: i32| egui::pos2(
|
||||
rect_min.x + pan.x + cx as f32 * zoom,
|
||||
rect_min.y + pan.y + cy as f32 * zoom,
|
||||
);
|
||||
Self::draw_marching_ants(painter, &[s(x0,y0), s(x1,y0), s(x1,y1), s(x0,y1)], phase);
|
||||
}
|
||||
|
||||
/// Draw marching ants around a canvas-space lasso polygon.
|
||||
fn draw_marching_ants_lasso(
|
||||
painter: &egui::Painter,
|
||||
rect_min: egui::Pos2,
|
||||
pts: &[(i32, i32)],
|
||||
zoom: f32, pan: egui::Vec2, phase: f32,
|
||||
) {
|
||||
let screen: Vec<egui::Pos2> = pts.iter().map(|&(cx, cy)| egui::pos2(
|
||||
rect_min.x + pan.x + cx as f32 * zoom,
|
||||
rect_min.y + pan.y + cy as f32 * zoom,
|
||||
)).collect();
|
||||
Self::draw_marching_ants(painter, &screen, phase);
|
||||
}
|
||||
|
||||
fn handle_paint_bucket_tool(
|
||||
&mut self,
|
||||
response: &egui::Response,
|
||||
|
|
@ -6850,15 +6609,8 @@ impl StagePane {
|
|||
|
||||
match *shared.selected_tool {
|
||||
Tool::Select => {
|
||||
let is_raster = shared.active_layer_id.and_then(|id| {
|
||||
shared.action_executor.document().get_layer(&id)
|
||||
}).map_or(false, |l| matches!(l, lightningbeam_core::layer::AnyLayer::Raster(_)));
|
||||
if is_raster {
|
||||
self.handle_raster_select_tool(ui, &response, world_pos, shared);
|
||||
} else {
|
||||
self.handle_select_tool(ui, &response, world_pos, shift_held, shared);
|
||||
}
|
||||
}
|
||||
Tool::BezierEdit => {
|
||||
self.handle_bezier_edit_tool(ui, &response, world_pos, shift_held, shared);
|
||||
}
|
||||
|
|
@ -6885,9 +6637,6 @@ impl StagePane {
|
|||
Tool::Smudge => {
|
||||
self.handle_raster_stroke_tool(ui, &response, world_pos, lightningbeam_core::raster_layer::RasterBlendMode::Smudge, shared);
|
||||
}
|
||||
Tool::SelectLasso => {
|
||||
self.handle_raster_lasso_tool(ui, &response, world_pos, shared);
|
||||
}
|
||||
Tool::Transform => {
|
||||
self.handle_transform_tool(ui, &response, world_pos, shared);
|
||||
}
|
||||
|
|
@ -7207,79 +6956,6 @@ impl StagePane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Render raster selection overlays:
|
||||
/// - Animated "marching ants" around the active raster selection (marquee or lasso)
|
||||
/// - Floating selection pixels as an egui texture composited at the float position
|
||||
fn render_raster_selection_overlays(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
rect: egui::Rect,
|
||||
shared: &mut SharedPaneState,
|
||||
) {
|
||||
use lightningbeam_core::selection::RasterSelection;
|
||||
|
||||
let has_sel = shared.selection.raster_selection.is_some();
|
||||
let has_float = shared.selection.raster_floating.is_some();
|
||||
if !has_sel && !has_float { return; }
|
||||
|
||||
let time = ui.input(|i| i.time) as f32;
|
||||
// 8px/s scroll rate → repeating every 1 s
|
||||
let phase = (time * 8.0).rem_euclid(8.0);
|
||||
let painter = ui.painter_at(rect);
|
||||
let pan = self.pan_offset;
|
||||
let zoom = self.zoom;
|
||||
|
||||
// ── Marching ants ─────────────────────────────────────────────────────
|
||||
if let Some(sel) = &shared.selection.raster_selection {
|
||||
match sel {
|
||||
RasterSelection::Rect(x0, y0, x1, y1) => {
|
||||
Self::draw_marching_ants_rect(
|
||||
&painter, rect.min,
|
||||
*x0, *y0, *x1, *y1,
|
||||
zoom, pan, phase,
|
||||
);
|
||||
}
|
||||
RasterSelection::Lasso(pts) => {
|
||||
Self::draw_marching_ants_lasso(&painter, rect.min, pts, zoom, pan, phase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Floating selection texture overlay ────────────────────────────────
|
||||
if let Some(float) = &shared.selection.raster_floating {
|
||||
let tex_id = format!("raster_float_{}_{}", float.layer_id, float.time.to_bits());
|
||||
|
||||
// Upload pixels as an egui texture (re-uploaded every frame the float exists;
|
||||
// egui caches by name so this is a no-op when the pixels haven't changed).
|
||||
let color_image = egui::ColorImage::from_rgba_premultiplied(
|
||||
[float.width as usize, float.height as usize],
|
||||
&float.pixels,
|
||||
);
|
||||
let texture = ui.ctx().load_texture(
|
||||
&tex_id,
|
||||
color_image,
|
||||
egui::TextureOptions::NEAREST,
|
||||
);
|
||||
|
||||
// Position in screen space
|
||||
let sx = rect.min.x + pan.x + float.x as f32 * zoom;
|
||||
let sy = rect.min.y + pan.y + float.y as f32 * zoom;
|
||||
let sw = float.width as f32 * zoom;
|
||||
let sh = float.height as f32 * zoom;
|
||||
let float_rect = egui::Rect::from_min_size(egui::pos2(sx, sy), egui::vec2(sw, sh));
|
||||
|
||||
painter.image(
|
||||
texture.id(),
|
||||
float_rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
}
|
||||
|
||||
// Keep animating while a selection is visible
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
|
||||
}
|
||||
|
||||
/// Render snap indicator when snap is active (works for all vector-editing tools).
|
||||
/// Also computes hover snap when idle (no active drag snap) so the user can
|
||||
/// preview snap targets before clicking.
|
||||
|
|
@ -7878,9 +7554,6 @@ impl PaneRenderer for StagePane {
|
|||
// Render vector editing overlays (vertices, control points, etc.)
|
||||
self.render_vector_editing_overlays(ui, rect, shared);
|
||||
|
||||
// Raster selection overlays: marching ants + floating selection texture
|
||||
self.render_raster_selection_overlays(ui, rect, shared);
|
||||
|
||||
// Render snap indicator (works for all tools, not just Select/BezierEdit)
|
||||
self.render_snap_indicator(ui, rect, shared);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use eframe::egui;
|
||||
use lightningbeam_core::layer::{AnyLayer, LayerType};
|
||||
use lightningbeam_core::tool::{Tool, RegionSelectMode};
|
||||
use crate::keymap::tool_app_action;
|
||||
use super::{NodePath, PaneRenderer, SharedPaneState};
|
||||
|
||||
/// Toolbar pane state
|
||||
|
|
@ -164,20 +163,15 @@ impl PaneRenderer for ToolbarPane {
|
|||
);
|
||||
}
|
||||
|
||||
// Show tooltip with tool name and shortcut (consumes response).
|
||||
// Hint text is pulled from the live keymap so it reflects user remappings.
|
||||
let hint = tool_app_action(*tool)
|
||||
.and_then(|action| shared.keymap.get(action))
|
||||
.map(|s| format!(" ({})", s.hint_text()))
|
||||
.unwrap_or_default();
|
||||
// Show tooltip with tool name and shortcut (consumes response)
|
||||
let tooltip = if *tool == Tool::RegionSelect {
|
||||
let mode = match *shared.region_select_mode {
|
||||
RegionSelectMode::Rectangle => "Rectangle",
|
||||
RegionSelectMode::Lasso => "Lasso",
|
||||
};
|
||||
format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint)
|
||||
format!("{} - {} ({})\nRight-click for options", tool.display_name(), mode, tool.shortcut_hint())
|
||||
} else {
|
||||
format!("{}{}", tool.display_name(), hint)
|
||||
format!("{} ({})", tool.display_name(), tool.shortcut_hint())
|
||||
};
|
||||
response.on_hover_text(tooltip);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Lasso loop -->
|
||||
<path d="M12 4 C7 4, 3 7, 3 11 C3 15, 6 17, 10 17 C12 17, 14 16, 15 14 C16 12, 15.5 10, 14 9 C12.5 8, 10 8.5, 9 10 C8 11.5, 8.5 13, 10 13.5 C11.5 14, 13 13, 13 11.5 C13 10, 11.5 9.5, 10.5 10.5"/>
|
||||
<!-- Handle/tail -->
|
||||
<path d="M15.5 14.5 L19 20"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 466 B |
Loading…
Reference in New Issue