Compare commits

..

No commits in common. "07b2478a0e5a266810c59cabc1b4f35a4357349c" and "6924fc0ffe456ce1f23ac6c49d929b959d95d989" have entirely different histories.

24 changed files with 999 additions and 1737 deletions

View File

@ -90,47 +90,6 @@ 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 {
@ -154,8 +113,6 @@ 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(),
}
}
}
@ -346,9 +303,6 @@ 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 }

View File

@ -1,380 +1,205 @@
//! Custom cursor system
//!
//! 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.
//! 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.
use eframe::egui;
use egui::TextureHandle;
use lightningbeam_core::tool::Tool;
use std::collections::HashMap;
use std::sync::Arc;
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.
/// Custom cursor identifiers
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CustomCursor {
Tool(Tool),
/// Timeline: dragging the end of the loop region.
// Stage tool cursors
Select,
Draw,
Transform,
Rectangle,
Ellipse,
PaintBucket,
Eyedropper,
Line,
Polygon,
BezierEdit,
Text,
// Timeline cursors
LoopExtend,
}
impl CustomCursor {
/// Convert a Tool enum to the corresponding custom cursor
pub fn from_tool(tool: Tool) -> Self {
CustomCursor::Tool(tool)
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,
}
}
pub fn kind(&self) -> CursorKind {
use egui::CursorIcon as Sys;
/// 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),
}
}
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 {
// The OS knows these better than we do.
Tool::Select => CursorKind::System(Sys::Default),
Tool::Transform => CursorKind::System(Sys::Move),
// 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),
// 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),
/// 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"),
}
}
}
// ---------------------------------------------------------------------------
// 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)]
/// Cache of rasterized cursor textures (black fill + white outline version)
pub struct CursorCache {
images: HashMap<(CustomCursor, u32), Option<egui::CursorImage>>,
next_id: u64,
/// Black cursor for the main image
textures: HashMap<CustomCursor, TextureHandle>,
/// White cursor for the outline
outline_textures: HashMap<CustomCursor, TextureHandle>,
}
impl CursorCache {
pub fn new() -> Self {
Self::default()
Self {
textures: HashMap::new(),
outline_textures: HashMap::new(),
}
}
fn get_or_build(
&mut self,
ctx: &egui::Context,
cursor: CustomCursor,
ppp: f32,
) -> Option<egui::CursorImage> {
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;
let built = build_cursor_image(ctx, cursor.kind(), ppp, id);
self.images.insert(key, built.clone());
built
/// 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")
})
}
}
/// An alpha coverage mask being composed, in physical pixels.
struct Mask {
w: usize,
h: usize,
a: Vec<f32>,
}
const CURSOR_SIZE: u32 = 24;
const OUTLINE_OFFSET: f32 = 1.0;
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(
/// 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,
ctx: &egui::Context,
kind: CursorKind,
ppp: f32,
id: u64,
) -> Option<egui::CursorImage> {
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,
),
})
) -> Option<TextureHandle> {
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))
}
// --- 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)));
}
/// Hand the active cursor to the windowing system. Call at the end of the main update loop.
/// Render the custom cursor overlay. 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");
@ -384,24 +209,43 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
val
});
let Some(ActiveCustomCursor(cursor)) = cursor else { return };
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;
}
// 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;
}
// Hide the system cursor
ctx.set_cursor_icon(egui::CursorIcon::None);
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);
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);
}
// 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);
}
}
}

View File

@ -1153,6 +1153,7 @@ 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<NodePath, PaneInstance>, // Pane instances per path
menu_system: Option<MenuSystem>, // Native menu system for event checking
pending_view_action: Option<MenuAction>, // Pending view action (zoom, recenter) to be handled by hovered pane
@ -1371,9 +1372,9 @@ struct EditorApp {
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
/// 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,
@ -1430,10 +1431,6 @@ 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.
@ -1556,6 +1553,7 @@ 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,
@ -1669,8 +1667,8 @@ impl EditorApp {
test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry),
// Debug overlay (F3)
tablet: tablet::TabletInput::new(cc),
cursor_cache: custom_cursor::CursorCache::new(),
tablet: tablet::TabletInput::new(cc),
debug_overlay_visible: false,
debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
gpu_info,
@ -8414,6 +8412,7 @@ 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,

View File

@ -83,60 +83,3 @@ 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,
})
}

View File

@ -160,27 +160,27 @@ pub fn gradient_stop_editor(
delete_idx = Some(i);
}
// 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;
}
// 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;
}
});
}
// Apply drag to whichever stop selected_stop points at.

View File

@ -28,9 +28,14 @@ pub struct InfopanelPane {
tool_section_open: bool,
/// Whether the shape properties section is expanded
shape_section_open: 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<crate::tools::BrushKind, bool>,
/// Index of the selected paint brush preset (None = custom / unset)
selected_brush_preset: Option<usize>,
/// Whether the paint brush picker is expanded
brush_picker_expanded: bool,
/// Index of the selected eraser brush preset
selected_eraser_preset: Option<usize>,
/// Whether the eraser brush picker is expanded
eraser_picker_expanded: bool,
/// Cached preview textures, one per preset (populated lazily).
brush_preview_textures: Vec<egui::TextureHandle>,
/// Selected stop index for gradient editor in shape section.
@ -56,10 +61,15 @@ 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,
brush_picker_expanded: std::collections::HashMap::new(),
selected_brush_preset: None,
brush_picker_expanded: false,
selected_eraser_preset: default_eraser_idx,
eraser_picker_expanded: false,
brush_preview_textures: Vec::new(),
selected_shape_gradient_stop: None,
selected_tool_gradient_stop: None,
@ -241,7 +251,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::Eyedropper
| Tool::Warp | Tool::Liquify | Tool::Gradient
);
if !has_options {
@ -275,11 +285,12 @@ impl InfopanelPane {
return;
}
// Raster paint tool: tool-unique controls, then the shared brush controls
// (library, color, size/strength/hardness/spacing) that every brush tool gets.
// Raster paint tool: delegate to per-tool impl.
if let Some(def) = raster_tool_def {
def.render_ui(ui, shared.raster_settings);
self.render_raster_tool_options(ui, shared, def);
if def.show_brush_preset_picker() {
self.render_raster_tool_options(ui, shared, def.is_eraser());
}
}
match tool {
@ -333,14 +344,7 @@ 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| {
@ -599,66 +603,68 @@ impl InfopanelPane {
});
}
/// 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).
/// Render all options for a raster paint tool (brush picker + sliders).
/// `is_eraser` drives which shared state is read/written.
fn render_raster_tool_options(
&mut self,
ui: &mut Ui,
shared: &mut SharedPaneState,
def: &'static dyn crate::tools::RasterToolDef,
is_eraser: bool,
) {
let kind = def.brush_kind();
self.render_brush_preset_grid(ui, shared, kind);
self.render_brush_preset_grid(ui, shared, is_eraser);
ui.add_space(2.0);
if def.uses_color() {
render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg);
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");
});
}
let slot = shared.raster_settings.brush_mut(kind);
macro_rules! field {
($eraser:ident, $brush:ident) => {
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
}
}
ui.horizontal(|ui| {
ui.label("Size:");
ui.add(egui::Slider::new(&mut slot.radius, 1.0_f32..=500.0)
.logarithmic(true)
.suffix(" px"));
ui.add(egui::Slider::new(field!(eraser_radius, brush_radius), 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
});
ui.horizontal(|ui| {
ui.label(format!("{}:", def.strength_label()));
ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0)
ui.label("Opacity:");
ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 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 slot.hardness, 0.0_f32..=1.0)
ui.add(egui::Slider::new(field!(eraser_hardness, brush_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 slot.spacing, 0.01_f32..=20.0)
ui.add(egui::Slider::new(field!(eraser_spacing, brush_spacing), 0.01_f32..=1.0)
.logarithmic(true)
.custom_formatter(|v, _| format!("{:.2}", v)));
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
});
if slot.settings.elliptical_dab_ratio > 1.001 {
let bs = if is_eraser { &rs.active_eraser_settings } else { &rs.active_brush_settings };
if bs.elliptical_dab_ratio > 1.001 {
ui.horizontal(|ui| {
ui.label("Angle:");
ui.add(egui::Slider::new(&mut slot.angle_offset, -180.0_f32..=180.0)
ui.add(egui::Slider::new(&mut rs.brush_angle_offset, -180.0_f32..=180.0)
.suffix("°")
.custom_formatter(|v, _| format!("{:.0}°", v)));
});
}
}
/// 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,
) {
/// 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) {
let presets = bundled_brushes();
if presets.is_empty() { return; }
@ -684,8 +690,8 @@ impl InfopanelPane {
}
// Read picker state into locals to avoid multiple &mut self borrows.
let mut expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false);
let selected = shared.raster_settings.brush(kind).preset;
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 gap = 3.0;
let cols = 2usize;
@ -756,14 +762,25 @@ 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;
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;
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;
}
}
}
}
@ -772,9 +789,14 @@ impl InfopanelPane {
}
}
// 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);
// 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;
}
}
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
@ -1921,17 +1943,3 @@ 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");
});
}

View File

@ -189,6 +189,8 @@ 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<crate::menu::MenuAction>,
/// Tracks the priority of the best fallback pane for view actions
/// Lower number = higher priority. None = no fallback pane seen yet

View File

@ -5722,14 +5722,9 @@ impl StagePane {
screen_pos: egui::Pos2,
shared: &mut SharedPaneState,
) {
// On click, store the screen position and which swatch the sample lands in.
// On click, store the screen position and color mode for sampling
if self.rsp_clicked(response) {
let mode = if shared.raster_settings.eyedropper_use_fg {
super::ColorMode::Stroke
} else {
super::ColorMode::Fill
};
self.pending_eyedropper_sample = Some((screen_pos, mode));
self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode));
}
}
@ -6497,16 +6492,15 @@ 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.brush(crate::tools::BrushKind::Smudge).strength;
b.smudge_radius_log = shared.raster_settings.smudge_strength;
}
if matches!(blend_mode, RasterBlendMode::BlurSharpen) {
b.dabs_per_actual_radius = 0.0;
}
let color = if !def.uses_color() {
let color = if matches!(blend_mode, RasterBlendMode::Erase) {
[1.0f32, 1.0, 1.0, 1.0]
} else {
let c = if shared.raster_settings.brush(def.brush_kind()).use_fg {
let c = if shared.raster_settings.brush_use_fg {
*shared.stroke_color
} else {
*shared.fill_color
@ -6715,8 +6709,7 @@ 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.brush(crate::tools::BrushKind::Smudge).strength;
b.smudge_radius_log = shared.raster_settings.smudge_strength; // linear [0,1] 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.
@ -6725,14 +6718,10 @@ impl StagePane {
b
};
let color = if !def.uses_color() {
let color = if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::Erase) {
[1.0f32, 1.0, 1.0, 1.0]
} else {
let c = if shared.raster_settings.brush(def.brush_kind()).use_fg {
*shared.stroke_color
} else {
*shared.fill_color
};
let c = if shared.raster_settings.brush_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) }
@ -7447,7 +7436,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(bucket_color(shared));
let fill_color = ShapeColor::from_egui(*shared.fill_color);
let action = PaintBucketAction::new(
active_layer_id,
*shared.playback_time,
@ -7501,7 +7490,7 @@ impl StagePane {
return;
}
let fill_egui = bucket_color(shared);
let fill_egui = *shared.fill_color;
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;
@ -11600,11 +11589,7 @@ 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).
// 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 {
if !alt_held && !self.marquee_gesture_active {
use lightningbeam_core::tool::Tool;
// On a shape-tween in-between frame the active vector layer's geometry is an
@ -11767,21 +11752,9 @@ impl StagePane {
self.pan_offset.y += scroll_delta.y;
}
// 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() {
// Handle panning with Alt+Drag
if alt_held && response.dragged() {
// Alt+Click+Drag panning
if let Some(last_pos) = self.last_pan_pos {
if let Some(current_pos) = response.interact_pointer_pos() {
let delta = current_pos - last_pos;
@ -11791,7 +11764,7 @@ impl StagePane {
self.last_pan_pos = response.interact_pointer_pos();
self.is_panning = true;
} else {
if !response.dragged() && !middle_down {
if !response.dragged() {
self.is_panning = false;
self.last_pan_pos = None;
}
@ -12135,22 +12108,26 @@ 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);
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();
(r * expand, r * expand / ratio, angle)
// 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 slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint);
let bs = &slot.settings;
let bs = &shared.raster_settings.active_brush_settings;
let r = shared.raster_settings.brush_radius;
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 angle = (bs.elliptical_dab_angle + shared.raster_settings.brush_angle_offset).to_radians();
(r * expand, r * expand / ratio, angle)
};
let a = a_world * self.zoom; // major semi-axis in screen pixels
@ -12195,84 +12172,9 @@ impl StagePane {
impl PaneRenderer for StagePane {
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
// 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()
{
ui.horizontal(|ui| {
// Zoom to fit button
if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() {
self.zoom_to_fit(shared);
}
@ -12282,8 +12184,7 @@ 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
}
@ -13212,13 +13113,3 @@ 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
}
}

View File

@ -10,35 +10,8 @@ use eframe::egui;
use daw_backend::{Beats, ContentTime, Seconds};
use lightningbeam_core::clip::{ClipDuration, 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;
@ -2609,53 +2582,32 @@ impl TimelinePane {
let Some(layer_for_controls) = any_layer_for_controls else { continue; };
// 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.
// Layer controls: volume slider top-right, buttons below it
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 + 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),
egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0),
egui::vec2(slider_width, 20.0),
);
// 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(_));
// 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,
);
// 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 solo_button_rect = egui::Rect::from_min_size(
egui::pos2(lock_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);
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,
);
// 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
@ -2683,66 +2635,30 @@ impl TimelinePane {
let is_soloed = layer_for_controls.soloed();
let is_locked = layer_for_controls.locked();
// 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;
// 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
};
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 })
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)
.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).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 })
ui.add(button)
} else {
// Mute button for non-video layers
let mute_text = if is_muted { "🔇" } else { "🔊" };
let button = egui::Button::new(mute_text)
.fill(if is_muted {
theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100))
} else {
@ -2750,11 +2666,19 @@ impl TimelinePane {
})
.stroke(egui::Stroke::NONE);
ui.add(button)
.on_hover_text(if is_muted { "Unmute layer" } else { "Mute layer" })
}).inner;
}
}).inner;
if response.clicked() {
self.layer_control_clicked = true;
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 {
pending_actions.push(Box::new(
lightningbeam_core::actions::SetLayerPropertiesAction::new(
layer_id,
@ -2765,34 +2689,33 @@ impl TimelinePane {
}
// Solo button
// 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;
// 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;
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 button = icon_button(if is_locked { icons::LOCK } else { icons::LOCK_OPEN })
let lock_text = if is_locked { "🔒" } else { "🔓" };
let button = egui::Button::new(lock_text)
.fill(if is_locked {
theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100))
} else {
@ -2800,7 +2723,6 @@ 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() {
@ -2813,40 +2735,9 @@ 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
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| {
let 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 {
@ -2865,12 +2756,6 @@ 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;
@ -2924,11 +2809,13 @@ impl TimelinePane {
}
}
// Input gain slider for sampled audio layers. Audio layers have no opacity, so this
// takes the opacity tier.
// Input gain slider for sampled audio layers (below volume slider)
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 = opacity_slider_rect;
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 current_gain = audio_layer.layer.input_gain;
// Map gain (0.0-4.0) to slider (0.0-1.0): linear
@ -2956,8 +2843,8 @@ impl TimelinePane {
// Label
let label_rect = egui::Rect::from_min_size(
egui::pos2(gain_slider_rect.min.x - 26.0, gain_slider_rect.min.y),
egui::vec2(24.0, gain_slider_rect.height()),
egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0),
egui::vec2(24.0, 16.0),
);
ui.painter().text(
label_rect.center(),
@ -2969,8 +2856,6 @@ 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
@ -6248,14 +6133,8 @@ 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
};
@ -6307,29 +6186,20 @@ 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);
// 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 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 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 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 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
};
// 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);
// Restore original clip rect
ui.set_clip_rect(original_clip_rect);

View File

@ -29,48 +29,10 @@ impl PaneRenderer for ToolbarPane {
path: &NodePath,
shared: &mut SharedPaneState,
) {
// `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)),
);
let button_size = 60.0;
let button_padding = 8.0;
let button_spacing = 4.0;
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<LayerType> = shared.active_layer_id
.and_then(|id| shared.action_executor.document().get_layer(&id))
@ -90,273 +52,320 @@ impl 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));
ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING);
ui.add_space(BUTTON_PADDING);
// Add color pickers below the tool buttons
if show_colors {
y += button_spacing * 2.0; // Extra spacing
// 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;
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;
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.
// Two color swatches:
// Stroke/FG always on top, Fill/BG always on bottom.
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
if show_colors {
ui.add_space(BUTTON_SPACING * 2.0);
{
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,
);
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);
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;
}
});
}
y += color_button_size + button_spacing;
}
ui.add_space(BUTTON_PADDING);
// 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.painter().text(
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
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 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(
button_rect.center(),
egui::Align2::CENTER_CENTER,
glyph,
crate::mobile::icons::font(26.0),
shared.theme.text_color(
&["#toolbar", ".tool-button"],
ui.ctx(),
egui::Color32::from_gray(220),
),
);
} 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);
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,
),
egui::vec2(checker_size, checker_size),
).intersect(rect);
ui.painter().rect_filled(checker_rect, 0.0, checker_color);
}
}
// 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,
);
}

View File

@ -5,7 +5,7 @@
use std::collections::HashMap;
use eframe::egui;
use crate::config::{AppConfig, TabletButtonAction};
use crate::config::AppConfig;
use crate::keymap::{self, AppAction, KeymapManager};
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
use crate::theme::{Theme, ThemeMode};
@ -62,8 +62,6 @@ 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 {
@ -82,8 +80,6 @@ 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,
}
}
}
@ -104,8 +100,6 @@ 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,
}
}
}
@ -248,8 +242,6 @@ 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);
});
}
@ -635,41 +627,6 @@ 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)
@ -726,8 +683,6 @@ 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() {
@ -764,16 +719,10 @@ 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();

View File

@ -13,8 +13,6 @@ 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
// ---------------------------------------------------------------------------
@ -23,58 +21,6 @@ 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<TabletButtonAction> {
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.01.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))
@ -111,32 +57,10 @@ 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<Self> {
// 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]
@ -167,13 +91,9 @@ pub struct TabletInput {
/// On X11, clicks already come through winit via OS mouse emulation.
inject_buttons: bool,
/// Pending tool switch (eraser in/out, barrel-button override). Consumed by `EditorApp::update()`.
/// Pending tool switch (eraser in/out). Consumed by `EditorApp::update()`.
pub pending_tool_switch: Option<Tool>,
tool_before_eraser: Option<Tool>,
/// 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<Tool>,
/// One-shot sender used to hand the egui Context to the background thread
/// on the first poll() call so it can call request_repaint().
@ -205,8 +125,6 @@ impl TabletInput {
inject_buttons,
pending_tool_switch: None,
tool_before_eraser: None,
prev_buttons: 0,
tool_before_button: None,
repaint_sender,
backend,
}
@ -284,9 +202,6 @@ 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;
@ -352,8 +267,6 @@ 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.
@ -376,14 +289,6 @@ 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.
}
@ -413,39 +318,6 @@ 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;
@ -471,7 +343,7 @@ impl TabletInput {
#[cfg(target_os = "linux")]
mod wayland {
use super::{RawTabletEvent, TabletButton, TabletToolType};
use super::{RawTabletEvent, TabletToolType};
use eframe::egui;
use std::collections::HashMap;
use std::sync::mpsc;
@ -573,7 +445,6 @@ mod wayland {
pending_motion: bool,
pending_tip: Option<bool>,
pending_proximity: Option<bool>,
pending_buttons: Vec<(TabletButton, bool)>,
}
// -----------------------------------------------------------------------
@ -714,20 +585,7 @@ 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 {
@ -772,7 +630,7 @@ mod wayland {
#[cfg(target_os = "linux")]
mod x11 {
use super::{RawTabletEvent, TabletButton, TabletToolType};
use super::{RawTabletEvent, TabletToolType};
use std::sync::mpsc;
use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle};
@ -1022,44 +880,17 @@ mod x11 {
}
Event::XinputRawButtonPress(raw) => {
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);
}
_ => {}
}
if device_axes.contains_key(&raw.deviceid) && raw.detail == 1 {
let _ = tx.send(RawTabletEvent::TipDown);
let _ = tx.send(RawTabletEvent::Frame);
}
}
Event::XinputRawButtonRelease(raw) => {
if device_axes.contains_key(&raw.deviceid) {
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);
}
_ => {}
if raw.detail == 1 {
let _ = tx.send(RawTabletEvent::TipUp);
let _ = tx.send(RawTabletEvent::Frame);
}
// When all buttons released, synthesise proximity out.
in_proximity.remove(&raw.deviceid);

View File

@ -1,6 +1,6 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct BlurSharpenTool;
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
@ -8,11 +8,19 @@ 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_kind(&self) -> BrushKind { BrushKind::BlurSharpen }
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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0]
}
fn strength_label(&self) -> &'static str { "Strength" }
fn show_brush_preset_picker(&self) -> bool { false }
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() {
@ -22,11 +30,31 @@ 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)));
});
}
}

View File

@ -1,4 +1,4 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
@ -8,7 +8,15 @@ 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_kind(&self) -> BrushKind { BrushKind::CloneStamp }
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,
}
}
/// 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] }

View File

@ -1,6 +1,6 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct DodgeBurnTool;
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
@ -8,11 +8,19 @@ 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_kind(&self) -> BrushKind { BrushKind::DodgeBurn }
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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0]
}
fn strength_label(&self) -> &'static str { "Exposure" }
fn show_brush_preset_picker(&self) -> bool { false }
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() {
@ -22,5 +30,25 @@ 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)));
});
}
}

View File

@ -1,4 +1,5 @@
use super::{BrushKind, RasterToolDef};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct EraseTool;
@ -7,6 +8,16 @@ 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_kind(&self) -> BrushKind { BrushKind::Erase }
fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
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) {}
}

View File

@ -1,4 +1,4 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
@ -8,7 +8,15 @@ 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_kind(&self) -> BrushKind { BrushKind::HealingBrush }
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,
}
}
/// 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 }

View File

@ -2,17 +2,12 @@
///
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
/// 1. A new file in this directory implementing `RasterToolDef`.
/// 2. A `BrushKind` variant (if it paints dabs) and one entry in `raster_tool_def()` below.
/// 2. 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::{bundled_brushes, BrushSettings},
brush_settings::BrushSettings,
raster_layer::RasterBlendMode,
tool::Tool,
};
@ -27,86 +22,6 @@ 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<usize>,
/// 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)
// ---------------------------------------------------------------------------
@ -114,8 +29,25 @@ impl BrushSlot {
/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
pub struct RasterToolSettings {
/// Per-tool brush state, indexed by `BrushKind`.
brushes: [BrushSlot; 9],
// --- 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,
// --- Clone / Healing ---
/// World-space source point set by Alt+click.
pub clone_source: Option<egui::Vec2>,
@ -123,12 +55,24 @@ 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 (120)
pub blur_sharpen_kernel: f32,
/// 0 = blur, 1 = sharpen
@ -143,7 +87,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) ---
// --- Flood fill (Paint Bucket, raster) ---
/// Color-distance threshold (Euclidean RGBA, 0510). Pixels within this
/// distance of the comparison color are included in the fill.
pub fill_threshold: f32,
@ -152,11 +96,6 @@ 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,
@ -170,16 +109,10 @@ pub struct RasterToolSettings {
// --- Gradient ---
pub gradient: lightningbeam_core::gradient::ShapeGradient,
pub gradient_opacity: 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 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,
}
/// Brush mode for the Liquify tool.
@ -225,32 +158,43 @@ 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 {
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),
],
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,
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,
@ -259,8 +203,6 @@ 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,
@ -270,6 +212,7 @@ impl Default for RasterToolSettings {
liquify_strength: 0.5,
gradient: lightningbeam_core::gradient::ShapeGradient::default(),
gradient_opacity: 1.0,
brush_angle_offset: 0.0,
}
}
}
@ -286,21 +229,6 @@ 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
// ---------------------------------------------------------------------------
@ -308,34 +236,19 @@ pub fn default_brush_params(kind: BrushKind, s: &RasterToolSettings) -> BrushPar
pub trait RasterToolDef: Send + Sync {
fn blend_mode(&self) -> RasterBlendMode;
fn header_label(&self) -> &'static str;
/// Which brush slot this tool paints with.
fn brush_kind(&self) -> BrushKind;
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams;
/// 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
}
/// 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 }
/// 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 }
/// Whether Alt+click sets a source point for this tool.
fn uses_alt_click(&self) -> bool { false }
}

View File

@ -1,4 +1,5 @@
use super::{BrushKind, RasterToolDef};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
pub struct PaintTool;
@ -7,7 +8,17 @@ 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_kind(&self) -> BrushKind { BrushKind::Paint }
fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
fn uses_color(&self) -> bool { true }
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) {}
}

View File

@ -1,4 +1,4 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
@ -12,12 +12,18 @@ 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_kind(&self) -> BrushKind { BrushKind::PatternStamp }
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 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)
@ -38,5 +44,6 @@ 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);
}
}

View File

@ -1,5 +1,6 @@
use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings};
use lightningbeam_core::raster_layer::RasterBlendMode;
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SmudgeTool;
pub static SMUDGE: SmudgeTool = SmudgeTool;
@ -7,15 +8,37 @@ 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_kind(&self) -> BrushKind { BrushKind::Smudge }
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
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
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 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)));
});
}
}

View File

@ -1,6 +1,6 @@
use super::{BrushKind, RasterToolDef, RasterToolSettings};
use super::{BrushParams, RasterToolDef, RasterToolSettings};
use eframe::egui;
use lightningbeam_core::raster_layer::RasterBlendMode;
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
pub struct SpongeTool;
pub static SPONGE: SpongeTool = SpongeTool;
@ -8,11 +8,19 @@ 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_kind(&self) -> BrushKind { BrushKind::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 tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
[s.sponge_mode as f32, 0.0, 0.0, 0.0]
}
fn strength_label(&self) -> &'static str { "Flow" }
fn show_brush_preset_picker(&self) -> bool { false }
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
ui.horizontal(|ui| {
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
@ -22,5 +30,25 @@ 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)));
});
}
}

View File

@ -1,102 +0,0 @@
//! 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,
);
}

View File

@ -1,7 +1,6 @@
//! Reusable UI widgets for the editor
mod text_field;
pub mod color_swatch;
pub mod dropdown_list;
pub use text_field::ImeTextField;