mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-03 00:39:44 -06:00
Merge main into fix/issue-894
# Conflicts: # ROADMAP.md
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -317,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 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-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. |
|
||||
|
||||
@@ -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<S: Socket> Driver<S> {
|
||||
/// 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::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Duplex>, 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()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1512,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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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<FieldEdit> {
|
||||
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<f64>,
|
||||
width: f32,
|
||||
lead: Lead,
|
||||
suffix: &str,
|
||||
) -> Option<FieldEdit> {
|
||||
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<Edit> {
|
||||
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<f64>,
|
||||
width: f32,
|
||||
empty: &str,
|
||||
empty: Empty<'_>,
|
||||
lead: Lead,
|
||||
suffix: &str,
|
||||
) -> Option<Edit> {
|
||||
|
||||
@@ -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<f64>, 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<f64>, suffix: &str, width: f32, step: f64) -> Option<Edit> {
|
||||
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<f64>, empty: &str, width: f32, step: f64) -> Option<FieldEdit> {
|
||||
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<Option<&str>>,
|
||||
empty: &str,
|
||||
empty: Empty<'_>,
|
||||
) -> (Option<Edit>, Option<BindEvent>) {
|
||||
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<Option<&str>>,
|
||||
empty: &str,
|
||||
empty: Empty<'_>,
|
||||
) -> (Option<Edit>, Option<BindEvent>) {
|
||||
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<String> = 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.
|
||||
|
||||
+9
-5
@@ -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-<family>.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-<family>.txt`
|
||||
(`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app).
|
||||
|
||||
### macOS
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user