Add Character fill and stroke eyedroppers
This commit is contained in:
parent
67434e9038
commit
b693a6286b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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>,
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue