mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into the Bosnian catalog follow-up
Main merged #1652 (the Bosnian catalog) and added more strings; ROADMAP keeps main's rows with the count updated, and bs.toml translates the two new strings (a pasted table's size limit, connector labels without a snapshot), so it covers all 1,906 UI strings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Tayw8nmxoLLmKv13jK93z
This commit is contained in:
+12
-3
File diff suppressed because one or more lines are too long
@@ -21,18 +21,10 @@ fn a_flood_of_changes_from_one_address_is_held_back_and_others_are_not() {
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Poster", &snapshot("Poster")).unwrap();
|
||||
let flooder = ip("198.51.100.1");
|
||||
let start = std::time::Instant::now();
|
||||
// The clock held still, so the budget doesn't refill while the requests run.
|
||||
store.frozen = Some(std::time::Instant::now());
|
||||
let done = (0..100).filter(|_| rename(&mut store, flooder, &file.id) == AdminReply::Done).count();
|
||||
// The budget refills one request a second, so a slow run (a loaded test machine) lets a few more in.
|
||||
let budget = ADMIN_PER_MINUTE as usize;
|
||||
let refilled = start.elapsed().as_secs() as usize + 1;
|
||||
assert!((budget..=budget + refilled).contains(&done), "a minute's worth, then wait: {done} done");
|
||||
// Use up whatever refilled meanwhile; the request right after is refused.
|
||||
for _ in 0..refilled {
|
||||
if rename(&mut store, flooder, &file.id) != AdminReply::Done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(done, ADMIN_PER_MINUTE as usize, "a minute's worth, then wait");
|
||||
assert_eq!(rename(&mut store, flooder, &file.id), AdminReply::Failed { reason: BUSY_ADMIN.into() });
|
||||
// An owner token's requests share the address's budget.
|
||||
let hosted = store.hosted(flooder, Some(&file.id), Admin::Rename { file: file.id.clone(), name: "Mine".into() }, &|_| None).reply;
|
||||
|
||||
@@ -58,6 +58,8 @@ fn large_downloads_keep_an_address_to_its_rate() {
|
||||
fn a_library_larger_than_the_burst_is_downloaded_then_the_address_waits() {
|
||||
let dir = TempDir::new("biglib");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
// The clock held still, so the address's share doesn't refill while the test runs.
|
||||
store.frozen = Some(Instant::now());
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 40 * MIB)).unwrap();
|
||||
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
|
||||
@@ -525,6 +525,9 @@ fn downloads_have_a_share_per_address() {
|
||||
// A team library is served within it, then refused, saying why; another address still gets it.
|
||||
let dir = TempDir::new("libshare");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
// The clock held still: on a loaded machine the 40 fetches can take seconds, long enough for
|
||||
// the address's share to refill one more.
|
||||
store.frozen = Some(t);
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 4 << 20)).unwrap();
|
||||
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
|
||||
|
||||
@@ -286,6 +286,9 @@ pub struct Store {
|
||||
abuse: Abuse,
|
||||
/// Team library bytes served, everyone together.
|
||||
served: figcraft_collab_net::net::Bucket,
|
||||
/// Tests: the time the rate limits read, held still (`None`: the clock).
|
||||
#[cfg(test)]
|
||||
pub(crate) frozen: Option<Instant>,
|
||||
/// Usage reports by library file.
|
||||
usage: BTreeMap<String, UsageCache>,
|
||||
blocked: BTreeSet<IpAddr>,
|
||||
@@ -391,6 +394,15 @@ fn write_key(root: &Path) -> Result<String, String> {
|
||||
const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32;
|
||||
|
||||
impl Store {
|
||||
/// The time the rate limits are measured at: the clock, or in tests the time they hold it at.
|
||||
pub(crate) fn clock(&self) -> Instant {
|
||||
#[cfg(test)]
|
||||
if let Some(t) = self.frozen {
|
||||
return t;
|
||||
}
|
||||
Instant::now()
|
||||
}
|
||||
|
||||
/// Use the data directory `root` (made if missing). Fails when another server or command holds
|
||||
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
|
||||
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
|
||||
@@ -420,6 +432,8 @@ impl Store {
|
||||
directory,
|
||||
abuse,
|
||||
served: figcraft_collab_net::net::Bucket::new(LIBRARY_RATE, LIBRARY_BURST),
|
||||
#[cfg(test)]
|
||||
frozen: None,
|
||||
usage: BTreeMap::new(),
|
||||
blocked: BTreeSet::new(),
|
||||
reports: Vec::new(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::journal::flush_log;
|
||||
use super::{
|
||||
Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr,
|
||||
LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret,
|
||||
Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, IpAddr, LibraryReply,
|
||||
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret,
|
||||
};
|
||||
|
||||
/// Whether `req` changes the server's files (a write, a removal or a join code's stretching):
|
||||
@@ -123,7 +123,7 @@ impl Backend for Store {
|
||||
if self.blocked.contains(&ip) {
|
||||
return Err("this server doesn't accept connections from your address".into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Connect, Instant::now()) {
|
||||
if !self.abuse.allow(ip, Action::Connect, self.clock()) {
|
||||
return Err("too many connections from your address lately: wait a minute and try again".into());
|
||||
}
|
||||
Ok(())
|
||||
@@ -133,7 +133,7 @@ impl Backend for Store {
|
||||
// Cursors and cursor chat (many people behind one address each send ~30 a second while
|
||||
// they move) have a budget of their own, beside everything else.
|
||||
let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message };
|
||||
self.abuse.allow(ip, what, Instant::now())
|
||||
self.abuse.allow(ip, what, self.clock())
|
||||
}
|
||||
|
||||
fn hosting(&mut self, file: &str) -> Option<HostInfo> {
|
||||
@@ -186,7 +186,7 @@ impl Backend for Store {
|
||||
|
||||
fn admin(&mut self, ip: IpAddr, req: Admin, open: &dyn Fn(&str) -> Option<OpenFile>) -> Handled {
|
||||
let failed = |reason: String| Handled::from(AdminReply::Failed { reason });
|
||||
if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, Instant::now()) {
|
||||
if changes_files(&req) && !self.abuse.allow(ip, Action::Admin, self.clock()) {
|
||||
log::info!("event=refused ip={ip} reason=admin_per_address");
|
||||
return failed(BUSY_ADMIN.into());
|
||||
}
|
||||
@@ -253,7 +253,7 @@ impl Backend for Store {
|
||||
// One address's share first (before everyone's budget is spent or anything is read).
|
||||
if let PluginReq::Fetch { id, version } = &req
|
||||
&& let Some(size) = self.directory.size_of(id, version.as_deref())
|
||||
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now())
|
||||
&& !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), self.clock())
|
||||
{
|
||||
return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() };
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ impl Store {
|
||||
if !open {
|
||||
return Err(KEY_ONLY.into());
|
||||
}
|
||||
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
|
||||
if !self.abuse.allow(ip, Action::Publish, self.clock()) {
|
||||
return Err("too many files published from your address lately: try again in an hour".into());
|
||||
}
|
||||
let blocks = self.per_block(&ip.to_string());
|
||||
@@ -340,7 +340,7 @@ impl Store {
|
||||
return Err("say what is wrong with the file".into());
|
||||
}
|
||||
let name = self.entry(file)?.meta.name.clone();
|
||||
if !self.abuse.allow(ip, Action::Report, Instant::now()) {
|
||||
if !self.abuse.allow(ip, Action::Report, self.clock()) {
|
||||
return Err("too many reports from your address lately: try again in an hour".into());
|
||||
}
|
||||
self.reports.push(ReportInfo {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
|
||||
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
|
||||
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
|
||||
write_json,
|
||||
};
|
||||
@@ -15,7 +15,7 @@ impl Store {
|
||||
}
|
||||
let path = library_file(&dir, lib.version);
|
||||
let size = fs::metadata(&path).map_or(0, |m| m.len());
|
||||
let now = Instant::now();
|
||||
let now = self.clock();
|
||||
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_spread(now, size as f64) {
|
||||
return Err(BUSY_DOWNLOADS.into());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Spreadsheet cells as a board table's cells (`board_table::spreadsheet_cells`): rows on lines,
|
||||
//! cells between tabs, quoted cells holding tabs, line breaks and quotes; other text isn't a table.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::board_table::{MAX_COLUMNS, MAX_ROWS, spreadsheet_cells};
|
||||
|
||||
fn cells(text: &str) -> Vec<Vec<String>> {
|
||||
let (cells, more) = spreadsheet_cells(text).expect("a table");
|
||||
assert!(!more, "nothing left out");
|
||||
cells
|
||||
}
|
||||
|
||||
fn rows(r: &[&[&str]]) -> Vec<Vec<String>> {
|
||||
r.iter().map(|row| row.iter().map(|c| (*c).to_string()).collect()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_text_makes_rows_of_cells() {
|
||||
assert_eq!(cells("Name\tRole\nAda\tLead\n"), rows(&[&["Name", "Role"], &["Ada", "Lead"]]), "a copy ends with a line break");
|
||||
assert_eq!(cells("Name\tRole\r\nAda\tLead\r\n"), rows(&[&["Name", "Role"], &["Ada", "Lead"]]), "Windows line breaks");
|
||||
assert_eq!(cells("a\t\tc\n\tb\t"), rows(&[&["a", "", "c"], &["", "b", ""]]), "empty cells keep their place");
|
||||
assert_eq!(cells("a\tb\nc"), rows(&[&["a", "b"], &["c"]]), "a short row stays short (the table fills it)");
|
||||
assert_eq!(cells("Title\nx\ty"), rows(&[&["Title"], &["x", "y"]]), "one row of two cells is enough");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_cells_hold_tabs_line_breaks_and_quotes() {
|
||||
let text = "\"a\tb\"\t\"line 1\nline 2\"\t\"say \"\"hi\"\"\"\n\"\"\tplain \"quote\"";
|
||||
assert_eq!(cells(text), rows(&[&["a\tb", "line 1\nline 2", "say \"hi\""], &["", "plain \"quote\""]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_text_is_not_a_table() {
|
||||
for text in ["", "just text", "one\ntwo\nthree", "a, b, c\n1, 2, 3", "\n\n"] {
|
||||
assert_eq!(spreadsheet_cells(text), None, "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_table_keeps_at_most_its_largest_size() {
|
||||
let row = vec!["x"; MAX_COLUMNS + 5].join("\t");
|
||||
let text = vec![row.as_str(); MAX_ROWS + 10].join("\n");
|
||||
let (cells, more) = spreadsheet_cells(&text).expect("a table");
|
||||
assert!(more, "the rest is left out, and said so");
|
||||
assert_eq!(cells.len(), MAX_ROWS);
|
||||
assert!(cells.iter().all(|r| r.len() == MAX_COLUMNS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_cells_make_a_board_table() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
let (cells, _) = spreadsheet_cells("Name\tRole\tTeam\nAda\tLead\n").unwrap();
|
||||
let r = s.execute("board.table", &json!({"cells": cells, "x": 0, "y": 0})).unwrap();
|
||||
assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(2), Some(3)));
|
||||
let doc = s.doc().unwrap();
|
||||
let text = |row: usize, col: usize| {
|
||||
let id = crate::params::id_of(&r["cells"][row][col]).unwrap();
|
||||
doc.node(id).and_then(|n| n.kind.text()).map(|t| t.characters.clone()).unwrap()
|
||||
};
|
||||
assert_eq!((text(0, 0), text(0, 2), text(1, 1), text(1, 2)), ("Name".into(), "Team".into(), "Lead".into(), String::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let parts = ["\t", "\n", "\r", "\r\n", "\"", "\"\"", "a", "é", "漢", " "];
|
||||
let mut seed = 7u64;
|
||||
for _ in 0..2000 {
|
||||
let mut text = String::new();
|
||||
for _ in 0..(seed % 24) {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
text.push_str(parts[(seed >> 33) as usize % parts.len()]);
|
||||
}
|
||||
if let Some((cells, _)) = spreadsheet_cells(&text) {
|
||||
assert!(cells.len() <= MAX_ROWS && cells.iter().all(|r| r.len() <= MAX_COLUMNS));
|
||||
assert!(cells.iter().any(|r| r.len() > 1), "{text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,57 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Cells copied from a spreadsheet, as `board.table`'s `cells`: the clipboard holds them as text,
|
||||
/// a row per line and a tab between cells, a cell holding a tab, a line break or a quote quoted
|
||||
/// (`""` for a quote). At most [`MAX_ROWS`] × [`MAX_COLUMNS`] are kept; the flag says whether
|
||||
/// there were more. `None` for text without a tab, or with one cell per row (other text: it
|
||||
/// pastes as text).
|
||||
pub fn spreadsheet_cells(text: &str) -> Option<(Vec<Vec<String>>, bool)> {
|
||||
if !text.contains('\t') {
|
||||
return None;
|
||||
}
|
||||
// A spreadsheet ends its copy with a line break: no empty row after the last.
|
||||
let text = text.strip_suffix("\r\n").or_else(|| text.strip_suffix('\n')).unwrap_or(text);
|
||||
let (mut rows, mut row, mut cell) = (Vec::new(), Vec::new(), String::new());
|
||||
let (mut quoted, mut cell_start) = (false, true);
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if quoted {
|
||||
match c {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
cell.push('"');
|
||||
}
|
||||
'"' => quoted = false,
|
||||
c => cell.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' if cell_start => quoted = true,
|
||||
'\t' => row.push(std::mem::take(&mut cell)),
|
||||
'\r' if chars.peek() == Some(&'\n') => continue,
|
||||
'\n' | '\r' => {
|
||||
row.push(std::mem::take(&mut cell));
|
||||
rows.push(std::mem::take(&mut row));
|
||||
}
|
||||
c => cell.push(c),
|
||||
}
|
||||
cell_start = matches!(c, '\t' | '\n' | '\r');
|
||||
}
|
||||
row.push(cell);
|
||||
rows.push(row);
|
||||
if !rows.iter().any(|r| r.len() > 1) {
|
||||
return None;
|
||||
}
|
||||
let more = rows.len() > MAX_ROWS || rows.iter().any(|r| r.len() > MAX_COLUMNS);
|
||||
rows.truncate(MAX_ROWS);
|
||||
for r in &mut rows {
|
||||
r.truncate(MAX_COLUMNS);
|
||||
}
|
||||
Some((rows, more))
|
||||
}
|
||||
|
||||
/// A count from 1 to `max` (`default` when missing).
|
||||
fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result<usize> {
|
||||
match num(p, key) {
|
||||
|
||||
@@ -166,6 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let show = boolean(p, "show").unwrap_or(true);
|
||||
s.prefs.link_notice_skipped = !show;
|
||||
s.prefs_unsaved = true;
|
||||
Ok(json!({"shown": show}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -39,9 +39,13 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
return;
|
||||
}
|
||||
let m = measure::TextMeasure;
|
||||
let restyled = doc.variables != before.variables || doc.styles != before.styles;
|
||||
// Layers that only moved join from step 3 on: what sits around them may follow, they don't.
|
||||
let moved = if restyled { BTreeSet::new() } else { only_moved_layers(doc, before, &touched) };
|
||||
touched.retain(|id| !moved.contains(id));
|
||||
// 0. Variable bindings and style links feed node values.
|
||||
let scope = binding_scope(doc, before, &touched);
|
||||
if doc.variables != before.variables || doc.styles != before.styles {
|
||||
if restyled {
|
||||
style_vars::settle(doc);
|
||||
}
|
||||
cmd::styles::apply_bindings_to(doc, scope.as_ref());
|
||||
@@ -93,6 +97,7 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
touched.extend(synced);
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
touched.extend(moved);
|
||||
// Parents of removed nodes (as they were before) count as touched.
|
||||
let removed_parents: Vec<NodeId> =
|
||||
touched.iter().filter(|i| !doc.contains(**i)).filter_map(|i| before.node(*i).and_then(|n| n.parent)).filter(|p| doc.contains(*p)).collect();
|
||||
@@ -164,6 +169,46 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
cmd::comment::follow_anchors(doc, before);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: every touched layer goes through every step, to compare with the shortcut.
|
||||
pub(crate) static NO_MOVE_SHORTCUT: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The layers of `touched` whose transform is all that changed since `before`, in no component or
|
||||
/// instance (whose copies and overrides follow moves). With variables and styles unchanged, their
|
||||
/// bindings (never a position), style links, sizing and text have nothing to follow (`before` is
|
||||
/// settled), so steps 0 to 2 skip them: a drag moving hundreds of layers re-runs only what sits
|
||||
/// around them (#1571).
|
||||
pub(crate) fn only_moved_layers(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) -> BTreeSet<NodeId> {
|
||||
#[cfg(test)]
|
||||
if NO_MOVE_SHORTCUT.with(std::cell::Cell::get) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let mut out = BTreeSet::new();
|
||||
for &id in touched {
|
||||
let (Some(b), Some(n)) = (before.node_arc(id), doc.node_arc(id)) else { continue };
|
||||
if Arc::ptr_eq(b, n) || b.transform == n.transform {
|
||||
continue;
|
||||
}
|
||||
let copied = |n: &Node| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::Instance { .. });
|
||||
if std::iter::successors(Some(&**n), |n| n.parent.and_then(|p| doc.node(p))).any(copied) {
|
||||
continue;
|
||||
}
|
||||
// Compared with the old transform put back in place (no copy of the layer).
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let moved = std::mem::replace(&mut n.transform, b.transform);
|
||||
let same = *n == **b;
|
||||
n.transform = moved;
|
||||
if same {
|
||||
out.insert(id);
|
||||
}
|
||||
}
|
||||
// `node_mut` marked them touched again: they are in `touched` already.
|
||||
doc.take_dirty();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether auto-width text `n` keeps its right edge when its width changes: it was already
|
||||
/// right-aligned auto-width text before the edit and no auto layout flow places it.
|
||||
fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool {
|
||||
|
||||
@@ -568,7 +568,7 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = open
|
||||
.panel
|
||||
.control(&control)
|
||||
.filter(|c| c.kind != ParamKind::Note)
|
||||
.filter(|c| !matches!(c.kind, ParamKind::Note | ParamKind::Image))
|
||||
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
|
||||
let field = c.is_field();
|
||||
let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null };
|
||||
|
||||
@@ -119,6 +119,9 @@ pub struct Session {
|
||||
pub active: usize,
|
||||
pub clipboard: Clipboard,
|
||||
pub prefs: Prefs,
|
||||
/// A command changed a preference that is kept at once (not only when the app quits): the app
|
||||
/// saves the preferences and clears this.
|
||||
pub prefs_unsaved: bool,
|
||||
pub tool: tools::ToolState,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// The interface's theme once the UI knows it ([`Session::set_ui_dark`]): new files get the
|
||||
@@ -207,6 +210,8 @@ mod board_connector_tests;
|
||||
#[cfg(test)]
|
||||
mod board_grid_tests;
|
||||
#[cfg(test)]
|
||||
mod board_table_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tables_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tests;
|
||||
@@ -399,6 +404,8 @@ mod measure_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod move_shortcut_tests;
|
||||
#[cfg(test)]
|
||||
mod moved_by_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Layers that only moved skip the steps of [`normalize`] that follow a layer's own content
|
||||
//! (#1571): the document must come out exactly as a full normalisation leaves it, for any set of
|
||||
//! layers moved, in the example files and during a drag.
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::kurbo::Affine;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::file::load_path;
|
||||
use crate::derived::{NO_MOVE_SHORTCUT, only_moved_layers};
|
||||
use crate::normalize;
|
||||
use crate::params::id_of;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
/// Normalise `doc` against `before`, with the shortcut or without it.
|
||||
fn normalized(mut doc: Document, before: &Document, shortcut: bool) -> Document {
|
||||
NO_MOVE_SHORTCUT.with(|c| c.set(!shortcut));
|
||||
normalize(&mut doc, before);
|
||||
NO_MOVE_SHORTCUT.with(|c| c.set(false));
|
||||
doc
|
||||
}
|
||||
|
||||
/// `doc` settled: everything normalised until nothing changes.
|
||||
fn settled(mut doc: Document) -> Document {
|
||||
for _ in 0..3 {
|
||||
let before = doc.clone();
|
||||
let all: Vec<NodeId> = doc.ids().collect();
|
||||
doc.mark_dirty(all);
|
||||
normalize(&mut doc, &before);
|
||||
}
|
||||
doc.take_dirty();
|
||||
doc
|
||||
}
|
||||
|
||||
/// `base` with each of `ids` moved by (13, -7) in its parent.
|
||||
fn moved(base: &Document, ids: &[NodeId]) -> Document {
|
||||
let mut doc = base.clone();
|
||||
for id in ids {
|
||||
if let Some(n) = doc.node_mut(*id) {
|
||||
n.transform = Affine::translate((13.0, -7.0)) * n.transform;
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_layers_of_the_examples_matches_a_full_normalise() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
|
||||
let mut paths: Vec<_> =
|
||||
std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "figcraft")).collect();
|
||||
paths.sort();
|
||||
assert!(!paths.is_empty());
|
||||
for p in paths {
|
||||
let name = p.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let base = settled(load_path(&p.to_string_lossy(), None).unwrap().0.doc);
|
||||
let layers: Vec<NodeId> = base.ids().filter(|i| !base.pages.contains(i)).collect();
|
||||
let top: Vec<NodeId> = base.pages.iter().flat_map(|pg| base.get(*pg).unwrap().children.clone()).collect();
|
||||
let every_third: Vec<NodeId> = layers.iter().copied().step_by(3).collect();
|
||||
for (case, ids) in [("top-level layers", &top), ("every layer", &layers), ("every third layer", &every_third)] {
|
||||
let doc = moved(&base, ids);
|
||||
let mut probe = doc.clone();
|
||||
let touched = probe.take_dirty();
|
||||
assert!(!only_moved_layers(&mut probe, &base, &touched).is_empty(), "{name}, {case}: some layers take the shortcut");
|
||||
assert!(normalized(doc.clone(), &base, true) == normalized(doc, &base, false), "{name}, {case}: as a full normalisation");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_that_did_more_than_move_go_through_every_step() {
|
||||
let mut s = Session::new();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"]).unwrap();
|
||||
let base = s.doc().unwrap().clone();
|
||||
let mut doc = moved(&base, &[r]);
|
||||
doc.node_mut(r).unwrap().width = 90.0;
|
||||
let touched = doc.take_dirty();
|
||||
assert!(only_moved_layers(&mut doc, &base, &touched).is_empty(), "resized too");
|
||||
let mut doc = base.clone();
|
||||
doc.mark_dirty([r]);
|
||||
let touched = doc.take_dirty();
|
||||
assert!(only_moved_layers(&mut doc, &base, &touched).is_empty(), "touched, but unchanged");
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, parent: Option<NodeId>, x: f64, y: f64) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap()
|
||||
}
|
||||
|
||||
/// Plain layers (one bound to variables), a layer in a group, one ignoring its frame's auto layout
|
||||
/// and one in a main component with an instance, all selected; then pressed at (20, 20) and
|
||||
/// dragged through `to`.
|
||||
fn drag_preview(shortcut: bool, to: &[(f64, f64)]) -> (Document, Document) {
|
||||
NO_MOVE_SHORTCUT.with(|c| c.set(!shortcut));
|
||||
let mut s = Session::new();
|
||||
let mut sel = vec![rect(&mut s, None, 0.0, 0.0), rect(&mut s, None, 100.0, 0.0)];
|
||||
s.execute("variable.createCollection", &json!({"name": "T"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"})).unwrap();
|
||||
s.execute("variable.create", &json!({"collection": "T", "name": "wide", "type": "number", "value": 64})).unwrap();
|
||||
for (field, variable) in [("fill", "brand"), ("width", "wide")] {
|
||||
s.execute("variable.bind", &json!({"ids": [sel[1]], "field": field, "variable": variable})).unwrap();
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(sel[1]).unwrap().bound.len(), 2);
|
||||
let g1 = rect(&mut s, None, 0.0, 200.0);
|
||||
let g2 = rect(&mut s, None, 80.0, 200.0);
|
||||
s.execute("object.group", &json!({"ids": [g1, g2]})).unwrap();
|
||||
sel.push(g1);
|
||||
let f = id_of(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap();
|
||||
rect(&mut s, Some(f), 310.0, 10.0);
|
||||
let free = rect(&mut s, Some(f), 400.0, 100.0);
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": free, "props": {"absolute": true}})).unwrap();
|
||||
sel.push(free);
|
||||
let c = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 200, "height": 200})).unwrap()["id"]).unwrap();
|
||||
sel.push(rect(&mut s, Some(c), 10.0, 410.0));
|
||||
let main = id_of(&s.execute("component.create", &json!({"ids": [c]})).unwrap()["id"]).unwrap();
|
||||
s.execute("instance.create", &json!({"component": main, "x": 300, "y": 400})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": sel})).unwrap();
|
||||
assert_eq!(s.selection().len(), sel.len());
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent::at(kind, x, y, 1.0);
|
||||
s.pointer(&ev(PointerKind::Down, (20.0, 20.0))).unwrap();
|
||||
for p in to {
|
||||
s.pointer(&ev(PointerKind::Move, *p)).unwrap();
|
||||
}
|
||||
let preview = s.doc().unwrap().clone();
|
||||
s.pointer(&ev(PointerKind::Up, *to.last().unwrap())).unwrap();
|
||||
NO_MOVE_SHORTCUT.with(|c| c.set(false));
|
||||
(preview, s.doc().unwrap().clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_previews_and_drops_as_with_a_full_normalise() {
|
||||
let path = [(30.0, 25.0), (61.0, 47.0), (44.0, 90.0)];
|
||||
let (preview, dropped) = drag_preview(true, &path);
|
||||
let (full_preview, full_dropped) = drag_preview(false, &path);
|
||||
assert!(preview == full_preview, "the preview");
|
||||
assert!(dropped == full_dropped, "the drop");
|
||||
assert!(preview.ids().count() > 8);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ impl Session {
|
||||
active: 0,
|
||||
clipboard: Clipboard::default(),
|
||||
prefs: Prefs::default(),
|
||||
prefs_unsaved: false,
|
||||
tool: tools::ToolState::default(),
|
||||
ui_requests: Vec::new(),
|
||||
ui_dark: None,
|
||||
|
||||
@@ -3,21 +3,59 @@ use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_rec
|
||||
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
|
||||
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
|
||||
|
||||
/// How a connector imports: from its saved snapshot (`terminal`: as one vector, no room for its
|
||||
/// label), or drawn from its ends, as a frame with its line and `label` when it has one.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct ConnectorShape {
|
||||
pub snapshot: bool,
|
||||
pub terminal: bool,
|
||||
pub label: bool,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// A connector's own properties on `o`; returns the children it adds (a line and a label).
|
||||
pub(super) fn connector_layer(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, space: Affine, shape: ConnectorShape) -> Vec<Value> {
|
||||
if shape.snapshot {
|
||||
if shape.terminal {
|
||||
self.connector_snapshot_flatten(o, v, rest_id, space);
|
||||
} else {
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] {
|
||||
o.remove(key);
|
||||
}
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
let mid = self.connector(o, v, space);
|
||||
if shape.label {
|
||||
return self.connector_label(o, v, rest_id, mid);
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Whether a connector without a snapshot keeps its label: it has text of its own and there is
|
||||
/// room for its two children under the depth and layer limits.
|
||||
pub(super) fn connector_label_fits(&self, v: &View<'a>, depth: usize) -> bool {
|
||||
own_text(v).is_some() && depth < super::MAX_DEPTH && self.count.saturating_add(2) < self.budget()
|
||||
}
|
||||
|
||||
/// A connector's end caps, and when it has no saved outline or network its line between its
|
||||
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
|
||||
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
|
||||
/// Returns the middle of that line by length, in the connector's own coordinates.
|
||||
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) -> Option<Point> {
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
if let Some(c) = v.str(from) {
|
||||
o.insert(to.into(), json!(cap_name(c)));
|
||||
}
|
||||
}
|
||||
if connector_has_saved_label(v) {
|
||||
self.warn("connector labels were skipped");
|
||||
}
|
||||
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let routing = match v.str("connectorLineStyle") {
|
||||
Some("ELBOWED") => Routing::Elbow,
|
||||
@@ -56,7 +94,7 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
|
||||
self.warn("connectors whose ends can't be placed are empty");
|
||||
return;
|
||||
return None;
|
||||
};
|
||||
if routing != Routing::Straight {
|
||||
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
|
||||
@@ -81,6 +119,55 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let vertices: Vec<Value> = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect();
|
||||
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments}));
|
||||
middle(&path).map(|m| m - b.origin().to_vec2())
|
||||
}
|
||||
|
||||
/// A connector with a label but no saved snapshot of it, as the frame a saved one imports as:
|
||||
/// `o` keeps the connector's place and size and gives its line to a vector child, and the
|
||||
/// label is a text child centred on `mid` (the line's middle; the frame's centre when the line
|
||||
/// came saved). Returns the two children.
|
||||
pub(super) fn connector_label(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, mid: Option<Point>) -> Vec<Value> {
|
||||
let (w, h) = o.get("size").map_or((0.0, 0.0), |s| (s["x"].as_f64().unwrap_or(0.0), s["y"].as_f64().unwrap_or(0.0)));
|
||||
let mut line = Obj::new();
|
||||
line.insert("id".into(), json!(format!("{rest_id};line")));
|
||||
line.insert("type".into(), json!("VECTOR"));
|
||||
line.insert("name".into(), json!(format!("{} line", v.str("name").unwrap_or("Connector"))));
|
||||
line.insert("size".into(), json!({"x": w, "y": h}));
|
||||
line.insert("relativeTransform".into(), rows(Affine::IDENTITY));
|
||||
// The connector's fill paints colour its label; the line is only stroked.
|
||||
let label_fills = o.remove("fills").filter(|f| f.as_array().is_some_and(|a| !a.is_empty()));
|
||||
for key in LINE_KEYS {
|
||||
if let Some(value) = o.remove(*key) {
|
||||
line.insert((*key).into(), value);
|
||||
}
|
||||
}
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
|
||||
let mut text = Obj::new();
|
||||
text.insert("id".into(), json!(format!("{rest_id};label")));
|
||||
text.insert("type".into(), json!("TEXT"));
|
||||
self.text(&mut text, v);
|
||||
let characters = text.get("characters").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
text.insert("name".into(), json!(characters));
|
||||
text.insert("fills".into(), label_fills.unwrap_or_else(|| json!([{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}])));
|
||||
// Centred on the line's middle in a box of the label's width, as tall as its lines (laid out
|
||||
// again with the fonts at hand, so the box only centres it).
|
||||
let size = text.get("style").and_then(|s| s["fontSize"].as_f64()).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(16.0);
|
||||
let lines = characters.lines().count().max(1) as f64;
|
||||
let (bw, bh) = (LABEL_WIDTH, lines * size * 1.4);
|
||||
if let Some(style) = text.get_mut("style").and_then(Value::as_object_mut) {
|
||||
style.insert("textAlignHorizontal".into(), json!("CENTER"));
|
||||
style.insert("textAlignVertical".into(), json!("CENTER"));
|
||||
style.insert("textAutoResize".into(), json!("NONE"));
|
||||
}
|
||||
let at = mid.unwrap_or(Point::new(w / 2.0, h / 2.0));
|
||||
text.insert("size".into(), json!({"x": bw, "y": bh}));
|
||||
text.insert("relativeTransform".into(), rows(Affine::translate((at.x - bw / 2.0, at.y - bh / 2.0))));
|
||||
self.count += 2;
|
||||
self.warn("connector labels without a saved snapshot are centred on the line, without their background");
|
||||
vec![Value::Object(line), Value::Object(text)]
|
||||
}
|
||||
|
||||
pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool {
|
||||
@@ -311,3 +398,45 @@ fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connector's line carries, moved to the line child when the connector becomes a frame.
|
||||
const LINE_KEYS: &[&str] = &[
|
||||
"vectorNetwork",
|
||||
"vectorNetworkRef",
|
||||
"fillGeometry",
|
||||
"strokeGeometry",
|
||||
"fills",
|
||||
"strokes",
|
||||
"strokeWeight",
|
||||
"strokeAlign",
|
||||
"strokeJoin",
|
||||
"strokeDashes",
|
||||
"strokeCap",
|
||||
"strokeCapStart",
|
||||
"strokeCapEnd",
|
||||
"strokeWidthProfile",
|
||||
"styles",
|
||||
];
|
||||
|
||||
/// How wide a label without a saved snapshot is laid out, centred on its connector.
|
||||
const LABEL_WIDTH: f64 = 200.0;
|
||||
|
||||
/// The point halfway along `path` by length.
|
||||
fn middle(path: &BezPath) -> Option<Point> {
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
figcraft_doc::geom::kurbo::flatten(path.iter(), 0.25, |el| {
|
||||
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
|
||||
pts.push(p);
|
||||
}
|
||||
});
|
||||
let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum();
|
||||
let mut left = total / 2.0;
|
||||
for s in pts.windows(2) {
|
||||
let l = (s[1] - s[0]).hypot();
|
||||
if l >= left && l > 0.0 {
|
||||
return Some(s[0].lerp(s[1], left / l));
|
||||
}
|
||||
left -= l;
|
||||
}
|
||||
pts.first().copied()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::component::{Entry, Inst, OverrideRow, prop_ref};
|
||||
use super::connector::connector_has_saved_label;
|
||||
use super::connector::{ConnectorShape, connector_has_saved_label};
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
|
||||
use super::{
|
||||
@@ -155,7 +155,9 @@ impl<'a> Mapper<'a> {
|
||||
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
|
||||
// A supported connector snapshot has its own note below instead of the generic stand-in one.
|
||||
let stand = stand_in(own).filter(|_| !connector_snapshot);
|
||||
let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
|
||||
// A label without a snapshot: the connector becomes a frame holding its line and its label.
|
||||
let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth);
|
||||
let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? };
|
||||
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
|
||||
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
|
||||
}
|
||||
@@ -206,20 +208,12 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if own == "CONNECTOR" && connector_snapshot {
|
||||
if connector_terminal {
|
||||
self.connector_snapshot_flatten(&mut o, &view, &rest_id, space);
|
||||
} else {
|
||||
o.insert("fills".into(), json!([]));
|
||||
o.insert("strokes".into(), json!([]));
|
||||
for key in ["styles", "strokeWeight", "strokeAlign", "strokeJoin", "strokeDashes", "strokeCap"] {
|
||||
o.remove(key);
|
||||
}
|
||||
o.insert("clipsContent".into(), json!(false));
|
||||
}
|
||||
} else if own == "CONNECTOR" {
|
||||
self.connector(&mut o, &view, space);
|
||||
}
|
||||
let connector_kids = if own == "CONNECTOR" {
|
||||
let shape = ConnectorShape { snapshot: connector_snapshot, terminal: connector_terminal, label: connector_label };
|
||||
self.connector_layer(&mut o, &view, &rest_id, space, shape)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if ty == "COMPONENT" || ty == "COMPONENT_SET" {
|
||||
self.component(&mut o, &view, &rest_id);
|
||||
}
|
||||
@@ -250,6 +244,7 @@ impl<'a> Mapper<'a> {
|
||||
included,
|
||||
slot_content,
|
||||
});
|
||||
kids.splice(0..0, connector_kids);
|
||||
if stand.is_some_and(|s| s.0 == "FRAME") {
|
||||
if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) {
|
||||
kids.splice(0..0, snapshot);
|
||||
|
||||
@@ -666,6 +666,45 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
|
||||
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A connector with a label but no saved snapshot: a frame holding its line and its label,
|
||||
/// centred on the line's middle, in black when the connector gives no paint.
|
||||
#[test]
|
||||
fn a_labelled_connector_without_a_snapshot_keeps_its_label() {
|
||||
let mut message = tail_message();
|
||||
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
|
||||
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
|
||||
let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]);
|
||||
let labelled = node(
|
||||
62,
|
||||
Some((1, "+")),
|
||||
"CONNECTOR",
|
||||
"Says",
|
||||
vec![
|
||||
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
|
||||
("connectorStart", side(60, 1.0)),
|
||||
("connectorEnd", side(61, 0.0)),
|
||||
("connectorLineStyle", e("STRAIGHT")),
|
||||
("textData", text_data("yes")),
|
||||
("fontSize", f(20.0)),
|
||||
],
|
||||
);
|
||||
nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), labelled]);
|
||||
let (doc, warnings) = import(&canvas(&schema(), &message), "Labels").unwrap();
|
||||
let says = named(&doc, "Says");
|
||||
assert!(matches!(says.kind, NodeKind::Frame), "{:?}", says.kind);
|
||||
assert!(says.fills.is_empty() && says.strokes.is_empty(), "the frame only holds the line and the label");
|
||||
let kids: Vec<&figcraft_doc::Node> = says.children.iter().map(|c| doc.node(*c).unwrap()).collect();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].strokes.len() == 1, "the line keeps its stroke");
|
||||
assert!(matches!(&kids[1].kind, NodeKind::Text(t) if t.characters == "yes"), "{:?}", kids[1].kind);
|
||||
assert_eq!(kids[1].fills.len(), 1, "black when the connector gives no paint");
|
||||
// Centred on the line's middle, (200, 650) on the page.
|
||||
let label = doc.world_transform(kids[1].id) * figcraft_doc::geom::Point::new(kids[1].width / 2.0, kids[1].height / 2.0);
|
||||
assert!((label.x - 200.0).abs() < 1e-6 && (label.y - 650.0).abs() < 1e-6, "{label:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("centred on the line")), "{warnings:?}");
|
||||
assert!(!warnings.iter().any(|w| w.contains("connector labels were skipped")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_prototype_actions_import() {
|
||||
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
|
||||
|
||||
@@ -20,8 +20,10 @@ const MAX_COMMANDS: usize = 64;
|
||||
const MAX_PARAMS: usize = 32;
|
||||
const MAX_OPTIONS: usize = 64;
|
||||
const MAX_ALLOW: usize = 256;
|
||||
/// Largest panel spec a plugin may show, and its most controls.
|
||||
pub const MAX_PANEL_BYTES: usize = 64 * 1024;
|
||||
/// Largest panel spec a plugin may show (images included, as base64), and its most controls.
|
||||
pub const MAX_PANEL_BYTES: usize = 1024 * 1024;
|
||||
/// Largest image in a panel (its file's bytes).
|
||||
pub const MAX_PANEL_IMAGE_BYTES: usize = 256 * 1024;
|
||||
const MAX_CONTROLS: usize = 64;
|
||||
/// Longest text parameter value.
|
||||
const MAX_TEXT_VALUE: usize = 10_000;
|
||||
@@ -143,6 +145,9 @@ pub struct Param {
|
||||
/// The choices of a `select` or `list`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub options: Vec<String>,
|
||||
/// An `image`'s file (PNG, JPEG…), base64.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub data: String,
|
||||
/// Bounds of a `number` (or a `slider`, which needs both).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub min: Option<f64>,
|
||||
@@ -169,6 +174,8 @@ pub enum ParamKind {
|
||||
Button,
|
||||
/// Panels only: text (`label`, up to 1000 characters).
|
||||
Note,
|
||||
/// Panels only: a picture (`data`); `label` is what screen readers say.
|
||||
Image,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
@@ -177,9 +184,9 @@ impl Param {
|
||||
if self.label.is_empty() { &self.name } else { &self.label }
|
||||
}
|
||||
|
||||
/// Whether it holds a value (not a button or a note).
|
||||
/// Whether it holds a value (not a button, a note or an image).
|
||||
pub fn is_field(&self) -> bool {
|
||||
!matches!(self.kind, ParamKind::Button | ParamKind::Note)
|
||||
!matches!(self.kind, ParamKind::Button | ParamKind::Note | ParamKind::Image)
|
||||
}
|
||||
|
||||
/// The value used when none is given.
|
||||
@@ -193,7 +200,7 @@ impl Param {
|
||||
ParamKind::Boolean => Value::Bool(false),
|
||||
ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
|
||||
ParamKind::Color => Value::String("#000000".into()),
|
||||
ParamKind::Button | ParamKind::Note => Value::Null,
|
||||
ParamKind::Button | ParamKind::Note | ParamKind::Image => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,14 +235,14 @@ impl Param {
|
||||
.and_then(figcraft_color::Color::from_hex)
|
||||
.map(|c| Value::String(c.to_css_hex()))
|
||||
.ok_or_else(|| format!("`{name}` must be a hex colour like #FF5A5F")),
|
||||
ParamKind::Button | ParamKind::Note => Ok(Value::Null),
|
||||
ParamKind::Button | ParamKind::Note | ParamKind::Image => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a parameter or panel control: its name, label, options and bounds. A default that
|
||||
/// doesn't fit its kind is dropped.
|
||||
fn check(&mut self) -> Result<(), String> {
|
||||
if !(self.kind == ParamKind::Note && self.name.is_empty()) {
|
||||
if !(matches!(self.kind, ParamKind::Note | ParamKind::Image) && self.name.is_empty()) {
|
||||
check_id("parameter name", &self.name, MAX_NAME)?;
|
||||
}
|
||||
let max = if self.kind == ParamKind::Note { MAX_TEXT } else { MAX_LABEL };
|
||||
@@ -255,6 +262,16 @@ impl Param {
|
||||
{
|
||||
return Err(format!("parameter `{}` has bad bounds", self.name));
|
||||
}
|
||||
// Base64: four characters for three bytes (the app decodes it, checking it again).
|
||||
let image = self.data.len() / 4 * 3;
|
||||
match self.kind {
|
||||
ParamKind::Image if self.data.is_empty() || image > MAX_PANEL_IMAGE_BYTES + 3 => {
|
||||
return Err(format!("image `{}` needs `data` (base64) of at most {} KB", self.name, MAX_PANEL_IMAGE_BYTES / 1024));
|
||||
}
|
||||
ParamKind::Image => {}
|
||||
_ if !self.data.is_empty() => return Err(format!("parameter `{}`: only images take `data`", self.name)),
|
||||
_ => {}
|
||||
}
|
||||
if self.kind == ParamKind::Slider && (self.min.is_none() || self.max.is_none()) {
|
||||
return Err(format!("slider `{}` needs a `min` and a `max`", self.name));
|
||||
}
|
||||
@@ -366,7 +383,7 @@ impl Manifest {
|
||||
for p in &mut c.params {
|
||||
p.check()?;
|
||||
if !p.is_field() {
|
||||
return Err(format!("parameter `{}`: buttons and notes only go in panels", p.name));
|
||||
return Err(format!("parameter `{}`: buttons, notes and images only go in panels", p.name));
|
||||
}
|
||||
}
|
||||
if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) {
|
||||
|
||||
@@ -451,6 +451,7 @@ fn bad_manifests_are_refused() {
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "number", "min": 5, "max": 1}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n"}, {"name": "n"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "date"}]}]}),
|
||||
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "i", "type": "image", "data": "iVBORw0KGgo="}]}]}),
|
||||
json!({"commands": "run"}),
|
||||
];
|
||||
for extra in bad {
|
||||
|
||||
@@ -122,6 +122,7 @@ fn panels_are_validated_and_keep_entered_values() {
|
||||
{"type": "color", "name": "accent", "value": "#f0a"},
|
||||
{"type": "number", "name": "n", "min": 1, "max": 4, "value": 9},
|
||||
{"type": "text", "name": "t"},
|
||||
{"type": "image", "label": "A chart", "data": "iVBORw0KGgo="},
|
||||
{"type": "slider", "name": "o", "min": 0, "max": 100, "value": -5},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]}"##,
|
||||
@@ -130,6 +131,7 @@ fn panels_are_validated_and_keep_entered_values() {
|
||||
assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA")));
|
||||
assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped");
|
||||
assert_eq!(p.control("go").unwrap().kind, ParamKind::Button);
|
||||
assert_eq!(p.controls[5].kind, ParamKind::Image, "an image needs no name and holds no value");
|
||||
let o = p.control("o").unwrap();
|
||||
assert_eq!((o.kind, o.default.clone()), (ParamKind::Slider, Some(json!(0.0))), "a slider is clamped like a number");
|
||||
assert_eq!(o.coerce(&json!("250")), Ok(json!(100.0)));
|
||||
@@ -142,6 +144,7 @@ fn panels_are_validated_and_keep_entered_values() {
|
||||
json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed", "o": 0.0}),
|
||||
"the spec's values win, then the entered ones"
|
||||
);
|
||||
let big_image = json!({"controls": [{"type": "image", "data": "A".repeat(256 * 1024 / 3 * 4 + 8)}]}).to_string();
|
||||
let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000));
|
||||
let many = json!({"controls": (0..65).map(|i| json!({"type": "button", "name": format!("b{i}")})).collect::<Vec<_>>()}).to_string();
|
||||
let junk = [
|
||||
@@ -156,13 +159,16 @@ fn panels_are_validated_and_keep_entered_values() {
|
||||
r#"{"controls": [{"type": "slider", "name": "a", "min": 3}]}"#,
|
||||
r#"{"controls": [{"type": "slider", "name": "a", "max": 3}]}"#,
|
||||
r#"{"title": "line\nbreak"}"#,
|
||||
r#"{"controls": [{"type": "image", "label": "no data"}]}"#,
|
||||
r#"{"controls": [{"type": "note", "label": "x", "data": "iVBORw0KGgo="}]}"#,
|
||||
&big_image,
|
||||
&deep,
|
||||
&many,
|
||||
];
|
||||
for j in junk {
|
||||
assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
|
||||
}
|
||||
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(70_000));
|
||||
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1_100_000));
|
||||
assert!(Panel::parse(&huge).unwrap_err().contains("larger"));
|
||||
let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001));
|
||||
assert!(Panel::parse(&long_note).is_err());
|
||||
|
||||
+16
-12
@@ -481,11 +481,12 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty
|
||||
/// when the table is missing, damaged or has no normal track.
|
||||
/// when the table is missing, damaged or has no normal track. A short size or value array invalidates
|
||||
/// the entire track.
|
||||
fn normal_track(t: &[u8]) -> Vec<(f64, f64)> {
|
||||
let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]]));
|
||||
let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]]));
|
||||
let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0);
|
||||
let u16_at = |o: usize| t.get(o..o.checked_add(2)?).map(|b| u16::from_be_bytes([b[0], b[1]]));
|
||||
let i16_at = |o: usize| t.get(o..o.checked_add(2)?).map(|b| i16::from_be_bytes([b[0], b[1]]));
|
||||
let fixed_at = |o: usize| t.get(o..o.checked_add(4)?).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0);
|
||||
let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() };
|
||||
let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() };
|
||||
let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() };
|
||||
@@ -497,10 +498,10 @@ fn normal_track(t: &[u8]) -> Vec<(f64, f64)> {
|
||||
}
|
||||
let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() };
|
||||
let out: Vec<(f64, f64)> = (0..sizes)
|
||||
.filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?))))
|
||||
.filter_map(|i| Some((fixed_at(size_table.checked_add(i.checked_mul(4)?)?)?, f64::from(i16_at(values.checked_add(i * 2)?)?))))
|
||||
.filter(|(s, _)| s.is_finite())
|
||||
.collect();
|
||||
return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() };
|
||||
return if out.len() == sizes && out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() };
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
@@ -1321,14 +1322,17 @@ fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
|
||||
} else {
|
||||
vec![0]
|
||||
};
|
||||
for at in fonts {
|
||||
let Some(tables) = fill(&mut buf, at, 12).and_then(|()| be(&buf, at + 4, 2)) else { continue };
|
||||
if fill(&mut buf, at + 12, 16 * tables).is_none() {
|
||||
'faces: for at in fonts {
|
||||
let Some(tables) = fill(&mut buf, at, 12).and_then(|()| be(&buf, at.checked_add(4)?, 2)) else { continue };
|
||||
let Some(dir) = at.checked_add(12) else { continue };
|
||||
if fill(&mut buf, dir, 16 * tables).is_none() {
|
||||
continue;
|
||||
}
|
||||
for rec in (0..tables).map(|t| at + 12 + 16 * t) {
|
||||
if buf.get(rec..rec + 4) == Some(b"name")
|
||||
&& let (Some(off), Some(n)) = (be(&buf, rec + 8, 4), be(&buf, rec + 12, 4))
|
||||
for t in 0..tables {
|
||||
let Some(rec) = dir.checked_add(16 * t) else { continue 'faces };
|
||||
let (Some(tag_end), Some(off_at), Some(len_at)) = (rec.checked_add(4), rec.checked_add(8), rec.checked_add(12)) else { continue 'faces };
|
||||
if buf.get(rec..tag_end) == Some(b"name")
|
||||
&& let (Some(off), Some(n)) = (be(&buf, off_at, 4), be(&buf, len_at, 4))
|
||||
{
|
||||
let _ = fill(&mut buf, off, n);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,62 @@ fn the_scan_reads_names_like_a_whole_file_read() {
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed_test_font(bytes: &[u8], case: &str) -> Vec<u8> {
|
||||
struct TempFile(std::path::PathBuf);
|
||||
impl Drop for TempFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let file = TempFile(std::env::temp_dir().join(format!("figcraft-font-index-{case}-{}.ttc", std::process::id())));
|
||||
std::fs::write(&file.0, bytes).unwrap();
|
||||
index_bytes(&file.0, bytes.len()).unwrap()
|
||||
}
|
||||
|
||||
fn synthetic_font_collection(first: u32) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 112];
|
||||
bytes[..4].copy_from_slice(b"ttcf");
|
||||
bytes[4..8].copy_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
bytes[8..12].copy_from_slice(&2u32.to_be_bytes());
|
||||
bytes[12..16].copy_from_slice(&first.to_be_bytes());
|
||||
bytes[16..20].copy_from_slice(&32u32.to_be_bytes());
|
||||
// The second face has a name table and an outline table. Only names should be read.
|
||||
bytes[32..36].copy_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
bytes[36..38].copy_from_slice(&2u16.to_be_bytes());
|
||||
bytes[44..48].copy_from_slice(b"name");
|
||||
bytes[52..56].copy_from_slice(&80u32.to_be_bytes());
|
||||
bytes[56..60].copy_from_slice(&8u32.to_be_bytes());
|
||||
bytes[60..64].copy_from_slice(b"glyf");
|
||||
bytes[68..72].copy_from_slice(&96u32.to_be_bytes());
|
||||
bytes[72..76].copy_from_slice(&4u32.to_be_bytes());
|
||||
bytes[80..88].copy_from_slice(b"fontname");
|
||||
bytes[96..100].copy_from_slice(b"skip");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_bytes_skips_collection_offsets_near_u32_max() {
|
||||
for offset in [u32::MAX - 11, u32::MAX - 1, u32::MAX] {
|
||||
let bytes = synthetic_font_collection(offset);
|
||||
let indexed = indexed_test_font(&bytes, "offset");
|
||||
assert_eq!(&indexed[32..76], &bytes[32..76]);
|
||||
assert_eq!(&indexed[80..88], b"fontname");
|
||||
assert_eq!(&indexed[96..100], &[0; 4], "outline bytes stay unread");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_bytes_skips_a_truncated_directory_and_reads_the_next_face() {
|
||||
let mut bytes = synthetic_font_collection(100);
|
||||
bytes[100..104].copy_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
bytes[104..106].copy_from_slice(&2u16.to_be_bytes());
|
||||
let indexed = indexed_test_font(&bytes, "directory");
|
||||
assert_eq!(&indexed[100..112], &bytes[100..112], "the header fits but its directory does not");
|
||||
assert_eq!(&indexed[32..76], &bytes[32..76]);
|
||||
assert_eq!(&indexed[80..88], b"fontname");
|
||||
assert_eq!(&indexed[96..100], &[0; 4], "outline bytes stay unread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macos_font_assets_are_found_in_the_asset_store() {
|
||||
// A store laid out like macOS's: fonts downloaded on demand sit in `<id>.asset/AssetData`
|
||||
@@ -403,6 +459,51 @@ fn named_styles_keep_their_optical_size() {
|
||||
assert_eq!(face.axis_values(&style)[opsz], face.axes[opsz].max);
|
||||
}
|
||||
|
||||
fn synthetic_trak() -> Vec<u8> {
|
||||
let mut bytes = vec![0; 46];
|
||||
bytes[..4].copy_from_slice(&0x0001_0000u32.to_be_bytes());
|
||||
bytes[6..8].copy_from_slice(&12u16.to_be_bytes());
|
||||
bytes[12..14].copy_from_slice(&1u16.to_be_bytes());
|
||||
bytes[14..16].copy_from_slice(&3u16.to_be_bytes());
|
||||
bytes[16..20].copy_from_slice(&28u32.to_be_bytes());
|
||||
// The normal track (0.0), with three sizes and their signed tracking values.
|
||||
bytes[24..26].copy_from_slice(&256u16.to_be_bytes());
|
||||
bytes[26..28].copy_from_slice(&40u16.to_be_bytes());
|
||||
for (i, size) in [10i32, 20, 40].into_iter().enumerate() {
|
||||
bytes[28 + i * 4..32 + i * 4].copy_from_slice(&(size * 65536).to_be_bytes());
|
||||
}
|
||||
for (i, value) in [-8i16, 0, 16].into_iter().enumerate() {
|
||||
bytes[40 + i * 2..42 + i * 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_track_reads_a_well_formed_table() {
|
||||
assert_eq!(normal_track(&synthetic_trak()), [(10.0, -8.0), (20.0, 0.0), (40.0, 16.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_track_ignores_size_table_offsets_near_u32_max() {
|
||||
let mut bytes = synthetic_trak();
|
||||
// On 32-bit targets even the first size's end can overflow. On 64-bit these are out of bounds.
|
||||
for offset in [u32::MAX - 7, u32::MAX - 3, u32::MAX - 1, u32::MAX] {
|
||||
bytes[16..20].copy_from_slice(&offset.to_be_bytes());
|
||||
assert!(normal_track(&bytes).is_empty(), "size table at {offset}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_track_never_returns_a_partial_track_from_a_damaged_table() {
|
||||
let mut bytes = synthetic_trak();
|
||||
bytes.truncate(44); // Two values fit, but the third is missing.
|
||||
assert!(normal_track(&bytes).is_empty());
|
||||
let mut bytes = synthetic_trak();
|
||||
bytes[26..28].copy_from_slice(&20u16.to_be_bytes());
|
||||
bytes.truncate(36); // All values fit, but only two sizes do.
|
||||
assert!(normal_track(&bytes).is_empty());
|
||||
}
|
||||
|
||||
/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text
|
||||
/// engine applies it on its own), in pixels, interpolated between the sizes the table lists.
|
||||
#[test]
|
||||
|
||||
@@ -26,6 +26,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "تُحاذى الطبقة المنفردة مع إطارها: حدد عدة طبقات أو طبقة داخل إطار"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "صفحة منتج متجاوبة: قسم رئيسي ومزايا وأرقام واقتباس وتذييل"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "يتسع الجدول لـ {rows} صفًا و{columns} عمودًا على الأكثر: لم يُلصق الباقي"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "عنوان وجدول أعمال ورقم بارز ومخطط وشريحة ختامية"
|
||||
"A to Z" = "من A إلى Z"
|
||||
"About" = "حول"
|
||||
@@ -144,7 +145,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "مؤرشف"
|
||||
"Arrange" = "ترتيب"
|
||||
"Arrow" = "سهم"
|
||||
"Arrow at the end" = "سهم عند النهاية"
|
||||
"As typed" = "كما أُدخل"
|
||||
"Asking the server…" = "جارٍ الاستعلام من الخادم…"
|
||||
"Assets" = "الأصول"
|
||||
|
||||
@@ -21,6 +21,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "একটি টেবিলে সর্বোচ্চ {rows}টি সারি ও {columns}টি কলাম থাকে: বাকিটা পেস্ট হয়নি"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "একটি শিরোনাম, একটি আলোচ্যসূচি, একটি বড় ফিগার, একটি চার্ট এবং একটি সমাপ্তি স্লাইড"
|
||||
"A to Z" = "A থেকে Z"
|
||||
"About" = "সম্পর্কিত"
|
||||
@@ -138,7 +139,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "আর্কাইভ করা হয়েছে"
|
||||
"Arrange" = "সাজান"
|
||||
"Arrow" = "তীর"
|
||||
"Arrow at the end" = "শেষে তীর"
|
||||
"As typed" = "যেমনটি টাইপ করা হয়েছে"
|
||||
"Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..."
|
||||
"Assets" = "অ্যাসেট"
|
||||
|
||||
@@ -23,6 +23,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Jedan sloj se poravnava prema svom okviru: odaberite više slojeva ili jedan sloj u okviru"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivna stranica proizvoda: naslovni dio, mogućnosti, brojke, citat i podnožje"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Opruga traje dok se ne smiri: njena masa, krutost i prigušenje određuju koliko dugo"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabela prima najviše {rows} redova i {columns} kolona: ostatak nije zalijepljen"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Naslov, dnevni red, velika brojka, grafikon i završni slajd"
|
||||
"A to Z" = "A do Ž"
|
||||
"About" = "O aplikaciji"
|
||||
@@ -143,7 +144,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Arhivirano"
|
||||
"Arrange" = "Rasporedi"
|
||||
"Arrow" = "Strelica"
|
||||
"Arrow at the end" = "Strelica na kraju"
|
||||
"As typed" = "Kako je otkucano"
|
||||
"Asking the server…" = "Upit serveru…"
|
||||
"Assets" = "Resursi"
|
||||
@@ -1713,6 +1713,7 @@ status = "draft — needs native review"
|
||||
"connector label was skipped: text style data is missing" = "oznaka konektora je preskočena: nedostaju podaci o stilu teksta"
|
||||
"connector label was skipped: text transform is invalid" = "oznaka konektora je preskočena: transformacija teksta nije ispravna"
|
||||
"connector labels were skipped" = "oznake konektora su preskočene"
|
||||
"connector labels without a saved snapshot are centred on the line, without their background" = "oznake konektora bez sačuvanog snimka centrirane su na liniji, bez svoje pozadine"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "slojevi konektora se uvoze kao statični snimci okvira/vektora s oznakama ako postoje; žive veze krajnjih tačaka se ne zadržavaju"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "nedostaju vektorski podaci snimka konektora; konektor je ostavljen prazan"
|
||||
"connectors whose ends can't be placed are empty" = "konektori čiji se krajevi ne mogu postaviti su prazni"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabulka má nejvýše {rows} řádků a {columns} sloupců: zbytek se nevložil"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Titulní snímek, program, výrazné číslo, graf a závěrečný snímek"
|
||||
"A to Z" = "Od A do Z"
|
||||
"About" = "O aplikaci"
|
||||
@@ -132,7 +133,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Archivováno"
|
||||
"Arrange" = "Uspořádat"
|
||||
"Arrow" = "Šipka"
|
||||
"Arrow at the end" = "Šipka na konci"
|
||||
"As typed" = "Podle zadání"
|
||||
"Asking the server…" = "Dotazování serveru…"
|
||||
"Assets" = "Zdroje"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Eine Tabelle hat höchstens {rows} Zeilen und {columns} Spalten: Der Rest wurde nicht eingefügt"
|
||||
"About" = "Über"
|
||||
"About FigCraft" = "Über FigCraft"
|
||||
"About this server" = "Über diesen Server"
|
||||
@@ -130,7 +131,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Archiviert"
|
||||
"Arrange" = "Anordnen"
|
||||
"Arrow" = "Pfeil"
|
||||
"Arrow at the end" = "Pfeil am Ende"
|
||||
"As typed" = "Wie eingegeben"
|
||||
"Asking the server…" = "Server wird gefragt…"
|
||||
"Assets" = "Assets"
|
||||
|
||||
@@ -20,6 +20,7 @@ name = "English"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "A title, an agenda, a big figure, a chart and a closing slide"
|
||||
"A to Z" = "A to Z"
|
||||
"About" = "About"
|
||||
@@ -140,7 +141,6 @@ name = "English"
|
||||
"Archived" = "Archived"
|
||||
"Arrange" = "Arrange"
|
||||
"Arrow" = "Arrow"
|
||||
"Arrow at the end" = "Arrow at the end"
|
||||
"As typed" = "As typed"
|
||||
"Asking the server…" = "Asking the server…"
|
||||
"Assets" = "Assets"
|
||||
@@ -1710,6 +1710,7 @@ name = "English"
|
||||
"connector label was skipped: text style data is missing" = "connector label was skipped: text style data is missing"
|
||||
"connector label was skipped: text transform is invalid" = "connector label was skipped: text transform is invalid"
|
||||
"connector labels were skipped" = "connector labels were skipped"
|
||||
"connector labels without a saved snapshot are centred on the line, without their background" = "connector labels without a saved snapshot are centred on the line, without their background"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "connector snapshot vector data is missing; connector was left empty"
|
||||
"connectors whose ends can't be placed are empty" = "connectors whose ends can't be placed are empty"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabla admite como máximo {rows} filas y {columns} columnas: el resto no se pegó"
|
||||
"Add a connected shape" = "Añadir una forma conectada"
|
||||
"Add a minute" = "Añadir un minuto"
|
||||
"Add column" = "Añadir columna"
|
||||
@@ -19,7 +20,6 @@ status = "draft — needs native review"
|
||||
"Along the right" = "A lo largo del borde derecho"
|
||||
"Along the top" = "A lo largo del borde superior"
|
||||
"Android Vector Drawable" = "Android Vector Drawable"
|
||||
"Arrow at the end" = "Flecha al final"
|
||||
"Best effort" = "Mejor aproximación"
|
||||
"Between the selected layers" = "Entre las capas seleccionadas"
|
||||
"Brainstorm" = "Lluvia de ideas"
|
||||
|
||||
@@ -22,6 +22,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston."
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Taulukossa voi olla enintään {rows} riviä ja {columns} saraketta: loppua ei liitetty"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Otsikko, esityslista, suuri tunnusluku, kaavio ja päätösdia"
|
||||
"A to Z" = "A:sta Z:aan"
|
||||
"About" = "Tietoja"
|
||||
@@ -139,7 +140,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Arkistoitu"
|
||||
"Arrange" = "Järjestä"
|
||||
"Arrow" = "Nuoli"
|
||||
"Arrow at the end" = "Nuoli loppuun"
|
||||
"As typed" = "Kirjoitetussa muodossa"
|
||||
"Asking the server…" = "Kysytään palvelimelta…"
|
||||
"Assets" = "Resurssit"
|
||||
|
||||
@@ -21,6 +21,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Un tableau compte au plus {rows} lignes et {columns} colonnes : le reste n'a pas été collé"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture"
|
||||
"A to Z" = "A à Z"
|
||||
"About" = "À propos"
|
||||
@@ -137,7 +138,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Archivée"
|
||||
"Arrange" = "Disposer"
|
||||
"Arrow" = "Flèche"
|
||||
"Arrow at the end" = "Flèche à la fin"
|
||||
"As typed" = "Tel que saisi"
|
||||
"Asking the server…" = "Interrogation du serveur…"
|
||||
"Assets" = "Ressources"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Bahasa Indonesia"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabel memuat paling banyak {rows} baris dan {columns} kolom: sisanya tidak ditempel"
|
||||
"Add a connected shape" = "Tambahkan bentuk yang terhubung"
|
||||
"Pasted design layers" = "Lapisan desain yang ditempel"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Lapisan desain ditempel; papan klip tidak membawa gambar dari: {layers}"
|
||||
@@ -142,7 +143,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Diarsipkan"
|
||||
"Arrange" = "Susun"
|
||||
"Arrow" = "Panah"
|
||||
"Arrow at the end" = "Panah di ujung"
|
||||
"As typed" = "Sesuai ketikan"
|
||||
"Asking the server…" = "Menghubungi server…"
|
||||
"Assets" = "Aset"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un livello da solo si allinea al proprio frame: seleziona più livelli, oppure uno dentro un frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Una pagina prodotto responsive: hero, funzionalità, figure, una citazione e un footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Una molla si muove finché non si assesta: massa, rigidità e smorzamento ne determinano la durata"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabella ha al massimo {rows} righe e {columns} colonne: il resto non è stato incollato"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titolo, un'agenda, una cifra grande, un grafico e una slide di chiusura"
|
||||
"A to Z" = "Dalla A alla Z"
|
||||
"About" = "Informazioni"
|
||||
|
||||
@@ -23,6 +23,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表は最大 {rows} 行 {columns} 列です。残りは貼り付けられませんでした"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド"
|
||||
"A to Z" = "昇順(A→Z)"
|
||||
"About" = "情報"
|
||||
@@ -138,7 +139,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "アーカイブ済み"
|
||||
"Arrange" = "配置"
|
||||
"Arrow" = "矢印"
|
||||
"Arrow at the end" = "末端に矢印"
|
||||
"As typed" = "入力どおり"
|
||||
"Asking the server…" = "サーバーに問い合わせ中…"
|
||||
"Assets" = "アセット"
|
||||
|
||||
@@ -23,6 +23,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "ცხრილში მაქსიმუმ {rows} მწკრივი და {columns} სვეტია: დანარჩენი არ ჩაისვა"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი"
|
||||
"A to Z" = "ა-დან ჰ-მდე"
|
||||
"About" = "პროგრამის შესახებ"
|
||||
@@ -140,7 +141,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Archived" = "დაარქივებულია"
|
||||
"Arrange" = "დალაგება"
|
||||
"Arrow" = "ისარი"
|
||||
"Arrow at the end" = "ისარი ბოლოში"
|
||||
"As typed" = "როგორც აიკრიფა"
|
||||
"Asking the server…" = "სერვერთან მოთხოვნა…"
|
||||
"Assets" = "რესურსები"
|
||||
|
||||
@@ -21,6 +21,7 @@ status = "draft — needs native review"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "표는 최대 {rows}행 {columns}열입니다. 나머지는 붙여넣지 않았습니다"
|
||||
"About" = "정보"
|
||||
"About FigCraft" = "FigCraft 정보"
|
||||
"About this server" = "이 서버 정보"
|
||||
@@ -130,7 +131,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "보관됨"
|
||||
"Arrange" = "배치"
|
||||
"Arrow" = "화살표"
|
||||
"Arrow at the end" = "끝에 화살표"
|
||||
"As typed" = "입력한 대로"
|
||||
"Asking the server…" = "서버에 확인하는 중…"
|
||||
"Assets" = "에셋"
|
||||
|
||||
@@ -22,6 +22,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Een tabel heeft hoogstens {rows} rijen en {columns} kolommen: de rest is niet geplakt"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Een titel, een agenda, een grote afbeelding, een grafiek en een afsluitende dia"
|
||||
"A to Z" = "A tot Z"
|
||||
"About" = "Over"
|
||||
@@ -139,7 +140,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Gearchiveerd"
|
||||
"Arrange" = "Schikken"
|
||||
"Arrow" = "Pijl"
|
||||
"Arrow at the end" = "Pijl aan het eind"
|
||||
"As typed" = "Zoals getypt"
|
||||
"Asking the server…" = "Bezig met vragen aan de server…"
|
||||
"Assets" = "Assets"
|
||||
|
||||
@@ -20,6 +20,7 @@ name = "Polski"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabela ma najwyżej {rows} wierszy i {columns} kolumn: reszta nie została wklejona"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Tytuł, agenda, wyróżniona liczba, wykres i slajd końcowy"
|
||||
"A to Z" = "Od A do Z"
|
||||
"About" = "O programie"
|
||||
@@ -137,7 +138,6 @@ name = "Polski"
|
||||
"Archived" = "Zarchiwizowano"
|
||||
"Arrange" = "Ułóż"
|
||||
"Arrow" = "Strzałka"
|
||||
"Arrow at the end" = "Strzałka na końcu"
|
||||
"As typed" = "Zgodnie z wpisanym"
|
||||
"Asking the server…" = "Odpytywanie serwera…"
|
||||
"Assets" = "Zasoby"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Uma tabela tem no máximo {rows} linhas e {columns} colunas: o resto não foi colado"
|
||||
"About" = "Sobre"
|
||||
"About FigCraft" = "Sobre o FigCraft"
|
||||
"About this server" = "Sobre este servidor"
|
||||
@@ -128,7 +129,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "Arquivada"
|
||||
"Arrange" = "Organizar"
|
||||
"Arrow" = "Seta"
|
||||
"Arrow at the end" = "Seta no final"
|
||||
"As typed" = "Como digitado"
|
||||
"Asking the server…" = "Consultando o servidor…"
|
||||
"Assets" = "Recursos"
|
||||
|
||||
@@ -20,6 +20,7 @@ name = "Русский"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "В таблице не больше {rows} строк и {columns} столбцов: остальное не вставлено"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд"
|
||||
"A to Z" = "От А до Я"
|
||||
"About" = "О программе"
|
||||
@@ -137,7 +138,6 @@ name = "Русский"
|
||||
"Archived" = "В архиве"
|
||||
"Arrange" = "Упорядочить"
|
||||
"Arrow" = "Стрелка"
|
||||
"Arrow at the end" = "Стрелка на конце"
|
||||
"As typed" = "Как введено"
|
||||
"Asking the server…" = "Запрос к серверу…"
|
||||
"Assets" = "Ресурсы"
|
||||
|
||||
@@ -20,6 +20,7 @@ name = "Svenska"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin frame: markera fler lager, eller ett lager i en frame"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "En tabell har högst {rows} rader och {columns} kolumner: resten klistrades inte in"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild"
|
||||
"A to Z" = "A till Ö"
|
||||
"About" = "Om"
|
||||
@@ -140,7 +141,6 @@ name = "Svenska"
|
||||
"Archived" = "Arkiverad"
|
||||
"Arrange" = "Ordna"
|
||||
"Arrow" = "Pil"
|
||||
"Arrow at the end" = "Pil i slutet"
|
||||
"As typed" = "Som inskrivet"
|
||||
"Asking the server…" = "Frågar servern…"
|
||||
"Assets" = "Resurser"
|
||||
|
||||
@@ -25,6 +25,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "เลเยอร์เดี่ยวจะจัดแนวตามเฟรมของมัน: เลือกเลเยอร์เพิ่ม หรือเลือกเลเยอร์ในเฟรม"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "หน้าสินค้าแบบรีสปอนซีฟ: ฮีโร่ ฟีเจอร์ ตัวเลข คำพูดอ้างอิง และฟุตเตอร์"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "สปริงเล่นจนนิ่ง: มวล ความแข็ง และการหน่วงกำหนดระยะเวลา"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "ตารางมีได้สูงสุด {rows} แถวและ {columns} คอลัมน์: ส่วนที่เหลือไม่ได้วาง"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "ชื่อเรื่อง วาระ ตัวเลขใหญ่ แผนภูมิ และสไลด์ปิดท้าย"
|
||||
"A to Z" = "A ถึง Z"
|
||||
"About" = "เกี่ยวกับ"
|
||||
@@ -138,7 +139,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "เก็บถาวรแล้ว"
|
||||
"Arrange" = "จัดเรียง"
|
||||
"Arrow" = "ลูกศร"
|
||||
"Arrow at the end" = "ลูกศรที่ปลาย"
|
||||
"As typed" = "ตามที่พิมพ์"
|
||||
"Asking the server…" = "กำลังถามเซิร์ฟเวอร์…"
|
||||
"Assets" = "แอสเซ็ต"
|
||||
|
||||
@@ -21,6 +21,7 @@ name = "Türkçe"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin."
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Bir tablo en fazla {rows} satır ve {columns} sütun alır: gerisi yapıştırılmadı"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Bir başlık, bir gündem, büyük bir rakam, bir grafik ve bir kapanış slaydı"
|
||||
"A to Z" = "A'dan Z'ye"
|
||||
"About" = "Hakkında"
|
||||
@@ -136,7 +137,6 @@ name = "Türkçe"
|
||||
"Archived" = "Arşivlendi"
|
||||
"Arrange" = "Konumlandır"
|
||||
"Arrow" = "Ok"
|
||||
"Arrow at the end" = "Sonda ok"
|
||||
"As typed" = "Yazıldığı gibi"
|
||||
"Asking the server…" = "Sunucuya soruluyor…"
|
||||
"Assets" = "Varlıklar"
|
||||
|
||||
@@ -21,6 +21,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Một bảng có tối đa {rows} hàng và {columns} cột: phần còn lại không được dán"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Tiêu đề, chương trình, số liệu lớn, biểu đồ và trang kết thúc"
|
||||
"A to Z" = "A đến Z"
|
||||
"About" = "Giới thiệu"
|
||||
|
||||
@@ -22,6 +22,7 @@ status = "draft — needs native review"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表格最多 {rows} 行 {columns} 列:其余内容未粘贴"
|
||||
"About" = "关于"
|
||||
"About FigCraft" = "关于 FigCraft"
|
||||
"About this server" = "关于此服务器"
|
||||
@@ -131,7 +132,6 @@ status = "draft — needs native review"
|
||||
"Archived" = "已归档"
|
||||
"Arrange" = "排列"
|
||||
"Arrow" = "箭头"
|
||||
"Arrow at the end" = "末端箭头"
|
||||
"As typed" = "原样"
|
||||
"Asking the server…" = "正在询问服务器…"
|
||||
"Assets" = "资源"
|
||||
|
||||
@@ -910,6 +910,11 @@ pub fn number(resp: &Response, label: &str, value: Option<f64>, suffix: &str, em
|
||||
});
|
||||
}
|
||||
|
||||
/// A picture described by `label`.
|
||||
pub fn image(resp: &Response, label: &str) {
|
||||
node(resp, label, |n| n.set_role(Role::Image));
|
||||
}
|
||||
|
||||
/// A slider over `min`..=`max` at `value` (None = mixed).
|
||||
pub fn slider(resp: &Response, label: &str, value: Option<f64>, min: f64, max: f64) {
|
||||
node(resp, label, |n| {
|
||||
|
||||
@@ -185,8 +185,11 @@ impl FigApp {
|
||||
// already happened and would otherwise duplicate).
|
||||
self.end_input_frame(&ctx);
|
||||
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
|
||||
// So are the owner tokens of files put online.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) {
|
||||
// So are the owner tokens of files put online, and preferences a command keeps at once.
|
||||
if std::mem::take(&mut self.session.plugins.unsaved)
|
||||
| std::mem::take(&mut self.session.live.unsaved)
|
||||
| std::mem::take(&mut self.session.prefs_unsaved)
|
||||
{
|
||||
self.services.save_prefs(&self.session.prefs);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! The bar over a board's selected connector (#1596), drawn headless: its line type, start and end
|
||||
//! points, dashed or solid line and weight, and any colour through the colour picker as well as
|
||||
//! the board colours; the colour wheel only when the selection is all connectors.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2};
|
||||
use figcraft_doc::{NodeId, Routing, StrokeCap};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, has_text, text_center};
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]);
|
||||
}
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
/// The bar over the selection, when it shows.
|
||||
fn bar(u: &Ui) -> Option<Rect> {
|
||||
let layer = egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar"));
|
||||
u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten()
|
||||
}
|
||||
|
||||
/// A board with two stickies joined by a selected elbow connector.
|
||||
fn connector() -> (Ui, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
|
||||
let b = s.execute("board.sticky", &json!({"x": 400, "y": 300})).unwrap()["id"].clone();
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap());
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
(u, c, NodeId(b.as_u64().unwrap()))
|
||||
}
|
||||
|
||||
/// Open the menu at `x` from the bar's left edge and pick `item` in it.
|
||||
fn pick(u: &mut Ui, x: f32, item: &str) {
|
||||
let r = bar(u).expect("the bar shows");
|
||||
click(u, pos2(r.min.x + x, r.center().y));
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
click(u, text_center(&shapes, item));
|
||||
}
|
||||
|
||||
// The bar's parts from its left edge: 8 board colours from 8 (24 apart) and the colour wheel
|
||||
// (to 224), Straight, Elbow and Curved from 232 (30 apart), the start point (62 wide) from 328, the
|
||||
// end point from 392 and the line style from 462.
|
||||
const START: f32 = 328.0 + 31.0;
|
||||
const END: f32 = 392.0 + 31.0;
|
||||
const STYLE: f32 = 462.0 + 20.0;
|
||||
const WHEEL: f32 = 200.0 + 10.0;
|
||||
|
||||
#[test]
|
||||
fn the_connector_bar_sets_its_ends_dashes_and_weight() {
|
||||
let (mut u, c, _) = connector();
|
||||
let stroke = |u: &Ui| u.app.session.doc().unwrap().node(c).unwrap().stroke.clone();
|
||||
assert_eq!((stroke(&u).cap_start, stroke(&u).cap_end), (StrokeCap::None, StrokeCap::TriangleArrow));
|
||||
pick(&mut u, START, "Circle");
|
||||
assert_eq!(stroke(&u).cap_start, StrokeCap::CircleFilled);
|
||||
pick(&mut u, END, "Diamond");
|
||||
assert_eq!(stroke(&u).cap_end, StrokeCap::DiamondFilled);
|
||||
pick(&mut u, STYLE, "Dash");
|
||||
assert!(!stroke(&u).dashes.is_empty(), "dashed");
|
||||
pick(&mut u, STYLE, "Stroke weight 4");
|
||||
assert_eq!(stroke(&u).weight, 4.0);
|
||||
pick(&mut u, STYLE, "Solid");
|
||||
assert!(stroke(&u).dashes.is_empty(), "solid again, the weight kept");
|
||||
assert_eq!(stroke(&u).weight, 4.0);
|
||||
// The line type cells still work, and each change is a step of its own.
|
||||
let r = bar(&u).unwrap();
|
||||
click(&mut u, pos2(r.min.x + 232.0 + 14.0, r.center().y));
|
||||
let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board {
|
||||
Some(figcraft_doc::BoardItem::Connector(k)) => k.routing,
|
||||
_ => panic!("not a connector"),
|
||||
};
|
||||
assert_eq!(routing(&u), Routing::Straight);
|
||||
u.app.session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(routing(&u), Routing::Elbow);
|
||||
assert_eq!(stroke(&u).weight, 4.0, "the weight is its own step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_colour_wheel_opens_the_picker_for_connectors_only() {
|
||||
let (mut u, c, b) = connector();
|
||||
let r = bar(&u).unwrap();
|
||||
click(&mut u, pos2(r.min.x + WHEEL, r.center().y));
|
||||
assert_eq!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::Paint { fills: false, index: 0, ids: vec![c] }), "the line's colour");
|
||||
crate::colorpicker::close(&mut u.app);
|
||||
// With a sticky selected too, the bar has only the board colours: no wheel, and the line type
|
||||
// cells start where the wheel was.
|
||||
u.app.session.execute("select.set", &json!({"ids": [c, b]})).unwrap();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(!has_text(&shapes, "Edit colour"));
|
||||
let r = bar(&u).unwrap();
|
||||
click(&mut u, pos2(r.min.x + WHEEL, r.center().y));
|
||||
assert!(u.app.ui.picker.is_none(), "no wheel to open");
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//! and the marks design files and boards get.
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2};
|
||||
use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, StrokeCap, board_color};
|
||||
use figcraft_doc::{BoardItem, BoardShape, FileKind, NodeId, Routing, Stamp, board_color};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::tools::ToolId;
|
||||
use serde_json::{Value, json};
|
||||
@@ -143,19 +143,16 @@ fn the_selection_bar_sets_a_connectors_style_and_offers_nothing_for_a_stamp() {
|
||||
u.frame(vec![]);
|
||||
let r = bar(&u).expect("the bar shows for a connector");
|
||||
let cy = r.center().y;
|
||||
// After the colours (208): Straight, Elbow, Curved, the end's arrow, 30 apart.
|
||||
click(&mut u, pos2(r.min.x + 208.0 + 14.0, cy));
|
||||
// After the colours and the colour wheel (232): Straight, Elbow, Curved, 30 apart.
|
||||
click(&mut u, pos2(r.min.x + 232.0 + 14.0, cy));
|
||||
let routing = |u: &Ui| match &u.app.session.doc().unwrap().node(c).unwrap().board {
|
||||
Some(BoardItem::Connector(k)) => k.routing,
|
||||
_ => panic!("not a connector"),
|
||||
};
|
||||
assert_eq!(routing(&u), Routing::Straight);
|
||||
let r = bar(&u).unwrap();
|
||||
click(&mut u, pos2(r.min.x + 208.0 + 60.0 + 14.0, cy));
|
||||
click(&mut u, pos2(r.min.x + 232.0 + 60.0 + 14.0, cy));
|
||||
assert_eq!(routing(&u), Routing::Curved);
|
||||
let r = bar(&u).unwrap();
|
||||
click(&mut u, pos2(r.min.x + 208.0 + 90.0 + 14.0, cy));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(c).unwrap().stroke.cap_end, StrokeCap::None);
|
||||
// Nothing is offered while a stamp alone is selected: it takes neither colour nor style.
|
||||
let st = id(&u.app.session.execute("board.stamp", &json!({"x": 900, "y": 0, "stamp": "star"})).unwrap());
|
||||
u.app.session.execute("select.set", &json!({"ids": [st]})).unwrap();
|
||||
|
||||
@@ -376,6 +376,9 @@ mod builder_poly_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod panel_nodes_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_cache_tests;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! board tools' flyouts share.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeKind, Routing, Stamp, StrokeCap};
|
||||
use figcraft_doc::{BOARD_COLORS, BoardItem, BoardShape, NodeId, NodeKind, Routing, Stamp, StrokeCap};
|
||||
use figcraft_engine::board::{BoardTool, TEXT_SIZES};
|
||||
use figcraft_engine::board_grid::GridStyle;
|
||||
use serde_json::{Value, json};
|
||||
@@ -96,16 +96,30 @@ struct Has {
|
||||
color: bool,
|
||||
shape: Option<BoardShape>,
|
||||
text: bool,
|
||||
connector: Option<(Routing, bool)>,
|
||||
connector: Option<Line>,
|
||||
/// Every selected board object is a connector: the colour part offers any colour too.
|
||||
only_lines: bool,
|
||||
table: bool,
|
||||
}
|
||||
|
||||
/// The selected connectors' line as the bar shows it (the first one's), and their ids.
|
||||
struct Line {
|
||||
routing: Routing,
|
||||
start: StrokeCap,
|
||||
end: StrokeCap,
|
||||
weight: f64,
|
||||
dashed: bool,
|
||||
ids: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// What the selected board objects take from the bar (layers that aren't board objects, a section
|
||||
/// say, are left out); `None` when none takes anything.
|
||||
fn has(app: &FigApp) -> Option<Has> {
|
||||
let ds = app.session.ds().ok()?;
|
||||
let mut h = Has::default();
|
||||
let mut others = false;
|
||||
for n in ds.selection.iter().filter_map(|id| ds.doc.node(*id)) {
|
||||
others |= n.board.is_some() && !matches!(n.board, Some(BoardItem::Connector(_)));
|
||||
match &n.board {
|
||||
Some(BoardItem::Sticky { .. } | BoardItem::Ink) => h.color = true,
|
||||
Some(BoardItem::Shape { shape }) => {
|
||||
@@ -115,13 +129,23 @@ fn has(app: &FigApp) -> Option<Has> {
|
||||
}
|
||||
Some(BoardItem::Connector(c)) => {
|
||||
h.color = true;
|
||||
h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None));
|
||||
let s = &n.stroke;
|
||||
let line = h.connector.get_or_insert_with(|| Line {
|
||||
routing: c.routing,
|
||||
start: s.cap_start,
|
||||
end: s.cap_end,
|
||||
weight: s.weight,
|
||||
dashed: !s.dashes.is_empty(),
|
||||
ids: Vec::new(),
|
||||
});
|
||||
line.ids.push(n.id);
|
||||
}
|
||||
Some(BoardItem::Label { .. }) => h.text = true,
|
||||
Some(BoardItem::Table) => h.table = true,
|
||||
Some(BoardItem::Stamp { .. }) | None => {}
|
||||
}
|
||||
}
|
||||
h.only_lines = h.connector.is_some() && !others;
|
||||
(h.color || h.text || h.connector.is_some() || h.table).then_some(h)
|
||||
}
|
||||
|
||||
@@ -137,6 +161,9 @@ impl Has {
|
||||
let mut w = 8.0;
|
||||
if self.color {
|
||||
w += BOARD_COLORS.len() as f32 * (SWATCH + GAP) + PART_END;
|
||||
if self.only_lines {
|
||||
w += SWATCH + GAP;
|
||||
}
|
||||
}
|
||||
if self.shape.is_some() {
|
||||
w += CELL + 12.0 + GAP + PART_END;
|
||||
@@ -145,7 +172,7 @@ impl Has {
|
||||
w += TEXT_SIZES.len() as f32 * (CELL + 2.0) + PART_END;
|
||||
}
|
||||
if self.connector.is_some() {
|
||||
w += 4.0 * (CELL + 2.0) + PART_END;
|
||||
w += LINE_W;
|
||||
}
|
||||
if self.table {
|
||||
w += crate::board_picker::TABLE_PART_W;
|
||||
@@ -190,7 +217,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
if h.color {
|
||||
x = color_part(app, ui, x, cy, &t);
|
||||
let lines = h.connector.as_ref().filter(|_| h.only_lines).map(|l| l.ids.as_slice());
|
||||
x = color_part(app, ui, x, cy, &t, lines);
|
||||
}
|
||||
if let Some(shape) = h.shape {
|
||||
x = shape_part(app, ui, shape, pos2(x, cy), &t);
|
||||
@@ -201,14 +229,15 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
if h.table {
|
||||
x = crate::board_picker::table_part(app, ui, x, cy, &t);
|
||||
}
|
||||
if let Some((routing, arrow)) = h.connector {
|
||||
line_part(app, ui, (routing, arrow), pos2(x, cy), &t);
|
||||
if let Some(line) = &h.connector {
|
||||
line_part(app, ui, line, pos2(x, cy), &t);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The board colours from `x` (`board.setColor`); where the next part starts.
|
||||
fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {
|
||||
/// The board colours from `x` (`board.setColor`), and for connectors `lines` any colour in the
|
||||
/// colour picker; where the next part starts.
|
||||
fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens, lines: Option<&[NodeId]>) -> f32 {
|
||||
for (i, (name, c)) in BOARD_COLORS.iter().enumerate() {
|
||||
let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name)));
|
||||
@@ -222,9 +251,34 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) ->
|
||||
}
|
||||
x += SWATCH + GAP;
|
||||
}
|
||||
if let Some(ids) = lines {
|
||||
let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH));
|
||||
let resp = ui.interact(r, ui.scope_id().with("bb_color_any"), Sense::click()).on_hover_text(tr("Edit colour"));
|
||||
a11y::button(&resp, tr("Edit colour"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover);
|
||||
}
|
||||
paint_wheel(ui.painter(), r.center(), SWATCH / 2.0 - 1.0);
|
||||
app.picker_control(crate::colorpicker::PickerTarget::Paint { fills: false, index: 0, ids: ids.to_vec() }, &resp);
|
||||
x += SWATCH + GAP;
|
||||
}
|
||||
divider(ui, t, x, cy)
|
||||
}
|
||||
|
||||
/// A small colour wheel: the button for any colour.
|
||||
fn paint_wheel(p: &egui::Painter, c: Pos2, r: f32) {
|
||||
const STEPS: usize = 12;
|
||||
let at = |i: usize| {
|
||||
let a = i as f32 / STEPS as f32 * std::f32::consts::TAU;
|
||||
c + vec2(a.cos(), a.sin()) * r
|
||||
};
|
||||
for i in 0..STEPS {
|
||||
let hue = egui::ecolor::Hsva::new(i as f32 / STEPS as f32, 0.75, 0.95, 1.0);
|
||||
p.add(egui::Shape::convex_polygon(vec![c, at(i), at(i + 1)], Color32::from(hue), Stroke::NONE));
|
||||
}
|
||||
p.circle_filled(c, r * 0.4, Color32::WHITE);
|
||||
}
|
||||
|
||||
/// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts.
|
||||
fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 {
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL));
|
||||
@@ -267,23 +321,114 @@ fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) ->
|
||||
divider(ui, t, x, cy)
|
||||
}
|
||||
|
||||
/// A connector's routing (straight, elbow, curved) and end arrow at `at`, as they are now (`line`).
|
||||
fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: &Tokens) {
|
||||
let (routing, arrow) = line;
|
||||
/// A start or end point menu's width: the end's picture and the menu's chevron.
|
||||
const CAP_W: f32 = 62.0;
|
||||
/// The line style menu's width.
|
||||
const STYLE_W: f32 = CELL + 12.0;
|
||||
/// The connector part's width: the three routings, the two ends and the line style.
|
||||
const LINE_W: f32 = 3.0 * (CELL + 2.0) + 8.0 + 2.0 * CAP_W + 2.0 + 8.0 + STYLE_W + PART_END;
|
||||
/// The weights the line style menu offers.
|
||||
const WEIGHTS: [f64; 4] = [1.0, 2.0, 4.0, 6.0];
|
||||
/// A dashed connector's dashes and gaps.
|
||||
const DASHES: [f64; 2] = [8.0, 6.0];
|
||||
|
||||
/// A connector's routing (straight, elbow, curved), its start and end points and its line style at
|
||||
/// `at`, as they are now (`line`): `board.setStyle`, and the stroke's dashes and weight.
|
||||
fn line_part(app: &mut FigApp, ui: &mut Ui, line: &Line, at: Pos2, t: &Tokens) {
|
||||
let cells = [
|
||||
(Icon::Line, tr("Straight"), routing == Routing::Straight, json!({"style": "straight"})),
|
||||
(Icon::Connector, tr("Elbow"), routing == Routing::Elbow, json!({"style": "elbow"})),
|
||||
(Icon::CurvedConnector, tr("Curved"), routing == Routing::Curved, json!({"style": "curved"})),
|
||||
(Icon::Arrow, tr("Arrow at the end"), arrow, json!({"endCap": if arrow { "none" } else { "triangleArrow" }})),
|
||||
(Icon::Line, tr("Straight"), Routing::Straight, "straight"),
|
||||
(Icon::Connector, tr("Elbow"), Routing::Elbow, "elbow"),
|
||||
(Icon::CurvedConnector, tr("Curved"), Routing::Curved, "curved"),
|
||||
];
|
||||
let mut x = at.x;
|
||||
for (i, (icon, tip, on, params)) in cells.into_iter().enumerate() {
|
||||
for (i, (icon, tip, routing, id)) in cells.into_iter().enumerate() {
|
||||
let r = Rect::from_center_size(pos2(x + CELL / 2.0, at.y), vec2(CELL, CELL));
|
||||
if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, on, tip).clicked() {
|
||||
app.run("board.setStyle", params);
|
||||
if crate::toolbar::tool_cell(ui, t, r, ("bb_line", i), icon, line.routing == routing, tip).clicked() {
|
||||
app.run("board.setStyle", json!({"style": id}));
|
||||
}
|
||||
x += CELL + 2.0;
|
||||
}
|
||||
x = divider(ui, t, x - 2.0, at.y);
|
||||
for (start, cap) in [(true, line.start), (false, line.end)] {
|
||||
cap_menu(app, ui, t, pos2(x, at.y), start, cap);
|
||||
x += CAP_W + 2.0;
|
||||
}
|
||||
x = divider(ui, t, x - 2.0, at.y);
|
||||
style_menu(app, ui, t, pos2(x, at.y), line);
|
||||
}
|
||||
|
||||
/// The menu of a connector's start (`start`) or end point at `at` (its left edge and middle),
|
||||
/// showing `cap`.
|
||||
fn cap_menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens, at: Pos2, start: bool, cap: StrokeCap) {
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CAP_W, CELL));
|
||||
let label = if start { tr("Start point") } else { tr("End point") };
|
||||
let resp = ui.interact(r, ui.scope_id().with(("bb_cap", start)), Sense::click()).on_hover_text(label);
|
||||
a11y::menu_button(&resp, label, a11y::menu_open(&resp));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
// The picture ends 14 before the rectangle it is given: 4 in from this cell's left edge.
|
||||
let art = Rect::from_min_max(pos2(r.min.x, r.min.y), pos2(r.min.x + 62.0, r.max.y));
|
||||
crate::right::cap_art(ui.painter(), art, t.icon, cap, start);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary);
|
||||
let key = if start { "startCap" } else { "endCap" };
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for c in StrokeCap::ALL {
|
||||
let art = |p: &egui::Painter, row: Rect, ink: Color32| crate::right::cap_art(p, row, ink, c, start);
|
||||
if crate::right::menu_item_with(ui, tr(c.label()), "", crate::right::CAP_ART_W, art) {
|
||||
app.run("board.setStyle", json!({key: c}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A line `weight` thick, dashed or not, across `r`'s middle.
|
||||
fn paint_line(p: &egui::Painter, r: Rect, ink: Color32, weight: f64, dashed: bool) {
|
||||
let w = (weight as f32).clamp(1.0, 5.0);
|
||||
let ends = [pos2(r.min.x, r.center().y), pos2(r.max.x, r.center().y)];
|
||||
if dashed {
|
||||
p.extend(egui::Shape::dashed_line(&ends, Stroke::new(w, ink), 5.0, 3.0));
|
||||
} else {
|
||||
p.line_segment(ends, Stroke::new(w, ink));
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu of the selected connectors' line style at `at`: solid or dashed, and its weight.
|
||||
fn style_menu(app: &mut FigApp, ui: &mut Ui, t: &Tokens, at: Pos2, line: &Line) {
|
||||
let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(STYLE_W, CELL));
|
||||
let label = tr("Stroke style");
|
||||
let resp = ui.interact(r, ui.scope_id().with("bb_line_style"), Sense::click()).on_hover_text(label);
|
||||
a11y::menu_button(&resp, label, a11y::menu_open(&resp));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
let sample = Rect::from_min_max(pos2(r.min.x + 5.0, at.y), pos2(r.max.x - 15.0, at.y));
|
||||
paint_line(ui.painter(), sample, t.icon, line.weight, line.dashed);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 7.0, at.y), vec2(12.0, 12.0)), Icon::Chevron, t.icon_secondary);
|
||||
let ids = line.ids.clone();
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
let art = |dashed: bool, weight: f64| {
|
||||
move |p: &egui::Painter, row: Rect, ink: Color32| {
|
||||
let r = Rect::from_min_max(pos2(row.max.x - 44.0, row.center().y), pos2(row.max.x - 12.0, row.center().y));
|
||||
paint_line(p, r, ink, weight, dashed);
|
||||
}
|
||||
};
|
||||
for (dashed, name) in [(false, tr("Solid")), (true, tr("Dash"))] {
|
||||
if crate::right::menu_item_with(ui, name, "", 48.0, art(dashed, 2.0)) {
|
||||
let dashes: &[f64] = if dashed { &DASHES } else { &[] };
|
||||
app.set_props(&ids, json!({"dashes": dashes}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
for w in WEIGHTS {
|
||||
if crate::right::menu_item_with(ui, &format!("{} {w}", tr("Stroke weight")), "", 48.0, art(false, w)) {
|
||||
app.set_props(&ids, json!({"strokeWeight": w}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A thin separator after `x`; where the next part starts.
|
||||
|
||||
@@ -57,7 +57,12 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
|
||||
}
|
||||
// Inside a boolean group (an operand selected or in vector edit mode): every operand's outline,
|
||||
// so the shapes making the live result stay visible while one of them changes.
|
||||
let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id));
|
||||
let editing = ds.vector_edit.as_ref().map(|ve| ve.id);
|
||||
let unmoved = crate::selection_cache::unmoved(ds, &doc);
|
||||
let key: Vec<u64> = editing.map(|e| e.0).into_iter().collect();
|
||||
let operands = crate::selection_cache::cached(painter.ctx(), "operands", &unmoved, &sel, &key, || {
|
||||
figcraft_engine::booleans::outlined_operands(&unmoved, &sel, editing)
|
||||
});
|
||||
for id in operands.iter().filter(|o| !sel.contains(o)) {
|
||||
draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0));
|
||||
}
|
||||
@@ -310,7 +315,7 @@ fn selection_overlay(
|
||||
// Constraint guides belong to the normal selection overlay: not while the Shape Builder
|
||||
// reshapes by regions, nor while measuring to another layer (Alt), whose lines take over.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() {
|
||||
constraint_lines(app, painter, doc, sel);
|
||||
constraint_lines(app, painter, ds, doc, sel);
|
||||
}
|
||||
// The shape builder reshapes by regions: no transform box.
|
||||
if app.session.tool.tool != ToolId::ShapeBuilder
|
||||
@@ -494,7 +499,17 @@ fn transform_box(
|
||||
/// short bar through the layer's middle — a horizontal one, a vertical one, or both (a cross when
|
||||
/// the layer is centred on both axes). Auto-layout frames ignore constraints, so no guides are
|
||||
/// drawn there, except for a layer that ignores the auto layout (absolute position).
|
||||
fn constraint_lines(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId]) {
|
||||
fn constraint_lines(app: &FigApp, painter: &egui::Painter, ds: &figcraft_engine::DocState, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
// Constraints bind a layer only to a frame it sits inside: one without auto layout, or one
|
||||
// whose auto layout the layer ignores (absolute position). A move changes neither.
|
||||
let unmoved = crate::selection_cache::unmoved(ds, doc);
|
||||
let bound = crate::selection_cache::cached(painter.ctx(), "constrained", &unmoved, sel, &[], || -> Vec<NodeId> {
|
||||
let binds = |n: &figcraft_doc::Node, p: &figcraft_doc::Node| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute);
|
||||
sel.iter()
|
||||
.copied()
|
||||
.filter(|i| unmoved.node(*i).is_some_and(|n| n.parent.and_then(|p| unmoved.node(p)).is_some_and(|p| binds(n, p))))
|
||||
.collect()
|
||||
});
|
||||
let s = stroke(accent(painter), 1.0);
|
||||
let line = |a: Pos2, b: Pos2| {
|
||||
painter.add(Shape::dashed_line(&[a, b], s, 4.0, 3.0));
|
||||
@@ -508,15 +523,10 @@ fn constraint_lines(app: &FigApp, painter: &egui::Painter, doc: &Document, sel:
|
||||
painter.line_segment([pos2(c.x, c.y - half), pos2(c.x, c.y + half)], s);
|
||||
}
|
||||
};
|
||||
for &id in sel {
|
||||
for &id in &bound {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let Some(parent_id) = n.parent else { continue };
|
||||
let Some(parent) = doc.node(parent_id) else { continue };
|
||||
// Constraints bind a layer only to a frame it sits inside: one without auto layout, or one
|
||||
// whose auto layout the layer ignores (absolute position).
|
||||
if !parent.kind.is_frame_like() || (parent.layout.is_some() && !n.child_layout.absolute) {
|
||||
continue;
|
||||
}
|
||||
let nw = doc.world_transform(id);
|
||||
let pw = doc.world_transform(parent_id);
|
||||
let (nw_w, nw_h) = (n.width, n.height);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! What a large selection's frames keep (#1571): the Design panel looks the selected layers up once
|
||||
//! per document snapshot and selection, and the canvas keeps which layers its constraint guides and
|
||||
//! boolean operand outlines go with. Each answers as the document does, after edits, another
|
||||
//! selection, a deleted layer, and for any other document.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Context, Rect, pos2, vec2};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::selection_cache_tests::drawn;
|
||||
use crate::right::common;
|
||||
use crate::selection_cache::{every, show};
|
||||
use crate::theme::set_canvas_page;
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: i32, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": x, "y": 10, "width": 40, "height": 40});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(s.execute("shape.rectangle", &p).unwrap())
|
||||
}
|
||||
|
||||
/// The panel's questions (the selection's common opacity, whether it is all rectangles), asked as
|
||||
/// the panel asks them.
|
||||
fn answers(doc: &Document, sel: &[NodeId]) -> (Option<u32>, bool) {
|
||||
(common(doc, sel, |n| n.opacity.to_bits()), every(doc, sel, |n| matches!(n.kind, NodeKind::Rectangle)))
|
||||
}
|
||||
|
||||
/// The same questions asked of the document directly.
|
||||
fn direct(doc: &Document, sel: &[NodeId]) -> (Option<u32>, bool) {
|
||||
let opacities: Vec<u32> = sel.iter().filter_map(|i| doc.node(*i)).map(|n| n.opacity.to_bits()).collect();
|
||||
let common = opacities.first().copied().filter(|o| opacities.iter().all(|x| x == o));
|
||||
(common, sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Rectangle))))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panels_kept_layers_answer_as_the_document_does() {
|
||||
let mut s = Session::new();
|
||||
let (a, b) = (rect(&mut s, 0, None), rect(&mut s, 80, None));
|
||||
s.execute("node.set", &json!({"ids": [a], "props": {"opacity": 0.5}})).unwrap();
|
||||
let sel = vec![a, b];
|
||||
let doc = s.ds().unwrap().doc.clone();
|
||||
show(&doc, &sel);
|
||||
assert_eq!(answers(&doc, &sel), direct(&doc, &sel));
|
||||
assert_eq!(answers(&doc, &sel).0, None, "mixed");
|
||||
// An edit is a new snapshot: answered from itself, before it is shown and after.
|
||||
s.execute("node.set", &json!({"ids": [a, b], "props": {"opacity": 0.25}})).unwrap();
|
||||
let edited = s.ds().unwrap().doc.clone();
|
||||
assert!(!Arc::ptr_eq(&doc, &edited));
|
||||
assert_eq!(answers(&edited, &sel), (Some(0.25f32.to_bits()), true));
|
||||
show(&edited, &sel);
|
||||
assert_eq!(answers(&edited, &sel), (Some(0.25f32.to_bits()), true));
|
||||
assert_eq!(answers(&doc, &sel), direct(&doc, &sel), "the document shown before still answers for itself");
|
||||
// Another selection.
|
||||
s.execute("node.set", &json!({"ids": [b], "props": {"opacity": 1.0}})).unwrap();
|
||||
let doc = s.ds().unwrap().doc.clone();
|
||||
show(&doc, &sel);
|
||||
for ids in [vec![a], vec![b], vec![b, a]] {
|
||||
assert_eq!(answers(&doc, &ids), direct(&doc, &ids), "{ids:?}");
|
||||
show(&doc, &ids);
|
||||
assert_eq!(answers(&doc, &ids), direct(&doc, &ids), "{ids:?} shown");
|
||||
}
|
||||
// A layer that isn't in the document: `every` says no, `common` goes by the others.
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
let doc = s.ds().unwrap().doc.clone();
|
||||
show(&doc, &sel);
|
||||
assert_eq!(answers(&doc, &sel), (Some(0.25f32.to_bits()), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_selection_never_answers_for_another_document() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0, None);
|
||||
let sel = vec![a];
|
||||
let base = s.doc().unwrap().clone();
|
||||
let first = Arc::new(base.clone());
|
||||
let kept_at = Arc::as_ptr(&first);
|
||||
show(&first, &sel);
|
||||
drop(first);
|
||||
let mut changed = base;
|
||||
changed.node_mut(a).unwrap().opacity = 0.1;
|
||||
let second = Arc::new(changed);
|
||||
assert_ne!(Arc::as_ptr(&second), kept_at, "the kept document's place stays taken while it is kept");
|
||||
assert_eq!(answers(&second, &sel), (Some(0.1f32.to_bits()), true));
|
||||
}
|
||||
|
||||
fn app_of(s: Session) -> (FigApp, Context) {
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
|
||||
let ctx = Context::default();
|
||||
set_canvas_page(&ctx, Color32::WHITE);
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
/// What one frame draws, and what it draws with nothing kept.
|
||||
fn drawn_now(app: &FigApp, ctx: &Context) -> (usize, usize) {
|
||||
let fresh = Context::default();
|
||||
set_canvas_page(&fresh, Color32::WHITE);
|
||||
(drawn(app, ctx), drawn(app, &fresh))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraint_guides_follow_the_layers_frame() {
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap());
|
||||
let r = rect(&mut s, 40, Some(f));
|
||||
let page = s.page().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let (mut app, ctx) = app_of(s);
|
||||
let (pinned, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(pinned, fresh);
|
||||
for (parent, what) in [(page, "on the page: no frame to pin it to"), (f, "back in its frame")] {
|
||||
app.session.execute("node.reparent", &json!({"ids": [r], "parent": parent})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let (now, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(now, fresh, "{what}");
|
||||
assert_eq!(now < pinned, parent == page, "{what}");
|
||||
}
|
||||
// In an auto layout flow its constraints don't apply; ignoring the layout, they do.
|
||||
app.session.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
let (placed, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(placed, fresh, "auto layout places the layer");
|
||||
app.set_props(&[r], json!({"absolute": true}));
|
||||
let (absolute, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(absolute, fresh, "ignoring the auto layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operand_outlines_follow_the_boolean_group() {
|
||||
let mut s = Session::new();
|
||||
let ops = [rect(&mut s, 0, None), rect(&mut s, 20, None), rect(&mut s, 40, None)];
|
||||
s.execute("bool.union", &json!({"ids": ops})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [ops[0]]})).unwrap();
|
||||
let (mut app, ctx) = app_of(s);
|
||||
let (three, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(three, fresh);
|
||||
app.session.execute("edit.delete", &json!({"ids": [ops[2]]})).unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [ops[0]]})).unwrap();
|
||||
let (two, fresh) = drawn_now(&app, &ctx);
|
||||
assert_eq!(two, fresh, "one operand fewer to outline");
|
||||
assert!(two < three);
|
||||
}
|
||||
@@ -411,6 +411,8 @@ pub struct Worker {
|
||||
/// The newest job the thread is done with: rendered (or skipped for a newer one) and the UI
|
||||
/// woken for it.
|
||||
finished: Arc<std::sync::atomic::AtomicU64>,
|
||||
/// The watchdog is drawing a late preview, or has one to hand over and wake the UI for.
|
||||
previewing: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -427,6 +429,8 @@ impl Worker {
|
||||
let (watch, watched) = std::sync::mpsc::channel::<(u64, Job, std::time::Instant)>();
|
||||
let refined = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let refined_seen = refined.clone();
|
||||
let previewing = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let busy = previewing.clone();
|
||||
let (preview_tx, preview_ctx) = (done_tx.clone(), ctx.clone());
|
||||
let watchdog =
|
||||
std::thread::Builder::new().name("figcraft-render-preview".into()).stack_size(figcraft_render::RENDER_STACK).spawn(move || {
|
||||
@@ -441,17 +445,23 @@ impl Worker {
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
}
|
||||
let still_rendering = || refined_seen.load(std::sync::atomic::Ordering::Acquire) < seq;
|
||||
if !still_rendering() {
|
||||
continue;
|
||||
}
|
||||
let Some(d) = preview_with(&job, &mut retained) else { continue };
|
||||
if still_rendering() {
|
||||
if preview_tx.send(answering(d, seq)).is_err() {
|
||||
break;
|
||||
}
|
||||
// Busy from before it looks at the render until its preview is handed over and the
|
||||
// UI woken, so `in_flight` holds until then: once the full render is done (stored
|
||||
// before the render thread's `finished`), this sees it and hands over nothing.
|
||||
busy.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
let still_rendering = || refined_seen.load(std::sync::atomic::Ordering::SeqCst) < seq;
|
||||
let mut sent = true;
|
||||
if still_rendering()
|
||||
&& let Some(d) = preview_with(&job, &mut retained)
|
||||
&& still_rendering()
|
||||
{
|
||||
sent = preview_tx.send(answering(d, seq)).is_ok();
|
||||
preview_ctx.request_repaint_of(viewport);
|
||||
}
|
||||
busy.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||
if !sent {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = watchdog {
|
||||
@@ -482,20 +492,20 @@ impl Worker {
|
||||
let _ = watch.send((seq, job.clone(), std::time::Instant::now()));
|
||||
}
|
||||
let done = renders.refine(&job);
|
||||
refined.store(seq, std::sync::atomic::Ordering::Release);
|
||||
refined.store(seq, std::sync::atomic::Ordering::SeqCst);
|
||||
if let Some(d) = done
|
||||
&& done_tx.send(answering(d, seq)).is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint_of(viewport);
|
||||
done_with.store(seq, std::sync::atomic::Ordering::Release);
|
||||
done_with.store(seq, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::error!("render thread: {e}");
|
||||
}
|
||||
Self { tx, rx, submitted: 0, finished }
|
||||
Self { tx, rx, submitted: 0, finished, previewing }
|
||||
}
|
||||
|
||||
pub fn submit(&mut self, job: Job) {
|
||||
@@ -504,10 +514,10 @@ impl Worker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a submitted job is still rendering or hasn't woken the UI yet (a finished image
|
||||
/// may still wait for [`Self::poll`]).
|
||||
/// Whether a submitted job is still rendering or hasn't woken the UI yet, the watchdog's late
|
||||
/// preview included (a finished image may still wait for [`Self::poll`]).
|
||||
pub fn in_flight(&self) -> bool {
|
||||
self.finished.load(std::sync::atomic::Ordering::Acquire) < self.submitted
|
||||
self.finished.load(std::sync::atomic::Ordering::SeqCst) < self.submitted || self.previewing.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// The best of the renders finished since the last poll ([`better`]).
|
||||
|
||||
@@ -112,7 +112,7 @@ fn every_input_the_geometry_shows_makes_it_again() {
|
||||
}
|
||||
|
||||
/// How many shapes one frame of the canvas overlays draws.
|
||||
fn drawn(app: &FigApp, ctx: &Context) -> usize {
|
||||
pub(super) fn drawn(app: &FigApp, ctx: &Context) -> usize {
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light());
|
||||
});
|
||||
|
||||
@@ -56,6 +56,9 @@ pub struct PluginsWindow {
|
||||
|
||||
/// Width of the plugin windows' fields.
|
||||
const FIELD_W: f32 = 220.0;
|
||||
/// Tallest a panel image is drawn, and the largest picture a panel shows (pixels on a side).
|
||||
const IMAGE_MAX_H: f32 = 240.0;
|
||||
const IMAGE_MAX_SIDE: u32 = 4096;
|
||||
/// The value field beside a slider.
|
||||
const SLIDER_VALUE_W: f32 = 56.0;
|
||||
/// How far apart plugin panels open, side by side (a panel and a gap).
|
||||
@@ -608,12 +611,13 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
|
||||
for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) {
|
||||
let Some(p) = row.first() else { continue };
|
||||
// Checkboxes and buttons carry their own label; a list's sits by its first item.
|
||||
let caption = if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note) { "" } else { p.title() };
|
||||
let caption = RichText::new(caption).font(regular(11.0)).color(t.text_secondary);
|
||||
let caption = if matches!(p.kind, ParamKind::Boolean | ParamKind::Button | ParamKind::Note | ParamKind::Image) { "" } else { p.title() };
|
||||
// Unwrapped: the column is as wide as its widest caption ("Palette" wrapped otherwise).
|
||||
let caption = egui::Label::new(RichText::new(caption).font(regular(11.0)).color(t.text_secondary)).extend();
|
||||
if p.kind == ParamKind::List {
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.label(caption));
|
||||
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| ui.add(caption));
|
||||
} else {
|
||||
ui.label(caption);
|
||||
ui.add(caption);
|
||||
}
|
||||
let hit = match p.kind {
|
||||
ParamKind::Note => {
|
||||
@@ -623,6 +627,10 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
|
||||
});
|
||||
None
|
||||
}
|
||||
ParamKind::Image => {
|
||||
image(ui, p, t);
|
||||
None
|
||||
}
|
||||
ParamKind::Button => {
|
||||
ui.horizontal(|ui| {
|
||||
let pressed = row.iter().filter(|b| widgets::button(ui, b.title(), std::mem::take(&mut first_button), true).clicked());
|
||||
@@ -644,6 +652,54 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
|
||||
changed
|
||||
}
|
||||
|
||||
/// Panel images decoded lately, by their `data` (None: it couldn't be shown); the oldest go past
|
||||
/// [`IMAGE_CACHE`], so a plugin that keeps changing its pictures doesn't keep every texture.
|
||||
type ImageCache = Vec<(Id, Option<egui::TextureHandle>)>;
|
||||
const IMAGE_CACHE: usize = 32;
|
||||
|
||||
/// A panel's picture, at most as wide as a field and [`IMAGE_MAX_H`] tall (never scaled up), named
|
||||
/// for screen readers by its label; its label (or "Image") as text when it can't be shown.
|
||||
fn image(ui: &mut Ui, p: &Param, t: &Tokens) {
|
||||
let (cache, key) = (Id::unique("plugin_images"), Id::unique(&p.data));
|
||||
let cached = ui.data(|d| d.get_temp::<ImageCache>(cache).and_then(|c| c.into_iter().find(|(k, _)| *k == key)));
|
||||
let tex = match cached {
|
||||
Some((_, tex)) => tex,
|
||||
None => {
|
||||
let tex = decode_image(ui.ctx(), &p.data);
|
||||
ui.data_mut(|d| {
|
||||
let c = d.get_temp_mut_or_default::<ImageCache>(cache);
|
||||
c.push((key, tex.clone()));
|
||||
let over = c.len().saturating_sub(IMAGE_CACHE);
|
||||
c.drain(..over);
|
||||
});
|
||||
tex
|
||||
}
|
||||
};
|
||||
let Some(tex) = tex else {
|
||||
let text: &str = if p.label.is_empty() { tr("Image") } else { &p.label };
|
||||
ui.label(RichText::new(text).font(regular(11.0)).color(t.text_secondary));
|
||||
return;
|
||||
};
|
||||
let size = tex.size_vec2();
|
||||
let size = size * (FIELD_W / size.x).min(IMAGE_MAX_H / size.y).min(1.0);
|
||||
let resp = ui.add(egui::Image::new(&tex).fit_to_exact_size(size));
|
||||
crate::a11y::image(&resp, &p.label);
|
||||
}
|
||||
|
||||
/// A panel image's texture: its base64 decoded, then the picture (at most [`IMAGE_MAX_SIDE`]
|
||||
/// pixels on a side, checked before it is decoded), scaled to twice the field's width.
|
||||
fn decode_image(ctx: &Context, data: &str) -> Option<egui::TextureHandle> {
|
||||
use base64::Engine as _;
|
||||
let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).ok()?;
|
||||
let (w, h) = figcraft_render::images::dimensions(&bytes)?;
|
||||
if w > IMAGE_MAX_SIDE || h > IMAGE_MAX_SIDE {
|
||||
return None;
|
||||
}
|
||||
let pic = figcraft_format::thumbnail::decode_thumbnail(&bytes, (FIELD_W * 2.0) as u32)?;
|
||||
let image = egui::ColorImage::from_rgba_unmultiplied([pic.width as usize, pic.height as usize], &pic.rgba);
|
||||
Some(ctx.load_texture("plugin-panel-image", image, egui::TextureOptions::LINEAR))
|
||||
}
|
||||
|
||||
/// One parameter's field, named for screen readers: its new value when it changed.
|
||||
fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Used> {
|
||||
let name = p.title();
|
||||
@@ -726,7 +782,7 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option<Used> {
|
||||
}
|
||||
typed
|
||||
}
|
||||
ParamKind::Button | ParamKind::Note => None,
|
||||
ParamKind::Button | ParamKind::Note | ParamKind::Image => None,
|
||||
};
|
||||
v.map(|v| Used::Set(p.name.clone(), v))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! The Export section's button names the layer it exports on one line inside the button: a long
|
||||
//! name ends with an ellipsis instead of wrapping into a block painted over the rest of the panel
|
||||
//! (#1650), at the default panel width and wider.
|
||||
|
||||
use egui::{Rect, Shape};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::widgets::FIELD_H;
|
||||
|
||||
/// The text painted this frame, with each piece's box.
|
||||
fn painted(u: &mut Ui) -> Vec<(String, Rect)> {
|
||||
fn walk(shape: &Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
u.frame(vec![]);
|
||||
let frame = u.frame_output(vec![]);
|
||||
let mut out = Vec::new();
|
||||
for c in &frame.shapes {
|
||||
walk(&c.shape, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A frame named `name` with an export setting, selected, in a right panel `width` wide: the export
|
||||
/// button's label and box, and the panel's box.
|
||||
fn export_label(name: &str, width: f32) -> (String, Rect, Rect) {
|
||||
let mut s = Session::new();
|
||||
let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap());
|
||||
s.execute("export.add", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("node.set", &json!({"id": f, "props": {"name": name}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_width = width;
|
||||
let texts = painted(&mut u);
|
||||
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
|
||||
assert!((panel.width() - width).abs() <= 1.0, "a {width} px panel (less its edge), not {panel:?}");
|
||||
let buttons: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("Export ") && r.min.x >= panel.min.x).collect();
|
||||
let [(text, rect)] = buttons.as_slice() else { panic!("one export button: {buttons:?}") };
|
||||
assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().name, name, "the name itself stays whole");
|
||||
(text.clone(), *rect, panel)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_name_stays_on_one_line_inside_the_export_button() {
|
||||
let long = "FRAME_NAME_OVERFLOW_".repeat(200);
|
||||
for width in [240.0, 400.0] {
|
||||
let (text, rect, panel) = export_label(&long, width);
|
||||
assert!(text.ends_with('…'), "{width}: shortened, not {} characters", text.chars().count());
|
||||
assert!(rect.min.x >= panel.min.x && rect.max.x <= panel.max.x, "{width}: {rect:?} inside {panel:?}");
|
||||
assert!(rect.height() <= FIELD_H, "{width}: one line, not {} tall", rect.height());
|
||||
}
|
||||
}
|
||||
|
||||
/// Dev Mode's header names the layer on one line inside the panel too.
|
||||
#[test]
|
||||
fn dev_modes_header_shortens_a_long_name() {
|
||||
let long = "FRAME_NAME_OVERFLOW_".repeat(200);
|
||||
let mut s = Session::new();
|
||||
let f = crate::present_tests::id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 856, "height": 1089})).unwrap());
|
||||
s.execute("node.set", &json!({"id": f, "props": {"name": long}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [f]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Inspect;
|
||||
let texts = painted(&mut u);
|
||||
let panel = u.app.ui.right_panel_rect.expect("the right panel shows");
|
||||
let names: Vec<(String, Rect)> = texts.into_iter().filter(|(t, r)| t.starts_with("FRAME_NAME") && r.min.x >= panel.min.x).collect();
|
||||
assert!(!names.is_empty(), "the header names the layer");
|
||||
for (text, rect) in names {
|
||||
assert!(text.ends_with('…'), "shortened: {} characters", text.chars().count());
|
||||
assert!(rect.max.x <= panel.max.x, "{rect:?} inside {panel:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Eliding as it was done before: every candidate from the longest down, the first that fits.
|
||||
fn elide_by_trying_all(ui: &egui::Ui, s: &str, max_w: f32, font: egui::FontId, from_start: bool) -> String {
|
||||
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x <= max_w;
|
||||
if fits(s) {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
(1..chars.len())
|
||||
.rev()
|
||||
.map(|n| {
|
||||
if from_start {
|
||||
format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start())
|
||||
} else {
|
||||
format!("{}…", chars.iter().take(n).collect::<String>().trim_end())
|
||||
}
|
||||
})
|
||||
.find(|c| fits(c))
|
||||
.unwrap_or_else(|| "…".into())
|
||||
}
|
||||
|
||||
/// Eliding halves its way to the longest candidate that fits (a long name no longer lays out one
|
||||
/// candidate per character every frame) and finds what trying every candidate finds.
|
||||
#[test]
|
||||
fn eliding_finds_what_trying_every_candidate_finds() {
|
||||
use crate::theme::{regular, semibold};
|
||||
let texts = [
|
||||
"FRAME_NAME_OVERFLOW_".repeat(20),
|
||||
"A name with spaces in it".to_string(),
|
||||
"W".repeat(40),
|
||||
"i".repeat(60),
|
||||
"Ünïcödé names — with “quotes” and 漢字 too".to_string(),
|
||||
];
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
for s in &texts {
|
||||
for w in [0.0, 5.0, 12.0, 40.0, 97.5, 160.0, 400.0, 2000.0] {
|
||||
for font in [regular(11.0), semibold(13.0)] {
|
||||
let end = crate::widgets::elide_font(ui, s, w, font.clone());
|
||||
assert_eq!(end, elide_by_trying_all(ui, s, w, font.clone(), false), "{s:?} in {w}");
|
||||
let start = crate::widgets::elide_start_font(ui, s, w, font.clone());
|
||||
assert_eq!(start, elide_by_trying_all(ui, s, w, font, true), "{s:?} from its start in {w}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_breaks_in_a_name_show_as_spaces_and_short_names_in_full() {
|
||||
let (text, rect, _) = export_label("First line\nsecond line", 240.0);
|
||||
assert_eq!(text, "Export First line second line");
|
||||
assert!(rect.height() <= FIELD_H);
|
||||
assert_eq!(export_label("Frame 1", 240.0).0, "Export Frame 1");
|
||||
}
|
||||
@@ -39,6 +39,8 @@ pub mod windows;
|
||||
#[cfg(test)]
|
||||
mod board_connect_tests;
|
||||
#[cfg(test)]
|
||||
mod board_line_tests;
|
||||
#[cfg(test)]
|
||||
mod board_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod board_ui_tests;
|
||||
@@ -98,6 +100,8 @@ mod display_name_tests;
|
||||
#[cfg(test)]
|
||||
mod duplicate_paint_tests;
|
||||
#[cfg(test)]
|
||||
mod export_name_tests;
|
||||
#[cfg(test)]
|
||||
mod export_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod field_tests;
|
||||
@@ -231,6 +235,8 @@ mod style_folder_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod style_props_tests;
|
||||
#[cfg(test)]
|
||||
mod table_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod text_enter_tests;
|
||||
#[cfg(test)]
|
||||
mod titlebar_tests;
|
||||
|
||||
@@ -92,12 +92,27 @@ fn is_clipboard_marker(text: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its
|
||||
/// thread, SVG imports, anything else becomes a text layer.
|
||||
/// thread, SVG imports, spreadsheet cells on a board make a table, anything else becomes a text
|
||||
/// layer.
|
||||
fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) {
|
||||
let board = app.session.doc().is_ok_and(figcraft_engine::board::is_board);
|
||||
if text.trim().starts_with("figcraft://comment/") {
|
||||
app.run("comment.open", json!({"link": text.trim()}));
|
||||
} else if dev_mode_refuses(app, "edit.paste") {
|
||||
// Dev Mode: the note says why nothing was pasted.
|
||||
} else if let Some((cells, more)) = board.then(|| figcraft_engine::board_table::spreadsheet_cells(text)).flatten() {
|
||||
// Centred in the view, or (Paste here) its top-left at the point.
|
||||
let mut params = json!({"cells": cells});
|
||||
if !center {
|
||||
(params["x"], params["y"]) = (json!(cx), json!(cy));
|
||||
}
|
||||
if app.run("board.table", params).is_some() && more {
|
||||
let (rows, columns) = (figcraft_engine::board_table::MAX_ROWS, figcraft_engine::board_table::MAX_COLUMNS);
|
||||
app.toast(trf(
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted",
|
||||
&[("rows", &rows.to_string()), ("columns", &columns.to_string())],
|
||||
));
|
||||
}
|
||||
} else if figcraft_engine::looks_like_svg(text) {
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
|
||||
@@ -362,8 +362,12 @@ pub(crate) fn wide_button(ui: &mut Ui, label: &str) -> bool {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
|
||||
let galley = ui.painter().layout(label.to_string(), regular(11.0), t.text, w - 16.0);
|
||||
// One line inside the button: a long layer name ends with an ellipsis and shows in full on
|
||||
// hover (#1650).
|
||||
let shown = widgets::elide(ui, &label.replace(['\r', '\n'], " "), w - 16.0);
|
||||
let galley = ui.painter().layout_no_wrap(shown.clone(), regular(11.0), t.text);
|
||||
ui.painter().galley(r.center() - galley.size() / 2.0, galley, t.text);
|
||||
let resp = if shown == label { resp } else { resp.on_hover_text(label) };
|
||||
clicked = resp.clicked();
|
||||
});
|
||||
clicked
|
||||
|
||||
@@ -29,9 +29,9 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
|
||||
/// no auto layout and the selection is editable outside an instance.
|
||||
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
|
||||
let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group)));
|
||||
let no_al = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.layout.is_none()));
|
||||
let frames = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like());
|
||||
let layout_containers = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group));
|
||||
let no_al = !sel.is_empty() && crate::selection_cache::every(doc, sel, |n| n.layout.is_none());
|
||||
let al = common(doc, sel, |n| n.layout.clone());
|
||||
let has_al = matches!(al, Some(Some(_)));
|
||||
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
|
||||
@@ -187,7 +187,7 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has
|
||||
return;
|
||||
}
|
||||
// Min/max apply to auto layout frames and their children; absolute children neither fill nor limit.
|
||||
let can_fill = parent_al && sel.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute));
|
||||
let can_fill = parent_al && crate::selection_cache::every(doc, sel, |n| !n.child_layout.absolute);
|
||||
let can_limit = has_al || can_fill;
|
||||
let first = sel.first().and_then(|i| doc.node(*i));
|
||||
row(ui, 32.0, |ui| {
|
||||
|
||||
@@ -42,9 +42,11 @@ pub(crate) use stroke::{CAP_ART_W, cap_art, join_label};
|
||||
|
||||
/// Common value of `f` across `ids` (None when mixed or empty).
|
||||
pub(crate) fn common<T: PartialEq + Clone>(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> T) -> Option<T> {
|
||||
let mut it = ids.iter().filter_map(|i| doc.node(*i));
|
||||
let first = f(it.next()?);
|
||||
if it.all(|n| f(n) == first) { Some(first) } else { None }
|
||||
crate::selection_cache::with_nodes(doc, ids, |it| {
|
||||
let mut it = it.flatten();
|
||||
let first = f(it.next()?);
|
||||
if it.all(|n| f(n) == first) { Some(first) } else { None }
|
||||
})
|
||||
}
|
||||
|
||||
impl FigApp {
|
||||
@@ -257,6 +259,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
page_section(app, ui, &doc);
|
||||
return;
|
||||
}
|
||||
crate::selection_cache::show(&shown, &sel);
|
||||
header(app, ui, &shown, &sel);
|
||||
widgets::separator(ui);
|
||||
// Editing a vector with points selected, the points' fields stand in for the layer's position
|
||||
@@ -287,7 +290,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
if variable_modes(app, ui, &shown, &sel) {
|
||||
widgets::separator(ui);
|
||||
}
|
||||
if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) {
|
||||
if crate::selection_cache::with_nodes(&shown, &sel, |it| it.flatten().any(|n| matches!(n.kind, NodeKind::Text(_)))) {
|
||||
crate::typography::typography(app, ui, &shown, &sel);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
@@ -298,7 +301,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
effects(app, ui, &shown, &sel);
|
||||
widgets::separator(ui);
|
||||
crate::selection_colors::section(app, ui, &shown, &sel);
|
||||
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
|
||||
if crate::selection_cache::every(&shown, &sel, |n| n.kind.is_frame_like()) {
|
||||
grids(app, ui, &shown, &sel);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
@@ -376,7 +379,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
};
|
||||
let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame));
|
||||
// Frames and groups convert into each other from the type menu.
|
||||
let is_group = !sel.is_empty() && sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Group)));
|
||||
let is_group = !sel.is_empty() && crate::selection_cache::every(doc, sel, |n| matches!(n.kind, NodeKind::Group));
|
||||
let kind_items = |app: &mut FigApp, ui: &mut Ui, frame: bool| {
|
||||
for (to_frame, label, cmd) in [(true, tr("Frame"), "object.convertToFrame"), (false, tr("Group"), "object.convertToGroup")] {
|
||||
if menu_check(ui, to_frame == frame, label, "") {
|
||||
@@ -718,13 +721,14 @@ fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option<figc
|
||||
/// (`node.set`'s `arcStart` / `arcSweep` / `arcInner`; bindable to number variables, sweep and
|
||||
/// ratio ones holding percentages). Returns whether it showed.
|
||||
fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
|
||||
let arcs: Vec<(f64, f64, f64)> = sel
|
||||
.iter()
|
||||
.filter_map(|i| match doc.node(*i)?.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let arcs: Vec<(f64, f64, f64)> = crate::selection_cache::with_nodes(doc, sel, |it| {
|
||||
it.flatten()
|
||||
.filter_map(|n| match n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => Some((start, sweep, inner)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if arcs.is_empty() || arcs.len() != sel.len() {
|
||||
return false;
|
||||
}
|
||||
@@ -761,14 +765,15 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) ->
|
||||
/// inner radius as a share of the outer one), as `node.set`'s `pointCount` / `innerRadius`.
|
||||
/// Returns whether it showed.
|
||||
fn points_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
|
||||
let shapes: Vec<(u32, Option<f64>)> = sel
|
||||
.iter()
|
||||
.filter_map(|i| match doc.node(*i)?.kind {
|
||||
NodeKind::Polygon { count } => Some((count, None)),
|
||||
NodeKind::Star { count, inner_ratio } => Some((count, Some(inner_ratio))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let shapes: Vec<(u32, Option<f64>)> = crate::selection_cache::with_nodes(doc, sel, |it| {
|
||||
it.flatten()
|
||||
.filter_map(|n| match n.kind {
|
||||
NodeKind::Polygon { count } => Some((count, None)),
|
||||
NodeKind::Star { count, inner_ratio } => Some((count, Some(inner_ratio))),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
if shapes.is_empty() || shapes.len() != sel.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -195,8 +195,13 @@ pub(super) fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
||||
crate::dev_mode::plugins_section(app, ui);
|
||||
return;
|
||||
};
|
||||
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
|
||||
widgets::section_header(ui, &name, &[], false);
|
||||
// The layer's name on one line: a long one ends with an ellipsis and shows in full on hover (#1650).
|
||||
let name = doc.node(first).map(|n| n.name.replace(['\r', '\n'], " ")).unwrap_or_default();
|
||||
let shown = widgets::elide_font(ui, &name, ui.available_width() - PAD - widgets::PAD_RIGHT, semibold(11.0));
|
||||
let (_, header) = widgets::section_header_resp(ui, &shown, &[], false);
|
||||
if shown != name {
|
||||
header.on_hover_text(name);
|
||||
}
|
||||
crate::dev_mode::status(app, ui, &doc, &sel);
|
||||
crate::dev_mode::annotations_section(app, ui, &doc, first);
|
||||
crate::dev_mode::dev_resources_section(app, ui, &doc, first);
|
||||
|
||||
@@ -33,7 +33,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0);
|
||||
// Masks: how they mask.
|
||||
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) {
|
||||
if crate::selection_cache::every(doc, sel, |n| n.is_mask) {
|
||||
let mt = common(doc, sel, |n| n.mask_type);
|
||||
widgets::sublabels(ui, tr("Mask"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
@@ -50,11 +50,11 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
|
||||
}
|
||||
let op_of = |n: &Node| (f64::from(n.opacity) * 100.0).round();
|
||||
let op = common(doc, sel, op_of);
|
||||
let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius()));
|
||||
let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius());
|
||||
// Only rectangles and frames round each corner on its own.
|
||||
let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners()));
|
||||
let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners());
|
||||
// For polygons and stars the same panel holds only the Corner smoothing row.
|
||||
let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing()));
|
||||
let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing());
|
||||
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
|
||||
widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius")));
|
||||
row(ui, 32.0, |ui| {
|
||||
@@ -147,7 +147,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N
|
||||
/// of its own (`variable.setMode`). Collections from enabled libraries follow under their
|
||||
/// library's name (`library.setVariableMode` copies the collection in). Returns whether it showed.
|
||||
pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
|
||||
if !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
|
||||
if !crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like()) {
|
||||
return false;
|
||||
}
|
||||
let libs = crate::variable_ui::libraries(app, doc);
|
||||
@@ -351,7 +351,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI
|
||||
// The row is the panel's width: the two fields split what the buttons at its end leave,
|
||||
// so the align field and the weight field are the same column (76 at the default width
|
||||
// with two buttons, 88 with one). The buttons are 24 pt each, 4 apart on the icon grid.
|
||||
let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));
|
||||
let sided = crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle));
|
||||
let group = if sided { 24.0 + 4.0 + 24.0 } else { 24.0 };
|
||||
let field_w = ((ui.available_width() - widgets::GUTTER * 2.0 - group) / 2.0).max(40.0);
|
||||
let resp = widgets::dropdown(
|
||||
|
||||
@@ -240,7 +240,7 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
|
||||
}
|
||||
});
|
||||
// End points only mean something on open paths.
|
||||
let open_paths = sel.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Line | NodeKind::Vector { .. })));
|
||||
let open_paths = crate::selection_cache::every(doc, sel, |n| matches!(n.kind, NodeKind::Line | NodeKind::Vector { .. }));
|
||||
if open_paths {
|
||||
widgets::sublabels(ui, tr("Start point"), Some(tr("End point")));
|
||||
row(ui, 32.0, |ui| {
|
||||
|
||||
@@ -107,7 +107,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
}
|
||||
});
|
||||
row(ui, 28.0, |ui| {
|
||||
let hidden = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.hides_contents()));
|
||||
let hidden = crate::selection_cache::every(doc, sel, |n| n.hides_contents());
|
||||
if widgets::checkbox(ui, hidden, tr("Hide contents")) {
|
||||
app.run("section.toggleContents", json!({"ids": ids, "hidden": !hidden}));
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(crate) fn panel_module() -> Vec<u8> {
|
||||
{"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"},
|
||||
{"type": "select", "name": "shape", "label": "Shape", "options": ["Square", "Circle"]},
|
||||
{"type": "text", "name": "title", "label": "Title"},
|
||||
{"type": "image", "name": "logo", "label": "Brand mark", "data": png(40, 20)},
|
||||
{"type": "image", "label": "Broken chart", "data": "bm90IGFuIGltYWdl"},
|
||||
{"type": "image", "label": "Too wide", "data": png(5000, 1)},
|
||||
{"type": "slider", "name": "opacity", "label": "Opacity", "min": 0, "max": 100, "value": 0},
|
||||
{"type": "button", "name": "go", "label": "Go"}
|
||||
]})
|
||||
@@ -46,6 +49,15 @@ pub(crate) fn panel_module() -> Vec<u8> {
|
||||
wat::parse_str(&wat).unwrap()
|
||||
}
|
||||
|
||||
/// A PNG of `w` × `h` pixels, base64.
|
||||
fn png(w: u32, h: u32) -> String {
|
||||
let mut out = Vec::new();
|
||||
image::RgbaImage::from_pixel(w, h, image::Rgba([255, 90, 95, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
base64::engine::general_purpose::STANDARD.encode(out)
|
||||
}
|
||||
|
||||
/// A session with that plugin's panel open.
|
||||
fn session() -> Session {
|
||||
let wasm = panel_module();
|
||||
@@ -124,6 +136,26 @@ fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() {
|
||||
assert_eq!((accents, u.app.session.plugins.panels.len()), (2, 2), "both panels are drawn");
|
||||
}
|
||||
|
||||
/// A panel shows its pictures at their size, named for screen readers; one that can't be decoded,
|
||||
/// or is too large, shows its label instead; an image isn't a control `plugin.event` can use.
|
||||
#[test]
|
||||
fn panel_images_show_named_or_fall_back_to_their_label() {
|
||||
let mut u = Ui::new(session());
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
|
||||
let images: Vec<_> = nodes.iter().map(|(_, n)| n).filter(|n| n.role() == egui::accesskit::Role::Image).collect();
|
||||
assert_eq!(images.len(), 1, "only the decodable picture is drawn");
|
||||
assert_eq!(images[0].label(), Some("Brand mark"));
|
||||
let b = images[0].bounds().unwrap();
|
||||
assert_eq!(((b.x1 - b.x0).round(), (b.y1 - b.y0).round()), (40.0, 20.0), "shown at its size");
|
||||
let text =
|
||||
|label: &str| nodes.iter().any(|(_, n)| n.role() != egui::accesskit::Role::Image && (n.label() == Some(label) || n.value() == Some(label)));
|
||||
assert!(text("Broken chart") && text("Too wide"), "a picture that can't be shown shows its label");
|
||||
let err = u.app.session.execute("plugin.event", &json!({"id": "org.test.panel", "control": "logo"})).unwrap_err();
|
||||
assert!(err.to_string().contains("no control `logo`"), "{err}");
|
||||
}
|
||||
|
||||
/// A slider shows the value it is dragged to as it moves and calls the plugin back once, with
|
||||
/// that value, when let go.
|
||||
#[test]
|
||||
|
||||
@@ -302,7 +302,6 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
|
||||
let Some((url, skip)) = p.link_notice.take() else { return };
|
||||
if skip == Some(true) {
|
||||
app.run("proto.linkNotice", json!({"show": false}));
|
||||
app.services.save_prefs(&app.session.prefs);
|
||||
}
|
||||
app.services.open_url(&url);
|
||||
} else if cancel {
|
||||
|
||||
@@ -355,6 +355,27 @@ fn dont_show_this_again_is_saved_with_the_preferences() {
|
||||
assert!(!u.app.session.prefs.link_notice_skipped);
|
||||
}
|
||||
|
||||
/// Skipping the notice with the command (the Actions palette, the control channel, MCP) is saved
|
||||
/// at once as well, not only when the app quits, and a new session started from the saved
|
||||
/// preferences skips it.
|
||||
#[test]
|
||||
fn skipping_the_notice_by_command_is_saved_at_once() {
|
||||
let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
|
||||
let before = seen.1.lock().unwrap().len();
|
||||
assert_eq!(u.run("proto.linkNotice", json!({"show": false})), json!({"shown": false}));
|
||||
u.frames(1, Vec::new);
|
||||
let saves = seen.1.lock().unwrap().clone();
|
||||
assert_eq!(saves.len(), before + 1, "saved once");
|
||||
let saved = saves.last().unwrap();
|
||||
assert!(saved.link_notice_skipped, "the choice is saved");
|
||||
// As the app keeps them: JSON, read back at the next start.
|
||||
let mut next = Session::new();
|
||||
next.prefs = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap();
|
||||
assert!(next.prefs.link_notice_skipped, "a new session reads it back");
|
||||
u.frames(1, Vec::new);
|
||||
assert_eq!(seen.1.lock().unwrap().len(), before + 1, "and not saved again every frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prototype_cannot_open_a_script_or_handler_url() {
|
||||
for url in [
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{Document, Node, NodeId};
|
||||
|
||||
/// One kept value and what it was made from. The document is held weakly, so a kept value never
|
||||
/// keeps an old snapshot alive; the weak reference keeps its address from being reused.
|
||||
@@ -52,6 +52,57 @@ pub(crate) fn moved(ds: &figcraft_engine::DocState) -> Option<(Arc<Document>, (f
|
||||
(it.selection == ds.selection).then(|| (it.doc.clone(), d))
|
||||
}
|
||||
|
||||
/// The document to work out from what a move doesn't change (which layers, their kinds and
|
||||
/// parents): the one a gesture that only moved the selection started from, kept for the whole
|
||||
/// gesture ([`moved`]), else `doc`.
|
||||
pub(crate) fn unmoved(ds: &figcraft_engine::DocState, doc: &Arc<Document>) -> Arc<Document> {
|
||||
moved(ds).map_or_else(|| doc.clone(), |(base, _)| base)
|
||||
}
|
||||
|
||||
/// The selected layers of the document the Design panel shows, looked up once: its sections go
|
||||
/// over the whole selection many times a frame (mixed values, which kinds are selected), and with
|
||||
/// hundreds of layers selected the lookups cost more than the rest of the panel (#1571).
|
||||
struct Shown {
|
||||
doc: Weak<Document>,
|
||||
sel: Vec<NodeId>,
|
||||
nodes: Vec<Option<Arc<Node>>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SHOWN: std::cell::RefCell<Option<Shown>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The panel is about to show `sel` of `doc`: [`with_nodes`] over exactly these reads the layers
|
||||
/// looked up here, kept while the document snapshot and the selection stay the same.
|
||||
pub(crate) fn show(doc: &Arc<Document>, sel: &[NodeId]) {
|
||||
SHOWN.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if s.as_ref().is_some_and(|k| std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc)) && k.sel == sel) {
|
||||
return;
|
||||
}
|
||||
*s = Some(Shown { doc: Arc::downgrade(doc), sel: sel.to_vec(), nodes: sel.iter().map(|i| doc.node_arc(*i).cloned()).collect() });
|
||||
});
|
||||
}
|
||||
|
||||
/// `f` over the layers of `ids` in `doc`, in order (`None` for one not in it): the ones [`show`]
|
||||
/// kept when `doc` and `ids` are what it was given, else looked up. The kept document is held
|
||||
/// weakly, so its address can't be another document's and its contents can't change.
|
||||
pub(crate) fn with_nodes<R>(doc: &Document, ids: &[NodeId], f: impl FnOnce(&mut dyn Iterator<Item = Option<&Node>>) -> R) -> R {
|
||||
SHOWN.with(|s| {
|
||||
let s = s.borrow();
|
||||
match s.as_ref().filter(|k| std::ptr::eq(k.doc.as_ptr(), doc) && k.sel == ids) {
|
||||
Some(k) => f(&mut k.nodes.iter().map(Option::as_deref)),
|
||||
None => f(&mut ids.iter().map(|i| doc.node(*i))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every one of `ids` is in `doc` and passes `f`.
|
||||
pub(crate) fn every(doc: &Document, ids: &[NodeId], f: impl Fn(&Node) -> bool) -> bool {
|
||||
// `Iterator::all` on the `&mut dyn Iterator` itself (sized), not on the trait object.
|
||||
with_nodes(doc, ids, |mut it| Iterator::all(&mut it, |n| n.is_some_and(&f)))
|
||||
}
|
||||
|
||||
/// A screen point as key material.
|
||||
pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] {
|
||||
[u64::from(p.x.to_bits()), u64::from(p.y.to_bits())]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Pasting spreadsheet cells: on a board they make a table (centred in the view, or with Paste here
|
||||
//! its top-left at the point); in a design file, or text that isn't a grid of cells, a text layer as
|
||||
//! before.
|
||||
|
||||
use figcraft_doc::{BoardItem, Document, NodeKind};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
const CELLS: &str = "Name\tRole\nAda\tLead\nGrace\tReview\n";
|
||||
|
||||
fn app(board: bool) -> FigApp {
|
||||
let mut s = Session::new();
|
||||
if board {
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
}
|
||||
FigApp::new(s, Box::new(NoServices))
|
||||
}
|
||||
|
||||
fn tables(doc: &Document) -> Vec<figcraft_doc::NodeId> {
|
||||
doc.ids().filter(|i| doc.node(*i).is_some_and(|n| n.board == Some(BoardItem::Table))).collect()
|
||||
}
|
||||
|
||||
fn texts(doc: &Document) -> usize {
|
||||
doc.ids().filter(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreadsheet_cells_paste_as_a_table_on_a_board() {
|
||||
let mut app = app(true);
|
||||
crate::menus::paste(&mut app, Some(CELLS), None);
|
||||
let doc = app.session.doc().unwrap();
|
||||
let [table] = tables(doc)[..] else { panic!("one table") };
|
||||
assert_eq!(app.session.selection(), vec![table], "selected, as a new table is");
|
||||
let rows = doc.node(table).unwrap().children.iter().filter(|r| doc.node(**r).is_some_and(|n| !n.child_layout.absolute)).count();
|
||||
assert_eq!(rows, 3);
|
||||
let view = app.session.ds().unwrap().view.clone();
|
||||
let b = doc.world_bounds(table).unwrap();
|
||||
assert!((b.center().x - view.cx).abs() < 1.0, "centred in the view: {b:?}");
|
||||
// Paste here: its top-left at the point.
|
||||
crate::menus::paste(&mut app, Some(CELLS), Some((500.0, 700.0)));
|
||||
let doc = app.session.doc().unwrap();
|
||||
let placed = tables(doc).into_iter().find(|t| *t != table).expect("a second table");
|
||||
let b = doc.world_bounds(placed).unwrap();
|
||||
assert_eq!((b.x0, b.y0), (500.0, 700.0));
|
||||
assert!(app.ui.toast.is_none(), "nothing left out, nothing to say");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_larger_paste_keeps_the_largest_table_and_says_so() {
|
||||
let mut app = app(true);
|
||||
let row = vec!["x"; 25].join("\t");
|
||||
crate::menus::paste(&mut app, Some(&vec![row.as_str(); 60].join("\n")), None);
|
||||
assert_eq!(tables(app.session.doc().unwrap()).len(), 1);
|
||||
assert!(app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("50 rows and 20 columns")), "the rest wasn't pasted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_pastes_stay_text() {
|
||||
// Cells pasted into a design file.
|
||||
let mut design = app(false);
|
||||
let before = texts(design.session.doc().unwrap());
|
||||
crate::menus::paste(&mut design, Some(CELLS), None);
|
||||
assert!(tables(design.session.doc().unwrap()).is_empty());
|
||||
assert_eq!(texts(design.session.doc().unwrap()), before + 1, "a text layer");
|
||||
// Lines without tabs on a board.
|
||||
let mut board = app(true);
|
||||
let before = texts(board.session.doc().unwrap());
|
||||
crate::menus::paste(&mut board, Some("first idea\nsecond idea"), None);
|
||||
assert!(tables(board.session.doc().unwrap()).is_empty());
|
||||
assert_eq!(texts(board.session.doc().unwrap()), before + 1);
|
||||
}
|
||||
@@ -775,31 +775,36 @@ pub fn elide(ui: &Ui, s: &str, max_w: f32) -> String {
|
||||
|
||||
/// [`elide`] in `font`.
|
||||
pub fn elide_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String {
|
||||
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w;
|
||||
if fits(s) {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
(1..chars.len())
|
||||
.rev()
|
||||
.map(|n| format!("{}…", chars.iter().take(n).collect::<String>().trim_end()))
|
||||
.find(|c| fits(c))
|
||||
.unwrap_or_else(|| "…".into())
|
||||
elide_with(ui, s, max_w, font, |chars, n| format!("{}…", chars.iter().take(n).collect::<String>().trim_end()))
|
||||
}
|
||||
|
||||
/// [`elide_font`] cut at its start, keeping its end: a path keeps its file and the folders
|
||||
/// nearest it.
|
||||
pub fn elide_start_font(ui: &Ui, s: &str, max_w: f32, font: egui::FontId) -> String {
|
||||
elide_with(ui, s, max_w, font, |chars, n| format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start()))
|
||||
}
|
||||
|
||||
/// `s` if it fits `max_w` in `font`, else the longest `shortened(chars, n)` (keeping `n` of its
|
||||
/// characters, 0 < n < all) that fits, else "…". Keeping more characters never makes it narrower,
|
||||
/// so the longest is found by halving: a name of thousands of characters (#1650) lays out about a
|
||||
/// dozen candidates, not one per character, on every frame.
|
||||
fn elide_with(ui: &Ui, s: &str, max_w: f32, font: egui::FontId, shortened: impl Fn(&[char], usize) -> String) -> String {
|
||||
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w;
|
||||
if fits(s) {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
(1..chars.len())
|
||||
.rev()
|
||||
.map(|n| format!("…{}", chars[chars.len() - n..].iter().collect::<String>().trim_start()))
|
||||
.find(|c| fits(c))
|
||||
.unwrap_or_else(|| "…".into())
|
||||
// Keeping `fit` characters fits (0: nothing kept yet); keeping `over` doesn't (all of them).
|
||||
let (mut fit, mut over) = (0, chars.len());
|
||||
while over - fit > 1 {
|
||||
let mid = fit + (over - fit) / 2;
|
||||
if fits(&shortened(&chars, mid)) {
|
||||
fit = mid;
|
||||
} else {
|
||||
over = mid;
|
||||
}
|
||||
}
|
||||
if fit == 0 { "…".into() } else { shortened(&chars, fit) }
|
||||
}
|
||||
|
||||
/// A row of icon buttons; returns the index clicked.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
|
||||
|
||||
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
|
||||
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
|
||||
@@ -28,7 +28,7 @@ all end with the same geometry.
|
||||
|---|---|---|
|
||||
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
|
||||
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) |
|
||||
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel |
|
||||
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL); for connectors straight/elbow/curved, the start and end points (none, round, square, line arrow, triangle arrow, reversed triangle, circle, diamond), solid or dashed, the weight, and any colour in the colour picker beside the board colours (#1596); each control says what it is on hover; the Design panel opens with its button or View ▸ Design panel |
|
||||
| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
|
||||
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
|
||||
| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks |
|
||||
@@ -52,7 +52,7 @@ all end with the same geometry.
|
||||
| Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only |
|
||||
| Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards |
|
||||
| Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own |
|
||||
| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells, no CSV paste |
|
||||
| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text |
|
||||
| Mind maps | missing | |
|
||||
| Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files |
|
||||
| Audio and huddles | missing | |
|
||||
@@ -64,6 +64,8 @@ all end with the same geometry.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | The contextual bar row: a selected connector's bar sets its start and end points, solid or dashed, its weight and any colour, beside the line type and the board colours (#1596). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. |
|
||||
| 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged |
|
||||
| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+10
-5
@@ -100,8 +100,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
so the differences come from the fonts' advances. Evidence:
|
||||
`crates/text/src/saved_breaks_tests.rs`, `crates/format/src/fig/text_wrap_tests.rs`; not
|
||||
checked on real files.
|
||||
- **Missing:** labels of
|
||||
connectors without a saved snapshot, table cells beyond their own layers, live region
|
||||
- **Missing:** the background of connector labels without a saved snapshot (the label is kept,
|
||||
centred on its line), table cells beyond their own layers, live region
|
||||
style/variable links, variables
|
||||
bound to fields the editor can't bind and video fills aren't read; text lays out again with
|
||||
local fonts (an unedited text keeps the file's line breaks, but glyph positions, line widths
|
||||
@@ -261,7 +261,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
Sliders (2026-10-30): a panel control or command parameter of type `slider` (a number between
|
||||
its `min` and `max`) is drawn as a slider with its value beside it; the plugin is called back
|
||||
once, when the drag ends.
|
||||
Panel images (2026-10-30): an `image` control shows a picture the plugin carries (base64, at most
|
||||
256 KB and 4,096 pixels on a side), as wide as a field at most, named by its label.
|
||||
- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings
|
||||
and install counts, background update checks, the directory in the browser build; widgets have no hover or
|
||||
timers, can't open a panel and draw only frames, text, shapes and images.
|
||||
Figma's own plugins are JavaScript against Figma's plugin API and
|
||||
and install counts, background update checks, the directory in the browser build; widgets have no
|
||||
timers, can't open a panel and draw only frames, text, shapes and images;
|
||||
no images in panels. Figma's own plugins are JavaScript against Figma's plugin API and
|
||||
@@ -411,9 +416,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux
|
||||
and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per
|
||||
platform, or an owner decision on that dependency); PlayStation and Switch button labels.
|
||||
`proto.linkNotice` from the Actions palette or the control channel changes the preference in
|
||||
memory only; it reaches the preferences file with the next preferences save (the notice's own
|
||||
"Don't show this again" saves at once).
|
||||
- **Estimate:** 2–5 h, the media part after video fills (gap 12).
|
||||
|
||||
### 16. Dev Mode: the tail after dev resources
|
||||
@@ -571,6 +573,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | patch | Gap 15: `proto.linkNotice` (the Actions palette, the control channel, MCP) is saved with the preferences at once, as the notice's own "Don't show this again" is, instead of only when the app quits; that note leaves the missing list. Estimate unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once` |
|
||||
| 2026-10-30 | minor | Gap 6: plugin panels show pictures: an `image` control (`data` base64, at most 256 KB and 4,096 px a side; panel specs up to 1 MB) drawn at its size up to a field's width, named by its label, which shows instead when the picture can't be decoded. "Images" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label` |
|
||||
| 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
|
||||
| 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. |
|
||||
| 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` |
|
||||
| 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` |
|
||||
|
||||
@@ -105,7 +105,7 @@ Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), revi
|
||||
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu
|
||||
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header
|
||||
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft
|
||||
with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,565 / 2,565 (100%: 1,905 UI
|
||||
with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,566 / 2,566 (100%: 1,906 UI
|
||||
strings, 629 command labels, 31 menu wordings), a machine draft awaiting native review, with
|
||||
one/few/other plurals (21 takes one). Figma ships none beyond the eight above.
|
||||
|
||||
@@ -125,7 +125,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,905), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
|
||||
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
|
||||
|
||||
+7
-5
@@ -72,7 +72,7 @@ A message the plugin shows (`notify`) appears as a toast; so does a text result.
|
||||
|
||||
**Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window,
|
||||
first placed beside the properties panel (drag its title bar to move it), with the plugin's notes,
|
||||
fields (text, number, slider, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets
|
||||
pictures, fields (text, number, slider, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets
|
||||
and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears
|
||||
of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or
|
||||
changing a field calls the plugin back with every field's value; what that callback does is one
|
||||
@@ -277,7 +277,7 @@ it can handle):
|
||||
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
|
||||
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
|
||||
(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
|
||||
16 MB. A panel spec is at most 1 MB with 64 controls, each image at most 256 KB; settings are at most 64 KB per plugin (keys
|
||||
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
|
||||
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
|
||||
its images 1 MB each, 2 MB together and 4096 pixels on a side. A property menu has at most 8
|
||||
@@ -372,8 +372,10 @@ plugin is removed.
|
||||
```
|
||||
|
||||
`title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`,
|
||||
`number`, `boolean`, `select`, `list`, `slider`, `color`) plus `button` and `note` (text in `label`, up to
|
||||
1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a
|
||||
`number`, `boolean`, `select`, `list`, `slider`, `color`) plus `button`, `note` (text in `label`, up to
|
||||
1,000 characters, no `name` needed) and `image` (`data`: a PNG, JPEG, WebP, GIF, BMP or TIFF file as
|
||||
base64, at most 256 KB and 4,096 pixels on a side; `label` is what screen readers say, and what
|
||||
shows if the picture can't be; no `name` needed), in order, each `name` unique. A field shows its `value`; a
|
||||
field without one keeps what the user entered before the panel was updated, so a plugin can update
|
||||
a note without resetting the other fields. Buttons next to each other share a row; the first button
|
||||
is the primary one. FigCraft checks the whole spec (sizes, kinds, options, bounds) and drops values
|
||||
@@ -523,7 +525,7 @@ import what you need from `figcraft`.
|
||||
- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field
|
||||
is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
|
||||
vectors, components or variables).
|
||||
- Panels: no images or free layout inside a panel.
|
||||
- Panels: no free layout inside a panel; images can't be clicked.
|
||||
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
|
||||
{pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which
|
||||
reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate
|
||||
|
||||
+10
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user