mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
i18n: translate the interface below the menu bar (panels, dialogs, tooltips) # Conflicts: # crates/ui-egui/src/lib.rs # docs/preferences.md
632 lines
31 KiB
Rust
632 lines
31 KiB
Rust
//! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community
|
||
//! buttons (Discord is always one click away).
|
||
|
||
use crate::i18n::tr;
|
||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||
use serde_json::json;
|
||
|
||
use crate::icons::{self, Icon};
|
||
use crate::state::Tool;
|
||
use crate::theme::Tokens;
|
||
use crate::widgets;
|
||
use crate::{Dialog, EffectcraftApp};
|
||
use effectcraft_color::BlendMode;
|
||
use effectcraft_engine::commands::shape_tool::{PaintKind, ToolPaint};
|
||
use effectcraft_engine::project::{LayerId, LayerSource};
|
||
use effectcraft_engine::viewer as vw;
|
||
|
||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||
let t = app.tokens;
|
||
let p = ui.painter().clone();
|
||
p.rect_filled(rect, 0.0, t.header_bg);
|
||
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.app_bg));
|
||
let cy = rect.center().y;
|
||
let mut x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 };
|
||
|
||
// Brand mark.
|
||
let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0));
|
||
paint_logo(&p, brand);
|
||
let bresp = ui.interact(brand, egui::Id::new("brand"), Sense::click());
|
||
app.auto.add("header.about", brand, "About EffectCraft");
|
||
if bresp.on_hover_text(tr("About EffectCraft")).clicked() {
|
||
app.dialog = Some(Dialog::About);
|
||
}
|
||
x += 32.0;
|
||
|
||
// Home.
|
||
let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||
if widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::new("tool-home")).on_hover_text(tr("Home")).clicked() {
|
||
app.ui.start_screen = !app.ui.start_screen;
|
||
}
|
||
app.auto.add("header.home", home, "Home");
|
||
x += 30.0;
|
||
p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator));
|
||
x += 6.0;
|
||
|
||
// Tool slots (with group separators after camera tools and pan-behind).
|
||
let held_id = egui::Id::new("tool-slot-held");
|
||
if ui.input(|i| i.pointer.any_pressed()) {
|
||
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
|
||
}
|
||
for (si, slot) in Tool::SLOTS.iter().enumerate() {
|
||
let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]);
|
||
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||
let active = slot.contains(&app.ui.tool);
|
||
let id = egui::Id::new(("tool-slot", si));
|
||
let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id);
|
||
if slot.len() > 1 {
|
||
// Flyout corner triangle.
|
||
p.add(egui::Shape::convex_polygon(
|
||
vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)],
|
||
if active { Color32::WHITE } else { t.text_dim },
|
||
Stroke::NONE,
|
||
));
|
||
}
|
||
app.auto.add(&format!("tools.{cur:?}"), r, cur.label());
|
||
let tip = match cur.shortcut() {
|
||
Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)),
|
||
None => cur.label().to_string(),
|
||
};
|
||
let resp = resp.on_hover_text(tip);
|
||
let held = ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(id);
|
||
if resp.clicked() && !held {
|
||
app.ui.tool = cur;
|
||
}
|
||
// Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content.
|
||
if cur == Tool::PanBehind
|
||
&& resp.double_clicked()
|
||
&& ui.input(|i| i.modifiers.command)
|
||
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({}))
|
||
{
|
||
app.ui.status = e;
|
||
}
|
||
if slot.len() > 1 {
|
||
let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten();
|
||
if let Some(seconds) = held_for
|
||
&& seconds < 0.35
|
||
{
|
||
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
|
||
}
|
||
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held;
|
||
let command = if resp.secondary_clicked() || long_press {
|
||
if long_press {
|
||
ui.data_mut(|d| d.insert_temp(held_id, id));
|
||
}
|
||
Some(egui::SetOpenCommand::Bool(true))
|
||
} else if resp.clicked() && !held {
|
||
Some(egui::SetOpenCommand::Bool(false))
|
||
} else {
|
||
None
|
||
};
|
||
let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside };
|
||
egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| {
|
||
for tool in slot.iter() {
|
||
let row = ui.selectable_label(app.ui.tool == *tool, tool.label());
|
||
app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label());
|
||
if row.clicked() {
|
||
app.ui.tool = *tool;
|
||
if let Some(saved) = app.ui.slot_tools.get_mut(si) {
|
||
*saved = *tool;
|
||
}
|
||
ui.close();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
x += 28.0;
|
||
if matches!(si, 2 | 5 | 7 | 10 | 13) {
|
||
x += 4.0;
|
||
p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator));
|
||
x += 6.0;
|
||
}
|
||
}
|
||
|
||
// Tool options.
|
||
x += 10.0;
|
||
// Selected shape layers: the options show their paint and edit it (as in After Effects).
|
||
let shape_layers: Vec<u64> = app
|
||
.session
|
||
.active_comp()
|
||
.map(|c| {
|
||
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
|
||
})
|
||
.unwrap_or_default();
|
||
let layer_paint = shape_layers
|
||
.first()
|
||
.and_then(|id| app.session.active_comp()?.layer(LayerId(*id)))
|
||
.map(|l| effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time())));
|
||
if let Some((fill, stroke, width)) = layer_paint {
|
||
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
|
||
let tool = &mut app.session.state.shape_tool;
|
||
if let Some(c) = fill {
|
||
tool.fill.color = rgb(c);
|
||
}
|
||
if let Some(c) = stroke {
|
||
tool.stroke.color = rgb(c);
|
||
}
|
||
tool.stroke_width = width.unwrap_or(0.0);
|
||
}
|
||
// One undo step per picker session or width drag: end the merge once both are done.
|
||
let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k)));
|
||
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
|
||
app.session.history.merge_key = None;
|
||
}
|
||
let drawing = app.ui.tool.is_shape() || app.ui.tool == Tool::Pen;
|
||
// Tool Creates Shape / Tool Creates Mask, with a shape layer selected (as in After Effects).
|
||
if drawing && !shape_layers.is_empty() {
|
||
x = creates_buttons(app, ui, x, cy);
|
||
}
|
||
// Fill and Stroke, hidden while the tools draw masks.
|
||
let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask;
|
||
if (drawing || !shape_layers.is_empty()) && !masking {
|
||
x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy);
|
||
}
|
||
if app.ui.tool.puppet_kind().is_some() {
|
||
x = puppet_options(app, ui, &p, x, cy);
|
||
}
|
||
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
|
||
app.ui.snapping = app.session.state.snapping;
|
||
if widgets::checkbox(ui, snap, app.ui.snapping, &t, egui::Id::new("snapping")).clicked() {
|
||
let _ = app.session.execute("view.snapping", serde_json::json!({}));
|
||
app.ui.snapping = app.session.state.snapping;
|
||
}
|
||
app.auto.add("header.snapping", snap, "Snapping");
|
||
let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, tr("Snapping"), Tokens::ui(12.0), t.text_dim);
|
||
snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0)));
|
||
|
||
// Right side: community buttons, workspaces.
|
||
let mut rx = rect.max.x - 10.0;
|
||
let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0));
|
||
let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click());
|
||
let dc = Color32::from_rgb(0x58, 0x65, 0xf2);
|
||
p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc });
|
||
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
|
||
p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE);
|
||
app.auto.add("header.discord", discord, "Join the ArtCraft Discord");
|
||
if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() {
|
||
let _ = app.session.execute("help.discord", json!({}));
|
||
}
|
||
rx = discord.min.x - 6.0;
|
||
for (id, icon, tip, cmd) in
|
||
[("hdr-github", Icon::Code, "EffectCraft on GitHub", "help.github"), ("hdr-web", Icon::Globe, "EffectCraft on getartcraft.com", "help.appPage")]
|
||
{
|
||
let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0));
|
||
if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new(id)).on_hover_text(tip).clicked() {
|
||
let _ = app.session.execute(cmd, json!({}));
|
||
}
|
||
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
|
||
rx = r.min.x - 4.0;
|
||
}
|
||
rx -= 10.0;
|
||
p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator));
|
||
rx -= 10.0;
|
||
// Workspace tabs (right to left), with a » menu of all.
|
||
let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0));
|
||
let mresp = ui.interact(more, egui::Id::new("ws-more"), Sense::click());
|
||
p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim });
|
||
app.auto.add("header.workspaces", more, "Workspaces");
|
||
mresp.context_menu(|ui| ws_menu(app, ui));
|
||
if mresp.clicked() {
|
||
widgets::open_popup(ui, egui::Id::new("ws-pop"));
|
||
}
|
||
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
|
||
if let Some(i) = widgets::popup_menu(
|
||
ui,
|
||
egui::Id::new("ws-pop"),
|
||
more.left_bottom() - vec2(140.0, 0.0),
|
||
&names,
|
||
crate::dock::WORKSPACES.iter().position(|w| *w == app.ui.workspace),
|
||
) {
|
||
app.set_workspace(crate::dock::WORKSPACES[i]);
|
||
}
|
||
rx = more.min.x - 6.0;
|
||
// After Effects 2026's workspace bar order (right to left here): Default, Review, Learn,
|
||
// Small Screen, Standard.
|
||
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
|
||
for name in shown {
|
||
let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text);
|
||
let w = g.size().x + 16.0;
|
||
let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0));
|
||
if r.min.x < x + 120.0 {
|
||
break;
|
||
}
|
||
let active = app.ui.workspace == name;
|
||
let resp = ui.interact(r, egui::Id::new(("ws", name)), Sense::click());
|
||
let col = if active {
|
||
t.hot_text
|
||
} else if resp.hovered() {
|
||
t.text
|
||
} else {
|
||
t.text_dim
|
||
};
|
||
p.galley_with_override_text_color(pos2(r.min.x + 8.0, cy - g.size().y / 2.0), g, col);
|
||
if active {
|
||
p.line_segment([pos2(r.min.x + 8.0, r.max.y - 3.0), pos2(r.max.x - 8.0, r.max.y - 3.0)], Stroke::new(2.0, t.hot_text));
|
||
}
|
||
app.auto.add(&format!("header.workspace.{name}"), r, name);
|
||
if resp.clicked() {
|
||
app.set_workspace(name);
|
||
}
|
||
rx = r.min.x - 2.0;
|
||
}
|
||
}
|
||
|
||
/// The Snapping options menu beside the checkbox: After Effects' Snap Edges Extended and Snap to
|
||
/// Features in Collapsed Compositions and Text Layers (`view.snappingOptions`).
|
||
fn snapping_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||
let t = app.tokens;
|
||
let pop = egui::Id::new("snap-options");
|
||
if widgets::icon_button(ui, r, Icon::ChevronDown, false, &t, egui::Id::new("snap-options-button")).on_hover_text(tr("Snapping options")).clicked() {
|
||
widgets::open_popup(ui, pop);
|
||
}
|
||
app.auto.add("header.snappingOptions", r, "Snapping options");
|
||
if !widgets::popup_is_open(ui, pop) {
|
||
return;
|
||
}
|
||
let f = app.session.state.snap_features;
|
||
let items: Vec<String> = vw::SnapFeatures::OPTIONS.iter().map(|(k, label)| widgets::check_label(f.option(k), label)).collect();
|
||
if let Some((k, _)) = widgets::popup_menu(ui, pop, r.left_bottom(), &items, None).and_then(|i| vw::SnapFeatures::OPTIONS.get(i))
|
||
&& let Err(e) = app.session.execute("view.snappingOptions", json!({"toggle": k}))
|
||
{
|
||
app.ui.status = e.to_string();
|
||
}
|
||
}
|
||
|
||
/// Set the shape tools' options (`shape.toolOptions`).
|
||
fn tool_options(app: &mut EffectcraftApp, v: serde_json::Value) {
|
||
if let Err(e) = app.session.execute("shape.toolOptions", v) {
|
||
app.ui.status = e.to_string();
|
||
}
|
||
}
|
||
|
||
/// The colour picker of the Fill or Stroke swatch.
|
||
fn picker_id(key: &str) -> egui::Id {
|
||
egui::Id::new(("tool-pick", key))
|
||
}
|
||
|
||
/// Tool Creates Shape / Tool Creates Mask: with a shape layer selected, whether the shape tools
|
||
/// and the Pen draw shapes into it or masks on it. Returns the next x.
|
||
fn creates_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, mut x: f32, cy: f32) -> f32 {
|
||
let t = app.tokens;
|
||
for (mask, icon, tip, key) in [(false, Icon::ShapeLayer, "Tool Creates Shape", "createsShape"), (true, Icon::MaskVis, "Tool Creates Mask", "createsMask")] {
|
||
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
|
||
let on = app.session.state.shape_tool.creates_mask == mask;
|
||
if widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("tool-creates", key))).on_hover_text(tip).clicked() {
|
||
tool_options(app, json!({"createsMask": mask}));
|
||
}
|
||
app.auto.add(&format!("header.{key}"), r, tip);
|
||
x += 28.0;
|
||
}
|
||
x + 10.0
|
||
}
|
||
|
||
/// Fill and Stroke (the words open Fill Options / Stroke Options), their swatches and the
|
||
/// Stroke Width: for new shapes, and for the selected shape layers. Returns the next x.
|
||
fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, shape_layers: &[u64], mut x: f32, cy: f32) -> f32 {
|
||
let t = app.tokens;
|
||
// The selected shape layers take the colours and the width too, one undo step per picker
|
||
// session or drag.
|
||
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
|
||
if shape_layers.is_empty() {
|
||
return;
|
||
}
|
||
let merge = format!("tool-{key}");
|
||
if let Err(e) = app.session.execute("shape.fillStroke", json!({"layers": shape_layers, key: v, "merge": merge})) {
|
||
app.ui.status = e.to_string();
|
||
}
|
||
};
|
||
for (key, title) in [("fill", "Fill"), ("stroke", "Stroke")] {
|
||
let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone();
|
||
let options = format!("{title} Options");
|
||
let g = p.layout_no_wrap(format!("{title}:"), Tokens::ui(12.0), t.text_dim);
|
||
let wr = Rect::from_min_size(pos2(x, cy - g.size().y / 2.0), g.size());
|
||
let wresp = ui.interact(wr, egui::Id::new(("tool-paint-word", key)), Sense::click()).on_hover_text(&options);
|
||
let col = if wresp.hovered() { t.text } else { t.text_dim };
|
||
if wresp.hovered() {
|
||
p.line_segment([pos2(wr.min.x, wr.max.y), pos2(wr.max.x - 3.0, wr.max.y)], Stroke::new(1.0, col));
|
||
}
|
||
p.galley_with_override_text_color(wr.min, g, col);
|
||
app.auto.add(&format!("header.{key}Options"), wr, &options);
|
||
let opts = egui::Id::new(("tool-paint-options", key));
|
||
if wresp.clicked() {
|
||
widgets::open_popup(ui, opts);
|
||
}
|
||
// The swatch picks a Solid Color's colour and opens the options for the other kinds;
|
||
// Alt-click steps through the kinds (as in After Effects).
|
||
let sr = Rect::from_min_size(pos2(wr.max.x + 6.0, cy - 8.0), vec2(20.0, 16.0));
|
||
let sresp = ui.interact(sr, egui::Id::new(("tool-paint-swatch", key)), Sense::click()).on_hover_text(format!("{title}: {}", tp.kind.label()));
|
||
paint_swatch(p, sr, &tp, sresp.hovered(), &t);
|
||
app.auto.add(&format!("header.{key}"), sr, &format!("{title} Color"));
|
||
if sresp.clicked() {
|
||
if ui.input(|i| i.modifiers.alt) {
|
||
let at = PaintKind::ALL.iter().position(|k| *k == tp.kind).unwrap_or(0);
|
||
let next = PaintKind::ALL.get((at + 1) % PaintKind::ALL.len()).copied().unwrap_or_default();
|
||
let k = format!("{key}Type");
|
||
tool_options(app, json!({k: next.id()}));
|
||
} else if tp.kind == PaintKind::Solid {
|
||
widgets::open_popup(ui, picker_id(key));
|
||
} else {
|
||
widgets::open_popup(ui, opts);
|
||
}
|
||
}
|
||
let mut c = tp.color;
|
||
if color_popup(ui, picker_id(key), sr.left_bottom(), &mut c) {
|
||
tool_options(app, json!({key: c}));
|
||
paint(app, key, json!(c));
|
||
}
|
||
paint_options_popup(app, ui, key, &options, wr.left_bottom());
|
||
x = sr.max.x + 12.0;
|
||
}
|
||
let width = app.session.state.shape_tool.stroke_width;
|
||
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::new("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
|
||
if let Some(v) = v {
|
||
tool_options(app, json!({"strokeWidth": v}));
|
||
paint(app, "strokeWidth", json!(v));
|
||
}
|
||
app.auto.add("header.strokeWidth", r, "Stroke Width");
|
||
r.max.x + 14.0
|
||
}
|
||
|
||
/// Fill Options / Stroke Options: None, Solid Color, Linear Gradient or Radial Gradient, and the
|
||
/// blend mode and opacity new shapes get (After Effects' dialogs, applied as they change).
|
||
fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, key: &str, title: &str, pos: egui::Pos2) {
|
||
let id = egui::Id::new(("tool-paint-options", key));
|
||
if !widgets::popup_is_open(ui, id) {
|
||
return;
|
||
}
|
||
let t = app.tokens;
|
||
let tp = if key == "fill" { &app.session.state.shape_tool.fill } else { &app.session.state.shape_tool.stroke }.clone();
|
||
let blend_pop = id.with("blend");
|
||
let mut set = None;
|
||
let mut blend_rect = Rect::NOTHING;
|
||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
let (rect, _) = ui.allocate_exact_size(vec2(236.0, 112.0), Sense::hover());
|
||
let p = ui.painter().clone();
|
||
let row = |y: f32| rect.min + vec2(0.0, y);
|
||
p.text(row(8.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
|
||
for (i, k) in PaintKind::ALL.into_iter().enumerate() {
|
||
let r = Rect::from_min_size(row(22.0) + vec2(i as f32 * 32.0, 0.0), vec2(28.0, 22.0));
|
||
let resp = ui.interact(r, id.with(k.id()), Sense::click()).on_hover_text(k.label());
|
||
if k == tp.kind {
|
||
p.rect_stroke(r, 3.0, Stroke::new(1.5, t.accent), StrokeKind::Outside);
|
||
}
|
||
paint_swatch(&p, r.shrink(3.0), &ToolPaint { kind: k, ..tp.clone() }, resp.hovered(), &t);
|
||
app.auto.add(&format!("header.{key}Options.{}", k.id()), r, k.label());
|
||
if resp.clicked() {
|
||
let k_type = format!("{key}Type");
|
||
set = Some(json!({k_type: k.id()}));
|
||
}
|
||
}
|
||
p.text(row(33.0) + vec2(134.0, 0.0), Align2::LEFT_CENTER, tp.kind.label(), Tokens::ui(11.5), t.text_dim);
|
||
p.text(row(66.0), Align2::LEFT_CENTER, tr("Blend Mode:"), Tokens::ui(12.0), t.text_dim);
|
||
blend_rect = Rect::from_min_size(row(56.0) + vec2(84.0, 0.0), vec2(150.0, 20.0));
|
||
if widgets::dropdown(ui, blend_rect, tp.blend.label(), &t, id.with("blend-dd")).clicked() {
|
||
widgets::open_popup(ui, blend_pop);
|
||
}
|
||
app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode");
|
||
p.text(row(98.0), Align2::LEFT_CENTER, tr("Opacity:"), Tokens::ui(12.0), t.text_dim);
|
||
let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t);
|
||
app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity");
|
||
if let Some(v) = v {
|
||
let k_opacity = format!("{key}Opacity");
|
||
set = Some(json!({k_opacity: v}));
|
||
}
|
||
});
|
||
});
|
||
// The blend modes in the Layer ▸ Blending Mode menu's groups, over the options.
|
||
let modes: Vec<String> =
|
||
BlendMode::ALL.iter().flat_map(|m| std::iter::once(m.label().to_string()).chain(m.ends_group().then(|| "-".to_string()))).collect();
|
||
let current = modes.iter().position(|m| m == tp.blend.label());
|
||
if let Some(m) = widgets::popup_menu(ui, blend_pop, blend_rect.left_bottom(), &modes, current).and_then(|i| modes.get(i)) {
|
||
let k_blend = format!("{key}Blend");
|
||
set = Some(json!({k_blend: m}));
|
||
}
|
||
// Closes on a press outside or Escape, but stays open while its blend mode menu is.
|
||
if !widgets::popup_is_open(ui, blend_pop) && (widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape))) {
|
||
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
|
||
}
|
||
if let Some(v) = set {
|
||
tool_options(app, v);
|
||
}
|
||
}
|
||
|
||
/// sRGB components (0–1) as a colour.
|
||
fn srgb32(c: [f32; 3]) -> Color32 {
|
||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
Color32::from_rgb(b(c[0]), b(c[1]), b(c[2]))
|
||
}
|
||
|
||
/// A Fill or Stroke swatch: its colour, a gradient (white to black), or None's red slash.
|
||
fn paint_swatch(p: &egui::Painter, r: Rect, paint: &ToolPaint, hovered: bool, t: &Tokens) {
|
||
let clip = p.with_clip_rect(r);
|
||
match paint.kind {
|
||
PaintKind::None => {
|
||
clip.rect_filled(r, 2.0, Color32::WHITE);
|
||
clip.line_segment([r.left_bottom(), r.right_top()], Stroke::new(1.5, Color32::from_rgb(0xe0, 0x30, 0x30)));
|
||
}
|
||
PaintKind::Solid => {
|
||
clip.rect_filled(r, 2.0, srgb32(paint.color));
|
||
}
|
||
PaintKind::Linear => {
|
||
let mut m = egui::Mesh::default();
|
||
for (q, c) in
|
||
[(r.left_top(), Color32::WHITE), (r.right_top(), Color32::BLACK), (r.right_bottom(), Color32::BLACK), (r.left_bottom(), Color32::WHITE)]
|
||
{
|
||
m.colored_vertex(q, c);
|
||
}
|
||
m.add_triangle(0, 1, 2);
|
||
m.add_triangle(0, 2, 3);
|
||
clip.add(m);
|
||
}
|
||
PaintKind::Radial => {
|
||
let rad = r.size().length() / 2.0;
|
||
for i in 0..8 {
|
||
let f = 1.0 - i as f32 / 8.0;
|
||
clip.circle_filled(r.center(), rad * f, Color32::from_gray((255.0 * (1.0 - f)) as u8));
|
||
}
|
||
}
|
||
}
|
||
p.rect_stroke(r, 2.0, Stroke::new(1.0, if hovered { t.text } else { t.field_border }), StrokeKind::Inside);
|
||
}
|
||
|
||
/// Puppet tool options: Mesh: Show, Expansion, Density (for new meshes and the selected
|
||
/// layer's meshes). Returns the next x.
|
||
fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
|
||
let t = app.tokens;
|
||
let o = app.session.state.puppet.clone();
|
||
p.text(pos2(x, cy), Align2::LEFT_CENTER, tr("Mesh:"), Tokens::ui(12.0), t.text_dim);
|
||
x += 40.0;
|
||
let show = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
|
||
if widgets::checkbox(ui, show, o.show_mesh, &t, egui::Id::new("puppet-show-mesh")).clicked() {
|
||
let _ = app.session.execute("puppet.mesh", json!({"showMesh": !o.show_mesh}));
|
||
}
|
||
app.auto.add("header.puppet.showMesh", show, "Show mesh");
|
||
p.text(pos2(show.max.x + 2.0, cy), Align2::LEFT_CENTER, tr("Show"), Tokens::ui(12.0), t.text_dim);
|
||
x = show.max.x + 44.0;
|
||
for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] {
|
||
p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
|
||
x += if key == "expansion" { 64.0 } else { 52.0 };
|
||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
|
||
app.auto.add(&format!("header.puppet.{key}"), r, label);
|
||
if let Some(nv) = nv {
|
||
let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")}));
|
||
}
|
||
x = r.max.x + 12.0;
|
||
}
|
||
// Record Options… (⌘/Ctrl-drag a pin records its motion in real time).
|
||
let label = "Record Options...";
|
||
let w = 104.0;
|
||
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(w, 20.0));
|
||
let resp = ui.interact(r, egui::Id::new("puppet-record-options"), egui::Sense::click()).on_hover_text(tr("⌘/Ctrl-drag a pin to record its motion"));
|
||
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
|
||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
|
||
app.auto.add("header.puppet.recordOptions", r, label);
|
||
if resp.clicked() {
|
||
let ctx = ui.ctx().clone();
|
||
if let Err(e) = crate::menus::invoke(app, &ctx, "puppet.recordOptions", json!({})) {
|
||
app.ui.status = e;
|
||
}
|
||
}
|
||
// Follow-Through… (select the leader pin, then Shift-click the pins that trail it).
|
||
let label = "Follow-Through...";
|
||
let r = Rect::from_min_size(pos2(r.max.x + 8.0, cy - 10.0), vec2(w, 20.0));
|
||
let resp = ui
|
||
.interact(r, egui::Id::new("puppet-follow"), egui::Sense::click())
|
||
.on_hover_text(tr("Select the leader pin, then Shift-click the pins that should trail it (hair, cloth, tails)"));
|
||
p.rect_stroke(r, 3.0, egui::Stroke::new(1.0, if resp.hovered() { t.accent } else { t.field_border }), egui::StrokeKind::Inside);
|
||
p.text(r.center(), Align2::CENTER_CENTER, label, Tokens::ui(11.5), t.text);
|
||
app.auto.add("header.puppet.follow", r, label);
|
||
if resp.clicked() {
|
||
let ctx = ui.ctx().clone();
|
||
if let Err(e) = crate::menus::invoke(app, &ctx, "puppet.follow", json!({})) {
|
||
app.ui.status = e;
|
||
}
|
||
}
|
||
r.max.x + 16.0
|
||
}
|
||
|
||
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||
for w in app.workspace_names() {
|
||
if ui.selectable_label(app.ui.workspace == w, &w).clicked() {
|
||
app.set_workspace(&w);
|
||
ui.close();
|
||
}
|
||
}
|
||
ui.separator();
|
||
if ui.button(tr("Reset to Saved Layout")).clicked() {
|
||
let n = app.ui.workspace.clone();
|
||
app.set_workspace(&n);
|
||
ui.close();
|
||
}
|
||
}
|
||
|
||
/// A small colour picker popup bound to an RGB value.
|
||
pub fn color_popup(ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, c: &mut [f32; 3]) -> bool {
|
||
let open_id = id.with("open");
|
||
if !ui.data(|d| d.get_temp::<bool>(open_id).unwrap_or(false)) {
|
||
return false;
|
||
}
|
||
let mut changed = false;
|
||
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos + vec2(0.0, 4.0)).show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||
let mut col = srgb32(*c);
|
||
if egui::color_picker::color_picker_color32(ui, &mut col, egui::color_picker::Alpha::Opaque) {
|
||
*c = [col.r() as f32 / 255.0, col.g() as f32 / 255.0, col.b() as f32 / 255.0];
|
||
changed = true;
|
||
}
|
||
});
|
||
});
|
||
if widgets::pressed_outside(ui.ctx(), &area.response) || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||
ui.data_mut(|d| d.insert_temp(open_id, false));
|
||
}
|
||
changed
|
||
}
|
||
|
||
/// The ArtCraft mark as supplied in `docs/brand` (the README's and getartcraft.com's logo; used
|
||
/// unmodified, see `docs/brand/LICENSE-brand.txt`).
|
||
static ARTCRAFT_MARK: &[u8] = include_bytes!("../../../docs/brand/artcraft-mark.png");
|
||
|
||
/// The ArtCraft mark in `r` (decoded once into a mipmapped texture, so it stays crisp from the
|
||
/// 24 px Tools bar to the About dialog). Nothing is drawn if it can't be decoded.
|
||
pub fn paint_logo(p: &egui::Painter, r: Rect) {
|
||
let ctx = p.ctx();
|
||
let id = egui::Id::new("artcraft-mark");
|
||
let tex = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(id)).unwrap_or_else(|| {
|
||
let tex = image::load_from_memory_with_format(ARTCRAFT_MARK, image::ImageFormat::Png).ok().map(|img| {
|
||
let img = img.to_rgba8();
|
||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
|
||
ctx.load_texture("artcraft-mark", ci, egui::TextureOptions::LINEAR.with_mipmap_mode(Some(egui::TextureFilter::Linear)))
|
||
});
|
||
ctx.data_mut(|d| d.insert_temp(id, tex.clone()));
|
||
tex
|
||
});
|
||
if let Some(t) = tex {
|
||
p.image(t.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) {
|
||
app.auto.begin_frame();
|
||
let mut out = ctx.run_ui(
|
||
egui::RawInput { time: Some(time), events, screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, 500.0))), ..Default::default() },
|
||
|ui| {
|
||
show(app, ui, Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, 40.0)));
|
||
},
|
||
);
|
||
out.textures_delta.clear();
|
||
}
|
||
|
||
#[test]
|
||
fn long_press_type_keeps_the_tool_until_vertical_is_selected() {
|
||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||
let ctx = egui::Context::default();
|
||
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
|
||
frame(&mut app, &ctx, 0.0, vec![]);
|
||
frame(&mut app, &ctx, 0.1, vec![]);
|
||
let center = |element: &crate::automation::Element| {
|
||
let [x, y, w, h] = element.rect;
|
||
egui::pos2(x + w / 2.0, y + h / 2.0)
|
||
};
|
||
let at = center(app.auto.elements.iter().find(|w| w.id == "tools.Type").unwrap());
|
||
let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||
frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]);
|
||
frame(&mut app, &ctx, 1.36, vec![]);
|
||
frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]);
|
||
frame(&mut app, &ctx, 1.45, vec![]);
|
||
assert_eq!(app.ui.tool, Tool::Selection);
|
||
let row = center(app.auto.elements.iter().find(|w| w.id == "tools.select.TypeVertical").unwrap());
|
||
frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]);
|
||
frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]);
|
||
assert_eq!(app.ui.tool, Tool::TypeVertical);
|
||
assert_eq!(app.ui.slot_tools[10], Tool::TypeVertical);
|
||
frame(&mut app, &ctx, 2.1, vec![]);
|
||
assert!(!app.auto.elements.iter().any(|w| w.id == "tools.select.TypeVertical"));
|
||
}
|
||
}
|