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