diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d1723d8..dd2aefdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,14 @@ jobs: with: app-name: figcraft app-title: FigCraft - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only + # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. + # Re-pin once one commit has both (storytold/craft-fonts#42). + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 has-server: true + # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). + # crates/text/build.rs keeps the web build to Inter. + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ROADMAP.md b/ROADMAP.md index 22d49f33..b70dcdec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -317,6 +317,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | +| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b8f6c154..4ebbec15 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status}; use web_time::Instant; use crate::inbox::Inbox; -use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// A WebSocket the driver opens, writes and closes; what it receives comes back through the @@ -114,6 +114,9 @@ impl Driver { /// A binary message from the host. pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { let Some(c) = self.conn.as_mut() else { return }; + if bytes.len() > self.cfg.limits.max_server_message { + return self.end(too_large(&self.cfg.limits), now); + } c.last_heard = now; let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { // The join code's answer: join the file it opens, on a new connection. diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ca6563c5..5e395b98 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, Status}; +use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() { assert!(d.done()); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused"))); } + // A message over the size limit: the file is too large for this app, not refused by the host. + let inbox = Inbox::new(Arc::new(|| {})); + let mut cfg = GuestConfig::new(invite(), "Ada".into()); + cfg.limits.max_server_message = 2 << 20; + let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0); + d.opened(t0); + d.message(&vec![0; (2 << 20) + 1], t0); + assert!(d.done()); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2"))); } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index eb899b8d..2e1d5046 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; pub use crate::join::GuestConfig; -use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::manage; use crate::net::{Duplex, NetLimits, config, open, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()), + Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits), Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 81cd2437..cc3be839 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status}; +use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { }) } +/// The host sent a message over this app's size limit (the file's welcome, usually): final, and +/// not the host's doing. +pub(crate) fn too_large(limits: &NetLimits) -> Outcome { + Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) +} + /// The server's answer to the join code: the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 32c86bd0..a043d8c7 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, PEOPLE, Status}; +use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } + +#[test] +fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { + let mut doc = Document::new("B".repeat(1 << 20)); + doc.repair(); + let probe = Probe::new(); + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits: fast_limits(), + core: Limits::default(), + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() }; + let mut gp = Probe::new(); + let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap(); + let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1"); + gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large))); + assert!(!welcomed(&gp.events)); + host.stop(); +} diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index ed13de5f..cd8a0f03 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -86,6 +86,10 @@ pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { Some((byte(0)?, byte(2)?, byte(4)?)) } +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + /// The connection to a live session. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf833be..ae566fc8 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1393,6 +1393,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1187c658..596feda6 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1394,6 +1394,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 96bd20bf..49163986 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1011,7 +1011,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); } } // Selection. @@ -1035,8 +1035,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // A layer smaller than its handles on screen shows just its outline (they still answer // just outside it). let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; + // Measuring to another layer (Alt held) shows just the outlines: no handles or size. + let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { 0 } else if is_line { 2 @@ -1045,9 +1047,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } else { 4 }; - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label); - arc_overlay(app, painter, col); - shape_overlay(app, painter, col); + size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label)); + if !measuring { + arc_overlay(app, painter, col); + shape_overlay(app, painter, col); + } if app.session.tool.tool == ToolId::Scale { // The point that stays put: the dragged pivot, else the anchor. let pivot = scale @@ -1185,8 +1189,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } /// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` -/// corner handles and, when dimensions are shown, the size `label` under it (its box returned). -fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option { +/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned). +fn transform_box( + app: &FigApp, + painter: &egui::Painter, + q: [Pos2; 4], + col: Color32, + outline: bool, + handles: usize, + label: Option, +) -> Option { if outline { quad(painter, q, stroke(col, 1.0)); } @@ -1195,7 +1207,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); } - if app.session.prefs.show_dimensions { + if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) { let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); let cx = q.iter().map(|p| p.x).sum::() / 4.0; return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col)); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e5..fea0be8e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -549,3 +549,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); } + +/// The middle of the field with response `id`. +fn at(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +/// Type `text` into the field whose response is `id` and press Enter. +fn type_at(u: &mut Ui, id: Id, text: &str) { + let p = at(u, id); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A frame with one layout grid, its settings open in the Design panel. +fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.frame(vec![]); + (u, f) +} + +/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the +/// grid settings sit below the fold. +fn scroll_to(u: &mut Ui, id: Id) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..10 { + let p = at(u, id); + if p.y < 860.0 { + break; + } + u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } + } +} + +/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count +/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's +/// placeholder said 0 everywhere, which turned the gap's mode off. +#[test] +fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let count_id = Id::new(("grid_field", "count", 0usize)); + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); + let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; + scroll_to(&mut u, count_id); + type_at(&mut u, count_id, "Auto"); + assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0"); + type_at(&mut u, count_id, "4"); + assert_eq!(count(&u), 4, "and a number is that count"); + // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); + let size_id = Id::new(("grid_field", "size", 0usize)); + scroll_to(&mut u, size_id); + type_at(&mut u, size_id, "40"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + // Nor its count: a stretched grid has at least one band, so the word commits nothing. + type_at(&mut u, count_id, "Auto"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); + + // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap(); + s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; + let before = gap(&u); + type_at(&mut u, Id::new("al_gap"), "Auto"); + assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); +} diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index fcf21fde..046212c1 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> { v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); + v.push(figcraft_engine::live::TOO_LARGE_TO_JOIN); // The starter templates' names, descriptions and kinds. v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v @@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() { assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); } +#[test] +fn a_live_failure_the_ui_knows_is_read_back_for_translation() { + use figcraft_engine::live::TOO_LARGE_TO_JOIN; + let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024"); + assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason)); + assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_))); +} + #[test] fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 84aedd6a..072aec62 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -147,6 +147,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod measure_nudge_tests; +#[cfg(test)] mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; @@ -1510,7 +1512,7 @@ impl FigApp { // What happened in a live session (the network woke us for it). if self.session.live_poll() { if let Some(m) = self.session.message.take() { - self.toast(m); + self.toast(live::failure_text(&m)); } self.background_done(&ctx); } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 513060ab..a4baa303 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -13,10 +13,11 @@ //! and it fades a few seconds after the last change (frames are asked for only until then). //! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. +use std::borrow::Cow; use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page}; use serde_json::{Value, json}; use crate::FigApp; @@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { (Status::Reconnecting { attempt }, _) => { (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } - (Status::Failed { .. }, _) => (status.label(), t.danger), + (Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger), _ => (tr(&status.label()).to_string(), t.text_secondary), }; if !line.is_empty() { @@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { match app.session.live.get(session).map(|l| &l.status) { Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary), - Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger), + Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger), _ => {} } } +/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's +/// English otherwise). +pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { + let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after)); + mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)]))) +} + /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { diff --git a/crates/ui-egui/src/measure_nudge_tests.rs b/crates/ui-egui/src/measure_nudge_tests.rs new file mode 100644 index 00000000..b4a5a247 --- /dev/null +++ b/crates/ui-egui/src/measure_nudge_tests.rs @@ -0,0 +1,64 @@ +//! Nudging while measuring (#894), driven through egui headless: with Alt held over another layer +//! the arrows still nudge the selection (Shift: the big step), the distance follows every nudge, +//! and the selection's handles and size label hide while measuring (its outline stays). + +use egui::{Event, Key, Modifiers, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::flat; +use crate::present_tests::id; + +fn labels(u: &Ui) -> Vec { + u.app.session.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect() +} + +fn top(u: &Ui, i: NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(i).unwrap().y0 +} + +/// Press and release `key` with `m` held, then a settled frame (the pointer still over the canvas). +fn press(u: &mut Ui, key: Key, m: Modifiers, at: Pos2) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: m }; + u.frame_with(vec![ev(true), ev(false)], m); + u.frame_with(vec![Event::PointerMoved(at)], m); +} + +/// Whether the frame drew corner handles or a text `label`. +fn drew(shapes: &[Shape], label: &str) -> (bool, bool) { + let handle = shapes.iter().any(|s| matches!(s, Shape::Rect(r) if (r.rect.width() - 8.0).abs() < 0.1 && (r.rect.height() - 8.0).abs() < 0.1)); + let text = shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label)); + (handle, text) +} + +#[test] +fn arrows_nudge_while_measuring_and_the_distance_follows() { + let mut s = Session::new(); + let upper = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let lower = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 100, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [lower]})).unwrap(); + let mut u = Ui::new(s); + let over = u.app.to_screen(50.0, 20.0); + let (alt, shift_alt) = (Modifiers { alt: true, ..Modifiers::NONE }, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + + // Not measuring: the selection shows its handles and size. + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], Modifiers::NONE).shapes); + assert_eq!(drew(&shapes, "100 × 40"), (true, true), "handles and size label show"); + + u.frame_with(vec![Event::PointerMoved(over)], alt); + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], alt).shapes); + assert!(u.app.session.tool.overlay.measure_box.is_some(), "Alt over the other layer measures to it"); + assert!(labels(&u).contains(&"100".to_string()), "the gap: {:?}", labels(&u)); + assert_eq!(drew(&shapes, "100 × 40"), (false, false), "measuring hides the handles and size label"); + + press(&mut u, Key::ArrowUp, alt, over); + assert_eq!(top(&u, lower), 139.0, "Alt+Up nudges by the small step"); + assert!(labels(&u).contains(&"99".to_string()), "the gap follows the nudge: {:?}", labels(&u)); + + press(&mut u, Key::ArrowUp, shift_alt, over); + assert_eq!(top(&u, lower), 129.0, "Shift+Alt+Up nudges by the big step"); + assert!(labels(&u).contains(&"89".to_string()), "the gap follows the big nudge: {:?}", labels(&u)); + assert_eq!(top(&u, upper), 0.0, "the measured layer stays put"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index d807a694..747dbaf6 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -926,7 +926,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { let shown = offset.and_then(|o| o.get(i).copied()); - let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + // "Auto" is no offset (right below the layer), not 0: the word isn't taken + // as a value here. + let Some(e) = widgets::number_field_bindable( + ui, + slot.id(key), + Lead::Text(name), + shown, + "", + w, + 1.0, + None, + widgets::Empty::Text(tr("Auto")), + ) + .0 else { continue; }; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index f2834080..afa884a8 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2056,8 +2056,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = - crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, ""); + let id = Id::new(("grid_field", "count", i)); + // A stretched grid has at least one band, so 0 (Auto) isn't a count it can take. + let e = if g.align == GridAlign::Stretch { + crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "") + } else { + crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "") + }; app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); @@ -2090,11 +2095,17 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let span = if cols { tr("Width") } else { tr("Height") }; match g.align { GridAlign::Stretch => { - widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter"))); + widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { + // The frame, divided by the count: shown as Auto and not editable. + ui.add_enabled_ui(false, |ui| { + let auto = widgets::Empty::Text(tr("Auto")); + widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto) + }); field(app, ui, "offset", g.offset, &offset); - field(app, ui, "gutter", g.gutter, &gutter); }); + widgets::sublabels(ui, tr("Gutter"), None); + row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter)); } GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f3197960..c86cfdf1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -279,7 +279,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { return true; } - // Arrow nudges; with nothing selected the arrows pan the view. + // Arrow nudges, Alt held too (measuring stays on while the selection moves); with nothing + // selected the arrows pan the view. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0), Key::ArrowRight => (1.0, 0.0), @@ -287,7 +288,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re Key::ArrowDown => (0.0, 1.0), _ => (0.0, 0.0), }; - if (dx, dy) != (0.0, 0.0) && !m.alt { + if (dx, dy) != (0.0, 0.0) { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); } else if m.command { diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index e6cc6860..39c5396e 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -17,7 +17,7 @@ use crate::a11y::Tip; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -258,7 +258,23 @@ pub fn number_field_or( lead: Lead, suffix: &str, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled() + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled() +} + +/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or +/// count): typing the word for Auto sets 0 ([`Empty::Auto`]). +pub fn number_field_auto( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + lead: Lead, + suffix: &str, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled() } /// [`number_field`] over several layers: a relative edit of mixed values comes back as one for @@ -274,7 +290,7 @@ pub fn number_field_mixed( suffix: &str, width: f32, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix) } fn number_field_edit( @@ -285,7 +301,7 @@ fn number_field_edit( id: Id, value: Option, width: f32, - empty: &str, + empty: Empty<'_>, lead: Lead, suffix: &str, ) -> Option { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ca7504ae..2d986c64 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -37,6 +37,25 @@ impl Lead<'_> { } } +/// What a number field shows when it has no single value, and whether that word types 0. +#[derive(Clone, Copy)] +pub enum Empty<'a> { + /// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else). + Text(&'a str), + /// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0. + Auto, +} + +impl<'a> Empty<'a> { + /// The text shown when the field has no value. + fn text(self) -> &'a str { + match self { + Empty::Text(s) => s, + Empty::Auto => tr("Auto"), + } + } +} + /// Result of a field edit. pub enum FieldEdit { /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). @@ -157,18 +176,19 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { - number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0 + number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0 } /// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there /// is no value to edit relative to, so `+4` is 4. pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the -/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"). +/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes +/// the word for Auto as 0. pub fn number_field_bindable( ui: &mut Ui, id: Id, @@ -178,7 +198,7 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -192,7 +212,7 @@ fn number_field_impl( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -210,7 +230,7 @@ fn number_field_impl( let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); - a11y::number(&field_resp, lead.name(), value, suffix, empty); + a11y::number(&field_resp, lead.name(), value, suffix, empty.text()); a11y::ring_around(ui, rect, &field_resp); // The label scrubs with the pointer; the keyboard stops at the value only. let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG); @@ -259,10 +279,21 @@ fn number_field_impl( }; // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); - let typed = |text: &str| match (parse(text, suffix == "%")?, value) { - (Typed::Value(v), _) => Some(set(v)), - (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), - (Typed::Rel(r), None) => Some(Edit::Each(r)), + let typed = |text: &str| { + // A field where 0 is its automatic value takes the word for Auto as input too: the + // placeholder's own text (so it is what the field shows, in the UI's language) or the + // English word. + if let Empty::Auto = empty { + let t = text.trim(); + if t == empty.text() || t.eq_ignore_ascii_case("auto") { + return Some(set(0.0)); + } + } + match (parse(text, suffix == "%")?, value) { + (Typed::Value(v), _) => Some(set(v)), + (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), + (Typed::Rel(r), None) => Some(Edit::Each(r)), + } }; // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. if editing.is_none() @@ -349,7 +380,7 @@ fn number_field_impl( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => empty.to_string(), + None => empty.text().to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; // A value too long for its room ends in "…", so a cut number never reads as another. diff --git a/docs/releasing.md b/docs/releasing.md index 7e9220ff..07492f6c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -95,13 +95,17 @@ examples from `packaging/server/` (systemd unit, Caddyfile, nginx; the Dockerfil the sources). Linux only for now; elsewhere operators build it with `cargo build --release -p figcraft-server`. -**Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at -`CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and -`CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them. +**Fonts.** Every build job, the web one included (`web-craft-fonts`), checks out +[craft-fonts](https://github.com/storytold/craft-fonts) at `craft-fonts-ref` (in `release.yml`) and +builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail +rather than ship without them. The browser has no system fonts, so a web build without craft-fonts +can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). The web build embeds -only Inter. Packages carry each embedded font's licence as `OFL-.txt` +`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no +Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an +installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, +Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS diff --git a/docs/translating.md b/docs/translating.md index b7b23d0b..04d9906b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -66,8 +66,9 @@ something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build ha so Japanese, Korean and Chinese appear there only in a build with craft-fonts and `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in -craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`. Web builds -keep it only when built with craft-fonts (`crates/text/build.rs`); the web release isn't, so its +craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`, or else in an +installed font on the desktop. Web builds keep it only when the craft-fonts they're built with has +it (`crates/text/build.rs`); the commit releases pin doesn't yet (`docs/releasing.md`), so the web picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in `cjk::language`, or a built-in craft-fonts face. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1..5b45eeb3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -64,7 +64,7 @@ interaction feel not yet compared. | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | | Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | -| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | +| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |