mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 19:36:46 -06:00
Merge branch 'pr-514' into batch/t5
# Conflicts: # contributors/people.toml
This commit is contained in:
@@ -38,4 +38,6 @@ real_name = "Justin John"
|
||||
[people.Losminek]
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -212,11 +212,13 @@ fn page_colors(app: &FigApp, ui: &Ui) -> Vec<Color> {
|
||||
/// Colour models the value row can show.
|
||||
const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"];
|
||||
|
||||
/// The model dropdown with its fields and the alpha field. Returns a committed colour.
|
||||
fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
/// The model dropdown with its fields and the alpha field. Returns a new colour and whether the
|
||||
/// edit is done (false while a field is being scrubbed).
|
||||
fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> {
|
||||
let model_id = Id::new("cp_model");
|
||||
let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1);
|
||||
let mut out: Option<Color> = None;
|
||||
let mut done = true;
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
@@ -238,7 +240,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
w,
|
||||
1.0,
|
||||
) {
|
||||
Some(widgets::FieldEdit::Set(x)) | Some(widgets::FieldEdit::Scrub(x, _)) => Some(x),
|
||||
Some(widgets::FieldEdit::Set(x)) => Some((x, true)),
|
||||
Some(widgets::FieldEdit::Scrub(x, end)) => Some((x, end)),
|
||||
None => None,
|
||||
};
|
||||
match MODELS.get(model).copied().unwrap_or("Hex") {
|
||||
@@ -246,7 +249,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
let [r, g, b, _] = c.to_rgba8();
|
||||
let vals = [f64::from(r), f64::from(g), f64::from(b)];
|
||||
for (k, v) in vals.iter().enumerate() {
|
||||
if let Some(x) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
|
||||
if let Some((x, end)) = num(ui, ["r", "g", "b"][k.min(2)], *v, 31.0) {
|
||||
done = end;
|
||||
let mut rgb = vals;
|
||||
if let Some(slot) = rgb.get_mut(k) {
|
||||
*slot = x.clamp(0.0, 255.0);
|
||||
@@ -266,7 +270,8 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
};
|
||||
let vals = [f64::from(h), f64::from(s2) * 100.0, f64::from(l2) * 100.0];
|
||||
for (k, v) in vals.iter().enumerate() {
|
||||
if let Some(x) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
|
||||
if let Some((x, end)) = num(ui, ["h", "s", "l"][k.min(2)], *v, 31.0) {
|
||||
done = end;
|
||||
let mut v3 = vals;
|
||||
if let Some(slot) = v3.get_mut(k) {
|
||||
*slot = if k == 0 { x.rem_euclid(360.0) } else { x.clamp(0.0, 100.0) };
|
||||
@@ -288,14 +293,20 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<Color> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if MODELS.get(model) != Some(&"CSS")
|
||||
&& let Some(widgets::FieldEdit::Set(v)) =
|
||||
widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, Some((f64::from(out.unwrap_or(c).a) * 100.0).round()), "%", 44.0, 1.0)
|
||||
{
|
||||
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
|
||||
if MODELS.get(model) != Some(&"CSS") {
|
||||
let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round());
|
||||
let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) {
|
||||
Some(widgets::FieldEdit::Set(v)) => Some((v, true)),
|
||||
Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)),
|
||||
None => None,
|
||||
};
|
||||
if let Some((v, end)) = edit {
|
||||
done = end;
|
||||
out = Some(out.unwrap_or(c).with_alpha((v / 100.0).clamp(0.0, 1.0) as f32));
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
out.map(|c| (c, done))
|
||||
}
|
||||
|
||||
fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
|
||||
@@ -744,11 +755,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
done |= d;
|
||||
}
|
||||
// Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.
|
||||
if let Some(c) = model_fields(ui, nc) {
|
||||
if let Some((c, d)) = model_fields(ui, nc) {
|
||||
nc = c;
|
||||
hsb = Hsb::from_color(nc);
|
||||
changed = true;
|
||||
done = true;
|
||||
done |= d;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));
|
||||
// A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//! a scrub and an arrow key move a value, and the Design panel's fields driven through egui
|
||||
//! headless: scrubbing by the label with and without Shift and Alt, arrow keys, arithmetic on
|
||||
//! commit, Tab and Shift+Tab, Escape, fields showing "Mixed", and one undo step per scrub or
|
||||
//! commit.
|
||||
//! commit (the colour picker's, the prototype panel's and the Scale panel's fields too).
|
||||
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, Pos2, pos2};
|
||||
use egui::{Event, Id, Key, Modifiers, Pos2, pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
@@ -216,6 +216,11 @@ fn type_into(u: &mut Ui, name: &str, text: &str, end: Key) {
|
||||
/// Scrub field `name` by its label: `dx` points with `modifiers` held, then let go.
|
||||
fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
|
||||
let from = label(u, name);
|
||||
drag(u, from, dx, modifiers);
|
||||
}
|
||||
|
||||
/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go.
|
||||
fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) {
|
||||
let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
u.frame_with(vec![Event::PointerMoved(from), press(true)], modifiers);
|
||||
let steps = 10;
|
||||
@@ -404,3 +409,67 @@ fn mixed_values_take_edits_relative_to_each_layer() {
|
||||
assert_eq!((geom(&u, a).0, geom(&u, b).0), (64.0, 64.0));
|
||||
assert_eq!(field(&mut u, "X").1, "64");
|
||||
}
|
||||
|
||||
/// The middle of the label (what scrubs) of the field with id `id`, as last painted.
|
||||
fn lead(u: &Ui, id: Id) -> Pos2 {
|
||||
u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8();
|
||||
let [r0, .., a0] = rgba(&u);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize));
|
||||
u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
|
||||
// The picker window takes a frame to size itself.
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("cp_f", "r")));
|
||||
drag(&mut u, from, -20.0, Modifiers::NONE);
|
||||
assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R");
|
||||
// The alpha field: a scrub moves it too.
|
||||
let from = lead(&u, Id::new("cp_a"));
|
||||
drag(&mut u, from, -30.0, Modifiers::NONE);
|
||||
let [.., alpha] = rgba(&u);
|
||||
assert!(alpha < a0, "alpha {a0} → {alpha}");
|
||||
assert_eq!(undo_steps(&u), steps + 2, "alpha");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
|
||||
let mut s = Session::new();
|
||||
let f1 = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
|
||||
let f2 = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap());
|
||||
let it = json!({"id": f1, "trigger": {"type": "afterDelay", "ms": 500}, "action": {"type": "navigate", "dest": f2}});
|
||||
s.execute("proto.interaction.add", &it).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [f1]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((f1, 0)));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new(("proto_delay", f1, 0usize)));
|
||||
drag(&mut u, from, 30.0, Modifiers::NONE);
|
||||
let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger {
|
||||
figcraft_doc::proto::Trigger::AfterDelay { ms } => ms,
|
||||
_ => panic!("still after a delay"),
|
||||
};
|
||||
assert_eq!((ms, undo_steps(&u)), (800, steps + 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (mut u, a, _) = setup();
|
||||
u.app.run("tool.set", json!({"tool": "scale"}));
|
||||
u.frame(vec![]);
|
||||
let steps = undo_steps(&u);
|
||||
let from = lead(&u, Id::new("scale_w"));
|
||||
drag(&mut u, from, 20.0, Modifiers::NONE);
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
|
||||
@@ -700,7 +700,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
|
||||
row(ui, 32.0, |ui| {
|
||||
widgets::label(ui, tr("Delay"), t.text_secondary);
|
||||
let id = Id::new(("proto_mouse_delay", node, k));
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) =
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0)
|
||||
&& v.is_finite()
|
||||
{
|
||||
@@ -711,13 +711,11 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
|
||||
Trigger::AfterDelay { ms } => {
|
||||
row(ui, 32.0, |ui| {
|
||||
widgets::label(ui, tr("Delay"), t.text_secondary);
|
||||
if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) {
|
||||
let v = match e {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
|
||||
};
|
||||
if v.is_finite() {
|
||||
patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}}));
|
||||
}
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0)
|
||||
&& v.is_finite()
|
||||
{
|
||||
patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -851,9 +849,10 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
|
||||
row(ui, 32.0, |ui| {
|
||||
let w = ((ui.available_width() - 6.0) / 2.0).max(36.0);
|
||||
for (i, (name, key)) in [("X", "proto_scroll_x"), ("Y", "proto_scroll_y")].into_iter().enumerate() {
|
||||
let Some(e) = widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0) else { continue };
|
||||
let v = match e {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
|
||||
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, slot.id(key), Lead::Text(name), offset.get(i).copied(), "", w, 1.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if v.is_finite() {
|
||||
let mut o = offset;
|
||||
@@ -901,7 +900,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, _)) = e.settled() else { continue };
|
||||
let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = e.settled() else { continue };
|
||||
if v.is_finite() {
|
||||
let mut o = offset.unwrap_or([0.0, 0.0]);
|
||||
if let Some(slot_v) = o.get_mut(i) {
|
||||
@@ -932,18 +931,12 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
|
||||
set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(c), offset });
|
||||
}
|
||||
let alpha = (bg.a * 100.0).round();
|
||||
if let Some(e) = widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) {
|
||||
let v = match e {
|
||||
FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v,
|
||||
};
|
||||
if v.is_finite() {
|
||||
let a = v.clamp(0.0, 100.0) as f32 / 100.0;
|
||||
set_action(
|
||||
app,
|
||||
slot,
|
||||
Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset },
|
||||
);
|
||||
}
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0)
|
||||
&& v.is_finite()
|
||||
{
|
||||
let a = v.clamp(0.0, 100.0) as f32 / 100.0;
|
||||
set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(a)), offset });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,23 +21,29 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
|
||||
let anchor = app.session.tool.scale_anchor;
|
||||
let mut factor: Option<f64> = None;
|
||||
// A scrub of W or H scales live, in one undo step: `Some(done)`.
|
||||
let mut scrub: Option<bool> = None;
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, tr("Dimensions"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
if let Some(FieldEdit::Set(w)) = widgets::number_field(ui, Id::new("scale_w"), Lead::Text("W"), Some(round(b.width())), "", col, 1.0)
|
||||
&& b.width() > 1e-9
|
||||
{
|
||||
factor = Some(w / b.width());
|
||||
}
|
||||
if let Some(FieldEdit::Set(h)) = widgets::number_field(ui, Id::new("scale_h"), Lead::Text("H"), Some(round(b.height())), "", col, 1.0)
|
||||
&& b.height() > 1e-9
|
||||
{
|
||||
factor = Some(h / b.height());
|
||||
for (key, lead, side) in [("scale_w", "W", b.width()), ("scale_h", "H", b.height())] {
|
||||
let (v, s) = match widgets::number_field(ui, Id::new(key), Lead::Text(lead), Some(round(side)), "", col, 1.0) {
|
||||
Some(FieldEdit::Set(v)) => (v, None),
|
||||
Some(FieldEdit::Scrub(v, done)) => (v, Some(done)),
|
||||
None => continue,
|
||||
};
|
||||
if side > 1e-9 {
|
||||
factor = Some(v / side);
|
||||
scrub = s;
|
||||
}
|
||||
}
|
||||
});
|
||||
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
|
||||
crate::right::block(ui, 52.0, 2.0, |ui| {
|
||||
if let Some(FieldEdit::Set(k)) = widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) {
|
||||
// Relative to the layers as they are: a scrub applies once, when let go.
|
||||
if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) =
|
||||
widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1)
|
||||
{
|
||||
factor = Some(k);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
@@ -45,8 +51,24 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
app.run("tool.scaleAnchor", json!({"anchor": figcraft_engine::tools::anchor_name(a)}));
|
||||
}
|
||||
});
|
||||
if let Some((k, cx, cy)) = factor.and_then(|k| scale_about(b, anchor, k)) {
|
||||
app.run("transform.scale", json!({"ids": sel, "factor": k, "cx": cx, "cy": cy}));
|
||||
let scale = factor.and_then(|k| scale_about(b, anchor, k)).map(|(k, cx, cy)| json!({"ids": sel, "factor": k, "cx": cx, "cy": cy}));
|
||||
match scrub {
|
||||
None => {
|
||||
if let Some(p) = scale {
|
||||
app.run("transform.scale", p);
|
||||
}
|
||||
}
|
||||
Some(done) => {
|
||||
if let Some(p) = scale {
|
||||
let _ = app.session.begin_interaction("Scale");
|
||||
if let Err(e) = app.session.execute("transform.scale", &p) {
|
||||
app.failed("transform.scale", &e);
|
||||
}
|
||||
}
|
||||
if done {
|
||||
let _ = app.session.commit_interaction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user