diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index a724288..1801a9b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -78,6 +78,47 @@ pub struct AppConfig { /// Last-used audio-export "Album" tag, remembered so it prefills next time. #[serde(default)] pub last_audio_album: String, + + /// What the stylus's lower barrel button does while held. + #[serde(default = "defaults::tablet_button_lower")] + pub tablet_button_lower: TabletButtonAction, + + /// What the stylus's upper barrel button does while held. + #[serde(default = "defaults::tablet_button_upper")] + pub tablet_button_upper: TabletButtonAction, +} + +/// What a stylus barrel button does while held down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum TabletButtonAction { + /// Do nothing. + None, + /// Drag to pan the stage. + #[default] + Pan, + /// Temporarily switch to the eyedropper. + Eyedropper, + /// Temporarily switch to the eraser. + Erase, +} + +impl TabletButtonAction { + pub const ALL: [TabletButtonAction; 4] = [ + TabletButtonAction::None, + TabletButtonAction::Pan, + TabletButtonAction::Eyedropper, + TabletButtonAction::Erase, + ]; + + pub fn label(self) -> &'static str { + match self { + TabletButtonAction::None => "None", + TabletButtonAction::Pan => "Pan", + TabletButtonAction::Eyedropper => "Eyedropper", + TabletButtonAction::Erase => "Eraser", + } + } } impl Default for AppConfig { @@ -100,6 +141,8 @@ impl Default for AppConfig { waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), last_audio_artist: String::new(), last_audio_album: String::new(), + tablet_button_lower: defaults::tablet_button_lower(), + tablet_button_upper: defaults::tablet_button_upper(), } } } @@ -290,6 +333,9 @@ impl AppConfig { /// Default values for preferences (matches JS implementation) mod defaults { + use super::TabletButtonAction; + pub fn tablet_button_lower() -> TabletButtonAction { TabletButtonAction::Pan } + pub fn tablet_button_upper() -> TabletButtonAction { TabletButtonAction::Eyedropper } pub fn bpm() -> u32 { 120 } pub fn framerate() -> u32 { 24 } pub fn file_width() -> u32 { 800 } diff --git a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs index c4ae419..1acc9de 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/custom_cursor.rs @@ -1,205 +1,380 @@ //! Custom cursor system //! -//! Provides SVG-based custom cursors beyond egui's built-in system cursors. -//! When a custom cursor is active, the system cursor is hidden and the SVG -//! cursor image is drawn at the pointer position. +//! Tool cursors are drawn from the bundled Lucide icon font. A tool falls into one of three +//! kinds, because "draw the tool's icon at the pointer" only works for icons that actually have +//! a point: +//! +//! * [`CursorKind::System`] — the OS cursor already says it better than we can (the arrow for +//! Select, the move cross for Transform). We use the real OS cursor. +//! * [`CursorKind::Precise`] — the glyph has an unambiguous tip (pencil, brush, pipette), so it +//! *is* the cursor: the tip lands exactly on the click point. +//! * [`CursorKind::Badge`] — the glyph has no focus point (stamp, bandage, spray can). Using it +//! alone would leave you guessing where you're clicking, so we pair it with a crosshair: the +//! crosshair marks the click point and the glyph hangs off its bottom-right. +//! +//! Rather than *painting* the cursor into the egui scene, we rasterize it and hand it to the +//! windowing system as a real OS cursor (`egui::CursorImage` → winit `CustomCursor`). A painted +//! cursor is composited with our frame and therefore always trails the pointer by a frame or +//! more; an OS cursor is composited by the window system and doesn't lag at all. +//! +//! The glyphs are rasterized out of egui's own font atlas using the same placement maths as +//! `epaint`'s text tessellator, so a glyph lands exactly where `painter.text` would have put it. use eframe::egui; -use egui::TextureHandle; use lightningbeam_core::tool::Tool; use std::collections::HashMap; +use std::sync::Arc; -/// Custom cursor identifiers +use crate::mobile::icons; + +/// What kind of cursor a tool gets. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CursorKind { + /// Use a native OS cursor; build nothing ourselves. + System(egui::CursorIcon), + /// The glyph is the cursor. `hotspot` is the click point in Lucide's 24×24 icon grid. + Precise { + glyph: &'static str, + hotspot: egui::Vec2, + }, + /// A crosshair marks the click point; the glyph sits to its bottom-right. + Badge { glyph: &'static str }, +} + +/// Hotspot for a glyph whose tip is at the bottom-left (pencil, brush, pen, pipette). +const TIP_BOTTOM_LEFT: egui::Vec2 = egui::vec2(3.0, 21.0); +/// Hotspot for a glyph centred on the click point. +const TIP_CENTER: egui::Vec2 = egui::vec2(12.0, 12.0); + +/// Which cursor a stage tool uses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum CustomCursor { - // Stage tool cursors - Select, - Draw, - Transform, - Rectangle, - Ellipse, - PaintBucket, - Eyedropper, - Line, - Polygon, - BezierEdit, - Text, - // Timeline cursors + Tool(Tool), + /// Timeline: dragging the end of the loop region. LoopExtend, } impl CustomCursor { - /// Convert a Tool enum to the corresponding custom cursor pub fn from_tool(tool: Tool) -> Self { + CustomCursor::Tool(tool) + } + + pub fn kind(&self) -> CursorKind { + use egui::CursorIcon as Sys; + + let tool = match self { + CustomCursor::LoopExtend => { + return CursorKind::Precise { + glyph: icons::REPEAT, + hotspot: TIP_CENTER, + } + } + CustomCursor::Tool(t) => *t, + }; + + let precise = |glyph, hotspot| CursorKind::Precise { glyph, hotspot }; + let badge = |glyph| CursorKind::Badge { glyph }; + match tool { - Tool::Select => CustomCursor::Select, - Tool::Draw => CustomCursor::Draw, - Tool::Transform => CustomCursor::Transform, - Tool::Rectangle => CustomCursor::Rectangle, - Tool::Ellipse => CustomCursor::Ellipse, - Tool::PaintBucket => CustomCursor::PaintBucket, - Tool::Eyedropper => CustomCursor::Eyedropper, - Tool::Line => CustomCursor::Line, - Tool::Polygon => CustomCursor::Polygon, - Tool::BezierEdit => CustomCursor::BezierEdit, - Tool::Text => CustomCursor::Text, - Tool::RegionSelect => CustomCursor::Select, - Tool::Split => CustomCursor::Select, - Tool::Erase => CustomCursor::Draw, - Tool::Smudge => CustomCursor::Draw, - Tool::SelectLasso => CustomCursor::Select, - // Raster brush tools — use draw cursor until implemented - Tool::Pencil - | Tool::Pen - | Tool::Airbrush - | Tool::CloneStamp - | Tool::HealingBrush - | Tool::PatternStamp - | Tool::DodgeBurn - | Tool::Sponge - | Tool::BlurSharpen => CustomCursor::Draw, - // Selection tools — use select cursor until implemented - Tool::SelectEllipse - | Tool::MagicWand - | Tool::QuickSelect => CustomCursor::Select, - // Other tools — use select cursor until implemented - Tool::Gradient - | Tool::CustomShape - | Tool::Warp - | Tool::Liquify => CustomCursor::Select, - } - } + // The OS knows these better than we do. + Tool::Select => CursorKind::System(Sys::Default), + Tool::Transform => CursorKind::System(Sys::Move), - /// Hotspot offset — the "click point" relative to the image top-left - pub fn hotspot(&self) -> egui::Vec2 { - match self { - // Select cursor: pointer tip at top-left - CustomCursor::Select => egui::vec2(3.0, 1.0), - // Drawing tools: tip at bottom-left - CustomCursor::Draw => egui::vec2(1.0, 23.0), - // Transform: center - CustomCursor::Transform => egui::vec2(12.0, 12.0), - // Shape tools: crosshair at center - CustomCursor::Rectangle - | CustomCursor::Ellipse - | CustomCursor::Line - | CustomCursor::Polygon => egui::vec2(12.0, 12.0), - // Paint bucket: tip at bottom-left - CustomCursor::PaintBucket => egui::vec2(2.0, 21.0), - // Eyedropper: tip at bottom - CustomCursor::Eyedropper => egui::vec2(4.0, 22.0), - // Bezier edit: tip at top-left - CustomCursor::BezierEdit => egui::vec2(3.0, 1.0), - // Text: I-beam center - CustomCursor::Text => egui::vec2(12.0, 12.0), - // Loop extend: center of circular arrow - CustomCursor::LoopExtend => egui::vec2(12.0, 12.0), - } - } + // Glyphs with a real tip: the icon is the cursor. + Tool::Draw => precise(icons::BRUSH, TIP_BOTTOM_LEFT), + Tool::Pencil => precise(icons::PENCIL, TIP_BOTTOM_LEFT), + Tool::Pen => precise(icons::PEN_TOOL, TIP_BOTTOM_LEFT), + Tool::Eyedropper => precise(icons::PIPETTE, TIP_BOTTOM_LEFT), + Tool::PaintBucket => precise(icons::PAINT_BUCKET, TIP_BOTTOM_LEFT), + Tool::Text => precise(icons::TEXT_CURSOR, TIP_CENTER), - /// Get the embedded SVG data for this cursor - fn svg_data(&self) -> &'static [u8] { - match self { - CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"), - CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"), - CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"), - CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"), - CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"), - CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"), - CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"), - CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"), - CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"), - CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"), - CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"), - CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"), + // Shape tools: crosshair for precision, glyph to say which shape. + Tool::Rectangle => badge(icons::SQUARE), + Tool::Ellipse => badge(icons::CIRCLE), + Tool::Line => badge(icons::MINUS), + Tool::Polygon => badge(icons::HEXAGON), + Tool::CustomShape => badge(icons::SHAPES), + + // Selection tools. + Tool::RegionSelect => badge(icons::SQUARE_DASHED), + Tool::SelectEllipse => badge(icons::CIRCLE_DASHED), + Tool::SelectLasso => badge(icons::LASSO_SELECT), + Tool::MagicWand => badge(icons::WAND_SPARKLES), + Tool::QuickSelect => badge(icons::BRUSH), + Tool::Split => badge(icons::SCISSORS), + + // Raster tools whose icons have no focus point. These also draw a brush-size ring on + // the stage, which is the real precision cue. + Tool::Erase => badge(icons::ERASER), + Tool::Airbrush => badge(icons::SPRAY_CAN), + Tool::Smudge => badge(icons::POINTER), + Tool::CloneStamp => badge(icons::STAMP), + Tool::PatternStamp => badge(icons::SHAPES), + Tool::HealingBrush => badge(icons::BANDAGE), + Tool::DodgeBurn => badge(icons::SUN_MOON), + Tool::Sponge => badge(icons::DROPLETS), + Tool::BlurSharpen => badge(icons::CONTRAST), + Tool::Gradient => badge(icons::BLEND), + Tool::Warp => badge(icons::SCALING), + Tool::Liquify => badge(icons::DROPLET), + + // Vertex editing: crosshair to place the point, glyph to say we're in bezier mode. + Tool::BezierEdit => badge(icons::SPLINE), } } } -/// Cache of rasterized cursor textures (black fill + white outline version) +// --------------------------------------------------------------------------- +// Geometry (logical points; scaled by pixels_per_point when rasterized) +// --------------------------------------------------------------------------- + +/// Rendered size of a cursor glyph. +const GLYPH_SIZE: f32 = 18.0; +/// Lucide icons are authored on a 24×24 grid; hotspots are given in those units. +const ICON_GRID: f32 = 24.0; +/// Where a badge glyph's top-left sits relative to the crosshair centre. +const BADGE_OFFSET: egui::Vec2 = egui::vec2(6.0, 6.0); +/// Half-length of a crosshair arm. +const CROSSHAIR_ARM: f32 = 7.0; +/// Gap between the crosshair centre and the start of each arm, so the click point stays visible. +const CROSSHAIR_GAP: f32 = 2.0; +/// Margin around the artwork, leaving room for the outline. +const MARGIN: f32 = 1.5; + +// --------------------------------------------------------------------------- +// Rasterization +// --------------------------------------------------------------------------- + +/// Cache of rasterized cursor images, keyed by cursor and DPI scale. +#[derive(Default)] pub struct CursorCache { - /// Black cursor for the main image - textures: HashMap, - /// White cursor for the outline - outline_textures: HashMap, + images: HashMap<(CustomCursor, u32), Option>, + next_id: u64, } impl CursorCache { pub fn new() -> Self { - Self { - textures: HashMap::new(), - outline_textures: HashMap::new(), + Self::default() + } + + fn get_or_build( + &mut self, + ctx: &egui::Context, + cursor: CustomCursor, + ppp: f32, + ) -> Option { + let key = (cursor, ppp.to_bits()); + if let Some(cached) = self.images.get(&key) { + return cached.clone(); } - } - /// Get or lazily load the black (fill) cursor texture - pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - let svg_with_color = svg_string.replace("currentColor", "#000000"); - rasterize_cursor_svg(svg_with_color.as_bytes(), &format!("cursor_{:?}", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG") - }) - } + let id = self.next_id; + self.next_id += 1; - /// Get or lazily load the white (outline) cursor texture - pub fn get_or_load_outline(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle { - self.outline_textures.entry(cursor).or_insert_with(|| { - let svg_data = cursor.svg_data(); - let svg_string = String::from_utf8_lossy(svg_data); - // Replace all colors with white for the outline - let svg_white = svg_string - .replace("currentColor", "#ffffff") - .replace("#000000", "#ffffff") - .replace("#000", "#ffffff"); - rasterize_cursor_svg(svg_white.as_bytes(), &format!("cursor_{:?}_outline", cursor), CURSOR_SIZE, ctx) - .expect("Failed to rasterize cursor SVG outline") - }) + let built = build_cursor_image(ctx, cursor.kind(), ppp, id); + self.images.insert(key, built.clone()); + built } } -const CURSOR_SIZE: u32 = 24; -const OUTLINE_OFFSET: f32 = 1.0; +/// An alpha coverage mask being composed, in physical pixels. +struct Mask { + w: usize, + h: usize, + a: Vec, +} -/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg) -fn rasterize_cursor_svg( - svg_data: &[u8], - name: &str, - render_size: u32, +impl Mask { + fn new(w: usize, h: usize) -> Self { + Self { w, h, a: vec![0.0; w * h] } + } + + fn add(&mut self, x: usize, y: usize, coverage: f32) { + if x < self.w && y < self.h { + let p = &mut self.a[y * self.w + x]; + *p = (*p + coverage).min(1.0); + } + } + + fn get(&self, x: isize, y: isize) -> f32 { + if x < 0 || y < 0 || x as usize >= self.w || y as usize >= self.h { + 0.0 + } else { + self.a[y as usize * self.w + x as usize] + } + } + + /// Fill an axis-aligned rect (physical px, may be fractional) with full coverage. + fn fill_rect(&mut self, rect: egui::Rect) { + let x0 = rect.min.x.floor().max(0.0) as usize; + let y0 = rect.min.y.floor().max(0.0) as usize; + let x1 = (rect.max.x.ceil() as usize).min(self.w); + let y1 = (rect.max.y.ceil() as usize).min(self.h); + for y in y0..y1 { + for x in x0..x1 { + self.add(x, y, 1.0); + } + } + } +} + +/// Rasterize a cursor into a premultiplied-RGBA image: black artwork with a white outline, so it +/// reads against both light and dark backgrounds. +fn build_cursor_image( ctx: &egui::Context, -) -> Option { - let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?; - let pixmap_size = tree.size().to_int_size(); - let scale_x = render_size as f32 / pixmap_size.width() as f32; - let scale_y = render_size as f32 / pixmap_size.height() as f32; - let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?; - resvg::render( - &tree, - resvg::tiny_skia::Transform::from_scale(scale_x, scale_y), - &mut pixmap.as_mut(), - ); - let rgba_data = pixmap.data().to_vec(); - let color_image = egui::ColorImage::from_rgba_unmultiplied( - [render_size as usize, render_size as usize], - &rgba_data, - ); - Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR)) + kind: CursorKind, + ppp: f32, + id: u64, +) -> Option { + let (glyph, glyph_origin, hotspot, crosshair) = match kind { + CursorKind::System(_) => return None, + CursorKind::Precise { glyph, hotspot } => { + let scale = GLYPH_SIZE / ICON_GRID; + ( + glyph, + egui::vec2(MARGIN, MARGIN), + egui::pos2(MARGIN, MARGIN) + hotspot * scale, + false, + ) + } + CursorKind::Badge { glyph } => { + // The crosshair centre is the click point; place it a margin + arm in from the corner. + let centre = egui::pos2(MARGIN + CROSSHAIR_ARM, MARGIN + CROSSHAIR_ARM); + (glyph, centre.to_vec2() + BADGE_OFFSET, centre, true) + } + }; + + // Lay the glyph out exactly as `painter.text(.., Align2::LEFT_TOP, ..)` would, so we inherit + // epaint's placement rather than re-deriving it from font metrics. + let galley = ctx.fonts_mut(|f| { + f.layout_no_wrap( + glyph.to_owned(), + icons::font(GLYPH_SIZE), + egui::Color32::WHITE, + ) + }); + + // Canvas must cover the glyph, and the crosshair too when there is one. + let mut content = egui::Rect::from_min_size(glyph_origin.to_pos2(), galley.size()); + if crosshair { + content = content.union(egui::Rect::from_center_size( + hotspot, + egui::Vec2::splat(2.0 * CROSSHAIR_ARM), + )); + } + let canvas = content.expand(MARGIN); + + let w = (canvas.width() * ppp).ceil() as usize; + let h = (canvas.height() * ppp).ceil() as usize; + if w == 0 || h == 0 || w > 2048 || h > 2048 { + return None; // winit caps cursors at 2048px. + } + + let mut mask = Mask::new(w, h); + // Everything is positioned relative to the canvas origin. + let to_px = |p: egui::Pos2| ((p - canvas.min.to_vec2()).to_vec2() * ppp).to_pos2(); + + // --- Crosshair --- + if crosshair { + let c = to_px(hotspot); + let thickness = ppp.max(1.0); // one physical pixel, at least + let arm = CROSSHAIR_ARM * ppp; + let gap = CROSSHAIR_GAP * ppp; + // Four arms, leaving a gap at the centre so the exact click point stays visible. + for (dx, dy) in [(-1.0, 0.0), (1.0, 0.0), (0.0, -1.0), (0.0, 1.0)] { + let from = egui::pos2(c.x + dx * gap, c.y + dy * gap); + let to = egui::pos2(c.x + dx * arm, c.y + dy * arm); + let rect = egui::Rect::from_two_pos(from, to).expand2(if dx == 0.0 { + egui::vec2(thickness / 2.0, 0.0) + } else { + egui::vec2(0.0, thickness / 2.0) + }); + mask.fill_rect(rect); + } + } + + // --- Glyph, copied out of egui's font atlas --- + let atlas = ctx.fonts_mut(|f| f.image()); + let atlas_w = atlas.size[0]; + for row in &galley.rows { + for g in &row.glyphs { + let uv = g.uv_rect; + if uv.is_nothing() { + continue; + } + // Same maths as epaint's text tessellator: the glyph's top-left in points is + // `glyph.pos + uv_rect.offset`, relative to the galley's top-left. + let left_top = to_px((glyph_origin + (g.pos + uv.offset).to_vec2()).to_pos2()); + let src_w = (uv.max[0] - uv.min[0]) as usize; + let src_h = (uv.max[1] - uv.min[1]) as usize; + + for sy in 0..src_h { + for sx in 0..src_w { + let src_i = (uv.min[1] as usize + sy) * atlas_w + (uv.min[0] as usize + sx); + let Some(px) = atlas.pixels.get(src_i) else { continue }; + let coverage = px.a() as f32 / 255.0; + if coverage <= 0.0 { + continue; + } + let dx = (left_top.x.round() as isize) + sx as isize; + let dy = (left_top.y.round() as isize) + sy as isize; + if dx >= 0 && dy >= 0 { + mask.add(dx as usize, dy as usize, coverage); + } + } + } + } + } + + // --- Compose: black fill over a white outline (dilate the mask by one pixel) --- + let mut rgba = vec![0u8; w * h * 4]; + for y in 0..h { + for x in 0..w { + let fill = mask.get(x as isize, y as isize); + + let mut outline: f32 = 0.0; + for oy in -1..=1_isize { + for ox in -1..=1_isize { + outline = outline.max(mask.get(x as isize + ox, y as isize + oy)); + } + } + + // White outline underneath, black fill on top; premultiplied. + let white = outline * (1.0 - fill); + let alpha = fill + outline * (1.0 - fill); + let c = (white * 255.0).round().clamp(0.0, 255.0) as u8; + + let i = (y * w + x) * 4; + rgba[i] = c; + rgba[i + 1] = c; + rgba[i + 2] = c; + rgba[i + 3] = (alpha * 255.0).round().clamp(0.0, 255.0) as u8; + } + } + + let hot = to_px(hotspot); + Some(egui::CursorImage { + id, + rgba: Arc::new(rgba), + size: (w as u16, h as u16), + hotspot: ( + hot.x.round().clamp(0.0, w as f32 - 1.0) as u16, + hot.y.round().clamp(0.0, h as f32 - 1.0) as u16, + ), + }) } // --- Per-frame cursor slot using egui context data --- -/// Key for storing the active custom cursor in egui's per-frame data #[derive(Clone, Copy)] struct ActiveCustomCursor(CustomCursor); /// Set the custom cursor for this frame. Call from any pane during rendering. -/// This hides the system cursor and draws the SVG cursor at pointer position. pub fn set(ctx: &egui::Context, cursor: CustomCursor) { ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor))); } -/// Render the custom cursor overlay. Call at the end of the main update loop. +/// Hand the active cursor to the windowing system. Call at the end of the main update loop. pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { // Take and remove the cursor so it doesn't persist to the next frame let id = egui::Id::new("active_custom_cursor"); @@ -209,43 +384,24 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) { val }); - if let Some(ActiveCustomCursor(cursor)) = cursor { - // If a system cursor was explicitly set (resize handles, text inputs, etc.), - // let it take priority over the custom cursor - let system_cursor = ctx.output(|o| o.cursor_icon); - if system_cursor != egui::CursorIcon::Default { - return; - } + let Some(ActiveCustomCursor(cursor)) = cursor else { return }; - // Hide the system cursor - ctx.set_cursor_icon(egui::CursorIcon::None); + // If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it + // win — it knows something about the hover target that we don't. + if ctx.output(|o| o.cursor_icon) != egui::CursorIcon::Default { + return; + } - if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) { - let hotspot = cursor.hotspot(); - let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32); - let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)); - let painter = ctx.debug_painter(); - - // Draw white outline: render white version offset in 8 directions - let outline_tex = cache.get_or_load_outline(cursor, ctx); - let outline_id = outline_tex.id(); - for &(dx, dy) in &[ - (-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0), - (0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET), - (-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET), - (-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET), - ] { - let offset_rect = egui::Rect::from_min_size( - pos - hotspot + egui::vec2(dx, dy), - size, - ); - painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE); + match cursor.kind() { + CursorKind::System(icon) => ctx.set_cursor_icon(icon), + _ => { + let ppp = ctx.pixels_per_point(); + if let Some(image) = cache.get_or_build(ctx, cursor, ppp) { + ctx.set_cursor_image(Some(image)); + } else { + // Rasterization failed — better a crosshair than an invisible cursor. + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); } - - // Draw black fill on top - let fill_tex = cache.get_or_load(cursor, ctx); - let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size); - painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE); } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index a2f1e88..2dcdfb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -1136,7 +1136,6 @@ struct EditorApp { selected_tool: Tool, // Currently selected drawing tool fill_color: egui::Color32, // Fill color for drawing stroke_color: egui::Color32, // Stroke color for drawing - active_color_mode: panes::ColorMode, // Which color (fill/stroke) was last interacted with pane_instances: HashMap, // Pane instances per path menu_system: Option, // Native menu system for event checking pending_view_action: Option, // Pending view action (zoom, recenter) to be handled by hovered pane @@ -1351,9 +1350,9 @@ struct EditorApp { effect_thumbnail_generator: Option, /// Custom cursor cache for SVG cursors - cursor_cache: custom_cursor::CursorCache, /// Cross-platform graphics tablet (pen/stylus) input state tablet: tablet::TabletInput, + cursor_cache: custom_cursor::CursorCache, /// Debug test mode (F5) — input recording, panic capture & visual replay #[cfg(debug_assertions)] test_mode: test_mode::TestModeState, @@ -1410,6 +1409,10 @@ impl EditorApp { // Load application config let mut config = AppConfig::load(); + + // Mirror the configured stylus barrel-button actions into the tablet layer. + tablet::set_button_actions(config.tablet_button_lower, config.tablet_button_upper); + // One-time cleanup: earlier builds added a Recovered file to Recent on restore. Drop any // recovery-dir paths that leaked in (and re-save if we removed any) so they don't show in // Recent or get auto-reopened below. @@ -1532,7 +1535,6 @@ impl EditorApp { selected_tool: Tool::Select, // Default tool fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke - active_color_mode: panes::ColorMode::default(), // Default to fill color pane_instances: HashMap::new(), // Initialize empty, panes created on-demand menu_system, pending_view_action: None, @@ -1645,8 +1647,8 @@ impl EditorApp { test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry), // Debug overlay (F3) - cursor_cache: custom_cursor::CursorCache::new(), tablet: tablet::TabletInput::new(cc), + cursor_cache: custom_cursor::CursorCache::new(), debug_overlay_visible: false, debug_stats_collector: debug_overlay::DebugStatsCollector::new(), gpu_info, @@ -7965,7 +7967,6 @@ impl EditorApp { selected_tool: &mut self.selected_tool, fill_color: &mut self.fill_color, stroke_color: &mut self.stroke_color, - active_color_mode: &mut self.active_color_mode, pending_view_action: &mut self.pending_view_action, fallback_pane_priority: &mut scratch.fallback_pane_priority, pending_handlers: &mut scratch.pending_handlers, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs index f1b0e21..d15a71e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/icons.rs @@ -82,3 +82,60 @@ pub const FILE_PLUS: &str = "\u{e0c9}"; pub const COPY: &str = "\u{e09e}"; pub const LAYERS: &str = "\u{e529}"; pub const ELLIPSIS: &str = "\u{e0b6}"; +// Undo / redo (stage header). +pub const UNDO_2: &str = "\u{e2a1}"; +pub const REDO_2: &str = "\u{e2a0}"; +// Tool cursors (see custom_cursor.rs). +pub const STAMP: &str = "\u{e3bb}"; +pub const SPRAY_CAN: &str = "\u{e495}"; +pub const BANDAGE: &str = "\u{e61d}"; +pub const DROPLET: &str = "\u{e0b4}"; +pub const TEXT_CURSOR: &str = "\u{e264}"; +pub const CROSSHAIR: &str = "\u{e0ac}"; +pub const POINTER: &str = "\u{e1e8}"; +pub const CONTRAST: &str = "\u{e09d}"; +pub const WIND: &str = "\u{e1b0}"; +pub const SUN_MOON: &str = "\u{e2b2}"; +pub const SPLINE: &str = "\u{e38b}"; +pub const DROPLETS: &str = "\u{e0b5}"; +pub const CIRCLE_DASHED: &str = "\u{e4b0}"; +pub const PALETTE: &str = "\u{e1dd}"; +pub const SHAPES: &str = "\u{e4b3}"; +pub const SCALING: &str = "\u{e2ec}"; +pub const FRAME: &str = "\u{e291}"; +// Timeline layer-row toggles. +pub const VOLUME_2: &str = "\u{e1ab}"; // unmuted +pub const VOLUME_X: &str = "\u{e1ac}"; // muted +pub const HEADPHONES: &str = "\u{e0f1}"; // solo +pub const LOCK: &str = "\u{e10b}"; +pub const LOCK_OPEN: &str = "\u{e10c}"; +pub const EYE: &str = "\u{e0ba}"; +pub const EYE_OFF: &str = "\u{e0bb}"; +pub const VIDEO: &str = "\u{e1a5}"; // camera enabled +pub const VIDEO_OFF: &str = "\u{e1a6}"; // camera disabled + +/// Lucide glyph for tools that have no bundled SVG icon. `None` means the tool has a real SVG +/// icon in `src/assets/` and the caller should use that instead. +pub fn tool_glyph(tool: lightningbeam_core::tool::Tool) -> Option<&'static str> { + use lightningbeam_core::tool::Tool; + Some(match tool { + Tool::Pencil => PENCIL, + Tool::Pen => PEN_TOOL, + Tool::Airbrush => SPRAY_CAN, + Tool::CloneStamp => STAMP, + Tool::HealingBrush => BANDAGE, + Tool::PatternStamp => SHAPES, + Tool::DodgeBurn => SUN_MOON, + Tool::Sponge => DROPLETS, + Tool::BlurSharpen => CONTRAST, + Tool::Gradient => BLEND, + Tool::CustomShape => HEXAGON, + Tool::SelectEllipse => CIRCLE_DASHED, + Tool::MagicWand => WAND_SPARKLES, + Tool::QuickSelect => BRUSH, + Tool::Warp => SCALING, + Tool::Liquify => DROPLET, + Tool::SelectLasso => LASSO_SELECT, + _ => return None, + }) +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs index 3333f01..63481ec 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/gradient_editor.rs @@ -160,27 +160,27 @@ pub fn gradient_stop_editor( delete_idx = Some(i); } - // Color picker popup — opens on click, closes on click-outside. - egui::containers::Popup::from_toggle_button_response(&resp) - .show(|ui| { - ui.spacing_mut().slider_width = 200.0; - let stop = &mut gradient.stops[i]; - let mut c32 = Color32::from_rgba_unmultiplied( - stop.color.r, stop.color.g, stop.color.b, stop.color.a, - ); - if egui::color_picker::color_picker_color32( - ui, &mut c32, egui::color_picker::Alpha::OnlyBlend, - ) { - // Color32 stores premultiplied RGB; unmultiply before storing - // as straight-alpha ShapeColor to avoid darkening on round-trip. - let [pr, pg, pb, a] = c32.to_array(); - let unpm = |c: u8| -> u8 { - if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } - }; - stop.color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); - changed = true; - } - }); + // Color picker popup — opens on click, closes on a press outside it. + let stop_color = gradient.stops[i].color; + let mut c32 = Color32::from_rgba_unmultiplied( + stop_color.r, stop_color.g, stop_color.b, stop_color.a, + ); + if crate::widgets::color_swatch::color_picker_popup( + ui, + resp.id.with("stop_color_popup"), + &resp, + &mut c32, + resp.rect, + ) { + // Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha + // ShapeColor to avoid darkening on round-trip. + let [pr, pg, pb, a] = c32.to_array(); + let unpm = |c: u8| -> u8 { + if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 } + }; + gradient.stops[i].color = ShapeColor::rgba(unpm(pr), unpm(pg), unpm(pb), a); + changed = true; + } } // Apply drag to whichever stop selected_stop points at. diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs index 1522f5b..7fae7bf 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/infopanel.rs @@ -28,14 +28,9 @@ pub struct InfopanelPane { tool_section_open: bool, /// Whether the shape properties section is expanded shape_section_open: bool, - /// Index of the selected paint brush preset (None = custom / unset) - selected_brush_preset: Option, - /// Whether the paint brush picker is expanded - brush_picker_expanded: bool, - /// Index of the selected eraser brush preset - selected_eraser_preset: Option, - /// Whether the eraser brush picker is expanded - eraser_picker_expanded: bool, + /// Whether each tool's brush picker is expanded. The *selected* preset lives on the tool's + /// `BrushSlot`, since it's part of the document-independent tool state, not panel chrome. + brush_picker_expanded: std::collections::HashMap, /// Cached preview textures, one per preset (populated lazily). brush_preview_textures: Vec, /// Selected stop index for gradient editor in shape section. @@ -61,15 +56,10 @@ impl InfopanelPane { // Kick off background loading of the font-picker fonts at startup so the dropdown // is ready without a hitch by the time the user opens it. lightningbeam_core::fonts::start_preload(); - let presets = bundled_brushes(); - let default_eraser_idx = presets.iter().position(|p| p.name == "Brush"); Self { tool_section_open: true, shape_section_open: true, - selected_brush_preset: None, - brush_picker_expanded: false, - selected_eraser_preset: default_eraser_idx, - eraser_picker_expanded: false, + brush_picker_expanded: std::collections::HashMap::new(), brush_preview_textures: Vec::new(), selected_shape_gradient_stop: None, selected_tool_gradient_stop: None, @@ -251,7 +241,7 @@ impl InfopanelPane { || is_raster_select || is_raster_shape || matches!( tool, Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect - | Tool::Warp | Tool::Liquify | Tool::Gradient + | Tool::Warp | Tool::Liquify | Tool::Gradient | Tool::Eyedropper ); if !has_options { @@ -285,12 +275,11 @@ impl InfopanelPane { return; } - // Raster paint tool: delegate to per-tool impl. + // Raster paint tool: tool-unique controls, then the shared brush controls + // (library, color, size/strength/hardness/spacing) that every brush tool gets. if let Some(def) = raster_tool_def { def.render_ui(ui, shared.raster_settings); - if def.show_brush_preset_picker() { - self.render_raster_tool_options(ui, shared, def.is_eraser()); - } + self.render_raster_tool_options(ui, shared, def); } match tool { @@ -344,7 +333,14 @@ impl InfopanelPane { ui.checkbox(shared.fill_enabled, "Fill Shape"); } + Tool::Eyedropper => { + // Which swatch the sampled color lands in. + render_color_slot_row(ui, &mut shared.raster_settings.eyedropper_use_fg); + } + Tool::PaintBucket => { + render_color_slot_row(ui, &mut shared.raster_settings.fill_use_fg); + if active_is_raster { use crate::tools::FillThresholdMode; ui.horizontal(|ui| { @@ -603,68 +599,66 @@ impl InfopanelPane { }); } - /// Render all options for a raster paint tool (brush picker + sliders). - /// `is_eraser` drives which shared state is read/written. + /// Render the shared controls every dab-painting tool has: the brush library, the FG/BG + /// color choice, and size/strength/hardness/spacing/angle. `def` supplies the tool's brush + /// slot and the label for the one slider whose meaning varies (opacity vs. exposure vs. + /// flow vs. strength). fn render_raster_tool_options( &mut self, ui: &mut Ui, shared: &mut SharedPaneState, - is_eraser: bool, + def: &'static dyn crate::tools::RasterToolDef, ) { - self.render_brush_preset_grid(ui, shared, is_eraser); + let kind = def.brush_kind(); + self.render_brush_preset_grid(ui, shared, kind); ui.add_space(2.0); - let rs = &mut shared.raster_settings; - - if !is_eraser { - ui.horizontal(|ui| { - ui.label("Color:"); - ui.selectable_value(&mut rs.brush_use_fg, true, "FG"); - ui.selectable_value(&mut rs.brush_use_fg, false, "BG"); - }); + if def.uses_color() { + render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg); } - macro_rules! field { - ($eraser:ident, $brush:ident) => { - if is_eraser { &mut rs.$eraser } else { &mut rs.$brush } - } - } + let slot = shared.raster_settings.brush_mut(kind); ui.horizontal(|ui| { ui.label("Size:"); - ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px")); + ui.add(egui::Slider::new(&mut slot.radius, 1.0_f32..=500.0) + .logarithmic(true) + .suffix(" px")); }); ui.horizontal(|ui| { - ui.label("Opacity:"); - ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0) + ui.label(format!("{}:", def.strength_label())); + ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Hardness:"); - ui.add(egui::Slider::new(field!(eraser_hardness, brush_hardness), 0.0_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.hardness, 0.0_f32..=1.0) .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); }); ui.horizontal(|ui| { ui.label("Spacing:"); - ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0) + ui.add(egui::Slider::new(&mut slot.spacing, 0.01_f32..=20.0) .logarithmic(true) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); + .custom_formatter(|v, _| format!("{:.2}", v))); }); - let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings }; - if bs.elliptical_dab_ratio > 1.001 { + if slot.settings.elliptical_dab_ratio > 1.001 { ui.horizontal(|ui| { ui.label("Angle:"); - ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0) + ui.add(egui::Slider::new(&mut slot.angle_offset, -180.0_f32..=180.0) .suffix("°") .custom_formatter(|v, _| format!("{:.0}°", v))); }); } } - /// Render the brush preset thumbnail grid (collapsible). - /// `is_eraser` drives which picker state and which shared settings are updated. - fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) { + /// Render the brush preset thumbnail grid (collapsible) for one tool's brush slot. + fn render_brush_preset_grid( + &mut self, + ui: &mut Ui, + shared: &mut SharedPaneState, + kind: crate::tools::BrushKind, + ) { let presets = bundled_brushes(); if presets.is_empty() { return; } @@ -690,8 +684,8 @@ impl InfopanelPane { } // Read picker state into locals to avoid multiple &mut self borrows. - let mut expanded = if is_eraser { self.eraser_picker_expanded } else { self.brush_picker_expanded }; - let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset }; + let mut expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false); + let selected = shared.raster_settings.brush(kind).preset; let gap = 3.0; let cols = 2usize; @@ -762,25 +756,14 @@ impl InfopanelPane { egui::FontId::proportional(9.5), if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) }); if resp.clicked() { - selected = Some(idx); expanded = false; - let s = &preset.settings; - let rs = &mut shared.raster_settings; - if is_eraser { - rs.eraser_opacity = s.opaque.clamp(0.0, 1.0); - rs.eraser_hardness = s.hardness.clamp(0.0, 1.0); - rs.eraser_spacing = s.dabs_per_radius; - rs.active_eraser_settings = s.clone(); - } else { - rs.brush_opacity = s.opaque.clamp(0.0, 1.0); - rs.brush_hardness = s.hardness.clamp(0.0, 1.0); - rs.brush_spacing = s.dabs_per_radius; - rs.active_brush_settings = s.clone(); - // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) - // and manually picked a different brush, revert to the generic tool. - if matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) { - *shared.selected_tool = Tool::Draw; - } + shared.raster_settings.brush_mut(kind).apply_preset(idx, &preset.settings); + // If the user was on a preset-backed tool (Pencil/Pen/Airbrush) + // and manually picked a different brush, revert to the generic tool. + if kind == crate::tools::BrushKind::Paint + && matches!(*shared.selected_tool, Tool::Pencil | Tool::Pen | Tool::Airbrush) + { + *shared.selected_tool = Tool::Draw; } } } @@ -789,14 +772,9 @@ impl InfopanelPane { } } - // Write back picker state. - if is_eraser { - self.eraser_picker_expanded = expanded; - self.selected_eraser_preset = selected; - } else { - self.brush_picker_expanded = expanded; - self.selected_brush_preset = selected; - } + // The selected preset lives on the slot itself (written by apply_preset above); only the + // expand/collapse state is panel-local. + self.brush_picker_expanded.insert(kind, expanded); } // Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms) @@ -1933,3 +1911,17 @@ impl PaneRenderer for InfopanelPane { "Info Panel" } } + +/// The FG/BG selector shared by every tool that reads or writes a color (brush, paint bucket, +/// eyedropper, …), so the choice is made the same way and in the same place regardless of tool. +/// The wording stays neutral because the eyedropper *writes* to the chosen swatch while the +/// painting tools *read* from it. +fn render_color_slot_row(ui: &mut Ui, use_fg: &mut bool) { + ui.horizontal(|ui| { + ui.label("Color:"); + ui.selectable_value(use_fg, true, "FG") + .on_hover_text("Foreground (stroke) color"); + ui.selectable_value(use_fg, false, "BG") + .on_hover_text("Background (fill) color"); + }); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index d79eebe..ed31908 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -189,8 +189,6 @@ pub struct SharedPaneState<'a> { pub selected_tool: &'a mut Tool, pub fill_color: &'a mut egui::Color32, pub stroke_color: &'a mut egui::Color32, - /// Tracks which color (fill or stroke) was last interacted with, for eyedropper tool - pub active_color_mode: &'a mut ColorMode, pub pending_view_action: &'a mut Option, /// Tracks the priority of the best fallback pane for view actions /// Lower number = higher priority. None = no fallback pane seen yet diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index 73dd94d..0f8598c 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -5715,9 +5715,14 @@ impl StagePane { screen_pos: egui::Pos2, shared: &mut SharedPaneState, ) { - // On click, store the screen position and color mode for sampling + // On click, store the screen position and which swatch the sample lands in. if self.rsp_clicked(response) { - self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode)); + let mode = if shared.raster_settings.eyedropper_use_fg { + super::ColorMode::Stroke + } else { + super::ColorMode::Fill + }; + self.pending_eyedropper_sample = Some((screen_pos, mode)); } } @@ -6485,15 +6490,16 @@ impl StagePane { b.dabs_per_radius = spacing; if matches!(blend_mode, RasterBlendMode::Smudge) { b.dabs_per_actual_radius = 0.0; - b.smudge_radius_log = shared.raster_settings.smudge_strength; + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, RasterBlendMode::BlurSharpen) { b.dabs_per_actual_radius = 0.0; } - let color = if matches!(blend_mode, RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { *shared.stroke_color } else { *shared.fill_color @@ -6702,7 +6708,8 @@ impl StagePane { b.dabs_per_actual_radius = 0.0; // strength controls how far behind the stroke to sample (smudge_dist multiplier). // smudge_dist = radius * exp(smudge_radius_log), so log(strength) gives the ratio. - b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] strength + b.smudge_radius_log = + shared.raster_settings.brush(crate::tools::BrushKind::Smudge).strength; } if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) { // Zero dabs_per_actual_radius so the spacing slider is the sole density control. @@ -6711,10 +6718,14 @@ impl StagePane { b }; - let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) { + let color = if !def.uses_color() { [1.0f32, 1.0, 1.0, 1.0] } else { - let c = if shared.raster_settings.brush_use_fg { *shared.stroke_color } else { *shared.fill_color }; + let c = if shared.raster_settings.brush(def.brush_kind()).use_fg { + *shared.stroke_color + } else { + *shared.fill_color + }; let s2l = |v: u8| -> f32 { let f = v as f32 / 255.0; if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) } @@ -7429,7 +7440,7 @@ impl StagePane { use lightningbeam_core::actions::PaintBucketAction; use vello::kurbo::Point; let click_point = Point::new(world_pos.x as f64, world_pos.y as f64); - let fill_color = ShapeColor::from_egui(*shared.fill_color); + let fill_color = ShapeColor::from_egui(bucket_color(shared)); let action = PaintBucketAction::new( active_layer_id, *shared.playback_time, @@ -7483,7 +7494,7 @@ impl StagePane { return; } - let fill_egui = *shared.fill_color; + let fill_egui = bucket_color(shared); let fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()]; let threshold = shared.raster_settings.fill_threshold; let softness = shared.raster_settings.fill_softness; @@ -11580,7 +11591,11 @@ impl StagePane { // Handle tool input (only if not using Alt modifier for panning, and not while a // double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture). - if !alt_held && !self.marquee_gesture_active { + // A pen barrel button bound to Pan suppresses the tool the same way Alt does — the pen + // tip is still down while panning, so without this the brush would paint as you pan. + let pen_panning = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + if !alt_held && !pen_panning && !self.marquee_gesture_active { use lightningbeam_core::tool::Tool; // On a shape-tween in-between frame the active vector layer's geometry is an @@ -11743,9 +11758,21 @@ impl StagePane { self.pan_offset.y += scroll_delta.y; } - // Handle panning with Alt+Drag - if alt_held && response.dragged() { - // Alt+Click+Drag panning + // Middle-mouse drag pans, independent of the stage's own drag sense. + let middle_down = ui.input(|i| i.pointer.middle_down()); + if middle_down { + let delta = ui.input(|i| i.pointer.delta()); + self.pan_offset += delta; + self.is_panning = true; + } + + // Holding a pen barrel button bound to Pan turns the pen drag into a pan instead of + // a stroke (the tip is still down, so this is a normal primary drag). + let pen_pan = crate::tablet::active_button_action() + == Some(crate::config::TabletButtonAction::Pan); + + // Handle panning with Alt+Drag (or a pan-bound pen button) + if (alt_held || pen_pan) && response.dragged() { if let Some(last_pos) = self.last_pan_pos { if let Some(current_pos) = response.interact_pointer_pos() { let delta = current_pos - last_pos; @@ -11755,7 +11782,7 @@ impl StagePane { self.last_pan_pos = response.interact_pointer_pos(); self.is_panning = true; } else { - if !response.dragged() { + if !response.dragged() && !middle_down { self.is_panning = false; self.last_pan_pos = None; } @@ -12099,26 +12126,22 @@ impl StagePane { let r = shared.raster_settings.quick_select_radius; (r, r, 0.0_f32) } else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) { + // Every brush tool can carry an elliptical library brush, so shape the cursor from + // whichever slot the active tool paints with. + let slot = shared.raster_settings.brush(def.brush_kind()); let r = def.cursor_radius(shared.raster_settings); - // For the standard paint brush, also account for elliptical shape. - if matches!(*shared.selected_tool, - Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush) - { - let bs = &shared.raster_settings.active_brush_settings; - let ratio = bs.elliptical_dab_ratio.max(1.0); - let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); - (r * expand, r * expand / ratio, angle) - } else { - (r, r, 0.0_f32) - } - } else { - let bs = &shared.raster_settings.active_brush_settings; - let r = shared.raster_settings.brush_radius; + let bs = &slot.settings; let ratio = bs.elliptical_dab_ratio.max(1.0); let expand = 1.0 + bs.offset_by_random; - let angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians(); + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); (r * expand, r * expand / ratio, angle) + } else { + let slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint); + let bs = &slot.settings; + let ratio = bs.elliptical_dab_ratio.max(1.0); + let expand = 1.0 + bs.offset_by_random; + let angle = (bs.elliptical_dab_angle + slot.angle_offset).to_radians(); + (slot.radius * expand, slot.radius * expand / ratio, angle) }; let a = a_world * self.zoom; // major semi-axis in screen pixels @@ -12163,9 +12186,84 @@ impl StagePane { impl PaneRenderer for StagePane { fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool { - ui.horizontal(|ui| { - // Zoom to fit button - if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() { + // Fill most of the 40px header so the buttons are a comfortable tablet-sized target + // rather than the ~20px egui default. + const BTN_H: f32 = 27.0; + let btn_size = egui::vec2(32.0, BTN_H); + + // Lay the row out in a full-width band of exactly BTN_H centred in the header, so the + // buttons sit vertically centred instead of dropping to the bottom of the taller header + // rect. Horizontally they still start at the left, as before. + let avail = ui.max_rect(); + let band = egui::Rect::from_min_size( + egui::pos2(avail.min.x, avail.center().y - BTN_H / 2.0), + egui::vec2(avail.width(), BTN_H), + ); + + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(band) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + |ui| { + // Undo / redo — duplicated here so tablet users don't need the keyboard. + let can_undo = shared.action_executor.can_undo(); + let can_redo = shared.action_executor.can_redo(); + let icon_btn = |glyph: &str| { + egui::Button::new( + egui::RichText::new(glyph).font(crate::mobile::icons::font(16.0)), + ) + .min_size(btn_size) + }; + + if ui + .add_enabled(can_undo, icon_btn(crate::mobile::icons::UNDO_2)) + .on_hover_text("Undo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Undo); + } + if ui + .add_enabled(can_redo, icon_btn(crate::mobile::icons::REDO_2)) + .on_hover_text("Redo") + .clicked() + { + shared.pending_menu_actions.push(crate::menu::MenuAction::Redo); + } + + ui.separator(); + + // Zoom to fit: Lucide "maximize" glyph followed by the label. Two fonts in one + // button means building the layout job by hand. + let fit_label = { + let mut job = egui::text::LayoutJob::default(); + let color = ui.visuals().widgets.inactive.fg_stroke.color; + job.append( + crate::mobile::icons::MAXIMIZE, + 0.0, + egui::TextFormat { + font_id: crate::mobile::icons::font(15.0), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job.append( + "Fit", + 6.0, + egui::TextFormat { + font_id: egui::TextStyle::Button.resolve(ui.style()), + color, + valign: egui::Align::Center, + ..Default::default() + }, + ); + job + }; + if ui + .add(egui::Button::new(fit_label).min_size(egui::vec2(0.0, BTN_H))) + .on_hover_text("Zoom to fit canvas in view") + .clicked() + { self.zoom_to_fit(shared); } @@ -12175,7 +12273,8 @@ impl PaneRenderer for StagePane { let text_style = shared.theme.style(".text-primary", ui.ctx()); let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200)); ui.colored_label(text_color, format!("Zoom: {:.0}%", self.zoom * 100.0)); - }); + }, + ); true } @@ -13104,3 +13203,13 @@ impl PaneRenderer for StagePane { "Stage" } } + +/// The color the paint bucket fills with. Like the brush, the bucket chooses between the +/// foreground (stroke) and background (fill) swatch — see `RasterToolSettings::fill_use_fg`. +fn bucket_color(shared: &SharedPaneState) -> egui::Color32 { + if shared.raster_settings.fill_use_fg { + *shared.stroke_color + } else { + *shared.fill_color + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 4ec6c21..42bd056 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -10,8 +10,35 @@ use eframe::egui; use daw_backend::{Beats, Seconds}; use lightningbeam_core::clip::ClipInstance; use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait}; +use crate::mobile::icons; use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState}; +/// A layer-row toggle button drawn as a Lucide glyph. +fn icon_button(glyph: &str) -> egui::Button<'static> { + egui::Button::new(egui::RichText::new(glyph).font(icons::font(13.0))) +} + +/// Label a layer-row slider with a Lucide glyph just to its left — the sliders are unlabelled +/// otherwise, and volume vs. opacity are indistinguishable on layers that have both. +fn draw_slider_icon( + ui: &egui::Ui, + theme: &crate::theme::Theme, + slider_rect: egui::Rect, + glyph: &str, +) { + ui.painter().text( + egui::pos2(slider_rect.min.x - 5.0, slider_rect.center().y), + egui::Align2::RIGHT_CENTER, + glyph, + icons::font(12.0), + theme.text_color( + &["#timeline", ".slider-icon"], + ui.ctx(), + egui::Color32::from_gray(140), + ), + ); +} + const RULER_HEIGHT: f32 = 30.0; const LAYER_HEIGHT: f32 = 60.0; const LAYER_HEADER_WIDTH: f32 = 200.0; @@ -2363,32 +2390,53 @@ impl TimelinePane { let Some(layer_for_controls) = any_layer_for_controls else { continue; }; - // Layer controls: volume slider top-right, buttons below it + // Layer controls, right-aligned in three stacked tiers: volume, opacity, buttons. + // A layer only shows the sliders that mean something for it (a raster layer has no + // volume; an audio layer has no opacity), but the buttons sit at a fixed offset so + // they line up across rows regardless of which sliders are present. let controls_right = header_rect.max.x - 8.0; let button_size = egui::vec2(20.0, 20.0); let slider_width = 60.0; + let slider_height = 14.0; let volume_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0), - egui::vec2(slider_width, 20.0), + egui::pos2(controls_right - slider_width, header_rect.min.y + 2.0), + egui::vec2(slider_width, slider_height), + ); + let opacity_slider_rect = egui::Rect::from_min_size( + egui::pos2(controls_right - slider_width, header_rect.min.y + 17.0), + egui::vec2(slider_width, slider_height), ); - // Buttons sit below the slider, right-aligned to match it - let buttons_top = volume_slider_rect.max.y + 4.0; - let lock_button_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - button_size.x, buttons_top), - button_size, - ); + // Which controls apply to this layer. Vector and Video layers can hold movie clips + // with audio *and* draw pixels, so they get both. + let (has_volume, has_opacity) = match layer_for_controls { + lightningbeam_core::layer::AnyLayer::Raster(_) + | lightningbeam_core::layer::AnyLayer::Text(_) => (false, true), + lightningbeam_core::layer::AnyLayer::Audio(_) => (true, false), + _ => (true, true), + }; + let is_video_layer = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let solo_button_rect = egui::Rect::from_min_size( - egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + // Buttons are laid out right-to-left, and a layer only gets the ones that mean + // something for it: [eye] [mute | camera] [solo] [lock]. + let buttons_top = header_rect.min.y + 34.0; + let mut next_x = controls_right; + let mut next_button_rect = || { + next_x -= button_size.x; + let r = egui::Rect::from_min_size(egui::pos2(next_x, buttons_top), button_size); + next_x -= 4.0; + r + }; - let mute_button_rect = egui::Rect::from_min_size( - egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top), - button_size, - ); + let lock_button_rect = next_button_rect(); + let solo_button_rect = has_volume.then(&mut next_button_rect); + // Video layers use this slot for the camera toggle instead of a mute button. + let mute_button_rect = + (has_volume && !is_video_layer).then(&mut next_button_rect); + let camera_button_rect = is_video_layer.then(&mut next_button_rect); + let visibility_button_rect = has_opacity.then(&mut next_button_rect); // Get layer ID and current property values from the layer we already have // Check if there's a Volume automation lane; use it to drive the slider @@ -2416,30 +2464,66 @@ impl TimelinePane { let is_soloed = layer_for_controls.soloed(); let is_locked = layer_for_controls.locked(); - // Mute button — or camera toggle for video layers - let is_video_layer = matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_)); - let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer_for_controls { - v.camera_enabled - } else { - false - }; + // Visibility toggle — on any layer that draws something. + if let Some(rect) = visibility_button_rect { + let is_visible = layer_for_controls.visible(); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_visible { icons::EYE } else { icons::EYE_OFF }) + .fill(if is_visible { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + } else { + theme.bg_color(&["#timeline", ".btn-hidden", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(120, 120, 120, 100)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_visible { "Hide layer" } else { "Show layer" }) + }).inner; - let first_btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| { - if is_video_layer { - // Camera toggle for video layers - let cam_text = if camera_enabled { "📹" } else { "📷" }; - let button = egui::Button::new(cam_text) + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Visible(!is_visible), + ) + )); + } + } + + // Camera toggle — video layers only. + if let Some(rect) = camera_button_rect { + let camera_enabled = + matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled); + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if camera_enabled { icons::VIDEO } else { icons::VIDEO_OFF }) .fill(if camera_enabled { theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) } else { theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) }) .stroke(egui::Stroke::NONE); - ui.add(button) - } else { - // Mute button for non-video layers - let mute_text = if is_muted { "🔇" } else { "🔊" }; - let button = egui::Button::new(mute_text) + ui.add(button).on_hover_text(if camera_enabled { + "Disable camera preview" + } else { + "Enable camera preview" + }) + }).inner; + + if response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), + ) + )); + } + } + + // Mute button — only where there's audio to mute. + if let Some(rect) = mute_button_rect { + let response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(if is_muted { icons::VOLUME_X } else { icons::VOLUME_2 }) .fill(if is_muted { theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100)) } else { @@ -2447,19 +2531,11 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) - } - }).inner; + .on_hover_text(if is_muted { "Unmute layer" } else { "Mute layer" }) + }).inner; - if first_btn_response.clicked() { - self.layer_control_clicked = true; - if is_video_layer { - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled), - ) - )); - } else { + if response.clicked() { + self.layer_control_clicked = true; pending_actions.push(Box::new( lightningbeam_core::actions::SetLayerPropertiesAction::new( layer_id, @@ -2470,33 +2546,34 @@ impl TimelinePane { } // Solo button - // TODO: Replace with SVG headphones icon - let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| { - let button = egui::Button::new("🎧") - .fill(if is_soloed { - theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) - } else { - theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) - }) - .stroke(egui::Stroke::NONE); - ui.add(button) - }).inner; + // Solo is an audio control, so it follows mute: only where there's audio. + if let Some(rect) = solo_button_rect { + let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { + let button = icon_button(icons::HEADPHONES) + .fill(if is_soloed { + theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100)) + } else { + theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40)) + }) + .stroke(egui::Stroke::NONE); + ui.add(button) + .on_hover_text(if is_soloed { "Unsolo layer" } else { "Solo layer" }) + }).inner; - if solo_response.clicked() { - self.layer_control_clicked = true; - pending_actions.push(Box::new( - lightningbeam_core::actions::SetLayerPropertiesAction::new( - layer_id, - lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), - ) - )); + if solo_response.clicked() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed), + ) + )); + } } // Lock button - // TODO: Replace with SVG lock/lock-open icons let lock_response = ui.scope_builder(egui::UiBuilder::new().max_rect(lock_button_rect), |ui| { - let lock_text = if is_locked { "🔒" } else { "🔓" }; - let button = egui::Button::new(lock_text) + let button = icon_button(if is_locked { icons::LOCK } else { icons::LOCK_OPEN }) .fill(if is_locked { theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100)) } else { @@ -2504,6 +2581,7 @@ impl TimelinePane { }) .stroke(egui::Stroke::NONE); ui.add(button) + .on_hover_text(if is_locked { "Unlock layer" } else { "Lock layer (prevent edits)" }) }).inner; if lock_response.clicked() { @@ -2516,9 +2594,40 @@ impl TimelinePane { )); } + // Opacity slider. + if has_opacity { + draw_slider_icon(ui, theme, opacity_slider_rect, icons::BLEND); + let current_opacity = layer_for_controls.opacity() as f32; + let mut new_opacity = current_opacity; + let opacity_response = ui.scope_builder( + egui::UiBuilder::new().max_rect(opacity_slider_rect), + |ui| { + ui.spacing_mut().slider_width = slider_width; + ui.add(egui::Slider::new(&mut new_opacity, 0.0..=1.0).show_value(false)) + }, + ).inner + .on_hover_text(format!("Opacity: {:.0}%", current_opacity * 100.0)); + + // Block layer drag while interacting with the slider + if opacity_response.dragged() || opacity_response.has_focus() { + self.layer_control_clicked = true; + } + if opacity_response.changed() { + self.layer_control_clicked = true; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Opacity(new_opacity as f64), + ) + )); + } + } + // Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume) // Disabled when the user has edited the Volume automation curve beyond the default single keyframe - let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { + if has_volume { + draw_slider_icon(ui, theme, volume_slider_rect, icons::VOLUME_2); + let mut volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { ui.spacing_mut().slider_width = slider_width; // Map volume (0.0-2.0) to slider position (0.0-1.0) let slider_value = if current_volume <= 1.0 { @@ -2537,6 +2646,12 @@ impl TimelinePane { (response, temp_slider_value) }).inner; + volume_response.0 = volume_response.0.on_hover_text(if volume_is_automated { + format!("Volume: {:.0}% (automated)", current_volume * 100.0) + } else { + format!("Volume: {:.0}%", current_volume * 100.0) + }); + // Block layer drag while interacting with the slider if volume_response.0.dragged() || volume_response.0.has_focus() { self.layer_control_clicked = true; @@ -2590,13 +2705,11 @@ impl TimelinePane { } } - // Input gain slider for sampled audio layers (below volume slider) + // Input gain slider for sampled audio layers. Audio layers have no opacity, so this + // takes the opacity tier. if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls { if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled { - let gain_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0), - egui::vec2(slider_width, 16.0), - ); + let gain_slider_rect = opacity_slider_rect; let current_gain = audio_layer.layer.input_gain; // Map gain (0.0-4.0) to slider (0.0-1.0): linear @@ -2624,8 +2737,8 @@ impl TimelinePane { // Label let label_rect = egui::Rect::from_min_size( - egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0), - egui::vec2(24.0, 16.0), + egui::pos2(gain_slider_rect.min.x - 26.0, gain_slider_rect.min.y), + egui::vec2(24.0, gain_slider_rect.height()), ); ui.painter().text( label_rect.center(), @@ -2637,6 +2750,8 @@ impl TimelinePane { } } + } // end volume/gain sliders + // Per-layer VU meter bar (4px tall at bottom of header) { // Look up the track level for this layer @@ -5795,8 +5910,14 @@ impl PaneRenderer for TimelinePane { // Split into layer header column (left) and timeline content (right). On mobile the header // column collapses to a minimal color-swatch width. + // + // Once the pane gets narrow enough that the track area would be a useless sliver, drop it + // entirely and give the whole pane to the layer headers — a mixer-style view. + let headers_only = !shared.is_mobile && rect.width() < LAYER_HEADER_WIDTH * 1.5; let header_width = if shared.is_mobile { MOBILE_LAYER_HEADER_WIDTH + } else if headers_only { + rect.width() } else { LAYER_HEADER_WIDTH }; @@ -5848,20 +5969,29 @@ impl PaneRenderer for TimelinePane { ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time, header_width, shared.is_mobile); - // Render time ruler (clip to ruler rect) - ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); - let cycle = document - .cycle_enabled - .then(|| self.shown_cycle_region(document)); - self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate, cycle); + // The track area only exists when the pane is wide enough for it. The interaction code + // further down is naturally inert in headers-only mode, since it all gates on + // `content_rect.contains(..)` and the rect is zero-width. + let (video_clip_hovers, pending_lane_renders) = if headers_only { + (Vec::new(), Vec::new()) + } else { + // Render time ruler (clip to ruler rect) + ui.set_clip_rect(ruler_rect.intersect(original_clip_rect)); + let cycle = document + .cycle_enabled + .then(|| self.shown_cycle_region(document)); + self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate, cycle); - // Render layer rows with clipping - ui.set_clip_rect(content_rect.intersect(original_clip_rect)); - let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); + // Render layer rows with clipping + ui.set_clip_rect(content_rect.intersect(original_clip_rect)); + let rendered = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, *shared.playback_time); - // Render playhead on top (clip to timeline area) - ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); - self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + // Render playhead on top (clip to timeline area) + ui.set_clip_rect(timeline_rect.intersect(original_clip_rect)); + self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time); + + rendered + }; // Restore original clip rect ui.set_clip_rect(original_clip_rect); diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs index 28315bd..3d40366 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/toolbar.rs @@ -29,10 +29,48 @@ impl PaneRenderer for ToolbarPane { path: &NodePath, shared: &mut SharedPaneState, ) { - let button_size = 60.0; - let button_padding = 8.0; - let button_spacing = 4.0; + // `ui` spans the whole window, not this pane — bind a child Ui to the pane's content rect + // first, or the ScrollArea would start at the window's top (under the pane header) and + // size itself against the window's height (so it would never need to scroll). + // Salt by path: widget ids inside are auto-generated from the Ui's id, so two toolbar + // panes would otherwise fight over the same ids. + let mut content_ui = ui.new_child( + egui::UiBuilder::new() + .id_salt(("toolbar", path)) + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + egui::ScrollArea::vertical() + .id_salt(("toolbar_scroll", path)) + .auto_shrink([false; 2]) + .show(&mut content_ui, |ui| { + self.render_toolbar(ui, path, shared); + }); + } + + fn name(&self) -> &str { + "Toolbar" + } +} + +const BUTTON_SIZE: f32 = 60.0; +const BUTTON_PADDING: f32 = 8.0; +const BUTTON_SPACING: f32 = 4.0; +const COLOR_BUTTON_SIZE: f32 = 50.0; +const COLOR_LABEL_WIDTH: f32 = 40.0; + +impl ToolbarPane { + /// Laid out with real egui widgets (`horizontal_wrapped` for the tool grid) rather than + /// absolute rect math, so the ScrollArea above can measure the content and scroll it when the + /// pane is too short. Buttons are still painted by hand — `allocate_exact_size` reserves the + /// space and gives us the Response, and we draw into the rect egui hands back. + fn render_toolbar( + &mut self, + ui: &mut egui::Ui, + path: &NodePath, + shared: &mut SharedPaneState, + ) { // Determine which tools to show based on the active layer type let active_layer_type: Option = shared.active_layer_id .and_then(|id| shared.action_executor.document().get_layer(&id)) @@ -52,320 +90,273 @@ impl PaneRenderer for ToolbarPane { *shared.selected_tool = Tool::Select; } - // Calculate how many columns we can fit - let available_width = rect.width() - (button_padding * 2.0); - let columns = - ((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize; - let columns = columns.max(1); // At least 1 column - let total_tools = tools.len(); - let total_rows = (total_tools + columns - 1) / columns; - - let mut y = rect.top() + button_padding; - - // Process tools row by row for centered layout - for row in 0..total_rows { - let start_idx = row * columns; - let end_idx = (start_idx + columns).min(total_tools); - let buttons_in_row = end_idx - start_idx; - - // Calculate the total width of buttons in this row - let row_width = (buttons_in_row as f32 * button_size) - + ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing); - - // Center the row - let mut x = rect.left() + (rect.width() - row_width) / 2.0; - - for tool_idx in start_idx..end_idx { - let tool = &tools[tool_idx]; - let button_rect = - egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(button_size, button_size)); - - // Check if this is the selected tool - let is_selected = *shared.selected_tool == *tool; - - // Button background - let bg_color = if is_selected { - shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) - } else { - shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) - }; - ui.painter().rect_filled(button_rect, 4.0, bg_color); - - // Load and render tool icon - if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { - let icon_rect = button_rect.shrink(8.0); // Padding inside button - ui.painter().image( - icon.id(), - icon_rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - } - - // Draw sub-tool arrow indicator for tools with modes - let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); - if has_sub_tools { - let arrow_size = 6.0; - let margin = 4.0; - let corner = button_rect.right_bottom() - egui::vec2(margin, margin); - let tri = [ - corner, - corner - egui::vec2(arrow_size, 0.0), - corner - egui::vec2(0.0, arrow_size), - ]; - ui.painter().add(egui::Shape::convex_polygon( - tri.to_vec(), - shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), - egui::Stroke::NONE, - )); - } - - // Make button interactive (include path to ensure unique IDs across panes) - let button_id = ui.id().with(("tool_button", path, *tool as usize)); - let response = ui.interact(button_rect, button_id, egui::Sense::click()); - - // Check for click first - if response.clicked() { - *shared.selected_tool = *tool; - // Preset-backed tools: auto-select the matching bundled brush. - let preset_name = match tool { - Tool::Pencil => Some("Pencil"), - Tool::Pen => Some("Pen"), - Tool::Airbrush => Some("Airbrush"), - _ => None, - }; - if let Some(name) = preset_name { - if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) { - let s = &preset.settings; - shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0); - shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0); - shared.raster_settings.brush_spacing = s.dabs_per_radius; - shared.raster_settings.active_brush_settings = s.clone(); - } - } - } - - // Right-click context menu for tools with sub-options - if has_sub_tools { - response.context_menu(|ui| { - match tool { - Tool::RegionSelect => { - ui.set_min_width(120.0); - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Rectangle, - "Rectangle", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Rectangle; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - if ui.selectable_label( - *shared.region_select_mode == RegionSelectMode::Lasso, - "Lasso", - ).clicked() { - *shared.region_select_mode = RegionSelectMode::Lasso; - *shared.selected_tool = Tool::RegionSelect; - ui.close(); - } - } - Tool::SelectLasso => { - ui.set_min_width(130.0); - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Freehand, - "Freehand", - ).clicked() { - *shared.lasso_mode = LassoMode::Freehand; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Polygonal, - "Polygonal", - ).clicked() { - *shared.lasso_mode = LassoMode::Polygonal; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - if ui.selectable_label( - *shared.lasso_mode == LassoMode::Magnetic, - "Magnetic", - ).clicked() { - *shared.lasso_mode = LassoMode::Magnetic; - *shared.selected_tool = Tool::SelectLasso; - ui.close(); - } - } - _ => {} - } - }); - } - - if response.hovered() { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), - egui::StrokeKind::Middle, - ); - } - - // 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(); - 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) - } else if *tool == Tool::SelectLasso { - let mode = match *shared.lasso_mode { - LassoMode::Freehand => "Freehand", - LassoMode::Polygonal => "Polygonal", - LassoMode::Magnetic => "Magnetic", - }; - format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) - } else { - format!("{}{}", tool.display_name(), hint) - }; - response.on_hover_text(tooltip); - - // Draw selection border - if is_selected { - ui.painter().rect_stroke( - button_rect, - 4.0, - egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), - egui::StrokeKind::Middle, - ); - } - - // Move to next column in this row - x += button_size + button_spacing; - } - - // Move to next row - y += button_size + button_spacing; - } - let is_raster = matches!(active_layer_type, Some(LayerType::Raster)); let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster)); - // Add color pickers below the tool buttons - if show_colors { - y += button_spacing * 2.0; // Extra spacing + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + ui.add_space(BUTTON_PADDING); - let fill_label_width = 40.0; - let color_button_size = 50.0; - let color_row_width = fill_label_width + color_button_size + button_spacing; - let color_x = rect.left() + (rect.width() - color_row_width) / 2.0; + // Centre the grid as a block. Work out how many columns fit, then lay the buttons out in + // a band of exactly that width, positioned to centre it. The band has to be an explicit + // max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits + // the parent's available width and wraps against that, not against the width we set. + let full_width = ui.available_width(); + let avail = full_width - BUTTON_PADDING * 2.0; + let columns = (((avail + BUTTON_SPACING) / (BUTTON_SIZE + BUTTON_SPACING)).floor() as usize) + .max(1) + .min(tools.len().max(1)); + let grid_width = + columns as f32 * BUTTON_SIZE + (columns.saturating_sub(1)) as f32 * BUTTON_SPACING; + let indent = ((full_width - grid_width) / 2.0).max(0.0); + let rows = (tools.len() + columns - 1) / columns; - // Two color swatches: + let cursor = ui.cursor().min; + let band = egui::Rect::from_min_size( + egui::pos2(cursor.x + indent, cursor.y), + egui::vec2(grid_width, rows as f32 * (BUTTON_SIZE + BUTTON_SPACING)), + ); + ui.scope_builder( + egui::UiBuilder::new().max_rect(band).layout( + egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), + ), + |ui| { + ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING); + for tool in tools.iter() { + self.render_tool_button(ui, tool, shared); + } + }, + ); + + // Colour swatches below the tools. // Stroke/FG always on top, Fill/BG always on bottom. // Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill". - { - let stroke_label = if is_raster { "FG" } else { "Stroke" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); - ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), - egui::Align2::CENTER_CENTER, - stroke_label, - egui::FontId::proportional(14.0), - label_color, - ); + if show_colors { + ui.add_space(BUTTON_SPACING * 2.0); - let stroke_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let stroke_button_id = ui.id().with(("stroke_color_button", path)); - let stroke_response = ui.interact(stroke_button_rect, stroke_button_id, egui::Sense::click()); - draw_color_button(ui, stroke_button_rect, *shared.stroke_color); - egui::containers::Popup::from_toggle_button_response(&stroke_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.stroke_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Stroke; - } + let row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING; + let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0); + + for (label, is_stroke) in [ + (if is_raster { "FG" } else { "Stroke" }, true), + (if is_raster { "BG" } else { "Fill" }, false), + ] { + ui.horizontal(|ui| { + ui.add_space(color_indent); + + let (label_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_LABEL_WIDTH, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let label_color = shared.theme.text_color( + &["#toolbar", ".text-secondary"], + ui.ctx(), + egui::Color32::from_gray(200), + ); + ui.painter().text( + label_rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(14.0), + label_color, + ); + + let (swatch_rect, _) = ui.allocate_exact_size( + egui::vec2(COLOR_BUTTON_SIZE, COLOR_BUTTON_SIZE), + egui::Sense::hover(), + ); + let (id_key, color) = if is_stroke { + ("stroke_color_button", &mut *shared.stroke_color) + } else { + ("fill_color_button", &mut *shared.fill_color) + }; + let button_id = ui.id().with((id_key, path)); + crate::widgets::color_swatch::color_swatch(ui, button_id, swatch_rect, color); }); - - y += color_button_size + button_spacing; + } } - // Fill/BG color swatch - { - let fill_label = if is_raster { "BG" } else { "Fill" }; - let label_color = shared.theme.text_color(&["#toolbar", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); + ui.add_space(BUTTON_PADDING); + } + + fn render_tool_button( + &mut self, + ui: &mut egui::Ui, + tool: &Tool, + shared: &mut SharedPaneState, + ) { + let (button_rect, response) = ui.allocate_exact_size( + egui::vec2(BUTTON_SIZE, BUTTON_SIZE), + egui::Sense::click(), + ); + + let is_selected = *shared.selected_tool == *tool; + + // Button background + let bg_color = if is_selected { + shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150)) + } else { + shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50)) + }; + ui.painter().rect_filled(button_rect, 4.0, bg_color); + + // Tool icon: tools without a bundled SVG fall back to a Lucide glyph rather than the + // shared TODO placeholder. + if let Some(glyph) = crate::mobile::icons::tool_glyph(*tool) { ui.painter().text( - egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0), + button_rect.center(), egui::Align2::CENTER_CENTER, - fill_label, - egui::FontId::proportional(14.0), - label_color, - ); - - let fill_button_rect = egui::Rect::from_min_size( - egui::pos2(color_x + fill_label_width + button_spacing, y), - egui::vec2(color_button_size, color_button_size), - ); - let fill_button_id = ui.id().with(("fill_color_button", path)); - let fill_response = ui.interact(fill_button_rect, fill_button_id, egui::Sense::click()); - draw_color_button(ui, fill_button_rect, *shared.fill_color); - egui::containers::Popup::from_toggle_button_response(&fill_response) - .show(|ui| { - ui.spacing_mut().slider_width = 275.0; - let changed = egui::color_picker::color_picker_color32(ui, shared.fill_color, egui::color_picker::Alpha::OnlyBlend); - if changed { - *shared.active_color_mode = super::ColorMode::Fill; - } - }); - } - } // end color pickers - } - - fn name(&self) -> &str { - "Toolbar" - } -} - -/// Draw a color button with checkerboard background for alpha channel -fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { - // Draw checkerboard background - let checker_size = 5.0; - let cols = (rect.width() / checker_size).ceil() as usize; - let rows = (rect.height() / checker_size).ceil() as usize; - - for row in 0..rows { - for col in 0..cols { - let is_light = (row + col) % 2 == 0; - let checker_color = if is_light { - egui::Color32::from_gray(180) - } else { - egui::Color32::from_gray(120) - }; - let checker_rect = egui::Rect::from_min_size( - egui::pos2( - rect.min.x + col as f32 * checker_size, - rect.min.y + row as f32 * checker_size, + glyph, + crate::mobile::icons::font(26.0), + shared.theme.text_color( + &["#toolbar", ".tool-button"], + ui.ctx(), + egui::Color32::from_gray(220), ), - egui::vec2(checker_size, checker_size), - ).intersect(rect); - ui.painter().rect_filled(checker_rect, 0.0, checker_color); + ); + } else if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) { + let icon_rect = button_rect.shrink(8.0); // Padding inside button + ui.painter().image( + icon.id(), + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); } + + // Draw sub-tool arrow indicator for tools with modes + let has_sub_tools = matches!(tool, Tool::RegionSelect | Tool::SelectLasso); + if has_sub_tools { + let arrow_size = 6.0; + let margin = 4.0; + let corner = button_rect.right_bottom() - egui::vec2(margin, margin); + let tri = [ + corner, + corner - egui::vec2(arrow_size, 0.0), + corner - egui::vec2(0.0, arrow_size), + ]; + ui.painter().add(egui::Shape::convex_polygon( + tri.to_vec(), + shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)), + egui::Stroke::NONE, + )); + } + + if response.clicked() { + *shared.selected_tool = *tool; + // Preset-backed tools: auto-select the matching bundled brush. + let preset_name = match tool { + Tool::Pencil => Some("Pencil"), + Tool::Pen => Some("Pen"), + Tool::Airbrush => Some("Airbrush"), + _ => None, + }; + if let Some(name) = preset_name { + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == name) { + let settings = bundled_brushes()[idx].settings.clone(); + shared + .raster_settings + .brush_mut(crate::tools::BrushKind::Paint) + .apply_preset(idx, &settings); + } + } + } + + // Right-click context menu for tools with sub-options + if has_sub_tools { + response.context_menu(|ui| { + match tool { + Tool::RegionSelect => { + ui.set_min_width(120.0); + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Rectangle, + "Rectangle", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Rectangle; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + if ui.selectable_label( + *shared.region_select_mode == RegionSelectMode::Lasso, + "Lasso", + ).clicked() { + *shared.region_select_mode = RegionSelectMode::Lasso; + *shared.selected_tool = Tool::RegionSelect; + ui.close(); + } + } + Tool::SelectLasso => { + ui.set_min_width(130.0); + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Freehand, + "Freehand", + ).clicked() { + *shared.lasso_mode = LassoMode::Freehand; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Polygonal, + "Polygonal", + ).clicked() { + *shared.lasso_mode = LassoMode::Polygonal; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + if ui.selectable_label( + *shared.lasso_mode == LassoMode::Magnetic, + "Magnetic", + ).clicked() { + *shared.lasso_mode = LassoMode::Magnetic; + *shared.selected_tool = Tool::SelectLasso; + ui.close(); + } + } + _ => {} + } + }); + } + + if response.hovered() { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))), + egui::StrokeKind::Middle, + ); + } + + // Draw selection border + if is_selected { + ui.painter().rect_stroke( + button_rect, + 4.0, + egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))), + egui::StrokeKind::Middle, + ); + } + + // 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(); + 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) + } else if *tool == Tool::SelectLasso { + let mode = match *shared.lasso_mode { + LassoMode::Freehand => "Freehand", + LassoMode::Polygonal => "Polygonal", + LassoMode::Magnetic => "Magnetic", + }; + format!("{} - {}{}\nRight-click for options", tool.display_name(), mode, hint) + } else { + format!("{}{}", tool.display_name(), hint) + }; + response.on_hover_text(tooltip); } - - // Draw color on top - ui.painter().rect_filled(rect, 2.0, color); - - // Draw border - ui.painter().rect_stroke( - rect, - 2.0, - egui::Stroke::new(1.0, egui::Color32::from_gray(80)), - egui::StrokeKind::Middle, - ); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs index 7ef5391..a0bfc9f 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/preferences/dialog.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use eframe::egui; -use crate::config::AppConfig; +use crate::config::{AppConfig, TabletButtonAction}; use crate::keymap::{self, AppAction, KeymapManager}; use crate::menu::{MenuSystem, Shortcut, ShortcutKey}; use crate::theme::{Theme, ThemeMode}; @@ -61,6 +61,8 @@ struct PreferencesState { waveform_stereo: bool, theme_mode: ThemeMode, large_media_default: LargeMediaMode, + tablet_button_lower: TabletButtonAction, + tablet_button_upper: TabletButtonAction, } impl From<(&AppConfig, &Theme)> for PreferencesState { @@ -78,6 +80,8 @@ impl From<(&AppConfig, &Theme)> for PreferencesState { waveform_stereo: config.waveform_stereo, theme_mode: theme.mode(), large_media_default: config.large_media_default, + tablet_button_lower: config.tablet_button_lower, + tablet_button_upper: config.tablet_button_upper, } } } @@ -97,6 +101,8 @@ impl Default for PreferencesState { waveform_stereo: false, theme_mode: ThemeMode::System, large_media_default: LargeMediaMode::default(), + tablet_button_lower: TabletButtonAction::Pan, + tablet_button_upper: TabletButtonAction::Eyedropper, } } } @@ -239,6 +245,8 @@ impl PreferencesDialog { ui.add_space(8.0); self.render_startup_section(ui); ui.add_space(8.0); + self.render_tablet_section(ui); + ui.add_space(8.0); self.render_advanced_section(ui); }); } @@ -591,6 +599,41 @@ impl PreferencesDialog { }); } + fn render_tablet_section(&mut self, ui: &mut egui::Ui) { + egui::CollapsingHeader::new("Tablet") + .default_open(false) + .show(ui, |ui| { + ui.label("What each barrel button on the stylus does while held:"); + ui.add_space(4.0); + + let button_row = |ui: &mut egui::Ui, label: &str, id: &str, value: &mut TabletButtonAction| { + ui.horizontal(|ui| { + ui.label(label); + egui::ComboBox::from_id_salt(id) + .selected_text(value.label()) + .show_ui(ui, |ui| { + for action in TabletButtonAction::ALL { + ui.selectable_value(value, action, action.label()); + } + }); + }); + }; + + button_row( + ui, + "Lower button:", + "tablet_button_lower", + &mut self.working_prefs.tablet_button_lower, + ); + button_row( + ui, + "Upper button:", + "tablet_button_upper", + &mut self.working_prefs.tablet_button_upper, + ); + }); + } + fn render_advanced_section(&mut self, ui: &mut egui::Ui) { egui::CollapsingHeader::new("Advanced") .default_open(false) @@ -646,6 +689,8 @@ impl PreferencesDialog { temp_config.debug = self.working_prefs.debug; temp_config.waveform_stereo = self.working_prefs.waveform_stereo; temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); + temp_config.tablet_button_lower = self.working_prefs.tablet_button_lower; + temp_config.tablet_button_upper = self.working_prefs.tablet_button_upper; // Validate if let Err(err) = temp_config.validate() { @@ -681,10 +726,16 @@ impl PreferencesDialog { config.waveform_stereo = self.working_prefs.waveform_stereo; config.theme_mode = self.working_prefs.theme_mode.to_string_lower(); config.large_media_default = self.working_prefs.large_media_default; + config.tablet_button_lower = self.working_prefs.tablet_button_lower; + config.tablet_button_upper = self.working_prefs.tablet_button_upper; config.keybindings = keybinding_config; // Apply theme immediately theme.set_mode(self.working_prefs.theme_mode); + crate::tablet::set_button_actions( + self.working_prefs.tablet_button_lower, + self.working_prefs.tablet_button_upper, + ); // Save to disk config.save(); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs index 8186b4f..6d76770 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tablet.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tablet.rs @@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU32, Ordering}; use eframe::egui; use lightningbeam_core::tool::Tool; +use crate::config::TabletButtonAction; + // --------------------------------------------------------------------------- // Global tablet state — read by make_stroke_point() on the UI thread // --------------------------------------------------------------------------- @@ -21,6 +23,58 @@ static TABLET_PRESSURE_BITS: AtomicU32 = AtomicU32::new(0x3f800000); // 1.0f32 static TABLET_TILT_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 static TABLET_TILT_Y_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32 +/// Bit 0 = lower barrel button held, bit 1 = upper. Read on the UI thread by the stage to +/// decide whether the pen is currently panning. +static TABLET_BUTTONS: AtomicU32 = AtomicU32::new(0); + +fn button_bit(b: TabletButton) -> u32 { + match b { + TabletButton::Lower => 1, + TabletButton::Upper => 2, + } +} + +/// Is the given barrel button currently held? +pub fn button_held(b: TabletButton) -> bool { + TABLET_BUTTONS.load(Ordering::Relaxed) & button_bit(b) != 0 +} + +/// The configured action for each barrel button, mirrored from `AppConfig` so the stage can +/// read it without threading config through every call site. +static BUTTON_ACTIONS: std::sync::Mutex<(TabletButtonAction, TabletButtonAction)> = + std::sync::Mutex::new((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + +/// Mirror the configured barrel-button actions. Call on startup and whenever preferences change. +pub fn set_button_actions(lower: TabletButtonAction, upper: TabletButtonAction) { + if let Ok(mut a) = BUTTON_ACTIONS.lock() { + *a = (lower, upper); + } +} + +fn action_for(b: TabletButton) -> TabletButtonAction { + let actions = BUTTON_ACTIONS + .lock() + .map(|a| *a) + .unwrap_or((TabletButtonAction::Pan, TabletButtonAction::Eyedropper)); + match b { + TabletButton::Lower => actions.0, + TabletButton::Upper => actions.1, + } +} + +/// The action of whichever barrel button is currently held (lower wins if both are). +pub fn active_button_action() -> Option { + for b in [TabletButton::Lower, TabletButton::Upper] { + if button_held(b) { + let a = action_for(b); + if a != TabletButtonAction::None { + return Some(a); + } + } + } + None +} + /// Current pen pressure (0.0–1.0). Falls back to 1.0 when no tablet is active. pub fn current_pressure() -> f32 { f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed)) @@ -57,10 +111,32 @@ pub enum RawTabletEvent { Tilt { x: f32, y: f32 }, TipDown, TipUp, + /// A barrel button on the pen was pressed or released. + Button { button: TabletButton, pressed: bool }, /// End of Wayland tablet event group; commit accumulated state. Frame, } +/// Which barrel button on the stylus. Most pens have two; some have a third. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TabletButton { + Lower, + Upper, +} + +impl TabletButton { + /// Map a Linux evdev button code (used by both the Wayland tablet protocol and X11's + /// underlying device) to a barrel button. + fn from_evdev(code: u32) -> Option { + // BTN_STYLUS = 0x14b, BTN_STYLUS2 = 0x14c, BTN_STYLUS3 = 0x149. + match code { + 0x14b => Some(TabletButton::Lower), + 0x14c => Some(TabletButton::Upper), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Default)] pub enum TabletToolType { #[default] @@ -91,9 +167,13 @@ pub struct TabletInput { /// On X11, clicks already come through winit via OS mouse emulation. inject_buttons: bool, - /// Pending tool switch (eraser in/out). Consumed by `EditorApp::update()`. + /// Pending tool switch (eraser in/out, barrel-button override). Consumed by `EditorApp::update()`. pub pending_tool_switch: Option, tool_before_eraser: Option, + /// Barrel-button state as of last frame, for press/release edge detection. + prev_buttons: u32, + /// Tool to restore when the barrel button driving a tool override is released. + tool_before_button: Option, /// One-shot sender used to hand the egui Context to the background thread /// on the first poll() call so it can call request_repaint(). @@ -125,6 +205,8 @@ impl TabletInput { inject_buttons, pending_tool_switch: None, tool_before_eraser: None, + prev_buttons: 0, + tool_before_button: None, repaint_sender, backend, } @@ -202,6 +284,9 @@ impl TabletInput { // Handle eraser tool switch. self.handle_tool_switch(current_tool); + // Handle barrel-button tool overrides (eyedropper / eraser held on the pen). + self.handle_button_tool_override(current_tool); + // Publish globals for make_stroke_point(). set_pressure(self.pressure); let (tx, ty) = self.tilt; @@ -267,6 +352,8 @@ impl TabletInput { RawTabletEvent::ProximityOut => { self.in_proximity = false; self.pressure = 0.0; + // Don't let a button stay latched if the pen leaves while it's held. + TABLET_BUTTONS.store(0, Ordering::Relaxed); } RawTabletEvent::Motion { x, y } => { // Wayland gives physical pixels; convert to egui logical points. @@ -289,6 +376,14 @@ impl TabletInput { RawTabletEvent::TipUp => { self.tip_down = Some(false); } + RawTabletEvent::Button { button, pressed } => { + let bit = button_bit(button); + if pressed { + TABLET_BUTTONS.fetch_or(bit, Ordering::Relaxed); + } else { + TABLET_BUTTONS.fetch_and(!bit, Ordering::Relaxed); + } + } RawTabletEvent::Frame => { // Frame is the commit signal; processing already happened in individual events. } @@ -318,6 +413,39 @@ impl TabletInput { } } + /// Barrel buttons bound to Eyedropper/Erase act as a spring-loaded tool override: switch + /// while held, restore on release. Pan is handled by the stage instead, since it needs the + /// drag delta rather than a tool. + fn handle_button_tool_override(&mut self, current_tool: Tool) { + let buttons = TABLET_BUTTONS.load(Ordering::Relaxed); + if buttons == self.prev_buttons { + return; + } + self.prev_buttons = buttons; + + let override_tool = active_button_action().and_then(|a| match a { + TabletButtonAction::Eyedropper => Some(Tool::Eyedropper), + TabletButtonAction::Erase => Some(Tool::Erase), + TabletButtonAction::Pan | TabletButtonAction::None => None, + }); + + match (override_tool, self.tool_before_button) { + // Button pressed: remember the tool we're overriding, then switch. + (Some(tool), None) => { + if current_tool != tool { + self.tool_before_button = Some(current_tool); + self.pending_tool_switch = Some(tool); + } + } + // Button released: restore. + (None, Some(prev)) => { + self.tool_before_button = None; + self.pending_tool_switch = Some(prev); + } + _ => {} + } + } + fn handle_tool_switch(&mut self, current_tool: Tool) { let just_entered = self.in_proximity && !self.was_in_proximity; let just_left = !self.in_proximity && self.was_in_proximity; @@ -343,7 +471,7 @@ impl TabletInput { #[cfg(target_os = "linux")] mod wayland { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use eframe::egui; use std::collections::HashMap; use std::sync::mpsc; @@ -445,6 +573,7 @@ mod wayland { pending_motion: bool, pending_tip: Option, pending_proximity: Option, + pending_buttons: Vec<(TabletButton, bool)>, } // ----------------------------------------------------------------------- @@ -585,7 +714,20 @@ mod wayland { Event::Tilt { tilt_x, tilt_y } => { acc.pending_tilt = (tilt_x as f32, tilt_y as f32); } + Event::Button { button, state: btn_state, .. } => { + // `button` is a Linux evdev code (BTN_STYLUS / BTN_STYLUS2). + if let Some(b) = TabletButton::from_evdev(button) { + let pressed = matches!( + btn_state.into_result(), + Ok(zwp_tablet_tool_v2::ButtonState::Pressed) + ); + acc.pending_buttons.push((b, pressed)); + } + } Event::Frame { .. } => { + for (b, pressed) in acc.pending_buttons.drain(..) { + let _ = state.tx.send(RawTabletEvent::Button { button: b, pressed }); + } // Flush accumulated events to the channel. if let Some(prox) = acc.pending_proximity.take() { if prox { @@ -630,7 +772,7 @@ mod wayland { #[cfg(target_os = "linux")] mod x11 { - use super::{RawTabletEvent, TabletToolType}; + use super::{RawTabletEvent, TabletButton, TabletToolType}; use std::sync::mpsc; use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle}; @@ -880,17 +1022,44 @@ mod x11 { } Event::XinputRawButtonPress(raw) => { - if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipDown); - let _ = tx.send(RawTabletEvent::Frame); + if device_axes.contains_key(&raw.deviceid) { + // X11 reports the tip as button 1 and the barrel buttons as 2 and 3. + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipDown); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: true }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} + } } } Event::XinputRawButtonRelease(raw) => { if device_axes.contains_key(&raw.deviceid) { - if raw.detail == 1 { - let _ = tx.send(RawTabletEvent::TipUp); - let _ = tx.send(RawTabletEvent::Frame); + match raw.detail { + 1 => { + let _ = tx.send(RawTabletEvent::TipUp); + let _ = tx.send(RawTabletEvent::Frame); + } + 2 | 3 => { + let button = if raw.detail == 2 { + TabletButton::Lower + } else { + TabletButton::Upper + }; + let _ = tx.send(RawTabletEvent::Button { button, pressed: false }); + let _ = tx.send(RawTabletEvent::Frame); + } + _ => {} } // When all buttons released, synthesise proximity out. in_proximity.remove(&raw.deviceid); diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs index 876b5d6..367e0f6 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/blur_sharpen.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct BlurSharpenTool; pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; @@ -8,19 +8,11 @@ pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool; impl RasterToolDef for BlurSharpenTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen } fn header_label(&self) -> &'static str { "Blur / Sharpen" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.blur_sharpen_radius, - opacity: s.blur_sharpen_strength, - hardness: s.blur_sharpen_hardness, - spacing: s.blur_sharpen_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::BlurSharpen } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Strength" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() { @@ -30,31 +22,11 @@ impl RasterToolDef for BlurSharpenTool { s.blur_sharpen_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); ui.horizontal(|ui| { ui.label("Kernel:"); ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0) .logarithmic(true) .custom_formatter(|v, _| format!("{:.1} px", v))); }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.blur_sharpen_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs index bbfb9f2..c623ecc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/clone_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static CLONE_STAMP: CloneStampTool = CloneStampTool; impl RasterToolDef for CloneStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp } fn header_label(&self) -> &'static str { "Clone Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::CloneStamp } /// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time /// (offset = clone_source - stroke_start), not from settings directly. fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs index 7ab1968..2b8279d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/dodge_burn.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct DodgeBurnTool; pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; @@ -8,19 +8,11 @@ pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool; impl RasterToolDef for DodgeBurnTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn } fn header_label(&self) -> &'static str { "Dodge / Burn" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.dodge_burn_radius, - opacity: s.dodge_burn_exposure, - hardness: s.dodge_burn_hardness, - spacing: s.dodge_burn_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::DodgeBurn } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.dodge_burn_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Exposure" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for DodgeBurnTool { s.dodge_burn_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Exposure:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_exposure, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.dodge_burn_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs index 361858a..a1f3102 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/erase.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct EraseTool; @@ -8,16 +7,6 @@ pub static ERASE: EraseTool = EraseTool; impl RasterToolDef for EraseTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase } fn header_label(&self) -> &'static str { "Eraser" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_eraser_settings.clone(), - radius: s.eraser_radius, - opacity: s.eraser_opacity, - hardness: s.eraser_hardness, - spacing: s.eraser_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn is_eraser(&self) -> bool { true } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Erase } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs index c6ae87a..1a920dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/healing_brush.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -8,15 +8,7 @@ pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool; impl RasterToolDef for HealingBrushTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing } fn header_label(&self) -> &'static str { "Healing Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::HealingBrush } /// tool_params are filled by stage.rs at stroke-start time (clone offset). fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } fn uses_alt_click(&self) -> bool { true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs index 80efc73..60cf8d7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/mod.rs @@ -2,12 +2,17 @@ /// /// Each tool implements `RasterToolDef`. Adding a new tool requires: /// 1. A new file in this directory implementing `RasterToolDef`. -/// 2. One entry in `raster_tool_def()` below. +/// 2. A `BrushKind` variant (if it paints dabs) and one entry in `raster_tool_def()` below. /// 3. Core changes: `RasterBlendMode` variant, `brush_engine.rs` constant, WGSL branch. +/// +/// Every dab-painting tool owns a `BrushSlot`: its own size/strength/hardness/spacing, its own +/// brush from the shared `.myb` library, and its own FG/BG color choice. The blend mode is what +/// makes a tool a brush vs. an eraser vs. dodge/burn — the brush shape is orthogonal, so all of +/// them get the same library and the same controls. use eframe::egui; use lightningbeam_core::{ - brush_settings::BrushSettings, + brush_settings::{bundled_brushes, BrushSettings}, raster_layer::RasterBlendMode, tool::Tool, }; @@ -22,6 +27,86 @@ pub mod dodge_burn; pub mod sponge; pub mod blur_sharpen; +// --------------------------------------------------------------------------- +// Brush slots — one per dab-painting tool +// --------------------------------------------------------------------------- + +/// Identifies a tool's brush slot. Each slot remembers its own brush independently, so +/// switching from Dodge to Sponge doesn't clobber either one's size or preset. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BrushKind { + Paint, + Erase, + Smudge, + CloneStamp, + HealingBrush, + PatternStamp, + DodgeBurn, + Sponge, + BlurSharpen, +} + +impl BrushKind { + fn index(self) -> usize { + match self { + BrushKind::Paint => 0, + BrushKind::Erase => 1, + BrushKind::Smudge => 2, + BrushKind::CloneStamp => 3, + BrushKind::HealingBrush => 4, + BrushKind::PatternStamp => 5, + BrushKind::DodgeBurn => 6, + BrushKind::Sponge => 7, + BrushKind::BlurSharpen => 8, + } + } +} + +/// One tool's brush: the shared controls every dab-painting tool has. +#[derive(Debug, Clone)] +pub struct BrushSlot { + pub radius: f32, + /// What this means is per-tool — opacity, exposure, flow, strength. The label comes from + /// [`RasterToolDef::strength_label`]. + pub strength: f32, + pub hardness: f32, + pub spacing: f32, + /// The brush shape, from the bundled `.myb` library. + pub settings: BrushSettings, + /// Index into `bundled_brushes()` of the selected preset, if any. + pub preset: Option, + /// Added to the preset's `elliptical_dab_angle` (degrees), so stock brushes can be + /// re-oriented without editing the file. + pub angle_offset: f32, + /// true = paint with the FG (stroke) color, false = BG (fill). Ignored when the tool + /// doesn't use a color (see [`RasterToolDef::uses_color`]). + pub use_fg: bool, +} + +impl BrushSlot { + fn new(radius: f32, strength: f32, hardness: f32, spacing: f32) -> Self { + Self { + radius, + strength, + hardness, + spacing, + settings: BrushSettings::default(), + preset: None, + angle_offset: 0.0, + use_fg: true, + } + } + + /// Adopt a brush preset, pulling its opacity/hardness/spacing along with the shape. + pub fn apply_preset(&mut self, index: usize, settings: &BrushSettings) { + self.preset = Some(index); + self.strength = settings.opaque.clamp(0.0, 1.0); + self.hardness = settings.hardness.clamp(0.0, 1.0); + self.spacing = settings.dabs_per_radius; + self.settings = settings.clone(); + } +} + // --------------------------------------------------------------------------- // Shared settings struct (replaces 20+ individual SharedPaneState / EditorApp fields) // --------------------------------------------------------------------------- @@ -29,25 +114,8 @@ pub mod blur_sharpen; /// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed /// by `SharedPaneState` as a single `&'a mut RasterToolSettings`. pub struct RasterToolSettings { - // --- Paint brush --- - pub brush_radius: f32, - pub brush_opacity: f32, - pub brush_hardness: f32, - pub brush_spacing: f32, - /// true = paint with FG (stroke) color, false = BG (fill) color - pub brush_use_fg: bool, - pub active_brush_settings: BrushSettings, - // --- Eraser --- - pub eraser_radius: f32, - pub eraser_opacity: f32, - pub eraser_hardness: f32, - pub eraser_spacing: f32, - pub active_eraser_settings: BrushSettings, - // --- Smudge --- - pub smudge_radius: f32, - pub smudge_hardness: f32, - pub smudge_spacing: f32, - pub smudge_strength: f32, + /// Per-tool brush state, indexed by `BrushKind`. + brushes: [BrushSlot; 9], // --- Clone / Healing --- /// World-space source point set by Alt+click. pub clone_source: Option, @@ -55,24 +123,12 @@ pub struct RasterToolSettings { pub pattern_type: u32, pub pattern_scale: f32, // --- Dodge / Burn --- - pub dodge_burn_radius: f32, - pub dodge_burn_hardness: f32, - pub dodge_burn_spacing: f32, - pub dodge_burn_exposure: f32, /// 0 = dodge (lighten), 1 = burn (darken) pub dodge_burn_mode: u32, // --- Sponge --- - pub sponge_radius: f32, - pub sponge_hardness: f32, - pub sponge_spacing: f32, - pub sponge_flow: f32, /// 0 = saturate, 1 = desaturate pub sponge_mode: u32, // --- Blur / Sharpen --- - pub blur_sharpen_radius: f32, - pub blur_sharpen_hardness: f32, - pub blur_sharpen_spacing: f32, - pub blur_sharpen_strength: f32, /// Neighborhood kernel radius in canvas pixels (1–20) pub blur_sharpen_kernel: f32, /// 0 = blur, 1 = sharpen @@ -87,7 +143,7 @@ pub struct RasterToolSettings { // --- Quick Select --- /// Brush radius in canvas pixels for the quick-select tool. pub quick_select_radius: f32, - // --- Flood fill (Paint Bucket, raster) --- + // --- Flood fill (Paint Bucket) --- /// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this /// distance of the comparison color are included in the fill. pub fill_threshold: f32, @@ -96,6 +152,11 @@ pub struct RasterToolSettings { /// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS /// parent pixel (Relative, spreads across gradients). pub fill_threshold_mode: FillThresholdMode, + /// true = fill with the FG (stroke) color, false = BG (fill). Mirrors `BrushSlot::use_fg`. + pub fill_use_fg: bool, + // --- Eyedropper --- + /// true = sampled color replaces the FG (stroke) swatch, false = the BG (fill) swatch. + pub eyedropper_use_fg: bool, // --- Marquee select shape --- /// Whether the rectangular select tool draws a rect or an ellipse. pub select_shape: SelectionShape, @@ -109,10 +170,16 @@ pub struct RasterToolSettings { // --- Gradient --- pub gradient: lightningbeam_core::gradient::ShapeGradient, pub gradient_opacity: f32, - // --- Brush rotation offset --- - /// User-controlled angle offset added to the brush's elliptical_dab_angle (degrees). - /// Lets the user re-orient stock .myb brushes without editing the file. - pub brush_angle_offset: f32, +} + +impl RasterToolSettings { + pub fn brush(&self, kind: BrushKind) -> &BrushSlot { + &self.brushes[kind.index()] + } + + pub fn brush_mut(&mut self, kind: BrushKind) -> &mut BrushSlot { + &mut self.brushes[kind.index()] + } } /// Brush mode for the Liquify tool. @@ -158,43 +225,32 @@ pub enum FillThresholdMode { impl Default for RasterToolSettings { fn default() -> Self { + // The eraser defaults to the bundled "Brush" shape rather than a bare Gaussian. + let mut erase = BrushSlot::new(10.0, 1.0, 0.5, 0.1); + if let Some(idx) = bundled_brushes().iter().position(|p| p.name == "Brush") { + erase.apply_preset(idx, &bundled_brushes()[idx].settings); + // Keep the eraser's own size/opacity rather than the preset's. + erase.radius = 10.0; + erase.strength = 1.0; + } + Self { - brush_radius: 10.0, - brush_opacity: 1.0, - brush_hardness: 0.5, - brush_spacing: 0.1, - brush_use_fg: true, - active_brush_settings: BrushSettings::default(), - eraser_radius: 10.0, - eraser_opacity: 1.0, - eraser_hardness: 0.5, - eraser_spacing: 0.1, - active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes() - .iter() - .find(|p| p.name == "Brush") - .map(|p| p.settings.clone()) - .unwrap_or_default(), - smudge_radius: 15.0, - smudge_hardness: 0.8, - smudge_spacing: 8.0, - smudge_strength: 1.0, + brushes: [ + /* Paint */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* Erase */ erase, + /* Smudge */ BrushSlot::new(15.0, 1.0, 0.8, 8.0), + /* CloneStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* HealingBrush */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* PatternStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1), + /* DodgeBurn */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* Sponge */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + /* BlurSharpen */ BrushSlot::new(30.0, 0.5, 0.5, 3.0), + ], clone_source: None, pattern_type: 0, pattern_scale: 32.0, - dodge_burn_radius: 30.0, - dodge_burn_hardness: 0.5, - dodge_burn_spacing: 3.0, - dodge_burn_exposure: 0.5, dodge_burn_mode: 0, - sponge_radius: 30.0, - sponge_hardness: 0.5, - sponge_spacing: 3.0, - sponge_flow: 0.5, sponge_mode: 0, - blur_sharpen_radius: 30.0, - blur_sharpen_hardness: 0.5, - blur_sharpen_spacing: 3.0, - blur_sharpen_strength: 0.5, blur_sharpen_kernel: 5.0, blur_sharpen_mode: 0, wand_threshold: 15.0, @@ -203,6 +259,8 @@ impl Default for RasterToolSettings { fill_threshold: 15.0, fill_softness: 0.0, fill_threshold_mode: FillThresholdMode::Absolute, + fill_use_fg: true, + eyedropper_use_fg: true, quick_select_radius: 20.0, select_shape: SelectionShape::Rect, warp_grid_cols: 4, @@ -212,7 +270,6 @@ impl Default for RasterToolSettings { liquify_strength: 0.5, gradient: lightningbeam_core::gradient::ShapeGradient::default(), gradient_opacity: 1.0, - brush_angle_offset: 0.0, } } } @@ -229,6 +286,21 @@ pub struct BrushParams { pub spacing: f32, } +/// Read a slot straight into `BrushParams`. This is what `RasterToolDef::brush_params` does by +/// default; it's a free function so a tool that overrides `brush_params` can still build on it. +pub fn default_brush_params(kind: BrushKind, s: &RasterToolSettings) -> BrushParams { + let slot = s.brush(kind); + let mut base_settings = slot.settings.clone(); + base_settings.elliptical_dab_angle += slot.angle_offset; + BrushParams { + base_settings, + radius: slot.radius, + opacity: slot.strength, + hardness: slot.hardness, + spacing: slot.spacing, + } +} + // --------------------------------------------------------------------------- // RasterToolDef trait // --------------------------------------------------------------------------- @@ -236,19 +308,34 @@ pub struct BrushParams { pub trait RasterToolDef: Send + Sync { fn blend_mode(&self) -> RasterBlendMode; fn header_label(&self) -> &'static str; - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams; + /// Which brush slot this tool paints with. + fn brush_kind(&self) -> BrushKind; /// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`. fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4]; + + /// Brush shape + size for this stroke. The default pulls everything from the tool's slot; + /// override only if a tool needs to reinterpret one of the fields (see `smudge`). + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + default_brush_params(self.brush_kind(), s) + } + /// Cursor display radius (world pixels). fn cursor_radius(&self, s: &RasterToolSettings) -> f32 { self.brush_params(s).radius } - /// Render tool-specific controls in the infopanel (called before preset picker if any). - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings); - /// Whether to show the brush preset picker after `render_ui`. - fn show_brush_preset_picker(&self) -> bool { true } - /// Whether this tool is the eraser (drives preset picker + color UI visibility). - fn is_eraser(&self) -> bool { false } + + /// Label for the slot's `strength` slider — the one field whose meaning is tool-specific. + fn strength_label(&self) -> &'static str { "Opacity" } + + /// Render tool-specific controls in the infopanel. The shared brush controls (color, size, + /// strength, hardness, spacing, angle, preset library) are rendered around this by the + /// infopanel — only put genuinely tool-unique widgets here. + fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + + /// Whether this tool paints with the FG/BG color. False for tools that only transform + /// pixels already on the canvas (erase, smudge, dodge/burn, sponge, blur, clone, heal). + fn uses_color(&self) -> bool { false } + /// Whether Alt+click sets a source point for this tool. fn uses_alt_click(&self) -> bool { false } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs index 6d3e192..7c632aa 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/paint.rs @@ -1,5 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; +use super::{BrushKind, RasterToolDef}; use lightningbeam_core::raster_layer::RasterBlendMode; pub struct PaintTool; @@ -8,17 +7,7 @@ pub static PAINT: PaintTool = PaintTool; impl RasterToolDef for PaintTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal } fn header_label(&self) -> &'static str { "Brush" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - let mut base_settings = s.active_brush_settings.clone(); - base_settings.elliptical_dab_angle += s.brush_angle_offset; - BrushParams { - base_settings, - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } - fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn render_ui(&self, _ui: &mut egui::Ui, _s: &mut RasterToolSettings) {} + fn brush_kind(&self) -> BrushKind { BrushKind::Paint } + fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] } + fn uses_color(&self) -> bool { true } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs index acd83d3..e3b8445 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/pattern_stamp.rs @@ -1,4 +1,4 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; use lightningbeam_core::raster_layer::RasterBlendMode; @@ -12,18 +12,12 @@ const PATTERN_NAMES: &[&str] = &[ impl RasterToolDef for PatternStampTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp } fn header_label(&self) -> &'static str { "Pattern Stamp" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: s.active_brush_settings.clone(), - radius: s.brush_radius, - opacity: s.brush_opacity, - hardness: s.brush_hardness, - spacing: s.brush_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::PatternStamp } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.pattern_type as f32, s.pattern_scale, 0.0, 0.0] } + /// The pattern is stamped in the brush color (see `brush_dab.wgsl`, blend mode 5). + fn uses_color(&self) -> bool { true } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { let selected_name = PATTERN_NAMES .get(s.pattern_type as usize) @@ -44,6 +38,5 @@ impl RasterToolDef for PatternStampTool { ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0) .logarithmic(true).suffix(" px")); }); - ui.add_space(4.0); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs index b79eeb8..0adf976 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/smudge.rs @@ -1,6 +1,5 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; -use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SmudgeTool; pub static SMUDGE: SmudgeTool = SmudgeTool; @@ -8,37 +7,15 @@ pub static SMUDGE: SmudgeTool = SmudgeTool; impl RasterToolDef for SmudgeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge } fn header_label(&self) -> &'static str { "Smudge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.smudge_radius, - opacity: 1.0, // strength is a separate smudge_dist multiplier - hardness: s.smudge_hardness, - spacing: s.smudge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Smudge } fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] } - fn show_brush_preset_picker(&self) -> bool { false } - fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Strength:"); - ui.add(egui::Slider::new(&mut s.smudge_strength, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.smudge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.smudge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); + fn strength_label(&self) -> &'static str { "Strength" } + + /// Smudge's slot `strength` drives the smudge distance (applied by the stage as + /// `smudge_radius_log`), not dab opacity — dabs always composite fully. + fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { + let mut p = super::default_brush_params(self.brush_kind(), s); + p.opacity = 1.0; + p } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs index a410810..7af9486 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/tools/sponge.rs @@ -1,6 +1,6 @@ -use super::{BrushParams, RasterToolDef, RasterToolSettings}; +use super::{BrushKind, RasterToolDef, RasterToolSettings}; use eframe::egui; -use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode}; +use lightningbeam_core::raster_layer::RasterBlendMode; pub struct SpongeTool; pub static SPONGE: SpongeTool = SpongeTool; @@ -8,19 +8,11 @@ pub static SPONGE: SpongeTool = SpongeTool; impl RasterToolDef for SpongeTool { fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge } fn header_label(&self) -> &'static str { "Sponge" } - fn brush_params(&self, s: &RasterToolSettings) -> BrushParams { - BrushParams { - base_settings: BrushSettings::default(), - radius: s.sponge_radius, - opacity: s.sponge_flow, - hardness: s.sponge_hardness, - spacing: s.sponge_spacing, - } - } + fn brush_kind(&self) -> BrushKind { BrushKind::Sponge } fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] { [s.sponge_mode as f32, 0.0, 0.0, 0.0] } - fn show_brush_preset_picker(&self) -> bool { false } + fn strength_label(&self) -> &'static str { "Flow" } fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) { ui.horizontal(|ui| { if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() { @@ -30,25 +22,5 @@ impl RasterToolDef for SpongeTool { s.sponge_mode = 1; } }); - ui.horizontal(|ui| { - ui.label("Size:"); - ui.add(egui::Slider::new(&mut s.sponge_radius, 1.0_f32..=500.0).logarithmic(true).suffix(" px")); - }); - ui.horizontal(|ui| { - ui.label("Flow:"); - ui.add(egui::Slider::new(&mut s.sponge_flow, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Hardness:"); - ui.add(egui::Slider::new(&mut s.sponge_hardness, 0.0_f32..=1.0) - .custom_formatter(|v, _| format!("{:.0}%", v * 100.0))); - }); - ui.horizontal(|ui| { - ui.label("Spacing:"); - ui.add(egui::Slider::new(&mut s.sponge_spacing, 0.5_f32..=20.0) - .logarithmic(true) - .custom_formatter(|v, _| format!("{:.1}", v))); - }); } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs new file mode 100644 index 0000000..b67984c --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/color_swatch.rs @@ -0,0 +1,102 @@ +//! Shared color swatch + picker popup used by the toolbar FG/BG swatches and the +//! gradient-stop editor. +//! +//! egui's default popup close behavior is `CloseOnClick`, which closes on a click *anywhere* — +//! including on the picker's own hue/alpha bars. And `CloseOnClickOutside` only reacts to a +//! click (press+release without movement), so painting a stroke on the stage — a drag — would +//! leave the popup open. Both call sites want the same thing, so it lives here once. + +use eframe::egui; + +/// Show a color-picker popup anchored to `toggle_response`. +/// +/// `swatch_rect` is excluded from the close-on-press check so the press that opens the popup +/// doesn't immediately close it again. +/// +/// Returns true if the color changed this frame. +pub fn color_picker_popup( + ui: &mut egui::Ui, + popup_id: egui::Id, + toggle_response: &egui::Response, + color: &mut egui::Color32, + swatch_rect: egui::Rect, +) -> bool { + let mut changed = false; + + let popup_response = egui::containers::Popup::from_toggle_button_response(toggle_response) + .id(popup_id) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| { + ui.spacing_mut().slider_width = 275.0; + changed = egui::color_picker::color_picker_color32( + ui, + color, + egui::color_picker::Alpha::OnlyBlend, + ); + }); + + // Close on any pointer *press* outside the popup, not just a click, so that starting a + // brush stroke on the stage dismisses the picker. + if let Some(popup) = &popup_response { + let popup_rect = popup.response.rect; + let pressed_outside = ui.ctx().input(|i| { + i.pointer.any_pressed() + && i.pointer + .interact_pos() + .is_some_and(|p| !popup_rect.contains(p) && !swatch_rect.contains(p)) + }); + if pressed_outside { + egui::Popup::close_id(ui.ctx(), popup_id); + } + } + + changed +} + +/// A color button (checkerboard under the color, so alpha reads correctly) that opens a color +/// picker popup when clicked. Returns true if the color changed this frame. +pub fn color_swatch( + ui: &mut egui::Ui, + id: egui::Id, + rect: egui::Rect, + color: &mut egui::Color32, +) -> bool { + let response = ui.interact(rect, id, egui::Sense::click()); + draw_color_button(ui, rect, *color); + color_picker_popup(ui, id.with("picker_popup"), &response, color, rect) +} + +/// Draw a color button with a checkerboard background so the alpha channel is visible. +pub fn draw_color_button(ui: &mut egui::Ui, rect: egui::Rect, color: egui::Color32) { + let checker_size = 5.0; + let cols = (rect.width() / checker_size).ceil() as usize; + let rows = (rect.height() / checker_size).ceil() as usize; + + for row in 0..rows { + for col in 0..cols { + let is_light = (row + col) % 2 == 0; + let checker_color = if is_light { + egui::Color32::from_gray(180) + } else { + egui::Color32::from_gray(120) + }; + let checker_rect = egui::Rect::from_min_size( + egui::pos2( + rect.min.x + col as f32 * checker_size, + rect.min.y + row as f32 * checker_size, + ), + egui::vec2(checker_size, checker_size), + ) + .intersect(rect); + ui.painter().rect_filled(checker_rect, 0.0, checker_color); + } + } + + ui.painter().rect_filled(rect, 2.0, color); + ui.painter().rect_stroke( + rect, + 2.0, + egui::Stroke::new(1.0, egui::Color32::from_gray(80)), + egui::StrokeKind::Middle, + ); +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs index 1e47eb5..0186941 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/widgets/mod.rs @@ -1,6 +1,7 @@ //! Reusable UI widgets for the editor mod text_field; +pub mod color_swatch; pub mod dropdown_list; pub use text_field::ImeTextField;