Compare commits
9 Commits
73ef9e3b9c
...
e578aadd61
| Author | SHA1 | Date |
|---|---|---|
|
|
e578aadd61 | |
|
|
e4b9d86688 | |
|
|
16b0d822e3 | |
|
|
759e41d84a | |
|
|
5c555bf7e1 | |
|
|
b4c7a45990 | |
|
|
885c52c02a | |
|
|
6b3a286caf | |
|
|
ec46e22782 |
|
|
@ -1710,6 +1710,7 @@ impl Engine {
|
||||||
Command::AmpSimLoadModel(track_id, node_id, model_path) => {
|
Command::AmpSimLoadModel(track_id, node_id, model_path) => {
|
||||||
use crate::audio::node_graph::nodes::AmpSimNode;
|
use crate::audio::node_graph::nodes::AmpSimNode;
|
||||||
|
|
||||||
|
eprintln!("[AmpSim] Loading model: {:?} for track {:?} node {}", model_path, track_id, node_id);
|
||||||
let graph = match self.project.get_track_mut(track_id) {
|
let graph = match self.project.get_track_mut(track_id) {
|
||||||
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
|
Some(TrackNode::Midi(track)) => Some(&mut track.instrument_graph),
|
||||||
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
|
Some(TrackNode::Audio(track)) => Some(&mut track.effects_graph),
|
||||||
|
|
@ -1719,8 +1720,16 @@ impl Engine {
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
let node_idx = NodeIndex::new(node_id as usize);
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
||||||
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
|
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
|
||||||
if let Err(e) = amp_sim.load_model(&model_path) {
|
let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") {
|
||||||
eprintln!("Failed to load NAM model: {}", e);
|
eprintln!("[AmpSim] Loading bundled model: {}", bundled_name);
|
||||||
|
amp_sim.load_bundled_model(bundled_name)
|
||||||
|
} else {
|
||||||
|
eprintln!("[AmpSim] Loading model from file: {}", model_path);
|
||||||
|
amp_sim.load_model(&model_path)
|
||||||
|
};
|
||||||
|
match &result {
|
||||||
|
Ok(()) => eprintln!("[AmpSim] Model loaded successfully"),
|
||||||
|
Err(e) => eprintln!("[AmpSim] Failed to load NAM model: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1140,11 +1140,20 @@ impl AudioGraph {
|
||||||
if let Some(ref model_path) = serialized_node.nam_model_path {
|
if let Some(ref model_path) = serialized_node.nam_model_path {
|
||||||
if serialized_node.node_type == "AmpSim" {
|
if serialized_node.node_type == "AmpSim" {
|
||||||
use crate::audio::node_graph::nodes::AmpSimNode;
|
use crate::audio::node_graph::nodes::AmpSimNode;
|
||||||
let resolved_path = resolve_sample_path(model_path);
|
eprintln!("[AmpSim] Preset restore: nam_model_path={:?}", model_path);
|
||||||
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
if let Some(graph_node) = graph.graph.node_weight_mut(node_idx) {
|
||||||
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
|
if let Some(amp_sim) = graph_node.node.as_any_mut().downcast_mut::<AmpSimNode>() {
|
||||||
if let Err(e) = amp_sim.load_model(&resolved_path) {
|
let result = if let Some(bundled_name) = model_path.strip_prefix("bundled:") {
|
||||||
eprintln!("Warning: failed to load NAM model {}: {}", resolved_path, e);
|
eprintln!("[AmpSim] Preset: loading bundled model {:?}", bundled_name);
|
||||||
|
amp_sim.load_bundled_model(bundled_name)
|
||||||
|
} else {
|
||||||
|
let resolved_path = resolve_sample_path(model_path);
|
||||||
|
eprintln!("[AmpSim] Preset: loading from file {:?}", resolved_path);
|
||||||
|
amp_sim.load_model(&resolved_path)
|
||||||
|
};
|
||||||
|
match &result {
|
||||||
|
Ok(()) => eprintln!("[AmpSim] Preset: model loaded successfully"),
|
||||||
|
Err(e) => eprintln!("[AmpSim] Preset: failed to load NAM model: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const PARAM_ATTACK: u32 = 0;
|
||||||
const PARAM_DECAY: u32 = 1;
|
const PARAM_DECAY: u32 = 1;
|
||||||
const PARAM_SUSTAIN: u32 = 2;
|
const PARAM_SUSTAIN: u32 = 2;
|
||||||
const PARAM_RELEASE: u32 = 3;
|
const PARAM_RELEASE: u32 = 3;
|
||||||
|
const PARAM_CURVE: u32 = 4;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
enum EnvelopeStage {
|
enum EnvelopeStage {
|
||||||
|
|
@ -15,6 +16,19 @@ enum EnvelopeStage {
|
||||||
Release,
|
Release,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Curve shape for envelope segments
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
enum CurveType {
|
||||||
|
Linear,
|
||||||
|
Exponential,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CurveType {
|
||||||
|
fn from_f32(v: f32) -> Self {
|
||||||
|
if v >= 0.5 { CurveType::Exponential } else { CurveType::Linear }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// ADSR Envelope Generator
|
/// ADSR Envelope Generator
|
||||||
/// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters
|
/// Outputs a CV signal (0.0-1.0) based on gate input and ADSR parameters
|
||||||
pub struct ADSRNode {
|
pub struct ADSRNode {
|
||||||
|
|
@ -23,8 +37,15 @@ pub struct ADSRNode {
|
||||||
decay: f32, // seconds
|
decay: f32, // seconds
|
||||||
sustain: f32, // level (0.0-1.0)
|
sustain: f32, // level (0.0-1.0)
|
||||||
release: f32, // seconds
|
release: f32, // seconds
|
||||||
|
curve: CurveType,
|
||||||
stage: EnvelopeStage,
|
stage: EnvelopeStage,
|
||||||
level: f32, // current envelope level
|
level: f32, // current envelope level
|
||||||
|
/// For exponential curves: the coefficient per sample (computed on stage entry)
|
||||||
|
exp_coeff: f32,
|
||||||
|
/// For exponential curves: the base level when the stage started
|
||||||
|
exp_base: f32,
|
||||||
|
/// For exponential curves: the target level
|
||||||
|
exp_target: f32,
|
||||||
gate_was_high: bool,
|
gate_was_high: bool,
|
||||||
inputs: Vec<NodePort>,
|
inputs: Vec<NodePort>,
|
||||||
outputs: Vec<NodePort>,
|
outputs: Vec<NodePort>,
|
||||||
|
|
@ -48,6 +69,7 @@ impl ADSRNode {
|
||||||
Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time),
|
Parameter::new(PARAM_DECAY, "Decay", 0.001, 5.0, 0.1, ParameterUnit::Time),
|
||||||
Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic),
|
Parameter::new(PARAM_SUSTAIN, "Sustain", 0.0, 1.0, 0.7, ParameterUnit::Generic),
|
||||||
Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time),
|
Parameter::new(PARAM_RELEASE, "Release", 0.001, 5.0, 0.2, ParameterUnit::Time),
|
||||||
|
Parameter::new(PARAM_CURVE, "Curve", 0.0, 1.0, 0.0, ParameterUnit::Generic),
|
||||||
];
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -56,8 +78,12 @@ impl ADSRNode {
|
||||||
decay: 0.1,
|
decay: 0.1,
|
||||||
sustain: 0.7,
|
sustain: 0.7,
|
||||||
release: 0.2,
|
release: 0.2,
|
||||||
|
curve: CurveType::Linear,
|
||||||
stage: EnvelopeStage::Idle,
|
stage: EnvelopeStage::Idle,
|
||||||
level: 0.0,
|
level: 0.0,
|
||||||
|
exp_coeff: 0.0,
|
||||||
|
exp_base: 0.0,
|
||||||
|
exp_target: 0.0,
|
||||||
gate_was_high: false,
|
gate_was_high: false,
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
|
|
@ -89,6 +115,7 @@ impl AudioNode for ADSRNode {
|
||||||
PARAM_DECAY => self.decay = value.clamp(0.001, 5.0),
|
PARAM_DECAY => self.decay = value.clamp(0.001, 5.0),
|
||||||
PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0),
|
PARAM_SUSTAIN => self.sustain = value.clamp(0.0, 1.0),
|
||||||
PARAM_RELEASE => self.release = value.clamp(0.001, 5.0),
|
PARAM_RELEASE => self.release = value.clamp(0.001, 5.0),
|
||||||
|
PARAM_CURVE => self.curve = CurveType::from_f32(value),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,6 +126,7 @@ impl AudioNode for ADSRNode {
|
||||||
PARAM_DECAY => self.decay,
|
PARAM_DECAY => self.decay,
|
||||||
PARAM_SUSTAIN => self.sustain,
|
PARAM_SUSTAIN => self.sustain,
|
||||||
PARAM_RELEASE => self.release,
|
PARAM_RELEASE => self.release,
|
||||||
|
PARAM_CURVE => match self.curve { CurveType::Linear => 0.0, CurveType::Exponential => 1.0 },
|
||||||
_ => 0.0,
|
_ => 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -130,9 +158,23 @@ impl AudioNode for ADSRNode {
|
||||||
if gate_high && !self.gate_was_high {
|
if gate_high && !self.gate_was_high {
|
||||||
// Note on: Start attack
|
// Note on: Start attack
|
||||||
self.stage = EnvelopeStage::Attack;
|
self.stage = EnvelopeStage::Attack;
|
||||||
|
if self.curve == CurveType::Exponential {
|
||||||
|
// For exponential attack, compute coefficient for ~5 time constants
|
||||||
|
// We overshoot the target slightly so the curve reaches 1.0 naturally
|
||||||
|
let samples = self.attack * sample_rate_f32;
|
||||||
|
self.exp_coeff = (-5.0 / samples).exp();
|
||||||
|
self.exp_base = self.level;
|
||||||
|
self.exp_target = 1.0;
|
||||||
|
}
|
||||||
} else if !gate_high && self.gate_was_high {
|
} else if !gate_high && self.gate_was_high {
|
||||||
// Note off: Start release
|
// Note off: Start release
|
||||||
self.stage = EnvelopeStage::Release;
|
self.stage = EnvelopeStage::Release;
|
||||||
|
if self.curve == CurveType::Exponential {
|
||||||
|
let samples = self.release * sample_rate_f32;
|
||||||
|
self.exp_coeff = (-5.0 / samples).exp();
|
||||||
|
self.exp_base = self.level;
|
||||||
|
self.exp_target = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.gate_was_high = gate_high;
|
self.gate_was_high = gate_high;
|
||||||
|
|
||||||
|
|
@ -142,22 +184,51 @@ impl AudioNode for ADSRNode {
|
||||||
self.level = 0.0;
|
self.level = 0.0;
|
||||||
}
|
}
|
||||||
EnvelopeStage::Attack => {
|
EnvelopeStage::Attack => {
|
||||||
// Rise from current level to 1.0
|
match self.curve {
|
||||||
let increment = 1.0 / (self.attack * sample_rate_f32);
|
CurveType::Linear => {
|
||||||
self.level += increment;
|
let increment = 1.0 / (self.attack * sample_rate_f32);
|
||||||
if self.level >= 1.0 {
|
self.level += increment;
|
||||||
self.level = 1.0;
|
if self.level >= 1.0 {
|
||||||
self.stage = EnvelopeStage::Decay;
|
self.level = 1.0;
|
||||||
|
self.stage = EnvelopeStage::Decay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CurveType::Exponential => {
|
||||||
|
// Asymptotic approach: level moves toward overshoot target
|
||||||
|
// Using target of 1.0 + small overshoot so we actually reach 1.0
|
||||||
|
let overshoot_target = 1.0 + (1.0 - self.exp_base) * 0.01;
|
||||||
|
self.level = overshoot_target - (overshoot_target - self.level) * self.exp_coeff;
|
||||||
|
if self.level >= 1.0 {
|
||||||
|
self.level = 1.0;
|
||||||
|
self.stage = EnvelopeStage::Decay;
|
||||||
|
// Set up decay exponential
|
||||||
|
let samples = self.decay * sample_rate_f32;
|
||||||
|
self.exp_coeff = (-5.0 / samples).exp();
|
||||||
|
self.exp_base = 1.0;
|
||||||
|
self.exp_target = self.sustain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EnvelopeStage::Decay => {
|
EnvelopeStage::Decay => {
|
||||||
// Fall from 1.0 to sustain level
|
|
||||||
let target = self.sustain;
|
let target = self.sustain;
|
||||||
let decrement = (1.0 - target) / (self.decay * sample_rate_f32);
|
match self.curve {
|
||||||
self.level -= decrement;
|
CurveType::Linear => {
|
||||||
if self.level <= target {
|
let decrement = (1.0 - target) / (self.decay * sample_rate_f32);
|
||||||
self.level = target;
|
self.level -= decrement;
|
||||||
self.stage = EnvelopeStage::Sustain;
|
if self.level <= target {
|
||||||
|
self.level = target;
|
||||||
|
self.stage = EnvelopeStage::Sustain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CurveType::Exponential => {
|
||||||
|
// Exponential decay toward sustain level
|
||||||
|
self.level = target + (self.level - target) * self.exp_coeff;
|
||||||
|
if (self.level - target).abs() < 0.001 {
|
||||||
|
self.level = target;
|
||||||
|
self.stage = EnvelopeStage::Sustain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EnvelopeStage::Sustain => {
|
EnvelopeStage::Sustain => {
|
||||||
|
|
@ -165,12 +236,23 @@ impl AudioNode for ADSRNode {
|
||||||
self.level = self.sustain;
|
self.level = self.sustain;
|
||||||
}
|
}
|
||||||
EnvelopeStage::Release => {
|
EnvelopeStage::Release => {
|
||||||
// Fall from current level to 0.0
|
match self.curve {
|
||||||
let decrement = self.level / (self.release * sample_rate_f32);
|
CurveType::Linear => {
|
||||||
self.level -= decrement;
|
let decrement = self.level / (self.release * sample_rate_f32);
|
||||||
if self.level <= 0.001 {
|
self.level -= decrement;
|
||||||
self.level = 0.0;
|
if self.level <= 0.001 {
|
||||||
self.stage = EnvelopeStage::Idle;
|
self.level = 0.0;
|
||||||
|
self.stage = EnvelopeStage::Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CurveType::Exponential => {
|
||||||
|
// Exponential decay toward 0
|
||||||
|
self.level *= self.exp_coeff;
|
||||||
|
if self.level <= 0.001 {
|
||||||
|
self.level = 0.0;
|
||||||
|
self.stage = EnvelopeStage::Idle;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -183,6 +265,9 @@ impl AudioNode for ADSRNode {
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.stage = EnvelopeStage::Idle;
|
self.stage = EnvelopeStage::Idle;
|
||||||
self.level = 0.0;
|
self.level = 0.0;
|
||||||
|
self.exp_coeff = 0.0;
|
||||||
|
self.exp_base = 0.0;
|
||||||
|
self.exp_target = 0.0;
|
||||||
self.gate_was_high = false;
|
self.gate_was_high = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,9 +286,13 @@ impl AudioNode for ADSRNode {
|
||||||
decay: self.decay,
|
decay: self.decay,
|
||||||
sustain: self.sustain,
|
sustain: self.sustain,
|
||||||
release: self.release,
|
release: self.release,
|
||||||
stage: EnvelopeStage::Idle, // Reset state
|
curve: self.curve,
|
||||||
level: 0.0, // Reset level
|
stage: EnvelopeStage::Idle,
|
||||||
gate_was_high: false, // Reset gate
|
level: 0.0,
|
||||||
|
exp_coeff: 0.0,
|
||||||
|
exp_base: 0.0,
|
||||||
|
exp_target: 0.0,
|
||||||
|
gate_was_high: false,
|
||||||
inputs: self.inputs.clone(),
|
inputs: self.inputs.clone(),
|
||||||
outputs: self.outputs.clone(),
|
outputs: self.outputs.clone(),
|
||||||
parameters: self.parameters.clone(),
|
parameters: self.parameters.clone(),
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,16 @@ impl AmpSimNode {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load a bundled NAM model by name (e.g. "BossSD1").
|
||||||
|
pub fn load_bundled_model(&mut self, name: &str) -> Result<(), String> {
|
||||||
|
let mut model = super::bundled_models::load_bundled_model(name)
|
||||||
|
.ok_or_else(|| format!("Unknown bundled model: {}", name))??;
|
||||||
|
model.set_max_buffer_size(1024);
|
||||||
|
self.model = Some(model);
|
||||||
|
self.model_path = Some(format!("bundled:{}", name));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the loaded model path (for preset serialization).
|
/// Get the loaded model path (for preset serialization).
|
||||||
pub fn model_path(&self) -> Option<&str> {
|
pub fn model_path(&self) -> Option<&str> {
|
||||||
self.model_path.as_deref()
|
self.model_path.as_deref()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
use nam_ffi::NamModel;
|
||||||
|
|
||||||
|
struct BundledModel {
|
||||||
|
name: &'static str,
|
||||||
|
filename: &'static str,
|
||||||
|
data: &'static [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
const BUNDLED_MODELS: &[BundledModel] = &[
|
||||||
|
BundledModel {
|
||||||
|
name: "BossSD1",
|
||||||
|
filename: "BossSD1-WaveNet.nam",
|
||||||
|
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/BossSD1-WaveNet.nam"),
|
||||||
|
},
|
||||||
|
BundledModel {
|
||||||
|
name: "DeluxeReverb",
|
||||||
|
filename: "DeluxeReverb.nam",
|
||||||
|
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DeluxeReverb.nam"),
|
||||||
|
},
|
||||||
|
BundledModel {
|
||||||
|
name: "DingwallBass",
|
||||||
|
filename: "DingwallBass.nam",
|
||||||
|
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/DingwallBass.nam"),
|
||||||
|
},
|
||||||
|
BundledModel {
|
||||||
|
name: "Rhythm",
|
||||||
|
filename: "Rhythm.nam",
|
||||||
|
data: include_bytes!("../../../../../vendor/NeuralAudio/Utils/Models/Rhythm.nam"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Return display names of all bundled NAM models.
|
||||||
|
pub fn bundled_model_names() -> Vec<&'static str> {
|
||||||
|
BUNDLED_MODELS.iter().map(|m| m.name).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a bundled NAM model by display name.
|
||||||
|
/// Returns `None` if the name isn't found, `Some(Err(...))` on load failure.
|
||||||
|
pub fn load_bundled_model(name: &str) -> Option<Result<NamModel, String>> {
|
||||||
|
eprintln!("[NAM] load_bundled_model: looking up {:?}", name);
|
||||||
|
let model = BUNDLED_MODELS.iter().find(|m| m.name == name)?;
|
||||||
|
eprintln!("[NAM] Found bundled model: name={}, filename={}, data_len={}", model.name, model.filename, model.data.len());
|
||||||
|
Some(
|
||||||
|
NamModel::from_bytes(model.filename, model.data)
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("[NAM] from_bytes failed for {}: {}", model.filename, e);
|
||||||
|
e.to_string()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
mod amp_sim;
|
mod amp_sim;
|
||||||
|
pub mod bundled_models;
|
||||||
mod adsr;
|
mod adsr;
|
||||||
mod arpeggiator;
|
mod arpeggiator;
|
||||||
mod audio_input;
|
mod audio_input;
|
||||||
|
|
|
||||||
|
|
@ -5,27 +5,19 @@
|
||||||
//! Based on the libmypaint brush engine (ISC license, Martin Renold et al.).
|
//! Based on the libmypaint brush engine (ISC license, Martin Renold et al.).
|
||||||
//!
|
//!
|
||||||
//! ### Dab shape
|
//! ### Dab shape
|
||||||
//! For each pixel at normalised squared distance `rr = (dist / radius)²` from the
|
//! For each pixel at normalised distance `r = dist / radius` from the dab centre,
|
||||||
//! dab centre, the opacity weight is calculated using two linear segments:
|
//! the opacity weight uses a flat inner core and smooth quadratic outer falloff:
|
||||||
//!
|
//!
|
||||||
//! ```text
|
//! - `r > 1`: opa = 0 (outside dab)
|
||||||
//! opa
|
//! - `r ≤ hardness` (or hardness = 1): opa = 1 (fully opaque core)
|
||||||
//! ^
|
//! - `hardness < r ≤ 1`: `opa = ((1 - r) / (1 - hardness))²` (smooth falloff)
|
||||||
//! * .
|
|
||||||
//! | *
|
|
||||||
//! | .
|
|
||||||
//! +-----------*> rr
|
|
||||||
//! 0 hardness 1
|
|
||||||
//! ```
|
|
||||||
//!
|
//!
|
||||||
//! - segment 1 (rr ≤ hardness): `opa = 1 + rr * (-(1/hardness - 1))`
|
//! The GPU compute shader (`brush_dab.wgsl`) is the authoritative implementation.
|
||||||
//! - segment 2 (hardness < rr ≤ 1): `opa = hardness/(1-hardness) - rr * hardness/(1-hardness)`
|
|
||||||
//! - rr > 1: opa = 0
|
|
||||||
//!
|
//!
|
||||||
//! ### Dab placement
|
//! ### Dab placement
|
||||||
//! Dabs are placed along the stroke polyline at intervals of
|
//! Dabs are placed along the stroke polyline at intervals of
|
||||||
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
//! `spacing = radius * dabs_per_radius`. Fractional remainder is tracked across
|
||||||
//! consecutive `apply_stroke` calls via `StrokeState`.
|
//! consecutive calls via `StrokeState`.
|
||||||
//!
|
//!
|
||||||
//! ### Blending
|
//! ### Blending
|
||||||
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
//! Normal mode uses the standard "over" operator on premultiplied RGBA:
|
||||||
|
|
@ -120,7 +112,7 @@ impl BrushEngine {
|
||||||
RasterBlendMode::Smudge => 2u32,
|
RasterBlendMode::Smudge => 2u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut push_dab = |dabs: &mut Vec<GpuDab>,
|
let push_dab = |dabs: &mut Vec<GpuDab>,
|
||||||
bbox: &mut (i32, i32, i32, i32),
|
bbox: &mut (i32, i32, i32, i32),
|
||||||
x: f32, y: f32,
|
x: f32, y: f32,
|
||||||
radius: f32, opacity: f32,
|
radius: f32, opacity: f32,
|
||||||
|
|
@ -205,312 +197,9 @@ impl BrushEngine {
|
||||||
|
|
||||||
(dabs, bbox)
|
(dabs, bbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a complete stroke to a pixel buffer.
|
|
||||||
///
|
|
||||||
/// A fresh [`StrokeState`] is created for each stroke (starts with full dab
|
|
||||||
/// placement spacing so the first dab lands at the very first point).
|
|
||||||
pub fn apply_stroke(buffer: &mut RgbaImage, stroke: &StrokeRecord) {
|
|
||||||
let mut state = StrokeState::new();
|
|
||||||
// Ensure the very first point always gets a dab
|
|
||||||
state.distance_since_last_dab = f32::MAX;
|
|
||||||
Self::apply_stroke_with_state(buffer, stroke, &mut state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Apply a stroke segment to a buffer while preserving dab-placement state.
|
|
||||||
///
|
|
||||||
/// Use this when building up a stroke incrementally (e.g. live drawing) so
|
|
||||||
/// that dab spacing is consistent across motion events.
|
|
||||||
pub fn apply_stroke_with_state(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
stroke: &StrokeRecord,
|
|
||||||
state: &mut StrokeState,
|
|
||||||
) {
|
|
||||||
if stroke.points.len() < 2 {
|
|
||||||
// Single-point "tap": draw one dab at the given pressure
|
|
||||||
if let Some(pt) = stroke.points.first() {
|
|
||||||
let r = stroke.brush_settings.radius_at_pressure(pt.pressure);
|
|
||||||
let o = stroke.brush_settings.opacity_at_pressure(pt.pressure);
|
|
||||||
// Smudge has no drag direction on a single tap — skip painting
|
|
||||||
if !matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
|
||||||
Self::render_dab(buffer, pt.x, pt.y, r, stroke.brush_settings.hardness,
|
|
||||||
o, stroke.color, stroke.blend_mode);
|
|
||||||
}
|
|
||||||
state.distance_since_last_dab = 0.0;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for window in stroke.points.windows(2) {
|
|
||||||
let p0 = &window[0];
|
|
||||||
let p1 = &window[1];
|
|
||||||
|
|
||||||
let dx = p1.x - p0.x;
|
|
||||||
let dy = p1.y - p0.y;
|
|
||||||
let seg_len = (dx * dx + dy * dy).sqrt();
|
|
||||||
if seg_len < 1e-4 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interpolate across this segment
|
|
||||||
let mut t = 0.0f32;
|
|
||||||
while t < 1.0 {
|
|
||||||
let pressure = p0.pressure + t * (p1.pressure - p0.pressure);
|
|
||||||
|
|
||||||
let radius = stroke.brush_settings.radius_at_pressure(pressure);
|
|
||||||
let spacing = radius * stroke.brush_settings.dabs_per_radius;
|
|
||||||
let spacing = spacing.max(0.5); // at least half a pixel
|
|
||||||
|
|
||||||
let dist_to_next = spacing - state.distance_since_last_dab;
|
|
||||||
let seg_t_to_next = (dist_to_next / seg_len).max(0.0);
|
|
||||||
|
|
||||||
if seg_t_to_next > 1.0 - t {
|
|
||||||
// Not enough distance left in this segment for another dab
|
|
||||||
state.distance_since_last_dab += seg_len * (1.0 - t);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
t += seg_t_to_next;
|
|
||||||
let x2 = p0.x + t * dx;
|
|
||||||
let y2 = p0.y + t * dy;
|
|
||||||
let pressure2 = p0.pressure + t * (p1.pressure - p0.pressure);
|
|
||||||
|
|
||||||
let radius2 = stroke.brush_settings.radius_at_pressure(pressure2);
|
|
||||||
let opacity2 = stroke.brush_settings.opacity_at_pressure(pressure2);
|
|
||||||
|
|
||||||
if matches!(stroke.blend_mode, RasterBlendMode::Smudge) {
|
|
||||||
// Directional warp smudge: each pixel in the dab footprint
|
|
||||||
// samples from a position offset backwards along the stroke,
|
|
||||||
// preserving lateral color structure.
|
|
||||||
let ndx = dx / seg_len;
|
|
||||||
let ndy = dy / seg_len;
|
|
||||||
let smudge_dist = (radius2 * stroke.brush_settings.dabs_per_radius).max(1.0);
|
|
||||||
Self::render_smudge_dab(buffer, x2, y2, radius2,
|
|
||||||
stroke.brush_settings.hardness,
|
|
||||||
opacity2, ndx, ndy, smudge_dist);
|
|
||||||
} else {
|
|
||||||
Self::render_dab(buffer, x2, y2, radius2,
|
|
||||||
stroke.brush_settings.hardness,
|
|
||||||
opacity2, stroke.color, stroke.blend_mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.distance_since_last_dab = 0.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a single Gaussian dab at pixel position (x, y).
|
|
||||||
///
|
|
||||||
/// Uses the two-segment linear falloff from MyPaint/libmypaint for the
|
|
||||||
/// opacity mask, then blends using the requested `blend_mode`.
|
|
||||||
pub fn render_dab(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
radius: f32,
|
|
||||||
hardness: f32,
|
|
||||||
opacity: f32,
|
|
||||||
color: [f32; 4],
|
|
||||||
blend_mode: RasterBlendMode,
|
|
||||||
) {
|
|
||||||
if radius < 0.5 || opacity <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hardness = hardness.clamp(1e-3, 1.0);
|
|
||||||
|
|
||||||
// Pre-compute the two linear-segment coefficients (from libmypaint render_dab_mask)
|
|
||||||
let seg1_offset = 1.0f32;
|
|
||||||
let seg1_slope = -(1.0 / hardness - 1.0);
|
|
||||||
let seg2_offset = hardness / (1.0 - hardness);
|
|
||||||
let seg2_slope = -hardness / (1.0 - hardness);
|
|
||||||
|
|
||||||
let r_fringe = radius + 1.0;
|
|
||||||
let x0 = ((x - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let y0 = ((y - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
|
|
||||||
let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
|
|
||||||
|
|
||||||
let one_over_r2 = 1.0 / (radius * radius);
|
|
||||||
|
|
||||||
for py in y0..=y1 {
|
|
||||||
for px in x0..=x1 {
|
|
||||||
let dx = px as f32 + 0.5 - x;
|
|
||||||
let dy = py as f32 + 0.5 - y;
|
|
||||||
let rr = (dx * dx + dy * dy) * one_over_r2;
|
|
||||||
|
|
||||||
if rr > 1.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two-segment opacity (identical to libmypaint calculate_opa)
|
|
||||||
let opa_weight = if rr <= hardness {
|
|
||||||
seg1_offset + rr * seg1_slope
|
|
||||||
} else {
|
|
||||||
seg2_offset + rr * seg2_slope
|
|
||||||
}
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let dab_alpha = opa_weight * opacity * color[3];
|
|
||||||
if dab_alpha <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pixel = buffer.get_pixel_mut(px, py);
|
|
||||||
let dst = [
|
|
||||||
pixel[0] as f32 / 255.0,
|
|
||||||
pixel[1] as f32 / 255.0,
|
|
||||||
pixel[2] as f32 / 255.0,
|
|
||||||
pixel[3] as f32 / 255.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
let (out_r, out_g, out_b, out_a) = match blend_mode {
|
|
||||||
RasterBlendMode::Normal | RasterBlendMode::Smudge => {
|
|
||||||
// Standard "over" operator (smudge pre-computes its color upstream)
|
|
||||||
let oa = dab_alpha;
|
|
||||||
let ba = 1.0 - oa;
|
|
||||||
let out_a = oa + ba * dst[3];
|
|
||||||
let out_r = oa * color[0] + ba * dst[0];
|
|
||||||
let out_g = oa * color[1] + ba * dst[1];
|
|
||||||
let out_b = oa * color[2] + ba * dst[2];
|
|
||||||
(out_r, out_g, out_b, out_a)
|
|
||||||
}
|
|
||||||
RasterBlendMode::Erase => {
|
|
||||||
// Multiplicative erase: each dab removes dab_alpha *fraction* of remaining
|
|
||||||
// alpha. This prevents dense overlapping dabs from summing past 1.0 and
|
|
||||||
// fully erasing at low opacity — opacity now controls the per-dab fraction
|
|
||||||
// removed rather than an absolute amount.
|
|
||||||
let new_a = dst[3] * (1.0 - dab_alpha);
|
|
||||||
let scale = if dst[3] > 1e-6 { new_a / dst[3] } else { 0.0 };
|
|
||||||
(dst[0] * scale, dst[1] * scale, dst[2] * scale, new_a)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
pixel[0] = (out_r.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[1] = (out_g.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[2] = (out_b.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
pixel[3] = (out_a.clamp(0.0, 1.0) * 255.0) as u8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a smudge dab using directional per-pixel warp.
|
|
||||||
///
|
|
||||||
/// Each pixel in the dab footprint samples from the canvas at a position offset
|
|
||||||
/// backwards along `(ndx, ndy)` by `smudge_dist` pixels, then blends that
|
|
||||||
/// sampled color over the current pixel weighted by the dab opacity.
|
|
||||||
///
|
|
||||||
/// Because each pixel samples its own source position, lateral color structure
|
|
||||||
/// is preserved: dragging over a 1-pixel dot with a 20-pixel brush produces a
|
|
||||||
/// narrow streak rather than a uniform smear.
|
|
||||||
///
|
|
||||||
/// Updates are collected before any writes to avoid read/write aliasing.
|
|
||||||
fn render_smudge_dab(
|
|
||||||
buffer: &mut RgbaImage,
|
|
||||||
x: f32,
|
|
||||||
y: f32,
|
|
||||||
radius: f32,
|
|
||||||
hardness: f32,
|
|
||||||
opacity: f32,
|
|
||||||
ndx: f32, // normalized stroke direction x
|
|
||||||
ndy: f32, // normalized stroke direction y
|
|
||||||
smudge_dist: f32,
|
|
||||||
) {
|
|
||||||
if radius < 0.5 || opacity <= 0.0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let hardness = hardness.clamp(1e-3, 1.0);
|
|
||||||
let seg1_offset = 1.0f32;
|
|
||||||
let seg1_slope = -(1.0 / hardness - 1.0);
|
|
||||||
let seg2_offset = hardness / (1.0 - hardness);
|
|
||||||
let seg2_slope = -hardness / (1.0 - hardness);
|
|
||||||
|
|
||||||
let r_fringe = radius + 1.0;
|
|
||||||
let x0 = ((x - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let y0 = ((y - r_fringe).floor() as i32).max(0) as u32;
|
|
||||||
let x1 = ((x + r_fringe).ceil() as i32).min(buffer.width() as i32 - 1).max(0) as u32;
|
|
||||||
let y1 = ((y + r_fringe).ceil() as i32).min(buffer.height() as i32 - 1).max(0) as u32;
|
|
||||||
|
|
||||||
let one_over_r2 = 1.0 / (radius * radius);
|
|
||||||
|
|
||||||
// Collect updates before writing to avoid aliasing between source and dest reads
|
|
||||||
let mut updates: Vec<(u32, u32, [u8; 4])> = Vec::new();
|
|
||||||
|
|
||||||
for py in y0..=y1 {
|
|
||||||
for px in x0..=x1 {
|
|
||||||
let fdx = px as f32 + 0.5 - x;
|
|
||||||
let fdy = py as f32 + 0.5 - y;
|
|
||||||
let rr = (fdx * fdx + fdy * fdy) * one_over_r2;
|
|
||||||
|
|
||||||
if rr > 1.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let opa_weight = if rr <= hardness {
|
|
||||||
seg1_offset + rr * seg1_slope
|
|
||||||
} else {
|
|
||||||
seg2_offset + rr * seg2_slope
|
|
||||||
}
|
|
||||||
.clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let alpha = opa_weight * opacity;
|
|
||||||
if alpha <= 0.0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sample from one dab-spacing behind the current position along stroke
|
|
||||||
let src_x = px as f32 + 0.5 - ndx * smudge_dist;
|
|
||||||
let src_y = py as f32 + 0.5 - ndy * smudge_dist;
|
|
||||||
let src = Self::sample_bilinear(buffer, src_x, src_y);
|
|
||||||
|
|
||||||
let dst = buffer.get_pixel(px, py);
|
|
||||||
let da = 1.0 - alpha;
|
|
||||||
let out = [
|
|
||||||
((alpha * src[0] + da * dst[0] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[1] + da * dst[1] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[2] + da * dst[2] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
((alpha * src[3] + da * dst[3] as f32 / 255.0).clamp(0.0, 1.0) * 255.0) as u8,
|
|
||||||
];
|
|
||||||
updates.push((px, py, out));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (px, py, rgba) in updates {
|
|
||||||
let p = buffer.get_pixel_mut(px, py);
|
|
||||||
p[0] = rgba[0];
|
|
||||||
p[1] = rgba[1];
|
|
||||||
p[2] = rgba[2];
|
|
||||||
p[3] = rgba[3];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bilinearly sample a floating-point position from the buffer, clamped to bounds.
|
|
||||||
fn sample_bilinear(buffer: &RgbaImage, x: f32, y: f32) -> [f32; 4] {
|
|
||||||
let w = buffer.width() as i32;
|
|
||||||
let h = buffer.height() as i32;
|
|
||||||
let x0 = (x.floor() as i32).clamp(0, w - 1);
|
|
||||||
let y0 = (y.floor() as i32).clamp(0, h - 1);
|
|
||||||
let x1 = (x0 + 1).min(w - 1);
|
|
||||||
let y1 = (y0 + 1).min(h - 1);
|
|
||||||
let fx = (x - x0 as f32).clamp(0.0, 1.0);
|
|
||||||
let fy = (y - y0 as f32).clamp(0.0, 1.0);
|
|
||||||
|
|
||||||
let p00 = buffer.get_pixel(x0 as u32, y0 as u32);
|
|
||||||
let p10 = buffer.get_pixel(x1 as u32, y0 as u32);
|
|
||||||
let p01 = buffer.get_pixel(x0 as u32, y1 as u32);
|
|
||||||
let p11 = buffer.get_pixel(x1 as u32, y1 as u32);
|
|
||||||
|
|
||||||
let mut out = [0.0f32; 4];
|
|
||||||
for i in 0..4 {
|
|
||||||
let top = p00[i] as f32 * (1.0 - fx) + p10[i] as f32 * fx;
|
|
||||||
let bot = p01[i] as f32 * (1.0 - fx) + p11[i] as f32 * fx;
|
|
||||||
out[i] = (top * (1.0 - fy) + bot * fy) / 255.0;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Create an `RgbaImage` from a raw RGBA pixel buffer.
|
/// Create an `RgbaImage` from a raw RGBA pixel buffer.
|
||||||
///
|
///
|
||||||
/// If `raw` is empty a blank (transparent) image of the given dimensions is returned.
|
/// If `raw` is empty a blank (transparent) image of the given dimensions is returned.
|
||||||
|
|
@ -542,46 +231,6 @@ pub fn decode_png(data: &[u8]) -> Result<RgbaImage, String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::raster_layer::{StrokePoint, StrokeRecord, RasterBlendMode};
|
|
||||||
use crate::brush_settings::BrushSettings;
|
|
||||||
|
|
||||||
fn make_stroke(color: [f32; 4]) -> StrokeRecord {
|
|
||||||
StrokeRecord {
|
|
||||||
brush_settings: BrushSettings::default_round_hard(),
|
|
||||||
color,
|
|
||||||
blend_mode: RasterBlendMode::Normal,
|
|
||||||
points: vec![
|
|
||||||
StrokePoint { x: 10.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
|
|
||||||
StrokePoint { x: 50.0, y: 10.0, pressure: 0.8, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.1 },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_stroke_modifies_buffer() {
|
|
||||||
let mut img = RgbaImage::new(100, 100);
|
|
||||||
let stroke = make_stroke([1.0, 0.0, 0.0, 1.0]); // red
|
|
||||||
BrushEngine::apply_stroke(&mut img, &stroke);
|
|
||||||
// The center pixel should have some red
|
|
||||||
let px = img.get_pixel(30, 10);
|
|
||||||
assert!(px[0] > 0, "expected red paint");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_erase_reduces_alpha() {
|
|
||||||
let mut img = RgbaImage::from_pixel(100, 100, image::Rgba([200, 100, 50, 255]));
|
|
||||||
let stroke = StrokeRecord {
|
|
||||||
brush_settings: BrushSettings::default_round_hard(),
|
|
||||||
color: [0.0, 0.0, 0.0, 1.0],
|
|
||||||
blend_mode: RasterBlendMode::Erase,
|
|
||||||
points: vec![
|
|
||||||
StrokePoint { x: 50.0, y: 50.0, pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
BrushEngine::apply_stroke(&mut img, &stroke);
|
|
||||||
let px = img.get_pixel(50, 50);
|
|
||||||
assert!(px[3] < 255, "alpha should be reduced by erase");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_png_roundtrip() {
|
fn test_png_roundtrip() {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,10 @@ pub struct RasterFloatingSelection {
|
||||||
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
/// undo (via `RasterStrokeAction`) when the float is committed, and for
|
||||||
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
/// Cancel (Escape) to restore the canvas without creating an undo entry.
|
||||||
pub canvas_before: Vec<u8>,
|
pub canvas_before: Vec<u8>,
|
||||||
|
/// Key for this float's GPU canvas in `GpuBrushEngine::canvases`.
|
||||||
|
/// Allows painting strokes directly onto the float buffer (B) without
|
||||||
|
/// touching the layer canvas (A).
|
||||||
|
pub canvas_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks the most recently selected thing(s) across the entire document.
|
/// Tracks the most recently selected thing(s) across the entire document.
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,103 @@
|
||||||
/* Lightningbeam Editor Styles
|
/* Lightningbeam Editor Styles
|
||||||
* CSS with variables and selector-based theming
|
* CSS with variables and selector-based theming
|
||||||
|
*
|
||||||
|
* Tier 1: :root variables (design tokens)
|
||||||
|
* Tier 2: Class selectors (.button, .layer-header)
|
||||||
|
* Tier 3: Compound/contextual (#timeline .layer-header)
|
||||||
|
*
|
||||||
|
* Pseudo-states: .hover, .selected, .active, .pressed, .disabled
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
LIGHT MODE VARIABLES
|
LIGHT MODE VARIABLES (design tokens)
|
||||||
============================================ */
|
============================================ */
|
||||||
:root {
|
:root {
|
||||||
/* Base colors */
|
/* Semantic text colors */
|
||||||
--bg-primary: #f6f6f6;
|
--text-primary: #0f0f0f;
|
||||||
--bg-secondary: #ccc;
|
--text-secondary: #666;
|
||||||
|
--text-tertiary: #999;
|
||||||
|
--text-disabled: #bbb;
|
||||||
|
--text-on-accent: #fff;
|
||||||
|
|
||||||
|
/* App backgrounds */
|
||||||
|
--bg-app: #e0e0e0;
|
||||||
--bg-panel: #aaa;
|
--bg-panel: #aaa;
|
||||||
--bg-header: #ddd;
|
--bg-header: #ddd;
|
||||||
|
--bg-surface: #ccc;
|
||||||
|
--bg-surface-raised: #ddd;
|
||||||
|
--bg-surface-sunken: #bbb;
|
||||||
|
|
||||||
|
/* Legacy compat aliases */
|
||||||
|
--bg-primary: #f6f6f6;
|
||||||
|
--bg-secondary: #ccc;
|
||||||
--background-color: #ccc;
|
--background-color: #ccc;
|
||||||
--foreground-color: #ddd;
|
--foreground-color: #ddd;
|
||||||
--highlight: #ddd;
|
--highlight: #ddd;
|
||||||
--shadow: #999;
|
--shadow: #999;
|
||||||
--shade: #aaa;
|
--shade: #aaa;
|
||||||
|
|
||||||
/* Text colors */
|
/* Borders */
|
||||||
--text-primary: #0f0f0f;
|
--border-subtle: #bbb;
|
||||||
--text-secondary: #666;
|
--border-default: #999;
|
||||||
--text-tertiary: #999;
|
--border-strong: #555;
|
||||||
|
/* Legacy aliases */
|
||||||
/* Border colors */
|
|
||||||
--border-light: #bbb;
|
--border-light: #bbb;
|
||||||
--border-medium: #999;
|
--border-medium: #999;
|
||||||
--border-dark: #555;
|
--border-dark: #555;
|
||||||
|
|
||||||
/* UI backgrounds */
|
/* Accent */
|
||||||
|
--accent: #396cd8;
|
||||||
|
--accent-hover: #4a7de9;
|
||||||
|
--accent-active: #2a5cc8;
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
--grid-bg: #555;
|
--grid-bg: #555;
|
||||||
--grid-hover: #666;
|
--grid-hover: #666;
|
||||||
|
|
||||||
|
/* Layer type colors */
|
||||||
|
--layer-vector: #ffb464;
|
||||||
|
--layer-audio: #64b4ff;
|
||||||
|
--layer-midi: #64ff96;
|
||||||
|
--layer-video: #b464ff;
|
||||||
|
--layer-effect: #ff64b4;
|
||||||
|
--layer-group: #00b4b4;
|
||||||
|
--layer-raster: #a064c8;
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
--status-error: #dc3232;
|
||||||
|
--status-warning: #dcc832;
|
||||||
|
--status-success: #32c850;
|
||||||
|
--scrubber: #cc2222;
|
||||||
|
|
||||||
|
/* Piano */
|
||||||
|
--piano-white-key: #ffffff;
|
||||||
|
--piano-white-key-pressed: #6496ff;
|
||||||
|
--piano-black-key: #000000;
|
||||||
|
--piano-black-key-pressed: #3264c8;
|
||||||
|
--piano-key-border: #000000;
|
||||||
|
--piano-white-label: #333333;
|
||||||
|
--piano-black-label: #ffffffb2;
|
||||||
|
--piano-sustain-on: #64c864;
|
||||||
|
--piano-sustain-off: #808080;
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
--tool-selected-bg: #466496;
|
||||||
|
--tool-unselected-bg: #999;
|
||||||
|
--tool-hover-border: #b4b4b4;
|
||||||
|
--tool-selected-border: #6496ff;
|
||||||
|
--tool-arrow-color: #c8c8c8;
|
||||||
|
--color-swatch-border: #555;
|
||||||
|
--checkerboard-light: #b4b4b4;
|
||||||
|
--checkerboard-dark: #787878;
|
||||||
|
|
||||||
/* Dimensions */
|
/* Dimensions */
|
||||||
--header-height: 40px;
|
--header-height: 40px;
|
||||||
|
--layer-height: 60px;
|
||||||
|
--ruler-height: 30px;
|
||||||
|
--layer-header-width: 200px;
|
||||||
|
--border-radius: 4px;
|
||||||
|
--font-size-small: 11px;
|
||||||
|
--font-size-default: 13px;
|
||||||
--pane-border-width: 1px;
|
--pane-border-width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,35 +106,73 @@
|
||||||
============================================ */
|
============================================ */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
/* Base colors */
|
/* Semantic text colors */
|
||||||
|
--text-primary: #f6f6f6;
|
||||||
|
--text-secondary: #aaa;
|
||||||
|
--text-tertiary: #777;
|
||||||
|
--text-disabled: #555;
|
||||||
|
--text-on-accent: #fff;
|
||||||
|
|
||||||
|
/* App backgrounds */
|
||||||
|
--bg-app: #2a2a2a;
|
||||||
|
--bg-panel: #222;
|
||||||
|
--bg-header: #353535;
|
||||||
|
--bg-surface: #2f2f2f;
|
||||||
|
--bg-surface-raised: #3f3f3f;
|
||||||
|
--bg-surface-sunken: #1a1a1a;
|
||||||
|
|
||||||
|
/* Legacy compat aliases */
|
||||||
--bg-primary: #2f2f2f;
|
--bg-primary: #2f2f2f;
|
||||||
--bg-secondary: #3f3f3f;
|
--bg-secondary: #3f3f3f;
|
||||||
--bg-panel: #222222;
|
|
||||||
--bg-header: #444;
|
|
||||||
--background-color: #333;
|
--background-color: #333;
|
||||||
--foreground-color: #888;
|
--foreground-color: #888;
|
||||||
--highlight: #4f4f4f;
|
--highlight: #4f4f4f;
|
||||||
--shadow: #111;
|
--shadow: #111;
|
||||||
--shade: #222;
|
--shade: #222;
|
||||||
|
|
||||||
/* Text colors */
|
/* Borders */
|
||||||
--text-primary: #f6f6f6;
|
--border-subtle: #333;
|
||||||
--text-secondary: #aaa;
|
--border-default: #444;
|
||||||
--text-tertiary: #777;
|
--border-strong: #555;
|
||||||
|
/* Legacy aliases */
|
||||||
/* Border colors */
|
|
||||||
--border-light: #555;
|
--border-light: #555;
|
||||||
--border-medium: #444;
|
--border-medium: #444;
|
||||||
--border-dark: #333;
|
--border-dark: #333;
|
||||||
|
|
||||||
/* UI backgrounds */
|
/* Accent */
|
||||||
|
--accent: #396cd8;
|
||||||
|
--accent-hover: #4a7de9;
|
||||||
|
--accent-active: #2a5cc8;
|
||||||
|
|
||||||
|
/* Grid */
|
||||||
--grid-bg: #0f0f0f;
|
--grid-bg: #0f0f0f;
|
||||||
--grid-hover: #1a1a1a;
|
--grid-hover: #1a1a1a;
|
||||||
|
|
||||||
|
/* Piano */
|
||||||
|
--piano-white-key: #ffffff;
|
||||||
|
--piano-white-key-pressed: #6496ff;
|
||||||
|
--piano-black-key: #000000;
|
||||||
|
--piano-black-key-pressed: #3264c8;
|
||||||
|
--piano-key-border: #000000;
|
||||||
|
--piano-white-label: #333333;
|
||||||
|
--piano-black-label: #ffffffb2;
|
||||||
|
--piano-sustain-on: #64c864;
|
||||||
|
--piano-sustain-off: #808080;
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
--tool-selected-bg: #466496;
|
||||||
|
--tool-unselected-bg: #323232;
|
||||||
|
--tool-hover-border: #b4b4b4;
|
||||||
|
--tool-selected-border: #6496ff;
|
||||||
|
--tool-arrow-color: #c8c8c8;
|
||||||
|
--color-swatch-border: #505050;
|
||||||
|
--checkerboard-light: #b4b4b4;
|
||||||
|
--checkerboard-dark: #787878;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
COMPONENT STYLES (applies to both modes)
|
COMPONENT STYLES
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
/* Pane headers */
|
/* Pane headers */
|
||||||
|
|
@ -77,22 +180,22 @@
|
||||||
background-color: var(--bg-header);
|
background-color: var(--bg-header);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
border-color: var(--border-medium);
|
border-color: var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pane content areas */
|
/* Pane content areas */
|
||||||
.pane-content {
|
.pane-content {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* General panel */
|
/* Generic panel */
|
||||||
.panel {
|
.panel {
|
||||||
background-color: var(--bg-panel);
|
background-color: var(--bg-panel);
|
||||||
border-color: var(--border-medium);
|
border-color: var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid backgrounds */
|
/* Grid */
|
||||||
.grid {
|
.grid {
|
||||||
background-color: var(--grid-bg);
|
background-color: var(--grid-bg);
|
||||||
}
|
}
|
||||||
|
|
@ -101,7 +204,10 @@
|
||||||
background-color: var(--grid-hover);
|
background-color: var(--grid-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Specific pane IDs */
|
/* ============================================
|
||||||
|
PANE-SPECIFIC STYLES
|
||||||
|
============================================ */
|
||||||
|
|
||||||
#stage {
|
#stage {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +229,22 @@
|
||||||
border-color: #4d4d4d;
|
border-color: #4d4d4d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Timeline specific elements */
|
#shader-editor {
|
||||||
|
background-color: #19191e;
|
||||||
|
}
|
||||||
|
|
||||||
|
#outliner {
|
||||||
|
background-color: #283219;
|
||||||
|
}
|
||||||
|
|
||||||
|
#piano-roll {
|
||||||
|
background-color: #1e1e23;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TIMELINE ELEMENTS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.timeline-background {
|
.timeline-background {
|
||||||
background-color: var(--shade);
|
background-color: var(--shade);
|
||||||
}
|
}
|
||||||
|
|
@ -137,8 +258,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-scrubber {
|
.timeline-scrubber {
|
||||||
background-color: #cc2222;
|
background-color: var(--scrubber);
|
||||||
border-color: #cc2222;
|
border-color: var(--scrubber);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-layer-active {
|
.timeline-layer-active {
|
||||||
|
|
@ -157,18 +278,75 @@
|
||||||
background-color: var(--foreground-color);
|
background-color: var(--foreground-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons */
|
.timeline-note {
|
||||||
|
background-color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layer type badges */
|
||||||
|
.layer-type-vector {
|
||||||
|
background-color: var(--layer-vector);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-audio {
|
||||||
|
background-color: var(--layer-audio);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-midi {
|
||||||
|
background-color: var(--layer-midi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-video {
|
||||||
|
background-color: var(--layer-video);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-effect {
|
||||||
|
background-color: var(--layer-effect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-group {
|
||||||
|
background-color: var(--layer-group);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layer-type-raster {
|
||||||
|
background-color: var(--layer-raster);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
BUTTONS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
background-color: var(--bg-primary);
|
background-color: var(--bg-primary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--border-medium);
|
border-color: var(--border-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-hover {
|
.button.hover {
|
||||||
border-color: #396cd8;
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text */
|
.button.selected {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tool buttons */
|
||||||
|
.tool-button {
|
||||||
|
background-color: var(--tool-unselected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-button.selected {
|
||||||
|
background-color: var(--tool-selected-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-button.hover {
|
||||||
|
border-color: var(--tool-hover-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
TEXT
|
||||||
|
============================================ */
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
@ -176,3 +354,81 @@
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-disabled {
|
||||||
|
color: var(--text-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
STATUS
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-warning {
|
||||||
|
color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-success {
|
||||||
|
color: var(--status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
PANE CHROME (borders, icon buttons)
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.pane-chrome {
|
||||||
|
background-color: #232323;
|
||||||
|
border-color: #505050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-chrome-separator {
|
||||||
|
border-color: #323232;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pane-icon-button {
|
||||||
|
background-color: #323232c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
NODE EDITOR
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
#node-editor .grid {
|
||||||
|
background-color: #373737;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
VIRTUAL PIANO
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
.piano-white-key {
|
||||||
|
background-color: var(--piano-white-key);
|
||||||
|
border-color: var(--piano-key-border);
|
||||||
|
color: var(--piano-white-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-white-key.pressed {
|
||||||
|
background-color: var(--piano-white-key-pressed);
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-black-key {
|
||||||
|
background-color: var(--piano-black-key);
|
||||||
|
color: var(--piano-black-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
.piano-black-key.pressed {
|
||||||
|
background-color: var(--piano-black-key-pressed);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sustain-indicator.active {
|
||||||
|
color: var(--piano-sustain-on);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sustain-indicator {
|
||||||
|
color: var(--piano-sustain-off);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -269,146 +269,150 @@ impl GpuBrushEngine {
|
||||||
.map_or(true, |c| c.width != width || c.height != height);
|
.map_or(true, |c| c.width != width || c.height != height);
|
||||||
if needs_new {
|
if needs_new {
|
||||||
self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height));
|
self.canvases.insert(keyframe_id, CanvasPair::new(device, width, height));
|
||||||
|
} else {
|
||||||
}
|
}
|
||||||
self.canvases.get_mut(&keyframe_id).unwrap()
|
self.canvases.get_mut(&keyframe_id).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`.
|
/// Dispatch the brush compute shader for `dabs` onto the canvas of `keyframe_id`.
|
||||||
///
|
///
|
||||||
/// * Pre-fills `dst` from `src` so untouched pixels are preserved.
|
/// Each dab is dispatched as a separate copy+compute+swap so that every dab
|
||||||
/// * Dispatches the compute shader.
|
/// reads the result of the previous one. This is required for the smudge tool:
|
||||||
/// * Swaps src/dst so the just-written texture becomes the new source.
|
/// if all dabs were batched into one dispatch they would all read the pre-batch
|
||||||
|
/// canvas state, breaking the carry-forward that makes smudge drag pixels along.
|
||||||
///
|
///
|
||||||
/// `dab_bbox` is `(x0, y0, x1, y1)` — the union bounding box of all dabs.
|
/// `dab_bbox` is the union bounding box (unused here; kept for API compat).
|
||||||
/// If `dabs` is empty or the bbox is invalid, does nothing.
|
/// If `dabs` is empty, does nothing.
|
||||||
pub fn render_dabs(
|
pub fn render_dabs(
|
||||||
&mut self,
|
&mut self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
queue: &wgpu::Queue,
|
queue: &wgpu::Queue,
|
||||||
keyframe_id: Uuid,
|
keyframe_id: Uuid,
|
||||||
dabs: &[GpuDab],
|
dabs: &[GpuDab],
|
||||||
bbox: (i32, i32, i32, i32),
|
_bbox: (i32, i32, i32, i32),
|
||||||
canvas_w: u32,
|
canvas_w: u32,
|
||||||
canvas_h: u32,
|
canvas_h: u32,
|
||||||
) {
|
) {
|
||||||
if dabs.is_empty() || bbox.0 == i32::MAX { return; }
|
if dabs.is_empty() { return; }
|
||||||
|
|
||||||
let canvas = match self.canvases.get_mut(&keyframe_id) {
|
if !self.canvases.contains_key(&keyframe_id) { return; }
|
||||||
Some(c) => c,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clamp bbox to canvas bounds
|
|
||||||
let x0 = bbox.0.max(0) as u32;
|
|
||||||
let y0 = bbox.1.max(0) as u32;
|
|
||||||
let x1 = (bbox.2.min(canvas_w as i32 - 1)).max(0) as u32;
|
|
||||||
let y1 = (bbox.3.min(canvas_h as i32 - 1)).max(0) as u32;
|
|
||||||
if x1 < x0 || y1 < y0 { return; }
|
|
||||||
|
|
||||||
let bbox_w = x1 - x0 + 1;
|
|
||||||
let bbox_h = y1 - y0 + 1;
|
|
||||||
|
|
||||||
// --- Pre-fill dst from src: copy the ENTIRE canvas so every pixel outside
|
|
||||||
// the dab bounding box is preserved across the ping-pong swap.
|
|
||||||
// Copying only the bbox would leave dst with data from two frames ago
|
|
||||||
// in all other regions, causing missing dabs on alternating frames. ---
|
|
||||||
let mut copy_encoder = device.create_command_encoder(
|
|
||||||
&wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") },
|
|
||||||
);
|
|
||||||
let full_extent = wgpu::Extent3d {
|
let full_extent = wgpu::Extent3d {
|
||||||
width: canvas.width,
|
width: self.canvases[&keyframe_id].width,
|
||||||
height: canvas.height,
|
height: self.canvases[&keyframe_id].height,
|
||||||
depth_or_array_layers: 1,
|
depth_or_array_layers: 1,
|
||||||
};
|
};
|
||||||
copy_encoder.copy_texture_to_texture(
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: canvas.src(),
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
wgpu::TexelCopyTextureInfo {
|
|
||||||
texture: canvas.dst(),
|
|
||||||
mip_level: 0,
|
|
||||||
origin: wgpu::Origin3d::ZERO,
|
|
||||||
aspect: wgpu::TextureAspect::All,
|
|
||||||
},
|
|
||||||
full_extent,
|
|
||||||
);
|
|
||||||
queue.submit(Some(copy_encoder.finish()));
|
|
||||||
|
|
||||||
// --- Upload dab data and params ---
|
for dab in dabs {
|
||||||
let dab_bytes = bytemuck::cast_slice(dabs);
|
// Per-dab bounding box
|
||||||
let dab_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
let r_fringe = dab.radius + 1.0;
|
||||||
label: Some("dab_storage_buf"),
|
let dx0 = (dab.x - r_fringe).floor() as i32;
|
||||||
size: dab_bytes.len() as u64,
|
let dy0 = (dab.y - r_fringe).floor() as i32;
|
||||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
let dx1 = (dab.x + r_fringe).ceil() as i32;
|
||||||
mapped_at_creation: false,
|
let dy1 = (dab.y + r_fringe).ceil() as i32;
|
||||||
});
|
|
||||||
queue.write_buffer(&dab_buf, 0, dab_bytes);
|
|
||||||
|
|
||||||
let params = DabParams {
|
let x0 = dx0.max(0) as u32;
|
||||||
bbox_x0: x0 as i32,
|
let y0 = dy0.max(0) as u32;
|
||||||
bbox_y0: y0 as i32,
|
let x1 = (dx1.min(canvas_w as i32 - 1)).max(0) as u32;
|
||||||
bbox_w,
|
let y1 = (dy1.min(canvas_h as i32 - 1)).max(0) as u32;
|
||||||
bbox_h,
|
if x1 < x0 || y1 < y0 { continue; }
|
||||||
num_dabs: dabs.len() as u32,
|
|
||||||
canvas_w,
|
|
||||||
canvas_h,
|
|
||||||
_pad: 0,
|
|
||||||
};
|
|
||||||
let params_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
|
||||||
label: Some("dab_params_buf"),
|
|
||||||
size: std::mem::size_of::<DabParams>() as u64,
|
|
||||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
|
||||||
mapped_at_creation: false,
|
|
||||||
});
|
|
||||||
queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms));
|
|
||||||
|
|
||||||
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
let bbox_w = x1 - x0 + 1;
|
||||||
label: Some("brush_dab_bg"),
|
let bbox_h = y1 - y0 + 1;
|
||||||
layout: &self.compute_bg_layout,
|
|
||||||
entries: &[
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 0,
|
|
||||||
resource: dab_buf.as_entire_binding(),
|
|
||||||
},
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 1,
|
|
||||||
resource: params_buf.as_entire_binding(),
|
|
||||||
},
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 2,
|
|
||||||
resource: wgpu::BindingResource::TextureView(canvas.src_view()),
|
|
||||||
},
|
|
||||||
wgpu::BindGroupEntry {
|
|
||||||
binding: 3,
|
|
||||||
resource: wgpu::BindingResource::TextureView(canvas.dst_view()),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Dispatch ---
|
let canvas = self.canvases.get_mut(&keyframe_id).unwrap();
|
||||||
let mut compute_encoder = device.create_command_encoder(
|
|
||||||
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") },
|
// Pre-fill dst from src so pixels outside this dab's bbox are preserved.
|
||||||
);
|
let mut copy_enc = device.create_command_encoder(
|
||||||
{
|
&wgpu::CommandEncoderDescriptor { label: Some("canvas_copy_encoder") },
|
||||||
let mut pass = compute_encoder.begin_compute_pass(
|
|
||||||
&wgpu::ComputePassDescriptor {
|
|
||||||
label: Some("brush_dab_pass"),
|
|
||||||
timestamp_writes: None,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
pass.set_pipeline(&self.compute_pipeline);
|
copy_enc.copy_texture_to_texture(
|
||||||
pass.set_bind_group(0, &bg, &[]);
|
wgpu::TexelCopyTextureInfo {
|
||||||
let wg_x = bbox_w.div_ceil(8);
|
texture: canvas.src(),
|
||||||
let wg_y = bbox_h.div_ceil(8);
|
mip_level: 0,
|
||||||
pass.dispatch_workgroups(wg_x, wg_y, 1);
|
origin: wgpu::Origin3d::ZERO,
|
||||||
}
|
aspect: wgpu::TextureAspect::All,
|
||||||
queue.submit(Some(compute_encoder.finish()));
|
},
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: canvas.dst(),
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
full_extent,
|
||||||
|
);
|
||||||
|
queue.submit(Some(copy_enc.finish()));
|
||||||
|
|
||||||
// Swap: dst is now the authoritative source
|
// Upload single-dab buffer and params
|
||||||
canvas.swap();
|
let dab_bytes = bytemuck::bytes_of(dab);
|
||||||
|
let dab_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("dab_storage_buf"),
|
||||||
|
size: dab_bytes.len() as u64,
|
||||||
|
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
queue.write_buffer(&dab_buf, 0, dab_bytes);
|
||||||
|
|
||||||
|
let params = DabParams {
|
||||||
|
bbox_x0: x0 as i32,
|
||||||
|
bbox_y0: y0 as i32,
|
||||||
|
bbox_w,
|
||||||
|
bbox_h,
|
||||||
|
num_dabs: 1,
|
||||||
|
canvas_w,
|
||||||
|
canvas_h,
|
||||||
|
_pad: 0,
|
||||||
|
};
|
||||||
|
let params_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some("dab_params_buf"),
|
||||||
|
size: std::mem::size_of::<DabParams>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
queue.write_buffer(¶ms_buf, 0, bytemuck::bytes_of(¶ms));
|
||||||
|
|
||||||
|
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some("brush_dab_bg"),
|
||||||
|
layout: &self.compute_bg_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: dab_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: params_buf.as_entire_binding(),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: wgpu::BindingResource::TextureView(canvas.src_view()),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(canvas.dst_view()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut compute_enc = device.create_command_encoder(
|
||||||
|
&wgpu::CommandEncoderDescriptor { label: Some("brush_dab_encoder") },
|
||||||
|
);
|
||||||
|
{
|
||||||
|
let mut pass = compute_enc.begin_compute_pass(
|
||||||
|
&wgpu::ComputePassDescriptor {
|
||||||
|
label: Some("brush_dab_pass"),
|
||||||
|
timestamp_writes: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
pass.set_pipeline(&self.compute_pipeline);
|
||||||
|
pass.set_bind_group(0, &bg, &[]);
|
||||||
|
pass.dispatch_workgroups(bbox_w.div_ceil(8), bbox_h.div_ceil(8), 1);
|
||||||
|
}
|
||||||
|
queue.submit(Some(compute_enc.finish()));
|
||||||
|
|
||||||
|
// Swap: the just-written dst becomes src for the next dab.
|
||||||
|
canvas.swap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the current canvas back to a CPU `Vec<u8>` (raw RGBA, row-major).
|
/// Read the current canvas back to a CPU `Vec<u8>` (raw RGBA, row-major).
|
||||||
|
|
@ -512,6 +516,8 @@ pub struct CanvasBlitPipeline {
|
||||||
pub pipeline: wgpu::RenderPipeline,
|
pub pipeline: wgpu::RenderPipeline,
|
||||||
pub bg_layout: wgpu::BindGroupLayout,
|
pub bg_layout: wgpu::BindGroupLayout,
|
||||||
pub sampler: wgpu::Sampler,
|
pub sampler: wgpu::Sampler,
|
||||||
|
/// Nearest-neighbour sampler used for the selection mask texture.
|
||||||
|
pub mask_sampler: wgpu::Sampler,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Camera parameters uniform for canvas_blit.wgsl.
|
/// Camera parameters uniform for canvas_blit.wgsl.
|
||||||
|
|
@ -567,6 +573,24 @@ impl CanvasBlitPipeline {
|
||||||
},
|
},
|
||||||
count: None,
|
count: None,
|
||||||
},
|
},
|
||||||
|
// Binding 3: selection mask texture (R8Unorm; 1×1 white = no mask)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 3,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
// Binding 4: nearest sampler for mask (sharp selection edges)
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 4,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -593,7 +617,7 @@ impl CanvasBlitPipeline {
|
||||||
module: &shader,
|
module: &shader,
|
||||||
entry_point: Some("fs_main"),
|
entry_point: Some("fs_main"),
|
||||||
targets: &[Some(wgpu::ColorTargetState {
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
format: wgpu::TextureFormat::Rgba16Float,
|
||||||
blend: None, // canvas already stores premultiplied alpha
|
blend: None, // canvas already stores premultiplied alpha
|
||||||
write_mask: wgpu::ColorWrites::ALL,
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
})],
|
})],
|
||||||
|
|
@ -621,12 +645,25 @@ impl CanvasBlitPipeline {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { pipeline, bg_layout, sampler }
|
let mask_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("canvas_mask_sampler"),
|
||||||
|
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||||
|
address_mode_w: wgpu::AddressMode::ClampToEdge,
|
||||||
|
mag_filter: wgpu::FilterMode::Nearest,
|
||||||
|
min_filter: wgpu::FilterMode::Nearest,
|
||||||
|
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self { pipeline, bg_layout, sampler, mask_sampler }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the canvas texture into `target_view` (Rgba8Unorm) with the given camera.
|
/// Render the canvas texture into `target_view` (Rgba16Float) with the given camera.
|
||||||
///
|
///
|
||||||
/// `target_view` is cleared to transparent before writing.
|
/// `target_view` is cleared to transparent before writing.
|
||||||
|
/// `mask_view` is an R8Unorm texture in canvas-pixel space: 255 = keep, 0 = discard.
|
||||||
|
/// Pass `None` to use the built-in 1×1 all-white default (no masking).
|
||||||
pub fn blit(
|
pub fn blit(
|
||||||
&self,
|
&self,
|
||||||
device: &wgpu::Device,
|
device: &wgpu::Device,
|
||||||
|
|
@ -634,7 +671,40 @@ impl CanvasBlitPipeline {
|
||||||
canvas_view: &wgpu::TextureView,
|
canvas_view: &wgpu::TextureView,
|
||||||
target_view: &wgpu::TextureView,
|
target_view: &wgpu::TextureView,
|
||||||
camera: &CameraParams,
|
camera: &CameraParams,
|
||||||
|
mask_view: Option<&wgpu::TextureView>,
|
||||||
) {
|
) {
|
||||||
|
// When no mask is provided, create a temporary 1×1 all-white texture.
|
||||||
|
// (queue is already available here, unlike in new())
|
||||||
|
let tmp_mask_tex;
|
||||||
|
let tmp_mask_view;
|
||||||
|
let mask_view: &wgpu::TextureView = match mask_view {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
tmp_mask_tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("canvas_default_mask"),
|
||||||
|
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &tmp_mask_tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&[255u8],
|
||||||
|
wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(1), rows_per_image: Some(1) },
|
||||||
|
wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||||
|
);
|
||||||
|
tmp_mask_view = tmp_mask_tex.create_view(&Default::default());
|
||||||
|
&tmp_mask_view
|
||||||
|
}
|
||||||
|
};
|
||||||
// Upload camera params
|
// Upload camera params
|
||||||
let cam_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
let cam_buf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
label: Some("canvas_blit_cam_buf"),
|
label: Some("canvas_blit_cam_buf"),
|
||||||
|
|
@ -660,6 +730,14 @@ impl CanvasBlitPipeline {
|
||||||
binding: 2,
|
binding: 2,
|
||||||
resource: cam_buf.as_entire_binding(),
|
resource: cam_buf.as_entire_binding(),
|
||||||
},
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 3,
|
||||||
|
resource: wgpu::BindingResource::TextureView(mask_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 4,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.mask_sampler),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ mod menu;
|
||||||
use menu::{MenuAction, MenuSystem};
|
use menu::{MenuAction, MenuSystem};
|
||||||
|
|
||||||
mod theme;
|
mod theme;
|
||||||
|
mod theme_render;
|
||||||
use theme::{Theme, ThemeMode};
|
use theme::{Theme, ThemeMode};
|
||||||
|
|
||||||
mod waveform_gpu;
|
mod waveform_gpu;
|
||||||
|
|
@ -418,7 +419,7 @@ impl FocusIconCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, ctx: &egui::Context) -> Option<&egui::TextureHandle> {
|
fn get_or_load(&mut self, icon: FocusIcon, icon_color: egui::Color32, display_size: f32, ctx: &egui::Context) -> Option<&egui::TextureHandle> {
|
||||||
if !self.icons.contains_key(&icon) {
|
if !self.icons.contains_key(&icon) {
|
||||||
let (svg_bytes, svg_filename) = match icon {
|
let (svg_bytes, svg_filename) = match icon {
|
||||||
FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"),
|
FocusIcon::Animation => (focus_icons::ANIMATION, "focus-animation.svg"),
|
||||||
|
|
@ -435,7 +436,8 @@ impl FocusIconCache {
|
||||||
);
|
);
|
||||||
let svg_with_color = svg_data.replace("currentColor", &color_hex);
|
let svg_with_color = svg_data.replace("currentColor", &color_hex);
|
||||||
|
|
||||||
if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, 120, ctx) {
|
let render_size = (display_size * ctx.pixels_per_point()).ceil() as u32;
|
||||||
|
if let Some(texture) = rasterize_svg(svg_with_color.as_bytes(), svg_filename, render_size, ctx) {
|
||||||
self.icons.insert(icon, texture);
|
self.icons.insert(icon, texture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1310,12 +1312,13 @@ impl EditorApp {
|
||||||
|
|
||||||
// Icon area - render SVG texture
|
// Icon area - render SVG texture
|
||||||
let icon_color = egui::Color32::from_gray(200);
|
let icon_color = egui::Color32::from_gray(200);
|
||||||
let icon_center = rect.center_top() + egui::vec2(0.0, 50.0);
|
let title_area_height = 40.0;
|
||||||
let icon_display_size = 60.0;
|
let icon_display_size = rect.width() - 16.0;
|
||||||
|
let icon_center = egui::pos2(rect.center().x, rect.min.y + (rect.height() - title_area_height) * 0.5);
|
||||||
|
|
||||||
// Get or load the SVG icon texture
|
// Get or load the SVG icon texture
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, &ctx) {
|
if let Some(texture) = self.focus_icon_cache.get_or_load(icon, icon_color, icon_display_size, &ctx) {
|
||||||
let texture_size = texture.size_vec2();
|
let texture_size = texture.size_vec2();
|
||||||
let scale = icon_display_size / texture_size.x.max(texture_size.y);
|
let scale = icon_display_size / texture_size.x.max(texture_size.y);
|
||||||
let scaled_size = texture_size * scale;
|
let scaled_size = texture_size * scale;
|
||||||
|
|
@ -1919,7 +1922,7 @@ impl EditorApp {
|
||||||
use lightningbeam_core::actions::RasterStrokeAction;
|
use lightningbeam_core::actions::RasterStrokeAction;
|
||||||
|
|
||||||
let Some(float) = self.selection.raster_floating.take() else { return };
|
let Some(float) = self.selection.raster_floating.take() else { return };
|
||||||
self.selection.raster_selection = None;
|
let sel = self.selection.raster_selection.take();
|
||||||
|
|
||||||
let document = self.action_executor.document_mut();
|
let document = self.action_executor.document_mut();
|
||||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else { return };
|
||||||
|
|
@ -1930,11 +1933,36 @@ impl EditorApp {
|
||||||
if kf.raw_pixels.len() != expected {
|
if kf.raw_pixels.len() != expected {
|
||||||
kf.raw_pixels.resize(expected, 0);
|
kf.raw_pixels.resize(expected, 0);
|
||||||
}
|
}
|
||||||
Self::composite_over(
|
|
||||||
&mut kf.raw_pixels, kf.width, kf.height,
|
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels,
|
||||||
&float.pixels, float.width, float.height,
|
// masked by the selection C when present.
|
||||||
float.x, float.y,
|
for row in 0..float.height {
|
||||||
);
|
let dy = float.y + row as i32;
|
||||||
|
if dy < 0 || dy >= kf.height as i32 { continue; }
|
||||||
|
for col in 0..float.width {
|
||||||
|
let dx = float.x + col as i32;
|
||||||
|
if dx < 0 || dx >= kf.width as i32 { continue; }
|
||||||
|
// Apply selection mask C (if selection exists, only composite where inside)
|
||||||
|
if let Some(ref s) = sel {
|
||||||
|
if !s.contains_pixel(dx, dy) { continue; }
|
||||||
|
}
|
||||||
|
let si = ((row * float.width + col) * 4) as usize;
|
||||||
|
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
|
||||||
|
let sa = float.pixels[si + 3] as u32;
|
||||||
|
if sa == 0 { continue; }
|
||||||
|
let da = kf.raw_pixels[di + 3] as u32;
|
||||||
|
let out_a = sa + da * (255 - sa) / 255;
|
||||||
|
kf.raw_pixels[di + 3] = out_a as u8;
|
||||||
|
if out_a > 0 {
|
||||||
|
for c in 0..3 {
|
||||||
|
let v = float.pixels[si + c] as u32 * 255
|
||||||
|
+ kf.raw_pixels[di + c] as u32 * (255 - sa);
|
||||||
|
kf.raw_pixels[di + c] = (v / 255).min(255) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let canvas_after = kf.raw_pixels.clone();
|
let canvas_after = kf.raw_pixels.clone();
|
||||||
let w = kf.width;
|
let w = kf.width;
|
||||||
let h = kf.height;
|
let h = kf.height;
|
||||||
|
|
@ -2393,6 +2421,7 @@ impl EditorApp {
|
||||||
layer_id,
|
layer_id,
|
||||||
time: self.playback_time,
|
time: self.playback_time,
|
||||||
canvas_before,
|
canvas_before,
|
||||||
|
canvas_id: uuid::Uuid::new_v4(),
|
||||||
});
|
});
|
||||||
// Update the marquee to show the floating selection bounds.
|
// Update the marquee to show the floating selection bounds.
|
||||||
self.selection.raster_selection = Some(RasterSelection::Rect(
|
self.selection.raster_selection = Some(RasterSelection::Rect(
|
||||||
|
|
@ -6009,6 +6038,7 @@ impl eframe::App for EditorApp {
|
||||||
self.audio_controller.as_ref(),
|
self.audio_controller.as_ref(),
|
||||||
);
|
);
|
||||||
debug_overlay::render_debug_overlay(ctx, &stats);
|
debug_overlay::render_debug_overlay(ctx, &stats);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render custom cursor overlay (on top of everything including debug overlay)
|
// Render custom cursor overlay (on top of everything including debug overlay)
|
||||||
|
|
@ -6342,22 +6372,20 @@ fn render_pane(
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw header background
|
// Draw header background
|
||||||
ui.painter().rect_filled(
|
let header_bg = ctx.shared.theme.bg_color(&[".pane-header"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35));
|
||||||
header_rect,
|
ui.painter().rect_filled(header_rect, 0.0, header_bg);
|
||||||
0.0,
|
|
||||||
egui::Color32::from_rgb(35, 35, 35),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Draw content background
|
// Draw content background
|
||||||
let bg_color = if let Some(pane_type) = pane_type {
|
let pane_id = pane_type.map(pane_type_css_id);
|
||||||
pane_color(pane_type)
|
let bg_color = if let Some(pane_id) = pane_id {
|
||||||
|
ctx.shared.theme.bg_color(&[pane_id, ".pane-content"], ui.ctx(), pane_color(pane_type.unwrap()))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(40, 40, 40)
|
egui::Color32::from_rgb(40, 40, 40)
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(content_rect, 0.0, bg_color);
|
ui.painter().rect_filled(content_rect, 0.0, bg_color);
|
||||||
|
|
||||||
// Draw border around entire pane
|
// Draw border around entire pane
|
||||||
let border_color = egui::Color32::from_gray(80);
|
let border_color = ctx.shared.theme.border_color(&[".pane-chrome"], ui.ctx(), egui::Color32::from_gray(80));
|
||||||
let border_width = 1.0;
|
let border_width = 1.0;
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
|
|
@ -6367,10 +6395,11 @@ fn render_pane(
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw header separator line
|
// Draw header separator line
|
||||||
|
let sep_color = ctx.shared.theme.border_color(&[".pane-chrome-separator"], ui.ctx(), egui::Color32::from_gray(50));
|
||||||
ui.painter().hline(
|
ui.painter().hline(
|
||||||
rect.x_range(),
|
rect.x_range(),
|
||||||
header_rect.max.y,
|
header_rect.max.y,
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(50)),
|
egui::Stroke::new(1.0, sep_color),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render icon button in header (left side)
|
// Render icon button in header (left side)
|
||||||
|
|
@ -6382,11 +6411,8 @@ fn render_pane(
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw icon button background
|
// Draw icon button background
|
||||||
ui.painter().rect_filled(
|
let icon_btn_bg = ctx.shared.theme.bg_color(&[".pane-icon-button"], ui.ctx(), egui::Color32::from_rgba_premultiplied(50, 50, 50, 200));
|
||||||
icon_button_rect,
|
ui.painter().rect_filled(icon_button_rect, 4.0, icon_btn_bg);
|
||||||
4.0,
|
|
||||||
egui::Color32::from_rgba_premultiplied(50, 50, 50, 200),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load and render icon if available
|
// Load and render icon if available
|
||||||
if let Some(pane_type) = pane_type {
|
if let Some(pane_type) = pane_type {
|
||||||
|
|
@ -6749,6 +6775,23 @@ fn pane_color(pane_type: PaneType) -> egui::Color32 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CSS ID selector for a pane type (e.g., PaneType::Stage -> "#stage")
|
||||||
|
fn pane_type_css_id(pane_type: PaneType) -> &'static str {
|
||||||
|
match pane_type {
|
||||||
|
PaneType::Stage => "#stage",
|
||||||
|
PaneType::Timeline => "#timeline",
|
||||||
|
PaneType::Toolbar => "#toolbar",
|
||||||
|
PaneType::Infopanel => "#infopanel",
|
||||||
|
PaneType::Outliner => "#outliner",
|
||||||
|
PaneType::PianoRoll => "#piano-roll",
|
||||||
|
PaneType::VirtualPiano => "#virtual-piano",
|
||||||
|
PaneType::NodeEditor => "#node-editor",
|
||||||
|
PaneType::PresetBrowser => "#preset-browser",
|
||||||
|
PaneType::AssetLibrary => "#asset-library",
|
||||||
|
PaneType::ScriptEditor => "#shader-editor",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Split a pane node into a horizontal or vertical grid with two copies of the pane
|
/// Split a pane node into a horizontal or vertical grid with two copies of the pane
|
||||||
fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) {
|
fn split_node(root: &mut LayoutNode, path: &NodePath, is_horizontal: bool, percent: f32) {
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -683,6 +683,26 @@ struct FolderContextMenuState {
|
||||||
position: egui::Pos2,
|
position: egui::Pos2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get background color for an interactive item based on its state
|
||||||
|
fn item_state_bg(
|
||||||
|
theme: &crate::theme::Theme,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
pane_ctx: &str,
|
||||||
|
is_dragging: bool,
|
||||||
|
is_selected: bool,
|
||||||
|
is_hovered: bool,
|
||||||
|
) -> egui::Color32 {
|
||||||
|
if is_dragging {
|
||||||
|
theme.bg_color(&[pane_ctx, ".item", ".dragging"], ctx, egui::Color32::from_rgb(80, 100, 120))
|
||||||
|
} else if is_selected {
|
||||||
|
theme.bg_color(&[pane_ctx, ".item", ".selected"], ctx, egui::Color32::from_rgb(60, 80, 100))
|
||||||
|
} else if is_hovered {
|
||||||
|
theme.bg_color(&[pane_ctx, ".item", ".hover"], ctx, egui::Color32::from_rgb(45, 45, 45))
|
||||||
|
} else {
|
||||||
|
theme.bg_color(&[pane_ctx, ".item"], ctx, egui::Color32::from_rgb(35, 35, 35))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AssetLibraryPane {
|
pub struct AssetLibraryPane {
|
||||||
/// Current search filter text
|
/// Current search filter text
|
||||||
search_filter: String,
|
search_filter: String,
|
||||||
|
|
@ -1335,7 +1355,7 @@ impl AssetLibraryPane {
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".panel-header", ui.ctx());
|
let bg_style = shared.theme.style(".panel-header", ui.ctx());
|
||||||
let bg_color = bg_style
|
let bg_color = bg_style
|
||||||
.background_color
|
.background_color()
|
||||||
.unwrap_or(egui::Color32::from_rgb(30, 30, 30));
|
.unwrap_or(egui::Color32::from_rgb(30, 30, 30));
|
||||||
ui.painter().rect_filled(search_rect, 0.0, bg_color);
|
ui.painter().rect_filled(search_rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -1366,9 +1386,9 @@ impl AssetLibraryPane {
|
||||||
let list_selected = self.view_mode == AssetViewMode::List;
|
let list_selected = self.view_mode == AssetViewMode::List;
|
||||||
let list_response = ui.allocate_rect(list_button_rect, egui::Sense::click());
|
let list_response = ui.allocate_rect(list_button_rect, egui::Sense::click());
|
||||||
let list_bg = if list_selected {
|
let list_bg = if list_selected {
|
||||||
egui::Color32::from_rgb(70, 90, 110)
|
shared.theme.bg_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 90, 110))
|
||||||
} else if list_response.hovered() {
|
} else if list_response.hovered() {
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
shared.theme.bg_color(&["#asset-library", ".view-toggle", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::TRANSPARENT
|
egui::Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
|
|
@ -1376,9 +1396,9 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Draw list icon (three horizontal lines)
|
// Draw list icon (three horizontal lines)
|
||||||
let list_icon_color = if list_selected {
|
let list_icon_color = if list_selected {
|
||||||
egui::Color32::WHITE
|
shared.theme.text_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::WHITE)
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_gray(150)
|
shared.theme.text_color(&["#asset-library", ".view-toggle"], ui.ctx(), egui::Color32::from_gray(150))
|
||||||
};
|
};
|
||||||
let line_spacing = 4.0;
|
let line_spacing = 4.0;
|
||||||
let line_width = 10.0;
|
let line_width = 10.0;
|
||||||
|
|
@ -1402,9 +1422,9 @@ impl AssetLibraryPane {
|
||||||
let grid_selected = self.view_mode == AssetViewMode::Grid;
|
let grid_selected = self.view_mode == AssetViewMode::Grid;
|
||||||
let grid_response = ui.allocate_rect(grid_button_rect, egui::Sense::click());
|
let grid_response = ui.allocate_rect(grid_button_rect, egui::Sense::click());
|
||||||
let grid_bg = if grid_selected {
|
let grid_bg = if grid_selected {
|
||||||
egui::Color32::from_rgb(70, 90, 110)
|
shared.theme.bg_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 90, 110))
|
||||||
} else if grid_response.hovered() {
|
} else if grid_response.hovered() {
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
shared.theme.bg_color(&["#asset-library", ".view-toggle", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::TRANSPARENT
|
egui::Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
|
|
@ -1412,9 +1432,9 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Draw grid icon (2x2 squares)
|
// Draw grid icon (2x2 squares)
|
||||||
let grid_icon_color = if grid_selected {
|
let grid_icon_color = if grid_selected {
|
||||||
egui::Color32::WHITE
|
shared.theme.text_color(&["#asset-library", ".view-toggle", ".selected"], ui.ctx(), egui::Color32::WHITE)
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_gray(150)
|
shared.theme.text_color(&["#asset-library", ".view-toggle"], ui.ctx(), egui::Color32::from_gray(150))
|
||||||
};
|
};
|
||||||
let square_size = 4.0;
|
let square_size = 4.0;
|
||||||
let square_gap = 2.0;
|
let square_gap = 2.0;
|
||||||
|
|
@ -1444,7 +1464,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
"Search:",
|
"Search:",
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
egui::Color32::from_gray(180),
|
shared.theme.text_color(&["#asset-library", ".search-label"], ui.ctx(), egui::Color32::from_gray(180)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Text field using IME-safe widget (leave room for view toggle buttons)
|
// Text field using IME-safe widget (leave room for view toggle buttons)
|
||||||
|
|
@ -1473,7 +1493,7 @@ impl AssetLibraryPane {
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
||||||
let bg_color = bg_style
|
let bg_color = bg_style
|
||||||
.background_color
|
.background_color()
|
||||||
.unwrap_or(egui::Color32::from_rgb(40, 40, 40));
|
.unwrap_or(egui::Color32::from_rgb(40, 40, 40));
|
||||||
ui.painter().rect_filled(tabs_rect, 0.0, bg_color);
|
ui.painter().rect_filled(tabs_rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -1490,7 +1510,7 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Tab background
|
// Tab background
|
||||||
let tab_bg = if is_selected {
|
let tab_bg = if is_selected {
|
||||||
egui::Color32::from_rgb(60, 60, 60)
|
shared.theme.bg_color(&["#asset-library", ".category-tab", ".selected"], ui.ctx(), egui::Color32::from_rgb(60, 60, 60))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::TRANSPARENT
|
egui::Color32::TRANSPARENT
|
||||||
};
|
};
|
||||||
|
|
@ -1508,7 +1528,7 @@ impl AssetLibraryPane {
|
||||||
let text_color = if is_selected {
|
let text_color = if is_selected {
|
||||||
indicator_color
|
indicator_color
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_gray(150)
|
shared.theme.text_color(&["#asset-library", ".category-tab"], ui.ctx(), egui::Color32::from_gray(150))
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
|
|
@ -1552,7 +1572,7 @@ impl AssetLibraryPane {
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".panel-header", ui.ctx());
|
let bg_style = shared.theme.style(".panel-header", ui.ctx());
|
||||||
let bg_color = bg_style
|
let bg_color = bg_style
|
||||||
.background_color
|
.background_color()
|
||||||
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
||||||
ui.painter().rect_filled(rect, 0.0, bg_color);
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -1600,11 +1620,11 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Determine color based on state
|
// Determine color based on state
|
||||||
let text_color = if is_last {
|
let text_color = if is_last {
|
||||||
egui::Color32::WHITE
|
shared.theme.text_color(&["#asset-library", ".breadcrumb", ".active"], ui.ctx(), egui::Color32::WHITE)
|
||||||
} else if response.hovered() {
|
} else if response.hovered() {
|
||||||
egui::Color32::from_rgb(100, 150, 255)
|
shared.theme.text_color(&["#asset-library", ".breadcrumb", ".hover"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(150, 150, 150)
|
shared.theme.text_color(&["#asset-library", ".breadcrumb"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw text
|
// Draw text
|
||||||
|
|
@ -1639,7 +1659,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::LEFT_CENTER,
|
egui::Align2::LEFT_CENTER,
|
||||||
">",
|
">",
|
||||||
egui::FontId::proportional(12.0),
|
egui::FontId::proportional(12.0),
|
||||||
egui::Color32::from_rgb(100, 100, 100),
|
shared.theme.text_color(&["#asset-library", ".breadcrumb-separator"], ui.ctx(), egui::Color32::from_rgb(100, 100, 100)),
|
||||||
);
|
);
|
||||||
x_offset += 16.0;
|
x_offset += 16.0;
|
||||||
}
|
}
|
||||||
|
|
@ -1718,15 +1738,7 @@ impl AssetLibraryPane {
|
||||||
let is_being_dragged = shared.dragging_asset.as_ref().map(|d| d.clip_id == asset.id).unwrap_or(false);
|
let is_being_dragged = shared.dragging_asset.as_ref().map(|d| d.clip_id == asset.id).unwrap_or(false);
|
||||||
|
|
||||||
// Item background
|
// Item background
|
||||||
let item_bg = if is_being_dragged {
|
let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered());
|
||||||
egui::Color32::from_rgb(80, 100, 120)
|
|
||||||
} else if is_selected {
|
|
||||||
egui::Color32::from_rgb(60, 80, 100)
|
|
||||||
} else if response.hovered() {
|
|
||||||
egui::Color32::from_rgb(45, 45, 45)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(item_rect, 4.0, item_bg);
|
ui.painter().rect_filled(item_rect, 4.0, item_bg);
|
||||||
|
|
||||||
// Thumbnail area
|
// Thumbnail area
|
||||||
|
|
@ -1960,12 +1972,11 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_color = if is_drop_hover {
|
let bg_color = if is_drop_hover {
|
||||||
// Highlight as drop target
|
shared.theme.bg_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(60, 100, 140))
|
||||||
egui::Color32::from_rgb(60, 100, 140)
|
|
||||||
} else if response.hovered() {
|
} else if response.hovered() {
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
shared.theme.bg_color(&["#asset-library", ".folder-item", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
shared.theme.bg_color(&["#asset-library", ".folder-item"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35))
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(item_rect, 0.0, bg_color);
|
ui.painter().rect_filled(item_rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -1974,7 +1985,7 @@ impl AssetLibraryPane {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
item_rect,
|
item_rect,
|
||||||
0.0,
|
0.0,
|
||||||
egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)),
|
egui::Stroke::new(2.0, shared.theme.border_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255))),
|
||||||
egui::StrokeKind::Middle,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2018,7 +2029,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::LEFT_CENTER,
|
egui::Align2::LEFT_CENTER,
|
||||||
&folder.name,
|
&folder.name,
|
||||||
egui::FontId::proportional(13.0),
|
egui::FontId::proportional(13.0),
|
||||||
egui::Color32::WHITE,
|
shared.theme.text_color(&["#asset-library", ".folder-item", ".name"], ui.ctx(), egui::Color32::WHITE),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2029,7 +2040,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::RIGHT_CENTER,
|
egui::Align2::RIGHT_CENTER,
|
||||||
count_text,
|
count_text,
|
||||||
egui::FontId::proportional(11.0),
|
egui::FontId::proportional(11.0),
|
||||||
egui::Color32::from_rgb(150, 150, 150),
|
shared.theme.text_color(&["#asset-library", ".folder-item", ".count"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle drop: move asset to folder
|
// Handle drop: move asset to folder
|
||||||
|
|
@ -2131,12 +2142,11 @@ impl AssetLibraryPane {
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_color = if is_drop_hover {
|
let bg_color = if is_drop_hover {
|
||||||
// Highlight as drop target
|
shared.theme.bg_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(60, 100, 140))
|
||||||
egui::Color32::from_rgb(60, 100, 140)
|
|
||||||
} else if response.hovered() {
|
} else if response.hovered() {
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
shared.theme.bg_color(&["#asset-library", ".folder-item", ".hover"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
shared.theme.bg_color(&["#asset-library", ".folder-item"], ui.ctx(), egui::Color32::from_rgb(35, 35, 35))
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(rect, 4.0, bg_color);
|
ui.painter().rect_filled(rect, 4.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -2145,7 +2155,7 @@ impl AssetLibraryPane {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
rect,
|
rect,
|
||||||
4.0,
|
4.0,
|
||||||
egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 180, 255)),
|
egui::Stroke::new(2.0, shared.theme.border_color(&["#asset-library", ".folder-item", ".drop-target"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255))),
|
||||||
egui::StrokeKind::Middle,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -2176,7 +2186,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
name,
|
name,
|
||||||
egui::FontId::proportional(10.0),
|
egui::FontId::proportional(10.0),
|
||||||
egui::Color32::WHITE,
|
shared.theme.text_color(&["#asset-library", ".folder-item", ".name"], ui.ctx(), egui::Color32::WHITE),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Item count
|
// Item count
|
||||||
|
|
@ -2185,7 +2195,7 @@ impl AssetLibraryPane {
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
format!("{} items", folder.item_count),
|
format!("{} items", folder.item_count),
|
||||||
egui::FontId::proportional(9.0),
|
egui::FontId::proportional(9.0),
|
||||||
egui::Color32::from_rgb(150, 150, 150),
|
shared.theme.text_color(&["#asset-library", ".folder-item", ".count"], ui.ctx(), egui::Color32::from_rgb(150, 150, 150)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle drop: move asset to folder
|
// Handle drop: move asset to folder
|
||||||
|
|
@ -2251,19 +2261,11 @@ impl AssetLibraryPane {
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Text colors
|
// Text colors
|
||||||
let text_color = egui::Color32::from_gray(200);
|
let text_color = shared.theme.text_color(&["#asset-library", ".text-primary"], ui.ctx(), egui::Color32::from_gray(200));
|
||||||
let secondary_text_color = egui::Color32::from_gray(120);
|
let secondary_text_color = shared.theme.text_color(&["#asset-library", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(120));
|
||||||
|
|
||||||
// Item background
|
// Item background
|
||||||
let item_bg = if is_being_dragged {
|
let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered());
|
||||||
egui::Color32::from_rgb(80, 100, 120)
|
|
||||||
} else if is_selected {
|
|
||||||
egui::Color32::from_rgb(60, 80, 100)
|
|
||||||
} else if response.hovered() {
|
|
||||||
egui::Color32::from_rgb(45, 45, 45)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(item_rect, 3.0, item_bg);
|
ui.painter().rect_filled(item_rect, 3.0, item_bg);
|
||||||
|
|
||||||
// Category color indicator bar
|
// Category color indicator bar
|
||||||
|
|
@ -2437,15 +2439,7 @@ impl AssetLibraryPane {
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_color = if is_being_dragged {
|
let bg_color = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered());
|
||||||
egui::Color32::from_rgb(80, 100, 120)
|
|
||||||
} else if is_selected {
|
|
||||||
egui::Color32::from_rgb(60, 80, 100)
|
|
||||||
} else if response.hovered() {
|
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(rect, 4.0, bg_color);
|
ui.painter().rect_filled(rect, 4.0, bg_color);
|
||||||
|
|
||||||
// Thumbnail
|
// Thumbnail
|
||||||
|
|
@ -2617,7 +2611,7 @@ impl AssetLibraryPane {
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
||||||
let bg_color = bg_style
|
let bg_color = bg_style
|
||||||
.background_color
|
.background_color()
|
||||||
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
||||||
ui.painter().rect_filled(rect, 0.0, bg_color);
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -2722,15 +2716,7 @@ impl AssetLibraryPane {
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
// Item background
|
// Item background
|
||||||
let item_bg = if is_being_dragged {
|
let item_bg = item_state_bg(shared.theme, ui.ctx(), "#asset-library", is_being_dragged, is_selected, response.hovered());
|
||||||
egui::Color32::from_rgb(80, 100, 120) // Highlight when dragging
|
|
||||||
} else if is_selected {
|
|
||||||
egui::Color32::from_rgb(60, 80, 100)
|
|
||||||
} else if response.hovered() {
|
|
||||||
egui::Color32::from_rgb(45, 45, 45)
|
|
||||||
} else {
|
|
||||||
egui::Color32::from_rgb(35, 35, 35)
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(item_rect, 3.0, item_bg);
|
ui.painter().rect_filled(item_rect, 3.0, item_bg);
|
||||||
|
|
||||||
// Category color indicator bar
|
// Category color indicator bar
|
||||||
|
|
@ -3030,7 +3016,7 @@ impl AssetLibraryPane {
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
let bg_style = shared.theme.style(".panel-content", ui.ctx());
|
||||||
let bg_color = bg_style
|
let bg_color = bg_style
|
||||||
.background_color
|
.background_color()
|
||||||
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
.unwrap_or(egui::Color32::from_rgb(25, 25, 25));
|
||||||
ui.painter().rect_filled(rect, 0.0, bg_color);
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -881,11 +881,8 @@ impl PaneRenderer for InfopanelPane {
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
// Background
|
// Background
|
||||||
ui.painter().rect_filled(
|
let bg = shared.theme.bg_color(&["#infopanel", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(30, 35, 40));
|
||||||
rect,
|
ui.painter().rect_filled(rect, 0.0, bg);
|
||||||
0.0,
|
|
||||||
egui::Color32::from_rgb(30, 35, 40),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create scrollable area for content
|
// Create scrollable area for content
|
||||||
let content_rect = rect.shrink(8.0);
|
let content_rect = rect.shrink(8.0);
|
||||||
|
|
|
||||||
|
|
@ -515,6 +515,8 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true);
|
ValueType::float_param(0.7, 0.0, 1.0, "", 2, None), InputParamKind::ConstantOnly, true);
|
||||||
graph.add_input_param(node_id, "Release".into(), DataType::CV,
|
graph.add_input_param(node_id, "Release".into(), DataType::CV,
|
||||||
ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true);
|
ValueType::float_param(0.2, 0.001, 5.0, " s", 3, None), InputParamKind::ConstantOnly, true);
|
||||||
|
graph.add_input_param(node_id, "Curve".into(), DataType::CV,
|
||||||
|
ValueType::float_param(0.0, 0.0, 1.0, "", 4, Some(&["Linear", "Exponential"])), InputParamKind::ConstantOnly, true);
|
||||||
graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV);
|
graph.add_output_param(node_id, "Envelope Out".into(), DataType::CV);
|
||||||
}
|
}
|
||||||
NodeTemplate::Lfo => {
|
NodeTemplate::Lfo => {
|
||||||
|
|
|
||||||
|
|
@ -2269,12 +2269,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
if self.track_id.is_none() || self.backend.is_none() {
|
if self.track_id.is_none() || self.backend.is_none() {
|
||||||
// Show message that no valid track is selected
|
// Show message that no valid track is selected
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let bg_color = egui::Color32::from_gray(30);
|
let bg_color = shared.theme.bg_color(&["#node-editor", ".pane-content"], ui.ctx(), egui::Color32::from_gray(30));
|
||||||
painter.rect_filled(rect, 0.0, bg_color);
|
painter.rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
let text = "Select a MIDI or Audio track to view its node graph";
|
let text = "Select a MIDI or Audio track to view its node graph";
|
||||||
let font_id = egui::FontId::proportional(16.0);
|
let font_id = egui::FontId::proportional(16.0);
|
||||||
let text_color = egui::Color32::from_gray(150);
|
let text_color = shared.theme.text_color(&["#node-editor", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(150));
|
||||||
|
|
||||||
let galley = painter.layout_no_wrap(text.to_string(), font_id, text_color);
|
let galley = painter.layout_no_wrap(text.to_string(), font_id, text_color);
|
||||||
let text_pos = rect.center() - galley.size() / 2.0;
|
let text_pos = rect.center() - galley.size() / 2.0;
|
||||||
|
|
@ -2351,8 +2351,8 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
let bg_style = shared.theme.style(".node-graph-background", ui.ctx());
|
let bg_style = shared.theme.style(".node-graph-background", ui.ctx());
|
||||||
let grid_style = shared.theme.style(".node-graph-grid", ui.ctx());
|
let grid_style = shared.theme.style(".node-graph-grid", ui.ctx());
|
||||||
|
|
||||||
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_gray(45));
|
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_gray(45));
|
||||||
let grid_color = grid_style.background_color.unwrap_or(egui::Color32::from_gray(55));
|
let grid_color = grid_style.background_color().unwrap_or(egui::Color32::from_gray(55));
|
||||||
|
|
||||||
// Draw breadcrumb bar when editing a subgraph
|
// Draw breadcrumb bar when editing a subgraph
|
||||||
let breadcrumb_height = if self.in_subgraph() { 28.0 } else { 0.0 };
|
let breadcrumb_height = if self.in_subgraph() { 28.0 } else { 0.0 };
|
||||||
|
|
@ -2363,10 +2363,12 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
egui::vec2(rect.width(), breadcrumb_height),
|
egui::vec2(rect.width(), breadcrumb_height),
|
||||||
);
|
);
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
painter.rect_filled(breadcrumb_rect, 0.0, egui::Color32::from_gray(35));
|
let bc_bg = shared.theme.bg_color(&["#node-editor", ".pane-header"], ui.ctx(), egui::Color32::from_gray(35));
|
||||||
|
painter.rect_filled(breadcrumb_rect, 0.0, bc_bg);
|
||||||
|
let bc_border = shared.theme.border_color(&["#node-editor", ".pane-header"], ui.ctx(), egui::Color32::from_gray(60));
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[breadcrumb_rect.left_bottom(), breadcrumb_rect.right_bottom()],
|
[breadcrumb_rect.left_bottom(), breadcrumb_rect.right_bottom()],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
|
egui::Stroke::new(1.0, bc_border),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Draw clickable breadcrumb segments
|
// Draw clickable breadcrumb segments
|
||||||
|
|
@ -2378,9 +2380,9 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
for (i, segment) in segments.iter().enumerate() {
|
for (i, segment) in segments.iter().enumerate() {
|
||||||
let is_last = i == segments.len() - 1;
|
let is_last = i == segments.len() - 1;
|
||||||
let text_color = if is_last {
|
let text_color = if is_last {
|
||||||
egui::Color32::from_gray(220)
|
shared.theme.text_color(&["#node-editor", ".text-primary"], ui.ctx(), egui::Color32::from_gray(220))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(100, 180, 255)
|
shared.theme.text_color(&["#node-editor", ".text-secondary"], ui.ctx(), egui::Color32::from_rgb(100, 180, 255))
|
||||||
};
|
};
|
||||||
|
|
||||||
let font_id = egui::FontId::proportional(13.0);
|
let font_id = egui::FontId::proportional(13.0);
|
||||||
|
|
@ -2497,42 +2499,14 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
.collect();
|
.collect();
|
||||||
self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
|
self.user_state.available_scripts.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()));
|
||||||
|
|
||||||
// Bundled NAM models — discover once and cache
|
// Bundled NAM models — populate from embedded registry
|
||||||
if self.user_state.available_nam_models.is_empty() {
|
if self.user_state.available_nam_models.is_empty() {
|
||||||
let bundled_dirs = [
|
for name in daw_backend::audio::node_graph::nodes::bundled_models::bundled_model_names() {
|
||||||
std::env::current_exe().ok()
|
self.user_state.available_nam_models.push(NamModelInfo {
|
||||||
.and_then(|p| p.parent().map(|d| d.join("models")))
|
name: name.to_string(),
|
||||||
.unwrap_or_default(),
|
path: format!("bundled:{}", name),
|
||||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
is_bundled: true,
|
||||||
.join("../../vendor/NeuralAudio/Utils/Models"),
|
});
|
||||||
];
|
|
||||||
for dir in &bundled_dirs {
|
|
||||||
if let Ok(canon) = dir.canonicalize() {
|
|
||||||
if canon.is_dir() {
|
|
||||||
for entry in std::fs::read_dir(&canon).into_iter().flatten().flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().map_or(false, |e| e == "nam") {
|
|
||||||
let stem = path.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
// Skip LSTM variants (performance alternates, not separate amps)
|
|
||||||
if stem.ends_with("-LSTM") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Clean up display name: remove "-WaveNet" suffix
|
|
||||||
let name = stem.strip_suffix("-WaveNet")
|
|
||||||
.unwrap_or(&stem)
|
|
||||||
.to_string();
|
|
||||||
self.user_state.available_nam_models.push(NamModelInfo {
|
|
||||||
name,
|
|
||||||
path: path.to_string_lossy().to_string(),
|
|
||||||
is_bundled: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break; // use first directory found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name));
|
self.user_state.available_nam_models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,22 +22,20 @@ impl PaneRenderer for OutlinerPane {
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
_path: &NodePath,
|
_path: &NodePath,
|
||||||
_shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
// Placeholder rendering
|
// Placeholder rendering
|
||||||
ui.painter().rect_filled(
|
let bg = shared.theme.bg_color(&["#outliner", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(40, 50, 30));
|
||||||
rect,
|
ui.painter().rect_filled(rect, 0.0, bg);
|
||||||
0.0,
|
|
||||||
egui::Color32::from_rgb(40, 50, 30),
|
|
||||||
);
|
|
||||||
|
|
||||||
let text = "Outliner\n(TODO: Implement layer tree)";
|
let text = "Outliner\n(TODO: Implement layer tree)";
|
||||||
|
let text_color = shared.theme.text_color(&["#outliner", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(150));
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.center(),
|
rect.center(),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
text,
|
text,
|
||||||
egui::FontId::proportional(16.0),
|
egui::FontId::proportional(16.0),
|
||||||
egui::Color32::from_gray(150),
|
text_color,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -290,7 +290,8 @@ impl PianoRollPane {
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(30, 30, 35));
|
let bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(30, 30, 35));
|
||||||
|
painter.rect_filled(rect, 0.0, bg);
|
||||||
|
|
||||||
// Render grid (clipped to grid area)
|
// Render grid (clipped to grid area)
|
||||||
let grid_painter = ui.painter_at(grid_rect);
|
let grid_painter = ui.painter_at(grid_rect);
|
||||||
|
|
@ -1450,7 +1451,8 @@ impl PianoRollPane {
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
painter.rect_filled(rect, 0.0, Color32::from_rgb(20, 20, 25));
|
let spec_bg = shared.theme.bg_color(&["#piano-roll", ".pane-content"], ui.ctx(), Color32::from_rgb(20, 20, 25));
|
||||||
|
painter.rect_filled(rect, 0.0, spec_bg);
|
||||||
|
|
||||||
// Dot grid background (visible where the spectrogram doesn't draw)
|
// Dot grid background (visible where the spectrogram doesn't draw)
|
||||||
let grid_painter = ui.painter_at(view_rect);
|
let grid_painter = ui.painter_at(view_rect);
|
||||||
|
|
@ -1633,10 +1635,14 @@ impl PianoRollPane {
|
||||||
impl PaneRenderer for PianoRollPane {
|
impl PaneRenderer for PianoRollPane {
|
||||||
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| {
|
ui.horizontal(|ui| {
|
||||||
|
let header_text = shared.theme.text_color(&["#piano-roll", ".pane-header"], ui.ctx(), Color32::from_gray(180));
|
||||||
|
let header_secondary = shared.theme.text_color(&["#piano-roll", ".text-secondary"], ui.ctx(), Color32::from_gray(140));
|
||||||
|
let header_accent = shared.theme.text_color(&["#piano-roll", ".status-success"], ui.ctx(), Color32::from_rgb(143, 252, 143));
|
||||||
|
|
||||||
// Pane title
|
// Pane title
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("Piano Roll")
|
egui::RichText::new("Piano Roll")
|
||||||
.color(Color32::from_gray(180))
|
.color(header_text)
|
||||||
.size(11.0),
|
.size(11.0),
|
||||||
);
|
);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
@ -1644,7 +1650,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
// Zoom
|
// Zoom
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("{:.0}px/s", self.pixels_per_second))
|
egui::RichText::new(format!("{:.0}px/s", self.pixels_per_second))
|
||||||
.color(Color32::from_gray(140))
|
.color(header_secondary)
|
||||||
.size(10.0),
|
.size(10.0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1653,7 +1659,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("{} selected", self.selected_note_indices.len()))
|
egui::RichText::new(format!("{} selected", self.selected_note_indices.len()))
|
||||||
.color(Color32::from_rgb(143, 252, 143))
|
.color(header_accent)
|
||||||
.size(10.0),
|
.size(10.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1669,7 +1675,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
let n = &resolved[idx];
|
let n = &resolved[idx];
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new(format!("{} vel:{}", Self::note_name(n.note), n.velocity))
|
egui::RichText::new(format!("{} vel:{}", Self::note_name(n.note), n.velocity))
|
||||||
.color(Color32::from_gray(140))
|
.color(header_secondary)
|
||||||
.size(10.0),
|
.size(10.0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1691,7 +1697,7 @@ impl PaneRenderer for PianoRollPane {
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.label(
|
ui.label(
|
||||||
egui::RichText::new("Gamma")
|
egui::RichText::new("Gamma")
|
||||||
.color(Color32::from_gray(140))
|
.color(header_secondary)
|
||||||
.size(10.0),
|
.size(10.0),
|
||||||
);
|
);
|
||||||
ui.add(
|
ui.add(
|
||||||
|
|
|
||||||
|
|
@ -338,7 +338,7 @@ impl PaneRenderer for PresetBrowserPane {
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_style = shared.theme.style(".pane-content", ui.ctx());
|
let bg_style = shared.theme.style(".pane-content", ui.ctx());
|
||||||
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(47, 47, 47));
|
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(47, 47, 47));
|
||||||
ui.painter().rect_filled(rect, 0.0, bg_color);
|
ui.painter().rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
let text_color = shared.theme.style(".text-primary", ui.ctx())
|
let text_color = shared.theme.style(".text-primary", ui.ctx())
|
||||||
|
|
|
||||||
|
|
@ -744,7 +744,8 @@ impl PaneRenderer for ShaderEditorPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
ui.painter().rect_filled(rect, 0.0, egui::Color32::from_rgb(25, 25, 30));
|
let bg = shared.theme.bg_color(&["#shader-editor", ".pane-content"], ui.ctx(), egui::Color32::from_rgb(25, 25, 30));
|
||||||
|
ui.painter().rect_filled(rect, 0.0, bg);
|
||||||
|
|
||||||
let content_rect = rect.shrink(8.0);
|
let content_rect = rect.shrink(8.0);
|
||||||
let mut content_ui = ui.new_child(
|
let mut content_ui = ui.new_child(
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,19 @@ fn apply_dab(current: vec4<f32>, dab: GpuDab, px: i32, py: i32) -> vec4<f32> {
|
||||||
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
let rr = (dx * dx + dy * dy) / (dab.radius * dab.radius);
|
||||||
if rr > 1.0 { return current; }
|
if rr > 1.0 { return current; }
|
||||||
|
|
||||||
// Two-segment linear falloff (identical to libmypaint calculate_opa)
|
// Quadratic falloff: flat inner core, smooth quadratic outer zone.
|
||||||
let h = clamp(dab.hardness, 0.001, 1.0);
|
// r is the actual normalised distance [0,1]; h controls the hard-core radius.
|
||||||
|
// Inner zone (r ≤ h): fully opaque.
|
||||||
|
// Outer zone (r > h): opa = ((1-r)/(1-h))^2, giving a smooth bell-shaped dab.
|
||||||
|
let h = clamp(dab.hardness, 0.0, 1.0);
|
||||||
|
let r = sqrt(rr);
|
||||||
var opa_weight: f32;
|
var opa_weight: f32;
|
||||||
if rr <= h {
|
if h >= 1.0 || r <= h {
|
||||||
opa_weight = 1.0 + rr * (-(1.0 / h - 1.0));
|
opa_weight = 1.0;
|
||||||
} else {
|
} else {
|
||||||
opa_weight = h / (1.0 - h) + rr * (-h / (1.0 - h));
|
let t = (1.0 - r) / (1.0 - h);
|
||||||
|
opa_weight = t * t;
|
||||||
}
|
}
|
||||||
opa_weight = clamp(opa_weight, 0.0, 1.0);
|
|
||||||
|
|
||||||
if dab.blend_mode == 0u {
|
if dab.blend_mode == 0u {
|
||||||
// Normal: "over" operator
|
// Normal: "over" operator
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
// Canvas blit shader.
|
// Canvas blit shader.
|
||||||
//
|
//
|
||||||
// Renders a GPU raster canvas (at document resolution) into the layer's sRGB
|
// Renders a GPU raster canvas (at document resolution) into an Rgba16Float HDR
|
||||||
// render buffer (at viewport resolution), applying the camera transform
|
// buffer (at viewport resolution), applying the camera transform (pan + zoom)
|
||||||
// (pan + zoom) to map document-space pixels to viewport-space pixels.
|
// to map document-space pixels to viewport-space pixels.
|
||||||
|
//
|
||||||
|
// The canvas stores premultiplied linear RGBA. We output it as-is so the HDR
|
||||||
|
// compositor sees the same premultiplied-linear format it always works with,
|
||||||
|
// bypassing the sRGB intermediate used for Vello layers.
|
||||||
//
|
//
|
||||||
// Any viewport pixel whose corresponding document coordinate falls outside
|
// Any viewport pixel whose corresponding document coordinate falls outside
|
||||||
// [0, canvas_w) × [0, canvas_h) outputs transparent black.
|
// [0, canvas_w) × [0, canvas_h) outputs transparent black.
|
||||||
|
|
@ -21,6 +25,10 @@ struct CameraParams {
|
||||||
@group(0) @binding(0) var canvas_tex: texture_2d<f32>;
|
@group(0) @binding(0) var canvas_tex: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var canvas_sampler: sampler;
|
@group(0) @binding(1) var canvas_sampler: sampler;
|
||||||
@group(0) @binding(2) var<uniform> camera: CameraParams;
|
@group(0) @binding(2) var<uniform> camera: CameraParams;
|
||||||
|
/// Selection mask: R8Unorm, 255 = inside selection (keep), 0 = outside (discard).
|
||||||
|
/// A 1×1 all-white texture is bound when no selection is active.
|
||||||
|
@group(0) @binding(3) var mask_tex: texture_2d<f32>;
|
||||||
|
@group(0) @binding(4) var mask_sampler: sampler;
|
||||||
|
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@builtin(position) position: vec4<f32>,
|
@builtin(position) position: vec4<f32>,
|
||||||
|
|
@ -38,17 +46,6 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linear → sRGB encoding for a single channel.
|
|
||||||
// Applied to premultiplied linear values so the downstream srgb_to_linear
|
|
||||||
// pass round-trips correctly without darkening semi-transparent edges.
|
|
||||||
fn linear_to_srgb(c: f32) -> f32 {
|
|
||||||
return select(
|
|
||||||
1.055 * pow(max(c, 0.0), 1.0 / 2.4) - 0.055,
|
|
||||||
c * 12.92,
|
|
||||||
c <= 0.0031308,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
// Map viewport UV [0,1] → viewport pixel
|
// Map viewport UV [0,1] → viewport pixel
|
||||||
|
|
@ -67,21 +64,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The canvas stores premultiplied linear RGBA.
|
// The canvas stores premultiplied linear RGBA.
|
||||||
// The downstream pipeline (srgb_to_linear → compositor) expects the sRGB
|
// The compositor expects straight-alpha linear (it premultiplies by src_alpha itself),
|
||||||
// buffer to contain straight-alpha sRGB, i.e. the same format Vello outputs:
|
// so unpremultiply here. No sRGB conversion — the HDR buffer is linear throughout.
|
||||||
// sRGB buffer: srgb(r_straight), srgb(g_straight), srgb(b_straight), a
|
|
||||||
// srgb_to_linear: r_straight, g_straight, b_straight, a (linear straight)
|
|
||||||
// compositor: r_straight * a * opacity (premultiplied, correct)
|
|
||||||
//
|
|
||||||
// Without unpremultiplying, the compositor would double-premultiply:
|
|
||||||
// src = (premul_r, premul_g, premul_b, a) → output = premul_r * a = r * a²
|
|
||||||
// which produces a dark halo over transparent regions.
|
|
||||||
let c = textureSample(canvas_tex, canvas_sampler, canvas_uv);
|
let c = textureSample(canvas_tex, canvas_sampler, canvas_uv);
|
||||||
|
let mask = textureSample(mask_tex, mask_sampler, canvas_uv).r;
|
||||||
|
let masked_a = c.a * mask;
|
||||||
let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6);
|
let inv_a = select(0.0, 1.0 / c.a, c.a > 1e-6);
|
||||||
return vec4<f32>(
|
return vec4<f32>(c.r * inv_a, c.g * inv_a, c.b * inv_a, masked_a);
|
||||||
linear_to_srgb(c.r * inv_a),
|
|
||||||
linear_to_srgb(c.g * inv_a),
|
|
||||||
linear_to_srgb(c.b * inv_a),
|
|
||||||
c.a,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,9 @@ struct VelloRenderContext {
|
||||||
painting_canvas: Option<(uuid::Uuid, uuid::Uuid)>,
|
painting_canvas: Option<(uuid::Uuid, uuid::Uuid)>,
|
||||||
/// GPU canvas keyframe to remove at the top of this prepare() call.
|
/// GPU canvas keyframe to remove at the top of this prepare() call.
|
||||||
pending_canvas_removal: Option<uuid::Uuid>,
|
pending_canvas_removal: Option<uuid::Uuid>,
|
||||||
|
/// True while the current stroke targets the float buffer (B) rather than
|
||||||
|
/// the layer canvas (A). Used in prepare() to route the GPU canvas blit.
|
||||||
|
painting_float: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Callback for Vello rendering within egui
|
/// Callback for Vello rendering within egui
|
||||||
|
|
@ -500,6 +503,24 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy float GPU canvas initialization.
|
||||||
|
// If a float exists but its GPU canvas hasn't been created yet, upload float.pixels now.
|
||||||
|
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
||||||
|
if let Ok(mut gpu_brush) = shared.gpu_brush.lock() {
|
||||||
|
if !gpu_brush.canvases.contains_key(&float_sel.canvas_id) {
|
||||||
|
gpu_brush.ensure_canvas(device, float_sel.canvas_id, float_sel.width, float_sel.height);
|
||||||
|
if let Some(canvas) = gpu_brush.canvases.get(&float_sel.canvas_id) {
|
||||||
|
let pixels = if float_sel.pixels.is_empty() {
|
||||||
|
vec![0u8; (float_sel.width * float_sel.height * 4) as usize]
|
||||||
|
} else {
|
||||||
|
float_sel.pixels.clone()
|
||||||
|
};
|
||||||
|
canvas.upload(queue, &pixels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- GPU brush dispatch ---
|
// --- GPU brush dispatch ---
|
||||||
// Dispatch the compute shader for any pending raster dabs from this frame's
|
// Dispatch the compute shader for any pending raster dabs from this frame's
|
||||||
// input event. Must happen before compositing so the updated canvas texture
|
// input event. Must happen before compositing so the updated canvas texture
|
||||||
|
|
@ -643,6 +664,64 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
buffer_pool.release(bg_srgb_handle);
|
buffer_pool.release(bg_srgb_handle);
|
||||||
buffer_pool.release(bg_hdr_handle);
|
buffer_pool.release(bg_hdr_handle);
|
||||||
|
|
||||||
|
// Build a float-local R8 selection mask for the float canvas blit.
|
||||||
|
// Computed every frame from raster_selection so it is always correct
|
||||||
|
// (during strokes and during idle move/drag).
|
||||||
|
let float_mask_texture: Option<wgpu::Texture> =
|
||||||
|
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
||||||
|
if let Some(ref sel) = self.ctx.selection.raster_selection {
|
||||||
|
let fw = float_sel.width;
|
||||||
|
let fh = float_sel.height;
|
||||||
|
let fx = float_sel.x;
|
||||||
|
let fy = float_sel.y;
|
||||||
|
let mut pixels = vec![0u8; (fw * fh) as usize];
|
||||||
|
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||||
|
let bx0 = (x0 - fx).max(0) as u32;
|
||||||
|
let by0 = (y0 - fy).max(0) as u32;
|
||||||
|
let bx1 = ((x1 - fx) as u32).min(fw);
|
||||||
|
let by1 = ((y1 - fy) as u32).min(fh);
|
||||||
|
for py in by0..by1 {
|
||||||
|
for px in bx0..bx1 {
|
||||||
|
if sel.contains_pixel(fx + px as i32, fy + py as i32) {
|
||||||
|
pixels[(py * fw + px) as usize] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some("float_mask_tex"),
|
||||||
|
size: wgpu::Extent3d { width: fw, height: fh, depth_or_array_layers: 1 },
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &tex,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&pixels,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(fw),
|
||||||
|
rows_per_image: Some(fh),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d { width: fw, height: fh, depth_or_array_layers: 1 },
|
||||||
|
);
|
||||||
|
Some(tex)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let float_mask_view: Option<wgpu::TextureView> =
|
||||||
|
float_mask_texture.as_ref().map(|t| t.create_view(&Default::default()));
|
||||||
|
|
||||||
// Lock effect processor
|
// Lock effect processor
|
||||||
let mut effect_processor = shared.effect_processor.lock().unwrap();
|
let mut effect_processor = shared.effect_processor.lock().unwrap();
|
||||||
|
|
||||||
|
|
@ -651,9 +730,16 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
// Check if this raster layer has a live GPU canvas that should be
|
// Check if this raster layer has a live GPU canvas that should be
|
||||||
// blitted every frame, even when no new dabs arrived this frame.
|
// blitted every frame, even when no new dabs arrived this frame.
|
||||||
// `painting_canvas` persists for the entire stroke duration.
|
// `painting_canvas` persists for the entire stroke duration.
|
||||||
let gpu_canvas_kf: Option<uuid::Uuid> = self.ctx.painting_canvas
|
// When painting into float (B), the GPU canvas is B's canvas — don't
|
||||||
.filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id)
|
// use it to replace the Vello scene for the layer (A must still render
|
||||||
.map(|(_, kf_id)| kf_id);
|
// via Vello).
|
||||||
|
let gpu_canvas_kf: Option<uuid::Uuid> = if self.ctx.painting_float {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
self.ctx.painting_canvas
|
||||||
|
.filter(|(layer_id, _)| *layer_id == rendered_layer.layer_id)
|
||||||
|
.map(|(_, kf_id)| kf_id)
|
||||||
|
};
|
||||||
|
|
||||||
if !rendered_layer.has_content && gpu_canvas_kf.is_none() {
|
if !rendered_layer.has_content && gpu_canvas_kf.is_none() {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -671,8 +757,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
&instance_resources.hdr_texture_view,
|
&instance_resources.hdr_texture_view,
|
||||||
) {
|
) {
|
||||||
// GPU canvas blit path: if a live GPU canvas exists for this
|
// GPU canvas blit path: if a live GPU canvas exists for this
|
||||||
// raster layer, sample it directly instead of rendering the Vello
|
// raster layer, blit it directly into the HDR buffer (premultiplied
|
||||||
// scene (which lags until raw_pixels is updated after readback).
|
// linear → Rgba16Float), bypassing the sRGB intermediate entirely.
|
||||||
|
// Vello path: render to sRGB buffer → srgb_to_linear → HDR buffer.
|
||||||
let used_gpu_canvas = if let Some(kf_id) = gpu_canvas_kf {
|
let used_gpu_canvas = if let Some(kf_id) = gpu_canvas_kf {
|
||||||
let mut used = false;
|
let mut used = false;
|
||||||
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
||||||
|
|
@ -690,8 +777,9 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
shared.canvas_blit.blit(
|
shared.canvas_blit.blit(
|
||||||
device, queue,
|
device, queue,
|
||||||
canvas.src_view(),
|
canvas.src_view(),
|
||||||
srgb_view,
|
hdr_layer_view, // blit directly to HDR
|
||||||
&camera,
|
&camera,
|
||||||
|
None, // no mask on layer canvas blit
|
||||||
);
|
);
|
||||||
used = true;
|
used = true;
|
||||||
}
|
}
|
||||||
|
|
@ -702,19 +790,17 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
};
|
};
|
||||||
|
|
||||||
if !used_gpu_canvas {
|
if !used_gpu_canvas {
|
||||||
// Render layer scene to sRGB buffer
|
// Render layer scene to sRGB buffer, then convert to HDR
|
||||||
if let Ok(mut renderer) = shared.renderer.lock() {
|
if let Ok(mut renderer) = shared.renderer.lock() {
|
||||||
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok();
|
renderer.render_to_texture(device, queue, &rendered_layer.scene, srgb_view, &layer_render_params).ok();
|
||||||
}
|
}
|
||||||
|
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("layer_srgb_to_linear_encoder"),
|
||||||
|
});
|
||||||
|
shared.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
|
||||||
|
queue.submit(Some(convert_encoder.finish()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert sRGB to linear HDR
|
|
||||||
let mut convert_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
|
||||||
label: Some("layer_srgb_to_linear_encoder"),
|
|
||||||
});
|
|
||||||
shared.srgb_to_linear.convert(device, &mut convert_encoder, srgb_view, hdr_layer_view);
|
|
||||||
queue.submit(Some(convert_encoder.finish()));
|
|
||||||
|
|
||||||
// Composite this layer onto the HDR accumulator with its opacity
|
// Composite this layer onto the HDR accumulator with its opacity
|
||||||
let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new(
|
let compositor_layer = lightningbeam_core::gpu::CompositorLayer::new(
|
||||||
hdr_layer_handle,
|
hdr_layer_handle,
|
||||||
|
|
@ -914,6 +1000,51 @@ impl egui_wgpu::CallbackTrait for VelloCallback {
|
||||||
buffer_pool.release(clip_hdr_handle);
|
buffer_pool.release(clip_hdr_handle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blit the float GPU canvas on top of all composited layers.
|
||||||
|
// The float_mask_view clips to the selection shape (None = full float visible).
|
||||||
|
if let Some(ref float_sel) = self.ctx.selection.raster_floating {
|
||||||
|
let float_canvas_id = float_sel.canvas_id;
|
||||||
|
let float_x = float_sel.x;
|
||||||
|
let float_y = float_sel.y;
|
||||||
|
let float_w = float_sel.width;
|
||||||
|
let float_h = float_sel.height;
|
||||||
|
if let Ok(gpu_brush) = shared.gpu_brush.lock() {
|
||||||
|
if let Some(canvas) = gpu_brush.canvases.get(&float_canvas_id) {
|
||||||
|
let float_hdr_handle = buffer_pool.acquire(device, hdr_spec);
|
||||||
|
if let (Some(fhdr_view), Some(hdr_view)) = (
|
||||||
|
buffer_pool.get_view(float_hdr_handle),
|
||||||
|
&instance_resources.hdr_texture_view,
|
||||||
|
) {
|
||||||
|
let fcamera = crate::gpu_brush::CameraParams {
|
||||||
|
pan_x: self.ctx.pan_offset.x + float_x as f32 * self.ctx.zoom,
|
||||||
|
pan_y: self.ctx.pan_offset.y + float_y as f32 * self.ctx.zoom,
|
||||||
|
zoom: self.ctx.zoom,
|
||||||
|
canvas_w: float_w as f32,
|
||||||
|
canvas_h: float_h as f32,
|
||||||
|
viewport_w: width as f32,
|
||||||
|
viewport_h: height as f32,
|
||||||
|
_pad: 0.0,
|
||||||
|
};
|
||||||
|
// Blit directly to HDR (straight-alpha linear, no sRGB step)
|
||||||
|
shared.canvas_blit.blit(
|
||||||
|
device, queue,
|
||||||
|
canvas.src_view(),
|
||||||
|
fhdr_view,
|
||||||
|
&fcamera,
|
||||||
|
float_mask_view.as_ref(),
|
||||||
|
);
|
||||||
|
let float_layer = lightningbeam_core::gpu::CompositorLayer::normal(float_hdr_handle, 1.0);
|
||||||
|
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||||
|
label: Some("float_canvas_composite"),
|
||||||
|
});
|
||||||
|
shared.compositor.composite(device, queue, &mut enc, &[float_layer], &buffer_pool, hdr_view, None);
|
||||||
|
queue.submit(Some(enc.finish()));
|
||||||
|
}
|
||||||
|
buffer_pool.release(float_hdr_handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Advance frame counter for buffer cleanup
|
// Advance frame counter for buffer cleanup
|
||||||
buffer_pool.next_frame();
|
buffer_pool.next_frame();
|
||||||
drop(buffer_pool);
|
drop(buffer_pool);
|
||||||
|
|
@ -2288,6 +2419,9 @@ pub struct StagePane {
|
||||||
/// Pixels outside the selection are restored from `buffer_before` so strokes
|
/// Pixels outside the selection are restored from `buffer_before` so strokes
|
||||||
/// only affect the area inside the selection outline.
|
/// only affect the area inside the selection outline.
|
||||||
stroke_clip_selection: Option<lightningbeam_core::selection::RasterSelection>,
|
stroke_clip_selection: Option<lightningbeam_core::selection::RasterSelection>,
|
||||||
|
/// True while the current stroke is being painted onto the float buffer (B)
|
||||||
|
/// rather than the layer canvas (A).
|
||||||
|
painting_float: bool,
|
||||||
/// Synthetic drag/click override for test mode replay (debug builds only)
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: Option<ReplayDragState>,
|
replay_override: Option<ReplayDragState>,
|
||||||
|
|
@ -2410,6 +2544,7 @@ impl StagePane {
|
||||||
painting_canvas: None,
|
painting_canvas: None,
|
||||||
pending_canvas_removal: None,
|
pending_canvas_removal: None,
|
||||||
stroke_clip_selection: None,
|
stroke_clip_selection: None,
|
||||||
|
painting_float: false,
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
replay_override: None,
|
replay_override: None,
|
||||||
}
|
}
|
||||||
|
|
@ -4395,7 +4530,7 @@ impl StagePane {
|
||||||
else {
|
else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
shared.selection.raster_selection = None;
|
let sel = shared.selection.raster_selection.take();
|
||||||
|
|
||||||
let document = shared.action_executor.document_mut();
|
let document = shared.action_executor.document_mut();
|
||||||
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else {
|
let Some(AnyLayer::Raster(rl)) = document.get_layer_mut(&float.layer_id) else {
|
||||||
|
|
@ -4403,13 +4538,24 @@ impl StagePane {
|
||||||
};
|
};
|
||||||
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
let Some(kf) = rl.keyframe_at_mut(float.time) else { return };
|
||||||
|
|
||||||
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels.
|
// Ensure the canvas buffer is allocated (empty Vec = blank transparent canvas).
|
||||||
|
let expected = (kf.width * kf.height * 4) as usize;
|
||||||
|
if kf.raw_pixels.len() != expected {
|
||||||
|
kf.raw_pixels.resize(expected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Porter-Duff "src over dst" for sRGB-encoded premultiplied pixels,
|
||||||
|
// masked by the selection C when present.
|
||||||
for row in 0..float.height {
|
for row in 0..float.height {
|
||||||
let dy = float.y + row as i32;
|
let dy = float.y + row as i32;
|
||||||
if dy < 0 || dy >= kf.height as i32 { continue; }
|
if dy < 0 || dy >= kf.height as i32 { continue; }
|
||||||
for col in 0..float.width {
|
for col in 0..float.width {
|
||||||
let dx = float.x + col as i32;
|
let dx = float.x + col as i32;
|
||||||
if dx < 0 || dx >= kf.width as i32 { continue; }
|
if dx < 0 || dx >= kf.width as i32 { continue; }
|
||||||
|
// Apply selection mask C (if selection exists, only composite where inside)
|
||||||
|
if let Some(ref s) = sel {
|
||||||
|
if !s.contains_pixel(dx, dy) { continue; }
|
||||||
|
}
|
||||||
let si = ((row * float.width + col) * 4) as usize;
|
let si = ((row * float.width + col) * 4) as usize;
|
||||||
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
|
let di = ((dy as u32 * kf.width + dx as u32) * 4) as usize;
|
||||||
let sa = float.pixels[si + 3] as u32;
|
let sa = float.pixels[si + 3] as u32;
|
||||||
|
|
@ -4445,6 +4591,52 @@ impl StagePane {
|
||||||
/// Call this immediately after a marquee / lasso selection is finalized so
|
/// Call this immediately after a marquee / lasso selection is finalized so
|
||||||
/// that all downstream operations (drag-move, copy, cut, stroke-masking)
|
/// that all downstream operations (drag-move, copy, cut, stroke-masking)
|
||||||
/// see a consistent `raster_floating` whenever a selection is active.
|
/// see a consistent `raster_floating` whenever a selection is active.
|
||||||
|
/// Build an R8 mask buffer (0 = outside, 255 = inside) from a selection.
|
||||||
|
fn build_selection_mask(
|
||||||
|
sel: &lightningbeam_core::selection::RasterSelection,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut mask = vec![0u8; (width * height) as usize];
|
||||||
|
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||||
|
let bx0 = x0.max(0) as u32;
|
||||||
|
let by0 = y0.max(0) as u32;
|
||||||
|
let bx1 = (x1 as u32).min(width);
|
||||||
|
let by1 = (y1 as u32).min(height);
|
||||||
|
for y in by0..by1 {
|
||||||
|
for x in bx0..bx1 {
|
||||||
|
if sel.contains_pixel(x as i32, y as i32) {
|
||||||
|
mask[(y * width + x) as usize] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an R8 mask buffer for the float canvas (0 = outside selection, 255 = inside).
|
||||||
|
/// Coordinates are in float-local space: pixel (fx, fy) corresponds to document pixel
|
||||||
|
/// (float_x+fx, float_y+fy).
|
||||||
|
fn build_float_mask(
|
||||||
|
sel: &lightningbeam_core::selection::RasterSelection,
|
||||||
|
float_x: i32, float_y: i32,
|
||||||
|
float_w: u32, float_h: u32,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let mut mask = vec![0u8; (float_w * float_h) as usize];
|
||||||
|
let (x0, y0, x1, y1) = sel.bounding_rect();
|
||||||
|
let bx0 = (x0 - float_x).max(0) as u32;
|
||||||
|
let by0 = (y0 - float_y).max(0) as u32;
|
||||||
|
let bx1 = ((x1 - float_x) as u32).min(float_w);
|
||||||
|
let by1 = ((y1 - float_y) as u32).min(float_h);
|
||||||
|
for fy in by0..by1 {
|
||||||
|
for fx in bx0..bx1 {
|
||||||
|
if sel.contains_pixel(float_x + fx as i32, float_y + fy as i32) {
|
||||||
|
mask[(fy * float_w + fx) as usize] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mask
|
||||||
|
}
|
||||||
|
|
||||||
fn lift_selection_to_float(shared: &mut SharedPaneState) {
|
fn lift_selection_to_float(shared: &mut SharedPaneState) {
|
||||||
use lightningbeam_core::layer::AnyLayer;
|
use lightningbeam_core::layer::AnyLayer;
|
||||||
use lightningbeam_core::selection::RasterFloatingSelection;
|
use lightningbeam_core::selection::RasterFloatingSelection;
|
||||||
|
|
@ -4493,6 +4685,7 @@ impl StagePane {
|
||||||
layer_id,
|
layer_id,
|
||||||
time,
|
time,
|
||||||
canvas_before,
|
canvas_before,
|
||||||
|
canvas_id: uuid::Uuid::new_v4(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4545,102 +4738,168 @@ impl StagePane {
|
||||||
[1.0f32, 1.0, 1.0, 1.0]
|
[1.0f32, 1.0, 1.0, 1.0]
|
||||||
} else {
|
} else {
|
||||||
let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
|
let c = if *shared.brush_use_fg { *shared.stroke_color } else { *shared.fill_color };
|
||||||
[c.r() as f32 / 255.0, c.g() as f32 / 255.0, c.b() as f32 / 255.0, c.a() as f32 / 255.0]
|
let s2l = |v: u8| -> f32 {
|
||||||
|
let f = v as f32 / 255.0;
|
||||||
|
if f <= 0.04045 { f / 12.92 } else { ((f + 0.055) / 1.055).powf(2.4) }
|
||||||
|
};
|
||||||
|
[s2l(c.r()), s2l(c.g()), s2l(c.b()), c.a() as f32 / 255.0]
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Mouse down: capture buffer_before, start stroke, compute first dab
|
// Mouse down: capture buffer_before, start stroke, compute first dab
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
|
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
|
||||||
// Save selection BEFORE commit clears it — used after readback to
|
// Determine if we are painting into the float (B) or the layer (A).
|
||||||
// mask the stroke result so only pixels inside the outline change.
|
let painting_float = shared.selection.raster_floating.is_some();
|
||||||
|
self.painting_float = painting_float;
|
||||||
self.stroke_clip_selection = shared.selection.raster_selection.clone();
|
self.stroke_clip_selection = shared.selection.raster_selection.clone();
|
||||||
|
|
||||||
// Commit any floating selection synchronously so buffer_before and
|
if painting_float {
|
||||||
// the GPU canvas initial upload see the fully-composited canvas.
|
// ---- Paint onto float buffer B ----
|
||||||
Self::commit_raster_floating_now(shared);
|
// Do NOT commit the float. Use the float's own GPU canvas.
|
||||||
|
let (canvas_id, float_x, float_y, canvas_width, canvas_height,
|
||||||
|
buffer_before, layer_id, time) = {
|
||||||
|
let float = shared.selection.raster_floating.as_ref().unwrap();
|
||||||
|
let buf = float.pixels.clone();
|
||||||
|
(float.canvas_id, float.x, float.y, float.width, float.height,
|
||||||
|
buf, float.layer_id, float.time)
|
||||||
|
};
|
||||||
|
|
||||||
let (doc_width, doc_height) = {
|
// Compute first dab (same arithmetic as the layer case).
|
||||||
let doc = shared.action_executor.document();
|
let mut stroke_state = StrokeState::new();
|
||||||
(doc.width as u32, doc.height as u32)
|
stroke_state.distance_since_last_dab = f32::MAX;
|
||||||
};
|
// Convert to float-local space: dabs must be in canvas pixel coords.
|
||||||
|
let first_pt = StrokePoint {
|
||||||
|
x: world_pos.x - float_x as f32,
|
||||||
|
y: world_pos.y - float_y as f32,
|
||||||
|
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
|
||||||
|
};
|
||||||
|
let single = StrokeRecord {
|
||||||
|
brush_settings: brush.clone(),
|
||||||
|
color,
|
||||||
|
blend_mode,
|
||||||
|
points: vec![first_pt.clone()],
|
||||||
|
};
|
||||||
|
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
|
||||||
|
|
||||||
// Ensure the keyframe exists BEFORE reading its ID, so we always get
|
self.painting_canvas = Some((layer_id, canvas_id));
|
||||||
// the real UUID. Previously we read the ID first and fell back to a
|
self.pending_undo_before = Some((
|
||||||
// randomly-generated UUID when no keyframe existed; that fake UUID was
|
layer_id,
|
||||||
// stored in painting_canvas but subsequent drag frames used the real UUID
|
time,
|
||||||
// from keyframe_at(), causing the GPU canvas to be a different object from
|
canvas_width,
|
||||||
// the one being composited.
|
canvas_height,
|
||||||
{
|
buffer_before,
|
||||||
let doc = shared.action_executor.document_mut();
|
));
|
||||||
if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) {
|
self.pending_raster_dabs = Some(PendingRasterDabs {
|
||||||
rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height);
|
keyframe_id: canvas_id,
|
||||||
}
|
layer_id,
|
||||||
}
|
time,
|
||||||
|
canvas_width,
|
||||||
|
canvas_height,
|
||||||
|
initial_pixels: None, // canvas already initialized via lazy GPU init
|
||||||
|
dabs,
|
||||||
|
dab_bbox,
|
||||||
|
wants_final_readback: false,
|
||||||
|
});
|
||||||
|
self.raster_stroke_state = Some((
|
||||||
|
layer_id,
|
||||||
|
time,
|
||||||
|
stroke_state,
|
||||||
|
Vec::new(),
|
||||||
|
));
|
||||||
|
self.raster_last_point = Some(first_pt);
|
||||||
|
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] };
|
||||||
|
|
||||||
// Now read the guaranteed-to-exist keyframe to get the real UUID.
|
} else {
|
||||||
let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = {
|
// ---- Paint onto layer canvas A (existing behavior) ----
|
||||||
let doc = shared.action_executor.document();
|
// Commit any floating selection synchronously so buffer_before and
|
||||||
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) {
|
// the GPU canvas initial upload see the fully-composited canvas.
|
||||||
if let Some(kf) = rl.keyframe_at(*shared.playback_time) {
|
Self::commit_raster_floating_now(shared);
|
||||||
let raw = kf.raw_pixels.clone();
|
|
||||||
let init = if raw.is_empty() {
|
let (doc_width, doc_height) = {
|
||||||
vec![0u8; (kf.width * kf.height * 4) as usize]
|
let doc = shared.action_executor.document();
|
||||||
} else {
|
(doc.width as u32, doc.height as u32)
|
||||||
raw.clone()
|
};
|
||||||
};
|
|
||||||
(kf.id, kf.width, kf.height, raw, init)
|
// Ensure the keyframe exists BEFORE reading its ID, so we always get
|
||||||
} else {
|
// the real UUID. Previously we read the ID first and fell back to a
|
||||||
return; // shouldn't happen after ensure_keyframe_at
|
// randomly-generated UUID when no keyframe existed; that fake UUID was
|
||||||
|
// stored in painting_canvas but subsequent drag frames used the real UUID
|
||||||
|
// from keyframe_at(), causing the GPU canvas to be a different object from
|
||||||
|
// the one being composited.
|
||||||
|
{
|
||||||
|
let doc = shared.action_executor.document_mut();
|
||||||
|
if let Some(AnyLayer::Raster(rl)) = doc.get_layer_mut(&active_layer_id) {
|
||||||
|
rl.ensure_keyframe_at(*shared.playback_time, doc_width, doc_height);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Compute the first dab (single-point tap)
|
// Now read the guaranteed-to-exist keyframe to get the real UUID.
|
||||||
let mut stroke_state = StrokeState::new();
|
let (keyframe_id, canvas_width, canvas_height, buffer_before, initial_pixels) = {
|
||||||
stroke_state.distance_since_last_dab = f32::MAX;
|
let doc = shared.action_executor.document();
|
||||||
|
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&active_layer_id) {
|
||||||
|
if let Some(kf) = rl.keyframe_at(*shared.playback_time) {
|
||||||
|
let raw = kf.raw_pixels.clone();
|
||||||
|
let init = if raw.is_empty() {
|
||||||
|
vec![0u8; (kf.width * kf.height * 4) as usize]
|
||||||
|
} else {
|
||||||
|
raw.clone()
|
||||||
|
};
|
||||||
|
(kf.id, kf.width, kf.height, raw, init)
|
||||||
|
} else {
|
||||||
|
return; // shouldn't happen after ensure_keyframe_at
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let first_pt = StrokePoint {
|
// Compute the first dab (single-point tap)
|
||||||
x: world_pos.x, y: world_pos.y,
|
let mut stroke_state = StrokeState::new();
|
||||||
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
|
stroke_state.distance_since_last_dab = f32::MAX;
|
||||||
};
|
|
||||||
let single = StrokeRecord {
|
|
||||||
brush_settings: brush.clone(),
|
|
||||||
color,
|
|
||||||
blend_mode,
|
|
||||||
points: vec![first_pt.clone()],
|
|
||||||
};
|
|
||||||
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
|
|
||||||
|
|
||||||
self.painting_canvas = Some((active_layer_id, keyframe_id));
|
let first_pt = StrokePoint {
|
||||||
self.pending_undo_before = Some((
|
x: world_pos.x, y: world_pos.y,
|
||||||
active_layer_id,
|
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
|
||||||
*shared.playback_time,
|
};
|
||||||
canvas_width,
|
let single = StrokeRecord {
|
||||||
canvas_height,
|
brush_settings: brush.clone(),
|
||||||
buffer_before,
|
color,
|
||||||
));
|
blend_mode,
|
||||||
self.pending_raster_dabs = Some(PendingRasterDabs {
|
points: vec![first_pt.clone()],
|
||||||
keyframe_id,
|
};
|
||||||
layer_id: active_layer_id,
|
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&single, &mut stroke_state);
|
||||||
time: *shared.playback_time,
|
|
||||||
canvas_width,
|
// Layer strokes apply selection masking at readback time via stroke_clip_selection.
|
||||||
canvas_height,
|
|
||||||
initial_pixels: Some(initial_pixels),
|
self.painting_canvas = Some((active_layer_id, keyframe_id));
|
||||||
dabs,
|
self.pending_undo_before = Some((
|
||||||
dab_bbox,
|
active_layer_id,
|
||||||
wants_final_readback: false,
|
*shared.playback_time,
|
||||||
});
|
canvas_width,
|
||||||
self.raster_stroke_state = Some((
|
canvas_height,
|
||||||
active_layer_id,
|
buffer_before,
|
||||||
*shared.playback_time,
|
));
|
||||||
stroke_state,
|
self.pending_raster_dabs = Some(PendingRasterDabs {
|
||||||
Vec::new(), // buffer_before now lives in pending_undo_before
|
keyframe_id,
|
||||||
));
|
layer_id: active_layer_id,
|
||||||
self.raster_last_point = Some(first_pt);
|
time: *shared.playback_time,
|
||||||
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] };
|
canvas_width,
|
||||||
|
canvas_height,
|
||||||
|
initial_pixels: Some(initial_pixels),
|
||||||
|
dabs,
|
||||||
|
dab_bbox,
|
||||||
|
wants_final_readback: false,
|
||||||
|
});
|
||||||
|
self.raster_stroke_state = Some((
|
||||||
|
active_layer_id,
|
||||||
|
*shared.playback_time,
|
||||||
|
stroke_state,
|
||||||
|
Vec::new(), // buffer_before now lives in pending_undo_before
|
||||||
|
));
|
||||||
|
self.raster_last_point = Some(first_pt);
|
||||||
|
*shared.tool_state = ToolState::DrawingRasterStroke { points: vec![] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
@ -4649,45 +4908,55 @@ impl StagePane {
|
||||||
if self.rsp_dragged(response) {
|
if self.rsp_dragged(response) {
|
||||||
if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state {
|
if let Some((layer_id, time, ref mut stroke_state, _)) = self.raster_stroke_state {
|
||||||
if let Some(prev_pt) = self.raster_last_point.take() {
|
if let Some(prev_pt) = self.raster_last_point.take() {
|
||||||
let curr_pt = StrokePoint {
|
// Get canvas info and float offset now (used for both distance check
|
||||||
x: world_pos.x, y: world_pos.y,
|
// and dab dispatch). prev_pt is already in canvas-local space.
|
||||||
|
let canvas_info = if self.painting_float {
|
||||||
|
shared.selection.raster_floating.as_ref().map(|f| {
|
||||||
|
(f.canvas_id, f.width, f.height, f.x as f32, f.y as f32)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let doc = shared.action_executor.document();
|
||||||
|
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) {
|
||||||
|
if let Some(kf) = rl.keyframe_at(time) {
|
||||||
|
Some((kf.id, kf.width, kf.height, 0.0f32, 0.0f32))
|
||||||
|
} else { None }
|
||||||
|
} else { None }
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((canvas_id, cw, ch, cx, cy)) = canvas_info else {
|
||||||
|
self.raster_last_point = Some(prev_pt);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert current world position to canvas-local space.
|
||||||
|
let curr_local = StrokePoint {
|
||||||
|
x: world_pos.x - cx, y: world_pos.y - cy,
|
||||||
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
|
pressure: 1.0, tilt_x: 0.0, tilt_y: 0.0, timestamp: 0.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const MIN_DIST_SQ: f32 = 1.5 * 1.5;
|
const MIN_DIST_SQ: f32 = 1.5 * 1.5;
|
||||||
let dx = curr_pt.x - prev_pt.x;
|
let dx = curr_local.x - prev_pt.x;
|
||||||
let dy = curr_pt.y - prev_pt.y;
|
let dy = curr_local.y - prev_pt.y;
|
||||||
let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ {
|
let moved_pt = if dx * dx + dy * dy >= MIN_DIST_SQ {
|
||||||
curr_pt.clone()
|
curr_local.clone()
|
||||||
} else {
|
} else {
|
||||||
prev_pt.clone()
|
prev_pt.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
if dx * dx + dy * dy >= MIN_DIST_SQ {
|
if dx * dx + dy * dy >= MIN_DIST_SQ {
|
||||||
// Get keyframe info (needed for canvas dimensions)
|
|
||||||
let (kf_id, kw, kh) = {
|
|
||||||
let doc = shared.action_executor.document();
|
|
||||||
if let Some(AnyLayer::Raster(rl)) = doc.get_layer(&layer_id) {
|
|
||||||
if let Some(kf) = rl.keyframe_at(time) {
|
|
||||||
(kf.id, kf.width, kf.height)
|
|
||||||
} else { self.raster_last_point = Some(moved_pt); return; }
|
|
||||||
} else { self.raster_last_point = Some(moved_pt); return; }
|
|
||||||
};
|
|
||||||
|
|
||||||
let seg = StrokeRecord {
|
let seg = StrokeRecord {
|
||||||
brush_settings: brush.clone(),
|
brush_settings: brush.clone(),
|
||||||
color,
|
color,
|
||||||
blend_mode,
|
blend_mode,
|
||||||
points: vec![prev_pt, curr_pt],
|
points: vec![prev_pt, curr_local],
|
||||||
};
|
};
|
||||||
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state);
|
let (dabs, dab_bbox) = BrushEngine::compute_dabs(&seg, stroke_state);
|
||||||
|
|
||||||
self.pending_raster_dabs = Some(PendingRasterDabs {
|
self.pending_raster_dabs = Some(PendingRasterDabs {
|
||||||
keyframe_id: kf_id,
|
keyframe_id: canvas_id,
|
||||||
layer_id,
|
layer_id,
|
||||||
time,
|
time,
|
||||||
canvas_width: kw,
|
canvas_width: cw,
|
||||||
canvas_height: kh,
|
canvas_height: ch,
|
||||||
initial_pixels: None,
|
initial_pixels: None,
|
||||||
dabs,
|
dabs,
|
||||||
dab_bbox,
|
dab_bbox,
|
||||||
|
|
@ -4718,12 +4987,17 @@ impl StagePane {
|
||||||
self.pending_undo_before.as_ref()
|
self.pending_undo_before.as_ref()
|
||||||
{
|
{
|
||||||
let (ub_layer, ub_time, ub_cw, ub_ch) = (*ub_layer, *ub_time, *ub_cw, *ub_ch);
|
let (ub_layer, ub_time, ub_cw, ub_ch) = (*ub_layer, *ub_time, *ub_cw, *ub_ch);
|
||||||
// Get keyframe_id for the canvas texture lookup
|
// Get canvas_id for the canvas texture lookup.
|
||||||
let kf_id = shared.action_executor.document()
|
// When painting into the float, use float.canvas_id; otherwise the keyframe id.
|
||||||
.get_layer(&ub_layer)
|
let kf_id = if self.painting_float {
|
||||||
.and_then(|l| if let AnyLayer::Raster(rl) = l {
|
self.painting_canvas.map(|(_, cid)| cid)
|
||||||
rl.keyframe_at(ub_time).map(|kf| kf.id)
|
} else {
|
||||||
} else { None });
|
shared.action_executor.document()
|
||||||
|
.get_layer(&ub_layer)
|
||||||
|
.and_then(|l| if let AnyLayer::Raster(rl) = l {
|
||||||
|
rl.keyframe_at(ub_time).map(|kf| kf.id)
|
||||||
|
} else { None })
|
||||||
|
};
|
||||||
if let Some(kf_id) = kf_id {
|
if let Some(kf_id) = kf_id {
|
||||||
self.pending_raster_dabs = Some(PendingRasterDabs {
|
self.pending_raster_dabs = Some(PendingRasterDabs {
|
||||||
keyframe_id: kf_id,
|
keyframe_id: kf_id,
|
||||||
|
|
@ -7322,7 +7596,7 @@ impl StagePane {
|
||||||
|
|
||||||
/// Render raster selection overlays:
|
/// Render raster selection overlays:
|
||||||
/// - Animated "marching ants" around the active raster selection (marquee or lasso)
|
/// - Animated "marching ants" around the active raster selection (marquee or lasso)
|
||||||
/// - Floating selection pixels as an egui texture composited at the float position
|
/// - (Float pixels are rendered through the Vello HDR pipeline in prepare(), not here)
|
||||||
fn render_raster_selection_overlays(
|
fn render_raster_selection_overlays(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
|
|
@ -7332,8 +7606,7 @@ impl StagePane {
|
||||||
use lightningbeam_core::selection::RasterSelection;
|
use lightningbeam_core::selection::RasterSelection;
|
||||||
|
|
||||||
let has_sel = shared.selection.raster_selection.is_some();
|
let has_sel = shared.selection.raster_selection.is_some();
|
||||||
let has_float = shared.selection.raster_floating.is_some();
|
if !has_sel { return; }
|
||||||
if !has_sel && !has_float { return; }
|
|
||||||
|
|
||||||
let time = ui.input(|i| i.time) as f32;
|
let time = ui.input(|i| i.time) as f32;
|
||||||
// 8px/s scroll rate → repeating every 1 s
|
// 8px/s scroll rate → repeating every 1 s
|
||||||
|
|
@ -7358,37 +7631,6 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Floating selection texture overlay ────────────────────────────────
|
|
||||||
if let Some(float) = &shared.selection.raster_floating {
|
|
||||||
let tex_id = format!("raster_float_{}_{}", float.layer_id, float.time.to_bits());
|
|
||||||
|
|
||||||
// Upload pixels as an egui texture (re-uploaded every frame the float exists;
|
|
||||||
// egui caches by name so this is a no-op when the pixels haven't changed).
|
|
||||||
let color_image = egui::ColorImage::from_rgba_premultiplied(
|
|
||||||
[float.width as usize, float.height as usize],
|
|
||||||
&float.pixels,
|
|
||||||
);
|
|
||||||
let texture = ui.ctx().load_texture(
|
|
||||||
&tex_id,
|
|
||||||
color_image,
|
|
||||||
egui::TextureOptions::NEAREST,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Position in screen space
|
|
||||||
let sx = rect.min.x + pan.x + float.x as f32 * zoom;
|
|
||||||
let sy = rect.min.y + pan.y + float.y as f32 * zoom;
|
|
||||||
let sw = float.width as f32 * zoom;
|
|
||||||
let sh = float.height as f32 * zoom;
|
|
||||||
let float_rect = egui::Rect::from_min_size(egui::pos2(sx, sy), egui::vec2(sw, sh));
|
|
||||||
|
|
||||||
painter.image(
|
|
||||||
texture.id(),
|
|
||||||
float_rect,
|
|
||||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
|
||||||
egui::Color32::WHITE,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep animating while a selection is visible
|
// Keep animating while a selection is visible
|
||||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
|
||||||
}
|
}
|
||||||
|
|
@ -7538,43 +7780,71 @@ impl PaneRenderer for StagePane {
|
||||||
.get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new())))
|
.get_or_init(|| Arc::new(Mutex::new(std::collections::HashMap::new())))
|
||||||
.lock() {
|
.lock() {
|
||||||
if let Some(readback) = results.remove(&self.instance_id) {
|
if let Some(readback) = results.remove(&self.instance_id) {
|
||||||
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
|
if self.painting_float {
|
||||||
use lightningbeam_core::actions::RasterStrokeAction;
|
// Float stroke: update float.pixels, don't create a layer RasterStrokeAction.
|
||||||
// If a selection was active at stroke-start, restore any pixels
|
if let Some((_, _, w, h, buffer_before)) = self.pending_undo_before.take() {
|
||||||
// outside the selection outline to their pre-stroke values.
|
if let Some(ref mut float) = shared.selection.raster_floating {
|
||||||
let canvas_after = match self.stroke_clip_selection.take() {
|
// Apply float-local selection mask: restore pixels outside C to
|
||||||
None => readback.pixels,
|
// pre-stroke values so the stroke only affects the selected area.
|
||||||
Some(sel) => {
|
let mut pixels = readback.pixels;
|
||||||
let mut masked = readback.pixels;
|
if let Some(ref sel) = self.stroke_clip_selection {
|
||||||
for y in 0..h {
|
for fy in 0..h {
|
||||||
for x in 0..w {
|
for fx in 0..w {
|
||||||
if !sel.contains_pixel(x as i32, y as i32) {
|
if !sel.contains_pixel(float.x + fx as i32, float.y + fy as i32) {
|
||||||
let i = ((y * w + x) * 4) as usize;
|
let i = ((fy * w + fx) * 4) as usize;
|
||||||
masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
|
pixels[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
masked
|
float.pixels = pixels;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
let action = RasterStrokeAction::new(
|
self.stroke_clip_selection = None;
|
||||||
layer_id,
|
self.painting_float = false;
|
||||||
time,
|
// Keep float GPU canvas alive for the next stroke on the float.
|
||||||
buffer_before,
|
// Don't schedule canvas_removal — just clear painting_canvas.
|
||||||
canvas_after,
|
self.painting_canvas = None;
|
||||||
w,
|
} else {
|
||||||
h,
|
// Layer stroke: existing behavior — create RasterStrokeAction on raw_pixels.
|
||||||
);
|
if let Some((layer_id, time, w, h, buffer_before)) = self.pending_undo_before.take() {
|
||||||
// execute() sets raw_pixels = buffer_after so future Vello renders
|
use lightningbeam_core::actions::RasterStrokeAction;
|
||||||
// and file saves see the completed stroke.
|
// If a selection was active at stroke-start, restore any pixels
|
||||||
let _ = shared.action_executor.execute(Box::new(action));
|
// outside the selection outline to their pre-stroke values.
|
||||||
}
|
let canvas_after = match self.stroke_clip_selection.take() {
|
||||||
// raw_pixels is now up to date; switch compositing back to the Vello
|
None => readback.pixels,
|
||||||
// scene. Schedule the GPU canvas for removal at the start of the next
|
Some(sel) => {
|
||||||
// prepare() — keeping it alive for this frame's composite avoids a
|
let mut masked = readback.pixels;
|
||||||
// one-frame flash of the stale Vello scene.
|
for y in 0..h {
|
||||||
if let Some((_, kf_id)) = self.painting_canvas.take() {
|
for x in 0..w {
|
||||||
self.pending_canvas_removal = Some(kf_id);
|
if !sel.contains_pixel(x as i32, y as i32) {
|
||||||
|
let i = ((y * w + x) * 4) as usize;
|
||||||
|
masked[i..i + 4].copy_from_slice(&buffer_before[i..i + 4]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
masked
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let action = RasterStrokeAction::new(
|
||||||
|
layer_id,
|
||||||
|
time,
|
||||||
|
buffer_before,
|
||||||
|
canvas_after,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
);
|
||||||
|
// execute() sets raw_pixels = buffer_after so future Vello renders
|
||||||
|
// and file saves see the completed stroke.
|
||||||
|
let _ = shared.action_executor.execute(Box::new(action));
|
||||||
|
}
|
||||||
|
// raw_pixels is now up to date; switch compositing back to the Vello
|
||||||
|
// scene. Schedule the GPU canvas for removal at the start of the next
|
||||||
|
// prepare() — keeping it alive for this frame's composite avoids a
|
||||||
|
// one-frame flash of the stale Vello scene.
|
||||||
|
if let Some((_, kf_id)) = self.painting_canvas.take() {
|
||||||
|
self.pending_canvas_removal = Some(kf_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -7929,6 +8199,7 @@ impl PaneRenderer for StagePane {
|
||||||
instance_id_for_readback: self.instance_id,
|
instance_id_for_readback: self.instance_id,
|
||||||
painting_canvas: self.painting_canvas,
|
painting_canvas: self.painting_canvas,
|
||||||
pending_canvas_removal: self.pending_canvas_removal.take(),
|
pending_canvas_removal: self.pending_canvas_removal.take(),
|
||||||
|
painting_float: self.painting_float,
|
||||||
}};
|
}};
|
||||||
|
|
||||||
let cb = egui_wgpu::Callback::new_paint_callback(
|
let cb = egui_wgpu::Callback::new_paint_callback(
|
||||||
|
|
@ -7939,13 +8210,14 @@ impl PaneRenderer for StagePane {
|
||||||
ui.painter().add(cb);
|
ui.painter().add(cb);
|
||||||
|
|
||||||
// Show camera info overlay
|
// Show camera info overlay
|
||||||
|
let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200));
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
rect.min + egui::vec2(10.0, 10.0),
|
rect.min + egui::vec2(10.0, 10.0),
|
||||||
egui::Align2::LEFT_TOP,
|
egui::Align2::LEFT_TOP,
|
||||||
format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})",
|
format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})",
|
||||||
self.zoom, self.pan_offset.x, self.pan_offset.y),
|
self.zoom, self.pan_offset.x, self.pan_offset.y),
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
egui::Color32::from_gray(200),
|
info_color,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render breadcrumb navigation when inside a movie clip
|
// Render breadcrumb navigation when inside a movie clip
|
||||||
|
|
|
||||||
|
|
@ -462,6 +462,21 @@ fn find_sampled_audio_track_for_clip(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get layer type display name and color for an AnyLayer
|
||||||
|
fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) {
|
||||||
|
match layer {
|
||||||
|
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
||||||
|
AnyLayer::Audio(al) => match al.audio_layer_type {
|
||||||
|
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
||||||
|
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
||||||
|
},
|
||||||
|
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
||||||
|
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
||||||
|
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
||||||
|
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TimelinePane {
|
impl TimelinePane {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -1008,7 +1023,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Background
|
// Background
|
||||||
let bg_style = theme.style(".timeline-background", ui.ctx());
|
let bg_style = theme.style(".timeline-background", ui.ctx());
|
||||||
let bg_color = bg_style.background_color.unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
||||||
painter.rect_filled(rect, 0.0, bg_color);
|
painter.rect_filled(rect, 0.0, bg_color);
|
||||||
|
|
||||||
let text_style = theme.style(".text-primary", ui.ctx());
|
let text_style = theme.style(".text-primary", ui.ctx());
|
||||||
|
|
@ -1027,7 +1042,7 @@ impl TimelinePane {
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
||||||
rect.min + egui::vec2(x, rect.height())],
|
rect.min + egui::vec2(x, rect.height())],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(100)),
|
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick"], ui.ctx(), egui::Color32::from_gray(100))),
|
||||||
);
|
);
|
||||||
painter.text(
|
painter.text(
|
||||||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||||||
|
|
@ -1041,7 +1056,7 @@ impl TimelinePane {
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||||||
rect.min + egui::vec2(minor_x, rect.height())],
|
rect.min + egui::vec2(minor_x, rect.height())],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(60)),
|
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick-minor"], ui.ctx(), egui::Color32::from_gray(60))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1111,7 +1126,7 @@ impl TimelinePane {
|
||||||
if x >= 0.0 && x <= rect.width() {
|
if x >= 0.0 && x <= rect.width() {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
let scrubber_style = theme.style(".timeline-scrubber", ui.ctx());
|
let scrubber_style = theme.style(".timeline-scrubber", ui.ctx());
|
||||||
let scrubber_color = scrubber_style.background_color.unwrap_or(egui::Color32::from_rgb(204, 34, 34));
|
let scrubber_color = scrubber_style.background_color().unwrap_or(egui::Color32::from_rgb(204, 34, 34));
|
||||||
|
|
||||||
// Red vertical line
|
// Red vertical line
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
|
|
@ -1159,7 +1174,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Get note color from theme CSS (fallback to black)
|
// Get note color from theme CSS (fallback to black)
|
||||||
let note_style = theme.style(".timeline-midi-note", ctx);
|
let note_style = theme.style(".timeline-midi-note", ctx);
|
||||||
let note_color = note_style.background_color.unwrap_or(egui::Color32::BLACK);
|
let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
|
||||||
|
|
||||||
// Build a map of active notes (note_number -> note_on_timestamp)
|
// Build a map of active notes (note_number -> note_on_timestamp)
|
||||||
// to calculate durations when we encounter note-offs
|
// to calculate durations when we encounter note-offs
|
||||||
|
|
@ -1269,7 +1284,7 @@ impl TimelinePane {
|
||||||
) {
|
) {
|
||||||
// Background for header column
|
// Background for header column
|
||||||
let header_style = theme.style(".timeline-header", ui.ctx());
|
let header_style = theme.style(".timeline-header", ui.ctx());
|
||||||
let header_bg = header_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
let header_bg = header_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
rect,
|
rect,
|
||||||
0.0,
|
0.0,
|
||||||
|
|
@ -1279,13 +1294,14 @@ impl TimelinePane {
|
||||||
// Theme colors for active/inactive layers
|
// Theme colors for active/inactive layers
|
||||||
let active_style = theme.style(".timeline-layer-active", ui.ctx());
|
let active_style = theme.style(".timeline-layer-active", ui.ctx());
|
||||||
let inactive_style = theme.style(".timeline-layer-inactive", ui.ctx());
|
let inactive_style = theme.style(".timeline-layer-inactive", ui.ctx());
|
||||||
let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(79, 79, 79));
|
let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(79, 79, 79));
|
||||||
let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(51, 51, 51));
|
let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(51, 51, 51));
|
||||||
|
|
||||||
// Get text color from theme
|
// Get text color from theme
|
||||||
let text_style = theme.style(".text-primary", ui.ctx());
|
let text_style = 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));
|
||||||
let secondary_text_color = egui::Color32::from_gray(150);
|
let secondary_style = theme.style(".text-secondary", ui.ctx());
|
||||||
|
let secondary_text_color = secondary_style.text_color.unwrap_or(egui::Color32::from_gray(150));
|
||||||
|
|
||||||
// Build virtual row list (accounts for group expansion)
|
// Build virtual row list (accounts for group expansion)
|
||||||
let all_rows = build_timeline_rows(context_layers);
|
let all_rows = build_timeline_rows(context_layers);
|
||||||
|
|
@ -1331,35 +1347,15 @@ impl TimelinePane {
|
||||||
let (layer_id, layer_name, layer_type, type_color) = match row {
|
let (layer_id, layer_name, layer_type, type_color) = match row {
|
||||||
TimelineRow::Normal(layer) => {
|
TimelineRow::Normal(layer) => {
|
||||||
let data = layer.layer();
|
let data = layer.layer();
|
||||||
let (lt, tc) = match layer {
|
let (lt, tc) = layer_type_info(layer);
|
||||||
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
|
||||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
|
||||||
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
|
||||||
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
|
||||||
},
|
|
||||||
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
|
||||||
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
|
||||||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
|
||||||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
|
||||||
};
|
|
||||||
(layer.id(), data.name.clone(), lt, tc)
|
(layer.id(), data.name.clone(), lt, tc)
|
||||||
}
|
}
|
||||||
TimelineRow::CollapsedGroup { group, .. } => {
|
TimelineRow::CollapsedGroup { group, .. } => {
|
||||||
(group.layer.id, group.layer.name.clone(), "Group", egui::Color32::from_rgb(0, 180, 180))
|
(group.layer.id, group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)))
|
||||||
}
|
}
|
||||||
TimelineRow::GroupChild { child, .. } => {
|
TimelineRow::GroupChild { child, .. } => {
|
||||||
let data = child.layer();
|
let data = child.layer();
|
||||||
let (lt, tc) = match child {
|
let (lt, tc) = layer_type_info(child);
|
||||||
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
|
||||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
|
||||||
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
|
||||||
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
|
||||||
},
|
|
||||||
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
|
||||||
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
|
||||||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
|
||||||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
|
||||||
};
|
|
||||||
(child.id(), data.name.clone(), lt, tc)
|
(child.id(), data.name.clone(), lt, tc)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1388,7 +1384,7 @@ impl TimelinePane {
|
||||||
let group_color = match row {
|
let group_color = match row {
|
||||||
TimelineRow::GroupChild { .. } | TimelineRow::CollapsedGroup { .. } => {
|
TimelineRow::GroupChild { .. } | TimelineRow::CollapsedGroup { .. } => {
|
||||||
// Solid dark teal for the group gutter
|
// Solid dark teal for the group gutter
|
||||||
egui::Color32::from_rgb(0, 50, 50)
|
theme.bg_color(&["#timeline", ".group-gutter"], ui.ctx(), egui::Color32::from_rgb(0, 50, 50))
|
||||||
}
|
}
|
||||||
_ => header_bg,
|
_ => header_bg,
|
||||||
};
|
};
|
||||||
|
|
@ -1399,7 +1395,7 @@ impl TimelinePane {
|
||||||
egui::pos2(header_rect.min.x + indent - 2.0, y),
|
egui::pos2(header_rect.min.x + indent - 2.0, y),
|
||||||
egui::vec2(2.0, LAYER_HEIGHT),
|
egui::vec2(2.0, LAYER_HEIGHT),
|
||||||
);
|
);
|
||||||
ui.painter().rect_filled(accent_rect, 0.0, egui::Color32::from_rgb(0, 180, 180));
|
ui.painter().rect_filled(accent_rect, 0.0, theme.bg_color(&["#timeline", ".group-accent"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)));
|
||||||
|
|
||||||
// Draw collapse triangle on first child row (painted, not text)
|
// Draw collapse triangle on first child row (painted, not text)
|
||||||
if let TimelineRow::GroupChild { show_collapse: true, .. } = row {
|
if let TimelineRow::GroupChild { show_collapse: true, .. } = row {
|
||||||
|
|
@ -1412,7 +1408,7 @@ impl TimelinePane {
|
||||||
egui::pos2(cx + s, cy - s * 0.6),
|
egui::pos2(cx + s, cy - s * 0.6),
|
||||||
egui::pos2(cx, cy + s * 0.6),
|
egui::pos2(cx, cy + s * 0.6),
|
||||||
];
|
];
|
||||||
ui.painter().add(egui::Shape::convex_polygon(tri, egui::Color32::from_gray(180), egui::Stroke::NONE));
|
ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make the ENTIRE gutter clickable for collapse on any GroupChild row
|
// Make the ENTIRE gutter clickable for collapse on any GroupChild row
|
||||||
|
|
@ -1448,7 +1444,7 @@ impl TimelinePane {
|
||||||
egui::pos2(cx - s * 0.6, cy + s),
|
egui::pos2(cx - s * 0.6, cy + s),
|
||||||
egui::pos2(cx + s * 0.6, cy),
|
egui::pos2(cx + s * 0.6, cy),
|
||||||
];
|
];
|
||||||
ui.painter().add(egui::Shape::convex_polygon(tri, egui::Color32::from_gray(180), egui::Stroke::NONE));
|
ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE));
|
||||||
|
|
||||||
// Clickable area for expand
|
// Clickable area for expand
|
||||||
let chevron_rect = egui::Rect::from_min_size(
|
let chevron_rect = egui::Rect::from_min_size(
|
||||||
|
|
@ -1562,9 +1558,9 @@ impl TimelinePane {
|
||||||
let cam_text = if camera_enabled { "📹" } else { "📷" };
|
let cam_text = if camera_enabled { "📹" } else { "📷" };
|
||||||
let button = egui::Button::new(cam_text)
|
let button = egui::Button::new(cam_text)
|
||||||
.fill(if camera_enabled {
|
.fill(if camera_enabled {
|
||||||
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 {
|
||||||
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)
|
||||||
|
|
@ -1573,9 +1569,9 @@ impl TimelinePane {
|
||||||
let mute_text = if is_muted { "🔇" } else { "🔊" };
|
let mute_text = if is_muted { "🔇" } else { "🔊" };
|
||||||
let button = egui::Button::new(mute_text)
|
let button = egui::Button::new(mute_text)
|
||||||
.fill(if is_muted {
|
.fill(if is_muted {
|
||||||
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 {
|
||||||
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)
|
||||||
|
|
@ -1606,9 +1602,9 @@ impl TimelinePane {
|
||||||
let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| {
|
let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| {
|
||||||
let button = egui::Button::new("🎧")
|
let button = egui::Button::new("🎧")
|
||||||
.fill(if is_soloed {
|
.fill(if is_soloed {
|
||||||
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 {
|
||||||
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)
|
||||||
|
|
@ -1630,9 +1626,9 @@ impl TimelinePane {
|
||||||
let lock_text = if is_locked { "🔒" } else { "🔓" };
|
let lock_text = if is_locked { "🔒" } else { "🔓" };
|
||||||
let button = egui::Button::new(lock_text)
|
let button = egui::Button::new(lock_text)
|
||||||
.fill(if is_locked {
|
.fill(if is_locked {
|
||||||
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 {
|
||||||
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)
|
||||||
|
|
@ -1732,7 +1728,7 @@ impl TimelinePane {
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
"Gain",
|
"Gain",
|
||||||
egui::FontId::proportional(9.0),
|
egui::FontId::proportional(9.0),
|
||||||
egui::Color32::from_gray(140),
|
theme.text_color(&["#timeline", ".gain-label"], ui.ctx(), egui::Color32::from_gray(140)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1763,11 +1759,11 @@ impl TimelinePane {
|
||||||
let clamped = level.min(1.0);
|
let clamped = level.min(1.0);
|
||||||
let filled_width = meter_rect.width() * clamped;
|
let filled_width = meter_rect.width() * clamped;
|
||||||
let color = if clamped > 0.9 {
|
let color = if clamped > 0.9 {
|
||||||
egui::Color32::from_rgb(220, 50, 50)
|
theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50))
|
||||||
} else if clamped > 0.7 {
|
} else if clamped > 0.7 {
|
||||||
egui::Color32::from_rgb(220, 200, 50)
|
theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(50, 200, 80)
|
theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80))
|
||||||
};
|
};
|
||||||
let filled = egui::Rect::from_min_size(
|
let filled = egui::Rect::from_min_size(
|
||||||
meter_rect.left_top(),
|
meter_rect.left_top(),
|
||||||
|
|
@ -1783,7 +1779,7 @@ impl TimelinePane {
|
||||||
egui::pos2(header_rect.min.x, header_rect.max.y),
|
egui::pos2(header_rect.min.x, header_rect.max.y),
|
||||||
egui::pos2(header_rect.max.x, header_rect.max.y),
|
egui::pos2(header_rect.max.x, header_rect.max.y),
|
||||||
],
|
],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(20)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1817,34 +1813,14 @@ impl TimelinePane {
|
||||||
};
|
};
|
||||||
let (drag_name, drag_type_str, drag_type_color) = match dragged_row {
|
let (drag_name, drag_type_str, drag_type_color) = match dragged_row {
|
||||||
TimelineRow::Normal(layer) => {
|
TimelineRow::Normal(layer) => {
|
||||||
let (lt, tc) = match layer {
|
let (lt, tc) = layer_type_info(layer);
|
||||||
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
|
||||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
|
||||||
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
|
||||||
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
|
||||||
},
|
|
||||||
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
|
||||||
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
|
||||||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
|
||||||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(100, 200, 255)),
|
|
||||||
};
|
|
||||||
(layer.layer().name.clone(), lt, tc)
|
(layer.layer().name.clone(), lt, tc)
|
||||||
}
|
}
|
||||||
TimelineRow::CollapsedGroup { group, .. } => {
|
TimelineRow::CollapsedGroup { group, .. } => {
|
||||||
(group.layer.name.clone(), "Group", egui::Color32::from_rgb(0, 180, 180))
|
(group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)))
|
||||||
}
|
}
|
||||||
TimelineRow::GroupChild { child, .. } => {
|
TimelineRow::GroupChild { child, .. } => {
|
||||||
let (lt, tc) = match child {
|
let (lt, tc) = layer_type_info(child);
|
||||||
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
|
||||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
|
||||||
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
|
||||||
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
|
||||||
},
|
|
||||||
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
|
||||||
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
|
||||||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
|
||||||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(100, 200, 255)),
|
|
||||||
};
|
|
||||||
(child.layer().name.clone(), lt, tc)
|
(child.layer().name.clone(), lt, tc)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1878,7 +1854,7 @@ impl TimelinePane {
|
||||||
// Separator line at bottom
|
// Separator line at bottom
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[egui::pos2(float_rect.min.x, float_rect.max.y), egui::pos2(float_rect.max.x, float_rect.max.y)],
|
[egui::pos2(float_rect.min.x, float_rect.max.y), egui::pos2(float_rect.max.x, float_rect.max.y)],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(20)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1889,7 +1865,7 @@ impl TimelinePane {
|
||||||
egui::pos2(rect.max.x, rect.min.y),
|
egui::pos2(rect.max.x, rect.min.y),
|
||||||
egui::pos2(rect.max.x, rect.max.y),
|
egui::pos2(rect.max.x, rect.max.y),
|
||||||
],
|
],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(20)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1923,8 +1899,8 @@ impl TimelinePane {
|
||||||
// Theme colors for active/inactive layers
|
// Theme colors for active/inactive layers
|
||||||
let active_style = theme.style(".timeline-row-active", ui.ctx());
|
let active_style = theme.style(".timeline-row-active", ui.ctx());
|
||||||
let inactive_style = theme.style(".timeline-row-inactive", ui.ctx());
|
let inactive_style = theme.style(".timeline-row-inactive", ui.ctx());
|
||||||
let active_color = active_style.background_color.unwrap_or(egui::Color32::from_rgb(85, 85, 85));
|
let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(85, 85, 85));
|
||||||
let inactive_color = inactive_style.background_color.unwrap_or(egui::Color32::from_rgb(136, 136, 136));
|
let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(136, 136, 136));
|
||||||
|
|
||||||
// Build a map of clip_instance_id -> InstanceGroup for linked clip previews
|
// Build a map of clip_instance_id -> InstanceGroup for linked clip previews
|
||||||
let mut instance_to_group: std::collections::HashMap<uuid::Uuid, &lightningbeam_core::instance_group::InstanceGroup> = std::collections::HashMap::new();
|
let mut instance_to_group: std::collections::HashMap<uuid::Uuid, &lightningbeam_core::instance_group::InstanceGroup> = std::collections::HashMap::new();
|
||||||
|
|
@ -2038,7 +2014,7 @@ impl TimelinePane {
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[egui::pos2(rect.min.x + x, y),
|
[egui::pos2(rect.min.x + x, y),
|
||||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(30))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
time += interval;
|
time += interval;
|
||||||
|
|
@ -2058,7 +2034,7 @@ impl TimelinePane {
|
||||||
painter.line_segment(
|
painter.line_segment(
|
||||||
[egui::pos2(rect.min.x + x, y),
|
[egui::pos2(rect.min.x + x, y),
|
||||||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||||||
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, egui::Color32::from_gray(gray)),
|
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(gray))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2102,14 +2078,14 @@ impl TimelinePane {
|
||||||
let any_selected = child_clips.iter().any(|(_, ci)| selection.contains_clip_instance(&ci.id));
|
let any_selected = child_clips.iter().any(|(_, ci)| selection.contains_clip_instance(&ci.id));
|
||||||
// Draw each merged span as a teal bar (brighter when selected)
|
// Draw each merged span as a teal bar (brighter when selected)
|
||||||
let teal = if any_selected {
|
let teal = if any_selected {
|
||||||
egui::Color32::from_rgb(30, 190, 190)
|
theme.bg_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(30, 190, 190))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(0, 150, 150)
|
theme.bg_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(0, 150, 150))
|
||||||
};
|
};
|
||||||
let bright_teal = if any_selected {
|
let bright_teal = if any_selected {
|
||||||
egui::Color32::from_rgb(150, 255, 255)
|
theme.text_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(150, 255, 255))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(100, 220, 220)
|
theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220))
|
||||||
};
|
};
|
||||||
for (s, e) in &merged {
|
for (s, e) in &merged {
|
||||||
let sx = self.time_to_x(*s);
|
let sx = self.time_to_x(*s);
|
||||||
|
|
@ -2344,7 +2320,7 @@ impl TimelinePane {
|
||||||
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
||||||
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
||||||
],
|
],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(20)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
continue; // Skip normal clip rendering for collapsed groups
|
continue; // Skip normal clip rendering for collapsed groups
|
||||||
}
|
}
|
||||||
|
|
@ -2641,39 +2617,15 @@ impl TimelinePane {
|
||||||
let visible_end_x = end_x.min(rect.width());
|
let visible_end_x = end_x.min(rect.width());
|
||||||
|
|
||||||
// Choose color based on layer type
|
// Choose color based on layer type
|
||||||
let (clip_color, bright_color) = match layer {
|
let (clip_color, bright_color) = {
|
||||||
lightningbeam_core::layer::AnyLayer::Vector(_) => (
|
let (_, base_color) = layer_type_info(layer);
|
||||||
egui::Color32::from_rgb(220, 150, 80), // Orange
|
// Derive bright version by lightening each channel
|
||||||
egui::Color32::from_rgb(255, 210, 150), // Bright orange
|
let bright = egui::Color32::from_rgb(
|
||||||
),
|
(base_color.r() as u16 + 80).min(255) as u8,
|
||||||
lightningbeam_core::layer::AnyLayer::Audio(audio_layer) => {
|
(base_color.g() as u16 + 60).min(255) as u8,
|
||||||
match audio_layer.audio_layer_type {
|
(base_color.b() as u16 + 70).min(255) as u8,
|
||||||
lightningbeam_core::layer::AudioLayerType::Midi => (
|
);
|
||||||
egui::Color32::from_rgb(100, 200, 150), // Green
|
(base_color, bright)
|
||||||
egui::Color32::from_rgb(150, 255, 200), // Bright green
|
|
||||||
),
|
|
||||||
lightningbeam_core::layer::AudioLayerType::Sampled => (
|
|
||||||
egui::Color32::from_rgb(80, 150, 220), // Blue
|
|
||||||
egui::Color32::from_rgb(150, 210, 255), // Bright blue
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lightningbeam_core::layer::AnyLayer::Video(_) => (
|
|
||||||
egui::Color32::from_rgb(150, 80, 220), // Purple
|
|
||||||
egui::Color32::from_rgb(200, 150, 255), // Bright purple
|
|
||||||
),
|
|
||||||
lightningbeam_core::layer::AnyLayer::Effect(_) => (
|
|
||||||
egui::Color32::from_rgb(220, 80, 160), // Pink
|
|
||||||
egui::Color32::from_rgb(255, 120, 200), // Bright pink
|
|
||||||
),
|
|
||||||
lightningbeam_core::layer::AnyLayer::Group(_) => (
|
|
||||||
egui::Color32::from_rgb(0, 150, 150), // Teal
|
|
||||||
egui::Color32::from_rgb(100, 220, 220), // Bright teal
|
|
||||||
),
|
|
||||||
lightningbeam_core::layer::AnyLayer::Raster(_) => (
|
|
||||||
egui::Color32::from_rgb(160, 100, 200), // Purple/violet
|
|
||||||
egui::Color32::from_rgb(200, 160, 240), // Bright purple/violet
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let (row, total_rows) = clip_stacking[clip_instance_index];
|
let (row, total_rows) = clip_stacking[clip_instance_index];
|
||||||
|
|
@ -3142,11 +3094,11 @@ impl TimelinePane {
|
||||||
egui::pos2(cx, cy + size),
|
egui::pos2(cx, cy + size),
|
||||||
egui::pos2(cx - size, cy),
|
egui::pos2(cx - size, cy),
|
||||||
];
|
];
|
||||||
let color = egui::Color32::from_rgb(255, 220, 100);
|
let color = theme.bg_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(255, 220, 100));
|
||||||
painter.add(egui::Shape::convex_polygon(
|
painter.add(egui::Shape::convex_polygon(
|
||||||
diamond.to_vec(),
|
diamond.to_vec(),
|
||||||
color,
|
color,
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_rgb(180, 150, 50)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3158,7 +3110,7 @@ impl TimelinePane {
|
||||||
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
||||||
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
||||||
],
|
],
|
||||||
egui::Stroke::new(1.0, egui::Color32::from_gray(20)),
|
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4258,7 +4210,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
egui::Sense::hover(),
|
egui::Sense::hover(),
|
||||||
);
|
);
|
||||||
// Background
|
// Background
|
||||||
ui.painter().rect_filled(meter_rect, 2.0, egui::Color32::from_gray(30));
|
ui.painter().rect_filled(meter_rect, 2.0, shared.theme.bg_color(&["#timeline", ".vu-meter-bg"], ui.ctx(), egui::Color32::from_gray(30)));
|
||||||
|
|
||||||
let levels = [shared.output_level.0.min(1.0), shared.output_level.1.min(1.0)];
|
let levels = [shared.output_level.0.min(1.0), shared.output_level.1.min(1.0)];
|
||||||
for (i, &level) in levels.iter().enumerate() {
|
for (i, &level) in levels.iter().enumerate() {
|
||||||
|
|
@ -4266,11 +4218,11 @@ impl PaneRenderer for TimelinePane {
|
||||||
if level > 0.001 {
|
if level > 0.001 {
|
||||||
let filled_width = meter_rect.width() * level;
|
let filled_width = meter_rect.width() * level;
|
||||||
let color = if level > 0.9 {
|
let color = if level > 0.9 {
|
||||||
egui::Color32::from_rgb(220, 50, 50)
|
shared.theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50))
|
||||||
} else if level > 0.7 {
|
} else if level > 0.7 {
|
||||||
egui::Color32::from_rgb(220, 200, 50)
|
shared.theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(50, 200, 80)
|
shared.theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80))
|
||||||
};
|
};
|
||||||
let filled_rect = egui::Rect::from_min_size(
|
let filled_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(meter_rect.min.x, bar_y),
|
egui::pos2(meter_rect.min.x, bar_y),
|
||||||
|
|
@ -4413,7 +4365,7 @@ impl PaneRenderer for TimelinePane {
|
||||||
|
|
||||||
// Render spacer above layer headers (same height as ruler)
|
// Render spacer above layer headers (same height as ruler)
|
||||||
let spacer_style = shared.theme.style(".timeline-spacer", ui.ctx());
|
let spacer_style = shared.theme.style(".timeline-spacer", ui.ctx());
|
||||||
let spacer_bg = spacer_style.background_color.unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
let spacer_bg = spacer_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
||||||
ui.painter().rect_filled(
|
ui.painter().rect_filled(
|
||||||
header_ruler_spacer,
|
header_ruler_spacer,
|
||||||
0.0,
|
0.0,
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,9 @@ impl PaneRenderer for ToolbarPane {
|
||||||
|
|
||||||
// Button background
|
// Button background
|
||||||
let bg_color = if is_selected {
|
let bg_color = if is_selected {
|
||||||
egui::Color32::from_rgb(70, 100, 150) // Highlighted blue
|
shared.theme.bg_color(&["#toolbar", ".tool-button", ".selected"], ui.ctx(), egui::Color32::from_rgb(70, 100, 150))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgb(50, 50, 50)
|
shared.theme.bg_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_rgb(50, 50, 50))
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
ui.painter().rect_filled(button_rect, 4.0, bg_color);
|
||||||
|
|
||||||
|
|
@ -113,7 +113,7 @@ impl PaneRenderer for ToolbarPane {
|
||||||
];
|
];
|
||||||
ui.painter().add(egui::Shape::convex_polygon(
|
ui.painter().add(egui::Shape::convex_polygon(
|
||||||
tri.to_vec(),
|
tri.to_vec(),
|
||||||
egui::Color32::from_gray(200),
|
shared.theme.text_color(&["#toolbar", ".tool-button"], ui.ctx(), egui::Color32::from_gray(200)),
|
||||||
egui::Stroke::NONE,
|
egui::Stroke::NONE,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -159,7 +159,7 @@ impl PaneRenderer for ToolbarPane {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
button_rect,
|
button_rect,
|
||||||
4.0,
|
4.0,
|
||||||
egui::Stroke::new(2.0, egui::Color32::from_gray(180)),
|
egui::Stroke::new(2.0, shared.theme.border_color(&["#toolbar", ".tool-button", ".hover"], ui.ctx(), egui::Color32::from_gray(180))),
|
||||||
egui::StrokeKind::Middle,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -186,7 +186,7 @@ impl PaneRenderer for ToolbarPane {
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
button_rect,
|
button_rect,
|
||||||
4.0,
|
4.0,
|
||||||
egui::Stroke::new(2.0, egui::Color32::from_rgb(100, 150, 255)),
|
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,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -216,12 +216,13 @@ impl PaneRenderer for ToolbarPane {
|
||||||
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
|
// Raster layers label them "FG" / "BG"; vector layers label them "Stroke" / "Fill".
|
||||||
{
|
{
|
||||||
let stroke_label = if is_raster { "FG" } else { "Stroke" };
|
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(
|
ui.painter().text(
|
||||||
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
stroke_label,
|
stroke_label,
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
egui::Color32::from_gray(200),
|
label_color,
|
||||||
);
|
);
|
||||||
|
|
||||||
let stroke_button_rect = egui::Rect::from_min_size(
|
let stroke_button_rect = egui::Rect::from_min_size(
|
||||||
|
|
@ -246,12 +247,13 @@ impl PaneRenderer for ToolbarPane {
|
||||||
// Fill/BG color swatch
|
// Fill/BG color swatch
|
||||||
{
|
{
|
||||||
let fill_label = if is_raster { "BG" } else { "Fill" };
|
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(
|
ui.painter().text(
|
||||||
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
egui::pos2(color_x + fill_label_width / 2.0, y + color_button_size / 2.0),
|
||||||
egui::Align2::CENTER_CENTER,
|
egui::Align2::CENTER_CENTER,
|
||||||
fill_label,
|
fill_label,
|
||||||
egui::FontId::proportional(14.0),
|
egui::FontId::proportional(14.0),
|
||||||
egui::Color32::from_gray(200),
|
label_color,
|
||||||
);
|
);
|
||||||
|
|
||||||
let fill_button_rect = egui::Rect::from_min_size(
|
let fill_button_rect = egui::Rect::from_min_size(
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ impl VirtualPianoPane {
|
||||||
&self,
|
&self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
|
shared: &SharedPaneState,
|
||||||
visible_start: u8,
|
visible_start: u8,
|
||||||
visible_end: u8,
|
visible_end: u8,
|
||||||
white_key_width: f32,
|
white_key_width: f32,
|
||||||
|
|
@ -173,15 +174,16 @@ impl VirtualPianoPane {
|
||||||
egui::vec2(white_key_width - 1.0, white_key_height),
|
egui::vec2(white_key_width - 1.0, white_key_height),
|
||||||
);
|
);
|
||||||
let color = if self.pressed_notes.contains(¬e) {
|
let color = if self.pressed_notes.contains(¬e) {
|
||||||
egui::Color32::from_rgb(100, 150, 255)
|
shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::WHITE
|
shared.theme.bg_color(&[".piano-white-key"], ui.ctx(), egui::Color32::WHITE)
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
ui.painter().rect_filled(key_rect, 2.0, color);
|
||||||
|
let border = shared.theme.border_color(&[".piano-white-key"], ui.ctx(), egui::Color32::BLACK);
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
key_rect,
|
key_rect,
|
||||||
2.0,
|
2.0,
|
||||||
egui::Stroke::new(1.0, egui::Color32::BLACK),
|
egui::Stroke::new(1.0, border),
|
||||||
egui::StrokeKind::Middle,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
white_pos += 1.0;
|
white_pos += 1.0;
|
||||||
|
|
@ -204,9 +206,9 @@ impl VirtualPianoPane {
|
||||||
egui::vec2(black_key_width, black_key_height),
|
egui::vec2(black_key_width, black_key_height),
|
||||||
);
|
);
|
||||||
let color = if self.pressed_notes.contains(¬e) {
|
let color = if self.pressed_notes.contains(¬e) {
|
||||||
egui::Color32::from_rgb(50, 100, 200)
|
shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::BLACK
|
shared.theme.bg_color(&[".piano-black-key"], ui.ctx(), egui::Color32::BLACK)
|
||||||
};
|
};
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
ui.painter().rect_filled(key_rect, 2.0, color);
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +234,7 @@ impl VirtualPianoPane {
|
||||||
self.send_note_off(note, shared);
|
self.send_note_off(note, shared);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.render_keyboard_visual_only(ui, rect, visible_start, visible_end, white_key_width, offset_x, white_key_height, black_key_width, black_key_height);
|
self.render_keyboard_visual_only(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x, white_key_height, black_key_width, black_key_height);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,16 +305,17 @@ impl VirtualPianoPane {
|
||||||
let is_pressed = self.pressed_notes.contains(¬e) ||
|
let is_pressed = self.pressed_notes.contains(¬e) ||
|
||||||
(!black_key_interacted && pointer_over_key && pointer_down);
|
(!black_key_interacted && pointer_over_key && pointer_down);
|
||||||
let color = if is_pressed {
|
let color = if is_pressed {
|
||||||
egui::Color32::from_rgb(100, 150, 255) // Blue when pressed
|
shared.theme.bg_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(100, 150, 255))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::WHITE
|
shared.theme.bg_color(&[".piano-white-key"], ui.ctx(), egui::Color32::WHITE)
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
ui.painter().rect_filled(key_rect, 2.0, color);
|
||||||
|
let border = shared.theme.border_color(&[".piano-white-key"], ui.ctx(), egui::Color32::BLACK);
|
||||||
ui.painter().rect_stroke(
|
ui.painter().rect_stroke(
|
||||||
key_rect,
|
key_rect,
|
||||||
2.0,
|
2.0,
|
||||||
egui::Stroke::new(1.0, egui::Color32::BLACK),
|
egui::Stroke::new(1.0, border),
|
||||||
egui::StrokeKind::Middle,
|
egui::StrokeKind::Middle,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -386,9 +389,9 @@ impl VirtualPianoPane {
|
||||||
let is_pressed = self.pressed_notes.contains(¬e) ||
|
let is_pressed = self.pressed_notes.contains(¬e) ||
|
||||||
(pointer_over_key && pointer_down);
|
(pointer_over_key && pointer_down);
|
||||||
let color = if is_pressed {
|
let color = if is_pressed {
|
||||||
egui::Color32::from_rgb(50, 100, 200) // Darker blue when pressed
|
shared.theme.bg_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::from_rgb(50, 100, 200))
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::BLACK
|
shared.theme.bg_color(&[".piano-black-key"], ui.ctx(), egui::Color32::BLACK)
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
ui.painter().rect_filled(key_rect, 2.0, color);
|
||||||
|
|
@ -670,6 +673,7 @@ impl VirtualPianoPane {
|
||||||
&self,
|
&self,
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
rect: egui::Rect,
|
rect: egui::Rect,
|
||||||
|
shared: &SharedPaneState,
|
||||||
visible_start: u8,
|
visible_start: u8,
|
||||||
visible_end: u8,
|
visible_end: u8,
|
||||||
white_key_width: f32,
|
white_key_width: f32,
|
||||||
|
|
@ -707,9 +711,9 @@ impl VirtualPianoPane {
|
||||||
// Check if key is currently pressed
|
// Check if key is currently pressed
|
||||||
let is_pressed = self.pressed_notes.contains(¬e);
|
let is_pressed = self.pressed_notes.contains(¬e);
|
||||||
let color = if is_pressed {
|
let color = if is_pressed {
|
||||||
egui::Color32::BLACK
|
shared.theme.text_color(&[".piano-white-key", ".pressed"], ui.ctx(), egui::Color32::BLACK)
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_gray(51) // #333333
|
shared.theme.text_color(&[".piano-white-key"], ui.ctx(), egui::Color32::from_gray(51))
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
|
|
@ -748,9 +752,9 @@ impl VirtualPianoPane {
|
||||||
|
|
||||||
let is_pressed = self.pressed_notes.contains(¬e);
|
let is_pressed = self.pressed_notes.contains(¬e);
|
||||||
let color = if is_pressed {
|
let color = if is_pressed {
|
||||||
egui::Color32::WHITE
|
shared.theme.text_color(&[".piano-black-key", ".pressed"], ui.ctx(), egui::Color32::WHITE)
|
||||||
} else {
|
} else {
|
||||||
egui::Color32::from_rgba_premultiplied(255, 255, 255, 178) // rgba(255,255,255,0.7)
|
shared.theme.text_color(&[".piano-black-key"], ui.ctx(), egui::Color32::from_rgba_premultiplied(255, 255, 255, 178))
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
|
|
@ -766,7 +770,7 @@ impl VirtualPianoPane {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PaneRenderer for VirtualPianoPane {
|
impl PaneRenderer for VirtualPianoPane {
|
||||||
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| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Octave Shift:");
|
ui.label("Octave Shift:");
|
||||||
if ui.button("-").clicked() && self.octave_offset > -2 {
|
if ui.button("-").clicked() && self.octave_offset > -2 {
|
||||||
|
|
@ -795,9 +799,13 @@ impl PaneRenderer for VirtualPianoPane {
|
||||||
// Sustain pedal indicator
|
// Sustain pedal indicator
|
||||||
ui.label("Sustain:");
|
ui.label("Sustain:");
|
||||||
let sustain_text = if self.sustain_active {
|
let sustain_text = if self.sustain_active {
|
||||||
egui::RichText::new("ON").color(egui::Color32::from_rgb(100, 200, 100))
|
egui::RichText::new("ON").color(
|
||||||
|
shared.theme.text_color(&[".sustain-indicator", ".active"], ui.ctx(), egui::Color32::from_rgb(100, 200, 100))
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
egui::RichText::new("OFF").color(egui::Color32::GRAY)
|
egui::RichText::new("OFF").color(
|
||||||
|
shared.theme.text_color(&[".sustain-indicator"], ui.ctx(), egui::Color32::GRAY)
|
||||||
|
)
|
||||||
};
|
};
|
||||||
ui.label(sustain_text);
|
ui.label(sustain_text);
|
||||||
|
|
||||||
|
|
@ -863,7 +871,7 @@ impl PaneRenderer for VirtualPianoPane {
|
||||||
self.render_keyboard(ui, rect, shared);
|
self.render_keyboard(ui, rect, shared);
|
||||||
|
|
||||||
// Render keyboard labels on top
|
// Render keyboard labels on top
|
||||||
self.render_key_labels(ui, rect, visible_start, visible_end, white_key_width, offset_x);
|
self.render_key_labels(ui, rect, shared, visible_start, visible_end, white_key_width, offset_x);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,15 @@
|
||||||
///
|
///
|
||||||
/// Parses CSS rules from assets/styles.css at runtime
|
/// Parses CSS rules from assets/styles.css at runtime
|
||||||
/// and provides type-safe access to styles via selectors.
|
/// and provides type-safe access to styles via selectors.
|
||||||
|
/// Supports cascading specificity with a 3-tier model:
|
||||||
|
/// Tier 1: :root CSS variables (design tokens)
|
||||||
|
/// Tier 2: Class selectors (.label, .button)
|
||||||
|
/// Tier 3: Compound/contextual (#timeline .label, .layer-header.hover)
|
||||||
|
|
||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
|
use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet};
|
||||||
use lightningcss::traits::ToCss;
|
use lightningcss::traits::ToCss;
|
||||||
|
use std::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -35,29 +40,254 @@ impl ThemeMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Background type for CSS backgrounds
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Background {
|
||||||
|
Solid(egui::Color32),
|
||||||
|
LinearGradient {
|
||||||
|
angle_degrees: f32,
|
||||||
|
stops: Vec<(f32, egui::Color32)>, // (position 0.0-1.0, color)
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Style properties that can be applied to UI elements
|
/// Style properties that can be applied to UI elements
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Style {
|
pub struct Style {
|
||||||
pub background_color: Option<egui::Color32>,
|
pub background: Option<Background>,
|
||||||
pub border_color: Option<egui::Color32>,
|
pub border_color: Option<egui::Color32>,
|
||||||
|
pub border_width: Option<f32>,
|
||||||
|
pub border_radius: Option<f32>,
|
||||||
pub text_color: Option<egui::Color32>,
|
pub text_color: Option<egui::Color32>,
|
||||||
pub width: Option<f32>,
|
pub width: Option<f32>,
|
||||||
pub height: Option<f32>,
|
pub height: Option<f32>,
|
||||||
// Add more properties as needed
|
pub padding: Option<f32>,
|
||||||
|
pub margin: Option<f32>,
|
||||||
|
pub font_size: Option<f32>,
|
||||||
|
pub opacity: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
/// Convenience: get background color if the background is Solid
|
||||||
|
pub fn background_color(&self) -> Option<egui::Color32> {
|
||||||
|
match &self.background {
|
||||||
|
Some(Background::Solid(c)) => Some(*c),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge another style on top of this one (other's values take precedence)
|
||||||
|
pub fn merge_over(&mut self, other: &Style) {
|
||||||
|
if other.background.is_some() {
|
||||||
|
self.background = other.background.clone();
|
||||||
|
}
|
||||||
|
if other.border_color.is_some() {
|
||||||
|
self.border_color = other.border_color;
|
||||||
|
}
|
||||||
|
if other.border_width.is_some() {
|
||||||
|
self.border_width = other.border_width;
|
||||||
|
}
|
||||||
|
if other.border_radius.is_some() {
|
||||||
|
self.border_radius = other.border_radius;
|
||||||
|
}
|
||||||
|
if other.text_color.is_some() {
|
||||||
|
self.text_color = other.text_color;
|
||||||
|
}
|
||||||
|
if other.width.is_some() {
|
||||||
|
self.width = other.width;
|
||||||
|
}
|
||||||
|
if other.height.is_some() {
|
||||||
|
self.height = other.height;
|
||||||
|
}
|
||||||
|
if other.padding.is_some() {
|
||||||
|
self.padding = other.padding;
|
||||||
|
}
|
||||||
|
if other.margin.is_some() {
|
||||||
|
self.margin = other.margin;
|
||||||
|
}
|
||||||
|
if other.font_size.is_some() {
|
||||||
|
self.font_size = other.font_size;
|
||||||
|
}
|
||||||
|
if other.opacity.is_some() {
|
||||||
|
self.opacity = other.opacity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed CSS selector with specificity
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ParsedSelector {
|
||||||
|
/// The original selector string parts, e.g. ["#timeline", ".label"]
|
||||||
|
/// For compound selectors like ".layer-header.hover", this is [".layer-header.hover"]
|
||||||
|
parts: Vec<String>,
|
||||||
|
/// Specificity: (id_count, class_count, source_order)
|
||||||
|
specificity: (u32, u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParsedSelector {
|
||||||
|
fn parse(selector_str: &str, source_order: u32) -> Self {
|
||||||
|
let parts: Vec<String> = selector_str
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut id_count = 0u32;
|
||||||
|
let mut class_count = 0u32;
|
||||||
|
|
||||||
|
for part in &parts {
|
||||||
|
// Count IDs and classes within each part (compound selectors)
|
||||||
|
for segment in Self::split_compound(part) {
|
||||||
|
if segment.starts_with('#') {
|
||||||
|
id_count += 1;
|
||||||
|
} else if segment.starts_with('.') {
|
||||||
|
class_count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ParsedSelector {
|
||||||
|
parts,
|
||||||
|
specificity: (id_count, class_count, source_order),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a compound selector like ".layer-header.hover" into [".layer-header", ".hover"]
|
||||||
|
fn split_compound(s: &str) -> Vec<&str> {
|
||||||
|
let mut segments = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
for i in 1..bytes.len() {
|
||||||
|
if bytes[i] == b'.' || bytes[i] == b'#' {
|
||||||
|
segments.push(&s[start..i]);
|
||||||
|
start = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
segments.push(&s[start..]);
|
||||||
|
segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if this selector matches a given context stack.
|
||||||
|
/// Context stack is outermost to innermost, e.g. ["#timeline", ".layer-header", ".selected"]
|
||||||
|
///
|
||||||
|
/// Key rules:
|
||||||
|
/// - The LAST selector part must match the target element. The target is
|
||||||
|
/// identified by the trailing context entries. This prevents
|
||||||
|
/// `#timeline { background }` from bleeding into child elements.
|
||||||
|
/// - For compound selectors like `.piano-white-key.pressed`, the segments
|
||||||
|
/// can be spread across multiple trailing context entries.
|
||||||
|
/// - Ancestor parts (all but last) use descendant matching in order.
|
||||||
|
fn matches(&self, context: &[&str]) -> bool {
|
||||||
|
if self.parts.is_empty() || context.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last selector part must match among the trailing context entries.
|
||||||
|
// Collect all segments from ALL context entries, then check if the last
|
||||||
|
// selector part's segments are all present. But we also need to ensure
|
||||||
|
// the match is "anchored" to the tail — at least one segment of the last
|
||||||
|
// part must come from the very last context entry.
|
||||||
|
let last_part = &self.parts[self.parts.len() - 1];
|
||||||
|
let last_segments = Self::split_compound(last_part);
|
||||||
|
|
||||||
|
// Gather all class/id segments from all context entries
|
||||||
|
let all_context_segments: Vec<&str> = context.iter()
|
||||||
|
.flat_map(|e| Self::split_compound(e))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// All segments of the last selector part must be present somewhere in context
|
||||||
|
if !last_segments.iter().all(|seg| all_context_segments.contains(seg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At least one segment of the last selector part must appear in the
|
||||||
|
// LAST context entry (anchors the match to the target element)
|
||||||
|
let last_ctx_segments = Self::split_compound(context[context.len() - 1]);
|
||||||
|
if !last_segments.iter().any(|seg| last_ctx_segments.contains(seg)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single-part selectors, target matched and there are no ancestors.
|
||||||
|
if self.parts.len() == 1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For multi-part selectors (e.g., "#timeline .label"), match ancestor parts
|
||||||
|
// in order against context entries from the beginning.
|
||||||
|
// The last selector part's segments consume some context entries at the end;
|
||||||
|
// ancestor parts match against earlier entries.
|
||||||
|
//
|
||||||
|
// Find how far from the end the last part's segments extend.
|
||||||
|
let mut remaining_segments: Vec<&str> = last_segments.to_vec();
|
||||||
|
let mut target_start = context.len();
|
||||||
|
for i in (0..context.len()).rev() {
|
||||||
|
let ctx_segs = Self::split_compound(context[i]);
|
||||||
|
let before_len = remaining_segments.len();
|
||||||
|
remaining_segments.retain(|seg| !ctx_segs.contains(seg));
|
||||||
|
if remaining_segments.len() < before_len {
|
||||||
|
target_start = i;
|
||||||
|
}
|
||||||
|
if remaining_segments.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ancestor_context = &context[..target_start];
|
||||||
|
let ancestor_parts = &self.parts[..self.parts.len() - 1];
|
||||||
|
|
||||||
|
let mut ctx_idx = 0;
|
||||||
|
for part in ancestor_parts {
|
||||||
|
let part_segments = Self::split_compound(part);
|
||||||
|
let mut found = false;
|
||||||
|
while ctx_idx < ancestor_context.len() {
|
||||||
|
if Self::context_entry_contains_all(ancestor_context[ctx_idx], &part_segments) {
|
||||||
|
found = true;
|
||||||
|
ctx_idx += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ctx_idx += 1;
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a context entry contains all the given selector segments
|
||||||
|
fn context_entry_contains_all(context_entry: &str, selector_segments: &[&str]) -> bool {
|
||||||
|
let context_segments = Self::split_compound(context_entry);
|
||||||
|
selector_segments.iter().all(|seg| context_segments.contains(seg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A CSS rule: selector + style
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Rule {
|
||||||
|
selector: ParsedSelector,
|
||||||
|
style: Style,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Theme {
|
pub struct Theme {
|
||||||
light_variables: HashMap<String, String>,
|
light_variables: HashMap<String, String>,
|
||||||
dark_variables: HashMap<String, String>,
|
dark_variables: HashMap<String, String>,
|
||||||
light_styles: HashMap<String, Style>,
|
light_rules: Vec<Rule>,
|
||||||
dark_styles: HashMap<String, Style>,
|
dark_rules: Vec<Rule>,
|
||||||
current_mode: ThemeMode,
|
current_mode: ThemeMode,
|
||||||
|
/// Cache: (context_key, is_dark) -> Style
|
||||||
|
cache: RefCell<HashMap<(Vec<String>, bool), Style>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Theme {
|
impl Theme {
|
||||||
/// Load theme from CSS file
|
/// Load theme from CSS string
|
||||||
pub fn from_css(css: &str) -> Result<Self, String> {
|
pub fn from_css(css: &str) -> Result<Self, String> {
|
||||||
|
Self::parse_css(css, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse CSS with a source order offset (for merging multiple stylesheets)
|
||||||
|
fn parse_css(css: &str, source_order_offset: u32) -> Result<Self, String> {
|
||||||
let stylesheet = StyleSheet::parse(
|
let stylesheet = StyleSheet::parse(
|
||||||
css,
|
css,
|
||||||
ParserOptions::default(),
|
ParserOptions::default(),
|
||||||
|
|
@ -65,8 +295,9 @@ impl Theme {
|
||||||
|
|
||||||
let mut light_variables = HashMap::new();
|
let mut light_variables = HashMap::new();
|
||||||
let mut dark_variables = HashMap::new();
|
let mut dark_variables = HashMap::new();
|
||||||
let mut light_styles = HashMap::new();
|
let mut light_rules = Vec::new();
|
||||||
let mut dark_styles = HashMap::new();
|
let mut dark_rules = Vec::new();
|
||||||
|
let mut source_order = source_order_offset;
|
||||||
|
|
||||||
// First pass: Extract CSS custom properties from :root
|
// First pass: Extract CSS custom properties from :root
|
||||||
for rule in &stylesheet.rules.0 {
|
for rule in &stylesheet.rules.0 {
|
||||||
|
|
@ -76,7 +307,6 @@ impl Theme {
|
||||||
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
|
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
// Check if this is :root
|
|
||||||
if selectors.iter().any(|s| s.contains(":root")) {
|
if selectors.iter().any(|s| s.contains(":root")) {
|
||||||
extract_css_variables(&style_rule.declarations, &mut light_variables)?;
|
extract_css_variables(&style_rule.declarations, &mut light_variables)?;
|
||||||
}
|
}
|
||||||
|
|
@ -104,7 +334,6 @@ impl Theme {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second pass: Parse style rules and resolve var() references
|
// Second pass: Parse style rules and resolve var() references
|
||||||
// We need to parse selectors TWICE - once with light variables, once with dark variables
|
|
||||||
for rule in &stylesheet.rules.0 {
|
for rule in &stylesheet.rules.0 {
|
||||||
match rule {
|
match rule {
|
||||||
lightningcss::rules::CssRule::Style(style_rule) => {
|
lightningcss::rules::CssRule::Style(style_rule) => {
|
||||||
|
|
@ -114,17 +343,25 @@ impl Theme {
|
||||||
|
|
||||||
for selector in selectors {
|
for selector in selectors {
|
||||||
let selector = selector.trim();
|
let selector = selector.trim();
|
||||||
// Only process class and ID selectors
|
|
||||||
if selector.starts_with('.') || selector.starts_with('#') {
|
if selector.starts_with('.') || selector.starts_with('#') {
|
||||||
|
let parsed = ParsedSelector::parse(selector, source_order);
|
||||||
|
source_order += 1;
|
||||||
|
|
||||||
// Parse with light variables
|
// Parse with light variables
|
||||||
let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?;
|
let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?;
|
||||||
light_styles.insert(selector.to_string(), light_style);
|
light_rules.push(Rule {
|
||||||
|
selector: parsed.clone(),
|
||||||
|
style: light_style,
|
||||||
|
});
|
||||||
|
|
||||||
// Also parse with dark variables (merge dark over light)
|
// Parse with dark variables (merged over light)
|
||||||
let mut dark_vars = light_variables.clone();
|
let mut dark_vars = light_variables.clone();
|
||||||
dark_vars.extend(dark_variables.clone());
|
dark_vars.extend(dark_variables.clone());
|
||||||
let dark_style = parse_style_properties(&style_rule.declarations, &dark_vars)?;
|
let dark_style = parse_style_properties(&style_rule.declarations, &dark_vars)?;
|
||||||
dark_styles.insert(selector.to_string(), dark_style);
|
dark_rules.push(Rule {
|
||||||
|
selector: parsed,
|
||||||
|
style: dark_style,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -132,29 +369,26 @@ impl Theme {
|
||||||
let media_str = media_rule.query.to_css_string(PrinterOptions::default())
|
let media_str = media_rule.query.to_css_string(PrinterOptions::default())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
eprintln!("🔍 Found media query: {}", media_str);
|
|
||||||
eprintln!(" Contains {} rules", media_rule.rules.0.len());
|
|
||||||
|
|
||||||
if media_str.contains("prefers-color-scheme") && media_str.contains("dark") {
|
if media_str.contains("prefers-color-scheme") && media_str.contains("dark") {
|
||||||
eprintln!(" ✓ This is a dark mode media query!");
|
for inner_rule in &media_rule.rules.0 {
|
||||||
for (i, inner_rule) in media_rule.rules.0.iter().enumerate() {
|
|
||||||
eprintln!(" Rule {}: {:?}", i, std::mem::discriminant(inner_rule));
|
|
||||||
if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule {
|
if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule {
|
||||||
let selectors = style_rule.selectors.0.iter()
|
let selectors = style_rule.selectors.0.iter()
|
||||||
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
|
.filter_map(|s| s.to_css_string(PrinterOptions::default()).ok())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
eprintln!(" Found selectors: {:?}", selectors);
|
|
||||||
|
|
||||||
for selector in selectors {
|
for selector in selectors {
|
||||||
let selector = selector.trim();
|
let selector = selector.trim();
|
||||||
if selector.starts_with('.') || selector.starts_with('#') {
|
if selector.starts_with('.') || selector.starts_with('#') {
|
||||||
// Merge dark and light variables (dark overrides light)
|
let parsed = ParsedSelector::parse(selector, source_order);
|
||||||
|
source_order += 1;
|
||||||
|
|
||||||
let mut vars = light_variables.clone();
|
let mut vars = light_variables.clone();
|
||||||
vars.extend(dark_variables.clone());
|
vars.extend(dark_variables.clone());
|
||||||
let style = parse_style_properties(&style_rule.declarations, &vars)?;
|
let style = parse_style_properties(&style_rule.declarations, &vars)?;
|
||||||
dark_styles.insert(selector.to_string(), style);
|
dark_rules.push(Rule {
|
||||||
eprintln!(" Added dark style for: {}", selector);
|
selector: parsed,
|
||||||
|
style,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,21 +402,52 @@ impl Theme {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
light_variables,
|
light_variables,
|
||||||
dark_variables,
|
dark_variables,
|
||||||
light_styles,
|
light_rules,
|
||||||
dark_styles,
|
dark_rules,
|
||||||
current_mode: ThemeMode::System,
|
current_mode: ThemeMode::System,
|
||||||
|
cache: RefCell::new(HashMap::new()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load theme from embedded CSS file
|
/// Load theme from embedded CSS file, optionally merging user stylesheet
|
||||||
pub fn load_default() -> Result<Self, String> {
|
pub fn load_default() -> Result<Self, String> {
|
||||||
let css = include_str!("../assets/styles.css");
|
let css = include_str!("../assets/styles.css");
|
||||||
Self::from_css(css)
|
let mut theme = Self::from_css(css)?;
|
||||||
|
|
||||||
|
// Try to load user stylesheet from ~/.config/lightningbeam/theme.css
|
||||||
|
if let Some(user_css_path) = directories::BaseDirs::new()
|
||||||
|
.map(|d| d.config_dir().join("lightningbeam").join("theme.css"))
|
||||||
|
{
|
||||||
|
if user_css_path.exists() {
|
||||||
|
if let Ok(user_css) = std::fs::read_to_string(&user_css_path) {
|
||||||
|
// Parse user CSS with higher source order so it overrides defaults
|
||||||
|
let user_offset = (theme.light_rules.len() + theme.dark_rules.len()) as u32;
|
||||||
|
match Self::parse_css(&user_css, user_offset) {
|
||||||
|
Ok(user_theme) => {
|
||||||
|
// Merge user variables (override defaults)
|
||||||
|
theme.light_variables.extend(user_theme.light_variables);
|
||||||
|
theme.dark_variables.extend(user_theme.dark_variables);
|
||||||
|
// Append user rules (higher source order = higher priority at same specificity)
|
||||||
|
theme.light_rules.extend(user_theme.light_rules);
|
||||||
|
theme.dark_rules.extend(user_theme.dark_rules);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Warning: Failed to parse user theme.css: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the current theme mode
|
/// Set the current theme mode
|
||||||
pub fn set_mode(&mut self, mode: ThemeMode) {
|
pub fn set_mode(&mut self, mode: ThemeMode) {
|
||||||
self.current_mode = mode;
|
if self.current_mode != mode {
|
||||||
|
self.current_mode = mode;
|
||||||
|
self.cache.borrow_mut().clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current theme mode
|
/// Get the current theme mode
|
||||||
|
|
@ -190,38 +455,118 @@ impl Theme {
|
||||||
self.current_mode
|
self.current_mode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get style for a selector (e.g., ".panel" or "#timeline-header")
|
/// Invalidate the cache (call on stylesheet reload or mode change)
|
||||||
pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style {
|
pub fn invalidate_cache(&self) {
|
||||||
let is_dark = match self.current_mode {
|
self.cache.borrow_mut().clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determine if dark mode is active
|
||||||
|
fn is_dark(&self, ctx: &egui::Context) -> bool {
|
||||||
|
match self.current_mode {
|
||||||
ThemeMode::Light => false,
|
ThemeMode::Light => false,
|
||||||
ThemeMode::Dark => true,
|
ThemeMode::Dark => true,
|
||||||
ThemeMode::System => ctx.style().visuals.dark_mode,
|
ThemeMode::System => ctx.style().visuals.dark_mode,
|
||||||
};
|
|
||||||
|
|
||||||
if is_dark {
|
|
||||||
// Try dark style first, fall back to light style
|
|
||||||
self.dark_styles.get(selector).cloned()
|
|
||||||
.or_else(|| self.light_styles.get(selector).cloned())
|
|
||||||
.unwrap_or_default()
|
|
||||||
} else {
|
|
||||||
self.light_styles.get(selector).cloned().unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the number of loaded selectors
|
/// Cascading resolve — context is outermost to innermost
|
||||||
pub fn len(&self) -> usize {
|
/// e.g., &["#timeline", ".layer-header", ".selected"]
|
||||||
self.light_styles.len()
|
pub fn resolve(&self, context: &[&str], ctx: &egui::Context) -> Style {
|
||||||
|
let is_dark = self.is_dark(ctx);
|
||||||
|
let cache_key = (context.iter().map(|s| s.to_string()).collect::<Vec<_>>(), is_dark);
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if let Some(cached) = self.cache.borrow().get(&cache_key) {
|
||||||
|
return cached.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rules = if is_dark { &self.dark_rules } else { &self.light_rules };
|
||||||
|
|
||||||
|
// Collect matching rules and sort by specificity
|
||||||
|
let mut matching: Vec<&Rule> = rules
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.selector.matches(context))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort by specificity: (ids, classes, source_order) — ascending so later = higher priority
|
||||||
|
matching.sort_by_key(|r| r.selector.specificity);
|
||||||
|
|
||||||
|
// Merge in specificity order (lower specificity first, higher overrides)
|
||||||
|
let mut result = Style::default();
|
||||||
|
for rule in &matching {
|
||||||
|
result.merge_over(&rule.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
self.cache.borrow_mut().insert(cache_key, result.clone());
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if theme has no styles
|
/// Convenience: resolve and extract background color with fallback
|
||||||
#[allow(dead_code)] // Used in tests
|
pub fn bg_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
|
||||||
|
self.resolve(context, ctx).background_color().unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: resolve and extract text color with fallback
|
||||||
|
pub fn text_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
|
||||||
|
self.resolve(context, ctx).text_color.unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: resolve and extract border color with fallback
|
||||||
|
pub fn border_color(&self, context: &[&str], ctx: &egui::Context, fallback: egui::Color32) -> egui::Color32 {
|
||||||
|
self.resolve(context, ctx).border_color.unwrap_or(fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: resolve and extract a dimension with fallback
|
||||||
|
pub fn dimension(&self, context: &[&str], ctx: &egui::Context, property: &str, fallback: f32) -> f32 {
|
||||||
|
let style = self.resolve(context, ctx);
|
||||||
|
match property {
|
||||||
|
"width" => style.width.unwrap_or(fallback),
|
||||||
|
"height" => style.height.unwrap_or(fallback),
|
||||||
|
"padding" => style.padding.unwrap_or(fallback),
|
||||||
|
"margin" => style.margin.unwrap_or(fallback),
|
||||||
|
"font-size" => style.font_size.unwrap_or(fallback),
|
||||||
|
"border-width" => style.border_width.unwrap_or(fallback),
|
||||||
|
"border-radius" => style.border_radius.unwrap_or(fallback),
|
||||||
|
"opacity" => style.opacity.unwrap_or(fallback),
|
||||||
|
_ => fallback,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint background for a region (handles solid/gradient/image)
|
||||||
|
pub fn paint_bg(
|
||||||
|
&self,
|
||||||
|
context: &[&str],
|
||||||
|
ctx: &egui::Context,
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: egui::Rect,
|
||||||
|
rounding: f32,
|
||||||
|
) {
|
||||||
|
let style = self.resolve(context, ctx);
|
||||||
|
if let Some(bg) = &style.background {
|
||||||
|
crate::theme_render::paint_background(painter, rect, bg, rounding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get style for a single selector (backward-compat wrapper)
|
||||||
|
pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style {
|
||||||
|
self.resolve(&[selector], ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the number of loaded rules
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.light_rules.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if theme has no rules
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.light_styles.is_empty()
|
self.light_rules.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Debug: print loaded theme info
|
/// Debug: print loaded theme info
|
||||||
pub fn debug_print(&self) {
|
pub fn debug_print(&self) {
|
||||||
println!("📊 Theme Debug Info:");
|
println!("Theme Debug Info:");
|
||||||
println!(" Light variables: {}", self.light_variables.len());
|
println!(" Light variables: {}", self.light_variables.len());
|
||||||
for (k, v) in self.light_variables.iter().take(5) {
|
for (k, v) in self.light_variables.iter().take(5) {
|
||||||
println!(" --{}: {}", k, v);
|
println!(" --{}: {}", k, v);
|
||||||
|
|
@ -230,13 +575,13 @@ impl Theme {
|
||||||
for (k, v) in self.dark_variables.iter().take(5) {
|
for (k, v) in self.dark_variables.iter().take(5) {
|
||||||
println!(" --{}: {}", k, v);
|
println!(" --{}: {}", k, v);
|
||||||
}
|
}
|
||||||
println!(" Light styles: {}", self.light_styles.len());
|
println!(" Light rules: {}", self.light_rules.len());
|
||||||
for k in self.light_styles.keys().take(5) {
|
for rule in self.light_rules.iter().take(5) {
|
||||||
println!(" {}", k);
|
println!(" {}", rule.selector.parts.join(" "));
|
||||||
}
|
}
|
||||||
println!(" Dark styles: {}", self.dark_styles.len());
|
println!(" Dark rules: {}", self.dark_rules.len());
|
||||||
for k in self.dark_styles.keys().take(5) {
|
for rule in self.dark_rules.iter().take(5) {
|
||||||
println!(" {}", k);
|
println!(" {}", rule.selector.parts.join(" "));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -269,22 +614,34 @@ fn parse_style_properties(
|
||||||
let mut style = Style::default();
|
let mut style = Style::default();
|
||||||
|
|
||||||
for property in &declarations.declarations {
|
for property in &declarations.declarations {
|
||||||
// Convert property to CSS string and parse
|
|
||||||
let prop_str = property.to_css_string(false, PrinterOptions::default())
|
let prop_str = property.to_css_string(false, PrinterOptions::default())
|
||||||
.map_err(|e| format!("Failed to serialize property: {:?}", e))?;
|
.map_err(|e| format!("Failed to serialize property: {:?}", e))?;
|
||||||
|
|
||||||
// Parse property name and value
|
|
||||||
if let Some((name, value)) = prop_str.split_once(':') {
|
if let Some((name, value)) = prop_str.split_once(':') {
|
||||||
let name = name.trim();
|
let name = name.trim();
|
||||||
let value = value.trim().trim_end_matches(';');
|
let value = value.trim().trim_end_matches(';');
|
||||||
|
|
||||||
match name {
|
match name {
|
||||||
"background-color" => {
|
"background-color" => {
|
||||||
style.background_color = parse_color_value(value, variables);
|
if let Some(color) = parse_color_value(value, variables) {
|
||||||
|
style.background = Some(Background::Solid(color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"background" => {
|
||||||
|
// Try gradient first, then solid color
|
||||||
|
if let Some(bg) = parse_background_value(value, variables) {
|
||||||
|
style.background = Some(bg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"border-color" | "border-top-color" => {
|
"border-color" | "border-top-color" => {
|
||||||
style.border_color = parse_color_value(value, variables);
|
style.border_color = parse_color_value(value, variables);
|
||||||
}
|
}
|
||||||
|
"border-width" => {
|
||||||
|
style.border_width = parse_dimension_value(value, variables);
|
||||||
|
}
|
||||||
|
"border-radius" => {
|
||||||
|
style.border_radius = parse_dimension_value(value, variables);
|
||||||
|
}
|
||||||
"color" => {
|
"color" => {
|
||||||
style.text_color = parse_color_value(value, variables);
|
style.text_color = parse_color_value(value, variables);
|
||||||
}
|
}
|
||||||
|
|
@ -294,6 +651,20 @@ fn parse_style_properties(
|
||||||
"height" => {
|
"height" => {
|
||||||
style.height = parse_dimension_value(value, variables);
|
style.height = parse_dimension_value(value, variables);
|
||||||
}
|
}
|
||||||
|
"padding" => {
|
||||||
|
style.padding = parse_dimension_value(value, variables);
|
||||||
|
}
|
||||||
|
"margin" => {
|
||||||
|
style.margin = parse_dimension_value(value, variables);
|
||||||
|
}
|
||||||
|
"font-size" => {
|
||||||
|
style.font_size = parse_dimension_value(value, variables);
|
||||||
|
}
|
||||||
|
"opacity" => {
|
||||||
|
if let Ok(v) = value.trim().parse::<f32>() {
|
||||||
|
style.opacity = Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -302,17 +673,122 @@ fn parse_style_properties(
|
||||||
Ok(style)
|
Ok(style)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a CSS background value (gradient, url, or solid color)
|
||||||
|
fn parse_background_value(value: &str, variables: &HashMap<String, String>) -> Option<Background> {
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
// Check for linear-gradient()
|
||||||
|
if value.starts_with("linear-gradient(") {
|
||||||
|
return parse_linear_gradient(value, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for url()
|
||||||
|
if value.starts_with("url(") {
|
||||||
|
let inner = value.strip_prefix("url(")?.strip_suffix(')')?;
|
||||||
|
let url = inner.trim().trim_matches('"').trim_matches('\'').to_string();
|
||||||
|
return Some(Background::Image { url });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to solid color
|
||||||
|
parse_color_value(value, variables).map(Background::Solid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a linear-gradient() CSS value
|
||||||
|
fn parse_linear_gradient(value: &str, variables: &HashMap<String, String>) -> Option<Background> {
|
||||||
|
// linear-gradient(180deg, #333, #222)
|
||||||
|
// linear-gradient(180deg, #333 0%, #222 100%)
|
||||||
|
let inner = value.strip_prefix("linear-gradient(")?.strip_suffix(')')?;
|
||||||
|
|
||||||
|
let mut parts: Vec<&str> = Vec::new();
|
||||||
|
let mut depth = 0;
|
||||||
|
let mut start = 0;
|
||||||
|
for (i, c) in inner.char_indices() {
|
||||||
|
match c {
|
||||||
|
'(' => depth += 1,
|
||||||
|
')' => depth -= 1,
|
||||||
|
',' if depth == 0 => {
|
||||||
|
parts.push(&inner[start..i]);
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(&inner[start..]);
|
||||||
|
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut angle_degrees = 180.0f32; // default: top to bottom
|
||||||
|
let mut color_start_idx = 0;
|
||||||
|
|
||||||
|
// Check if first part is an angle
|
||||||
|
let first = parts[0].trim();
|
||||||
|
if first.ends_with("deg") {
|
||||||
|
if let Ok(angle) = first.strip_suffix("deg").unwrap().trim().parse::<f32>() {
|
||||||
|
angle_degrees = angle;
|
||||||
|
color_start_idx = 1;
|
||||||
|
}
|
||||||
|
} else if first == "to bottom" {
|
||||||
|
angle_degrees = 180.0;
|
||||||
|
color_start_idx = 1;
|
||||||
|
} else if first == "to top" {
|
||||||
|
angle_degrees = 0.0;
|
||||||
|
color_start_idx = 1;
|
||||||
|
} else if first == "to right" {
|
||||||
|
angle_degrees = 90.0;
|
||||||
|
color_start_idx = 1;
|
||||||
|
} else if first == "to left" {
|
||||||
|
angle_degrees = 270.0;
|
||||||
|
color_start_idx = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let color_parts = &parts[color_start_idx..];
|
||||||
|
if color_parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stops = Vec::new();
|
||||||
|
let count = color_parts.len();
|
||||||
|
for (i, part) in color_parts.iter().enumerate() {
|
||||||
|
let part = part.trim();
|
||||||
|
// Check for "color position%" pattern
|
||||||
|
let (color_str, position) = if let Some(pct_idx) = part.rfind('%') {
|
||||||
|
// Find the space before the percentage
|
||||||
|
let before_pct = &part[..pct_idx];
|
||||||
|
if let Some(space_idx) = before_pct.rfind(' ') {
|
||||||
|
let color_str = &part[..space_idx];
|
||||||
|
let pct_str = &part[space_idx + 1..pct_idx];
|
||||||
|
let pct = pct_str.trim().parse::<f32>().unwrap_or(0.0) / 100.0;
|
||||||
|
(color_str.trim(), pct)
|
||||||
|
} else {
|
||||||
|
(part, i as f32 / (count - 1).max(1) as f32)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(part, i as f32 / (count - 1).max(1) as f32)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(color) = parse_color_value(color_str, variables) {
|
||||||
|
stops.push((position, color));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if stops.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Background::LinearGradient { angle_degrees, stops })
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a CSS color value (hex or var())
|
/// Parse a CSS color value (hex or var())
|
||||||
fn parse_color_value(value: &str, variables: &HashMap<String, String>) -> Option<egui::Color32> {
|
fn parse_color_value(value: &str, variables: &HashMap<String, String>) -> Option<egui::Color32> {
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
|
|
||||||
// Check if it's a var() reference
|
|
||||||
if let Some(var_name) = parse_var_reference(value) {
|
if let Some(var_name) = parse_var_reference(value) {
|
||||||
let resolved = variables.get(&var_name)?;
|
let resolved = variables.get(&var_name)?;
|
||||||
return parse_hex_color(resolved);
|
return parse_hex_color(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as direct hex color
|
|
||||||
parse_hex_color(value)
|
parse_hex_color(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -320,13 +796,11 @@ fn parse_color_value(value: &str, variables: &HashMap<String, String>) -> Option
|
||||||
fn parse_dimension_value(value: &str, variables: &HashMap<String, String>) -> Option<f32> {
|
fn parse_dimension_value(value: &str, variables: &HashMap<String, String>) -> Option<f32> {
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
|
|
||||||
// Check if it's a var() reference
|
|
||||||
if let Some(var_name) = parse_var_reference(value) {
|
if let Some(var_name) = parse_var_reference(value) {
|
||||||
let resolved = variables.get(&var_name)?;
|
let resolved = variables.get(&var_name)?;
|
||||||
return parse_dimension_string(resolved);
|
return parse_dimension_string(resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as direct dimension
|
|
||||||
parse_dimension_string(value)
|
parse_dimension_string(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,4 +867,76 @@ mod tests {
|
||||||
let theme = Theme::load_default().expect("Failed to load default theme");
|
let theme = Theme::load_default().expect("Failed to load default theme");
|
||||||
assert!(!theme.is_empty(), "Theme should have styles loaded");
|
assert!(!theme.is_empty(), "Theme should have styles loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_selector_matching() {
|
||||||
|
let sel = ParsedSelector::parse("#timeline .layer-header", 0);
|
||||||
|
assert!(sel.matches(&["#timeline", ".layer-header"]));
|
||||||
|
assert!(sel.matches(&["#timeline", ".something", ".layer-header"]));
|
||||||
|
assert!(!sel.matches(&[".layer-header"]));
|
||||||
|
assert!(!sel.matches(&["#timeline"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_compound_selector() {
|
||||||
|
let sel = ParsedSelector::parse(".layer-header.hover", 0);
|
||||||
|
assert!(sel.matches(&[".layer-header.hover"]));
|
||||||
|
// Also matches if context has both classes separately at same level?
|
||||||
|
// No — compound requires the context entry itself to contain both
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_specificity_ordering() {
|
||||||
|
let s1 = ParsedSelector::parse(".button", 0);
|
||||||
|
let s2 = ParsedSelector::parse("#timeline .button", 1);
|
||||||
|
assert!(s1.specificity < s2.specificity);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cascade_resolve() {
|
||||||
|
let css = r#"
|
||||||
|
:root { --bg: #ff0000; }
|
||||||
|
.button { background-color: var(--bg); }
|
||||||
|
#timeline .button { background-color: #00ff00; }
|
||||||
|
"#;
|
||||||
|
let theme = Theme::from_css(css).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
|
||||||
|
// .button alone should get red
|
||||||
|
let s = theme.resolve(&[".button"], &ctx);
|
||||||
|
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(255, 0, 0)));
|
||||||
|
|
||||||
|
// #timeline .button should get green (higher specificity)
|
||||||
|
let s = theme.resolve(&["#timeline", ".button"], &ctx);
|
||||||
|
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(0, 255, 0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_parse_linear_gradient() {
|
||||||
|
let css = r#"
|
||||||
|
.panel { background: linear-gradient(180deg, #333333, #222222); }
|
||||||
|
"#;
|
||||||
|
let theme = Theme::from_css(css).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let s = theme.resolve(&[".panel"], &ctx);
|
||||||
|
match &s.background {
|
||||||
|
Some(Background::LinearGradient { angle_degrees, stops }) => {
|
||||||
|
assert_eq!(*angle_degrees, 180.0);
|
||||||
|
assert_eq!(stops.len(), 2);
|
||||||
|
}
|
||||||
|
other => panic!("Expected LinearGradient, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_style_backward_compat() {
|
||||||
|
let css = r#"
|
||||||
|
:root { --bg: #aabbcc; }
|
||||||
|
.panel { background-color: var(--bg); }
|
||||||
|
"#;
|
||||||
|
let theme = Theme::from_css(css).unwrap();
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let s = theme.style(".panel", &ctx);
|
||||||
|
assert_eq!(s.background_color(), Some(egui::Color32::from_rgb(0xaa, 0xbb, 0xcc)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
/// Theme rendering helpers for painting CSS backgrounds
|
||||||
|
///
|
||||||
|
/// Handles solid colors, linear gradients (via egui Mesh), and image backgrounds.
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::theme::Background;
|
||||||
|
|
||||||
|
/// Paint a background into the given rect
|
||||||
|
pub fn paint_background(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: egui::Rect,
|
||||||
|
background: &Background,
|
||||||
|
rounding: f32,
|
||||||
|
) {
|
||||||
|
match background {
|
||||||
|
Background::Solid(color) => {
|
||||||
|
painter.rect_filled(rect, rounding, *color);
|
||||||
|
}
|
||||||
|
Background::LinearGradient { angle_degrees, stops } => {
|
||||||
|
paint_linear_gradient(painter, rect, *angle_degrees, stops, rounding);
|
||||||
|
}
|
||||||
|
Background::Image { .. } => {
|
||||||
|
// Image backgrounds require a TextureHandle loaded externally.
|
||||||
|
// For now, fall back to transparent (no-op).
|
||||||
|
// TODO: image cache integration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint a linear gradient using an egui Mesh with colored vertices
|
||||||
|
///
|
||||||
|
/// Supports arbitrary angles. The gradient direction follows CSS conventions:
|
||||||
|
/// - 0deg = bottom to top
|
||||||
|
/// - 90deg = left to right
|
||||||
|
/// - 180deg = top to bottom (default)
|
||||||
|
/// - 270deg = right to left
|
||||||
|
pub fn paint_linear_gradient(
|
||||||
|
painter: &egui::Painter,
|
||||||
|
rect: egui::Rect,
|
||||||
|
angle_degrees: f32,
|
||||||
|
stops: &[(f32, egui::Color32)],
|
||||||
|
rounding: f32,
|
||||||
|
) {
|
||||||
|
if stops.len() < 2 {
|
||||||
|
if let Some((_, color)) = stops.first() {
|
||||||
|
painter.rect_filled(rect, rounding, *color);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert CSS angle to a direction vector
|
||||||
|
// CSS: 0deg = to top, 90deg = to right, 180deg = to bottom
|
||||||
|
let angle_rad = (angle_degrees - 90.0).to_radians();
|
||||||
|
let dir = egui::vec2(angle_rad.cos(), angle_rad.sin());
|
||||||
|
|
||||||
|
// Project rect corners onto gradient direction to find start/end
|
||||||
|
let center = rect.center();
|
||||||
|
let half_size = rect.size() / 2.0;
|
||||||
|
|
||||||
|
// The gradient line length is the projection of the rect diagonal onto the direction
|
||||||
|
let gradient_half_len = (half_size.x * dir.x.abs()) + (half_size.y * dir.y.abs());
|
||||||
|
|
||||||
|
// For simple horizontal/vertical gradients with no rounding, use a mesh directly
|
||||||
|
if rounding <= 0.0 {
|
||||||
|
let mut mesh = egui::Mesh::default();
|
||||||
|
mesh.texture_id = egui::TextureId::default();
|
||||||
|
|
||||||
|
// For each consecutive pair of stops, add a quad
|
||||||
|
for i in 0..stops.len() - 1 {
|
||||||
|
let (t0, c0) = stops[i];
|
||||||
|
let (t1, c1) = stops[i + 1];
|
||||||
|
|
||||||
|
// Map t to positions along the gradient line
|
||||||
|
let p0_along = -gradient_half_len + t0 * 2.0 * gradient_half_len;
|
||||||
|
let p1_along = -gradient_half_len + t1 * 2.0 * gradient_half_len;
|
||||||
|
|
||||||
|
// Perpendicular direction for quad width
|
||||||
|
let perp = egui::vec2(-dir.y, dir.x);
|
||||||
|
let perp_extent = (half_size.x * perp.x.abs()) + (half_size.y * perp.y.abs());
|
||||||
|
|
||||||
|
let base0 = center + dir * p0_along;
|
||||||
|
let base1 = center + dir * p1_along;
|
||||||
|
|
||||||
|
let v0 = base0 - perp * perp_extent;
|
||||||
|
let v1 = base0 + perp * perp_extent;
|
||||||
|
let v2 = base1 + perp * perp_extent;
|
||||||
|
let v3 = base1 - perp * perp_extent;
|
||||||
|
|
||||||
|
let uv = egui::pos2(0.0, 0.0);
|
||||||
|
let idx = mesh.vertices.len() as u32;
|
||||||
|
mesh.vertices.push(egui::epaint::Vertex { pos: v0, uv, color: c0 });
|
||||||
|
mesh.vertices.push(egui::epaint::Vertex { pos: v1, uv, color: c0 });
|
||||||
|
mesh.vertices.push(egui::epaint::Vertex { pos: v2, uv, color: c1 });
|
||||||
|
mesh.vertices.push(egui::epaint::Vertex { pos: v3, uv, color: c1 });
|
||||||
|
|
||||||
|
mesh.indices.extend_from_slice(&[idx, idx + 1, idx + 2, idx, idx + 2, idx + 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
painter.add(egui::Shape::mesh(mesh));
|
||||||
|
} else {
|
||||||
|
// For rounded rects, paint without rounding for now.
|
||||||
|
// TODO: proper rounded gradient with tessellation or clip mask
|
||||||
|
paint_linear_gradient(painter, rect, angle_degrees, stops, 0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
type WChar = u16;
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
type WChar = u32;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod ffi {
|
mod ffi {
|
||||||
use super::WChar;
|
use std::os::raw::{c_char, c_float, c_int};
|
||||||
use std::os::raw::{c_float, c_int};
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
type PathChar = u16; // wchar_t on Windows
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
type PathChar = c_char; // char on Linux/macOS
|
||||||
|
|
||||||
#[repr(C)]
|
#[repr(C)]
|
||||||
pub struct NeuralModel {
|
pub struct NeuralModel {
|
||||||
|
|
@ -16,7 +15,7 @@ mod ffi {
|
||||||
}
|
}
|
||||||
|
|
||||||
unsafe extern "C" {
|
unsafe extern "C" {
|
||||||
pub fn CreateModelFromFile(model_path: *const WChar) -> *mut NeuralModel;
|
pub fn CreateModelFromFile(model_path: *const PathChar) -> *mut NeuralModel;
|
||||||
pub fn DeleteModel(model: *mut NeuralModel);
|
pub fn DeleteModel(model: *mut NeuralModel);
|
||||||
|
|
||||||
pub fn SetLSTMLoadMode(load_mode: c_int);
|
pub fn SetLSTMLoadMode(load_mode: c_int);
|
||||||
|
|
@ -58,24 +57,36 @@ pub struct NamModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NamModel {
|
impl NamModel {
|
||||||
|
/// Load a model from in-memory bytes by writing to a temp file first.
|
||||||
|
/// The NAM C API only supports file-based loading.
|
||||||
|
pub fn from_bytes(name: &str, data: &[u8]) -> Result<Self, NamError> {
|
||||||
|
let dir = std::env::temp_dir().join("lightningbeam-nam");
|
||||||
|
eprintln!("[NAM] from_bytes: name={}, data_len={}, temp_dir={}", name, data.len(), dir.display());
|
||||||
|
std::fs::create_dir_all(&dir)
|
||||||
|
.map_err(|e| NamError::ModelLoadFailed(format!("create_dir_all failed: {}", e)))?;
|
||||||
|
let file_path = dir.join(name);
|
||||||
|
std::fs::write(&file_path, data)
|
||||||
|
.map_err(|e| NamError::ModelLoadFailed(format!("write failed: {}", e)))?;
|
||||||
|
eprintln!("[NAM] Wrote {} bytes to {}", data.len(), file_path.display());
|
||||||
|
Self::from_file(&file_path)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_file(path: &Path) -> Result<Self, NamError> {
|
pub fn from_file(path: &Path) -> Result<Self, NamError> {
|
||||||
let wide: Vec<WChar> = {
|
let ptr = unsafe {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
use std::os::windows::ffi::OsStrExt;
|
use std::os::windows::ffi::OsStrExt;
|
||||||
path.as_os_str().encode_wide().chain(std::iter::once(0)).collect()
|
let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
|
||||||
|
ffi::CreateModelFromFile(wide.as_ptr())
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
path.to_string_lossy()
|
use std::ffi::CString;
|
||||||
.chars()
|
let c_path = CString::new(path.to_string_lossy().as_bytes())
|
||||||
.map(|c| c as WChar)
|
.map_err(|_| NamError::ModelLoadFailed(path.display().to_string()))?;
|
||||||
.chain(std::iter::once(0))
|
ffi::CreateModelFromFile(c_path.as_ptr())
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let ptr = unsafe { ffi::CreateModelFromFile(wide.as_ptr()) };
|
|
||||||
if ptr.is_null() {
|
if ptr.is_null() {
|
||||||
return Err(NamError::ModelLoadFailed(path.display().to_string()));
|
return Err(NamError::ModelLoadFailed(path.display().to_string()));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,74 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 100 100">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="80"
|
||||||
|
height="80"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
version="1.1"
|
||||||
|
id="svg3"
|
||||||
|
sodipodi:docname="focus-painting.svg"
|
||||||
|
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs3">
|
||||||
|
<inkscape:path-effect
|
||||||
|
effect="taper_stroke"
|
||||||
|
start_shape="center | center"
|
||||||
|
end_shape="center | center"
|
||||||
|
id="path-effect4"
|
||||||
|
is_visible="true"
|
||||||
|
lpeversion="1"
|
||||||
|
stroke_width="5.6125002"
|
||||||
|
subpath="1"
|
||||||
|
attach_start="0.10963619"
|
||||||
|
end_offset="2.2"
|
||||||
|
start_smoothing="0.5"
|
||||||
|
end_smoothing="0.5"
|
||||||
|
jointype="extrapolated"
|
||||||
|
miter_limit="100" />
|
||||||
|
</defs>
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview3"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#ffffff"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:zoom="14.7625"
|
||||||
|
inkscape:cx="39.96613"
|
||||||
|
inkscape:cy="40"
|
||||||
|
inkscape:window-width="2256"
|
||||||
|
inkscape:window-height="1432"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg3" />
|
||||||
<!-- Brush handle -->
|
<!-- Brush handle -->
|
||||||
<rect x="48" y="10" width="8" height="40" rx="3" fill="currentColor" opacity="0.7"/>
|
<rect
|
||||||
<!-- Brush ferrule -->
|
x="81.272194"
|
||||||
<rect x="46" y="46" width="12" height="6" rx="1" fill="currentColor"/>
|
y="-36.096752"
|
||||||
<!-- Brush tip -->
|
width="11.958412"
|
||||||
<ellipse cx="52" cy="58" rx="6" ry="10" fill="currentColor"/>
|
height="59.792061"
|
||||||
|
rx="4.4844046"
|
||||||
|
fill="currentColor"
|
||||||
|
id="rect1"
|
||||||
|
transform="rotate(30)" />
|
||||||
<!-- Paint strokes -->
|
<!-- Paint strokes -->
|
||||||
<path d="M15 72 Q25 62 38 68 Q50 74 60 65" stroke="currentColor" stroke-width="5"
|
<path
|
||||||
stroke-linecap="round" fill="none" opacity="0.9"/>
|
fill="currentColor"
|
||||||
<path d="M12 85 Q30 78 50 83 Q65 87 80 80" stroke="currentColor" stroke-width="4"
|
d="m 66.299746,58.763759 c -7.62066,-5.673158 -17.74792,1.05602 -19.475021,9.568162 -2.370872,11.685014 3.629614,18.355507 -8.806098,22.015242 -3.172111,0.933528 23.285353,2.624894 32.260797,-14.902625 6.333382,-12.368021 -3.979678,-16.680779 -3.979678,-16.680779 z"
|
||||||
stroke-linecap="round" fill="none" opacity="0.6"/>
|
id="path3"
|
||||||
|
sodipodi:nodetypes="csssc" />
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="nonzero"
|
||||||
|
d="m 33.813416,93.341948 c 3.786453,0.273778 7.592172,-2.402067 7.592172,-2.402067 0,0 -3.590846,-2.935629 -7.18731,-3.195812 -5.228251,-0.378129 -9.762439,-0.934698 -13.051756,-1.560403 -2.192878,-0.417137 -3.867634,-0.900044 -4.607842,-1.225905 -0.246736,-0.108621 -0.38281,-0.201357 -0.355456,-0.180906 0.0022,0.0016 0.0054,0.0041 0.0097,0.0075 0.0039,0.0031 0.0086,0.0069 0.0142,0.01158 0.005,0.0042 0.01078,0.0091 0.01723,0.01482 0.0058,0.0051 0.01217,0.01087 0.01911,0.0173 0.0062,0.0058 0.01294,0.01214 0.02008,0.01909 0.06432,0.06258 0.169505,0.174207 0.284048,0.373635 0.916341,1.595426 -0.196372,2.547062 -0.426706,2.713677 -0.0128,0.0093 -0.01712,0.01167 -0.01332,0.0093 0.0042,-0.0026 0.01845,-0.01109 0.04197,-0.02353 0.02614,-0.01382 0.06375,-0.03252 0.111905,-0.05439 0.05351,-0.0243 0.120022,-0.05252 0.198527,-0.08321 0.08723,-0.03409 0.189254,-0.07124 0.305056,-0.110227 2.565369,-0.894934 5.565859,-1.136655 8.73304,-1.508027 3.205326,-0.336708 6.457931,-1.031908 9.561136,-2.347605 2.159805,-0.821077 4.480948,-2.255287 6.157316,-4.677186 0,0 6e-6,-9e-6 6e-6,-9e-6 0.654807,-0.986306 1.053246,-2.205234 1.088284,-3.558681 -0.03505,-1.42684 -0.460926,-2.674323 -1.124653,-3.658412 -2.02781,-2.904054 -4.881022,-4.446693 -7.363423,-5.409479 -3.797402,-1.653345 -7.235314,-3.220765 -10.08975,-5.483149 -1.052382,-0.868697 -1.961075,-1.659161 -2.408247,-2.565594 0,0 -2e-6,-4e-6 -2e-6,-4e-6 -0.30318,-0.587823 -0.441836,-1.301051 -0.218785,-1.76016 0.251179,-0.929295 1.355962,-1.74296 2.512237,-2.571731 2.157881,-1.423245 4.733062,-2.387225 7.159739,-3.21808 8.413892,-2.856629 17.372351,-4.416313 22.339998,-5.248581 5.517396,-0.924372 8.68008,-1.381256 8.68008,-1.381256 0,0 -3.191898,0.107112 -8.784136,0.510369 -5.097074,0.36755 -14.091507,1.125272 -23.035558,3.433462 -2.560557,0.653766 -5.411841,1.514808 -8.102357,3.009424 -1.486784,0.76195 -3.118328,1.985189 -4.147427,3.99712 -0.687787,1.63464 -0.624481,3.422233 0.09692,4.935688 0,0 4e-6,8e-6 4e-6,8e-6 0.82151,1.695416 2.02396,3.00905 3.255783,4.042946 3.235014,2.809503 6.953183,4.795794 10.775008,6.614646 2.174575,1.159484 4.287272,2.221643 5.044095,3.772438 0.190302,0.3826 0.327622,0.723299 0.273143,0.918609 0.01659,0.181774 -0.128716,0.467185 -0.317084,0.774431 0,0 -2e-6,3e-6 -2e-6,3e-6 -0.594382,1.061879 -2.132563,1.75573 -3.860557,2.553569 -2.461171,1.001334 -5.204568,1.506386 -8.062238,1.796138 -3.112491,0.277459 -6.513856,0.583452 -9.953224,1.709918 -1.229365,0.413854 -3.462781,1.330519 -3.729057,3.579039 -0.288308,2.434558 1.908918,3.667813 3.031985,4.162219 3.497002,1.539481 11.237715,2.648688 19.516091,3.247415 z"
|
||||||
|
id="path4"
|
||||||
|
sodipodi:nodetypes="cssc"
|
||||||
|
inkscape:path-effect="#path-effect4"
|
||||||
|
inkscape:original-d="M 41.405588,90.939881 C 17.95089,90.093141 2.3853409,85.764617 25.232854,83.403895 38.982538,81.983208 45.797946,74.569678 32.853514,68.670617 16.874547,61.388652 3.3869602,50.211686 61.812023,44.284506" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 698 B After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,204 @@
|
||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "Electric Guitar",
|
||||||
|
"description": "Synthesized electric guitar with exponential pluck envelope through a tube amp sim",
|
||||||
|
"author": "Lightningbeam",
|
||||||
|
"version": 2,
|
||||||
|
"tags": ["guitar", "electric", "amp", "pluck"]
|
||||||
|
},
|
||||||
|
"midi_targets": [0],
|
||||||
|
"output_node": 4,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"node_type": "MidiInput",
|
||||||
|
"name": "MIDI In",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [100.0, 150.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"node_type": "VoiceAllocator",
|
||||||
|
"name": "Voice Allocator",
|
||||||
|
"parameters": {
|
||||||
|
"0": 6.0
|
||||||
|
},
|
||||||
|
"position": [400.0, 150.0],
|
||||||
|
"template_graph": {
|
||||||
|
"metadata": {
|
||||||
|
"name": "Voice Template",
|
||||||
|
"description": "Per-voice electric guitar synth patch with stacked oscillators and sub octave",
|
||||||
|
"author": "Lightningbeam",
|
||||||
|
"version": 3,
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"midi_targets": [0],
|
||||||
|
"output_node": 11,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"node_type": "TemplateInput",
|
||||||
|
"name": "Template Input",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [-200.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"node_type": "MidiToCV",
|
||||||
|
"name": "MIDI→CV",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [100.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"node_type": "Constant",
|
||||||
|
"name": "Octave (-1)",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0
|
||||||
|
},
|
||||||
|
"position": [100.0, 350.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"node_type": "Math",
|
||||||
|
"name": "Sub Oct V/Oct",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0
|
||||||
|
},
|
||||||
|
"position": [300.0, 300.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Fundamental (Triangle)",
|
||||||
|
"parameters": {
|
||||||
|
"0": 220.0,
|
||||||
|
"1": 0.4,
|
||||||
|
"2": 3.0
|
||||||
|
},
|
||||||
|
"position": [500.0, -200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Harmonics (Saw)",
|
||||||
|
"parameters": {
|
||||||
|
"0": 220.0,
|
||||||
|
"1": 0.18,
|
||||||
|
"2": 1.0
|
||||||
|
},
|
||||||
|
"position": [500.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"node_type": "Oscillator",
|
||||||
|
"name": "Sub (-1 oct, Sine)",
|
||||||
|
"parameters": {
|
||||||
|
"0": 110.0,
|
||||||
|
"1": 0.35,
|
||||||
|
"2": 0.0
|
||||||
|
},
|
||||||
|
"position": [500.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"node_type": "Mixer",
|
||||||
|
"name": "Osc Mix",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0,
|
||||||
|
"1": 1.0,
|
||||||
|
"2": 1.0
|
||||||
|
},
|
||||||
|
"position": [800.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"node_type": "ADSR",
|
||||||
|
"name": "Pluck Env",
|
||||||
|
"parameters": {
|
||||||
|
"0": 0.002,
|
||||||
|
"1": 4.7,
|
||||||
|
"2": 0.0,
|
||||||
|
"3": 0.3,
|
||||||
|
"4": 1.0
|
||||||
|
},
|
||||||
|
"position": [500.0, 450.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"node_type": "Gain",
|
||||||
|
"name": "VCA",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.0
|
||||||
|
},
|
||||||
|
"position": [1100.0, 0.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"node_type": "Gain",
|
||||||
|
"name": "Drive",
|
||||||
|
"parameters": {
|
||||||
|
"0": 1.4
|
||||||
|
},
|
||||||
|
"position": [1100.0, 200.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"node_type": "TemplateOutput",
|
||||||
|
"name": "Template Output",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [1400.0, 0.0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 4, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 5, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 3, "to_port": 0 },
|
||||||
|
{ "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 1 },
|
||||||
|
{ "from_node": 3, "from_port": 0, "to_node": 6, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 1, "to_node": 8, "to_port": 0 },
|
||||||
|
{ "from_node": 4, "from_port": 0, "to_node": 7, "to_port": 0 },
|
||||||
|
{ "from_node": 5, "from_port": 0, "to_node": 7, "to_port": 1 },
|
||||||
|
{ "from_node": 6, "from_port": 0, "to_node": 7, "to_port": 2 },
|
||||||
|
{ "from_node": 7, "from_port": 0, "to_node": 9, "to_port": 0 },
|
||||||
|
{ "from_node": 8, "from_port": 0, "to_node": 9, "to_port": 1 },
|
||||||
|
{ "from_node": 9, "from_port": 0, "to_node": 10, "to_port": 0 },
|
||||||
|
{ "from_node": 10, "from_port": 0, "to_node": 11, "to_port": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"node_type": "AmpSim",
|
||||||
|
"name": "Tube Amp",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [700.0, 150.0],
|
||||||
|
"nam_model_path": "bundled:BossSD1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"node_type": "Reverb",
|
||||||
|
"name": "Room",
|
||||||
|
"parameters": {
|
||||||
|
"0": 0.8,
|
||||||
|
"1": 0.4,
|
||||||
|
"2": 0.5
|
||||||
|
},
|
||||||
|
"position": [1000.0, 150.0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"node_type": "AudioOutput",
|
||||||
|
"name": "Out",
|
||||||
|
"parameters": {},
|
||||||
|
"position": [1300.0, 150.0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{ "from_node": 0, "from_port": 0, "to_node": 1, "to_port": 0 },
|
||||||
|
{ "from_node": 1, "from_port": 0, "to_node": 2, "to_port": 0 },
|
||||||
|
{ "from_node": 2, "from_port": 0, "to_node": 3, "to_port": 0 },
|
||||||
|
{ "from_node": 3, "from_port": 0, "to_node": 4, "to_port": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue