Files
effectcraft/crates/ui-egui/src/header.rs
T
Brandon Thomas 4047ea94ea Merge pull request #398 from FeiLiuEM/ui-i18n
i18n: translate the interface below the menu bar (panels, dialogs, tooltips)

# Conflicts:
#	crates/ui-egui/src/lib.rs
#	docs/preferences.md
2026-10-09 10:22:32 -04:00

632 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}