//! 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, 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 { 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, annotated: Vec, annotated_key: Option<(u64, u64, NodeId)>, /// The measurements kept on the current page, with their lines. measured: Vec, measured_key: Option<(u64, u64, NodeId)>, ready: Vec, ready_key: Option<(u64, u64, usize)>, /// Every ready-for-dev frame (the focus view's previous / next). focus_list: Vec, 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, values: serde_json::Map, shown: Option<(PlayKey, Played)>, } type PlayKey = (u64, u64, NodeId, String, CodeLang); struct Played { preview: Option, properties: Vec, 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 = 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 { 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>, note: Option>, 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)]) -> 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)], 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::(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 { 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(), ©, inst, 416.0), properties: figcraft_engine::playground::properties(©, inst)["properties"].as_array().cloned().unwrap_or_default(), code: code_for(©, 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::()))); } }), "instanceSwap" => row(ui, 32.0, |ui| { widgets::label(ui, name, t.text_secondary); let preferred: Vec = 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, "", 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)), §ions, &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::(adding_id)).unwrap_or(false); let mut editing = ui.data(|d| d.get_temp::(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::(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 { 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 => {} } }