M-egui037: document input lifecycle and UI regression contracts

This commit is contained in:
skylar taylor-barrick
2026-10-25 14:52:12 +01:00
parent fc344de33c
commit b751271c2a
76 changed files with 478 additions and 0 deletions
+3
View File
@@ -71,6 +71,9 @@ struct WebShell {
impl eframe::App for WebShell {
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
+3
View File
@@ -97,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
@@ -28,6 +28,7 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
@@ -115,6 +116,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
@@ -139,6 +142,7 @@ fn a_committed_reply_wins_over_the_transport_timeout() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
@@ -147,6 +151,8 @@ fn a_timeout_cancels_an_open_request() {
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
@@ -268,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
+3
View File
@@ -62,6 +62,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
+3
View File
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
/// Asserts two files are hosted and joined independently: each has its own port, session and
/// edits, presence stays in its file's session, and closing one file's session leaves the
/// other live.
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
+4
View File
@@ -601,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
/// store them as a foreign-file clipboard source with a bumped layers generation, then
/// delegate to `paste` and append import warnings.
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
@@ -134,6 +134,8 @@ fn ime_composition_with_cjk() {
assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none());
}
/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a
/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step.
#[test]
fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() {
// 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired
@@ -156,6 +158,9 @@ fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() {
assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session");
}
/// Asserts a byte range that only partly covers a character leaves it in place, while an
/// exact range deletes it, and a selection survives for the paired commit with its
/// direction preserved.
#[test]
fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() {
// The fork deletes the byte range exactly and leaves a character the range only partly
+32
View File
@@ -69,6 +69,8 @@ pub struct ImeSurrounding {
/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics.
static EDITING_SESSION: LazyLock<Mutex<u64>> = LazyLock::new(|| Mutex::new(0));
/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`).
/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics.
fn next_editing_session() -> u64 {
let mut session = match EDITING_SESSION.lock() {
Ok(session) => session,
@@ -143,6 +145,9 @@ fn range(te: &TextEdit) -> (usize, usize) {
(te.caret.min(te.anchor), te.caret.max(te.anchor))
}
/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the
/// composition, clamps caret and anchor to the character count, marks a `characters` override
/// when the node is source-backed, normalizes the document and bumps the revision.
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
edit_if(s, |t, te| {
f(t, te);
@@ -230,6 +235,11 @@ pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result<ImeInsertion> {
insert_model(s, text, true)
}
/// Insert committed text, optionally as an IME commit: normalize line endings, drop control
/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active
/// composition for an empty or control-only IME commit, and turn a committed space after a
/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was
/// plain, transformed, or a no-op.
fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInsertion> {
// Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a
// real commit may do so, since typing Enter goes through `insert`.
@@ -894,6 +904,9 @@ mod tests {
e
}
/// Asserts double-clicking a text layer selects all text, a second double-click selects the
/// word without changing the editing session, and drag and Shift+click extend the
/// selection.
#[test]
fn pointer_double_click_drag_and_shift_click() {
let (mut s, id) = setup("Hello world");
@@ -1132,6 +1145,8 @@ mod tests {
}
}
/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed
/// selection reversed.
#[test]
fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() {
let (mut s, id) = setup("café😀e\u{301}x");
@@ -1146,6 +1161,8 @@ mod tests {
assert_eq!(ime.owner.document, s.ds().unwrap().uid);
}
/// Asserts an active composition is excluded from surrounding text and an empty text layer
/// reports an available, empty payload.
#[test]
fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() {
let (mut s, id) = setup("Hi");
@@ -1162,6 +1179,8 @@ mod tests {
assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable");
}
/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized
/// selection is describable, and one byte more is `SelectionTooWide`.
#[test]
fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() {
let long = "a".repeat(5000);
@@ -1182,6 +1201,8 @@ mod tests {
assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide));
}
/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new
/// edit session changes the generation.
#[test]
fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() {
let (mut s, id) = setup("Hello");
@@ -1197,6 +1218,8 @@ mod tests {
assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch");
}
/// Asserts byte deletion around a reversed selection removes whole characters on both
/// sides, keeps the selected text and direction, and a final no-op leaves the text alone.
#[test]
fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() {
let (mut s, id) = setup("ab你好cd");
@@ -1215,6 +1238,8 @@ mod tests {
assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text");
}
/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an
/// active composition and the revision untouched.
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() {
let (mut s, id) = setup("ab你好cd");
@@ -1235,6 +1260,7 @@ mod tests {
assert_eq!(s.ds().unwrap().revision, revision);
}
/// Asserts a text layer in another document has a different IME owner.
#[test]
fn ime_owner_identity_changes_with_the_document() {
let (mut s, id) = setup("Hi");
@@ -1250,6 +1276,8 @@ mod tests {
assert_eq!(second.node, other);
}
/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports
/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`.
#[test]
fn ime_commit_reports_list_autoformat_as_an_application_transformation() {
let (mut s, id) = setup("-");
@@ -1261,6 +1289,8 @@ mod tests {
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
}
/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`,
/// and bare newline/CR commits are ignored while a live composition remains.
#[test]
fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() {
let (mut s, id) = setup("Hi");
@@ -1283,6 +1313,8 @@ mod tests {
assert_eq!(data(&s, id).characters, "Hia\nb\n");
}
/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision
/// once), inserts nothing, and is a harmless no-op when no composition is active.
#[test]
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
let (mut s, id) = setup("Hello");
+2
View File
@@ -64,6 +64,8 @@ fn a_one_word_last_line_is_avoided() {
assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}");
}
/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the
/// source text layer untouched.
#[test]
fn data_fill_sizes_each_copys_text() {
figcraft_text::scan_system_fonts();
+4
View File
@@ -2010,6 +2010,10 @@ impl Session {
Ok(())
}
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
/// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text
/// editing (select-all on entry, word-select inside the running edit), enter vector editing
/// for selected shapes, and step groups/frames one level deeper.
fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
+21
View File
@@ -6,6 +6,9 @@
use std::fmt::Write as _;
use std::path::PathBuf;
/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs;
/// report generation and write failures with `cargo::error`.
fn main() {
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
@@ -77,6 +80,10 @@ struct Catalog {
faces: Vec<(String, String)>,
}
/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont`
/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog
/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!`
/// items.
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result<Catalog, String> {
let manifest = dir.join(manifest_rel);
watch(&manifest);
@@ -121,6 +128,8 @@ mod tests {
struct Fixture(PathBuf);
impl Fixture {
/// Test fixture: create a unique temporary directory, write a synthetic face file and
/// install the default Georgian manifest.
fn new() -> Self {
let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir_all(dir.join("fonts")).unwrap();
@@ -131,6 +140,7 @@ mod tests {
fixture
}
/// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face.
fn manifest(&self, name: &str, faces: &[(&str, &str)]) {
let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::<String>();
std::fs::write(self.0.join("fonts").join(name), text).unwrap();
@@ -138,11 +148,15 @@ mod tests {
}
impl Drop for Fixture {
/// Remove the fixture's temporary directory, ignoring errors.
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web
/// catalog keeps only the four Inter faces, Adobe-authored families are absent, and
/// permitted native faces remain.
#[test]
fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() {
let f = Fixture::new();
@@ -160,6 +174,7 @@ mod tests {
assert!(input_fonts(Some(&f.0), true, true, false).is_ok());
}
/// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold.
#[test]
fn required_web_rejects_core_only_and_partial_latin_catalogs() {
let f = Fixture::new();
@@ -168,6 +183,8 @@ mod tests {
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold"));
}
/// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and
/// rejects a manifest without Noto Sans Georgian Regular.
#[test]
fn required_web_embeds_both_catalogs_and_requires_georgian() {
let f = Fixture::new();
@@ -180,6 +197,8 @@ mod tests {
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular"));
}
/// Asserts absent font input is empty when optional and an error when required, while present
/// input includes Noto Sans Georgian.
#[test]
fn optional_and_native_inputs_keep_their_existing_policy() {
let f = Fixture::new();
@@ -189,6 +208,8 @@ mod tests {
assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian"));
}
/// Asserts required web input rejects a manifest whose face file is missing, even when every
/// face row is listed.
#[test]
fn required_web_rejects_missing_asset_even_with_all_face_rows() {
let f = Fixture::new();
+2
View File
@@ -253,6 +253,8 @@ struct Shared {
name: String,
}
/// Run `f` on the per-context temporary `Shared` accessibility state keyed by
/// `figcraft_a11y`.
fn shared<R>(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R {
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<Shared>(Id::unique("figcraft_a11y"))))
}
+2
View File
@@ -404,6 +404,8 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() {
assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it");
}
/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the
/// side-panel edges.
#[test]
fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() {
// FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges
+13
View File
@@ -164,6 +164,7 @@ enum Act {
Hide(NodeId, bool),
}
/// Stable egui id for the assets drag payload.
fn drag_id() -> Id {
Id::unique("asset_drag")
}
@@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId
}
}
/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked,
/// otherwise cache a listing keyed by revision, query and library state, render local and
/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle
/// and drag actions.
pub fn show(app: &mut FigApp, ui: &mut Ui) {
refresh_libraries(app, ui);
if app.ui.libraries {
@@ -773,6 +778,8 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap<S
ui.ctx().request_repaint();
}
/// Draw the 64×64 component ghost on the tooltip layer, using the thumbnail when available
/// and the component or component-set icon otherwise.
fn ghost(ui: &Ui, p: Pos2, set: bool, tex: Option<egui::TextureHandle>) {
let t = Tokens::get(ui.ctx());
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost")));
@@ -831,6 +838,10 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) {
});
}
/// Draw the publish form: status, publishable components/styles/collections, description and
/// publish buttons. Run `library.publish` locally or publish to the server in the
/// background, reset the form on success, and report when the file is unsaved so other files
/// cannot yet use it.
fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
let form_id = Id::unique("asset_publish_form");
let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default();
@@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
ui.add_space(8.0);
}
/// Draw the libraries enabled in this file with version/count/status, remove and swap
/// actions, add-file and pasted-link controls, and background fetch/cancel handling.
fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
let t = Tokens::get(ui.ctx());
title(ui, w, tr("Libraries in this file"));
+4
View File
@@ -163,6 +163,10 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers
}
}
/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input
/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll
/// renders and upload the texture; draw the page background and last texture; then draw overlays,
/// labels, handles, comments, versions/branch/dev/live UI and the cursor.
pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let rect = ui.max_rect();
app.canvas.rect = rect;
@@ -25,14 +25,17 @@ struct Recorded {
struct Clipboard(Arc<Mutex<Recorded>>);
impl Services for Clipboard {
/// Record a system-clipboard text write in the test clipboard service.
fn clipboard_set_text(&mut self, s: &str) {
let mut r = self.0.lock().unwrap();
r.text.push(s.to_string());
r.last_text = Some(s.to_string());
}
/// Return the test system clipboard's last text.
fn clipboard_get_text(&mut self) -> Option<String> {
self.0.lock().unwrap().last_text.clone()
}
/// Count a PNG clipboard write in the test clipboard service.
fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {
self.0.lock().unwrap().pngs += 1;
}
@@ -46,6 +49,7 @@ struct Win {
}
impl Win {
/// Create a test window over a session containing one selected rectangle.
fn new() -> Self {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
@@ -54,6 +58,7 @@ impl Win {
win
}
/// Build a test window around `s` with a recording clipboard service and run three frames.
fn with_session(s: Session) -> Self {
let recorded = Arc::new(Mutex::new(Recorded::default()));
let app = FigApp::new(s, Box::new(Clipboard(recorded.clone())));
@@ -64,6 +69,7 @@ impl Win {
win
}
/// Run one production UI frame with `events` at the next 60 Hz test time.
fn frame(&mut self, events: Vec<Event>) {
self.time += 1.0 / 60.0;
self.app.clock.set_test_now(Some(self.time));
@@ -74,12 +80,15 @@ impl Win {
out.textures_delta.clear();
}
/// Return the recorded `(text writes, PNG writes)` count.
fn writes(&self) -> (usize, usize) {
let r = self.recorded.lock().unwrap();
(r.text.len(), r.pngs)
}
}
/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the
/// command and is acknowledged.
#[test]
fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() {
let mut w = Win::new();
@@ -106,6 +115,8 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() {
assert!(rx.try_recv().is_ok(), "the batch is acknowledged");
}
/// Asserts copy commands run under the synthetic-control fence write nothing, while the same
/// command afterwards writes once.
#[test]
fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() {
let mut w = Win::new();
@@ -120,6 +131,7 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() {
assert_eq!(w.writes(), (1, 0));
}
/// Return the characters of a text node in the test window.
fn edit_text(w: &Win, id: NodeId) -> String {
match &w.app.session.doc().unwrap().node(id).unwrap().kind {
NodeKind::Text(t) => t.characters.clone(),
@@ -127,6 +139,8 @@ fn edit_text(w: &Win, id: NodeId) -> String {
}
}
/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes
/// no system clipboard, and ends its fence with the frame.
#[test]
fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() {
let mut s = Session::new();
@@ -147,6 +161,8 @@ fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone()
assert!(!w.app.control_synthetic, "the fence ends with the frame");
}
/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale
/// mirror, so a later user paste uses the system text rather than the programmatic layers.
#[test]
fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() {
let mut s = Session::new();
+9
View File
@@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 {
doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color())
}
/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or
/// drag; returns `(changed, done)`.
fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
// White → hue horizontally, then transparent → black vertically.
let mut m = Mesh::default();
@@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
(changed, resp.drag_stopped() || resp.clicked())
}
/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns
/// `(changed, done)`.
fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) {
let n = 12;
let mut m = Mesh::default();
@@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> {
None
}
/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the
/// new alpha and done state, or `None` when the slider is not interacting.
fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0);
let mut m = Mesh::default();
@@ -441,6 +447,9 @@ pub fn window_id() -> Id {
Id::unique("color_picker")
}
/// Draw the colour picker for the current target: close it when there is no target or the
/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and
/// gradient-stop edits live, positioned beside a plugin field when applicable.
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
let Some(target) = app.ui.picker.clone() else {
// A colour sampled for a picker that has closed has no paint to go to.
+13
View File
@@ -70,18 +70,22 @@ pub struct RequestGate {
}
impl RequestGate {
/// Create a request gate in its default `Open` state.
pub fn new() -> Self {
Self::default()
}
/// Return a copy of the gate's current request state.
pub fn state(&self) -> RequestState {
*self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Whether the gate is `Cancelled`.
pub fn is_cancelled(&self) -> bool {
self.state() == RequestState::Cancelled
}
/// Whether the gate is `Replied`.
pub fn is_replied(&self) -> bool {
self.state() == RequestState::Replied
}
@@ -121,6 +125,7 @@ pub struct ControlRequest {
}
impl ControlRequest {
/// Whether this request's gate is `Cancelled`.
pub fn is_cancelled(&self) -> bool {
self.state.is_cancelled()
}
@@ -147,6 +152,7 @@ struct SlotInner {
}
impl CaptureSlot {
/// Create an empty capture slot for `viewport`, holding a clone of the UI context.
fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc<Self> {
Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport })
}
@@ -161,6 +167,8 @@ impl CaptureSlot {
})
}
/// Store the first delivered image only while the slot is open and untaken, ignoring later
/// or closed deliveries, then wake the viewport after releasing the lock.
pub(crate) fn complete(&self, image: Arc<egui::ColorImage>) {
{
let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -214,6 +222,7 @@ pub struct CaptureSpec {
const SHOT_PNG_OVERHEAD: usize = 64 * 1024;
impl CaptureSpec {
/// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow.
fn bytes(&self) -> Option<usize> {
self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4)
}
@@ -272,6 +281,7 @@ pub struct ShotRequest {
}
impl ShotRequest {
/// Close and drop the capture slot, if any, and zero the reserved byte count.
fn release(&mut self) {
if let Some(slot) = self.slot.take() {
slot.close();
@@ -327,6 +337,7 @@ fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option<CaptureSpec
Some(CaptureSpec { viewport, size_px: [w as usize, h as usize], pixels_per_point: ppp, zoom })
}
/// Send one reply carrying the request id, ignoring a disconnected receiver.
fn reply(r: &mut ShotRequest, result: Result<Value, String>) {
let _ = r.reply.send(ControlReply { id: r.id.clone(), result });
}
@@ -819,6 +830,8 @@ enum EncodedShot {
}
impl EncodedShot {
/// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its
/// path and dimensions; for `Data`, return the prebuilt result.
fn finish(self) -> Result<Value, String> {
match self {
Self::File { path, png, width, height } => {
@@ -12,6 +12,7 @@ use serde_json::json;
use crate::control::{CaptureSpec, ControlReply, ControlRequest};
use crate::layers_drag_tests::Ui;
/// Queue a `ui.screenshot` request in the test app and return its reply receiver.
fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver<ControlReply> {
let (tx, rx) = std::sync::mpsc::channel();
u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() });
@@ -38,10 +39,13 @@ fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput {
out
}
/// Whether any viewport in the frame output issues a `Screenshot` command.
fn has_screenshot(out: &egui::FullOutput) -> bool {
out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_))))
}
/// Asserts reverse capture completion replies only to the matching request, then drains both
/// requests.
#[test]
fn two_captures_correlate_by_request_across_reverse_completion() {
let mut u = Ui::new(Session::empty());
@@ -67,6 +71,7 @@ fn two_captures_correlate_by_request_across_reverse_completion() {
assert!(u.app.capture.shots.is_empty(), "no retained requests");
}
/// Asserts a cancelled capture gets no reply and its slot is released.
#[test]
fn a_cancelled_capture_never_replies_or_writes() {
let mut u = Ui::new(Session::empty());
@@ -89,6 +94,7 @@ fn commit_dir(tag: &str) -> std::path::PathBuf {
dir
}
/// Asserts cancellation after encoding still prevents the final file write and reply.
#[test]
fn a_cancellation_after_encoding_prevents_the_final_write() {
let dir = commit_dir("cancelled");
@@ -107,6 +113,7 @@ fn a_cancellation_after_encoding_prevents_the_final_write() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG.
#[test]
fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() {
let mut u = Ui::new(Session::empty());
@@ -129,6 +136,8 @@ fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts an expiry during encoding reports the timeout under the gate, marks the request
/// replied and writes no file.
#[test]
fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() {
let dir = commit_dir("expired");
@@ -149,6 +158,8 @@ fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a commit before the deadline writes the file and queues one reply, and a second
/// commit is refused.
#[test]
fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() {
let dir = commit_dir("committed");
@@ -172,6 +183,8 @@ fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late
/// pixels are ignored.
#[test]
fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() {
let mut u = Ui::new(Session::empty());
@@ -191,6 +204,8 @@ fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() {
assert!(rx.try_recv().is_err(), "a late capture is inert");
}
/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every
/// admitted capture still completes under its own id.
#[test]
fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() {
let mut u = Ui::new(Session::empty());
@@ -213,6 +228,7 @@ fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() {
rxs.clear();
}
/// Asserts an undersized readback is not accepted but reissued with one retry and no reply.
#[test]
fn an_undersized_readback_is_reissued_instead_of_accepted() {
let mut u = Ui::new(Session::empty());
@@ -230,6 +246,7 @@ fn an_undersized_readback_is_reissued_instead_of_accepted() {
assert!(!r.issued, "waits for the settled geometry again");
}
/// Asserts a one-pixel rounding difference is accepted as the same surface.
#[test]
fn a_one_pixel_rounding_difference_is_still_the_same_surface() {
let mut u = Ui::new(Session::empty());
@@ -245,6 +262,7 @@ fn a_one_pixel_rounding_difference_is_still_the_same_surface() {
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed".
#[test]
fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() {
let mut s = Session::empty();
@@ -268,6 +286,8 @@ fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() {
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old
/// pixels.
#[test]
fn a_geometry_change_reissues_instead_of_accepting_old_pixels() {
let mut u = Ui::new(Session::empty());
@@ -288,6 +308,8 @@ fn a_geometry_change_reissues_instead_of_accepting_old_pixels() {
assert!(!r.issued, "waits for the settled geometry again");
}
/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without
/// darkening.
#[test]
fn a_translucent_pixel_round_trips_straight_rgba() {
let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]);
@@ -298,6 +320,8 @@ fn a_translucent_pixel_round_trips_straight_rgba() {
assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened");
}
/// Asserts a capture is not issued before three completed real UI frames; the following
/// completed frame issues it.
#[test]
fn a_capture_is_not_issued_before_three_completed_real_ui_frames() {
let mut u = Ui::new(Session::empty());
@@ -313,6 +337,8 @@ fn a_capture_is_not_issued_before_three_completed_real_ui_frames() {
assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout");
}
/// Asserts a discarded sizing pass does not count toward capture settling, and the next
/// completed frame issues it.
#[test]
fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() {
let mut u = Ui::new(Session::empty());
@@ -327,6 +353,8 @@ fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() {
assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture");
}
/// Asserts pixels delivered while hidden force a draining paint and are answered rather than
/// timed out.
#[test]
fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() {
let mut u = Ui::new(Session::empty());
@@ -354,6 +382,8 @@ fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() {
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts a capture for a window with unknown visibility is parked with its deadline and a
/// paint request, then times out once.
#[test]
fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() {
let mut u = Ui::new(Session::empty());
@@ -386,6 +416,8 @@ fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline()
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts a completed capture is rejected when current geometry is unavailable, failing
/// closed.
#[test]
fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() {
let mut u = Ui::new(Session::empty());
@@ -412,6 +444,7 @@ fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() {
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts a tab switch while a capture settles invalidates it with "document changed".
#[test]
fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() {
let mut s = Session::empty();
@@ -435,6 +468,8 @@ fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrive
assert!(u.app.capture.shots.is_empty(), "released");
}
/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an
/// over-budget resize is rejected before readback.
#[test]
fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() {
let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 };
@@ -462,6 +497,8 @@ fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() {
assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command");
}
/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request
/// that no longer fits, and makes the survivor reserve its real cost.
#[test]
fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() {
let mut u = Ui::new(Session::empty());
@@ -481,6 +518,8 @@ fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() {
assert!(!has_screenshot(&out), "neither request was read back using the old small reservation");
}
/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and
/// reserves the current cost.
#[test]
fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() {
let mut u = Ui::new(Session::empty());
@@ -518,6 +557,8 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver<ControlReply> {
rx
}
/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once
/// after that pass.
#[test]
fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() {
let mut u = Ui::new(Session::empty());
@@ -534,6 +575,8 @@ fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() {
assert!(!u.app.control_synthetic, "the fence ends with the frame");
}
/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented
/// pass acknowledges once.
#[test]
fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() {
let mut u = Ui::new(Session::empty());
@@ -565,6 +608,8 @@ fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() {
assert!(app.input_acks.is_empty() && app.applied_acks.is_empty());
}
/// Asserts injected text is applied once across a discarded sizing pass and acknowledged
/// once.
#[test]
fn injected_text_lands_once_across_a_discarded_sizing_pass() {
// egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in
+2
View File
@@ -51,10 +51,12 @@ impl Ui {
u
}
/// Store the About table's current view in test context data.
fn set_view(&self, v: View) {
self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v));
}
/// Read the stored About table view, defaulting when unset.
fn view(&self) -> View {
self.ctx.data(|d| d.get_temp::<View>(egui::Id::unique("credits_view"))).unwrap_or_default()
}
+4
View File
@@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
}
}
/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About,
/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare,
/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and
/// close confirmation.
pub fn show(app: &mut FigApp, ctx: &Context) {
// Dropped files waiting to open: their card, then (once it has shown) the files.
crate::pending_open::pending_opens(app, ctx);
+9
View File
@@ -467,6 +467,8 @@ fn lead(u: &Ui, id: Id) -> Pos2 {
u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center()
}
/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each
/// scrub adding one undo step.
#[test]
fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
let (mut u, a, _) = setup();
@@ -490,6 +492,8 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() {
assert_eq!(undo_steps(&u), steps + 2, "alpha");
}
/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo
/// step.
#[test]
fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
let mut s = Session::new();
@@ -514,6 +518,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() {
assert_eq!((ms, undo_steps(&u)), (800, steps + 1));
}
/// Asserts scrubbing the scale panel's width scales the layer live in one undo step.
#[test]
fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
let (mut u, a, _) = setup();
@@ -584,6 +589,8 @@ fn the_intensity_label_fits_whole_in_every_language() {
out.textures_delta.clear();
}
/// Asserts a label too long for its number field is elided before the value, while the value
/// stays whole and both remain inside the field.
#[test]
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
let ctx = egui::Context::default();
@@ -780,6 +787,8 @@ fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs()
}
}
/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other
/// file stays untouched, Save as is not opened, and the stale request is consumed once.
#[test]
fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() {
for change in ["closed", "moved", "foreign"] {
+3
View File
@@ -58,6 +58,9 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) {
});
}
/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves
/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to
/// ellipses/rects/text, and rotates meshes.
fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) {
let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot);
match shape {
+2
View File
@@ -10,6 +10,8 @@ use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets::{self, PAD};
/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`,
/// switches to the Move tool and zooms to the new selection.
pub fn panel(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, tr("Frame"), &[], false);
+1
View File
@@ -49,6 +49,7 @@ struct Placed {
line: Option<[Point; 2]>,
}
/// Stable egui id for guide dragging.
fn drag_id() -> Id {
Id::unique("guide_drag")
}
+1
View File
@@ -67,6 +67,7 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po
])
}
/// Stable egui id for handle dragging.
fn drag_id() -> Id {
Id::unique("figcraft_handle_drag")
}
+4
View File
@@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
}
}
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it)
/// and section tabs; choosing a section clears the file query.
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
let mut y = left.min.y + 20.0;
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
@@ -475,6 +477,8 @@ fn matches(template: &Template, query: &str, kind: Option<Kind>) -> bool {
|| has(template.size))
}
/// Draw template search and kind filter, show no-match guidance and a "Show all templates"
/// button that clears the query and kind, and lay matching templates out in a grid.
fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
heading(ui, t, tr("Templates"));
ui.horizontal(|ui| {
+7
View File
@@ -248,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() {
assert!(worker.poll().is_some(), "the woken frame finds the image");
}
/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames
/// coming until delivered before the UI rests again.
#[test]
fn control_requests_and_screenshots_wake_an_idle_ui() {
let (mut h, _) = document();
@@ -341,6 +343,8 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() {
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL);
}
/// Asserts a hidden window answers edits and inspection at once, while a screenshot is
/// parked with a deadline and times out once instead of waiting for a frame.
#[test]
fn a_hidden_window_answers_at_once() {
// #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs:
@@ -424,6 +428,8 @@ fn auto_save_writes_a_changed_file_then_rests() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a hidden logic tick runs background work and advances the autosave deadline
/// without counting a UI frame, and is idempotent at the same time.
#[test]
fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() {
let (mut h, _) = document();
@@ -444,6 +450,7 @@ fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() {
assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time");
}
/// Asserts a discarded sizing pass counts one drawn frame, not one per pass.
#[test]
fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() {
let (mut h, _) = document();
+42
View File
@@ -48,6 +48,7 @@ fn ime_descriptor(u: &mut Ui, events: Vec<Event>) -> IMEOutput {
u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing")
}
/// Return the available surrounding-text payload of an IME output, panicking when unavailable.
fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload {
match &ime.surrounding_text {
IMESurroundingText::Available(payload) => payload,
@@ -55,6 +56,7 @@ fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload {
}
}
/// Create a text layer with `text` and return its node id.
fn text_layer(s: &mut Session, text: &str) -> NodeId {
NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap())
}
@@ -103,6 +105,8 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() {
assert_eq!(text(&u, t), "Hi你好\n!");
}
/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor
/// focused, and the next pass observes the committed text.
#[test]
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
// Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last
@@ -126,6 +130,8 @@ fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
assert_eq!(text(&u, t), "Hello");
}
/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo
/// restores the pre-IME text.
#[test]
fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() {
// 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The
@@ -146,6 +152,8 @@ fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() {
assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text");
}
/// Asserts a caret click invalidates the whole stale IME batch (both byte and character
/// deletions) while a later direct commit still works.
#[test]
fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() {
figcraft_text::scan_system_fonts();
@@ -185,6 +193,8 @@ fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() {
}
}
/// Asserts a control-channel document switch rejects the old composition's delete and commit
/// batch, while a direct commit to the new target remains valid.
#[test]
fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() {
figcraft_text::scan_system_fonts();
@@ -214,6 +224,8 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() {
assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid");
}
/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is
/// `InputMethod` with UTF-8 offsets, the same owner and generation.
#[test]
fn ime_cause_is_input_method_only_for_an_accepted_commit() {
figcraft_text::scan_system_fonts();
@@ -240,6 +252,8 @@ fn ime_cause_is_input_method_only_for_an_accepted_commit() {
assert_eq!(text(&u, t), "Hi你好x");
}
/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is
/// `InputMethod` without lingering state.
#[test]
fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() {
figcraft_text::scan_system_fonts();
@@ -267,6 +281,7 @@ fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() {
assert_eq!(text(&u, t), "Hiあbうcえおdか");
}
/// Asserts a control-channel text edit is an application change (`Other`).
#[test]
fn a_control_channel_text_edit_is_an_application_change() {
figcraft_text::scan_system_fonts();
@@ -283,6 +298,8 @@ fn a_control_channel_text_edit_is_an_application_change() {
assert_eq!(available(&out).text, "Hiz");
}
/// Asserts a byte delete followed by a commit is `InputMethod` and describes the
/// post-mutation text and caret.
#[test]
fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() {
figcraft_text::scan_system_fonts();
@@ -302,6 +319,8 @@ fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text()
assert_eq!(text(&u, t), "abXd");
}
/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the
/// following commit is `InputMethod`.
#[test]
fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() {
figcraft_text::scan_system_fonts();
@@ -322,6 +341,8 @@ fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method()
assert_eq!(available(&committed).cursor, 8);
}
/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed
/// selection.
#[test]
fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() {
figcraft_text::scan_system_fonts();
@@ -339,6 +360,8 @@ fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() {
assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted");
}
/// Asserts empty text is available while a selection wider than the 4000-byte budget is
/// explicitly unavailable but still has a nonzero IME context generation.
#[test]
fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() {
figcraft_text::scan_system_fonts();
@@ -361,6 +384,7 @@ fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable(
assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable");
}
/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt.
#[test]
fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() {
figcraft_text::scan_system_fonts();
@@ -382,6 +406,8 @@ fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() {
assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1));
}
/// Asserts another text node is another owner, and re-entering the same node changes the
/// context generation while keeping the owner key.
#[test]
fn a_new_edit_session_and_another_text_node_are_new_ime_owners() {
figcraft_text::scan_system_fonts();
@@ -405,6 +431,8 @@ fn a_new_edit_session_and_another_text_node_are_new_ime_owners() {
assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target");
}
/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps
/// its `InputMethod` cause, while a mixed rerun is `Other`.
#[test]
fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() {
figcraft_text::scan_system_fonts();
@@ -428,6 +456,9 @@ fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its
assert_eq!(mixed.change_cause, ImeChangeCause::Other);
}
/// Asserts IME context state is scoped per viewport: each viewport has its own generation,
/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit
/// the old generation.
#[test]
fn ime_context_state_is_scoped_per_viewport() {
figcraft_text::scan_system_fonts();
@@ -480,6 +511,7 @@ fn ime_context_state_is_scoped_per_viewport() {
assert_ne!(aux_again.context_generation, aux_ime.context_generation);
}
/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit.
#[test]
fn the_engine_surrounding_limit_matches_the_wire_limit() {
// A divergence would truncate or overrun the bounded payload silently.
@@ -488,6 +520,8 @@ fn the_engine_surrounding_limit_matches_the_wire_limit() {
assert_eq!(engine, wire);
}
/// Asserts a frame observes the IME exactly once in both the editor pass and the Home
/// early-return pass.
#[test]
fn a_frame_observes_the_ime_once_even_when_it_ends_early() {
figcraft_text::scan_system_fonts();
@@ -506,6 +540,8 @@ fn a_frame_observes_the_ime_once_even_when_it_ends_early() {
assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen");
}
/// Asserts a double-click inside an existing edit selects the word without changing the IME
/// owner or generation.
#[test]
fn a_double_click_inside_the_edit_keeps_the_ime_generation() {
figcraft_text::scan_system_fonts();
@@ -536,6 +572,8 @@ fn a_double_click_inside_the_edit_keeps_the_ime_generation() {
assert_eq!(available(&after).text, "Hello world");
}
/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME
/// descriptor.
#[test]
fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() {
figcraft_text::scan_system_fonts();
@@ -560,6 +598,8 @@ fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() {
assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows");
}
/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and
/// control-only commits insert nothing.
#[test]
fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() {
figcraft_text::scan_system_fonts();
@@ -582,6 +622,8 @@ fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing()
assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_)));
}
/// Asserts character deletion keeps the selection for the paired commit, which replaces it
/// and reports `InputMethod`.
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() {
figcraft_text::scan_system_fonts();
+8
View File
@@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
}
}
/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place
/// file rename and collapse control, followed by Pages and Layers or the Assets panel.
pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
if app.ui.versions.open {
crate::versions::panel(app, ui);
@@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
}
}
/// Draw the page list with open/search/add controls, filtering, selection, rename,
/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations.
fn pages(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete.
@@ -444,6 +448,7 @@ struct Sweep {
ids: Vec<NodeId>,
}
/// Stable egui id for the eye/padlock sweep state.
fn sweep_id() -> Id {
Id::unique("layer_toggle_sweep")
}
@@ -654,6 +659,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) {
app.ui.layers_seen_sel = app.session.selection();
}
/// Stable egui id for a layer row's rename field.
fn rename_id(id: NodeId) -> Id {
Id::unique(("rename", id))
}
@@ -722,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut
}
}
/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling,
/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions.
fn layers(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock.
@@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec<NodeId> {
opened
}
/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages.
#[test]
fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() {
let mut s = Session::new();
@@ -383,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) {
}
}
/// Asserts double-clicking a page selects and renames it, with the keyboard in its name
/// field for the screen-reader tree.
#[test]
fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() {
let mut s = Session::new();
@@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() {
assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc));
}
/// Asserts a layer rename continues after its row is scrolled out of view and commits on
/// Enter.
#[test]
fn a_rename_scrolled_out_of_view_goes_on() {
let mut s = Session::new();
+2
View File
@@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response {
resp
}
/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the
/// horizontal baseline checkbox.
fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) {
let t = Tokens::get(ui.ctx());
let grid_mode = l.mode == LayoutMode::Grid;
+10
View File
@@ -488,6 +488,7 @@ pub struct FrameClock {
}
impl Default for FrameClock {
/// Start the frame clock at the current web time with no injected test time.
fn default() -> Self {
Self { origin: web_time::Instant::now(), test_now: None }
}
@@ -722,6 +723,9 @@ pub struct UiState {
}
impl Default for UiState {
/// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and
/// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel
/// widths and the pages height from preferences.
fn default() -> Self {
Self {
left_width: PANEL_DEFAULT,
@@ -915,6 +919,9 @@ pub struct FigApp {
pub type Done = Box<dyn FnOnce(&mut FigApp, &egui::Context, Value)>;
impl FigApp {
/// Construct `FigApp` from a session and services, taking panel widths and pages height
/// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel
/// state.
pub fn new(session: Session, services: Box<dyn Services>) -> Self {
// The main window's panels open as wide as they were left.
let (left_width, right_width) = session.prefs.panel_widths;
@@ -2044,6 +2051,9 @@ impl FigApp {
}
}
/// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request
/// that frame and the frame that hides it, expire and remove it at the deadline, and place it
/// at the bottom centre.
fn toast_ui(&mut self, ctx: &egui::Context) {
let Some((msg, clock)) = &mut self.ui.toast else { return };
let now = ctx.input(|i| i.time);
+1
View File
@@ -14,6 +14,7 @@ fn escape(h: &mut Harness) {
}
}
/// Whether the Join link field currently has focus.
fn link_focused(h: &Harness) -> bool {
h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link")))
}
+1
View File
@@ -20,6 +20,7 @@ fn key(h: &mut Harness, key: Key) {
}
}
/// Whether the widget with the given id currently has focus.
fn focused(h: &Harness, id: &str) -> bool {
h.ctx.memory(|m| m.has_focus(egui::Id::unique(id)))
}
+5
View File
@@ -385,6 +385,9 @@ fn without_a_code_the_link_is_shared_instead() {
assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN));
}
/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after
/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local
/// codes and links.
#[test]
fn the_join_field_takes_a_code_or_a_link_and_enter_joins() {
let (mut h, script) = harness();
@@ -450,6 +453,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() {
assert_eq!(h.app.session.docs.len(), 2);
}
/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the
/// join starts.
#[test]
fn the_join_dialog_is_the_same_field() {
let (mut h, script) = harness();
+2
View File
@@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() {
assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left"));
}
/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas
/// stops following.
#[test]
fn clicking_an_avatar_follows_and_escape_stops() {
let (mut h, _) = Harness::new();
+3
View File
@@ -87,6 +87,7 @@ impl Ui {
const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"];
/// Stable id for a top-level menu in the menu-aim test harness.
fn top(name: &str) -> Id {
Id::unique(("menu", name))
}
@@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() {
assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown());
}
/// Asserts leaving a top menu and returning to it does not restore the submenu that was open
/// before.
#[test]
fn a_submenu_one_level_down_reopens_closed() {
let mut u = Ui::new();
+2
View File
@@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() {
assert!(ui.app.ui.last_error.is_none());
}
/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog),
/// focuses it, and commits the typed name on Enter.
#[test]
fn rename_file_edits_the_file_name_in_place() {
let mut ui = Ui::new(Session::new());
+6
View File
@@ -478,10 +478,12 @@ struct At {
path: String,
}
/// Scoped egui id for the current menu level.
fn at_key(ui: &Ui) -> Id {
ui.scope_id().with("menu_level")
}
/// Return the current menu root/path, defaulting to the main menu root.
fn level_at(ui: &Ui) -> At {
ui.data(|d| d.get_temp::<At>(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() })
}
@@ -817,6 +819,9 @@ const TOP_MENUS: &[&str] = &[
tr_noop("Help"),
];
/// Draw the main and context menu trees: forget closed/stale submenu state, apply
/// control-channel Select-layer open/close, close on outside click or Escape, then draw the
/// colour picker and palette popups.
pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
let t = Tokens::get(ctx);
// A closed menu forgets its open submenus (and a context menu opened somewhere else starts
@@ -1020,6 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
/// The context menu's Select layer submenu (its key in the context menu's tree).
const SELECT_LAYER: &str = "Select layer";
/// Stable egui id for the context menu's pending Select-layer open request.
fn select_layer_ask_id() -> Id {
Id::unique("ctx_select_layer_ask")
}
+5
View File
@@ -64,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 {
.unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}"))
}
/// Read a temporary boolean open-settings flag keyed by `salt` and row index.
fn flag(u: &Ui, salt: &'static str, i: usize) -> bool {
u.ctx.data(|d| d.get_temp::<bool>(Id::unique((salt, i)))).unwrap_or(false)
}
/// Asserts removing a fill keeps the open picker on the surviving paint (with its index
/// shifted), and removing the paint being edited closes the picker.
#[test]
fn removing_a_fill_keeps_the_picker_on_its_paint() {
let mut s = Session::new();
@@ -93,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() {
assert_eq!(u.app.ui.picker, None);
}
/// Asserts removing effects or grids moves open settings and an open colour picker to the
/// surviving row, or closes them when their own row is removed.
#[test]
fn removing_effects_and_grids_keeps_open_settings_with_their_rows() {
let mut s = Session::new();
@@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() {
assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag");
}
/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no
/// drag handle.
#[test]
fn effects_and_grids_drag_too_and_mixed_lists_dont() {
let (mut u, r) = setup();
+3
View File
@@ -68,6 +68,8 @@ impl Bench {
}
}
/// Build and print a synthetic UI benchmark report for the given layer count; it makes no
/// assertions.
fn report(layers: usize) {
let mut b = Bench::new(layers);
let mut rows: Vec<(&str, f64)> = Vec::new();
@@ -180,6 +182,7 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine::
figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap()
}
/// Build a test mouse-wheel event for the given vertical delta.
fn wheel(d: f32) -> Event {
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
+3
View File
@@ -89,6 +89,9 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() {
Harness::new(session()).assert_idle("a plugin panel");
}
/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour
/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels
/// present).
#[test]
fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() {
let mut u = Ui::new(session());
+5
View File
@@ -245,6 +245,9 @@ const INSTALL: &str = "plugin.directory.install";
const MANAGE_W: f32 = 400.0;
const ROW_TEXT_W: f32 = 300.0;
/// Draw the Plugins window with installed and browse tabs, dispatching remove, import,
/// browse, confirm and stop actions; closing the window cancels its directory requests and
/// clears confirmation.
fn manage_window(app: &mut FigApp, ctx: &Context) {
let t = Tokens::get(ctx);
let mut open = true;
@@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String {
parts.join(" · ")
}
/// Draw a plugin command's parameter form: close it when the plugin or command disappeared,
/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel.
fn form_window(app: &mut FigApp, ctx: &Context) {
let Some(mut form) = app.ui.plugin_form.clone() else { return };
let Some((name, command)) = app
+2
View File
@@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() {
assert!(u.app.ui.preview.is_none());
}
/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the
/// end clamp with spring-back, and that navigation resets scroll to the top.
#[test]
fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() {
let (mut u, a, b, _) = setup();
+3
View File
@@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 {
v.as_f64().unwrap()
}
/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning,
/// option carry-over to the next presentation, and R restarting the flow.
#[test]
fn presenter_scales_by_its_options_and_pans_what_overflows() {
let (mut u, big) = big();
@@ -272,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() {
assert_eq!(u.preview()["flinging"], json!(false));
}
/// Asserts wheel momentum continues after input stops and then comes to rest.
#[test]
fn the_wheel_coasts_after_it_stops() {
let (mut u, a, ..) = setup();
+3
View File
@@ -20,6 +20,7 @@ struct Drag {
pos: Pos2,
}
/// Stable egui id for prototype connection dragging.
fn drag_id() -> Id {
Id::unique("proto_canvas_drag")
}
@@ -59,6 +60,8 @@ struct Sources {
ids: Arc<Vec<NodeId>>,
}
/// Return the cached subtree nodes with interactions for `page`, recomputing only when the
/// document pointer or page changes.
pub(crate) fn sources(ctx: &egui::Context, doc: &Arc<Document>, page: NodeId) -> Arc<Vec<NodeId>> {
ctx.data_mut(|d| {
let c = d.get_temp_mut_or_default::<Sources>(Id::unique("proto_canvas_sources"));
+1
View File
@@ -129,6 +129,7 @@ fn unavailable(label: &str) -> &'static str {
}
}
/// Stable egui id for a node interaction's open action path.
fn open_id(cx: &Cx) -> Id {
Id::unique(("proto_open_action", cx.node, cx.k))
}
+5
View File
@@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String {
blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default()
}
/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a
/// condition when a variable of another type is picked, ignores a right-hand-side variable pick
/// for a complex condition, and one undo restores the previous condition.
#[test]
fn the_interaction_editor_shows_conditionals_and_picks_their_variables() {
let (mut s, _, b, btn) = setup();
@@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() {
}
}
/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid
/// one clears the error and saves, and Enter does not leak to shortcuts.
#[test]
fn typed_conditions_are_checked_and_errors_show_until_fixed() {
let (mut s, _, b, btn) = setup();
+10
View File
@@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) {
}
}
/// Draw prototype settings: device and rotation, device colour, custom size, responsive
/// toggle and page background, running `proto.settings`.
fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) {
if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() {
app.run("proto.preview", json!({}));
@@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option<proto::Device>) {
});
}
/// Show, create or preview the flow starting at `frame` and edit its description.
fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) {
let flows = flows_of(doc, page);
let mine = flows.iter().find(|f| f.start == frame).cloned();
@@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra
}
}
/// List a node's interactions, add a default click navigation (or Back), open the editor
/// popover and remove interactions.
fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) {
let t = Tokens::get(ui.ctx());
let id = node.id;
@@ -696,6 +701,7 @@ fn animates(a: &Action) -> bool {
)
}
/// Stable egui id for the prototype key-capture flag.
fn capture_id() -> Id {
Id::unique("proto_key_capture")
}
@@ -704,6 +710,8 @@ fn capturing(ctx: &egui::Context) -> bool {
ctx.data(|d| d.get_temp::<bool>(capture_id())).unwrap_or(false)
}
/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture
/// and controller-button selection, patching the interaction.
fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) {
let t = Tokens::get(ui.ctx());
widgets::sublabels(ui, tr("Trigger"), None);
@@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
}
}
/// Draw an interaction's animation controls: transition, direction, easing, duration and
/// custom easing fields, plus an animated curve preview.
fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) {
let t = Tokens::get(ui.ctx());
widgets::sublabels(ui, tr("Animation"), None);
@@ -44,6 +44,7 @@ fn setup() -> (Ui, NodeId) {
(u, btn)
}
/// Stable id for a prototype node's open action path in the reorder tests.
fn open_id(btn: NodeId) -> egui::Id {
egui::Id::unique(("proto_open_action", btn, 0usize))
}
+18
View File
@@ -78,6 +78,8 @@ impl FigApp {
}
}
/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom
/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect.
pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
@@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
crate::export_panel::section(app, ui, &doc, &sel);
}
/// Draw the page section: background and colour profile, local variables/styles, and the
/// page export section.
fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
let t = Tokens::get(ui.ctx());
let Ok(page) = app.session.page() else { return };
@@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {
crate::export_panel::page_section(app, ui, doc, page);
}
/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation
/// swap and frame presets, component/mask/boolean quick actions, and instance details.
fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let t = Tokens::get(ui.ctx());
let label = match common(doc, sel, |n| n.kind.label()) {
@@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.add_space(8.0);
}
/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the
/// absolute-position toggle and frame constraints.
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let col = widgets::col_width(ui.available_width());
@@ -807,6 +815,8 @@ fn pretty(s: &str) -> String {
}
}
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
/// the blend-mode row.
fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let visible = common(doc, sel, |n| n.visible).unwrap_or(true);
@@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:
});
}
/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder,
/// stroke align/weight, per-side weights and advanced stroke settings.
fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) {
if fills
&& let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session)
@@ -1641,6 +1653,8 @@ fn paint_label(p: &Paint) -> String {
}
}
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
/// open picker and settings flags aligned when rows move or are removed.
fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let list = common(doc, sel, |n| n.effects.clone());
@@ -1723,6 +1737,8 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str,
crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width)
}
/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live
/// scrub edits that stay one undo step and bindable colour fields.
fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) {
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
let set = |app: &mut FigApp, e2: Effect, scrub: Option<bool>| {
@@ -1959,6 +1975,8 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect,
}
}
/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings,
/// and keep open-settings flags aligned when rows move or are removed.
fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
let list = common(doc, sel, |n| n.grids.clone());
+11
View File
@@ -34,6 +34,7 @@ fn caret_x(ctx: &egui::Context, text: &str, cursor: CCursor) -> f32 {
ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(11.0), Color32::WHITE).pos_from_cursor(cursor).min.x)
}
/// Read the primary character cursor of a test text field.
fn cursor(ctx: &egui::Context, id: Id) -> CCursor {
egui::TextEdit::load_state(ctx, id).unwrap().cursor.char_range().unwrap().primary
}
@@ -75,6 +76,8 @@ fn fonts() -> Option<egui::Context> {
Some(ctx)
}
/// Asserts names keep their logical text while glyphs paint in visual order, including
/// ellipsis placement at the overflow end and mixed-direction rows.
#[test]
fn names_keep_logical_text_and_paint_in_visual_order() {
let Some(ctx) = fonts() else { return };
@@ -137,6 +140,8 @@ fn names_keep_logical_text_and_paint_in_visual_order() {
});
}
/// Asserts Arabic letters take joined contextual forms and truncation puts the ellipsis at
/// the visual end of a right-to-left row.
#[test]
fn arabic_joining_and_truncation_use_the_visual_end() {
let Some(ctx) = fonts() else { return };
@@ -177,6 +182,8 @@ fn arabic_joining_and_truncation_use_the_visual_end() {
});
}
/// Asserts the shaper mirrors right-to-left parentheses in both glyph order and the atlas
/// shapes chosen for them.
#[test]
fn rtl_brackets_are_mirrored_by_the_shaper() {
let Some(ctx) = fonts() else { return };
@@ -211,6 +218,8 @@ fn rtl_brackets_are_mirrored_by_the_shaper() {
});
}
/// Asserts Right/Left move the caret visually while logical offsets jump, Shift extends
/// visually, and typing inserts at the logical caret; Latin text keeps egui's own caret.
#[test]
fn rename_caret_moves_visually_with_logical_offsets() {
let Some(ctx) = fonts() else { return };
@@ -266,6 +275,8 @@ fn rename_caret_moves_visually_with_logical_offsets() {
assert_eq!(caret(&ctx, lid).0, 2);
}
/// Asserts clicks map right-to-left visual positions to logical carets, so a click at the
/// visual left end leaves the caret at the logical end and typing appends there.
#[test]
fn clicks_place_the_logical_caret_by_what_is_shown() {
let Some(ctx) = fonts() else { return };
+3
View File
@@ -48,6 +48,9 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod
});
}
/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the
/// last-change note and Resize to fit; returns for an empty selection or one without a Dev
/// status.
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i));
if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) {
+6
View File
@@ -45,6 +45,7 @@ fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> {
Some((a.min(b), a.max(b)))
}
/// Asserts clicking a hex field focuses it and selects all six characters.
#[test]
fn a_click_into_a_hex_field_selects_it_all() {
let ctx = egui::Context::default();
@@ -57,6 +58,7 @@ fn a_click_into_a_hex_field_selects_it_all() {
assert_eq!(selection(&ctx, id.with("te")), Some((0, 6)));
}
/// Asserts clicking an inline field selects it all and typing replaces the selection.
#[test]
fn a_click_into_an_inline_field_selects_it_all() {
// A fill's hex in the Design panel, a variable's value, a rename.
@@ -75,6 +77,7 @@ fn a_click_into_an_inline_field_selects_it_all() {
assert_eq!(out.as_deref(), Some("red"));
}
/// Asserts clicking a search box selects its whole query.
#[test]
fn a_click_into_a_search_box_selects_its_query() {
let ctx = egui::Context::default();
@@ -87,6 +90,7 @@ fn a_click_into_a_search_box_selects_its_query() {
assert_eq!(selection(&ctx, id), Some((0, 6)));
}
/// Asserts Tab focus selects a field's whole value.
#[test]
fn tab_into_a_field_selects_it_all() {
let ctx = egui::Context::default();
@@ -106,6 +110,7 @@ fn tab_into_a_field_selects_it_all() {
assert_eq!(selection(&ctx, id), Some((0, 7)));
}
/// Asserts a drag selects a non-empty proper subrange rather than the whole value.
#[test]
fn a_drag_selects_only_what_it_covers() {
let ctx = egui::Context::default();
@@ -120,6 +125,7 @@ fn a_drag_selects_only_what_it_covers() {
assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}");
}
/// Asserts re-clicking a field after focus was lost mid-edit selects all.
#[test]
fn a_field_left_mid_edit_selects_all_on_the_next_click() {
let ctx = egui::Context::default();
+10
View File
@@ -24,6 +24,9 @@ struct Cache {
colors: Arc<Vec<SelectionColor>>,
}
/// Return the cached selection colours when the document and selection match. During an
/// interaction reuse an existing matching cache, or return an empty list without discovering
/// colours; otherwise discover and cache them.
fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacting: bool) -> Arc<Vec<SelectionColor>> {
let key = Id::unique("selection_colors_cache");
if let Some(cache) = ui.data(|d| d.get_temp::<Cache>(key))
@@ -49,6 +52,9 @@ pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {
app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
}
/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity
/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled
/// during an interaction.
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
let Ok(ds) = app.session.ds() else { return };
let interacting = ds.interaction.is_some();
@@ -249,6 +255,8 @@ mod tests {
assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors"));
}
/// Asserts the panel reuses cached colours through a live interaction and refreshes the cache
/// after the commit.
#[test]
fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() {
let (mut p, a, _) = Panel::new();
@@ -268,6 +276,8 @@ mod tests {
assert!(!Arc::ptr_eq(&before.colors, &after.colors));
}
/// Asserts no cached colours are discovered during an interaction, and cancelling restores
/// the panel with the original colours.
#[test]
fn panel_defers_discovery_without_a_matching_cache_during_interaction() {
let (mut p, a, _) = Panel::new();
+6
View File
@@ -24,6 +24,8 @@ struct LayoutCache {
reorder: Option<Arc<ReorderLayout>>,
}
/// Return one cached spacing/reorder calculation per document, page, selection and frame,
/// refreshing next frame after an edit and reusing an unchanged document snapshot.
fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpacing> {
let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) };
let key = Id::unique("selection_spacing_layout");
@@ -52,11 +54,13 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpa
});
layout
}
/// Return the cached reorder layout, first ensuring the shared layout cache is populated.
fn reorder_layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Option<Arc<ReorderLayout>> {
layout(app, ctx, ids);
ctx.data(|d| d.get_temp::<LayoutCache>(Id::unique("selection_spacing_layout"))).and_then(|c| c.reorder)
}
/// Stable egui id for spacing/reorder drag state.
fn drag_id() -> Id {
Id::unique("selection_spacing_drag")
}
@@ -107,6 +111,8 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec<NodeId>, p: Pos2,
}
}
/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with
/// `fromGesture`.
pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) {
let layout = layout(app, ui.ctx(), ids);
if layout.horizontal.is_none() && layout.vertical.is_none() {
@@ -43,6 +43,7 @@ fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) {
}
}
/// Asserts typed and scrubbed spacing changes take effect with exactly one undo step.
#[test]
fn spacing_fields_type_scrub_and_keep_one_undo_step() {
let (mut u, ids) = scene(true);
@@ -88,6 +89,8 @@ fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() {
}
}
/// Asserts Escape rolls a spacing drag back, and releasing outside the window commits once
/// while keeping the selection.
#[test]
fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() {
let (mut u, ids) = scene(false);
+11
View File
@@ -205,6 +205,9 @@ pub struct AfterFieldSave {
}
impl AfterFieldSave {
/// Run a deferred field-save binding only if the originating window still exists and owns
/// the document; temporarily activate that document, run the binding, then restore the
/// previously front tab.
fn run(self, app: &mut FigApp) {
// A closed file or one moved to another window cannot turn this into a save of the
// current tab. The UiState normally stays in its window's pane; validate that too.
@@ -299,6 +302,7 @@ pub(crate) fn revalidate_field_save(app: &FigApp, ctx: &Context, save: Option<Af
}
}
/// Stable id, per viewport and editor, for a rejected field-save cancellation.
fn field_save_cancellation(ctx: &Context, editor: egui::Id) -> egui::Id {
egui::Id::unique(("field_save_cancelled", ctx.viewport_id(), editor))
}
@@ -644,12 +648,16 @@ pub(crate) struct ImeInputBaseline {
}
impl ImeInputBaseline {
/// Capture an IME baseline only while a text edit and an active preedit exist, recording
/// the IME target and the caret/anchor.
pub(crate) fn capture(app: &FigApp) -> Option<Self> {
let te = app.session.ds().ok()?.text_edit.as_ref()?;
te.preedit.as_ref()?;
Some(Self { target: ime_target(app)?, caret: te.caret, anchor: te.anchor })
}
/// Whether the IME target changed, editing or the preedit ended, or the caret/anchor moved
/// since the baseline was captured.
fn interrupted(&self, app: &FigApp) -> bool {
ime_target(app) != Some(self.target)
|| app
@@ -661,6 +669,9 @@ impl ImeInputBaseline {
}
}
/// Apply a batch of text, IME, paste, copy, cut and key events: reject all IME events for an
/// interrupted composition, swallow text produced by a key that ran a shortcut, then finish
/// with triple-click line selection and preedit sync.
fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Option<ImeInputBaseline>) {
// Validate once before the batch: an accepted DeleteSurrounding deliberately clears preedit,
// but its following Commit still belongs to the same valid transaction. An interrupted
+2
View File
@@ -95,6 +95,8 @@ fn text_session() -> (Session, figcraft_doc::NodeId) {
(s, t)
}
/// Asserts hovering a text style previews it without an edit, leaving the popover restores
/// the value, and clicking applies one undo step and closes the picker.
#[test]
fn text_styles_preview_on_hover_and_apply_on_click() {
let (s, t) = text_session();
+2
View File
@@ -97,6 +97,8 @@ fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) ->
}
}
/// Draw the top tab bar: Home, document and presenter tabs, new-tab and window controls,
/// open/close/reorder animation and tab closing, pausing the presenter when going Home.
pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
+2
View File
@@ -173,6 +173,7 @@ impl Tokens {
}
}
/// Stable id for temporary theme tokens.
fn tokens_id() -> egui::Id {
egui::Id::unique("figcraft_tokens")
}
@@ -436,6 +437,7 @@ pub fn c32(c: figcraft_color::Color) -> Color32 {
Color32::from_rgba_unmultiplied(r, g, b, a)
}
/// Stable id for the current document-to-screen colour conversion.
fn screen_conversion_id() -> egui::Id {
egui::Id::unique("figcraft_screen_conversion")
}
@@ -84,6 +84,8 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() {
assert_eq!(Tokens::get(&ctx), Tokens::light());
}
/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts`
/// features by asserting both a Latin and an emoji glyph are available.
#[test]
fn tests_have_the_explicit_default_and_monochrome_emoji_font_features() {
// 0.37 egui has no default features, and kittest's own dev-features do not propagate to this
+2
View File
@@ -81,6 +81,8 @@ fn window_buttons_minimise_maximise_and_close() {
assert!(u.frame(vec![]).contains(&ViewportCommand::Close));
}
/// Asserts closing the custom titlebar over a presenter with unsaved changes asks with the
/// unsaved-changes dialog instead of emitting `Close`.
#[test]
fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() {
let mut u = Ui::new(TitleBar::Custom);
+3
View File
@@ -91,6 +91,7 @@ const BOARD_GROUPS: &[Group] = &[
Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]),
];
/// Return the last tool index remembered for a toolbar group, falling back to `default`.
fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
ui.data(|d| d.get_temp::<u8>(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default)
}
@@ -535,6 +536,8 @@ fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::P
}
}
/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered
/// selection when a tool is clicked, and Escape to close.
fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
let t = Tokens::get(ui.ctx());
let item_h = 32.0;
@@ -2,6 +2,8 @@
//! de paragraphe") stays inside the popover, which otherwise widened a little every frame.
use egui::{Area, Id, Pos2, RawInput, Rect, vec2};
/// Asserts a long type-settings row label does not widen the popover beyond 264 pt across
/// frames.
#[test]
fn a_long_label_does_not_widen_the_type_settings_popover() {
let ctx = egui::Context::default();
+5
View File
@@ -1167,10 +1167,13 @@ const ROW: f32 = 26.0;
/// The font list's tallest (it scrolls past that).
const LIST_HEIGHT: f32 = 380.0;
/// Return the persisted list of recently used font families.
fn recent_families(ctx: &egui::Context) -> Vec<String> {
ctx.data_mut(|d| d.get_persisted::<Vec<String>>(Id::unique(RECENT_KEY))).unwrap_or_default()
}
/// Remember a font family at the front of the recent list, removing an earlier
/// case-insensitive match and keeping at most eight entries.
fn remember_family(ctx: &egui::Context, family: &str) {
ctx.data_mut(|d| {
let v = d.get_persisted_mut_or_default::<Vec<String>>(Id::unique(RECENT_KEY));
@@ -1568,6 +1571,8 @@ mod variable_tests {
assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error);
}
/// Asserts bound font family and style chips replace the dropdowns, a selected text range
/// offers no variable binding, and detaching restores the last values.
#[test]
fn bound_font_family_and_style_show_their_variables_and_detach() {
let mut p = Panel::new();
@@ -44,6 +44,7 @@ impl Fields {
out.textures_delta.clear();
out.platform_output.cursor_icon
}
/// Screen position of a typography field's label/lead, used to start a scrub.
fn lead(&self, key: &str) -> Pos2 {
let rect = self.ctx.read_response(Id::unique(key).with("click")).unwrap().rect;
Pos2::new(rect.min.x + 12.0, rect.center().y)
@@ -157,6 +158,8 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex
assert_eq!(f.value("ty_ls"), 0.0);
}
/// Asserts typography spacing fields accept typed percent units, commit on Enter, and
/// Escape restores the previous value without adding an undo step.
#[test]
fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() {
let mut f = Fields::new();
+1
View File
@@ -40,6 +40,7 @@ struct Picker {
at: Pos2,
}
/// Stable egui id for the variable picker.
fn picker_id() -> Id {
Id::unique("variable_picker")
}
+9
View File
@@ -173,6 +173,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
app.ui.variables = open.then_some(st);
}
/// Draw the variables header: the title at the left, and the import/export/Close controls at the
/// right.
fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
@@ -253,6 +255,7 @@ fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) {
}
}
/// Stable egui id for the rename field of a `Rename` target.
fn rename_id(target: &Rename) -> Id {
Id::unique(("var_rename", target))
}
@@ -281,6 +284,8 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
(1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
/// Draw the collections sidebar with library headings, collection rows and a create-collection
/// button.
fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) {
let t = Tokens::get(ui.ctx());
let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover());
@@ -505,6 +510,8 @@ fn group_of(name: &str) -> &str {
name.rsplit_once('/').map(|(g, _)| g).unwrap_or("")
}
/// Draw the variables table for the selected collection: search, library/extended notes,
/// grouped rows, mode columns and the create-variable row when editable.
fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
let t = Tokens::get(ui.ctx());
// A sidebar click this frame picked another source: shown next frame.
@@ -594,6 +601,8 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
});
}
/// Draw a collection's mode header row with rename, duplicate, reorder and delete actions
/// and an add-mode button when the collection is editable and not extended.
fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) {
let t = Tokens::get(ui.ctx());
// An extended collection has the modes of the one it extends.
+2
View File
@@ -72,6 +72,8 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
ui.add_space(8.0);
}
/// Draw the selected points' position, dimensions, corner radius, mirroring and per-point
/// stroke cap/join controls; the width tool delegates to its own panel.
pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
if ve.tool == vectoredit::VTool::Width {
width_point(app, ui, doc, ve);
+2
View File
@@ -403,6 +403,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool {
!active && resp.on_hover_text(tr("Back to the current version")).clicked()
}
/// Draw one version row with timeline, name, time, up to three description lines and a
/// more/context menu; returns the chosen preview, restore, edit, duplicate or delete action.
fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<Action> {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
+3
View File
@@ -223,6 +223,9 @@ fn cancel_retargeted_field_save(ui: &mut Ui, id: Id) {
ui.memory_mut(|m| m.surrender_focus(editor));
}
/// Implement the shared number field: label/icon lead, scrubbing, keyboard stepping and
/// typing, select-all when a click starts editing, bindable values and deferred-save
/// cancellation; returns an edit and/or bind event.
fn number_field_impl(
ui: &mut Ui,
id: Id,
+14
View File
@@ -45,6 +45,8 @@ fn names(app: &FigApp) -> Vec<String> {
app.session.docs.iter().map(|d| d.doc.name.clone()).collect()
}
/// Asserts closing a window in the presenter shows the unsaved-changes dialog and does not
/// dismiss the preview.
#[test]
fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() {
let mut session = Session::new();
@@ -104,6 +106,8 @@ fn windows_hold_tabs_and_close_with_their_files() {
assert_eq!(app.session.ds().unwrap().doc.name, "A");
}
/// Asserts a hidden other window answers edits and inspection in its own file while the main
/// window keeps its file, and a capture parks with a deadline and times out once.
#[test]
fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() {
let mut s = Session::empty();
@@ -180,6 +184,8 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() {
assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap());
}
/// Asserts every direct UI frame clears the logic-tick marker so background work runs each
/// frame.
#[test]
fn every_direct_ui_frame_runs_background_work() {
// Direct `App::ui` harnesses never call `App::logic`: the tick marker the UI pass consumes
@@ -192,6 +198,8 @@ fn every_direct_ui_frame_runs_background_work() {
}
}
/// Asserts synthetic input for a child window reaches the pane that draws it, leaving the
/// main window's pane untouched.
#[test]
fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() {
let mut s = Session::empty();
@@ -214,6 +222,8 @@ fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() {
assert_eq!(crate::windows::take_input(&mut app, other), events, "the child's next frame takes its batch");
}
/// Asserts a child raw-input hook with no batch does not error or drain the root window's
/// pending acknowledgement.
#[test]
fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() {
let mut s = Session::empty();
@@ -238,6 +248,8 @@ fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() {
assert_eq!(app.input_acks[0].reply.id, json!(5));
}
/// Asserts a child window runs `viewport_setup` and its pane keeps the dark theme after the
/// swap back.
#[test]
fn a_child_window_runs_the_ui_setup_the_main_window_does() {
// eframe calls `App::ui` only for the root window, so a child that draws while the root does
@@ -263,6 +275,8 @@ fn a_child_window_runs_the_ui_setup_the_main_window_does() {
assert_eq!(again, Some(json!(true)));
}
/// Asserts a closed and later reused viewport does not inherit its old IME context
/// generation.
#[test]
fn an_emptied_window_forgets_its_ime_context() {
// `windows::begin` ends the viewport when a window loses its last file; a later window that