Files
figcraft/crates/ui-egui/src/dev_mode.rs
T
236f3185d6 Migrate FigCraft to egui 0.37 (#1312)
* E21: require release font coverage and stabilize capture deadline tests

* E21: separate capture, IME and field-save regression phases

* M0: correct the migration qualification reference

* M-egui037: document input lifecycle and UI regression contracts

* M0: correct egui 0.37 status docs and stabilize join-code regression

---------

Co-authored-by: Dylan M. Taylor <dylan@dylanmtaylor.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
2026-10-25 15:04:39 -04:00

1106 lines
53 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.
//! Dev Mode extras in the Inspect tab: annotations (notes with pinned properties and a category)
//! drawn as callouts in their category's colour on the canvas and edited in the panel, the
//! categories and the filter of the callouts, the handoff status of the selected frames (with the
//! focus view and Compare changes), the list of frames and sections that are ready for dev, the
//! focus view's bar, Connect to code (a component's link to its code component), dev resources
//! (links for developers), measurements kept on the canvas and the plugins that only read the
//! document.
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId};
use figcraft_engine::annotations::{self, PINNABLE, Pinnable};
use figcraft_engine::code_connect;
use figcraft_engine::codegen::{CodeLang, code_for};
use figcraft_engine::dev_measurements::{self, Placed};
use figcraft_engine::devinspect::{self, DevItem, Prop};
use figcraft_engine::plugins::Access;
use figcraft_engine::snap::Guide;
use figcraft_engine::{components, dev_resources};
use serde_json::json;
use crate::a11y::Tip;
use crate::i18n::{tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::right::{menu_check, menu_item, menu_item_with, menu_popup, prop_row, row};
use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold};
use crate::widgets::{self, DropLead, PAD};
use crate::{FigApp, RightTab};
/// Width of an annotation callout on the canvas.
const CALLOUT_W: f32 = 220.0;
/// Gap between a layer and its callouts.
const CALLOUT_GAP: f32 = 24.0;
/// The status filters of the ready-for-dev list.
const FILTERS: [(Option<DevStatus>, bool, &str); 4] = [
(Some(DevStatus::ReadyForDev), false, tr_noop("Ready for dev")),
(Some(DevStatus::ReadyForDev), true, tr_noop("Changed")),
(Some(DevStatus::Completed), false, tr_noop("Completed")),
(None, false, tr_noop("Any status")),
];
/// Which annotations the canvas shows.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum CategoryFilter {
#[default]
All,
Uncategorized,
/// One category, by id.
Only(String),
}
impl CategoryFilter {
/// From `all`, `none` or a category id or name.
pub fn parse(doc: &Document, s: &str) -> Option<Self> {
match s.trim() {
k if k.eq_ignore_ascii_case("all") => Some(Self::All),
k if k.eq_ignore_ascii_case("none") => Some(Self::Uncategorized),
k => annotations::find_category(annotations::categories(doc), k).map(|c| Self::Only(c.id.clone())),
}
}
pub fn id(&self) -> &str {
match self {
Self::All => "all",
Self::Uncategorized => "none",
Self::Only(id) => id,
}
}
fn shows(&self, doc: &Document, a: &Annotation) -> bool {
let cat = annotations::category_of(doc, a);
match self {
Self::All => true,
Self::Uncategorized => cat.is_none(),
Self::Only(id) => cat.is_some_and(|c| c.id == *id),
}
}
fn label(&self, doc: &Document) -> String {
match self {
Self::All => tr("All categories").into(),
Self::Uncategorized => tr("No category").into(),
Self::Only(id) => {
annotations::categories(doc).iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| tr("All categories").into())
}
}
}
}
/// Dev Mode UI state: the filters of the ready list and of the annotations, the compare view, and
/// what the canvas, the lists and the code show, recomputed only when the document (its
/// revision), the page or the choice change.
#[derive(Default)]
pub struct DevUi {
pub filter: usize,
pub categories: CategoryFilter,
pub compare: Option<crate::dev_compare::CompareView>,
annotated: Vec<NodeId>,
annotated_key: Option<(u64, u64, NodeId)>,
/// The measurements kept on the current page, with their lines.
measured: Vec<Placed>,
measured_key: Option<(u64, u64, NodeId)>,
ready: Vec<DevItem>,
ready_key: Option<(u64, u64, usize)>,
/// Every ready-for-dev frame (the focus view's previous / next).
focus_list: Vec<NodeId>,
focus_key: Option<(u64, u64)>,
code: Option<((u64, u64, NodeId, CodeLang), String)>,
playground: Playground,
}
/// The component playground of the Inspect tab: the layer it is for, the property values tried
/// there, and what they gave (for the document, layer, values and language it was made from).
#[derive(Default)]
struct Playground {
target: Option<NodeId>,
values: serde_json::Map<String, serde_json::Value>,
shown: Option<(PlayKey, Played)>,
}
type PlayKey = (u64, u64, NodeId, String, CodeLang);
struct Played {
preview: Option<egui::TextureHandle>,
properties: Vec<serde_json::Value>,
code: String,
}
/// Whether Dev Mode extras show: the Inspect tab is open and nothing is presented.
fn active(app: &FigApp) -> bool {
app.ui.right_tab == RightTab::Inspect && app.ui.present.is_none()
}
// ------------------------------------------------------------------ canvas
/// The annotations on the current page as callouts beside their layers (Inspect tab only).
pub fn draw(app: &mut FigApp, painter: &egui::Painter) {
if !active(app) {
return;
}
let Ok(ds) = app.session.ds() else { return };
let key = (ds.uid, ds.revision, ds.page);
if app.ui.dev.annotated_key != Some(key) {
app.ui.dev.annotated = annotations::on_page(&ds.doc, ds.page);
app.ui.dev.annotated_key = Some(key);
}
// A filter on a category the file no longer has shows everything again.
if let CategoryFilter::Only(id) = &app.ui.dev.categories
&& !annotations::categories(&ds.doc).iter().any(|c| c.id == *id)
{
app.ui.dev.categories = CategoryFilter::All;
}
let t = Tokens::get(painter.ctx());
let clip = painter.clip_rect();
let focus = ds.focused();
let filter = &app.ui.dev.categories;
// Callouts already drawn: a later one that would overlap moves down below them.
let mut placed: Vec<Rect> = Vec::new();
for id in &app.ui.dev.annotated {
// The focus view shows only its frame's.
if focus.is_some_and(|f| !ds.doc.is_ancestor(f, *id)) {
continue;
}
let Some(n) = ds.doc.node(*id).filter(|n| n.visible) else { continue };
let Some(b) = ds.doc.world_bounds(*id) else { continue };
let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1));
let x = r.max.x + CALLOUT_GAP;
if x > clip.max.x || x + CALLOUT_W < clip.min.x || r.min.y > clip.max.y {
continue;
}
let selected = ds.selection.contains(id);
let anchor = pos2(r.max.x, r.min.y);
let mut y = r.min.y;
let mut first = true;
for a in n.annotations.iter().filter(|a| filter.shows(&ds.doc, a)) {
if y > clip.max.y {
break;
}
let pinned = annotations::pinned(&ds.doc, *id, a);
let category = annotations::category_of(&ds.doc, a);
let color = category.map_or(ACCENT, |c| doc_c32(painter.ctx(), c.color));
let card = Callout::new(painter, &t, a, category, &pinned);
let mut rect = Rect::from_min_size(pos2(x, y), vec2(CALLOUT_W, card.height));
while let Some(below) = placed.iter().filter(|p| p.intersects(rect.expand(3.0))).map(|p| p.max.y).reduce(f32::max) {
rect = rect.translate(vec2(0.0, below + 6.0 - rect.min.y));
}
if first {
painter.line_segment([anchor, pos2(rect.min.x, rect.min.y + 14.0)], Stroke::new(1.0, color));
painter.circle_filled(anchor, 3.5, color);
first = false;
}
card.paint(painter, &t, rect, color, &pinned, selected);
placed.push(rect);
y = rect.max.y + 6.0;
}
}
}
/// Bring the current page's measurements up to date (when the document, its revision or the page
/// changed).
pub fn refresh_measurements(app: &mut FigApp) {
let Ok(ds) = app.session.ds() else { return };
let key = (ds.uid, ds.revision, ds.page);
if app.ui.dev.measured_key != Some(key) {
app.ui.dev.measured = dev_measurements::on_page(&ds.doc, ds.page);
app.ui.dev.measured_key = Some(key);
}
}
/// The lines of the measurements kept on the canvas, drawn with the hover measurements (Inspect
/// tab only; both layers shown; the focus view's frame only).
pub fn saved_lines(app: &FigApp) -> impl Iterator<Item = &Guide> {
let ds = app.session.ds().ok().filter(|_| active(app));
let focus = ds.and_then(figcraft_engine::DocState::focused);
app.ui
.dev
.measured
.iter()
.filter(move |m| {
ds.is_some_and(|ds| {
let shown = |id: NodeId| ds.doc.node(id).is_some_and(|n| n.visible);
shown(m.from) && m.measurement.to.is_none_or(shown) && focus.is_none_or(|f| ds.doc.is_ancestor(f, m.from))
})
})
.flat_map(|m| &m.lines)
}
/// One annotation as a card: its category, its note (up to six lines) and its pinned properties
/// with their values.
struct Callout {
category: Option<std::sync::Arc<egui::Galley>>,
note: Option<std::sync::Arc<egui::Galley>>,
height: f32,
}
/// Height of a callout's category line.
const CATEGORY_H: f32 = 18.0;
impl Callout {
fn new(painter: &egui::Painter, t: &Tokens, a: &Annotation, category: Option<&AnnotationCategory>, pinned: &[(&Pinnable, Option<Prop>)]) -> Self {
let note = (!a.label.trim().is_empty()).then(|| {
let mut job = egui::text::LayoutJob::simple(a.label.clone(), regular(11.0), t.text, CALLOUT_W - 22.0);
job.wrap.max_rows = 6;
painter.layout_job(job)
});
let category = category.map(|c| painter.layout_no_wrap(c.name.clone(), semibold(9.0), Color32::WHITE));
let note_h = note.as_ref().map_or(0.0, |g| g.size().y);
let gap = if note.is_some() && !pinned.is_empty() { 6.0 } else { 0.0 };
let cat_h = if category.is_some() { CATEGORY_H } else { 0.0 };
Callout { category, note, height: cat_h + note_h + gap + pinned.len() as f32 * 18.0 + 16.0 }
}
fn paint(self, painter: &egui::Painter, t: &Tokens, rect: Rect, color: Color32, pinned: &[(&Pinnable, Option<Prop>)], selected: bool) {
painter.add(egui::epaint::Shadow { offset: [0, 2], blur: 8, spread: 0, color: t.shadow }.as_shape(rect, CornerRadius::same(6)));
painter.rect_filled(rect, CornerRadius::same(6), t.panel);
painter.rect_stroke(rect, CornerRadius::same(6), Stroke::new(1.0, if selected { ACCENT } else { t.border_strong }), StrokeKind::Inside);
painter.rect_filled(Rect::from_min_size(rect.min + vec2(0.0, 6.0), vec2(3.0, rect.height() - 12.0)), CornerRadius::same(2), color);
let x = rect.min.x + 12.0;
let mut y = rect.min.y + 8.0;
// The category as a pill in its colour.
if let Some(g) = self.category {
let w = g.size().x.min(CALLOUT_W - 30.0);
let pill = Rect::from_min_size(pos2(x, y - 1.0), vec2(w + 10.0, 14.0));
painter.rect_filled(pill, CornerRadius::same(4), color);
painter.with_clip_rect(pill.shrink2(vec2(5.0, 0.0))).galley(pill.center() - g.size() / 2.0, g, Color32::WHITE);
y += CATEGORY_H;
}
if let Some(g) = self.note {
let top = y;
y += g.size().y + if pinned.is_empty() { 0.0 } else { 6.0 };
painter.galley(pos2(x, top), g, t.text);
}
for (p, v) in pinned {
let mid = y + 9.0;
painter.text(pos2(x, mid), Align2::LEFT_CENTER, tr(p.label), regular(10.0), t.text_secondary);
let mut vx = x + 84.0;
if let Some(c) = v.as_ref().and_then(|v| v.color.as_deref()).and_then(figcraft_color::Color::from_hex) {
widgets::paint_swatch(painter, Rect::from_center_size(pos2(vx + 5.0, mid), vec2(10.0, 10.0)), &figcraft_color::Paint::solid(c));
vx += 15.0;
}
// The style or variable reads better than its value.
let value = v.as_ref().map(|v| v.token.clone().unwrap_or_else(|| v.value.clone())).unwrap_or_else(|| "—".into());
let value_r = Rect::from_min_max(pos2(vx, y), pos2(rect.max.x - 8.0, y + 18.0));
painter.with_clip_rect(value_r).text(pos2(vx, mid), Align2::LEFT_CENTER, value, regular(10.0), t.text);
y += 18.0;
}
}
}
// ------------------------------------------------------------------ Inspect panel
/// Where the template being edited in Connect to code keeps its draft.
fn template_id(owner: NodeId, lang: CodeLang) -> Id {
Id::unique(("code_connect_template", owner, lang.id()))
}
/// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a
/// component set's code compares every variant). While a connected template is being edited, its
/// draft's snippet (the live preview).
pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String {
let Ok(ds) = app.session.ds() else { return String::new() };
// The draft lives on after the field loses focus to another layer: only a focused one counts.
if let Some(owner) = code_connect::owner(&ds.doc, id)
&& ui.memory(|m| m.has_focus(template_id(owner, lang).with("te")))
&& let Some(template) = ui.data(|d| d.get_temp::<String>(template_id(owner, lang)))
&& let Some(link) = code_connect::link(&ds.doc, id, lang)
{
let draft = CodeLink { template, ..link.clone() };
return code_connect::connected_code(&ds.doc, id, lang, &draft).unwrap_or_default();
}
let key = (ds.uid, ds.revision, id, lang);
match &app.ui.dev.code {
Some((k, c)) if *k == key => c.clone(),
_ => {
let c = code_for(&ds.doc, id, lang);
app.ui.dev.code = Some((key, c.clone()));
c
}
}
}
/// The component playground, for a selected instance, component or component set: a preview of the
/// component and a control for each variant, boolean, text and instance-swap property. The values
/// tried here change the preview and the code only (returned when any is set: the Code section
/// shows it), never the document.
pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option<String> {
figcraft_engine::playground::component_of(doc, id)?;
let t = Tokens::get(ui.ctx());
let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None };
let comps = crate::component_panel::local_components(app, ui, doc);
let play = &mut app.ui.dev.playground;
if play.target != Some(id) {
*play = Playground { target: Some(id), ..Playground::default() };
}
let key: PlayKey = (uid, revision, id, serde_json::Value::Object(play.values.clone()).to_string(), lang);
if play.shown.as_ref().is_none_or(|(k, _)| *k != key) {
// Values the component no longer takes (a property renamed or removed) are dropped.
let built = figcraft_engine::playground::build(doc, id, &play.values).or_else(|_| {
play.values.clear();
figcraft_engine::playground::build(doc, id, &play.values)
});
let played = built.ok().map(|(copy, inst)| Played {
preview: crate::assets_panel::thumbnail(ui.ctx(), &copy, inst, 416.0),
properties: figcraft_engine::playground::properties(&copy, inst)["properties"].as_array().cloned().unwrap_or_default(),
code: code_for(&copy, inst, lang),
});
let key = (uid, revision, id, serde_json::Value::Object(play.values.clone()).to_string(), lang);
play.shown = played.map(|p| (key, p));
}
let Some((_, shown)) = &play.shown else { return None };
let tried = !play.values.is_empty();
widgets::separator(ui);
let buttons = [(Icon::Rotate, tr("Reset"))];
let reset = widgets::section_header(ui, tr("Playground"), if tried { &buttons } else { &[] }, false).is_some();
// The preview, fitted into its box with some room around it.
egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(6)).outer_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 136.0), Sense::hover());
if let Some(tex) = &shown.preview {
let size = tex.size_vec2();
let k = ((r.width() - 16.0) / size.x).min((r.height() - 16.0) / size.y).clamp(0.0, 1.0);
let image = Rect::from_center_size(r.center(), size * k);
ui.painter().image(tex.id(), image, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
});
ui.add_space(4.0);
let mut set: Option<(String, serde_json::Value)> = None;
for p in &shown.properties {
let (Some(name), Some(kind)) = (p["name"].as_str(), p["type"].as_str()) else { continue };
let value = &p["value"];
match kind {
"variant" => row(ui, 32.0, |ui| {
widgets::label(ui, name, t.text_secondary);
let resp = widgets::dropdown(ui, value.as_str().unwrap_or(""), ui.available_width().max(60.0), None);
crate::a11y::combo(&resp, name, value.as_str().unwrap_or(""), crate::a11y::menu_open(&resp));
menu_popup(&resp).show(|ui| {
for o in p["options"].as_array().into_iter().flatten().filter_map(|o| o.as_str()) {
if crate::right::menu_item(ui, o, "") {
set = Some((name.to_string(), json!(o)));
ui.close();
}
}
});
}),
"boolean" => row(ui, 28.0, |ui| {
let on = value.as_bool().unwrap_or(false);
if widgets::checkbox(ui, on, name) {
set = Some((name.to_string(), json!(!on)));
}
}),
"text" => row(ui, 32.0, |ui| {
widgets::label(ui, name, t.text_secondary);
let cur = value.as_str().unwrap_or("");
let field = Id::unique(("playground_text", id, name));
if let Some(new) = widgets::text_field_named(ui, field, cur, ui.available_width().max(60.0), name)
&& new != cur
{
set = Some((name.to_string(), json!(new.chars().take(10_000).collect::<String>())));
}
}),
"instanceSwap" => row(ui, 32.0, |ui| {
widgets::label(ui, name, t.text_secondary);
let preferred: Vec<NodeId> = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
let w = ui.available_width().max(60.0);
if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1
{
set = Some((name.to_string(), json!(c.0)));
}
}),
// Slots show as the component has them.
_ => {}
}
}
widgets::note(ui, tr("What you try here changes this preview and the code below, not the file"), t.text_secondary);
let code = tried.then(|| shown.code.clone());
let play = &mut app.ui.dev.playground;
if reset {
play.values.clear();
}
if let Some((k, v)) = set {
play.values.insert(k, v);
}
code
}
/// Connect to code for the selected component, set or instance (its main), in the Code section's
/// language: a button to connect, then a Connected badge, the import path, name and template (the
/// code above shows the snippet, live while the template is edited).
pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) {
let Some(owner) = code_connect::owner(doc, id) else { return };
let t = Tokens::get(ui.ctx());
let link = code_connect::link(doc, id, lang).cloned();
let connectable = code_connect::language(lang.id()).is_some();
widgets::separator(ui);
let buttons: &[(Icon, &str)] = match (&link, connectable) {
(Some(_), _) => &[(Icon::Minus, tr("Disconnect from code"))],
(None, true) => &[(Icon::Plus, tr("Connect to code"))],
_ => &[],
};
let clicked = widgets::section_header(ui, tr("Connect to code"), buttons, link.is_none()).is_some();
let params = json!({"id": owner, "language": lang.id()});
let Some(link) = link else {
if clicked {
app.run("codeConnect.set", params);
}
let text = if connectable {
tr("Link this component to its code component: instances then show your real snippet with their values.")
} else {
tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
};
widgets::note(ui, text, t.text_secondary);
return;
};
if clicked {
app.run("codeConnect.remove", params);
return;
}
// The badge, and what the snippet comes from.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
let g = ui.painter().layout_no_wrap(tr("Connected").to_string(), semibold(10.0), Color32::WHITE);
let pill = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - g.size().y / 2.0 - 2.0), g.size() + vec2(10.0, 4.0));
ui.painter().rect_filled(pill, CornerRadius::same(4), t.success);
ui.painter().galley(pill.min + vec2(5.0, 2.0), g, Color32::WHITE);
let from =
if link.import_path.is_empty() { link.name.clone() } else { trf("{name} from {path}", &[("name", &link.name), ("path", &link.import_path)]) };
crate::a11y::status(&resp, tr("Connected"), &trf("Connected to code: {from}", &[("from", &from)]));
let from_x = pill.max.x + 8.0;
let from = widgets::elide(ui, &from, r.max.x - PAD - from_x);
ui.painter().text(pos2(from_x, r.center().y), Align2::LEFT_CENTER, from, regular(11.0), t.text_secondary);
let mut edit: Option<(&str, String)> = None;
widgets::sublabels(ui, tr("Import path"), Some(tr("Name")));
let col = widgets::col_width(ui.available_width());
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) {
edit = Some(("importPath", v));
}
if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) {
edit = Some(("name", v));
}
});
widgets::sublabels(ui, tr("Template"), None);
if let Some(v) = widgets::multiline_field(ui, template_id(owner, lang), &link.template, "<Button size={size}>{label}</Button>", 3) {
edit = Some(("template", v));
}
widgets::note(ui, tr("{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."), t.text_secondary);
if let Some((key, value)) = edit {
let mut params = params;
params[key] = json!(value);
if let Some(out) = app.run("codeConnect.set", params)
&& let Some(names) = out["unknown"].as_array().filter(|u| !u.is_empty())
{
let names: Vec<&str> = names.iter().filter_map(serde_json::Value::as_str).collect();
app.toast(trf("Not properties, kept as written: {names}", &[("names", &names.join(", "))]));
}
}
ui.add_space(4.0);
}
/// Dev Mode plugins: the installed plugins that only read the document, run from the Inspect tab.
pub fn plugins_section(app: &mut FigApp, ui: &mut Ui) {
let runnable: Vec<(String, String, String)> = app
.session
.prefs
.plugins
.iter()
.filter(|p| p.manifest.permissions.access == Access::Read)
.flat_map(|p| {
let m = &p.manifest;
m.commands.iter().map(move |c| {
let label = if m.commands.len() > 1 { format!("{} · {}", m.name, c.label) } else { m.name.clone() };
(m.id.clone(), c.id.clone(), label)
})
})
.collect();
if runnable.is_empty() {
return;
}
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
widgets::section_header(ui, tr("Plugins"), &[], false);
let mut run = None;
for (id, command, label) in &runnable {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp = resp.tip(trf("Run {label}", &[("label", label)]));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Actions, t.icon);
let name = widgets::elide(ui, label, r.width() - PAD * 2.0 - 20.0);
ui.painter().text(pos2(r.min.x + PAD + 20.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.0), t.text);
if resp.clicked() {
run = Some((id.clone(), command.clone()));
}
}
if let Some((id, command)) = run {
crate::plugins_ui::start(app, &id, &command);
}
ui.add_space(4.0);
}
/// The handoff status of the selected frames or sections (nothing for other layers).
pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if sel.is_empty() || !sel.iter().all(|i| crate::section_panel::has_status(doc, *i)) {
return;
}
widgets::separator(ui);
widgets::sublabels(ui, tr("Status"), None);
let w = ui.available_width() - PAD * 2.0;
row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w));
if let [id] = sel {
let changed = doc.node(*id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed);
let ds = app.session.ds().ok();
let focused = ds.and_then(figcraft_engine::DocState::focused) == Some(*id);
// Unchanged since it was marked (or not ready for dev): compared with the newest saved version.
let newest = ds.and_then(|d| d.versions.last()).map(|v| v.id.clone()).filter(|_| !changed);
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if !focused && widgets::button(ui, tr("Focus view"), false, true).on_hover_text(tr("Show only this on the canvas")).clicked() {
app.run("dev.focus", json!({"id": id}));
}
let tip = if changed { tr("What changed since it was marked ready for dev") } else { tr("What changed since a saved version") };
if (changed || newest.is_some()) && widgets::button(ui, tr("Compare changes"), changed, true).on_hover_text(tip).clicked() {
app.run("dev.compare", json!({"id": id, "version": newest, "show": true}));
}
});
}
ui.add_space(4.0);
}
/// The selected layer's annotations: each note editable in place with its pinned properties (click
/// to copy), a menu to pin more, and a field to add one.
pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) {
let t = Tokens::get(ui.ctx());
let Some(n) = doc.node(id) else { return };
let sections = devinspect::properties(doc, id);
widgets::separator(ui);
widgets::section_header(ui, tr("Annotations"), &[], n.annotations.is_empty());
for (k, a) in n.annotations.iter().enumerate() {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().text(
pos2(r.min.x + PAD, r.center().y),
Align2::LEFT_CENTER,
trf("Note {n}", &[("n", &(k + 1))]),
semibold(10.0),
t.text_secondary,
);
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
if widgets::icon_button_at(ui, Id::unique(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) {
app.run("annotation.remove", json!({"id": id, "index": k}));
}
if let Some(cat) = category_menu(ui, doc, Id::unique(("annot_cat", id, k)), a) {
app.run("annotation.edit", json!({"id": id, "index": k, "category": cat}));
}
if let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) {
if label.trim().is_empty() && a.properties.is_empty() {
app.run("annotation.remove", json!({"id": id, "index": k}));
} else {
app.run("annotation.edit", json!({"id": id, "index": k, "label": label}));
}
}
ui.add_space(2.0);
for (p, v) in annotations::pinned(doc, id, a) {
prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None }));
}
if let Some(p) = pin_menu(ui, Id::unique(("annot_pin", id, k)), &sections, &a.properties) {
let mut props = a.properties.clone();
match props.iter().position(|q| q == p.id) {
Some(i) => {
props.remove(i);
}
None => props.push(p.id.to_string()),
}
if props.is_empty() && a.label.trim().is_empty() {
app.run("annotation.remove", json!({"id": id, "index": k}));
} else {
app.run("annotation.edit", json!({"id": id, "index": k, "properties": props}));
}
}
ui.add_space(6.0);
}
// A new annotation starts as a note; properties are pinned to it afterwards.
if n.annotations.len() < annotations::MAX_PER_LAYER
&& let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1)
&& !label.trim().is_empty()
{
app.run("annotation.add", json!({"id": id, "label": label}));
}
ui.add_space(4.0);
}
/// The selected layer's dev resources: links for developers, opened in the browser when clicked.
/// Its own can be renamed, changed and removed; a main component's (and its set's) show on its
/// instances, read-only, with where they come from.
pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) {
let t = Tokens::get(ui.ctx());
let Some(n) = doc.node(id).filter(|n| !n.is_page()) else { return };
let list = dev_resources::list(doc, id);
let can_add = !components::inside_instance(doc, id) && n.dev_resources.len() < dev_resources::MAX_PER_LAYER;
let (adding_id, editing_id) = (Id::unique(("devres_adding", id)), Id::unique(("devres_editing", id)));
let mut adding = can_add && ui.data(|d| d.get_temp::<bool>(adding_id)).unwrap_or(false);
let mut editing = ui.data(|d| d.get_temp::<usize>(editing_id)).filter(|i| *i < n.dev_resources.len());
widgets::separator(ui);
let buttons: &[(Icon, &str)] = if can_add { &[(Icon::Plus, tr("Add dev resource"))] } else { &[] };
if widgets::section_header(ui, tr("Dev resources"), buttons, list.is_empty() && !adding).is_some() {
adding = !adding;
}
for item in &list {
let (name, url) = (&item.resource.name, &item.resource.url);
if let Some(i) = item.index.filter(|i| editing == Some(*i)) {
widgets::sublabels(ui, tr("Name"), Some(tr("Link")));
let col = widgets::col_width(ui.available_width());
let mut edit = None;
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if let Some(v) = widgets::text_field(ui, Id::unique(("devres_name", id, i)), name, col, tr("Name")) {
edit = Some(json!({"id": id, "index": i, "name": v}));
}
if let Some(v) = widgets::text_field(ui, Id::unique(("devres_url", id, i)), url, col, "https://…") {
edit = Some(json!({"id": id, "index": i, "url": v}));
}
if widgets::icon_button(ui, Icon::Check, tr("Done")) {
editing = None;
}
});
if let Some(p) = edit {
app.run("devResource.edit", p);
}
continue;
}
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
let buttons_w = if item.index.is_some() { 52.0 } else { 0.0 };
let text_w = r.width() - PAD * 2.0 - 22.0 - buttons_w;
let hovered = resp.hovered();
if hovered {
ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary);
let x = r.min.x + PAD + 22.0;
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text);
let below = match item.from.and_then(|f| doc.node(f)) {
Some(m) => trf("From {name}", &[("name", &m.name)]),
None => url.clone(),
};
ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &below, text_w), regular(10.0), t.text_secondary);
crate::a11y::link(&resp, name);
// Opened only on a click, never fetched, and a dev resource's URL comes from the
// document, so only a web link opens.
if resp.on_hover_text(url.as_str()).clicked() && figcraft_engine::doc_urls::openable(url) {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
if let Some(i) = item.index {
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
let pen = del.translate(vec2(-26.0, 0.0));
if widgets::icon_button_at(ui, Id::unique(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) {
editing = Some(i);
}
if widgets::icon_button_at(ui, Id::unique(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) {
app.run("devResource.remove", json!({"id": id, "index": i}));
editing = None;
}
}
}
if adding {
let w = ui.available_width() - PAD * 2.0;
let mut added = None;
row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::unique(("devres_new", id)), "", w, tr("Paste a link (https://…)")));
if let Some(url) = added.filter(|u| !u.trim().is_empty())
&& app.run("devResource.add", json!({"id": id, "url": url})).is_some()
{
adding = false;
}
} else if list.is_empty() && can_add {
widgets::note(ui, tr("Link docs, code or tickets for developers."), t.text_secondary);
}
ui.data_mut(|d| {
d.insert_temp(adding_id, adding);
match editing {
Some(i) => {
d.insert_temp(editing_id, i);
}
None => d.remove::<usize>(editing_id),
}
});
ui.add_space(4.0);
}
/// The menu label of measuring along `side`.
fn side_label(side: figcraft_doc::Side) -> &'static str {
use figcraft_doc::Side;
match side {
Side::Top => tr("Along the top"),
Side::Right => tr("Along the right"),
Side::Bottom => tr("Along the bottom"),
Side::Left | Side::Auto => tr("Along the left"),
}
}
/// Measurements kept on the canvas: those from or to the selected layer (with nothing selected,
/// every one on the page), each with its distances; a click selects the layer it starts from and
/// − removes it. With a layer selected, a menu places a new one: between two selected layers, to
/// the layer around it or along one of its sides.
pub fn measurements_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
use figcraft_doc::Side;
refresh_measurements(app);
let t = Tokens::get(ui.ctx());
let first = sel.first().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_page()));
let rows: Vec<&Placed> = app.ui.dev.measured.iter().filter(|m| first.is_none_or(|id| m.touches(id))).collect();
if first.is_none() && rows.is_empty() {
return;
}
let mut action: Option<(&str, serde_json::Value)> = None;
widgets::separator(ui);
widgets::section_header(ui, tr("Measurements"), &[], rows.is_empty());
for m in &rows {
if let Some(a) = measurement_row(ui, &t, doc, m) {
action = Some(a);
}
}
if let Some(id) = first {
let around = doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| !p.is_page());
let w = ui.available_width() - PAD * 2.0;
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, tr("Add measurement"), w, Some(Icon::Measure))
.on_hover_text(tr("Keep a measurement on the canvas for developers"));
menu_popup(&resp).show(|ui| {
if let [a, b, ..] = sel
&& menu_item(ui, tr("Between the selected layers"), "")
{
action = Some(("devMode.addMeasurement", json!({"from": a, "to": b})));
}
if around && menu_item(ui, tr("To the layer around it"), "") {
action = Some(("devMode.addMeasurement", json!({"from": id})));
}
for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] {
if menu_item(ui, side_label(side), "") {
action = Some(("devMode.addMeasurement", json!({"from": id, "side": side})));
}
}
});
});
}
if let Some((cmd, params)) = action {
app.run(cmd, params);
}
ui.add_space(4.0);
}
/// One measurement in the Inspect tab, on two lines: what it measures, and its distances below. A
/// click selects the layer it starts from, − removes it: the command to run.
fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Option<(&'static str, serde_json::Value)> {
let name = |id: NodeId| doc.node(id).map_or(String::new(), |n| n.name.clone());
let mut action = None;
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Measure, t.icon_secondary);
let what = match (m.measurement.to, m.measurement.side) {
(Some(to), _) => format!("{} → {}", name(m.from), name(to)),
(None, side) => format!("{} · {}", name(m.from), side.map_or("", side_label)),
};
let value = m.labels().join(" · ");
let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0));
let x = r.min.x + PAD + 22.0;
let text_w = del.min.x - 6.0 - x;
ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &what, text_w), regular(11.0), t.text);
ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &value, text_w), regular(10.0), t.text_secondary);
crate::a11y::button(&resp, &format!("{what}: {value}"));
if resp.clicked() {
action = Some(("select.set", json!({"ids": [m.from]})));
}
if widgets::icon_button_at(ui, Id::unique(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) {
action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index})));
}
action
}
/// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none).
fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option<serde_json::Value> {
let current = annotations::category_of(doc, a);
let mut out = None;
let w = ui.available_width() - PAD * 2.0;
row(ui, 30.0, |ui| {
let lead = current.map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color)));
let label = current.map_or(tr("No category"), |c| c.name.as_str());
let resp = ui.push_id(id, |ui| widgets::dropdown_lead(ui, label, w, lead)).inner;
menu_popup(&resp).show(|ui| {
if menu_check(ui, current.is_none(), tr("No category"), "") {
out = Some(serde_json::Value::Null);
ui.close();
}
for c in annotations::categories(doc) {
if category_item(ui, c, current.is_some_and(|x| x.id == c.id)) {
out = Some(json!(c.id));
ui.close();
}
}
});
});
out
}
/// A menu row for category `c` with its colour dot; true when clicked.
fn category_item(ui: &mut Ui, c: &AnnotationCategory, on: bool) -> bool {
let col = doc_c32(ui.ctx(), c.color);
let label = format!("{}{}", if on { "✓ " } else { " " }, c.name);
menu_item_with(ui, &label, "", 14.0, |p, r, _| {
p.circle_filled(pos2(r.max.x - 7.0, r.center().y), 4.5, col);
})
}
/// With nothing selected: which annotations the canvas shows, and the file's categories (rename,
/// recolour, delete, add).
pub fn categories_section(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(doc) = app.session.doc().cloned() else { return };
widgets::separator(ui);
let add = widgets::section_header(ui, tr("Annotations"), &[(Icon::Plus, tr("Add a category"))], false).is_some();
let w = ui.available_width() - PAD * 2.0;
let filter = app.ui.dev.categories.clone();
row(ui, 32.0, |ui| {
let lead = match &filter {
CategoryFilter::Only(id) => annotations::categories(&doc).iter().find(|c| c.id == *id).map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color))),
_ => Some(DropLead::Icon(Icon::Comment)),
};
let resp = widgets::dropdown_lead(ui, &filter.label(&doc), w, lead).on_hover_text(tr("Annotations shown on the canvas"));
menu_popup(&resp).show(|ui| {
for (f, label) in [(CategoryFilter::All, tr("All categories")), (CategoryFilter::Uncategorized, tr("No category"))] {
if menu_check(ui, filter == f, label, "") {
app.ui.dev.categories = f;
ui.close();
}
}
for c in annotations::categories(&doc) {
if category_item(ui, c, filter == CategoryFilter::Only(c.id.clone())) {
app.ui.dev.categories = CategoryFilter::Only(c.id.clone());
ui.close();
}
}
});
});
widgets::sublabels(ui, tr("Categories"), None);
let mut action: Option<(&str, serde_json::Value)> = None;
for c in annotations::categories(&doc) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
// The colour: click for the palette.
let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0));
let sw_resp = ui.interact(sw, Id::unique(("cat_color", &c.id)), Sense::click()).tip(tr("Colour"));
crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex());
ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color));
if sw_resp.hovered() {
ui.painter().rect_stroke(sw.expand(2.0), CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
}
egui::Popup::menu(&sw_resp).style(crate::right::menu_style).show(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for col in AnnotationCategory::COLORS {
let (cr, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
ui.painter().rect_filled(cr, CornerRadius::same(4), doc_c32(ui.ctx(), col));
if col.to_hex() == c.color.to_hex() {
ui.painter().rect_stroke(cr.expand(2.0), CornerRadius::same(5), Stroke::new(1.5, ACCENT), StrokeKind::Inside);
}
if resp.clicked() {
action = Some(("annotation.editCategory", json!({"id": c.id, "color": col.to_css_hex()})));
ui.close();
}
}
});
});
let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0));
if let Some(name) = widgets::text_field_inline(ui, Id::unique(("cat_name", &c.id)), &c.name, name_r, regular(11.0))
&& name.trim() != c.name
&& !name.trim().is_empty()
{
action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()})));
}
let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0));
if widgets::icon_button_at(ui, Id::unique(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) {
action = Some(("annotation.removeCategory", json!({"id": c.id})));
}
}
if add {
let list = annotations::categories(&doc);
let name = (1..=list.len() + 1).map(|k| format!("Category {k}")).find(|n| annotations::find_category(list, n).is_none()).unwrap_or_default();
action = Some(("annotation.addCategory", json!({"name": name})));
}
if let Some((cmd, params)) = action {
app.run(cmd, params);
}
ui.add_space(4.0);
}
/// "Pin a property": a menu of the properties the layer has (those already `pinned` checked);
/// returns the one picked.
fn pin_menu(ui: &mut Ui, id: Id, sections: &[devinspect::Section], pinned: &[String]) -> Option<&'static Pinnable> {
let mut out = None;
let w = ui.available_width() - PAD * 2.0;
row(ui, 30.0, |ui| {
let resp = ui.push_id(id, |ui| widgets::dropdown(ui, tr("Pin a property"), w, Some(Icon::Plus))).inner;
menu_popup(&resp).show(|ui| {
for p in PINNABLE {
let on = pinned.iter().any(|q| q == p.id);
if (on || annotations::applies(sections, p)) && menu_check(ui, on, tr(p.label), "") {
out = Some(p);
ui.close();
}
}
});
});
out
}
/// The frames and sections with a handoff status, filtered by status, across every page: click one
/// to go to it.
pub fn ready_list(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(ds) = app.session.ds() else { return };
let filter = app.ui.dev.filter.min(FILTERS.len() - 1);
let key = (ds.uid, ds.revision, filter);
if app.ui.dev.ready_key != Some(key) {
let (status, changed_only) = FILTERS.get(filter).map_or((None, false), |f| (f.0, f.1));
app.ui.dev.ready = devinspect::dev_items(&ds.doc, status, None);
app.ui.dev.ready.retain(|i| i.changed || !changed_only);
app.ui.dev.ready_key = Some(key);
}
let doc = ds.doc.clone();
widgets::separator(ui);
widgets::section_header(ui, tr("Ready for dev"), &[], app.ui.dev.ready.is_empty());
let w = ui.available_width() - PAD * 2.0;
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.2)), w, Some(Icon::Code));
menu_popup(&resp).show(|ui| {
for (i, (_, _, label)) in FILTERS.iter().enumerate() {
if menu_check(ui, i == filter, tr(label), "") {
app.ui.dev.filter = i;
ui.close();
}
}
});
});
if app.ui.dev.ready.is_empty() {
widgets::note(ui, tr("Nothing here yet. Set a frame's status to list it."), t.text_secondary);
return;
}
let (mut go, mut focus) = (None, None);
for item in &app.ui.dev.ready {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp = resp.tip(trf("Go to {name}", &[("name", &item.name)]));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
// The status colours the canvas badges use; a changed one also says so after its name.
let n = doc.node(item.id);
let dot = n.and_then(crate::canvas::status_label).map_or(t.text_tertiary, |(_, c)| c);
ui.painter().circle_filled(pos2(r.min.x + PAD + 4.0, r.center().y), 4.0, dot);
let page = doc.node(item.page).map(|p| p.name.clone()).unwrap_or_default();
let page_w = ui.painter().layout_no_wrap(page.clone(), regular(10.0), t.text_secondary).size().x.min(90.0);
let page_r = Rect::from_min_max(pos2(r.max.x - PAD - page_w, r.min.y), pos2(r.max.x - PAD, r.max.y));
ui.painter().with_clip_rect(page_r).text(page_r.right_center(), Align2::RIGHT_CENTER, page, regular(10.0), t.text_secondary);
let name_r = Rect::from_min_max(pos2(r.min.x + PAD + 14.0, r.min.y), pos2(page_r.min.x - 8.0, r.max.y));
// Room for the "Changed" badge (about 60 points) after the name.
let name = widgets::elide(ui, &item.name, name_r.width() - if item.changed { 64.0 } else { 0.0 });
let name_end = ui.painter().text(name_r.left_center(), Align2::LEFT_CENTER, name, regular(11.0), t.text).max.x;
if let Some(n) = n.filter(|_| item.changed) {
crate::canvas::status_badge(&ui.painter().with_clip_rect(name_r), n, pos2(name_end + 6.0, r.center().y));
}
// Hovered: a button to open it in the focus view, over the page name.
let focus_r = Rect::from_center_size(pos2(r.max.x - PAD - 10.0, r.center().y), vec2(22.0, 22.0));
let over_focus = ui.rect_contains_pointer(focus_r);
if resp.hovered() || over_focus {
ui.painter().rect_filled(
Rect::from_min_max(pos2(focus_r.min.x - 4.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0)),
CornerRadius::same(5),
t.hover,
);
if widgets::icon_button_at(ui, Id::unique(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) {
focus = Some(item.id);
}
}
if resp.clicked() && !over_focus {
go = Some(item.id);
}
}
if let Some(id) = focus {
app.run("dev.focus", json!({"id": id}));
} else if let Some(id) = go {
app.run("select.set", json!({"ids": [id]}));
app.run("view.zoomSelection", json!({}));
}
ui.add_space(4.0);
}
// ------------------------------------------------------------------ focus view
enum FocusAction {
Previous,
Next,
Compare,
Exit,
}
/// The focus view's bar at the top of the canvas: the frame's name and status, Compare changes,
/// previous / next ready-for-dev frame and exit.
pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
let Ok(ds) = app.session.ds() else { return };
// A version preview has the top of the canvas.
let (Some(id), None) = (ds.focused(), ds.preview.as_ref()) else { return };
let doc = ds.doc.clone();
let key = (ds.uid, ds.revision);
if app.ui.dev.focus_key != Some(key) {
app.ui.dev.focus_list = devinspect::dev_items(&doc, Some(DevStatus::ReadyForDev), None).into_iter().map(|i| i.id).collect();
app.ui.dev.focus_key = Some(key);
}
let Some(n) = doc.node(id) else { return };
let list = &app.ui.dev.focus_list;
let at = list.iter().position(|i| *i == id);
// Somewhere to go: another ready frame, or into the list from outside it.
let can_step = list.len() > usize::from(at.is_some());
let t = Tokens::get(ctx);
let action = widgets::canvas_banner(ctx, Id::unique("dev_focus_bar"), canvas, |ui| {
let mut out = None;
let icon = crate::layers::node_icon(&doc, id);
icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary);
ui.add_space(20.0);
let name = widgets::elide(ui, &n.name, 260.0);
ui.label(egui::RichText::new(name).font(semibold(11.0)).color(t.text));
if let Some(b) = crate::canvas::status_badge(ui.painter(), n, ui.cursor().min + vec2(0.0, 12.0)) {
ui.add_space(b.width());
}
if n.dev_status == DevStatus::ReadyForDev && n.dev_changed && widgets::button(ui, tr("Compare changes"), true, true).clicked() {
out = Some(FocusAction::Compare);
}
if can_step {
ui.add_space(4.0);
if widgets::icon_button(ui, Icon::ChevronLeft, tr("Previous ready for dev")) {
out = Some(FocusAction::Previous);
}
let pos = at.map_or_else(|| trn("{n} ready", list.len(), &[]), |k| trf("{n} of {total}", &[("n", &(k + 1)), ("total", &list.len())]));
ui.label(egui::RichText::new(pos).font(regular(11.0)).color(t.text_secondary));
if widgets::icon_button(ui, Icon::ChevronRight, tr("Next ready for dev")) {
out = Some(FocusAction::Next);
}
}
if widgets::icon_button(ui, Icon::Close, tr("Exit focus view (Esc)")) {
out = Some(FocusAction::Exit);
}
out
});
match action {
Some(FocusAction::Previous) => {
app.run("dev.focus", json!({"direction": "previous"}));
}
Some(FocusAction::Next) => {
app.run("dev.focus", json!({"direction": "next"}));
}
Some(FocusAction::Compare) => {
app.run("dev.compare", json!({"id": id, "show": true}));
}
Some(FocusAction::Exit) => {
app.run("dev.unfocus", json!({}));
}
None => {}
}
}