From b693a6286b7a570fed5e91e5a7617348fa89387f Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 01:49:59 +0200 Subject: [PATCH 1/3] Add Character fill and stroke eyedroppers --- crates/ui-egui/src/panels/effect_controls.rs | 46 +++++++++- crates/ui-egui/src/panels/text_panels.rs | 17 +++- crates/ui-egui/src/state.rs | 13 +++ .../ui-egui/tests/ui_character_eyedropper.rs | 85 +++++++++++++++++++ 4 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/tests/ui_character_eyedropper.rs diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 501adf89..4f449e8c 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -194,6 +194,7 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p app.ui.fx_pick = None; } else { app.ui.fx_pick = Some(pick); + app.ui.text_color_pick = None; app.ui.status = if kind == "point" { format!("Click in the Composition panel to set {}", prop.name) } else { @@ -1386,7 +1387,50 @@ pub fn viewer_hook( app.ui.status.clear(); } } - let picking = app.ui.fx_pick.is_some(); + if let Some(pick) = app.ui.text_color_pick.clone() { + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::new("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, format!("Click to sample text {}", pick.field), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, super::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } + } + let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { // ⊕ controls for the point parameters of the selected effects. diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index a32d74b4..73176f18 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -8,6 +8,7 @@ use effectcraft_engine::render::EvalCtx; use egui::{Align2, Rect, Sense, pos2, vec2}; use serde_json::json; +use crate::state::TextColorPick; use crate::theme::Tokens; use crate::{EffectcraftApp, widgets}; @@ -201,7 +202,21 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if crate::widgets::color_popup(ui, pop, fs.left_bottom(), &mut rgb) { actions.push(json!({key: [rgb[0], rgb[1], rgb[2]], "merge": format!("char-{key}")})); } - p.text(pos2(fs.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); + let er = Rect::from_min_size(pos2(fs.max.x + 4.0, y + 1.0), vec2(18.0, 18.0)); + let active = app.ui.text_color_pick.as_ref().is_some_and(|pick| pick.layer == target.as_ref().map_or(0, |tt| tt.layer) && pick.field == key); + let eye = super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::new(("char-eye", key)), &t); + app.auto.add(&format!("character.{key}.eyedropper"), er, &format!("{label} eyedropper")); + if eye.clicked() { + if active { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else if let Some(tt) = &target { + app.ui.fx_pick = None; + app.ui.text_color_pick = Some(TextColorPick { layer: tt.layer, field: key.into(), range: tt.range }); + app.ui.status = format!("Click in the Composition panel to sample text {label}"); + } + } + p.text(pos2(er.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim); } y += 32.0; // Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0cce2c11..fe70d80a 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -488,6 +488,15 @@ pub struct FxPick { pub name: String, } +/// A Character panel Fill or Stroke eyedropper waiting for a Composition viewer click. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TextColorPick { + pub layer: u64, + pub field: String, + /// Preserve the edited character range when focus moves from the panel to the viewer. + pub range: Option<[usize; 2]>, +} + /// Effects & Presets contents-menu options. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct EffectsView { @@ -615,6 +624,9 @@ pub struct UiState { /// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click. #[serde(default)] pub fx_pick: Option, + /// Character panel colour eyedropper waiting for a viewer click. + #[serde(default)] + pub text_color_pick: Option, /// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid. #[serde(default)] pub fx_curve_channel: BTreeMap, @@ -702,6 +714,7 @@ impl Default for UiState { fx_closed: BTreeSet::new(), fx_slider_open: BTreeSet::new(), fx_pick: None, + text_color_pick: None, fx_curve_channel: BTreeMap::new(), fx_curve_pencil: BTreeSet::new(), fx_levels_channel: BTreeMap::new(), diff --git a/crates/ui-egui/tests/ui_character_eyedropper.rs b/crates/ui-egui/tests/ui_character_eyedropper.rs new file mode 100644 index 00000000..b11a3e7b --- /dev/null +++ b/crates/ui-egui/tests/ui_character_eyedropper.rs @@ -0,0 +1,85 @@ +//! Character Fill and Stroke can sample a displayed Composition pixel. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind, panels::viewer::comp_to_screen}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, pos: Pos2) { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); +} + +fn click_auto(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + click(h, pos2(e.rect[0] + e.rect[2] * 0.5, e.rect[1] + e.rect[3] * 0.5)); +} + +fn settle(h: &mut Harness<'_, EffectcraftApp>) { + for _ in 0..600 { + h.step(); + if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().is_ok_and(|v| *v > 0.0) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + } + h.run_steps(4); +} + +#[test] +fn character_fill_eyedropper_samples_viewer_and_keeps_text_selection() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "Background", "color": "#20c040"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": text})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.set_workspace("Text"); + let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + settle(&mut h); + click_auto(&mut h, "character.fill.eyedropper"); + assert_eq!(h.state().ui.text_color_pick.as_ref().unwrap().range, Some([0, 5])); + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + assert!(h.state().ui.text_color_pick.is_none()); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x20 as f32 / 255.0, 0xc0 as f32 / 255.0, 0x40 as f32 / 255.0]; + for (actual, expected) in doc.style_at(0).fill.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } + assert_ne!(doc.style_at(8).fill, doc.style_at(0).fill, "unselected text keeps its colour"); +} + +#[test] +fn character_stroke_eyedropper_samples_viewer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); + s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap(); + let text = s.execute("layer.newText", json!({"text": "brat", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0]})).unwrap(); + let mut app = EffectcraftApp::new(s); + app.set_workspace("Text"); + let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; + h.state_mut().show_panel(PanelKind::Character); + settle(&mut h); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); + } + click_auto(&mut h, "character.stroke.eyedropper"); + let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); + click(&mut h, sample); + let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); + let want = [0x8a as f32 / 255.0, 0xce as f32 / 255.0, 0.0]; + for (actual, expected) in doc.stroke.iter().zip(want) { + assert!((actual - expected).abs() < 0.02); + } +} From b6919500eebddda7b3c6c49e27f1eea3ab51b99d Mon Sep 17 00:00:00 2001 From: Kristian Myram Date: Mon, 12 Oct 2026 01:57:18 +0200 Subject: [PATCH 2/3] Character eyedropper test: a compact layout for the demo snapshot --- .../ui-egui/tests/ui_character_eyedropper.rs | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/tests/ui_character_eyedropper.rs b/crates/ui-egui/tests/ui_character_eyedropper.rs index b11a3e7b..a2c4c6d1 100644 --- a/crates/ui-egui/tests/ui_character_eyedropper.rs +++ b/crates/ui-egui/tests/ui_character_eyedropper.rs @@ -1,6 +1,10 @@ //! Character Fill and Stroke can sample a displayed Composition pixel. use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; -use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind, panels::viewer::comp_to_screen}; +use effectcraft_ui_egui::{ + EffectcraftApp, + dock::{DockNode, PanelKind, SplitSize}, + panels::viewer::comp_to_screen, +}; use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; use egui_kittest::Harness; use serde_json::json; @@ -64,17 +68,24 @@ fn character_stroke_eyedropper_samples_viewer() { s.execute("comp.new", json!({"width": 320, "height": 180, "duration": 2})).unwrap(); s.execute("layer.newSolid", json!({"name": "brat green", "color": "#8ACE00"})).unwrap(); let text = s.execute("layer.newText", json!({"text": "brat", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); - s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0]})).unwrap(); + s.execute("layer.setText", json!({"layer": text, "fill": [0.0, 0.0, 0.0], "size": 32.0})).unwrap(); let mut app = EffectcraftApp::new(s); - app.set_workspace("Text"); - let builder = Harness::builder().with_size(vec2(1600.0, 1400.0)); + app.ui.viewer.show_layer_controls = false; + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedB(360.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Composition], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Character], active: 0 }), + }; + let builder = Harness::builder().with_size(vec2(1200.0, 760.0)); let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { builder.wgpu().build_eframe(|_| app) } else { builder.build_eframe(|_| app) }; h.state_mut().show_panel(PanelKind::Character); + h.state_mut().show_panel(PanelKind::Composition); settle(&mut h); + click_auto(&mut h, "character.stroke.eyedropper"); if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { h.render().unwrap().save(format!("{dir}/character-eyedropper.png")).unwrap(); } - click_auto(&mut h, "character.stroke.eyedropper"); let sample = comp_to_screen(&h.ctx, [20.0, 20.0]).unwrap(); click(&mut h, sample); let doc = layer_doc(&h.state().session, LayerId(text)).unwrap(); From 37c4e9850215ab392ab71d7da48cd0b280ffa507 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 04:23:58 -0400 Subject: [PATCH 3/3] Character eyedroppers: the sampling hints go through the catalog Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/effect_controls.rs | 2 +- crates/ui-egui/src/panels/text_panels.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 4f449e8c..81c2dd72 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -1397,7 +1397,7 @@ pub fn viewer_hook( app.auto.add("viewer.textColorPick", map.area, &pick.field); let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, format!("Click to sample text {}", pick.field), Tokens::ui(11.5), t.text); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); if let Some(pos) = resp.hover_pos() { ctx.set_cursor_icon(egui::CursorIcon::Crosshair); let c = map.to_comp(pos); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 73176f18..2a4fb647 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -213,7 +213,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } else if let Some(tt) = &target { app.ui.fx_pick = None; app.ui.text_color_pick = Some(TextColorPick { layer: tt.layer, field: key.into(), range: tt.range }); - app.ui.status = format!("Click in the Composition panel to sample text {label}"); + app.ui.status = crate::i18n::tr_args("Click in the Composition panel to sample text {}", &[&label]); } } p.text(pos2(er.max.x + 6.0, y + 10.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), t.text_dim);