Merge #938 from nanox333/codex/fix-712-character-eyedropper (without the demo screenshot)

Add Character Fill and Stroke eyedroppers
This commit is contained in:
Brandon Thomas 2026-10-12 04:24:28 -04:00
commit 5f386cc97c
4 changed files with 170 additions and 2 deletions

View File

@ -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 {
@ -1416,7 +1417,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, 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);
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.

View File

@ -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 = 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);
}
y += 32.0;
// Style toggles: Faux Bold, Faux Italic, All Caps, Small Caps, Superscript, Subscript.

View File

@ -449,6 +449,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 {
@ -580,6 +589,9 @@ pub struct UiState {
/// Effect Controls: a point crosshair or colour eyedropper waiting for a viewer click.
#[serde(default)]
pub fx_pick: Option<FxPick>,
/// Character panel colour eyedropper waiting for a viewer click.
#[serde(default)]
pub text_color_pick: Option<TextColorPick>,
/// Curves editor: shown channel (index into RGB/Red/Green/Blue/Alpha) per effect uid.
#[serde(default)]
pub fx_curve_channel: BTreeMap<u64, usize>,
@ -676,6 +688,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(),

View File

@ -0,0 +1,96 @@
//! 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::{DockNode, PanelKind, SplitSize},
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], "size": 32.0})).unwrap();
let mut app = EffectcraftApp::new(s);
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();
}
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);
}
}