Painting UX: unified color/brush tools, tablet buttons, layer opacity
Addresses a batch of issues found during a painting test. Tools - Paint bucket used the background color unconditionally. It now picks FG/BG like the brush does, via a shared Color: [FG][BG] row that every color-using tool renders (brush, bucket, eyedropper). - The eyedropper previously inferred its target swatch from active_color_mode, which was only ever set as a side effect of opening a color picker — so where a sample landed depended on invisible state. It now has an explicit toggle, and active_color_mode is gone. - Effect brushes (dodge/burn, sponge, blur, smudge, clone, heal, pattern) were stuck on a hardcoded Gaussian dab. Replaced ~20 flat per-tool fields with a BrushKind -> BrushSlot model: every dab-painting tool now owns its size/strength/hardness/spacing, its .myb preset, and its FG/BG choice, and gets the full brush library. base_settings already flowed into the dab engine independently of blend_mode, so this needed no core or WGSL changes. Cursors - Rebuilt around three kinds instead of "icon = cursor": System (real OS cursor for Select/Transform), Precise (glyph with a real tip, e.g. pencil/pipette), and Badge (crosshair marks the click point, glyph sits to its bottom-right) for icons with no focus point. - Cursors are now OS-composited via egui::CursorImage rather than painted into the scene, so they no longer lag the pointer. Glyphs are rasterized from egui's font atlas using epaint's own placement maths, so they land where painter.text put them. Drops the SVG cursor rasterization path. Tablet - The Wayland backend never handled zwp_tablet_tool_v2 Button events, so stylus barrel buttons were silently dropped. Both Wayland and X11 now emit them, with actions bound in Preferences (Pan/Eyedropper/Eraser). A pan-bound button suppresses the tool the way Alt+drag does, since the pen tip is still down while panning. Also adds middle-mouse panning. Timeline - Raster layers had no opacity control; the slider always drove volume, which is meaningless on them. The layer row is now three tiers, and each layer only shows controls that apply to it: Vector/Video get both volume and opacity (they can hold movie clips with audio), Raster/Text get opacity, Audio gets volume. Mute/solo are hidden where there's no audio. - Layer toggles use Lucide icons with tooltips, and gain a visibility (eye) toggle. LayerProperty::Visible already existed but was only reachable from the info panel. - Below 1.5x the layer-header width, the track area is dropped and the headers take the full pane. Panes - Color picker popups closed on any click (including on their own hue slider) and would not close on a drag — so drawing on the stage left one open. Shared color_swatch widget closes on a press outside instead. - Stage header gains undo/redo buttons for tablet use, and taller targets. - Toolbar is real egui layout (horizontal_wrapped) instead of absolute rect math, so it scrolls when the pane is too short. - Tools with no SVG icon use Lucide glyphs instead of a TODO placeholder.
This commit is contained in:
parent
8cde113797
commit
5e73f7f75d
|
|
@ -78,6 +78,47 @@ pub struct AppConfig {
|
||||||
/// Last-used audio-export "Album" tag, remembered so it prefills next time.
|
/// Last-used audio-export "Album" tag, remembered so it prefills next time.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_audio_album: String,
|
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 {
|
impl Default for AppConfig {
|
||||||
|
|
@ -100,6 +141,8 @@ impl Default for AppConfig {
|
||||||
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
|
waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(),
|
||||||
last_audio_artist: String::new(),
|
last_audio_artist: String::new(),
|
||||||
last_audio_album: String::new(),
|
last_audio_album: String::new(),
|
||||||
|
tablet_button_lower: defaults::tablet_button_lower(),
|
||||||
|
tablet_button_upper: defaults::tablet_button_upper(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +333,9 @@ impl AppConfig {
|
||||||
|
|
||||||
/// Default values for preferences (matches JS implementation)
|
/// Default values for preferences (matches JS implementation)
|
||||||
mod defaults {
|
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 bpm() -> u32 { 120 }
|
||||||
pub fn framerate() -> u32 { 24 }
|
pub fn framerate() -> u32 { 24 }
|
||||||
pub fn file_width() -> u32 { 800 }
|
pub fn file_width() -> u32 { 800 }
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,380 @@
|
||||||
//! Custom cursor system
|
//! Custom cursor system
|
||||||
//!
|
//!
|
||||||
//! Provides SVG-based custom cursors beyond egui's built-in system cursors.
|
//! Tool cursors are drawn from the bundled Lucide icon font. A tool falls into one of three
|
||||||
//! When a custom cursor is active, the system cursor is hidden and the SVG
|
//! kinds, because "draw the tool's icon at the pointer" only works for icons that actually have
|
||||||
//! cursor image is drawn at the pointer position.
|
//! 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 eframe::egui;
|
||||||
use egui::TextureHandle;
|
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::Tool;
|
||||||
use std::collections::HashMap;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum CustomCursor {
|
pub enum CustomCursor {
|
||||||
// Stage tool cursors
|
Tool(Tool),
|
||||||
Select,
|
/// Timeline: dragging the end of the loop region.
|
||||||
Draw,
|
|
||||||
Transform,
|
|
||||||
Rectangle,
|
|
||||||
Ellipse,
|
|
||||||
PaintBucket,
|
|
||||||
Eyedropper,
|
|
||||||
Line,
|
|
||||||
Polygon,
|
|
||||||
BezierEdit,
|
|
||||||
Text,
|
|
||||||
// Timeline cursors
|
|
||||||
LoopExtend,
|
LoopExtend,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CustomCursor {
|
impl CustomCursor {
|
||||||
/// Convert a Tool enum to the corresponding custom cursor
|
|
||||||
pub fn from_tool(tool: Tool) -> Self {
|
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 {
|
match tool {
|
||||||
Tool::Select => CustomCursor::Select,
|
// The OS knows these better than we do.
|
||||||
Tool::Draw => CustomCursor::Draw,
|
Tool::Select => CursorKind::System(Sys::Default),
|
||||||
Tool::Transform => CustomCursor::Transform,
|
Tool::Transform => CursorKind::System(Sys::Move),
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hotspot offset — the "click point" relative to the image top-left
|
// Glyphs with a real tip: the icon is the cursor.
|
||||||
pub fn hotspot(&self) -> egui::Vec2 {
|
Tool::Draw => precise(icons::BRUSH, TIP_BOTTOM_LEFT),
|
||||||
match self {
|
Tool::Pencil => precise(icons::PENCIL, TIP_BOTTOM_LEFT),
|
||||||
// Select cursor: pointer tip at top-left
|
Tool::Pen => precise(icons::PEN_TOOL, TIP_BOTTOM_LEFT),
|
||||||
CustomCursor::Select => egui::vec2(3.0, 1.0),
|
Tool::Eyedropper => precise(icons::PIPETTE, TIP_BOTTOM_LEFT),
|
||||||
// Drawing tools: tip at bottom-left
|
Tool::PaintBucket => precise(icons::PAINT_BUCKET, TIP_BOTTOM_LEFT),
|
||||||
CustomCursor::Draw => egui::vec2(1.0, 23.0),
|
Tool::Text => precise(icons::TEXT_CURSOR, TIP_CENTER),
|
||||||
// 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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the embedded SVG data for this cursor
|
// Shape tools: crosshair for precision, glyph to say which shape.
|
||||||
fn svg_data(&self) -> &'static [u8] {
|
Tool::Rectangle => badge(icons::SQUARE),
|
||||||
match self {
|
Tool::Ellipse => badge(icons::CIRCLE),
|
||||||
CustomCursor::Select => include_bytes!("../../../src/assets/select.svg"),
|
Tool::Line => badge(icons::MINUS),
|
||||||
CustomCursor::Draw => include_bytes!("../../../src/assets/draw.svg"),
|
Tool::Polygon => badge(icons::HEXAGON),
|
||||||
CustomCursor::Transform => include_bytes!("../../../src/assets/transform.svg"),
|
Tool::CustomShape => badge(icons::SHAPES),
|
||||||
CustomCursor::Rectangle => include_bytes!("../../../src/assets/rectangle.svg"),
|
|
||||||
CustomCursor::Ellipse => include_bytes!("../../../src/assets/ellipse.svg"),
|
// Selection tools.
|
||||||
CustomCursor::PaintBucket => include_bytes!("../../../src/assets/paint_bucket.svg"),
|
Tool::RegionSelect => badge(icons::SQUARE_DASHED),
|
||||||
CustomCursor::Eyedropper => include_bytes!("../../../src/assets/eyedropper.svg"),
|
Tool::SelectEllipse => badge(icons::CIRCLE_DASHED),
|
||||||
CustomCursor::Line => include_bytes!("../../../src/assets/line.svg"),
|
Tool::SelectLasso => badge(icons::LASSO_SELECT),
|
||||||
CustomCursor::Polygon => include_bytes!("../../../src/assets/polygon.svg"),
|
Tool::MagicWand => badge(icons::WAND_SPARKLES),
|
||||||
CustomCursor::BezierEdit => include_bytes!("../../../src/assets/bezier_edit.svg"),
|
Tool::QuickSelect => badge(icons::BRUSH),
|
||||||
CustomCursor::Text => include_bytes!("../../../src/assets/text.svg"),
|
Tool::Split => badge(icons::SCISSORS),
|
||||||
CustomCursor::LoopExtend => include_bytes!("../../../src/assets/arrow-counterclockwise.svg"),
|
|
||||||
|
// 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 {
|
pub struct CursorCache {
|
||||||
/// Black cursor for the main image
|
images: HashMap<(CustomCursor, u32), Option<egui::CursorImage>>,
|
||||||
textures: HashMap<CustomCursor, TextureHandle>,
|
next_id: u64,
|
||||||
/// White cursor for the outline
|
|
||||||
outline_textures: HashMap<CustomCursor, TextureHandle>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CursorCache {
|
impl CursorCache {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self::default()
|
||||||
textures: HashMap::new(),
|
|
||||||
outline_textures: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get or lazily load the black (fill) cursor texture
|
fn get_or_build(
|
||||||
pub fn get_or_load(&mut self, cursor: CustomCursor, ctx: &egui::Context) -> &TextureHandle {
|
&mut self,
|
||||||
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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const CURSOR_SIZE: u32 = 24;
|
|
||||||
const OUTLINE_OFFSET: f32 = 1.0;
|
|
||||||
|
|
||||||
/// Rasterize an SVG into an egui texture (same approach as main.rs rasterize_svg)
|
|
||||||
fn rasterize_cursor_svg(
|
|
||||||
svg_data: &[u8],
|
|
||||||
name: &str,
|
|
||||||
render_size: u32,
|
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
) -> Option<TextureHandle> {
|
cursor: CustomCursor,
|
||||||
let tree = resvg::usvg::Tree::from_data(svg_data, &resvg::usvg::Options::default()).ok()?;
|
ppp: f32,
|
||||||
let pixmap_size = tree.size().to_int_size();
|
) -> Option<egui::CursorImage> {
|
||||||
let scale_x = render_size as f32 / pixmap_size.width() as f32;
|
let key = (cursor, ppp.to_bits());
|
||||||
let scale_y = render_size as f32 / pixmap_size.height() as f32;
|
if let Some(cached) = self.images.get(&key) {
|
||||||
let mut pixmap = resvg::tiny_skia::Pixmap::new(render_size, render_size)?;
|
return cached.clone();
|
||||||
resvg::render(
|
}
|
||||||
&tree,
|
|
||||||
resvg::tiny_skia::Transform::from_scale(scale_x, scale_y),
|
let id = self.next_id;
|
||||||
&mut pixmap.as_mut(),
|
self.next_id += 1;
|
||||||
);
|
|
||||||
let rgba_data = pixmap.data().to_vec();
|
let built = build_cursor_image(ctx, cursor.kind(), ppp, id);
|
||||||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
self.images.insert(key, built.clone());
|
||||||
[render_size as usize, render_size as usize],
|
built
|
||||||
&rgba_data,
|
}
|
||||||
);
|
}
|
||||||
Some(ctx.load_texture(name, color_image, egui::TextureOptions::LINEAR))
|
|
||||||
|
/// An alpha coverage mask being composed, in physical pixels.
|
||||||
|
struct Mask {
|
||||||
|
w: usize,
|
||||||
|
h: usize,
|
||||||
|
a: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 ---
|
// --- Per-frame cursor slot using egui context data ---
|
||||||
|
|
||||||
/// Key for storing the active custom cursor in egui's per-frame data
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
struct ActiveCustomCursor(CustomCursor);
|
struct ActiveCustomCursor(CustomCursor);
|
||||||
|
|
||||||
/// Set the custom cursor for this frame. Call from any pane during rendering.
|
/// 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) {
|
pub fn set(ctx: &egui::Context, cursor: CustomCursor) {
|
||||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("active_custom_cursor"), ActiveCustomCursor(cursor)));
|
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) {
|
pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
|
||||||
// Take and remove the cursor so it doesn't persist to the next frame
|
// Take and remove the cursor so it doesn't persist to the next frame
|
||||||
let id = egui::Id::new("active_custom_cursor");
|
let id = egui::Id::new("active_custom_cursor");
|
||||||
|
|
@ -209,43 +384,24 @@ pub fn render_overlay(ctx: &egui::Context, cache: &mut CursorCache) {
|
||||||
val
|
val
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Some(ActiveCustomCursor(cursor)) = cursor {
|
let Some(ActiveCustomCursor(cursor)) = cursor else { return };
|
||||||
// If a system cursor was explicitly set (resize handles, text inputs, etc.),
|
|
||||||
// let it take priority over the custom cursor
|
// If a widget explicitly asked for a system cursor (resize handles, text inputs, ...), let it
|
||||||
let system_cursor = ctx.output(|o| o.cursor_icon);
|
// win — it knows something about the hover target that we don't.
|
||||||
if system_cursor != egui::CursorIcon::Default {
|
if ctx.output(|o| o.cursor_icon) != egui::CursorIcon::Default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the system cursor
|
match cursor.kind() {
|
||||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
CursorKind::System(icon) => ctx.set_cursor_icon(icon),
|
||||||
|
_ => {
|
||||||
if let Some(pos) = ctx.input(|i| i.pointer.latest_pos()) {
|
let ppp = ctx.pixels_per_point();
|
||||||
let hotspot = cursor.hotspot();
|
if let Some(image) = cache.get_or_build(ctx, cursor, ppp) {
|
||||||
let size = egui::vec2(CURSOR_SIZE as f32, CURSOR_SIZE as f32);
|
ctx.set_cursor_image(Some(image));
|
||||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
|
} else {
|
||||||
let painter = ctx.debug_painter();
|
// Rasterization failed — better a crosshair than an invisible cursor.
|
||||||
|
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||||
// Draw white outline: render white version offset in 8 directions
|
}
|
||||||
let outline_tex = cache.get_or_load_outline(cursor, ctx);
|
|
||||||
let outline_id = outline_tex.id();
|
|
||||||
for &(dx, dy) in &[
|
|
||||||
(-OUTLINE_OFFSET, 0.0), (OUTLINE_OFFSET, 0.0),
|
|
||||||
(0.0, -OUTLINE_OFFSET), (0.0, OUTLINE_OFFSET),
|
|
||||||
(-OUTLINE_OFFSET, -OUTLINE_OFFSET), (OUTLINE_OFFSET, -OUTLINE_OFFSET),
|
|
||||||
(-OUTLINE_OFFSET, OUTLINE_OFFSET), (OUTLINE_OFFSET, OUTLINE_OFFSET),
|
|
||||||
] {
|
|
||||||
let offset_rect = egui::Rect::from_min_size(
|
|
||||||
pos - hotspot + egui::vec2(dx, dy),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
painter.image(outline_id, offset_rect, uv, egui::Color32::WHITE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw black fill on top
|
|
||||||
let fill_tex = cache.get_or_load(cursor, ctx);
|
|
||||||
let cursor_rect = egui::Rect::from_min_size(pos - hotspot, size);
|
|
||||||
painter.image(fill_tex.id(), cursor_rect, uv, egui::Color32::WHITE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1136,7 +1136,6 @@ struct EditorApp {
|
||||||
selected_tool: Tool, // Currently selected drawing tool
|
selected_tool: Tool, // Currently selected drawing tool
|
||||||
fill_color: egui::Color32, // Fill color for drawing
|
fill_color: egui::Color32, // Fill color for drawing
|
||||||
stroke_color: egui::Color32, // Stroke 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
|
pane_instances: HashMap<NodePath, PaneInstance>, // Pane instances per path
|
||||||
menu_system: Option<MenuSystem>, // Native menu system for event checking
|
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
|
pending_view_action: Option<MenuAction>, // Pending view action (zoom, recenter) to be handled by hovered pane
|
||||||
|
|
@ -1351,9 +1350,9 @@ struct EditorApp {
|
||||||
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
|
effect_thumbnail_generator: Option<EffectThumbnailGenerator>,
|
||||||
|
|
||||||
/// Custom cursor cache for SVG cursors
|
/// Custom cursor cache for SVG cursors
|
||||||
cursor_cache: custom_cursor::CursorCache,
|
|
||||||
/// Cross-platform graphics tablet (pen/stylus) input state
|
/// Cross-platform graphics tablet (pen/stylus) input state
|
||||||
tablet: tablet::TabletInput,
|
tablet: tablet::TabletInput,
|
||||||
|
cursor_cache: custom_cursor::CursorCache,
|
||||||
/// Debug test mode (F5) — input recording, panic capture & visual replay
|
/// Debug test mode (F5) — input recording, panic capture & visual replay
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
test_mode: test_mode::TestModeState,
|
test_mode: test_mode::TestModeState,
|
||||||
|
|
@ -1410,6 +1409,10 @@ impl EditorApp {
|
||||||
|
|
||||||
// Load application config
|
// Load application config
|
||||||
let mut config = AppConfig::load();
|
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
|
// 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
|
// 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.
|
// Recent or get auto-reopened below.
|
||||||
|
|
@ -1532,7 +1535,6 @@ impl EditorApp {
|
||||||
selected_tool: Tool::Select, // Default tool
|
selected_tool: Tool::Select, // Default tool
|
||||||
fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill
|
fill_color: egui::Color32::from_rgb(100, 100, 255), // Default blue fill
|
||||||
stroke_color: egui::Color32::from_rgb(0, 0, 0), // Default black stroke
|
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
|
pane_instances: HashMap::new(), // Initialize empty, panes created on-demand
|
||||||
menu_system,
|
menu_system,
|
||||||
pending_view_action: None,
|
pending_view_action: None,
|
||||||
|
|
@ -1645,8 +1647,8 @@ impl EditorApp {
|
||||||
test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry),
|
test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying, pending_geometry),
|
||||||
|
|
||||||
// Debug overlay (F3)
|
// Debug overlay (F3)
|
||||||
cursor_cache: custom_cursor::CursorCache::new(),
|
|
||||||
tablet: tablet::TabletInput::new(cc),
|
tablet: tablet::TabletInput::new(cc),
|
||||||
|
cursor_cache: custom_cursor::CursorCache::new(),
|
||||||
debug_overlay_visible: false,
|
debug_overlay_visible: false,
|
||||||
debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
|
debug_stats_collector: debug_overlay::DebugStatsCollector::new(),
|
||||||
gpu_info,
|
gpu_info,
|
||||||
|
|
@ -7965,7 +7967,6 @@ impl EditorApp {
|
||||||
selected_tool: &mut self.selected_tool,
|
selected_tool: &mut self.selected_tool,
|
||||||
fill_color: &mut self.fill_color,
|
fill_color: &mut self.fill_color,
|
||||||
stroke_color: &mut self.stroke_color,
|
stroke_color: &mut self.stroke_color,
|
||||||
active_color_mode: &mut self.active_color_mode,
|
|
||||||
pending_view_action: &mut self.pending_view_action,
|
pending_view_action: &mut self.pending_view_action,
|
||||||
fallback_pane_priority: &mut scratch.fallback_pane_priority,
|
fallback_pane_priority: &mut scratch.fallback_pane_priority,
|
||||||
pending_handlers: &mut scratch.pending_handlers,
|
pending_handlers: &mut scratch.pending_handlers,
|
||||||
|
|
|
||||||
|
|
@ -82,3 +82,60 @@ pub const FILE_PLUS: &str = "\u{e0c9}";
|
||||||
pub const COPY: &str = "\u{e09e}";
|
pub const COPY: &str = "\u{e09e}";
|
||||||
pub const LAYERS: &str = "\u{e529}";
|
pub const LAYERS: &str = "\u{e529}";
|
||||||
pub const ELLIPSIS: &str = "\u{e0b6}";
|
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);
|
delete_idx = Some(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color picker popup — opens on click, closes on click-outside.
|
// Color picker popup — opens on click, closes on a press outside it.
|
||||||
egui::containers::Popup::from_toggle_button_response(&resp)
|
let stop_color = gradient.stops[i].color;
|
||||||
.show(|ui| {
|
|
||||||
ui.spacing_mut().slider_width = 200.0;
|
|
||||||
let stop = &mut gradient.stops[i];
|
|
||||||
let mut c32 = Color32::from_rgba_unmultiplied(
|
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(
|
if crate::widgets::color_swatch::color_picker_popup(
|
||||||
ui, &mut c32, egui::color_picker::Alpha::OnlyBlend,
|
ui,
|
||||||
|
resp.id.with("stop_color_popup"),
|
||||||
|
&resp,
|
||||||
|
&mut c32,
|
||||||
|
resp.rect,
|
||||||
) {
|
) {
|
||||||
// Color32 stores premultiplied RGB; unmultiply before storing
|
// Color32 stores premultiplied RGB; unmultiply before storing as straight-alpha
|
||||||
// as straight-alpha ShapeColor to avoid darkening on round-trip.
|
// ShapeColor to avoid darkening on round-trip.
|
||||||
let [pr, pg, pb, a] = c32.to_array();
|
let [pr, pg, pb, a] = c32.to_array();
|
||||||
let unpm = |c: u8| -> u8 {
|
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 }
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply drag to whichever stop selected_stop points at.
|
// Apply drag to whichever stop selected_stop points at.
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,9 @@ pub struct InfopanelPane {
|
||||||
tool_section_open: bool,
|
tool_section_open: bool,
|
||||||
/// Whether the shape properties section is expanded
|
/// Whether the shape properties section is expanded
|
||||||
shape_section_open: bool,
|
shape_section_open: bool,
|
||||||
/// Index of the selected paint brush preset (None = custom / unset)
|
/// Whether each tool's brush picker is expanded. The *selected* preset lives on the tool's
|
||||||
selected_brush_preset: Option<usize>,
|
/// `BrushSlot`, since it's part of the document-independent tool state, not panel chrome.
|
||||||
/// Whether the paint brush picker is expanded
|
brush_picker_expanded: std::collections::HashMap<crate::tools::BrushKind, bool>,
|
||||||
brush_picker_expanded: bool,
|
|
||||||
/// Index of the selected eraser brush preset
|
|
||||||
selected_eraser_preset: Option<usize>,
|
|
||||||
/// Whether the eraser brush picker is expanded
|
|
||||||
eraser_picker_expanded: bool,
|
|
||||||
/// Cached preview textures, one per preset (populated lazily).
|
/// Cached preview textures, one per preset (populated lazily).
|
||||||
brush_preview_textures: Vec<egui::TextureHandle>,
|
brush_preview_textures: Vec<egui::TextureHandle>,
|
||||||
/// Selected stop index for gradient editor in shape section.
|
/// 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
|
// 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.
|
// is ready without a hitch by the time the user opens it.
|
||||||
lightningbeam_core::fonts::start_preload();
|
lightningbeam_core::fonts::start_preload();
|
||||||
let presets = bundled_brushes();
|
|
||||||
let default_eraser_idx = presets.iter().position(|p| p.name == "Brush");
|
|
||||||
Self {
|
Self {
|
||||||
tool_section_open: true,
|
tool_section_open: true,
|
||||||
shape_section_open: true,
|
shape_section_open: true,
|
||||||
selected_brush_preset: None,
|
brush_picker_expanded: std::collections::HashMap::new(),
|
||||||
brush_picker_expanded: false,
|
|
||||||
selected_eraser_preset: default_eraser_idx,
|
|
||||||
eraser_picker_expanded: false,
|
|
||||||
brush_preview_textures: Vec::new(),
|
brush_preview_textures: Vec::new(),
|
||||||
selected_shape_gradient_stop: None,
|
selected_shape_gradient_stop: None,
|
||||||
selected_tool_gradient_stop: None,
|
selected_tool_gradient_stop: None,
|
||||||
|
|
@ -251,7 +241,7 @@ impl InfopanelPane {
|
||||||
|| is_raster_select || is_raster_shape || matches!(
|
|| is_raster_select || is_raster_shape || matches!(
|
||||||
tool,
|
tool,
|
||||||
Tool::PaintBucket | Tool::RegionSelect | Tool::MagicWand | Tool::QuickSelect
|
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 {
|
if !has_options {
|
||||||
|
|
@ -285,12 +275,11 @@ impl InfopanelPane {
|
||||||
return;
|
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 {
|
if let Some(def) = raster_tool_def {
|
||||||
def.render_ui(ui, shared.raster_settings);
|
def.render_ui(ui, shared.raster_settings);
|
||||||
if def.show_brush_preset_picker() {
|
self.render_raster_tool_options(ui, shared, def);
|
||||||
self.render_raster_tool_options(ui, shared, def.is_eraser());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match tool {
|
match tool {
|
||||||
|
|
@ -344,7 +333,14 @@ impl InfopanelPane {
|
||||||
ui.checkbox(shared.fill_enabled, "Fill Shape");
|
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 => {
|
Tool::PaintBucket => {
|
||||||
|
render_color_slot_row(ui, &mut shared.raster_settings.fill_use_fg);
|
||||||
|
|
||||||
if active_is_raster {
|
if active_is_raster {
|
||||||
use crate::tools::FillThresholdMode;
|
use crate::tools::FillThresholdMode;
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -603,68 +599,66 @@ impl InfopanelPane {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render all options for a raster paint tool (brush picker + sliders).
|
/// Render the shared controls every dab-painting tool has: the brush library, the FG/BG
|
||||||
/// `is_eraser` drives which shared state is read/written.
|
/// 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(
|
fn render_raster_tool_options(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
shared: &mut SharedPaneState,
|
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);
|
ui.add_space(2.0);
|
||||||
|
|
||||||
let rs = &mut shared.raster_settings;
|
if def.uses_color() {
|
||||||
|
render_color_slot_row(ui, &mut shared.raster_settings.brush_mut(kind).use_fg);
|
||||||
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");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! field {
|
let slot = shared.raster_settings.brush_mut(kind);
|
||||||
($eraser:ident, $brush:ident) => {
|
|
||||||
if is_eraser { &mut rs.$eraser } else { &mut rs.$brush }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Size:");
|
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.horizontal(|ui| {
|
||||||
ui.label("Opacity:");
|
ui.label(format!("{}:", def.strength_label()));
|
||||||
ui.add(egui::Slider::new(field!(eraser_opacity, brush_opacity), 0.0_f32..=1.0)
|
ui.add(egui::Slider::new(&mut slot.strength, 0.0_f32..=1.0)
|
||||||
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Hardness:");
|
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)));
|
.custom_formatter(|v, _| format!("{:.0}%", v * 100.0)));
|
||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Spacing:");
|
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)
|
.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 slot.settings.elliptical_dab_ratio > 1.001 {
|
||||||
if bs.elliptical_dab_ratio > 1.001 {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Angle:");
|
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("°")
|
.suffix("°")
|
||||||
.custom_formatter(|v, _| format!("{:.0}°", v)));
|
.custom_formatter(|v, _| format!("{:.0}°", v)));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the brush preset thumbnail grid (collapsible).
|
/// Render the brush preset thumbnail grid (collapsible) for one tool's brush slot.
|
||||||
/// `is_eraser` drives which picker state and which shared settings are updated.
|
fn render_brush_preset_grid(
|
||||||
fn render_brush_preset_grid(&mut self, ui: &mut Ui, shared: &mut SharedPaneState, is_eraser: bool) {
|
&mut self,
|
||||||
|
ui: &mut Ui,
|
||||||
|
shared: &mut SharedPaneState,
|
||||||
|
kind: crate::tools::BrushKind,
|
||||||
|
) {
|
||||||
let presets = bundled_brushes();
|
let presets = bundled_brushes();
|
||||||
if presets.is_empty() { return; }
|
if presets.is_empty() { return; }
|
||||||
|
|
||||||
|
|
@ -690,8 +684,8 @@ impl InfopanelPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read picker state into locals to avoid multiple &mut self borrows.
|
// 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 expanded = self.brush_picker_expanded.get(&kind).copied().unwrap_or(false);
|
||||||
let mut selected = if is_eraser { self.selected_eraser_preset } else { self.selected_brush_preset };
|
let selected = shared.raster_settings.brush(kind).preset;
|
||||||
|
|
||||||
let gap = 3.0;
|
let gap = 3.0;
|
||||||
let cols = 2usize;
|
let cols = 2usize;
|
||||||
|
|
@ -762,41 +756,25 @@ impl InfopanelPane {
|
||||||
egui::FontId::proportional(9.5),
|
egui::FontId::proportional(9.5),
|
||||||
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
|
if is_sel { egui::Color32::from_rgb(140, 190, 255) } else { egui::Color32::from_gray(160) });
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
selected = Some(idx);
|
|
||||||
expanded = false;
|
expanded = false;
|
||||||
let s = &preset.settings;
|
shared.raster_settings.brush_mut(kind).apply_preset(idx, &preset.settings);
|
||||||
let rs = &mut shared.raster_settings;
|
|
||||||
if is_eraser {
|
|
||||||
rs.eraser_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.eraser_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_eraser_settings = s.clone();
|
|
||||||
} else {
|
|
||||||
rs.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
|
||||||
rs.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
|
||||||
rs.brush_spacing = s.dabs_per_radius;
|
|
||||||
rs.active_brush_settings = s.clone();
|
|
||||||
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
// If the user was on a preset-backed tool (Pencil/Pen/Airbrush)
|
||||||
// and manually picked a different brush, revert to the generic tool.
|
// 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;
|
*shared.selected_tool = Tool::Draw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
ui.add_space(gap);
|
ui.add_space(gap);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write back picker state.
|
// The selected preset lives on the slot itself (written by apply_preset above); only the
|
||||||
if is_eraser {
|
// expand/collapse state is panel-local.
|
||||||
self.eraser_picker_expanded = expanded;
|
self.brush_picker_expanded.insert(kind, expanded);
|
||||||
self.selected_eraser_preset = selected;
|
|
||||||
} else {
|
|
||||||
self.brush_picker_expanded = expanded;
|
|
||||||
self.selected_brush_preset = selected;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
// Transform section: deferred to Phase 2 (DCEL elements don't have instance transforms)
|
||||||
|
|
@ -1933,3 +1911,17 @@ impl PaneRenderer for InfopanelPane {
|
||||||
"Info Panel"
|
"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 selected_tool: &'a mut Tool,
|
||||||
pub fill_color: &'a mut egui::Color32,
|
pub fill_color: &'a mut egui::Color32,
|
||||||
pub stroke_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>,
|
pub pending_view_action: &'a mut Option<crate::menu::MenuAction>,
|
||||||
/// Tracks the priority of the best fallback pane for view actions
|
/// Tracks the priority of the best fallback pane for view actions
|
||||||
/// Lower number = higher priority. None = no fallback pane seen yet
|
/// Lower number = higher priority. None = no fallback pane seen yet
|
||||||
|
|
|
||||||
|
|
@ -5715,9 +5715,14 @@ impl StagePane {
|
||||||
screen_pos: egui::Pos2,
|
screen_pos: egui::Pos2,
|
||||||
shared: &mut SharedPaneState,
|
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) {
|
if self.rsp_clicked(response) {
|
||||||
self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode));
|
let mode = if shared.raster_settings.eyedropper_use_fg {
|
||||||
|
super::ColorMode::Stroke
|
||||||
|
} else {
|
||||||
|
super::ColorMode::Fill
|
||||||
|
};
|
||||||
|
self.pending_eyedropper_sample = Some((screen_pos, mode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -6485,15 +6490,16 @@ impl StagePane {
|
||||||
b.dabs_per_radius = spacing;
|
b.dabs_per_radius = spacing;
|
||||||
if matches!(blend_mode, RasterBlendMode::Smudge) {
|
if matches!(blend_mode, RasterBlendMode::Smudge) {
|
||||||
b.dabs_per_actual_radius = 0.0;
|
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) {
|
if matches!(blend_mode, RasterBlendMode::BlurSharpen) {
|
||||||
b.dabs_per_actual_radius = 0.0;
|
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]
|
[1.0f32, 1.0, 1.0, 1.0]
|
||||||
} else {
|
} 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
|
*shared.stroke_color
|
||||||
} else {
|
} else {
|
||||||
*shared.fill_color
|
*shared.fill_color
|
||||||
|
|
@ -6702,7 +6708,8 @@ impl StagePane {
|
||||||
b.dabs_per_actual_radius = 0.0;
|
b.dabs_per_actual_radius = 0.0;
|
||||||
// strength controls how far behind the stroke to sample (smudge_dist multiplier).
|
// 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.
|
// 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) {
|
if matches!(blend_mode, lightningbeam_core::raster_layer::RasterBlendMode::BlurSharpen) {
|
||||||
// Zero dabs_per_actual_radius so the spacing slider is the sole density control.
|
// Zero dabs_per_actual_radius so the spacing slider is the sole density control.
|
||||||
|
|
@ -6711,10 +6718,14 @@ impl StagePane {
|
||||||
b
|
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]
|
[1.0f32, 1.0, 1.0, 1.0]
|
||||||
} else {
|
} 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 s2l = |v: u8| -> f32 {
|
||||||
let f = v as f32 / 255.0;
|
let f = v as f32 / 255.0;
|
||||||
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
|
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
|
||||||
|
|
@ -7429,7 +7440,7 @@ impl StagePane {
|
||||||
use lightningbeam_core::actions::PaintBucketAction;
|
use lightningbeam_core::actions::PaintBucketAction;
|
||||||
use vello::kurbo::Point;
|
use vello::kurbo::Point;
|
||||||
let click_point = Point::new(world_pos.x as f64, world_pos.y as f64);
|
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(
|
let action = PaintBucketAction::new(
|
||||||
active_layer_id,
|
active_layer_id,
|
||||||
*shared.playback_time,
|
*shared.playback_time,
|
||||||
|
|
@ -7483,7 +7494,7 @@ impl StagePane {
|
||||||
return;
|
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 fill_color = [fill_egui.r(), fill_egui.g(), fill_egui.b(), fill_egui.a()];
|
||||||
let threshold = shared.raster_settings.fill_threshold;
|
let threshold = shared.raster_settings.fill_threshold;
|
||||||
let softness = shared.raster_settings.fill_softness;
|
let softness = shared.raster_settings.fill_softness;
|
||||||
|
|
@ -11580,7 +11591,11 @@ impl StagePane {
|
||||||
|
|
||||||
// Handle tool input (only if not using Alt modifier for panning, and not while a
|
// 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).
|
// 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;
|
use lightningbeam_core::tool::Tool;
|
||||||
|
|
||||||
// On a shape-tween in-between frame the active vector layer's geometry is an
|
// On a shape-tween in-between frame the active vector layer's geometry is an
|
||||||
|
|
@ -11743,9 +11758,21 @@ impl StagePane {
|
||||||
self.pan_offset.y += scroll_delta.y;
|
self.pan_offset.y += scroll_delta.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle panning with Alt+Drag
|
// Middle-mouse drag pans, independent of the stage's own drag sense.
|
||||||
if alt_held && response.dragged() {
|
let middle_down = ui.input(|i| i.pointer.middle_down());
|
||||||
// Alt+Click+Drag panning
|
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(last_pos) = self.last_pan_pos {
|
||||||
if let Some(current_pos) = response.interact_pointer_pos() {
|
if let Some(current_pos) = response.interact_pointer_pos() {
|
||||||
let delta = current_pos - last_pos;
|
let delta = current_pos - last_pos;
|
||||||
|
|
@ -11755,7 +11782,7 @@ impl StagePane {
|
||||||
self.last_pan_pos = response.interact_pointer_pos();
|
self.last_pan_pos = response.interact_pointer_pos();
|
||||||
self.is_panning = true;
|
self.is_panning = true;
|
||||||
} else {
|
} else {
|
||||||
if !response.dragged() {
|
if !response.dragged() && !middle_down {
|
||||||
self.is_panning = false;
|
self.is_panning = false;
|
||||||
self.last_pan_pos = None;
|
self.last_pan_pos = None;
|
||||||
}
|
}
|
||||||
|
|
@ -12099,26 +12126,22 @@ impl StagePane {
|
||||||
let r = shared.raster_settings.quick_select_radius;
|
let r = shared.raster_settings.quick_select_radius;
|
||||||
(r, r, 0.0_f32)
|
(r, r, 0.0_f32)
|
||||||
} else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) {
|
} else if let Some(def) = crate::tools::raster_tool_def(shared.selected_tool) {
|
||||||
|
// Every brush tool can carry an elliptical library brush, so shape the cursor from
|
||||||
|
// whichever slot the active tool paints with.
|
||||||
|
let slot = shared.raster_settings.brush(def.brush_kind());
|
||||||
let r = def.cursor_radius(shared.raster_settings);
|
let r = def.cursor_radius(shared.raster_settings);
|
||||||
// For the standard paint brush, also account for elliptical shape.
|
let bs = &slot.settings;
|
||||||
if matches!(*shared.selected_tool,
|
|
||||||
Tool::Draw | Tool::Pencil | Tool::Pen | Tool::Airbrush)
|
|
||||||
{
|
|
||||||
let bs = &shared.raster_settings.active_brush_settings;
|
|
||||||
let ratio = bs.elliptical_dab_ratio.max(1.0);
|
let ratio = bs.elliptical_dab_ratio.max(1.0);
|
||||||
let expand = 1.0 + bs.offset_by_random;
|
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)
|
(r * expand, r * expand / ratio, angle)
|
||||||
} else {
|
} else {
|
||||||
(r, r, 0.0_f32)
|
let slot = shared.raster_settings.brush(crate::tools::BrushKind::Paint);
|
||||||
}
|
let bs = &slot.settings;
|
||||||
} else {
|
|
||||||
let bs = &shared.raster_settings.active_brush_settings;
|
|
||||||
let r = shared.raster_settings.brush_radius;
|
|
||||||
let ratio = bs.elliptical_dab_ratio.max(1.0);
|
let ratio = bs.elliptical_dab_ratio.max(1.0);
|
||||||
let expand = 1.0 + bs.offset_by_random;
|
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)
|
(slot.radius * expand, slot.radius * expand / ratio, angle)
|
||||||
};
|
};
|
||||||
|
|
||||||
let a = a_world * self.zoom; // major semi-axis in screen pixels
|
let a = a_world * self.zoom; // major semi-axis in screen pixels
|
||||||
|
|
@ -12163,9 +12186,84 @@ impl StagePane {
|
||||||
|
|
||||||
impl PaneRenderer for StagePane {
|
impl PaneRenderer for StagePane {
|
||||||
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
||||||
ui.horizontal(|ui| {
|
// Fill most of the 40px header so the buttons are a comfortable tablet-sized target
|
||||||
// Zoom to fit button
|
// rather than the ~20px egui default.
|
||||||
if ui.button("⊡ Fit").on_hover_text("Zoom to fit canvas in view").clicked() {
|
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);
|
self.zoom_to_fit(shared);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12175,7 +12273,8 @@ impl PaneRenderer for StagePane {
|
||||||
let text_style = shared.theme.style(".text-primary", ui.ctx());
|
let text_style = shared.theme.style(".text-primary", ui.ctx());
|
||||||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
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));
|
ui.colored_label(text_color, format!("Zoom: {:.0}%", self.zoom * 100.0));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13104,3 +13203,13 @@ impl PaneRenderer for StagePane {
|
||||||
"Stage"
|
"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, Seconds};
|
use daw_backend::{Beats, Seconds};
|
||||||
use lightningbeam_core::clip::ClipInstance;
|
use lightningbeam_core::clip::ClipInstance;
|
||||||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
||||||
|
use crate::mobile::icons;
|
||||||
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
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 RULER_HEIGHT: f32 = 30.0;
|
||||||
const LAYER_HEIGHT: f32 = 60.0;
|
const LAYER_HEIGHT: f32 = 60.0;
|
||||||
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
||||||
|
|
@ -2363,32 +2390,53 @@ impl TimelinePane {
|
||||||
|
|
||||||
let Some(layer_for_controls) = any_layer_for_controls else { continue; };
|
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 controls_right = header_rect.max.x - 8.0;
|
||||||
let button_size = egui::vec2(20.0, 20.0);
|
let button_size = egui::vec2(20.0, 20.0);
|
||||||
let slider_width = 60.0;
|
let slider_width = 60.0;
|
||||||
|
let slider_height = 14.0;
|
||||||
|
|
||||||
let volume_slider_rect = egui::Rect::from_min_size(
|
let volume_slider_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0),
|
egui::pos2(controls_right - slider_width, header_rect.min.y + 2.0),
|
||||||
egui::vec2(slider_width, 20.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
|
// Which controls apply to this layer. Vector and Video layers can hold movie clips
|
||||||
let buttons_top = volume_slider_rect.max.y + 4.0;
|
// with audio *and* draw pixels, so they get both.
|
||||||
let lock_button_rect = egui::Rect::from_min_size(
|
let (has_volume, has_opacity) = match layer_for_controls {
|
||||||
egui::pos2(controls_right - button_size.x, buttons_top),
|
lightningbeam_core::layer::AnyLayer::Raster(_)
|
||||||
button_size,
|
| 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(
|
// Buttons are laid out right-to-left, and a layer only gets the ones that mean
|
||||||
egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top),
|
// something for it: [eye] [mute | camera] [solo] [lock].
|
||||||
button_size,
|
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(
|
let lock_button_rect = next_button_rect();
|
||||||
egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top),
|
let solo_button_rect = has_volume.then(&mut next_button_rect);
|
||||||
button_size,
|
// 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
|
// 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
|
// Check if there's a Volume automation lane; use it to drive the slider
|
||||||
|
|
@ -2416,30 +2464,66 @@ impl TimelinePane {
|
||||||
let is_soloed = layer_for_controls.soloed();
|
let is_soloed = layer_for_controls.soloed();
|
||||||
let is_locked = layer_for_controls.locked();
|
let is_locked = layer_for_controls.locked();
|
||||||
|
|
||||||
// Mute button — or camera toggle for video layers
|
// Visibility toggle — on any layer that draws something.
|
||||||
let is_video_layer = matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_));
|
if let Some(rect) = visibility_button_rect {
|
||||||
let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer_for_controls {
|
let is_visible = layer_for_controls.visible();
|
||||||
v.camera_enabled
|
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 {
|
} 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 response.clicked() {
|
||||||
if is_video_layer {
|
self.layer_control_clicked = true;
|
||||||
// Camera toggle for video layers
|
pending_actions.push(Box::new(
|
||||||
let cam_text = if camera_enabled { "📹" } else { "📷" };
|
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||||||
let button = egui::Button::new(cam_text)
|
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 {
|
.fill(if camera_enabled {
|
||||||
theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
||||||
} else {
|
} else {
|
||||||
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
||||||
})
|
})
|
||||||
.stroke(egui::Stroke::NONE);
|
.stroke(egui::Stroke::NONE);
|
||||||
ui.add(button)
|
ui.add(button).on_hover_text(if camera_enabled {
|
||||||
|
"Disable camera preview"
|
||||||
} else {
|
} else {
|
||||||
// Mute button for non-video layers
|
"Enable camera preview"
|
||||||
let mute_text = if is_muted { "🔇" } else { "🔊" };
|
})
|
||||||
let button = egui::Button::new(mute_text)
|
}).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 {
|
.fill(if is_muted {
|
||||||
theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100))
|
theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2447,19 +2531,11 @@ impl TimelinePane {
|
||||||
})
|
})
|
||||||
.stroke(egui::Stroke::NONE);
|
.stroke(egui::Stroke::NONE);
|
||||||
ui.add(button)
|
ui.add(button)
|
||||||
}
|
.on_hover_text(if is_muted { "Unmute layer" } else { "Mute layer" })
|
||||||
}).inner;
|
}).inner;
|
||||||
|
|
||||||
if first_btn_response.clicked() {
|
if response.clicked() {
|
||||||
self.layer_control_clicked = true;
|
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(
|
pending_actions.push(Box::new(
|
||||||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||||||
layer_id,
|
layer_id,
|
||||||
|
|
@ -2470,9 +2546,10 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solo button
|
// Solo button
|
||||||
// TODO: Replace with SVG headphones icon
|
// Solo is an audio control, so it follows mute: only where there's audio.
|
||||||
let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| {
|
if let Some(rect) = solo_button_rect {
|
||||||
let button = egui::Button::new("🎧")
|
let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
|
||||||
|
let button = icon_button(icons::HEADPHONES)
|
||||||
.fill(if is_soloed {
|
.fill(if is_soloed {
|
||||||
theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2480,6 +2557,7 @@ impl TimelinePane {
|
||||||
})
|
})
|
||||||
.stroke(egui::Stroke::NONE);
|
.stroke(egui::Stroke::NONE);
|
||||||
ui.add(button)
|
ui.add(button)
|
||||||
|
.on_hover_text(if is_soloed { "Unsolo layer" } else { "Solo layer" })
|
||||||
}).inner;
|
}).inner;
|
||||||
|
|
||||||
if solo_response.clicked() {
|
if solo_response.clicked() {
|
||||||
|
|
@ -2491,12 +2569,11 @@ impl TimelinePane {
|
||||||
)
|
)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lock button
|
// 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_response = ui.scope_builder(egui::UiBuilder::new().max_rect(lock_button_rect), |ui| {
|
||||||
let lock_text = if is_locked { "🔒" } else { "🔓" };
|
let button = icon_button(if is_locked { icons::LOCK } else { icons::LOCK_OPEN })
|
||||||
let button = egui::Button::new(lock_text)
|
|
||||||
.fill(if is_locked {
|
.fill(if is_locked {
|
||||||
theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100))
|
theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100))
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -2504,6 +2581,7 @@ impl TimelinePane {
|
||||||
})
|
})
|
||||||
.stroke(egui::Stroke::NONE);
|
.stroke(egui::Stroke::NONE);
|
||||||
ui.add(button)
|
ui.add(button)
|
||||||
|
.on_hover_text(if is_locked { "Unlock layer" } else { "Lock layer (prevent edits)" })
|
||||||
}).inner;
|
}).inner;
|
||||||
|
|
||||||
if lock_response.clicked() {
|
if lock_response.clicked() {
|
||||||
|
|
@ -2516,9 +2594,40 @@ impl TimelinePane {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opacity slider.
|
||||||
|
if has_opacity {
|
||||||
|
draw_slider_icon(ui, theme, opacity_slider_rect, icons::BLEND);
|
||||||
|
let current_opacity = layer_for_controls.opacity() as f32;
|
||||||
|
let mut new_opacity = current_opacity;
|
||||||
|
let opacity_response = ui.scope_builder(
|
||||||
|
egui::UiBuilder::new().max_rect(opacity_slider_rect),
|
||||||
|
|ui| {
|
||||||
|
ui.spacing_mut().slider_width = slider_width;
|
||||||
|
ui.add(egui::Slider::new(&mut new_opacity, 0.0..=1.0).show_value(false))
|
||||||
|
},
|
||||||
|
).inner
|
||||||
|
.on_hover_text(format!("Opacity: {:.0}%", current_opacity * 100.0));
|
||||||
|
|
||||||
|
// Block layer drag while interacting with the slider
|
||||||
|
if opacity_response.dragged() || opacity_response.has_focus() {
|
||||||
|
self.layer_control_clicked = true;
|
||||||
|
}
|
||||||
|
if opacity_response.changed() {
|
||||||
|
self.layer_control_clicked = true;
|
||||||
|
pending_actions.push(Box::new(
|
||||||
|
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||||||
|
layer_id,
|
||||||
|
lightningbeam_core::actions::LayerProperty::Opacity(new_opacity as f64),
|
||||||
|
)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume)
|
// 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
|
// 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;
|
ui.spacing_mut().slider_width = slider_width;
|
||||||
// Map volume (0.0-2.0) to slider position (0.0-1.0)
|
// Map volume (0.0-2.0) to slider position (0.0-1.0)
|
||||||
let slider_value = if current_volume <= 1.0 {
|
let slider_value = if current_volume <= 1.0 {
|
||||||
|
|
@ -2537,6 +2646,12 @@ impl TimelinePane {
|
||||||
(response, temp_slider_value)
|
(response, temp_slider_value)
|
||||||
}).inner;
|
}).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
|
// Block layer drag while interacting with the slider
|
||||||
if volume_response.0.dragged() || volume_response.0.has_focus() {
|
if volume_response.0.dragged() || volume_response.0.has_focus() {
|
||||||
self.layer_control_clicked = true;
|
self.layer_control_clicked = true;
|
||||||
|
|
@ -2590,13 +2705,11 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input gain slider for sampled audio layers (below volume slider)
|
// Input gain slider for sampled audio layers. Audio layers have no opacity, so this
|
||||||
|
// takes the opacity tier.
|
||||||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls {
|
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls {
|
||||||
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
||||||
let gain_slider_rect = egui::Rect::from_min_size(
|
let gain_slider_rect = opacity_slider_rect;
|
||||||
egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0),
|
|
||||||
egui::vec2(slider_width, 16.0),
|
|
||||||
);
|
|
||||||
let current_gain = audio_layer.layer.input_gain;
|
let current_gain = audio_layer.layer.input_gain;
|
||||||
|
|
||||||
// Map gain (0.0-4.0) to slider (0.0-1.0): linear
|
// Map gain (0.0-4.0) to slider (0.0-1.0): linear
|
||||||
|
|
@ -2624,8 +2737,8 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Label
|
// Label
|
||||||
let label_rect = egui::Rect::from_min_size(
|
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::pos2(gain_slider_rect.min.x - 26.0, gain_slider_rect.min.y),
|
||||||
egui::vec2(24.0, 16.0),
|
egui::vec2(24.0, gain_slider_rect.height()),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
label_rect.center(),
|
label_rect.center(),
|
||||||
|
|
@ -2637,6 +2750,8 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} // end volume/gain sliders
|
||||||
|
|
||||||
// Per-layer VU meter bar (4px tall at bottom of header)
|
// Per-layer VU meter bar (4px tall at bottom of header)
|
||||||
{
|
{
|
||||||
// Look up the track level for this layer
|
// Look up the track level for this layer
|
||||||
|
|
@ -5795,8 +5910,14 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Split into layer header column (left) and timeline content (right). On mobile the header
|
// Split into layer header column (left) and timeline content (right). On mobile the header
|
||||||
// column collapses to a minimal color-swatch width.
|
// 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 {
|
let header_width = if shared.is_mobile {
|
||||||
MOBILE_LAYER_HEADER_WIDTH
|
MOBILE_LAYER_HEADER_WIDTH
|
||||||
|
} else if headers_only {
|
||||||
|
rect.width()
|
||||||
} else {
|
} else {
|
||||||
LAYER_HEADER_WIDTH
|
LAYER_HEADER_WIDTH
|
||||||
};
|
};
|
||||||
|
|
@ -5848,6 +5969,12 @@ impl PaneRenderer for TimelinePane {
|
||||||
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
|
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);
|
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)
|
// Render time ruler (clip to ruler rect)
|
||||||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||||||
let cycle = document
|
let cycle = document
|
||||||
|
|
@ -5857,12 +5984,15 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render layer rows with clipping
|
// Render layer rows with clipping
|
||||||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
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)
|
// Render playhead on top (clip to timeline area)
|
||||||
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
||||||
self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time);
|
self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time);
|
||||||
|
|
||||||
|
rendered
|
||||||
|
};
|
||||||
|
|
||||||
// Restore original clip rect
|
// Restore original clip rect
|
||||||
ui.set_clip_rect(original_clip_rect);
|
ui.set_clip_rect(original_clip_rect);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,48 @@ impl PaneRenderer for ToolbarPane {
|
||||||
path: &NodePath,
|
path: &NodePath,
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
let button_size = 60.0;
|
// `ui` spans the whole window, not this pane — bind a child Ui to the pane's content rect
|
||||||
let button_padding = 8.0;
|
// first, or the ScrollArea would start at the window's top (under the pane header) and
|
||||||
let button_spacing = 4.0;
|
// 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
|
// Determine which tools to show based on the active layer type
|
||||||
let active_layer_type: Option<LayerType> = shared.active_layer_id
|
let active_layer_type: Option<LayerType> = shared.active_layer_id
|
||||||
.and_then(|id| shared.action_executor.document().get_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;
|
*shared.selected_tool = Tool::Select;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how many columns we can fit
|
let is_raster = matches!(active_layer_type, Some(LayerType::Raster));
|
||||||
let available_width = rect.width() - (button_padding * 2.0);
|
let show_colors = matches!(active_layer_type, None | Some(LayerType::Vector) | Some(LayerType::Raster));
|
||||||
let columns =
|
|
||||||
((available_width + button_spacing) / (button_size + button_spacing)).floor() as usize;
|
|
||||||
let columns = columns.max(1); // At least 1 column
|
|
||||||
let total_tools = tools.len();
|
|
||||||
let total_rows = (total_tools + columns - 1) / columns;
|
|
||||||
|
|
||||||
let mut y = rect.top() + button_padding;
|
ui.spacing_mut().item_spacing = egui::vec2(BUTTON_SPACING, BUTTON_SPACING);
|
||||||
|
ui.add_space(BUTTON_PADDING);
|
||||||
|
|
||||||
// Process tools row by row for centered layout
|
// Centre the grid as a block. Work out how many columns fit, then lay the buttons out in
|
||||||
for row in 0..total_rows {
|
// a band of exactly that width, positioned to centre it. The band has to be an explicit
|
||||||
let start_idx = row * columns;
|
// max_rect on the child Ui — a `horizontal_wrapped` nested inside a `horizontal` inherits
|
||||||
let end_idx = (start_idx + columns).min(total_tools);
|
// the parent's available width and wraps against that, not against the width we set.
|
||||||
let buttons_in_row = end_idx - start_idx;
|
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 cursor = ui.cursor().min;
|
||||||
let row_width = (buttons_in_row as f32 * button_size)
|
let band = egui::Rect::from_min_size(
|
||||||
+ ((buttons_in_row.saturating_sub(1)) as f32 * button_spacing);
|
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
|
// Colour swatches below the tools.
|
||||||
let mut x = rect.left() + (rect.width() - row_width) / 2.0;
|
// 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 row_width = COLOR_LABEL_WIDTH + COLOR_BUTTON_SIZE + BUTTON_SPACING;
|
||||||
let tool = &tools[tool_idx];
|
let color_indent = ((ui.available_width() - row_width) / 2.0).max(0.0);
|
||||||
let button_rect =
|
|
||||||
egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(button_size, button_size));
|
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;
|
let is_selected = *shared.selected_tool == *tool;
|
||||||
|
|
||||||
// Button background
|
// Button background
|
||||||
|
|
@ -91,8 +199,21 @@ impl PaneRenderer for ToolbarPane {
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
||||||
|
|
||||||
// Load and render tool icon
|
// Tool icon: tools without a bundled SVG fall back to a Lucide glyph rather than the
|
||||||
if let Some(icon) = shared.tool_icon_cache.get_or_load(*tool, ui.ctx()) {
|
// 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
|
let icon_rect = button_rect.shrink(8.0); // Padding inside button
|
||||||
ui.painter().image(
|
ui.painter().image(
|
||||||
icon.id(),
|
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() {
|
if response.clicked() {
|
||||||
*shared.selected_tool = *tool;
|
*shared.selected_tool = *tool;
|
||||||
// Preset-backed tools: auto-select the matching bundled brush.
|
// Preset-backed tools: auto-select the matching bundled brush.
|
||||||
|
|
@ -135,12 +251,12 @@ impl PaneRenderer for ToolbarPane {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(name) = preset_name {
|
if let Some(name) = preset_name {
|
||||||
if let Some(preset) = bundled_brushes().iter().find(|p| p.name == name) {
|
if let Some(idx) = bundled_brushes().iter().position(|p| p.name == name) {
|
||||||
let s = &preset.settings;
|
let settings = bundled_brushes()[idx].settings.clone();
|
||||||
shared.raster_settings.brush_opacity = s.opaque.clamp(0.0, 1.0);
|
shared
|
||||||
shared.raster_settings.brush_hardness = s.hardness.clamp(0.0, 1.0);
|
.raster_settings
|
||||||
shared.raster_settings.brush_spacing = s.dabs_per_radius;
|
.brush_mut(crate::tools::BrushKind::Paint)
|
||||||
shared.raster_settings.active_brush_settings = s.clone();
|
.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).
|
// Show tooltip with tool name and shortcut (consumes response).
|
||||||
// Hint text is pulled from the live keymap so it reflects user remappings.
|
// Hint text is pulled from the live keymap so it reflects user remappings.
|
||||||
let hint = tool_app_action(*tool)
|
let hint = tool_app_action(*tool)
|
||||||
|
|
@ -232,140 +358,5 @@ impl PaneRenderer for ToolbarPane {
|
||||||
format!("{}{}", tool.display_name(), hint)
|
format!("{}{}", tool.display_name(), hint)
|
||||||
};
|
};
|
||||||
response.on_hover_text(tooltip);
|
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 std::collections::HashMap;
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use crate::config::AppConfig;
|
use crate::config::{AppConfig, TabletButtonAction};
|
||||||
use crate::keymap::{self, AppAction, KeymapManager};
|
use crate::keymap::{self, AppAction, KeymapManager};
|
||||||
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
|
use crate::menu::{MenuSystem, Shortcut, ShortcutKey};
|
||||||
use crate::theme::{Theme, ThemeMode};
|
use crate::theme::{Theme, ThemeMode};
|
||||||
|
|
@ -61,6 +61,8 @@ struct PreferencesState {
|
||||||
waveform_stereo: bool,
|
waveform_stereo: bool,
|
||||||
theme_mode: ThemeMode,
|
theme_mode: ThemeMode,
|
||||||
large_media_default: LargeMediaMode,
|
large_media_default: LargeMediaMode,
|
||||||
|
tablet_button_lower: TabletButtonAction,
|
||||||
|
tablet_button_upper: TabletButtonAction,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<(&AppConfig, &Theme)> for PreferencesState {
|
impl From<(&AppConfig, &Theme)> for PreferencesState {
|
||||||
|
|
@ -78,6 +80,8 @@ impl From<(&AppConfig, &Theme)> for PreferencesState {
|
||||||
waveform_stereo: config.waveform_stereo,
|
waveform_stereo: config.waveform_stereo,
|
||||||
theme_mode: theme.mode(),
|
theme_mode: theme.mode(),
|
||||||
large_media_default: config.large_media_default,
|
large_media_default: config.large_media_default,
|
||||||
|
tablet_button_lower: config.tablet_button_lower,
|
||||||
|
tablet_button_upper: config.tablet_button_upper,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +101,8 @@ impl Default for PreferencesState {
|
||||||
waveform_stereo: false,
|
waveform_stereo: false,
|
||||||
theme_mode: ThemeMode::System,
|
theme_mode: ThemeMode::System,
|
||||||
large_media_default: LargeMediaMode::default(),
|
large_media_default: LargeMediaMode::default(),
|
||||||
|
tablet_button_lower: TabletButtonAction::Pan,
|
||||||
|
tablet_button_upper: TabletButtonAction::Eyedropper,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -239,6 +245,8 @@ impl PreferencesDialog {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
self.render_startup_section(ui);
|
self.render_startup_section(ui);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
self.render_tablet_section(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
self.render_advanced_section(ui);
|
self.render_advanced_section(ui);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -591,6 +599,41 @@ impl PreferencesDialog {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_tablet_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("Tablet")
|
||||||
|
.default_open(false)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("What each barrel button on the stylus does while held:");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
|
||||||
|
let button_row = |ui: &mut egui::Ui, label: &str, id: &str, value: &mut TabletButtonAction| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(label);
|
||||||
|
egui::ComboBox::from_id_salt(id)
|
||||||
|
.selected_text(value.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for action in TabletButtonAction::ALL {
|
||||||
|
ui.selectable_value(value, action, action.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
button_row(
|
||||||
|
ui,
|
||||||
|
"Lower button:",
|
||||||
|
"tablet_button_lower",
|
||||||
|
&mut self.working_prefs.tablet_button_lower,
|
||||||
|
);
|
||||||
|
button_row(
|
||||||
|
ui,
|
||||||
|
"Upper button:",
|
||||||
|
"tablet_button_upper",
|
||||||
|
&mut self.working_prefs.tablet_button_upper,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn render_advanced_section(&mut self, ui: &mut egui::Ui) {
|
fn render_advanced_section(&mut self, ui: &mut egui::Ui) {
|
||||||
egui::CollapsingHeader::new("Advanced")
|
egui::CollapsingHeader::new("Advanced")
|
||||||
.default_open(false)
|
.default_open(false)
|
||||||
|
|
@ -646,6 +689,8 @@ impl PreferencesDialog {
|
||||||
temp_config.debug = self.working_prefs.debug;
|
temp_config.debug = self.working_prefs.debug;
|
||||||
temp_config.waveform_stereo = self.working_prefs.waveform_stereo;
|
temp_config.waveform_stereo = self.working_prefs.waveform_stereo;
|
||||||
temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
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
|
// Validate
|
||||||
if let Err(err) = temp_config.validate() {
|
if let Err(err) = temp_config.validate() {
|
||||||
|
|
@ -681,10 +726,16 @@ impl PreferencesDialog {
|
||||||
config.waveform_stereo = self.working_prefs.waveform_stereo;
|
config.waveform_stereo = self.working_prefs.waveform_stereo;
|
||||||
config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
||||||
config.large_media_default = self.working_prefs.large_media_default;
|
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;
|
config.keybindings = keybinding_config;
|
||||||
|
|
||||||
// Apply theme immediately
|
// Apply theme immediately
|
||||||
theme.set_mode(self.working_prefs.theme_mode);
|
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
|
// Save to disk
|
||||||
config.save();
|
config.save();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::tool::Tool;
|
use lightningbeam_core::tool::Tool;
|
||||||
|
|
||||||
|
use crate::config::TabletButtonAction;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Global tablet state — read by make_stroke_point() on the UI thread
|
// 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_X_BITS: AtomicU32 = AtomicU32::new(0); // 0.0f32
|
||||||
static TABLET_TILT_Y_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.
|
/// Current pen pressure (0.0–1.0). Falls back to 1.0 when no tablet is active.
|
||||||
pub fn current_pressure() -> f32 {
|
pub fn current_pressure() -> f32 {
|
||||||
f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed))
|
f32::from_bits(TABLET_PRESSURE_BITS.load(Ordering::Relaxed))
|
||||||
|
|
@ -57,10 +111,32 @@ pub enum RawTabletEvent {
|
||||||
Tilt { x: f32, y: f32 },
|
Tilt { x: f32, y: f32 },
|
||||||
TipDown,
|
TipDown,
|
||||||
TipUp,
|
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.
|
/// End of Wayland tablet event group; commit accumulated state.
|
||||||
Frame,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||||
pub enum TabletToolType {
|
pub enum TabletToolType {
|
||||||
#[default]
|
#[default]
|
||||||
|
|
@ -91,9 +167,13 @@ pub struct TabletInput {
|
||||||
/// On X11, clicks already come through winit via OS mouse emulation.
|
/// On X11, clicks already come through winit via OS mouse emulation.
|
||||||
inject_buttons: bool,
|
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>,
|
pub pending_tool_switch: Option<Tool>,
|
||||||
tool_before_eraser: 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
|
/// One-shot sender used to hand the egui Context to the background thread
|
||||||
/// on the first poll() call so it can call request_repaint().
|
/// on the first poll() call so it can call request_repaint().
|
||||||
|
|
@ -125,6 +205,8 @@ impl TabletInput {
|
||||||
inject_buttons,
|
inject_buttons,
|
||||||
pending_tool_switch: None,
|
pending_tool_switch: None,
|
||||||
tool_before_eraser: None,
|
tool_before_eraser: None,
|
||||||
|
prev_buttons: 0,
|
||||||
|
tool_before_button: None,
|
||||||
repaint_sender,
|
repaint_sender,
|
||||||
backend,
|
backend,
|
||||||
}
|
}
|
||||||
|
|
@ -202,6 +284,9 @@ impl TabletInput {
|
||||||
// Handle eraser tool switch.
|
// Handle eraser tool switch.
|
||||||
self.handle_tool_switch(current_tool);
|
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().
|
// Publish globals for make_stroke_point().
|
||||||
set_pressure(self.pressure);
|
set_pressure(self.pressure);
|
||||||
let (tx, ty) = self.tilt;
|
let (tx, ty) = self.tilt;
|
||||||
|
|
@ -267,6 +352,8 @@ impl TabletInput {
|
||||||
RawTabletEvent::ProximityOut => {
|
RawTabletEvent::ProximityOut => {
|
||||||
self.in_proximity = false;
|
self.in_proximity = false;
|
||||||
self.pressure = 0.0;
|
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 } => {
|
RawTabletEvent::Motion { x, y } => {
|
||||||
// Wayland gives physical pixels; convert to egui logical points.
|
// Wayland gives physical pixels; convert to egui logical points.
|
||||||
|
|
@ -289,6 +376,14 @@ impl TabletInput {
|
||||||
RawTabletEvent::TipUp => {
|
RawTabletEvent::TipUp => {
|
||||||
self.tip_down = Some(false);
|
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 => {
|
RawTabletEvent::Frame => {
|
||||||
// Frame is the commit signal; processing already happened in individual events.
|
// 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) {
|
fn handle_tool_switch(&mut self, current_tool: Tool) {
|
||||||
let just_entered = self.in_proximity && !self.was_in_proximity;
|
let just_entered = self.in_proximity && !self.was_in_proximity;
|
||||||
let just_left = !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")]
|
#[cfg(target_os = "linux")]
|
||||||
mod wayland {
|
mod wayland {
|
||||||
use super::{RawTabletEvent, TabletToolType};
|
use super::{RawTabletEvent, TabletButton, TabletToolType};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
@ -445,6 +573,7 @@ mod wayland {
|
||||||
pending_motion: bool,
|
pending_motion: bool,
|
||||||
pending_tip: Option<bool>,
|
pending_tip: Option<bool>,
|
||||||
pending_proximity: Option<bool>,
|
pending_proximity: Option<bool>,
|
||||||
|
pending_buttons: Vec<(TabletButton, bool)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
@ -585,7 +714,20 @@ mod wayland {
|
||||||
Event::Tilt { tilt_x, tilt_y } => {
|
Event::Tilt { tilt_x, tilt_y } => {
|
||||||
acc.pending_tilt = (tilt_x as f32, tilt_y as f32);
|
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 { .. } => {
|
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.
|
// Flush accumulated events to the channel.
|
||||||
if let Some(prox) = acc.pending_proximity.take() {
|
if let Some(prox) = acc.pending_proximity.take() {
|
||||||
if prox {
|
if prox {
|
||||||
|
|
@ -630,7 +772,7 @@ mod wayland {
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod x11 {
|
mod x11 {
|
||||||
use super::{RawTabletEvent, TabletToolType};
|
use super::{RawTabletEvent, TabletButton, TabletToolType};
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle};
|
use winit::raw_window_handle::{XcbDisplayHandle, XlibDisplayHandle};
|
||||||
|
|
||||||
|
|
@ -880,18 +1022,45 @@ mod x11 {
|
||||||
}
|
}
|
||||||
|
|
||||||
Event::XinputRawButtonPress(raw) => {
|
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::TipDown);
|
||||||
let _ = tx.send(RawTabletEvent::Frame);
|
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) => {
|
Event::XinputRawButtonRelease(raw) => {
|
||||||
if device_axes.contains_key(&raw.deviceid) {
|
if device_axes.contains_key(&raw.deviceid) {
|
||||||
if raw.detail == 1 {
|
match raw.detail {
|
||||||
|
1 => {
|
||||||
let _ = tx.send(RawTabletEvent::TipUp);
|
let _ = tx.send(RawTabletEvent::TipUp);
|
||||||
let _ = tx.send(RawTabletEvent::Frame);
|
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.
|
// When all buttons released, synthesise proximity out.
|
||||||
in_proximity.remove(&raw.deviceid);
|
in_proximity.remove(&raw.deviceid);
|
||||||
let _ = tx.send(RawTabletEvent::ProximityOut);
|
let _ = tx.send(RawTabletEvent::ProximityOut);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, RasterToolDef, RasterToolSettings};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
pub struct BlurSharpenTool;
|
pub struct BlurSharpenTool;
|
||||||
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
|
pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
|
||||||
|
|
@ -8,19 +8,11 @@ pub static BLUR_SHARPEN: BlurSharpenTool = BlurSharpenTool;
|
||||||
impl RasterToolDef for BlurSharpenTool {
|
impl RasterToolDef for BlurSharpenTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::BlurSharpen }
|
||||||
fn header_label(&self) -> &'static str { "Blur / Sharpen" }
|
fn header_label(&self) -> &'static str { "Blur / Sharpen" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::BlurSharpen }
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.blur_sharpen_radius,
|
|
||||||
opacity: s.blur_sharpen_strength,
|
|
||||||
hardness: s.blur_sharpen_hardness,
|
|
||||||
spacing: s.blur_sharpen_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
||||||
[s.blur_sharpen_mode as f32, s.blur_sharpen_kernel, 0.0, 0.0]
|
[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) {
|
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() {
|
if ui.selectable_label(s.blur_sharpen_mode == 0, "Blur").clicked() {
|
||||||
|
|
@ -30,31 +22,11 @@ impl RasterToolDef for BlurSharpenTool {
|
||||||
s.blur_sharpen_mode = 1;
|
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.horizontal(|ui| {
|
||||||
ui.label("Kernel:");
|
ui.label("Kernel:");
|
||||||
ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0)
|
ui.add(egui::Slider::new(&mut s.blur_sharpen_kernel, 1.0_f32..=20.0)
|
||||||
.logarithmic(true)
|
.logarithmic(true)
|
||||||
.custom_formatter(|v, _| format!("{:.1} px", v)));
|
.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 eframe::egui;
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
|
|
@ -8,15 +8,7 @@ pub static CLONE_STAMP: CloneStampTool = CloneStampTool;
|
||||||
impl RasterToolDef for CloneStampTool {
|
impl RasterToolDef for CloneStampTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::CloneStamp }
|
||||||
fn header_label(&self) -> &'static str { "Clone Stamp" }
|
fn header_label(&self) -> &'static str { "Clone Stamp" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::CloneStamp }
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
|
/// For Clone Stamp, tool_params are filled by stage.rs at stroke-start time
|
||||||
/// (offset = clone_source - stroke_start), not from settings directly.
|
/// (offset = clone_source - stroke_start), not from settings directly.
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
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 eframe::egui;
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
pub struct DodgeBurnTool;
|
pub struct DodgeBurnTool;
|
||||||
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
|
pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
|
||||||
|
|
@ -8,19 +8,11 @@ pub static DODGE_BURN: DodgeBurnTool = DodgeBurnTool;
|
||||||
impl RasterToolDef for DodgeBurnTool {
|
impl RasterToolDef for DodgeBurnTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::DodgeBurn }
|
||||||
fn header_label(&self) -> &'static str { "Dodge / Burn" }
|
fn header_label(&self) -> &'static str { "Dodge / Burn" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::DodgeBurn }
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.dodge_burn_radius,
|
|
||||||
opacity: s.dodge_burn_exposure,
|
|
||||||
hardness: s.dodge_burn_hardness,
|
|
||||||
spacing: s.dodge_burn_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
||||||
[s.dodge_burn_mode as f32, 0.0, 0.0, 0.0]
|
[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) {
|
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
|
if ui.selectable_label(s.dodge_burn_mode == 0, "Dodge").clicked() {
|
||||||
|
|
@ -30,25 +22,5 @@ impl RasterToolDef for DodgeBurnTool {
|
||||||
s.dodge_burn_mode = 1;
|
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 super::{BrushKind, RasterToolDef};
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
pub struct EraseTool;
|
pub struct EraseTool;
|
||||||
|
|
@ -8,16 +7,6 @@ pub static ERASE: EraseTool = EraseTool;
|
||||||
impl RasterToolDef for EraseTool {
|
impl RasterToolDef for EraseTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Erase }
|
||||||
fn header_label(&self) -> &'static str { "Eraser" }
|
fn header_label(&self) -> &'static str { "Eraser" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::Erase }
|
||||||
BrushParams {
|
fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
||||||
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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, RasterToolDef, RasterToolSettings};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
|
|
@ -8,15 +8,7 @@ pub static HEALING_BRUSH: HealingBrushTool = HealingBrushTool;
|
||||||
impl RasterToolDef for HealingBrushTool {
|
impl RasterToolDef for HealingBrushTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Healing }
|
||||||
fn header_label(&self) -> &'static str { "Healing Brush" }
|
fn header_label(&self) -> &'static str { "Healing Brush" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::HealingBrush }
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/// tool_params are filled by stage.rs at stroke-start time (clone offset).
|
/// 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 tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
||||||
fn uses_alt_click(&self) -> bool { true }
|
fn uses_alt_click(&self) -> bool { true }
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,17 @@
|
||||||
///
|
///
|
||||||
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
|
/// Each tool implements `RasterToolDef`. Adding a new tool requires:
|
||||||
/// 1. A new file in this directory implementing `RasterToolDef`.
|
/// 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.
|
/// 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 eframe::egui;
|
||||||
use lightningbeam_core::{
|
use lightningbeam_core::{
|
||||||
brush_settings::BrushSettings,
|
brush_settings::{bundled_brushes, BrushSettings},
|
||||||
raster_layer::RasterBlendMode,
|
raster_layer::RasterBlendMode,
|
||||||
tool::Tool,
|
tool::Tool,
|
||||||
};
|
};
|
||||||
|
|
@ -22,6 +27,86 @@ pub mod dodge_burn;
|
||||||
pub mod sponge;
|
pub mod sponge;
|
||||||
pub mod blur_sharpen;
|
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)
|
// 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
|
/// All per-tool settings for raster painting. Owned by `EditorApp`; borrowed
|
||||||
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
|
/// by `SharedPaneState` as a single `&'a mut RasterToolSettings`.
|
||||||
pub struct RasterToolSettings {
|
pub struct RasterToolSettings {
|
||||||
// --- Paint brush ---
|
/// Per-tool brush state, indexed by `BrushKind`.
|
||||||
pub brush_radius: f32,
|
brushes: [BrushSlot; 9],
|
||||||
pub brush_opacity: f32,
|
|
||||||
pub brush_hardness: f32,
|
|
||||||
pub brush_spacing: f32,
|
|
||||||
/// true = paint with FG (stroke) color, false = BG (fill) color
|
|
||||||
pub brush_use_fg: bool,
|
|
||||||
pub active_brush_settings: BrushSettings,
|
|
||||||
// --- Eraser ---
|
|
||||||
pub eraser_radius: f32,
|
|
||||||
pub eraser_opacity: f32,
|
|
||||||
pub eraser_hardness: f32,
|
|
||||||
pub eraser_spacing: f32,
|
|
||||||
pub active_eraser_settings: BrushSettings,
|
|
||||||
// --- Smudge ---
|
|
||||||
pub smudge_radius: f32,
|
|
||||||
pub smudge_hardness: f32,
|
|
||||||
pub smudge_spacing: f32,
|
|
||||||
pub smudge_strength: f32,
|
|
||||||
// --- Clone / Healing ---
|
// --- Clone / Healing ---
|
||||||
/// World-space source point set by Alt+click.
|
/// World-space source point set by Alt+click.
|
||||||
pub clone_source: Option<egui::Vec2>,
|
pub clone_source: Option<egui::Vec2>,
|
||||||
|
|
@ -55,24 +123,12 @@ pub struct RasterToolSettings {
|
||||||
pub pattern_type: u32,
|
pub pattern_type: u32,
|
||||||
pub pattern_scale: f32,
|
pub pattern_scale: f32,
|
||||||
// --- Dodge / Burn ---
|
// --- 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)
|
/// 0 = dodge (lighten), 1 = burn (darken)
|
||||||
pub dodge_burn_mode: u32,
|
pub dodge_burn_mode: u32,
|
||||||
// --- Sponge ---
|
// --- Sponge ---
|
||||||
pub sponge_radius: f32,
|
|
||||||
pub sponge_hardness: f32,
|
|
||||||
pub sponge_spacing: f32,
|
|
||||||
pub sponge_flow: f32,
|
|
||||||
/// 0 = saturate, 1 = desaturate
|
/// 0 = saturate, 1 = desaturate
|
||||||
pub sponge_mode: u32,
|
pub sponge_mode: u32,
|
||||||
// --- Blur / Sharpen ---
|
// --- 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)
|
/// Neighborhood kernel radius in canvas pixels (1–20)
|
||||||
pub blur_sharpen_kernel: f32,
|
pub blur_sharpen_kernel: f32,
|
||||||
/// 0 = blur, 1 = sharpen
|
/// 0 = blur, 1 = sharpen
|
||||||
|
|
@ -87,7 +143,7 @@ pub struct RasterToolSettings {
|
||||||
// --- Quick Select ---
|
// --- Quick Select ---
|
||||||
/// Brush radius in canvas pixels for the quick-select tool.
|
/// Brush radius in canvas pixels for the quick-select tool.
|
||||||
pub quick_select_radius: f32,
|
pub quick_select_radius: f32,
|
||||||
// --- Flood fill (Paint Bucket, raster) ---
|
// --- Flood fill (Paint Bucket) ---
|
||||||
/// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this
|
/// Color-distance threshold (Euclidean RGBA, 0–510). Pixels within this
|
||||||
/// distance of the comparison color are included in the fill.
|
/// distance of the comparison color are included in the fill.
|
||||||
pub fill_threshold: f32,
|
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
|
/// Whether to compare each pixel to the seed pixel (Absolute) or to its BFS
|
||||||
/// parent pixel (Relative, spreads across gradients).
|
/// parent pixel (Relative, spreads across gradients).
|
||||||
pub fill_threshold_mode: FillThresholdMode,
|
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 ---
|
// --- Marquee select shape ---
|
||||||
/// Whether the rectangular select tool draws a rect or an ellipse.
|
/// Whether the rectangular select tool draws a rect or an ellipse.
|
||||||
pub select_shape: SelectionShape,
|
pub select_shape: SelectionShape,
|
||||||
|
|
@ -109,10 +170,16 @@ pub struct RasterToolSettings {
|
||||||
// --- Gradient ---
|
// --- Gradient ---
|
||||||
pub gradient: lightningbeam_core::gradient::ShapeGradient,
|
pub gradient: lightningbeam_core::gradient::ShapeGradient,
|
||||||
pub gradient_opacity: f32,
|
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.
|
impl RasterToolSettings {
|
||||||
pub brush_angle_offset: f32,
|
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.
|
/// Brush mode for the Liquify tool.
|
||||||
|
|
@ -158,43 +225,32 @@ pub enum FillThresholdMode {
|
||||||
|
|
||||||
impl Default for RasterToolSettings {
|
impl Default for RasterToolSettings {
|
||||||
fn default() -> Self {
|
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 {
|
Self {
|
||||||
brush_radius: 10.0,
|
brushes: [
|
||||||
brush_opacity: 1.0,
|
/* Paint */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
|
||||||
brush_hardness: 0.5,
|
/* Erase */ erase,
|
||||||
brush_spacing: 0.1,
|
/* Smudge */ BrushSlot::new(15.0, 1.0, 0.8, 8.0),
|
||||||
brush_use_fg: true,
|
/* CloneStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
|
||||||
active_brush_settings: BrushSettings::default(),
|
/* HealingBrush */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
|
||||||
eraser_radius: 10.0,
|
/* PatternStamp */ BrushSlot::new(10.0, 1.0, 0.5, 0.1),
|
||||||
eraser_opacity: 1.0,
|
/* DodgeBurn */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
|
||||||
eraser_hardness: 0.5,
|
/* Sponge */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
|
||||||
eraser_spacing: 0.1,
|
/* BlurSharpen */ BrushSlot::new(30.0, 0.5, 0.5, 3.0),
|
||||||
active_eraser_settings: lightningbeam_core::brush_settings::bundled_brushes()
|
],
|
||||||
.iter()
|
|
||||||
.find(|p| p.name == "Brush")
|
|
||||||
.map(|p| p.settings.clone())
|
|
||||||
.unwrap_or_default(),
|
|
||||||
smudge_radius: 15.0,
|
|
||||||
smudge_hardness: 0.8,
|
|
||||||
smudge_spacing: 8.0,
|
|
||||||
smudge_strength: 1.0,
|
|
||||||
clone_source: None,
|
clone_source: None,
|
||||||
pattern_type: 0,
|
pattern_type: 0,
|
||||||
pattern_scale: 32.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,
|
dodge_burn_mode: 0,
|
||||||
sponge_radius: 30.0,
|
|
||||||
sponge_hardness: 0.5,
|
|
||||||
sponge_spacing: 3.0,
|
|
||||||
sponge_flow: 0.5,
|
|
||||||
sponge_mode: 0,
|
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_kernel: 5.0,
|
||||||
blur_sharpen_mode: 0,
|
blur_sharpen_mode: 0,
|
||||||
wand_threshold: 15.0,
|
wand_threshold: 15.0,
|
||||||
|
|
@ -203,6 +259,8 @@ impl Default for RasterToolSettings {
|
||||||
fill_threshold: 15.0,
|
fill_threshold: 15.0,
|
||||||
fill_softness: 0.0,
|
fill_softness: 0.0,
|
||||||
fill_threshold_mode: FillThresholdMode::Absolute,
|
fill_threshold_mode: FillThresholdMode::Absolute,
|
||||||
|
fill_use_fg: true,
|
||||||
|
eyedropper_use_fg: true,
|
||||||
quick_select_radius: 20.0,
|
quick_select_radius: 20.0,
|
||||||
select_shape: SelectionShape::Rect,
|
select_shape: SelectionShape::Rect,
|
||||||
warp_grid_cols: 4,
|
warp_grid_cols: 4,
|
||||||
|
|
@ -212,7 +270,6 @@ impl Default for RasterToolSettings {
|
||||||
liquify_strength: 0.5,
|
liquify_strength: 0.5,
|
||||||
gradient: lightningbeam_core::gradient::ShapeGradient::default(),
|
gradient: lightningbeam_core::gradient::ShapeGradient::default(),
|
||||||
gradient_opacity: 1.0,
|
gradient_opacity: 1.0,
|
||||||
brush_angle_offset: 0.0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +286,21 @@ pub struct BrushParams {
|
||||||
pub spacing: f32,
|
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
|
// RasterToolDef trait
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -236,19 +308,34 @@ pub struct BrushParams {
|
||||||
pub trait RasterToolDef: Send + Sync {
|
pub trait RasterToolDef: Send + Sync {
|
||||||
fn blend_mode(&self) -> RasterBlendMode;
|
fn blend_mode(&self) -> RasterBlendMode;
|
||||||
fn header_label(&self) -> &'static str;
|
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`.
|
/// Encode tool-specific state into the 4-float `StrokeRecord::tool_params`.
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4];
|
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).
|
/// Cursor display radius (world pixels).
|
||||||
fn cursor_radius(&self, s: &RasterToolSettings) -> f32 {
|
fn cursor_radius(&self, s: &RasterToolSettings) -> f32 {
|
||||||
self.brush_params(s).radius
|
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);
|
/// Label for the slot's `strength` slider — the one field whose meaning is tool-specific.
|
||||||
/// Whether to show the brush preset picker after `render_ui`.
|
fn strength_label(&self) -> &'static str { "Opacity" }
|
||||||
fn show_brush_preset_picker(&self) -> bool { true }
|
|
||||||
/// Whether this tool is the eraser (drives preset picker + color UI visibility).
|
/// Render tool-specific controls in the infopanel. The shared brush controls (color, size,
|
||||||
fn is_eraser(&self) -> bool { false }
|
/// 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.
|
/// Whether Alt+click sets a source point for this tool.
|
||||||
fn uses_alt_click(&self) -> bool { false }
|
fn uses_alt_click(&self) -> bool { false }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, RasterToolDef};
|
||||||
use eframe::egui;
|
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
pub struct PaintTool;
|
pub struct PaintTool;
|
||||||
|
|
@ -8,17 +7,7 @@ pub static PAINT: PaintTool = PaintTool;
|
||||||
impl RasterToolDef for PaintTool {
|
impl RasterToolDef for PaintTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Normal }
|
||||||
fn header_label(&self) -> &'static str { "Brush" }
|
fn header_label(&self) -> &'static str { "Brush" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::Paint }
|
||||||
let mut base_settings = s.active_brush_settings.clone();
|
fn tool_params(&self, _s: &super::RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
||||||
base_settings.elliptical_dab_angle += s.brush_angle_offset;
|
fn uses_color(&self) -> bool { true }
|
||||||
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) {}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, RasterToolDef, RasterToolSettings};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::raster_layer::RasterBlendMode;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
|
|
@ -12,18 +12,12 @@ const PATTERN_NAMES: &[&str] = &[
|
||||||
impl RasterToolDef for PatternStampTool {
|
impl RasterToolDef for PatternStampTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::PatternStamp }
|
||||||
fn header_label(&self) -> &'static str { "Pattern Stamp" }
|
fn header_label(&self) -> &'static str { "Pattern Stamp" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::PatternStamp }
|
||||||
BrushParams {
|
|
||||||
base_settings: s.active_brush_settings.clone(),
|
|
||||||
radius: s.brush_radius,
|
|
||||||
opacity: s.brush_opacity,
|
|
||||||
hardness: s.brush_hardness,
|
|
||||||
spacing: s.brush_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
||||||
[s.pattern_type as f32, s.pattern_scale, 0.0, 0.0]
|
[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) {
|
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
||||||
let selected_name = PATTERN_NAMES
|
let selected_name = PATTERN_NAMES
|
||||||
.get(s.pattern_type as usize)
|
.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)
|
ui.add(egui::Slider::new(&mut s.pattern_scale, 4.0_f32..=256.0)
|
||||||
.logarithmic(true).suffix(" px"));
|
.logarithmic(true).suffix(" px"));
|
||||||
});
|
});
|
||||||
ui.add_space(4.0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, BrushParams, RasterToolDef, RasterToolSettings};
|
||||||
use eframe::egui;
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
|
||||||
|
|
||||||
pub struct SmudgeTool;
|
pub struct SmudgeTool;
|
||||||
pub static SMUDGE: SmudgeTool = SmudgeTool;
|
pub static SMUDGE: SmudgeTool = SmudgeTool;
|
||||||
|
|
@ -8,37 +7,15 @@ pub static SMUDGE: SmudgeTool = SmudgeTool;
|
||||||
impl RasterToolDef for SmudgeTool {
|
impl RasterToolDef for SmudgeTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Smudge }
|
||||||
fn header_label(&self) -> &'static str { "Smudge" }
|
fn header_label(&self) -> &'static str { "Smudge" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::Smudge }
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.smudge_radius,
|
|
||||||
opacity: 1.0, // strength is a separate smudge_dist multiplier
|
|
||||||
hardness: s.smudge_hardness,
|
|
||||||
spacing: s.smudge_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
fn tool_params(&self, _s: &RasterToolSettings) -> [f32; 4] { [0.0; 4] }
|
||||||
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| {
|
/// Smudge's slot `strength` drives the smudge distance (applied by the stage as
|
||||||
ui.label("Size:");
|
/// `smudge_radius_log`), not dab opacity — dabs always composite fully.
|
||||||
ui.add(egui::Slider::new(&mut s.smudge_radius, 1.0_f32..=200.0).logarithmic(true).suffix(" px"));
|
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
||||||
});
|
let mut p = super::default_brush_params(self.brush_kind(), s);
|
||||||
ui.horizontal(|ui| {
|
p.opacity = 1.0;
|
||||||
ui.label("Strength:");
|
p
|
||||||
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)));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use super::{BrushParams, RasterToolDef, RasterToolSettings};
|
use super::{BrushKind, RasterToolDef, RasterToolSettings};
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningbeam_core::{brush_settings::BrushSettings, raster_layer::RasterBlendMode};
|
use lightningbeam_core::raster_layer::RasterBlendMode;
|
||||||
|
|
||||||
pub struct SpongeTool;
|
pub struct SpongeTool;
|
||||||
pub static SPONGE: SpongeTool = SpongeTool;
|
pub static SPONGE: SpongeTool = SpongeTool;
|
||||||
|
|
@ -8,19 +8,11 @@ pub static SPONGE: SpongeTool = SpongeTool;
|
||||||
impl RasterToolDef for SpongeTool {
|
impl RasterToolDef for SpongeTool {
|
||||||
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
|
fn blend_mode(&self) -> RasterBlendMode { RasterBlendMode::Sponge }
|
||||||
fn header_label(&self) -> &'static str { "Sponge" }
|
fn header_label(&self) -> &'static str { "Sponge" }
|
||||||
fn brush_params(&self, s: &RasterToolSettings) -> BrushParams {
|
fn brush_kind(&self) -> BrushKind { BrushKind::Sponge }
|
||||||
BrushParams {
|
|
||||||
base_settings: BrushSettings::default(),
|
|
||||||
radius: s.sponge_radius,
|
|
||||||
opacity: s.sponge_flow,
|
|
||||||
hardness: s.sponge_hardness,
|
|
||||||
spacing: s.sponge_spacing,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
fn tool_params(&self, s: &RasterToolSettings) -> [f32; 4] {
|
||||||
[s.sponge_mode as f32, 0.0, 0.0, 0.0]
|
[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) {
|
fn render_ui(&self, ui: &mut egui::Ui, s: &mut RasterToolSettings) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
|
if ui.selectable_label(s.sponge_mode == 0, "Saturate").clicked() {
|
||||||
|
|
@ -30,25 +22,5 @@ impl RasterToolDef for SpongeTool {
|
||||||
s.sponge_mode = 1;
|
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
|
//! Reusable UI widgets for the editor
|
||||||
|
|
||||||
mod text_field;
|
mod text_field;
|
||||||
|
pub mod color_swatch;
|
||||||
pub mod dropdown_list;
|
pub mod dropdown_list;
|
||||||
|
|
||||||
pub use text_field::ImeTextField;
|
pub use text_field::ImeTextField;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue