mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 22:36:51 -06:00
Merge main into #1700
This commit is contained in:
Generated
+1
@@ -1820,6 +1820,7 @@ dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
"flate2",
|
||||
"imbl",
|
||||
"postcard",
|
||||
"serde",
|
||||
|
||||
+6
-1
File diff suppressed because one or more lines are too long
@@ -165,7 +165,10 @@ impl Raw {
|
||||
pub fn next(&mut self) -> ServerMsg {
|
||||
loop {
|
||||
if let Message::Binary(b) = self.0.read().unwrap() {
|
||||
return message::decode(&b, usize::MAX).unwrap();
|
||||
// As the app's clients do: a packed welcome is read before anything sees it.
|
||||
let mut m = message::decode(&b, usize::MAX).unwrap();
|
||||
message::unpack(&mut m, usize::MAX).unwrap();
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ enum WaitFailure {
|
||||
Timeout,
|
||||
/// The UI side dropped its sender before replying (the app is closing).
|
||||
Disconnected,
|
||||
/// The UI started the request before the timeout and is still running it: it takes effect
|
||||
/// when it finishes, though nobody waits for its reply (#1673).
|
||||
StillRunning,
|
||||
}
|
||||
|
||||
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
|
||||
@@ -46,10 +49,30 @@ fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGa
|
||||
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
|
||||
match gate.cancel_if_open() {
|
||||
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
|
||||
// It may have finished just now: its reply is the truth then.
|
||||
RequestState::Running => rx.try_recv().map_err(|_| WaitFailure::StillRunning),
|
||||
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a request that got no reply is told. Only a request that needs a drawn frame can be held
|
||||
/// up by the window being out of sight; any other was either never started (the app was busy, so
|
||||
/// it was dropped and never applied) or is still running and will be applied (#1673).
|
||||
fn timeout_message(failure: &WaitFailure, needs_frame: bool) -> &'static str {
|
||||
match failure {
|
||||
WaitFailure::Disconnected => "the FigCraft window closed before answering",
|
||||
WaitFailure::StillRunning => {
|
||||
"the app is still running this request after 60 s: it will take effect when it finishes, but its reply is lost. Check the document before retrying"
|
||||
}
|
||||
WaitFailure::Timeout if needs_frame => {
|
||||
"timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"
|
||||
}
|
||||
WaitFailure::Timeout => {
|
||||
"timed out after 60 s before the app started this request (it was busy, or its window isn't drawing): it was not applied"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
|
||||
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
|
||||
target.retain(|request| !request.is_cancelled());
|
||||
@@ -171,6 +194,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
|
||||
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
|
||||
continue;
|
||||
}
|
||||
let needs_frame = matches!(method.as_str(), "ui.screenshot" | "ui.input");
|
||||
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
|
||||
drop(pending);
|
||||
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
|
||||
@@ -181,12 +205,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
|
||||
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
|
||||
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
|
||||
},
|
||||
Err(WaitFailure::Timeout) => {
|
||||
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
|
||||
}
|
||||
Err(WaitFailure::Disconnected) => {
|
||||
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
|
||||
}
|
||||
Err(failure) => json!({"id": id, "ok": false, "error": timeout_message(&failure, needs_frame)}),
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -162,6 +162,38 @@ fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
|
||||
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
|
||||
}
|
||||
|
||||
/// #1673: a request the UI already started can't be cancelled any more. Its timeout says it is
|
||||
/// still running and will take effect, not that the window is hidden; one that finished just as
|
||||
/// the timeout fired gets its real reply.
|
||||
#[test]
|
||||
fn a_started_request_times_out_as_still_running() {
|
||||
let gate = RequestGate::new();
|
||||
assert!(gate.start(), "an open request starts");
|
||||
let (tx, rx) = mpsc::channel::<ControlReply>();
|
||||
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
|
||||
assert_eq!(failure, WaitFailure::StillRunning);
|
||||
assert_eq!(gate.state(), RequestState::Running, "the timeout doesn't cancel a running request");
|
||||
let message = timeout_message(&failure, false);
|
||||
assert!(message.contains("will take effect") && !message.contains("window"), "{message}");
|
||||
tx.send(ControlReply { id: json!(3), result: Ok(json!({"done": true})) }).unwrap();
|
||||
assert_eq!(wait_for_reply(&rx, &gate, Duration::ZERO).unwrap().id, json!(3), "a reply that made it is the truth");
|
||||
}
|
||||
|
||||
/// #1673: a request the transport gave up on before the UI started it never starts, and its
|
||||
/// timeout says it wasn't applied; only requests that need a drawn frame blame the window.
|
||||
#[test]
|
||||
fn a_request_cancelled_before_it_started_never_runs() {
|
||||
let gate = RequestGate::new();
|
||||
let (_tx, rx) = mpsc::channel::<ControlReply>();
|
||||
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
|
||||
assert_eq!(failure, WaitFailure::Timeout);
|
||||
assert!(!gate.start(), "the UI must not run a cancelled request");
|
||||
assert_eq!(gate.state(), RequestState::Cancelled);
|
||||
assert!(timeout_message(&failure, false).contains("not applied"));
|
||||
assert!(!timeout_message(&failure, false).contains("window on screen"));
|
||||
assert!(timeout_message(&failure, true).contains("window on screen"), "screenshots and input still need the window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_and_partial_frame_connections_expire() {
|
||||
for bytes in [b"".as_slice(), b"{\"id\":"] {
|
||||
|
||||
@@ -374,6 +374,7 @@ impl<B: Backend> Hub<B> {
|
||||
return refuse(&c.out, BARRED);
|
||||
}
|
||||
let hosting = reads_hosting(&hello.client_version);
|
||||
let packed = message::reads_packed(&hello.client_version);
|
||||
// The backend checked the secret: the sessions don't keep it.
|
||||
hello.token.clear();
|
||||
let file = hello.file.clone();
|
||||
@@ -405,7 +406,7 @@ impl<B: Backend> Hub<B> {
|
||||
let old = room.ids.insert(id, conn).filter(|old| *old != conn);
|
||||
self.backend.changed(&file, &mut room.server);
|
||||
// The newcomer learns how it is shown, then where everyone is.
|
||||
let mut more = outs;
|
||||
let mut more = if packed { packed_welcome(outs) } else { outs };
|
||||
if let Some(me) = room.server.peers().find(|p| p.client_id == id) {
|
||||
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
|
||||
}
|
||||
@@ -747,6 +748,19 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A joining client's messages with the document in its welcome compressed (its hello announced
|
||||
/// that it reads that). Should compressing fail, the welcome goes as it was.
|
||||
fn packed_welcome(mut outs: Vec<Outgoing>) -> Vec<Outgoing> {
|
||||
for o in &mut outs {
|
||||
if let Outgoing::To(_, msg) = o
|
||||
&& let Err(e) = message::pack(msg)
|
||||
{
|
||||
log::warn!("event=pack_failed error={e}");
|
||||
}
|
||||
}
|
||||
outs
|
||||
}
|
||||
|
||||
/// Queue `b` for connection `conn`: false when it must be dropped (it can't keep up, or it is
|
||||
/// gone). When the server's budget for queued bytes is used up, the connection holding the most
|
||||
/// queued bytes is shed (added to `shed`) to make room, unless that is `conn` itself: whoever
|
||||
|
||||
@@ -201,7 +201,13 @@ pub(crate) enum Taken {
|
||||
/// Take in one message from the host: events for the UI go to `inbox`, and `resume` follows the
|
||||
/// session. On the welcome, `welcomed` runs before the UI hears of it: what the sync client sends
|
||||
/// in answer must go out on this connection.
|
||||
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
|
||||
pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
|
||||
// A compressed document is read here, within the size the welcome could have had unpacked.
|
||||
match message::unpack(&mut m, cfg.limits.max_server_message) {
|
||||
Ok(()) => {}
|
||||
Err(figcraft_collab::Error::TooLarge(_)) => return Taken::End(too_large(&cfg.limits)),
|
||||
Err(e) => return Taken::End(Outcome::Refused(format!("the host sent a document this version can't read ({e})"))),
|
||||
}
|
||||
match m {
|
||||
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
|
||||
if let Ok(mut r) = resume.lock() {
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
@@ -575,6 +575,23 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// The document in a welcome is compressed only for a client whose hello says it reads that; a
|
||||
/// client from before gets it as it always did (#1665).
|
||||
#[test]
|
||||
fn welcomes_are_packed_only_for_clients_that_read_them() {
|
||||
let (mut host, _hp, invite) = start();
|
||||
let addr: SocketAddr = invite.server.host.parse().unwrap();
|
||||
let mut old = Raw::connect(addr);
|
||||
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") };
|
||||
assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.12.1 signals hosting comments".into(), ..h })));
|
||||
let Some(ServerMsg::Welcome { catch_up: CatchUp::Snapshot(plain), .. }) = old.next() else { panic!("no plain welcome") };
|
||||
let mut new = Raw::hello(addr, TOKEN);
|
||||
let Some(mut welcome @ ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. }) = new.next() else { panic!("no packed welcome") };
|
||||
message::unpack(&mut welcome, 64 << 20).unwrap();
|
||||
assert!(matches!(welcome, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(s), .. } if s == plain), "the same document");
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signals_reach_only_clients_that_read_them_and_are_rate_limited() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
|
||||
@@ -17,6 +17,7 @@ chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -227,6 +227,8 @@ pub enum DocProp {
|
||||
Timer,
|
||||
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
|
||||
Vote,
|
||||
/// Ended votes' results kept in the file, oldest first (whole).
|
||||
VoteHistory,
|
||||
}
|
||||
|
||||
/// A document-level entry, by id.
|
||||
|
||||
@@ -560,6 +560,10 @@ impl Client {
|
||||
peers: Vec<Peer>,
|
||||
recompute: &dyn Recompute,
|
||||
) -> Result<Vec<ClientMsg>, Error> {
|
||||
// The transport unpacks a packed document first (`message::unpack`).
|
||||
if matches!(catch_up, CatchUp::Packed(_)) {
|
||||
return Err(Error::Protocol("expected the document unpacked".into()));
|
||||
}
|
||||
let last_batch = processed.last_batch;
|
||||
// An undo or redo lost in flight is made again before it goes out, knowing what others did
|
||||
// meanwhile: its step conflicts with what they wrote while it was in flight, and with what
|
||||
@@ -573,6 +577,7 @@ impl Client {
|
||||
self.presence.clear();
|
||||
let offline = || "it no longer applied when the connection came back".to_string();
|
||||
match catch_up {
|
||||
CatchUp::Packed(_) => {}
|
||||
CatchUp::Delta(batches) => {
|
||||
for acc in batches {
|
||||
if acc.author == self.id {
|
||||
|
||||
@@ -269,6 +269,7 @@ doc_scalars! {
|
||||
Kind => kind,
|
||||
Timer => timer,
|
||||
Vote => vote,
|
||||
VoteHistory => vote_history,
|
||||
}
|
||||
|
||||
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
|
||||
|
||||
@@ -65,6 +65,9 @@ pub enum Error {
|
||||
Encode(String),
|
||||
#[error("can't decode: {0}")]
|
||||
Decode(String),
|
||||
/// A packed document that would grow past this many bytes ([`message::unpack`]).
|
||||
#[error("the document is over the {0}-byte limit")]
|
||||
TooLarge(usize),
|
||||
/// The other side broke the protocol (a message out of order or out of place).
|
||||
#[error("protocol error: {0}")]
|
||||
Protocol(String),
|
||||
@@ -151,6 +154,8 @@ mod merge_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
#[cfg(test)]
|
||||
mod packed_welcome_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod rename_tests;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Messages between clients and the session server, and their two encodings: a compact binary form
|
||||
//! (a version byte, then postcard) and a JSON form for debugging and agents.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -27,9 +29,14 @@ pub const HOSTING: &str = "hosting";
|
||||
/// reads [`Access::Comment`]: older ones get a comment link as a view link.
|
||||
pub const COMMENTS: &str = "comments";
|
||||
|
||||
/// The word a client puts in its [`Hello::client_version`] when it reads [`CatchUp::Packed`]: a
|
||||
/// server compresses the document in a welcome only for clients that say so (older ones couldn't
|
||||
/// read it).
|
||||
pub const PACKED: &str = "packed";
|
||||
|
||||
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
|
||||
pub fn client_version() -> String {
|
||||
format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION"))
|
||||
format!("{} {SIGNALS} {HOSTING} {COMMENTS} {PACKED}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Whether `client_version` announces `word`.
|
||||
@@ -52,6 +59,11 @@ pub fn reads_comments(client_version: &str) -> bool {
|
||||
announces(client_version, COMMENTS)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`PACKED`].
|
||||
pub fn reads_packed(client_version: &str) -> bool {
|
||||
announces(client_version, PACKED)
|
||||
}
|
||||
|
||||
/// Join a file's session.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
@@ -317,6 +329,10 @@ pub enum CatchUp {
|
||||
Snapshot(Vec<Change>),
|
||||
/// The batches a reconnecting client missed, in order.
|
||||
Delta(Vec<Accepted>),
|
||||
/// A [`Snapshot`](CatchUp::Snapshot) compressed ([`pack`]), for a client whose hello announced
|
||||
/// [`PACKED`]: a document's changes shrink several times over. The client reads it with
|
||||
/// [`unpack`] before anything else sees the welcome.
|
||||
Packed(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Opens a connection that manages a server's files (instead of [`Hello`]): the server's admin
|
||||
@@ -926,6 +942,11 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T,
|
||||
if version != WIRE_VERSION {
|
||||
return Err(Error::Decode(format!("wire version {version}, expected {WIRE_VERSION}")));
|
||||
}
|
||||
from_postcard(body)
|
||||
}
|
||||
|
||||
/// One postcard value that fills `body` (no trailing bytes).
|
||||
fn from_postcard<T: DeserializeOwned>(body: &[u8]) -> Result<T, Error> {
|
||||
let (msg, rest) = postcard::take_from_bytes::<T>(body).map_err(|e| Error::Decode(e.to_string()))?;
|
||||
if !rest.is_empty() {
|
||||
return Err(Error::Decode(format!("{} bytes after the message", rest.len())));
|
||||
@@ -933,6 +954,43 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T,
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Compress the document in a welcome ([`CatchUp::Packed`]), for a client that announced
|
||||
/// [`PACKED`]. Anything else, a catch-up by the missed batches too, is left as it is. Deflate at
|
||||
/// its fastest level: the hub packs on its own thread, and a document's changes still shrink
|
||||
/// about eightfold.
|
||||
pub fn pack(msg: &mut ServerMsg) -> Result<(), Error> {
|
||||
if let ServerMsg::Welcome { catch_up, .. } = msg
|
||||
&& let CatchUp::Snapshot(changes) = catch_up
|
||||
{
|
||||
let raw = postcard::to_allocvec(changes).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
let mut z = flate2::write::DeflateEncoder::new(Vec::with_capacity(raw.len() / 4), flate2::Compression::fast());
|
||||
z.write_all(&raw).map_err(|e| Error::Encode(e.to_string()))?;
|
||||
*catch_up = CatchUp::Packed(z.finish().map_err(|e| Error::Encode(e.to_string()))?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undo [`pack`]: a welcome's packed document back as its changes, refused once it would grow
|
||||
/// past `max_bytes` (untrusted input: the limit an unpacked welcome would have had to fit).
|
||||
/// Anything else is left as it is.
|
||||
pub fn unpack(msg: &mut ServerMsg, max_bytes: usize) -> Result<(), Error> {
|
||||
if let ServerMsg::Welcome { catch_up, .. } = msg
|
||||
&& let CatchUp::Packed(packed) = catch_up
|
||||
{
|
||||
let mut raw = Vec::new();
|
||||
let limit = u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1);
|
||||
flate2::read::DeflateDecoder::new(packed.as_slice())
|
||||
.take(limit)
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|e| Error::Decode(format!("packed document: {e}")))?;
|
||||
if raw.len() > max_bytes {
|
||||
return Err(Error::TooLarge(max_bytes));
|
||||
}
|
||||
*catch_up = CatchUp::Snapshot(from_postcard(&raw)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The JSON form of `msg` (`{"<type>": {…}}`).
|
||||
pub fn encode_json<T: Serialize>(msg: &T) -> Result<String, Error> {
|
||||
serde_json::to_string(msg).map_err(|e| Error::Encode(e.to_string()))
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The document in a welcome goes compressed to clients that read that ([`message::PACKED`],
|
||||
//! #1665): it shrinks several times over, comes back unchanged, and a hostile packed document
|
||||
//! can't make the client allocate past the size an unpacked welcome could have had.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use crate::message::{self, CatchUp, ClientMsg, ServerMsg};
|
||||
use crate::{Client, Error, Limits, NoRecompute, Outgoing, Replica, Server};
|
||||
|
||||
/// A design file of 40 screens with 100 cards each, as a server's first welcome.
|
||||
fn welcome() -> ServerMsg {
|
||||
let mut doc = Document::new("Synthetic");
|
||||
let page = doc.first_page().unwrap();
|
||||
for f in 0..40 {
|
||||
let screen =
|
||||
doc.create(page, NodeKind::Frame, &format!("Screen {f}"), f64::from(f % 8) * 1500.0, f64::from(f / 8) * 1000.0, 1440.0, 900.0).unwrap();
|
||||
for i in 0..100 {
|
||||
let (x, y) = (f64::from(i % 10) * 140.0, f64::from(i / 10) * 88.0);
|
||||
doc.create(screen, NodeKind::Rectangle, &format!("Card {i} of screen {f}"), x, y, 128.0, 80.0).unwrap();
|
||||
}
|
||||
}
|
||||
doc.repair();
|
||||
let mut s = Server::new(doc, "tok", Limits::default());
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
let (_, out) = s.join(h).unwrap();
|
||||
out.into_iter().find_map(|o| if let Outgoing::To(_, m @ ServerMsg::Welcome { .. }) = o { Some(m) } else { None }).unwrap()
|
||||
}
|
||||
|
||||
fn with_catch_up(mut msg: ServerMsg, c: CatchUp) -> ServerMsg {
|
||||
if let ServerMsg::Welcome { catch_up, .. } = &mut msg {
|
||||
*catch_up = c;
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
fn deflated(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
z.write_all(bytes).unwrap();
|
||||
z.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_packed_welcome_is_much_smaller_and_reads_back_the_same() {
|
||||
let plain = welcome();
|
||||
let mut packed = plain.clone();
|
||||
message::pack(&mut packed).unwrap();
|
||||
assert!(matches!(packed, ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. }));
|
||||
let (before, after) = (message::encode(&plain).unwrap().len(), message::encode(&packed).unwrap().len());
|
||||
println!("welcome of 4,040 layers: {before} bytes, packed {after} bytes");
|
||||
assert!(after * 5 < before, "{before} → {after}");
|
||||
let mut back: ServerMsg = message::decode(&message::encode(&packed).unwrap(), after).unwrap();
|
||||
message::unpack(&mut back, before).unwrap();
|
||||
assert_eq!(back, plain);
|
||||
// The client opens the same document from it.
|
||||
let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = &plain else { panic!("not a snapshot") };
|
||||
assert_eq!(Client::join(back).unwrap().document(), Replica::from_snapshot(changes).unwrap().document());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_welcomes_whole_document_is_packed() {
|
||||
for msg in [with_catch_up(welcome(), CatchUp::Delta(Vec::new())), ServerMsg::Pong, ServerMsg::PeerLeft { client_id: 3 }] {
|
||||
let mut m = msg.clone();
|
||||
message::pack(&mut m).unwrap();
|
||||
assert_eq!(m, msg, "left as it is");
|
||||
message::unpack(&mut m, 0).unwrap();
|
||||
assert_eq!(m, msg, "nothing to unpack");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_documents_are_untrusted() {
|
||||
let mut packed = welcome();
|
||||
message::pack(&mut packed).unwrap();
|
||||
let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = welcome() else { panic!("not a snapshot") };
|
||||
let size = postcard::to_allocvec(&changes).unwrap().len();
|
||||
// Unpacked, it may be no larger than an unpacked welcome could have been.
|
||||
assert!(matches!(message::unpack(&mut packed.clone(), size - 1), Err(Error::TooLarge(n)) if n == size - 1));
|
||||
message::unpack(&mut packed, size).unwrap();
|
||||
// A bomb stops at the limit instead of filling memory.
|
||||
let mut bomb = with_catch_up(welcome(), CatchUp::Packed(deflated(&vec![0; 64 << 20])));
|
||||
assert!(matches!(message::unpack(&mut bomb, 1 << 20), Err(Error::TooLarge(_))));
|
||||
// Not deflate, not changes, or changes with bytes after them.
|
||||
let mut good = postcard::to_allocvec(&changes).unwrap();
|
||||
for bytes in [
|
||||
vec![0xFF; 64],
|
||||
deflated(b"not a document"),
|
||||
deflated(&{
|
||||
good.push(0);
|
||||
good
|
||||
}),
|
||||
] {
|
||||
let mut m = with_catch_up(welcome(), CatchUp::Packed(bytes));
|
||||
assert!(matches!(message::unpack(&mut m, usize::MAX), Err(Error::Decode(_))), "{m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_announce_that_they_read_packed_welcomes() {
|
||||
assert!(message::reads_packed(&message::client_version()));
|
||||
assert!(!message::reads_packed("0.12.1 signals hosting comments"), "a client from before");
|
||||
}
|
||||
|
||||
/// The sync client reads only what the transport unpacked: a packed document reaching it is a
|
||||
/// mistake it refuses, on the first welcome and on one after a dropped connection.
|
||||
#[test]
|
||||
fn the_sync_client_wants_the_document_unpacked() {
|
||||
let packed = with_catch_up(welcome(), CatchUp::Packed(Vec::new()));
|
||||
assert!(Client::join(packed.clone()).is_err());
|
||||
let mut c = Client::join(welcome()).unwrap();
|
||||
c.disconnect();
|
||||
assert!(matches!(c.receive(packed, &NoRecompute), Err(Error::Protocol(_))));
|
||||
}
|
||||
@@ -90,6 +90,25 @@ pub struct Vote {
|
||||
/// Every voter's ballot: the objects they put their votes on (once per vote).
|
||||
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
|
||||
|
||||
/// The most ended votes a board keeps the results of (the oldest goes first).
|
||||
pub const VOTE_HISTORY_MAX: usize = 20;
|
||||
|
||||
/// An ended vote's results, kept in the file: the vote, everyone's ballots and the names the
|
||||
/// voters went by when it ended (a live session's client ids mean nothing once it's over).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VoteRecord {
|
||||
pub vote: Vote,
|
||||
#[serde(default)]
|
||||
pub ballots: Ballots,
|
||||
/// Voter key → name, for the keys that aren't names themselves.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub names: BTreeMap<String, String>,
|
||||
/// When it ended (milliseconds since the Unix epoch).
|
||||
#[serde(default)]
|
||||
pub ended: u64,
|
||||
}
|
||||
|
||||
impl Vote {
|
||||
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
|
||||
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
|
||||
|
||||
@@ -245,6 +245,9 @@ pub struct Document {
|
||||
/// The vote's ballots, by voter.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub votes: Ballots,
|
||||
/// Ended votes' results kept in the file, oldest first (at most [`VOTE_HISTORY_MAX`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub vote_history: Vec<VoteRecord>,
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -287,6 +290,7 @@ impl Document {
|
||||
timer: None,
|
||||
vote: None,
|
||||
votes: BTreeMap::new(),
|
||||
vote_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,12 @@ impl Session {
|
||||
Some(Ok(lib)) => Some(lib.manifest.version),
|
||||
_ => None,
|
||||
};
|
||||
// Confined to automation roots, the server isn't asked (#1095). That is no offline
|
||||
// failure: the last version fetched doesn't stand in for an answer, so `library.updates`
|
||||
// and `library.list` say the check was refused instead of reporting no updates.
|
||||
if let Some(why) = crate::file_access::network_refused() {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
let usage = self.doc().ok().map(|d| {
|
||||
let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default();
|
||||
usage(d, &id)
|
||||
@@ -213,7 +219,11 @@ impl Session {
|
||||
std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report)
|
||||
})
|
||||
.collect();
|
||||
if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) {
|
||||
// Confined to automation roots, nothing reaches a server (#1095).
|
||||
if jobs.is_empty() || crate::file_access::network_refused().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(net) = self.live.link() {
|
||||
net.library_background(jobs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +218,18 @@ pub fn current() -> Option<Arc<Roots>> {
|
||||
ROOTS.with(|r| r.borrow().clone())
|
||||
}
|
||||
|
||||
/// Why this thread's work may not reach the network now, if it may not (#1095): the automation
|
||||
/// roots keep an agent to the folders it was given, so while they confine it nothing is published,
|
||||
/// hosted, joined or fetched from a server either. The engine reaches the network only through the
|
||||
/// live link (`crate::live::Lives`), which asks this first.
|
||||
pub fn network_refused() -> Option<String> {
|
||||
current().map(|_| NETWORK_REFUSED.to_string())
|
||||
}
|
||||
|
||||
/// What a confined command that would reach the network says.
|
||||
pub const NETWORK_REFUSED: &str =
|
||||
"automation roots are set: commands don't reach the network (publishing, live sessions, team libraries and plugin directories are off)";
|
||||
|
||||
/// The path to use for `access` to `path`: resolved when confined, else as given.
|
||||
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -334,6 +334,10 @@ impl Session {
|
||||
let sel = ds.selection.clone();
|
||||
if self.tool.pick_only {
|
||||
self.tool.picked = Some(c);
|
||||
} else if ev.cmd && ev.shift {
|
||||
// Cmd/Ctrl+Shift: make a colour style or variable of it instead (the UI asks which
|
||||
// and what to call it; nothing is applied).
|
||||
self.request_ui("dialog.createFromColor", json!({"color": c.to_css_hex()}));
|
||||
} else if !sel.is_empty() {
|
||||
self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,16 @@
|
||||
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
|
||||
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
|
||||
use figcraft_doc::{Ballots, BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTE_HISTORY_MAX, VOTES_MAX, Vote, VoteRecord, VoteScope};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board::{require, text_of};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{id, num, req_string, req_targets, string};
|
||||
use crate::params::{id, num, opt_index, req_string, req_targets, string};
|
||||
use crate::tools::PointerEvent;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -47,19 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.vote",
|
||||
"Voting session",
|
||||
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
|
||||
"{action: start|cast|retract|end|clear|delete, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), index (delete: which kept results, 0 the oldest), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends, and its results are kept in the file (the last 20 votes'); clear takes the results off the board, delete removes kept ones",
|
||||
false,
|
||||
vote_cmd
|
||||
),
|
||||
spec!(
|
||||
"board.voteResults",
|
||||
"Voting results",
|
||||
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
|
||||
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank, voters: [{voter, name (null: someone who left the live session), votes}]}] (once ended, most votes first, with who voted for each), sessions [{index (in the kept results, 0 the oldest; null for one not kept), onBoard, ended (ms since 1970), votesPerPerson, on, by, results}] (every ended vote's results, newest first)}",
|
||||
false,
|
||||
|s, p| {
|
||||
require(s, "board.voteResults")?;
|
||||
let who = voter(s, p);
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
vote_json(s, &who)
|
||||
}
|
||||
),
|
||||
]
|
||||
@@ -176,12 +177,7 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match action.as_str() {
|
||||
"start" => start_vote(s, p, CMD)?,
|
||||
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
|
||||
"end" => {
|
||||
running(CMD, s.doc()?)?;
|
||||
if let Some(v) = s.doc_mut()?.vote.as_mut() {
|
||||
v.ended = true;
|
||||
}
|
||||
}
|
||||
"end" => end_vote(s, CMD)?,
|
||||
"clear" => {
|
||||
let doc = s.doc()?;
|
||||
if doc.vote.is_some() || !doc.votes.is_empty() {
|
||||
@@ -190,9 +186,50 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.votes.clear();
|
||||
}
|
||||
}
|
||||
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
|
||||
"delete" => delete_results(s, p, CMD)?,
|
||||
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear|delete)"))),
|
||||
}
|
||||
Ok(vote_json(s.doc()?, &who))
|
||||
vote_json(s, &who)
|
||||
}
|
||||
|
||||
/// `board.vote {action: end}`: everyone's votes show, and the results are kept in the file with
|
||||
/// the names the voters go by now (the last [`VOTE_HISTORY_MAX`] votes').
|
||||
fn end_vote(s: &mut Session, cmd: &str) -> Result<()> {
|
||||
running(cmd, s.doc()?)?;
|
||||
let names = s.doc()?.votes.keys().filter_map(|k| voter_name(s, k).filter(|n| n != k).map(|n| (k.clone(), n))).collect();
|
||||
let ended = now_ms();
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(v) = doc.vote.as_mut() {
|
||||
v.ended = true;
|
||||
doc.vote_history.push(VoteRecord { vote: v.clone(), ballots: doc.votes.clone(), names, ended });
|
||||
let extra = doc.vote_history.len().saturating_sub(VOTE_HISTORY_MAX);
|
||||
doc.vote_history.drain(..extra);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `board.vote {action: delete, index}`: kept results deleted from the file (`index` 0 the
|
||||
/// oldest, as `board.voteResults` lists them); those on the board now are cleared from it too.
|
||||
fn delete_results(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
|
||||
let doc = s.doc()?;
|
||||
if doc.vote_history.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no results are kept".into()));
|
||||
}
|
||||
let last = doc.vote_history.len() - 1;
|
||||
let index = opt_index(cmd, p, "index")?.filter(|i| *i <= last).ok_or_else(|| bad(cmd, format!("`index` is from 0 to {last}")))?;
|
||||
let on_board = doc.vote_history.get(index).is_some_and(|r| on_board(doc, r));
|
||||
let doc = s.doc_mut()?;
|
||||
doc.vote_history.remove(index);
|
||||
if on_board {
|
||||
doc.vote = None;
|
||||
doc.votes.clear();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether kept results `r` are the ended vote on the board now.
|
||||
fn on_board(doc: &Document, r: &VoteRecord) -> bool {
|
||||
doc.vote.as_ref().is_some_and(|v| v.ended && *v == r.vote) && doc.votes == r.ballots
|
||||
}
|
||||
|
||||
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
|
||||
@@ -280,13 +317,119 @@ pub fn object_name(doc: &Document, id: NodeId) -> String {
|
||||
if short.len() < name.len() { format!("{short}…") } else { short }
|
||||
}
|
||||
|
||||
fn vote_json(doc: &Document, who: &str) -> Value {
|
||||
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
|
||||
let on = match &vote.scope {
|
||||
/// Who put votes on `id` in `ballots`, and how many each, by voter (as the ballots key them).
|
||||
pub fn voters_of(doc: &Document, vote: &Vote, ballots: &Ballots, id: NodeId) -> Vec<(String, u32)> {
|
||||
ballots
|
||||
.iter()
|
||||
.filter_map(|(who, ballot)| {
|
||||
let n = vote.counted(doc, ballot).filter(|v| *v == id).count();
|
||||
(n > 0).then(|| (who.clone(), u32::try_from(n).unwrap_or(u32::MAX)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One object in an ended vote's results.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ResultRow {
|
||||
/// Ties share a rank ("1, 2, 2, 4").
|
||||
pub rank: usize,
|
||||
pub id: NodeId,
|
||||
/// The sticky's text or the layer's name ([`object_name`]).
|
||||
pub name: String,
|
||||
pub votes: u32,
|
||||
/// Who voted for it: their key, the name they go by (`None`: someone who left a live session
|
||||
/// before the vote ended) and their votes on it.
|
||||
pub voters: Vec<(String, Option<String>, u32)>,
|
||||
}
|
||||
|
||||
/// An ended vote's results: kept in the file (`index` in [`Document::vote_history`]), on the
|
||||
/// board now (`on_board`: its badges show), or both.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VoteSession {
|
||||
pub index: Option<usize>,
|
||||
pub on_board: bool,
|
||||
/// When it ended (ms since 1970; 0 when not known).
|
||||
pub ended: u64,
|
||||
pub vote: Vote,
|
||||
pub rows: Vec<ResultRow>,
|
||||
}
|
||||
|
||||
/// `ballots`' results under `vote`, most votes first; voters named from `names` (as they were
|
||||
/// when it ended), else as the session knows them now ([`voter_name`]).
|
||||
fn result_rows(s: &Session, doc: &Document, vote: &Vote, ballots: &Ballots, names: &BTreeMap<String, String>) -> Vec<ResultRow> {
|
||||
let name = |key: &str| names.get(key).cloned().or_else(|| voter_name(s, key));
|
||||
ranked(doc, vote, ballots)
|
||||
.into_iter()
|
||||
.map(|(rank, id, votes)| {
|
||||
let voters = voters_of(doc, vote, ballots, id).into_iter().map(|(key, n)| (key.clone(), name(&key), n)).collect();
|
||||
ResultRow { rank, id, name: object_name(doc, id), votes, voters }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every ended vote's results, newest first: those the file keeps, and the ended vote on the
|
||||
/// board when the file doesn't keep it (one that ended before results were kept).
|
||||
pub fn sessions(s: &Session) -> Result<Vec<VoteSession>> {
|
||||
let doc = s.doc()?;
|
||||
let mut out: Vec<VoteSession> = doc
|
||||
.vote_history
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| VoteSession {
|
||||
index: Some(i),
|
||||
on_board: on_board(doc, r),
|
||||
ended: r.ended,
|
||||
vote: r.vote.clone(),
|
||||
rows: result_rows(s, doc, &r.vote, &r.ballots, &r.names),
|
||||
})
|
||||
.collect();
|
||||
if let Some(vote) = doc.vote.as_ref().filter(|v| v.ended)
|
||||
&& !out.iter().any(|x| x.on_board)
|
||||
{
|
||||
let rows = result_rows(s, doc, vote, &doc.votes, &BTreeMap::new());
|
||||
out.push(VoteSession { index: None, on_board: true, ended: 0, vote: vote.clone(), rows });
|
||||
}
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The name voter `key` goes by: in a live session a client id's ("peer 3") is the person's
|
||||
/// name there (yours or another's); `None` for one who isn't in the session any more. Any other
|
||||
/// key is a name already (yours offline, or a `voter` given to `board.vote`).
|
||||
pub fn voter_name(s: &Session, key: &str) -> Option<String> {
|
||||
let Some(client) = key.strip_prefix("peer ").and_then(|n| n.parse::<u32>().ok()) else { return Some(key.to_string()) };
|
||||
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).map(|ds| s.live_of(ds.uid))?;
|
||||
match &live.info {
|
||||
Some(info) if info.me == client => Some(info.name.clone()),
|
||||
_ => live.peers.get(&client).map(|p| p.name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_json(scope: &VoteScope) -> Value {
|
||||
match scope {
|
||||
VoteScope::All => json!("all"),
|
||||
VoteScope::Stickies => json!("stickies"),
|
||||
VoteScope::Only { ids } => json!({"selection": ids}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_json(rows: &[ResultRow]) -> Value {
|
||||
let voters = |r: &ResultRow| r.voters.iter().map(|(key, name, n)| json!({"voter": key, "name": name, "votes": n})).collect::<Vec<_>>();
|
||||
json!(rows.iter().map(|r| json!({"id": r.id, "name": r.name, "votes": r.votes, "rank": r.rank, "voters": voters(r)})).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn vote_json(s: &Session, who: &str) -> Result<Value> {
|
||||
let sessions = sessions(s)?;
|
||||
let kept: Vec<Value> = sessions
|
||||
.iter()
|
||||
.map(|x| {
|
||||
json!({"index": x.index, "onBoard": x.on_board, "ended": x.ended, "votesPerPerson": x.vote.per_person,
|
||||
"on": scope_json(&x.vote.scope), "by": x.vote.by, "results": rows_json(&x.rows)})
|
||||
})
|
||||
.collect();
|
||||
let doc = s.doc()?;
|
||||
let Some(vote) = &doc.vote else { return Ok(json!({"state": "off", "sessions": kept})) };
|
||||
let on = scope_json(&vote.scope);
|
||||
let mine = mine(doc, who);
|
||||
let used: u32 = mine.iter().map(|m| m.1).sum();
|
||||
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
|
||||
@@ -298,20 +441,21 @@ fn vote_json(doc: &Document, who: &str) -> Value {
|
||||
"left": vote.per_person.saturating_sub(used),
|
||||
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
|
||||
"voters": voters,
|
||||
"sessions": kept,
|
||||
});
|
||||
if vote.ended {
|
||||
let results: Vec<Value> =
|
||||
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
|
||||
out["results"] = json!(results);
|
||||
// Who voted for what shows once the vote ends; until then each person sees only their own.
|
||||
if let Some(on_board) = sessions.iter().find(|x| x.on_board).filter(|_| vote.ended) {
|
||||
out["results"] = rows_json(&on_board.rows);
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
|
||||
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
|
||||
/// The objects with votes in `ballots`, most first, each with its rank (ties share one: "1, 2,
|
||||
/// 2, 4").
|
||||
pub fn ranked(doc: &Document, vote: &Vote, ballots: &Ballots) -> Vec<(usize, NodeId, u32)> {
|
||||
let mut last = None;
|
||||
let mut rank = 0;
|
||||
vote.tally(doc, &doc.votes)
|
||||
vote.tally(doc, ballots)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, n))| {
|
||||
@@ -361,13 +505,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
|
||||
/// keep them as they are now (`from`).
|
||||
/// Undo and redo bring back a document as it was, but the timer, the vote and the kept results
|
||||
/// aren't undoable: keep them as they are now (`from`).
|
||||
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
|
||||
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
|
||||
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes || from.vote_history != to.vote_history {
|
||||
let d = Arc::make_mut(to);
|
||||
d.timer = from.timer;
|
||||
d.vote = from.vote.clone();
|
||||
d.votes = from.votes.clone();
|
||||
d.vote_history = from.vote_history.clone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +594,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => doc.libraries.enabled.iter().filter(|l| is_server_link(&l.path)).map(|l| l.path.clone()).collect(),
|
||||
};
|
||||
// Confined to automation roots, nothing is checked (#1095): say so, and don't remember the
|
||||
// libraries as yours either.
|
||||
if let (false, Some(why)) = (links.is_empty(), crate::file_access::network_refused()) {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), why));
|
||||
}
|
||||
for l in &links {
|
||||
s.allow_library(l);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of what it picks (as
|
||||
//! the reference app's eyedropper does): it asks the UI which (`dialog.createFromColor`) and leaves the
|
||||
//! selection's fills alone; a plain pick still fills the selection.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
|
||||
/// A white page with a red square at (0, 0) and a green one selected at (200, 0).
|
||||
fn scene() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
|
||||
let rect = |s: &mut Session, x: f64, fill: &str| {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap();
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
s.execute("node.set", &json!({"id": id, "props": {"fills": [fill]}})).unwrap();
|
||||
id
|
||||
};
|
||||
rect(&mut s, 0.0, "#FF0000");
|
||||
let green = rect(&mut s, 200.0, "#00FF00");
|
||||
s.execute("select.set", &json!({"ids": [green]})).unwrap();
|
||||
s.ui_requests.clear();
|
||||
(s, green)
|
||||
}
|
||||
|
||||
fn pick(s: &mut Session, cmd: bool, shift: bool) {
|
||||
s.execute("edit.pickColor", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent { cmd, shift, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap();
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> Option<String> {
|
||||
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_shift_asks_to_make_a_style_or_variable_of_the_pick() {
|
||||
let (mut s, green) = scene();
|
||||
let undo = s.undo_depth().0;
|
||||
pick(&mut s, true, true);
|
||||
let asks: Vec<_> = s.ui_requests.iter().filter(|r| r.kind == "dialog.createFromColor").collect();
|
||||
assert_eq!(asks.len(), 1, "{:?}", s.ui_requests);
|
||||
assert_eq!(asks[0].data["color"], "#FF0000", "the colour under the pointer");
|
||||
assert_eq!(fill(&s, green), Some("00FF00".into()), "the selection keeps its fill");
|
||||
assert_eq!(s.undo_depth().0, undo, "nothing to undo");
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
|
||||
assert_eq!(s.recent_colors.first().map(|c| c.to_hex()), Some("FF0000".into()), "a recent colour as any pick");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_picks_fill_the_selection_as_before() {
|
||||
for (cmd, shift) in [(false, false), (true, false), (false, true)] {
|
||||
let (mut s, green) = scene();
|
||||
pick(&mut s, cmd, shift);
|
||||
assert!(!s.ui_requests.iter().any(|r| r.kind == "dialog.createFromColor"), "cmd {cmd} shift {shift}");
|
||||
assert_eq!(fill(&s, green), Some("FF0000".into()), "cmd {cmd} shift {shift}");
|
||||
}
|
||||
// The colour picker's eyedropper only samples, whatever is held.
|
||||
let (mut s, green) = scene();
|
||||
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
|
||||
s.pointer(&PointerEvent { cmd: true, shift: true, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap();
|
||||
assert!(s.ui_requests.is_empty());
|
||||
assert_eq!(s.tool.picked.map(|c| c.to_hex()), Some("FF0000".into()));
|
||||
assert_eq!(fill(&s, green), Some("00FF00".into()));
|
||||
}
|
||||
@@ -132,12 +132,14 @@ fn votes_are_capped_hidden_until_the_end_and_ranked() {
|
||||
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended");
|
||||
let r = ex(&mut s, "board.voteResults", json!({}));
|
||||
assert_eq!(r["state"], "ended");
|
||||
// Once it ends, everyone sees who voted for what.
|
||||
let voter = |name: &str, votes: u32| json!({"voter": name, "name": name, "votes": votes});
|
||||
assert_eq!(
|
||||
r["results"],
|
||||
json!([
|
||||
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1},
|
||||
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2},
|
||||
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3},
|
||||
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1, "voters": [voter("Ada", 2), voter("Cy", 1)]},
|
||||
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2, "voters": [voter("Bo", 1), voter("Cy", 1)]},
|
||||
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3, "voters": [voter("Bo", 1)]},
|
||||
])
|
||||
);
|
||||
// A deleted object's votes no longer count.
|
||||
@@ -238,10 +240,26 @@ fn everyone_in_a_live_session_shares_the_timer_and_the_vote() {
|
||||
room.check();
|
||||
assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same");
|
||||
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}]));
|
||||
ex(room.s(1), "board.vote", json!({"action": "end"}));
|
||||
// Who voted shows by the names people go by in the session, kept with the results when it
|
||||
// ends (this room delivers no join messages, so peer 2 is added as one would add them).
|
||||
let me = room.s(0).live_mut().and_then(|l| l.info.as_ref()).map(|i| i.name.clone()).expect("in the session");
|
||||
let peer2 = figcraft_collab::Peer { client_id: 2, name: "Grace".into(), color: "#00AA88".into() };
|
||||
room.s(0).live_mut().expect("in the session").peers.insert(2, peer2);
|
||||
ex(room.s(0), "board.vote", json!({"action": "end"}));
|
||||
room.settle();
|
||||
let r = ex(room.s(0), "board.voteResults", json!({}));
|
||||
assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}]));
|
||||
room.check();
|
||||
let grace = json!({"voter": "peer 2", "name": "Grace", "votes": 1});
|
||||
let want = json!([
|
||||
{"id": a.0, "name": "A", "votes": 2, "rank": 1, "voters": [{"voter": "peer 1", "name": me, "votes": 1}, grace]},
|
||||
{"id": b.0, "name": "B", "votes": 1, "rank": 2, "voters": [grace]},
|
||||
]);
|
||||
for i in 0..2 {
|
||||
assert_eq!(ex(room.s(i), "board.voteResults", json!({}))["results"], want, "everyone sees them ({i})");
|
||||
}
|
||||
// Someone who has left keeps the name the results were kept with; their votes still count.
|
||||
room.s(0).live_mut().expect("in the session").peers.remove(&2);
|
||||
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["results"], want);
|
||||
assert_eq!(room.server.document().vote_history.len(), 1, "the results are kept for everyone");
|
||||
// Undo in a session takes back your own edits only, not the vote.
|
||||
room.s(0).undo().unwrap();
|
||||
room.settle();
|
||||
@@ -292,6 +310,11 @@ fn facilitation_commands_survive_junk() {
|
||||
json!({"action": "end"}),
|
||||
json!({"action": "clear", "ids": 7}),
|
||||
json!({"action": "resume", "minutes": [1]}),
|
||||
json!({"action": "delete"}),
|
||||
json!({"action": "delete", "index": -1}),
|
||||
json!({"action": "delete", "index": 1e308}),
|
||||
json!({"action": "delete", "index": "0"}),
|
||||
json!({"action": "delete", "index": 0}),
|
||||
];
|
||||
for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] {
|
||||
for j in &junk {
|
||||
|
||||
@@ -309,6 +309,8 @@ mod export_depth_tests;
|
||||
#[cfg(test)]
|
||||
mod extended_chain_tests;
|
||||
#[cfg(test)]
|
||||
mod eyedropper_create_tests;
|
||||
#[cfg(test)]
|
||||
mod facilitate_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_export_assets_tests;
|
||||
@@ -607,6 +609,8 @@ mod version_tests;
|
||||
#[cfg(test)]
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vote_history_tests;
|
||||
#[cfg(test)]
|
||||
mod vue_codegen_tests;
|
||||
#[cfg(test)]
|
||||
mod widget_hover_tests;
|
||||
|
||||
@@ -115,3 +115,27 @@ fn checking_one_team_library_by_name() {
|
||||
assert_eq!(sent(&wire), vec![LINK.to_string()]);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// Confined to automation roots (#1095), checking says it was refused instead of counting
|
||||
/// libraries it never asked about, and doesn't remember them as yours: unconfined later, they are
|
||||
/// still not asked by themselves.
|
||||
#[test]
|
||||
fn a_confined_check_is_refused_before_the_libraries_are_remembered() {
|
||||
let (mut s, wire, path) = opened(Prefs::default(), "confined");
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-beacon-roots-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
|
||||
crate::file_access::confined(Some(&roots), || {
|
||||
for p in [json!({}), json!({"library": "Kit"})] {
|
||||
let e = s.execute("library.check", &p).unwrap_err();
|
||||
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e}");
|
||||
assert!(e.to_string().contains(crate::file_access::NETWORK_REFUSED), "{e}");
|
||||
}
|
||||
});
|
||||
assert!(sent(&wire).is_empty(), "nothing was asked: {:?}", sent(&wire));
|
||||
assert!(!s.library_allowed(LINK) && !s.live.unsaved, "not remembered");
|
||||
s.refresh_server_libraries();
|
||||
assert!(sent(&wire).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ impl Session {
|
||||
/// answered when it was an earlier one (what it carried then).
|
||||
pub(crate) fn net(&mut self, req: NetRequest) -> Result<(NetReply, Option<NetRequest>)> {
|
||||
let Some(link) = self.live.link() else { return Err(EngineError::Other(req.unavailable().into())) };
|
||||
if let Some(why) = crate::file_access::network_refused() {
|
||||
return Err(EngineError::Other(why));
|
||||
}
|
||||
if let Some(run) = self.background.run.as_mut() {
|
||||
let earlier = run.answers.iter().zip(run.used.iter_mut()).find(|((sent, _), used)| !**used && sent.stands_for(&req));
|
||||
if let Some(((sent, reply), used)) = earlier {
|
||||
|
||||
@@ -270,3 +270,65 @@ fn a_link_without_background_requests_is_waited_on() {
|
||||
let mut bare = Session::new();
|
||||
assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app"));
|
||||
}
|
||||
|
||||
/// Confined to automation roots, commands reach no server (#1095): browsing a plugin directory,
|
||||
/// hosting, joining and publishing are refused before the link is asked. Unconfined, the same
|
||||
/// request reaches the server as before.
|
||||
#[test]
|
||||
fn commands_confined_to_automation_roots_reach_no_server() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-roots-network-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
|
||||
let (mut s, wire) = session(false);
|
||||
let refused = |r: Result<serde_json::Value, EngineError>| {
|
||||
let e = r.unwrap_err().to_string();
|
||||
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{e}");
|
||||
};
|
||||
crate::file_access::confined(Some(&roots), || {
|
||||
refused(s.execute("plugin.directory.list", &json!({"server": SERVER})));
|
||||
refused(s.execute("collab.host", &json!({})));
|
||||
refused(s.execute("collab.join", &json!({"link": LINK})));
|
||||
refused(s.execute("collab.publish", &json!({"server": SERVER})));
|
||||
});
|
||||
assert_eq!(wire.lock().unwrap().waited, 0, "the server was never asked");
|
||||
assert!(s.live.iter().next().is_none(), "no session was started");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap();
|
||||
assert_eq!(r["plugins"][0]["id"], ID);
|
||||
assert_eq!(wire.lock().unwrap().waited, 1);
|
||||
let e = s.execute("collab.host", &json!({})).unwrap_err().to_string();
|
||||
assert!(!e.contains(crate::file_access::NETWORK_REFUSED), "unconfined, the link is asked: {e}");
|
||||
}
|
||||
|
||||
/// A team library fetched before stays cached, but confined to automation roots (#1095) checking
|
||||
/// it for updates is refused, and `library.updates` and `library.list` say so instead of
|
||||
/// answering from the cache as if the server had nothing newer.
|
||||
#[test]
|
||||
fn a_confined_update_check_reports_the_refusal_not_the_cached_library() {
|
||||
let (mut s, wire) = session(false);
|
||||
wire.lock().unwrap().library = Some(published());
|
||||
s.execute("library.add", &json!({"path": LINK})).unwrap();
|
||||
assert!(matches!(s.libraries.cached(LINK), Some(Ok(_))));
|
||||
let waited = wire.lock().unwrap().waited;
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-roots-updates-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
|
||||
crate::file_access::confined(Some(&roots), || {
|
||||
let u = s.execute("library.updates", &json!({})).unwrap();
|
||||
assert_eq!(u["count"], 0);
|
||||
let e = u["errors"][0]["error"].as_str().unwrap();
|
||||
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{u}");
|
||||
let l = s.execute("library.list", &json!({})).unwrap();
|
||||
let e = l["libraries"][0]["error"].as_str().unwrap();
|
||||
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{l}");
|
||||
});
|
||||
assert_eq!(wire.lock().unwrap().waited, waited, "the server was never asked");
|
||||
assert!(matches!(s.libraries.cached(LINK), Some(Ok(_))), "the cached library is kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
// Unconfined, the same check reaches the server and finds nothing new.
|
||||
let u = s.execute("library.updates", &json!({})).unwrap();
|
||||
assert_eq!((u["count"].clone(), u["errors"].as_array().map(Vec::len)), (json!(0), Some(0)), "{u}");
|
||||
assert_eq!(wire.lock().unwrap().waited, waited + 1);
|
||||
}
|
||||
|
||||
@@ -274,6 +274,9 @@ impl Lives {
|
||||
/// session's id, which comes back with what it returned.
|
||||
fn start<T>(&mut self, cmd: &str, start: impl FnOnce(&mut dyn LiveLink, SessionId) -> Result<T>) -> Result<(SessionId, T)> {
|
||||
let link = self.link.as_deref_mut().ok_or_else(|| unavailable(cmd))?;
|
||||
if let Some(why) = crate::file_access::network_refused() {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), why));
|
||||
}
|
||||
let id = self.last.saturating_add(1);
|
||||
let out = start(link, id)?;
|
||||
self.last = id;
|
||||
|
||||
@@ -99,7 +99,7 @@ impl Session {
|
||||
pub(crate) fn team_refresh(&mut self) -> Result<()> {
|
||||
let server = plain_server(&self.prefs.publish_server);
|
||||
let public = option_env!("FIGCRAFT_DEFAULT_SERVER").is_none() && same_server(&server, DEFAULT_SERVER);
|
||||
if server.is_empty() || public || self.live.link().is_none() {
|
||||
if server.is_empty() || public || self.live.link().is_none() || crate::file_access::network_refused().is_some() {
|
||||
self.live.team = None;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Earlier votes' results kept in the board file (the reference app keeps each voting session's
|
||||
//! results until they're deleted): recorded when a vote ends, at most [`VOTE_HISTORY_MAX`], listed
|
||||
//! newest first, deleted one at a time, saved, carried over undo, and opened from older files.
|
||||
|
||||
use figcraft_doc::{NodeId, VOTE_HISTORY_MAX, Vote, VoteRecord, VoteScope};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::board_tests::{board, id};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId {
|
||||
id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id")
|
||||
}
|
||||
|
||||
/// A vote of `votes` (voter, object) pairs, ended.
|
||||
fn vote(s: &mut Session, votes: &[(&str, NodeId)]) {
|
||||
ex(s, "board.vote", json!({"action": "start", "votes": 3}));
|
||||
for (who, on) in votes {
|
||||
ex(s, "board.vote", json!({"action": "cast", "id": on, "voter": who}));
|
||||
}
|
||||
ex(s, "board.vote", json!({"action": "end"}));
|
||||
}
|
||||
|
||||
/// A listed session: its index, whether it's on the board, and its objects' votes (id, votes).
|
||||
type Listed = (Value, bool, Vec<(u64, u64)>);
|
||||
|
||||
fn sessions(s: &mut Session) -> Vec<Listed> {
|
||||
let r = ex(s, "board.voteResults", json!({}));
|
||||
r["sessions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|x| {
|
||||
let rows = x["results"].as_array().unwrap().iter().map(|r| (r["id"].as_u64().unwrap(), r["votes"].as_u64().unwrap())).collect();
|
||||
(x["index"].clone(), x["onBoard"].as_bool().unwrap(), rows)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ended_votes_are_kept_newest_first_and_the_last_twenty() {
|
||||
let mut s = board();
|
||||
let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B"));
|
||||
vote(&mut s, &[("Ada", a), ("Bo", a)]);
|
||||
vote(&mut s, &[("Ada", b)]);
|
||||
assert_eq!(
|
||||
sessions(&mut s),
|
||||
vec![(json!(1), true, vec![(b.0, 1)]), (json!(0), false, vec![(a.0, 2)])],
|
||||
"the vote on the board first, then the one before"
|
||||
);
|
||||
let kept = &s.doc().unwrap().vote_history;
|
||||
assert!(kept.iter().all(|r| r.vote.ended && r.ended > 0), "ended, with when");
|
||||
// Clearing takes the results off the board; starting a vote leaves the kept ones too.
|
||||
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
||||
assert_eq!(sessions(&mut s).iter().filter(|x| x.1).count(), 0, "none on the board");
|
||||
assert_eq!(sessions(&mut s).len(), 2, "still kept");
|
||||
ex(&mut s, "board.vote", json!({"action": "start"}));
|
||||
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["sessions"].as_array().map(Vec::len), Some(2), "kept while a vote runs");
|
||||
ex(&mut s, "board.vote", json!({"action": "end"}));
|
||||
for _ in 0..VOTE_HISTORY_MAX + 2 {
|
||||
vote(&mut s, &[("Cy", a)]);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().vote_history.len(), VOTE_HISTORY_MAX, "the oldest go");
|
||||
assert!(s.doc().unwrap().vote_history.iter().all(|r| r.ballots.contains_key("Cy")), "the first two are gone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_kept_results() {
|
||||
let mut s = board();
|
||||
let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B"));
|
||||
assert!(matches!(s.execute("board.vote", &json!({"action": "delete", "index": 0})), Err(EngineError::Disabled(..))), "none kept");
|
||||
vote(&mut s, &[("Ada", a)]);
|
||||
vote(&mut s, &[("Bo", b)]);
|
||||
assert!(s.execute("board.vote", &json!({"action": "delete", "index": 2})).is_err(), "no third");
|
||||
assert!(s.execute("board.vote", &json!({"action": "delete"})).is_err(), "which one");
|
||||
assert!(s.execute("board.vote", &json!({"action": "undelete", "index": 0})).is_err(), "no such action");
|
||||
// An earlier one: the vote on the board stays.
|
||||
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
|
||||
assert_eq!(sessions(&mut s), vec![(json!(0), true, vec![(b.0, 1)])]);
|
||||
assert!(s.doc().unwrap().vote.is_some());
|
||||
// The one on the board: it leaves the board too.
|
||||
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.vote_history.is_empty() && doc.vote.is_none() && doc.votes.is_empty());
|
||||
assert_eq!(ex(&mut s, "board.voteResults", json!({})), json!({"state": "off", "sessions": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_results_save_and_older_files_open() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
vote(&mut s, &[("Ada", a)]);
|
||||
let doc = s.doc().unwrap().clone();
|
||||
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
|
||||
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert_eq!(back.vote_history, doc.vote_history);
|
||||
// A file from before results were kept: none listed but the ended vote on its board, which
|
||||
// shows and clears as before (it can't be deleted: it isn't kept).
|
||||
let mut v = figcraft_format::to_json(&doc).unwrap();
|
||||
assert!(v.as_object_mut().unwrap().remove("vote_history").is_some(), "saved as vote_history");
|
||||
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
|
||||
assert!(old.vote_history.is_empty() && old.vote.is_some());
|
||||
let mut s = board();
|
||||
s.ds_mut().unwrap().doc = std::sync::Arc::new(old);
|
||||
assert_eq!(sessions(&mut s), vec![(Value::Null, true, vec![(a.0, 1)])]);
|
||||
ex(&mut s, "board.vote", json!({"action": "clear"}));
|
||||
assert!(sessions(&mut s).is_empty());
|
||||
// Design files don't write it.
|
||||
assert!(figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap().get("vote_history").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_leaves_kept_results_alone() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
vote(&mut s, &[("Ada", a)]);
|
||||
let b = sticky(&mut s, 300.0, "B");
|
||||
vote(&mut s, &[("Bo", b)]);
|
||||
s.undo().unwrap();
|
||||
assert!(s.doc().unwrap().node(b).is_none(), "the sticky is undone");
|
||||
assert_eq!(s.doc().unwrap().vote_history.len(), 2, "the results ended since stay");
|
||||
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
|
||||
s.redo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().vote_history.len(), 1, "redo doesn't bring deleted results back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voters_go_by_the_names_kept_with_the_results() {
|
||||
let mut s = board();
|
||||
let a = sticky(&mut s, 0.0, "A");
|
||||
let record = |names: &[(&str, &str)]| VoteRecord {
|
||||
vote: Vote { per_person: 3, scope: VoteScope::All, ended: true, by: String::new() },
|
||||
ballots: [("peer 7".to_string(), vec![a]), ("peer 9".to_string(), vec![a])].into(),
|
||||
names: names.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(),
|
||||
ended: 1,
|
||||
};
|
||||
s.ds_mut().unwrap().doc = {
|
||||
let mut d = s.doc().unwrap().clone();
|
||||
d.vote_history = vec![record(&[("peer 7", "Grace")])];
|
||||
std::sync::Arc::new(d)
|
||||
};
|
||||
let r = ex(&mut s, "board.voteResults", json!({}));
|
||||
assert_eq!(
|
||||
r["sessions"][0]["results"][0]["voters"],
|
||||
json!([{"voter": "peer 7", "name": "Grace", "votes": 1}, {"voter": "peer 9", "name": null, "votes": 1}]),
|
||||
"a name kept, and one nobody knew"
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: طلبات السحب: {prs}، الإيداعات: {commits}، +{added} / −{deleted} سطر (Δ {delta})، +{bin_added} / −{bin_deleted} أصل ثنائي، {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "مجموعة من هذا الملف بأوضاعها ومتغيراتها: تجاوز قيمها لكل وضع وأضف متغيرات خاصة بها"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "يوجد ملف .figcraft بهذا الاسم. اختر اسمه الكامل لتأكيد الاستبدال."
|
||||
"A later vote" = "تصويت أحدث"
|
||||
"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" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها"
|
||||
@@ -115,6 +116,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "أبجدي"
|
||||
"Alt+drag" = "Alt+سحب"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "شاشة تحليلات شبكية: أرقام ومخططات مرسومة بمتجهات وجدول"
|
||||
"An earlier vote" = "تصويت أقدم"
|
||||
"An invite link that opens FigCraft and joins" = "رابط دعوة يفتح FigCraft وينضم إلى الجلسة"
|
||||
"Anchor" = "مرساة"
|
||||
"Android" = "Android"
|
||||
@@ -241,6 +243,7 @@ status = "draft — needs native review"
|
||||
"Convert to frame" = "تحويل إلى إطار"
|
||||
"Convert to section" = "تحويل إلى قسم"
|
||||
"CSS custom properties…" = "خصائص CSS المخصصة…"
|
||||
"Create from color" = "إنشاء من اللون"
|
||||
"Dart constants for Flutter…" = "ثوابت Dart لـ Flutter…"
|
||||
"Can edit" = "يمكنه التحرير"
|
||||
"Can't read this library: {e}" = "تعذرت قراءة هذه المكتبة: {e}"
|
||||
@@ -407,6 +410,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "حذف الصفحة"
|
||||
"Delete preset" = "حذف الإعداد المسبق"
|
||||
"Delete property" = "حذف الخاصية"
|
||||
"Delete results" = "حذف النتائج"
|
||||
"Delete the style (layers keep their values)" = "حذف النمط (تحتفظ الطبقات بقيمها)"
|
||||
"Delete thread" = "حذف سلسلة التعليقات"
|
||||
"Delete variable" = "حذف المتغير"
|
||||
@@ -811,8 +815,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "القائمة الرئيسية"
|
||||
"Make a component" = "إنشاء مكوّن"
|
||||
"Make slot" = "إنشاء خانة محتوى"
|
||||
"New collection" = "مجموعة جديدة"
|
||||
"Pasted design layers" = "طبقات تصميم ملصقة"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "لُصقت طبقات التصميم؛ لم تحمل الحافظة صور: {layers}"
|
||||
"Remove these results from the file" = "احذف هذه النتائج من الملف"
|
||||
"Scroll wheel zoom" = "التكبير بعجلة التمرير"
|
||||
"Slot name" = "اسم خانة المحتوى"
|
||||
"Mar" = "مارس"
|
||||
@@ -951,6 +957,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "لم تُقرأ بعد"
|
||||
"Check for updates" = "التحقق من التحديثات"
|
||||
"Not checked yet" = "لم يُتحقق منها بعد"
|
||||
"Someone who left" = "شخص غادر"
|
||||
"Take the results off the board; they stay in the file" = "أزل النتائج من اللوحة؛ تبقى في الملف"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "يستخدم هذا الملف مكتبات فريق على خوادم لم تستخدمها هنا. لا يُتصل بها حتى تتحقق منها."
|
||||
"Not shared" = "غير مشارك"
|
||||
"Note {n}" = "الملاحظة {n}"
|
||||
@@ -1822,6 +1830,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · على خادم"
|
||||
"{name} · published version {v}" = "{name} · الإصدار المنشور {v}"
|
||||
"{name} · version {v} on the server" = "{name} · الإصدار {v} على الخادم"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}، في جلسة مباشرة"
|
||||
"{name}, online, open only to you" = "{name}، على الإنترنت ومتاح لك وحدك"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}، {kind}، {w} × {h}، محدد"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।"
|
||||
"A later vote" = "পরের ভোট"
|
||||
"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) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে"
|
||||
@@ -110,6 +111,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "বর্ণানুক্রমিক"
|
||||
"Alt+drag" = "Alt+ড্র্যাগ"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল"
|
||||
"An earlier vote" = "আগের ভোট"
|
||||
"An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়"
|
||||
"Anchor" = "অ্যাঙ্কর"
|
||||
"Android" = "অ্যান্ড্রয়েড"
|
||||
@@ -351,6 +353,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "কম্পোনেন্ট তৈরি করুন"
|
||||
"Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন"
|
||||
"Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।"
|
||||
"Create from color" = "রং থেকে তৈরি করুন"
|
||||
"Create property…" = "প্রপার্টি তৈরি করুন..."
|
||||
"Create style" = "স্টাইল তৈরি করুন"
|
||||
"Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন"
|
||||
@@ -387,6 +390,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "পেজ ডিলিট করুন"
|
||||
"Delete preset" = "প্রিসেট ডিলিট করুন"
|
||||
"Delete property" = "প্রপার্টি ডিলিট করুন"
|
||||
"Delete results" = "ফলাফল মুছুন"
|
||||
"Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)"
|
||||
"Delete thread" = "থ্রেড ডিলিট করুন"
|
||||
"Delete variable" = "ভেরিয়েবল ডিলিট করুন"
|
||||
@@ -779,8 +783,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "প্রধান মেনু"
|
||||
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
|
||||
"Make slot" = "স্লট তৈরি করুন"
|
||||
"New collection" = "নতুন সংগ্রহ"
|
||||
"Pasted design layers" = "পেস্ট করা ডিজাইন লেয়ার"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "ডিজাইন লেয়ার পেস্ট হয়েছে; ক্লিপবোর্ডে এগুলোর ছবি ছিল না: {layers}"
|
||||
"Remove these results from the file" = "এই ফলাফল ফাইল থেকে মুছুন"
|
||||
"Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম"
|
||||
"Slot name" = "স্লটের নাম"
|
||||
"Mar" = "মার্চ"
|
||||
@@ -919,6 +925,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "এখনো পড়া হয়নি"
|
||||
"Check for updates" = "আপডেট দেখুন"
|
||||
"Not checked yet" = "এখনো যাচাই করা হয়নি"
|
||||
"Someone who left" = "কেউ একজন যিনি চলে গেছেন"
|
||||
"Take the results off the board; they stay in the file" = "ফলাফল বোর্ড থেকে সরান; সেগুলো ফাইলে থাকবে"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "এই ফাইলটি এমন সার্ভারের টিম লাইব্রেরি ব্যবহার করে যা আপনি এখানে ব্যবহার করেননি। আপনি যাচাই না করা পর্যন্ত সেগুলোর সাথে যোগাযোগ করা হয় না।"
|
||||
"Not shared" = "শেয়ার করা হয়নি"
|
||||
"Note {n}" = "নোট {n}"
|
||||
@@ -1763,6 +1771,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · একটি সার্ভারে"
|
||||
"{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}"
|
||||
"{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, লাইভ সেশনে"
|
||||
"{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত"
|
||||
|
||||
@@ -243,6 +243,7 @@ status = "draft — needs native review"
|
||||
"Convert to frame" = "Pretvori u okvir"
|
||||
"Convert to section" = "Pretvori u sekciju"
|
||||
"CSS custom properties…" = "Prilagođena CSS svojstva…"
|
||||
"Create from color" = "Napravi iz boje"
|
||||
"Dart constants for Flutter…" = "Dart konstante za Flutter…"
|
||||
"Can edit" = "Može uređivati"
|
||||
"Can't read this library: {e}" = "Nije moguće pročitati ovu biblioteku: {e}"
|
||||
@@ -823,6 +824,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Glavni meni"
|
||||
"Make a component" = "Napravi komponentu"
|
||||
"Make slot" = "Napravi slot"
|
||||
"New collection" = "Nova kolekcija"
|
||||
"Pasted design layers" = "Zalijepljeni slojevi dizajna"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Zalijepljeni slojevi dizajna; međuspremnik nije prenio slike za: {layers}"
|
||||
"Scroll wheel zoom" = "Zumiranje kotačićem miša"
|
||||
|
||||
@@ -14,6 +14,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu."
|
||||
"A later vote" = "Pozdější hlasování"
|
||||
"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í"
|
||||
@@ -104,6 +105,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Abecedně"
|
||||
"Alt+drag" = "Alt+tažení"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytický přehled na mřížce: čísla, vektorové grafy a tabulka"
|
||||
"An earlier vote" = "Dřívější hlasování"
|
||||
"An invite link that opens FigCraft and joins" = "Odkaz s pozvánkou, který otevře FigCraft a připojí k relaci"
|
||||
"Anchor" = "Kotva"
|
||||
"Android" = "Android"
|
||||
@@ -341,6 +343,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Vytvořit komponentu"
|
||||
"Create component property" = "Vytvořit vlastnost komponenty"
|
||||
"Create components, styles or variables to publish them as a library." = "Vytvořte komponenty, styly nebo proměnné a publikujte je jako knihovnu."
|
||||
"Create from color" = "Vytvořit z barvy"
|
||||
"Create property…" = "Vytvořit vlastnost…"
|
||||
"Create style" = "Vytvořit styl"
|
||||
"Create style from the selected text" = "Vytvořit styl z vybraného textu"
|
||||
@@ -377,6 +380,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Smazat stránku"
|
||||
"Delete preset" = "Smazat předvolbu"
|
||||
"Delete property" = "Smazat vlastnost"
|
||||
"Delete results" = "Smazat výsledky"
|
||||
"Delete the style (layers keep their values)" = "Smazat styl (vrstvy si zachovají hodnoty)"
|
||||
"Delete thread" = "Smazat vlákno"
|
||||
"Delete variable" = "Smazat proměnnou"
|
||||
@@ -764,8 +768,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Hlavní nabídka"
|
||||
"Make a component" = "Vytvořit komponentu"
|
||||
"Make slot" = "Vytvořit slot"
|
||||
"New collection" = "Nová kolekce"
|
||||
"Pasted design layers" = "Vložené návrhové vrstvy"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Návrhové vrstvy vloženy; schránka neobsahovala obrázky těchto vrstev: {layers}"
|
||||
"Remove these results from the file" = "Odebrat tyto výsledky ze souboru"
|
||||
"Scroll wheel zoom" = "Přibližování kolečkem myši"
|
||||
"Slot name" = "Název slotu"
|
||||
"Mar" = "března"
|
||||
@@ -901,6 +907,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Dosud nepřečteno"
|
||||
"Check for updates" = "Zkontrolovat aktualizace"
|
||||
"Not checked yet" = "Dosud nezkontrolováno"
|
||||
"Someone who left" = "Někdo, kdo odešel"
|
||||
"Take the results off the board; they stay in the file" = "Odebrat výsledky z tabule; zůstanou v souboru"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tento soubor používá týmové knihovny na serverech, které jste tu dosud nepoužili. Nekontaktují se, dokud je nezkontrolujete."
|
||||
"Not shared" = "Nesdíleno"
|
||||
"Note {n}" = "Poznámka {n}"
|
||||
@@ -1727,6 +1735,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · na serveru"
|
||||
"{name} · published version {v}" = "{name} · publikovaná verze {v}"
|
||||
"{name} · version {v} on the server" = "{name} · verze {v} na serveru"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, v živé relaci"
|
||||
"{name}, online, open only to you" = "{name}, online, přístupný pouze vám"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
|
||||
"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 later vote" = "Eine spätere Abstimmung"
|
||||
"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"
|
||||
@@ -104,6 +105,7 @@ status = "draft — needs native review"
|
||||
"Along the top" = "Entlang der Oberkante"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+Ziehen"
|
||||
"An earlier vote" = "Eine frühere Abstimmung"
|
||||
"An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt"
|
||||
"Anchor" = "Anker"
|
||||
"Android" = "Android"
|
||||
@@ -342,6 +344,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Komponente erstellen"
|
||||
"Create component property" = "Komponenteneigenschaft erstellen"
|
||||
"Create components, styles or variables to publish them as a library." = "Erstellen Sie Komponenten, Stile oder Variablen, um sie als Bibliothek zu veröffentlichen."
|
||||
"Create from color" = "Aus Farbe erstellen"
|
||||
"Create property…" = "Eigenschaft erstellen…"
|
||||
"Create style" = "Stil erstellen"
|
||||
"Create style from the selected text" = "Stil aus dem ausgewählten Text erstellen"
|
||||
@@ -374,6 +377,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Seite löschen"
|
||||
"Delete preset" = "Vorgabe löschen"
|
||||
"Delete property" = "Eigenschaft löschen"
|
||||
"Delete results" = "Ergebnisse löschen"
|
||||
"Delete the style (layers keep their values)" = "Stil löschen (Ebenen behalten ihre Werte)"
|
||||
"Delete thread" = "Thread löschen"
|
||||
"Delete variable" = "Variable löschen"
|
||||
@@ -749,9 +753,11 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "Hauptkomponente gelöscht"
|
||||
"Main menu" = "Hauptmenü"
|
||||
"Make slot" = "Slot erstellen"
|
||||
"New collection" = "Neue Sammlung"
|
||||
"Nobody shares a file there now." = "Dort teilt gerade niemand eine Datei."
|
||||
"Pasted design layers" = "Eingefügte Designebenen"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designebenen eingefügt; die Zwischenablage enthielt die Bilder von {layers} nicht"
|
||||
"Remove these results from the file" = "Diese Ergebnisse aus der Datei entfernen"
|
||||
"Scroll wheel zoom" = "Mit dem Mausrad zoomen"
|
||||
"Shared by your team on {server}" = "Von Ihrem Team geteilt auf {server}"
|
||||
"Slot name" = "Slot-Name"
|
||||
@@ -883,6 +889,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Noch nicht gelesen"
|
||||
"Check for updates" = "Nach Updates suchen"
|
||||
"Not checked yet" = "Noch nicht geprüft"
|
||||
"Someone who left" = "Jemand, der gegangen ist"
|
||||
"Take the results off the board; they stay in the file" = "Ergebnisse vom Board nehmen; sie bleiben in der Datei"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Diese Datei nutzt Teambibliotheken auf Servern, die Sie hier noch nicht verwendet haben. Diese Server werden erst kontaktiert, wenn Sie die Bibliotheken prüfen."
|
||||
"Not shared" = "Nicht geteilt"
|
||||
"Note {n}" = "Notiz {n}"
|
||||
@@ -1694,6 +1702,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · auf einem Server"
|
||||
"{name} · published version {v}" = "{name} · veröffentlichte Version {v}"
|
||||
"{name} · version {v} on the server" = "{name} · Version {v} auf dem Server"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, in einer Live-Sitzung"
|
||||
"{name}, online, open only to you" = "{name}, online, nur für Sie zugänglich"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, ausgewählt"
|
||||
|
||||
@@ -17,6 +17,7 @@ name = "English"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement."
|
||||
"A later vote" = "A later vote"
|
||||
"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"
|
||||
@@ -111,6 +112,7 @@ name = "English"
|
||||
"Alphabetical" = "Alphabetical"
|
||||
"Alt+drag" = "Alt+drag"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table"
|
||||
"An earlier vote" = "An earlier vote"
|
||||
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
|
||||
"Anchor" = "Anchor"
|
||||
"Android" = "Android"
|
||||
@@ -242,6 +244,7 @@ name = "English"
|
||||
"Copy browser link" = "Copy browser link"
|
||||
"Couldn't reach {server} for its team list." = "Couldn't reach {server} for its team list."
|
||||
"CSS custom properties…" = "CSS custom properties…"
|
||||
"Create from color" = "Create from color"
|
||||
"Dart constants for Flutter…" = "Dart constants for Flutter…"
|
||||
"Calculated from other variables; it follows them" = "Calculated from other variables; it follows them"
|
||||
"Can edit" = "Can edit"
|
||||
@@ -412,6 +415,7 @@ name = "English"
|
||||
"Delete page" = "Delete page"
|
||||
"Delete preset" = "Delete preset"
|
||||
"Delete property" = "Delete property"
|
||||
"Delete results" = "Delete results"
|
||||
"Delete the style (layers keep their values)" = "Delete the style (layers keep their values)"
|
||||
"Delete thread" = "Delete thread"
|
||||
"Delete variable" = "Delete variable"
|
||||
@@ -826,9 +830,11 @@ name = "English"
|
||||
"Main menu" = "Main menu"
|
||||
"Make a component" = "Make a component"
|
||||
"Make slot" = "Make slot"
|
||||
"New collection" = "New collection"
|
||||
"Nobody shares a file there now." = "Nobody shares a file there now."
|
||||
"Pasted design layers" = "Pasted design layers"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}"
|
||||
"Remove these results from the file" = "Remove these results from the file"
|
||||
"Scroll wheel zoom" = "Scroll wheel zoom"
|
||||
"Shared by your team on {server}" = "Shared by your team on {server}"
|
||||
"Slot name" = "Slot name"
|
||||
@@ -1302,6 +1308,8 @@ name = "English"
|
||||
"Search files and templates" = "Search files and templates"
|
||||
"Search results" = "Search results"
|
||||
"Search fonts" = "Search fonts"
|
||||
"Someone who left" = "Someone who left"
|
||||
"Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file"
|
||||
"Use fonts installed on this computer…" = "Use fonts installed on this computer…"
|
||||
"Search layers" = "Search layers"
|
||||
"Search pages" = "Search pages"
|
||||
@@ -1863,6 +1871,7 @@ name = "English"
|
||||
"{name} · on a server" = "{name} · on a server"
|
||||
"{name} · published version {v}" = "{name} · published version {v}"
|
||||
"{name} · version {v} on the server" = "{name} · version {v} on the server"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, in a live session"
|
||||
"{name}, online, open only to you" = "{name}, online, open only to you"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} by {h}, selected"
|
||||
|
||||
@@ -8,6 +8,7 @@ name = "Español"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"A later vote" = "Una votación posterior"
|
||||
"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"
|
||||
@@ -19,6 +20,7 @@ status = "draft — needs native review"
|
||||
"Along the left" = "A lo largo del borde izquierdo"
|
||||
"Along the right" = "A lo largo del borde derecho"
|
||||
"Along the top" = "A lo largo del borde superior"
|
||||
"An earlier vote" = "Una votación anterior"
|
||||
"Android Vector Drawable" = "Android Vector Drawable"
|
||||
"Best effort" = "Mejor aproximación"
|
||||
"Between the selected layers" = "Entre las capas seleccionadas"
|
||||
@@ -32,10 +34,16 @@ status = "draft — needs native review"
|
||||
"Connector" = "Conector"
|
||||
"Couldn't download the variables" = "No se pudieron descargar las variables"
|
||||
"Couldn't download {name}" = "No se pudo descargar {name}"
|
||||
"Create from color" = "Crear a partir del color"
|
||||
"New collection" = "Nueva colección"
|
||||
"Delete results" = "Eliminar resultados"
|
||||
"Pasted design layers" = "Capas de diseño pegadas"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Capas de diseño pegadas; el portapapeles no traía las imágenes de: {layers}"
|
||||
"Relative to" = "Relativa a"
|
||||
"Remove these results from the file" = "Quitar estos resultados del archivo"
|
||||
"Scroll wheel zoom" = "Zoom con la rueda del ratón"
|
||||
"Someone who left" = "Alguien que se fue"
|
||||
"Take the results off the board; they stay in the file" = "Quitar los resultados del tablero; se quedan en el archivo"
|
||||
"Trigger layer" = "Capa que la abre"
|
||||
"Whole screen" = "Pantalla completa"
|
||||
|
||||
@@ -1810,6 +1818,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · en un servidor"
|
||||
"{name} · published version {v}" = "{name} · versión publicada {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versión {v} en el servidor"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, en una sesión en vivo"
|
||||
"{name}, online, open only to you" = "{name}, en línea, abierto solo para ti"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, seleccionado"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi."
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia."
|
||||
"A later vote" = "Myöhempi äänestys"
|
||||
"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."
|
||||
@@ -113,6 +114,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Aakkosjärjestys"
|
||||
"Alt+drag" = "Alt+drag"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko"
|
||||
"An earlier vote" = "Aiempi äänestys"
|
||||
"An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon"
|
||||
"Anchor" = "Ankkuri"
|
||||
"Android" = "Android"
|
||||
@@ -374,6 +376,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Luo komponentti"
|
||||
"Create component property" = "Luo komponentin ominaisuus"
|
||||
"Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona."
|
||||
"Create from color" = "Luo väristä"
|
||||
"Create property…" = "Luo ominaisuus…"
|
||||
"Create style" = "Luo tyyli"
|
||||
"Create style from the selected text" = "Luo tyyli valitusta tekstistä"
|
||||
@@ -411,6 +414,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Poista sivu"
|
||||
"Delete preset" = "Poista esiasetus"
|
||||
"Delete property" = "Poista ominaisuus"
|
||||
"Delete results" = "Poista tulokset"
|
||||
"Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)"
|
||||
"Delete thread" = "Poista keskustelu"
|
||||
"Delete variable" = "Poista muuttuja"
|
||||
@@ -826,8 +830,10 @@ status = "draft — needs native review"
|
||||
"Make a component" = "Luo komponentti"
|
||||
"Make slot" = "Luo sisältöpaikka"
|
||||
"Nobody shares a file there now." = "Kukaan ei jaa siellä nyt tiedostoa."
|
||||
"New collection" = "Uusi kokoelma"
|
||||
"Pasted design layers" = "Liitetyt suunnittelutasot"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Suunnittelutasot liitetty; leikepöydällä ei ollut näiden kuvia: {layers}"
|
||||
"Remove these results from the file" = "Poista nämä tulokset tiedostosta"
|
||||
"Scroll wheel zoom" = "Zoomaus hiiren rullalla"
|
||||
"Shared by your team on {server}" = "Tiimisi jakama palvelimella {server}"
|
||||
"Slot name" = "Sisältöpaikan nimi"
|
||||
@@ -971,6 +977,8 @@ status = "draft — needs native review"
|
||||
"Check for updates…" = "Tarkista päivitykset…"
|
||||
"Checking for updates…" = "Tarkistetaan päivityksiä…"
|
||||
"Not checked yet" = "Ei vielä tarkistettu"
|
||||
"Someone who left" = "Joku, joka lähti"
|
||||
"Take the results off the board; they stay in the file" = "Poista tulokset taululta; ne jäävät tiedostoon"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tämä tiedosto käyttää tiimikirjastoja palvelimilla, joita et ole käyttänyt täällä. Niihin ei oteta yhteyttä ennen kuin tarkistat ne."
|
||||
"Not shared" = "Ei jaettu"
|
||||
"Note {n}" = "Huomautus {n}"
|
||||
@@ -1865,6 +1873,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · palvelimella"
|
||||
"{name} · published version {v}" = "{name} · julkaistu versio {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versio {v} palvelimella"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa"
|
||||
"{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
|
||||
"A later vote" = "Un vote plus récent"
|
||||
"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"
|
||||
@@ -109,6 +110,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Alphabétique"
|
||||
"Alt+drag" = "Alt+glisser"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
|
||||
"An earlier vote" = "Un vote plus ancien"
|
||||
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
|
||||
"Anchor" = "Ancre"
|
||||
"Android" = "Android"
|
||||
@@ -351,6 +353,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Créer un composant"
|
||||
"Create component property" = "Créer une propriété de composant"
|
||||
"Create components, styles or variables to publish them as a library." = "Créez des composants, des styles ou des variables pour les publier en tant que bibliothèque."
|
||||
"Create from color" = "Créer à partir de la couleur"
|
||||
"Create property…" = "Créer une propriété…"
|
||||
"Create style" = "Créer un style"
|
||||
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
|
||||
@@ -387,6 +390,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Supprimer la page"
|
||||
"Delete preset" = "Supprimer le préréglage"
|
||||
"Delete property" = "Supprimer la propriété"
|
||||
"Delete results" = "Supprimer les résultats"
|
||||
"Delete the style (layers keep their values)" = "Supprimer le style (les calques conservent leurs valeurs)"
|
||||
"Delete thread" = "Supprimer le fil"
|
||||
"Delete variable" = "Supprimer la variable"
|
||||
@@ -781,8 +785,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make a component" = "Créer un composant"
|
||||
"Make slot" = "Créer un emplacement"
|
||||
"New collection" = "Nouvelle collection"
|
||||
"Pasted design layers" = "Calques de design collés"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Calques de design collés ; le presse-papiers ne contenait pas les images de : {layers}"
|
||||
"Remove these results from the file" = "Retirer ces résultats du fichier"
|
||||
"Scroll wheel zoom" = "Zoom avec la molette"
|
||||
"Slot name" = "Nom de l'emplacement"
|
||||
"Mar" = "mars"
|
||||
@@ -919,6 +925,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Pas encore lu"
|
||||
"Check for updates" = "Rechercher des mises à jour"
|
||||
"Not checked yet" = "Pas encore vérifiée"
|
||||
"Someone who left" = "Quelqu'un qui est parti"
|
||||
"Take the results off the board; they stay in the file" = "Retirer les résultats du tableau ; ils restent dans le fichier"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ce fichier utilise des bibliothèques d’équipe sur des serveurs que vous n’avez pas utilisés ici. Ils ne sont contactés que lorsque vous les vérifiez."
|
||||
"Not shared" = "Non partagé"
|
||||
"Note {n}" = "Note {n}"
|
||||
@@ -1770,6 +1778,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · sur un serveur"
|
||||
"{name} · published version {v}" = "{name} · version publiée {v}"
|
||||
"{name} · version {v} on the server" = "{name} · version {v} sur le serveur"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, dans une session en direct"
|
||||
"{name}, online, open only to you" = "{name}, en ligne, accessible à vous seul"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} sur {h}, sélectionné"
|
||||
|
||||
@@ -8,12 +8,20 @@ name = "Bahasa Indonesia"
|
||||
status = "draft — needs native review"
|
||||
|
||||
[ui]
|
||||
"A later vote" = "Pemungutan suara yang lebih baru"
|
||||
"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"
|
||||
"Create from color" = "Buat dari warna"
|
||||
"New collection" = "Koleksi baru"
|
||||
"An earlier vote" = "Pemungutan suara yang lebih lama"
|
||||
"Delete results" = "Hapus hasil"
|
||||
"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}"
|
||||
"Relative to" = "Relatif terhadap"
|
||||
"Remove these results from the file" = "Hapus hasil ini dari file"
|
||||
"Scroll wheel zoom" = "Zoom dengan roda gulir"
|
||||
"Someone who left" = "Seseorang yang sudah keluar"
|
||||
"Take the results off the board; they stay in the file" = "Hapus hasil dari papan; tetap tersimpan di file"
|
||||
"Trigger layer" = "Layer pemicu"
|
||||
"Whole screen" = "Seluruh layar"
|
||||
|
||||
@@ -1731,6 +1739,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · di server"
|
||||
"{name} · published version {v}" = "{name} · versi terpublikasi {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versi {v} di server"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, dalam sesi langsung"
|
||||
"{name}, online, open only to you" = "{name}, online, hanya terbuka untuk Anda"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} x {h}, dipilih"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{bin_added} / −{bin_deleted} asset binari, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una collezione di questo file con queste modalità e variabili: sovrascrivi i loro valori per modalità e aggiungi variabili proprie"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Esiste già un file .figcraft con questo nome. Scegli il nome completo per confermare la sostituzione."
|
||||
"A later vote" = "Una votazione successiva"
|
||||
"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"
|
||||
@@ -100,6 +101,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Alfabetico"
|
||||
"Alt+drag" = "Alt+trascina"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una schermata di analisi su una griglia: cifre, grafici disegnati con vettori e una tabella"
|
||||
"An earlier vote" = "Una votazione precedente"
|
||||
"An invite link that opens FigCraft and joins" = "Un link di invito che apre FigCraft e si unisce"
|
||||
"Anchor" = "Ancora"
|
||||
"Android" = "Android"
|
||||
@@ -335,6 +337,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Crea componente"
|
||||
"Create component property" = "Crea proprietà del componente"
|
||||
"Create components, styles or variables to publish them as a library." = "Crea componenti, stili o variabili per pubblicarli come libreria."
|
||||
"Create from color" = "Crea dal colore"
|
||||
"Create property…" = "Crea proprietà…"
|
||||
"Create style" = "Crea stile"
|
||||
"Create style from the selected text" = "Crea stile dal testo selezionato"
|
||||
@@ -371,6 +374,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Elimina pagina"
|
||||
"Delete preset" = "Elimina preset"
|
||||
"Delete property" = "Elimina proprietà"
|
||||
"Delete results" = "Elimina risultati"
|
||||
"Delete the style (layers keep their values)" = "Elimina lo stile (i livelli mantengono i propri valori)"
|
||||
"Delete thread" = "Elimina discussione"
|
||||
"Delete variable" = "Elimina variabile"
|
||||
@@ -755,8 +759,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menu principale"
|
||||
"Make a component" = "Crea un componente"
|
||||
"Make slot" = "Crea slot"
|
||||
"New collection" = "Nuova collezione"
|
||||
"Pasted design layers" = "Livelli di design incollati"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Livelli di design incollati; gli appunti non contenevano le immagini di: {layers}"
|
||||
"Remove these results from the file" = "Rimuovi questi risultati dal file"
|
||||
"Scroll wheel zoom" = "Zoom con la rotella del mouse"
|
||||
"Slot name" = "Nome dello slot"
|
||||
"Mar" = "mar"
|
||||
@@ -890,6 +896,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Non ancora letto"
|
||||
"Check for updates" = "Verifica aggiornamenti"
|
||||
"Not checked yet" = "Non ancora verificata"
|
||||
"Someone who left" = "Qualcuno che è uscito"
|
||||
"Take the results off the board; they stay in the file" = "Togli i risultati dalla lavagna; restano nel file"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Questo file usa librerie del team su server che non hai usato qui. Non vengono contattati finché non le verifichi."
|
||||
"Not shared" = "Non condiviso"
|
||||
"Note {n}" = "Nota {n}"
|
||||
@@ -1707,6 +1715,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · su un server"
|
||||
"{name} · published version {v}" = "{name} · versione {v} pubblicata"
|
||||
"{name} · version {v} on the server" = "{name} · versione {v} sul server"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, in una sessione live"
|
||||
"{name}, online, open only to you" = "{name}, online, aperto solo a te"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} per {h}, selezionato"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。"
|
||||
"A later vote" = "後の投票"
|
||||
"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" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
|
||||
@@ -110,6 +111,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "名前順"
|
||||
"Alt+drag" = "Alt+ドラッグ"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表"
|
||||
"An earlier vote" = "前の投票"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク"
|
||||
"Anchor" = "アンカー"
|
||||
"Android" = "Android"
|
||||
@@ -349,6 +351,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "コンポーネントを作成"
|
||||
"Create component property" = "コンポーネントプロパティを作成"
|
||||
"Create components, styles or variables to publish them as a library." = "コンポーネント、スタイル、変数を作成すると、ライブラリとして公開できる。"
|
||||
"Create from color" = "色から作成"
|
||||
"Create property…" = "プロパティを作成…"
|
||||
"Create style" = "スタイルを作成"
|
||||
"Create style from the selected text" = "選択したテキストからスタイルを作成"
|
||||
@@ -385,6 +388,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "ページを削除"
|
||||
"Delete preset" = "プリセットを削除"
|
||||
"Delete property" = "プロパティを削除"
|
||||
"Delete results" = "結果を削除"
|
||||
"Delete the style (layers keep their values)" = "スタイルを削除(レイヤーの値は保持)"
|
||||
"Delete thread" = "スレッドを削除"
|
||||
"Delete variable" = "変数を削除"
|
||||
@@ -775,8 +779,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "メインメニュー"
|
||||
"Make a component" = "コンポーネントを作成"
|
||||
"Make slot" = "スロットにする"
|
||||
"New collection" = "新しいコレクション"
|
||||
"Pasted design layers" = "貼り付けたデザインレイヤー"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "デザインレイヤーを貼り付けました。クリップボードに次の画像がありませんでした: {layers}"
|
||||
"Remove these results from the file" = "この結果をファイルから削除します"
|
||||
"Scroll wheel zoom" = "スクロールホイールでズーム"
|
||||
"Slot name" = "スロット名"
|
||||
"Mar" = "3月"
|
||||
@@ -912,6 +918,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "未読"
|
||||
"Check for updates" = "アップデートを確認"
|
||||
"Not checked yet" = "未確認"
|
||||
"Someone who left" = "退出した人"
|
||||
"Take the results off the board; they stay in the file" = "ボードから結果を外します(ファイルには残ります)"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "このファイルは、ここで使ったことのないサーバー上のチームライブラリを使う。確認するまでそれらのサーバーには接続しない。"
|
||||
"Not shared" = "共有していない"
|
||||
"Note {n}" = "メモ {n}"
|
||||
@@ -1751,6 +1759,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · サーバー上"
|
||||
"{name} · published version {v}" = "{name} · 公開バージョン {v}"
|
||||
"{name} · version {v} on the server" = "{name} · サーバー上のバージョン {v}"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}、ライブセッション中"
|
||||
"{name}, online, open only to you" = "{name}、オンライン、開けるのはあなただけ"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}、{kind}、{w} × {h}、選択中"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, კომიტი: {commits}, +{added} / −{deleted} სტრიქონი (Δ {delta}), +{bin_added} / −{bin_deleted} ბინარული რესურსი, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი."
|
||||
"A later vote" = "მოგვიანებითი კენჭისყრა"
|
||||
"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" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას"
|
||||
@@ -112,6 +113,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Alphabetical" = "ანბანის მიხედვით"
|
||||
"Alt+drag" = "Alt+გადათრევა"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი"
|
||||
"An earlier vote" = "ადრინდელი კენჭისყრა"
|
||||
"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას"
|
||||
"Anchor" = "საყრდენი"
|
||||
"Android" = "Android"
|
||||
@@ -356,6 +358,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Create component" = "კომპონენტის შექმნა"
|
||||
"Create component property" = "კომპონენტის თვისების შექმნა"
|
||||
"Create components, styles or variables to publish them as a library." = "შექმენით კომპონენტები, სტილები ან ცვლადები, რომ ბიბლიოთეკად გამოაქვეყნოთ."
|
||||
"Create from color" = "ფერიდან შექმნა"
|
||||
"Create property…" = "თვისების შექმნა…"
|
||||
"Create style" = "სტილის შექმნა"
|
||||
"Create style from the selected text" = "სტილის შექმნა მონიშნული ტექსტიდან"
|
||||
@@ -392,6 +395,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Delete page" = "გვერდის წაშლა"
|
||||
"Delete preset" = "პრესეტის წაშლა"
|
||||
"Delete property" = "თვისების წაშლა"
|
||||
"Delete results" = "შედეგების წაშლა"
|
||||
"Delete the style (layers keep their values)" = "სტილის წაშლა (შრეები ინარჩუნებს მნიშვნელობებს)"
|
||||
"Delete thread" = "კომენტარისა და პასუხების წაშლა"
|
||||
"Delete variable" = "ცვლადის წაშლა"
|
||||
@@ -788,8 +792,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Main menu" = "მთავარი მენიუ"
|
||||
"Make a component" = "შექმენით კომპონენტი"
|
||||
"Make slot" = "სლოტად გადაქცევა"
|
||||
"New collection" = "ახალი კოლექცია"
|
||||
"Pasted design layers" = "ჩასმული დიზაინის ფენები"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "დიზაინის ფენები ჩაისვა; ბუფერში არ იყო შემდეგის სურათები: {layers}"
|
||||
"Remove these results from the file" = "ამ შედეგების ფაილიდან წაშლა"
|
||||
"Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით"
|
||||
"Slot name" = "სლოტის სახელი"
|
||||
"Mar" = "მარ"
|
||||
@@ -928,6 +934,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Not read yet" = "ჯერ არ არის წაკითხული"
|
||||
"Check for updates" = "განახლებების შემოწმება"
|
||||
"Not checked yet" = "ჯერ არ არის შემოწმებული"
|
||||
"Someone who left" = "ვინც წავიდა"
|
||||
"Take the results off the board; they stay in the file" = "შედეგების დაფიდან მოხსნა; ფაილში დარჩება"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ეს ფაილი იყენებს გუნდის ბიბლიოთეკებს სერვერებზე, რომლებიც აქ არ გამოგიყენებიათ. მათ არ დავუკავშირდებით, სანამ არ შეამოწმებთ."
|
||||
"Not shared" = "არ არის გაზიარებული"
|
||||
"Note {n}" = "შენიშვნა {n}"
|
||||
@@ -1790,6 +1798,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"{name} · on a server" = "{name} · სერვერზე"
|
||||
"{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}"
|
||||
"{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, ცოცხალ სესიაში"
|
||||
"{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요."
|
||||
"A later vote" = "이후 투표"
|
||||
"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}열입니다. 나머지는 붙여넣지 않았습니다"
|
||||
@@ -104,6 +105,7 @@ status = "draft — needs native review"
|
||||
"Along the top" = "위쪽 가장자리를 따라"
|
||||
"Alpha" = "알파"
|
||||
"Alt+drag" = "Alt+드래그"
|
||||
"An earlier vote" = "이전 투표"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크"
|
||||
"Anchor" = "앵커"
|
||||
"Android" = "Android"
|
||||
@@ -338,6 +340,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "컴포넌트 만들기"
|
||||
"Create component property" = "컴포넌트 속성 만들기"
|
||||
"Create components, styles or variables to publish them as a library." = "컴포넌트, 스타일 또는 변수를 만들어 라이브러리로 게시하세요."
|
||||
"Create from color" = "색상으로 만들기"
|
||||
"Create property…" = "속성 만들기…"
|
||||
"Create style" = "스타일 만들기"
|
||||
"Create style from the selected text" = "선택한 텍스트로 스타일 만들기"
|
||||
@@ -370,6 +373,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "페이지 삭제"
|
||||
"Delete preset" = "프리셋 삭제"
|
||||
"Delete property" = "속성 삭제"
|
||||
"Delete results" = "결과 삭제"
|
||||
"Delete the style (layers keep their values)" = "스타일 삭제(레이어는 값을 유지함)"
|
||||
"Delete thread" = "스레드 삭제"
|
||||
"Delete variable" = "변수 삭제"
|
||||
@@ -739,8 +743,10 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "메인 컴포넌트가 삭제됨"
|
||||
"Main menu" = "메인 메뉴"
|
||||
"Make slot" = "슬롯으로 만들기"
|
||||
"New collection" = "새 컬렉션"
|
||||
"Pasted design layers" = "붙여넣은 디자인 레이어"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "디자인 레이어를 붙여넣었습니다. 클립보드에 다음 이미지가 없었습니다: {layers}"
|
||||
"Remove these results from the file" = "이 결과를 파일에서 삭제합니다"
|
||||
"Scroll wheel zoom" = "스크롤 휠로 확대/축소"
|
||||
"Slot name" = "슬롯 이름"
|
||||
"Margin" = "여백"
|
||||
@@ -871,6 +877,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "아직 읽지 않음"
|
||||
"Check for updates" = "업데이트 확인"
|
||||
"Not checked yet" = "아직 확인 안 함"
|
||||
"Someone who left" = "나간 사람"
|
||||
"Take the results off the board; they stay in the file" = "보드에서 결과를 내립니다. 파일에는 남습니다"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "이 파일은 여기서 사용한 적 없는 서버의 팀 라이브러리를 사용합니다. 확인하기 전까지는 해당 서버에 연결하지 않습니다."
|
||||
"Not shared" = "공유 안 함"
|
||||
"Note {n}" = "메모 {n}"
|
||||
@@ -1676,6 +1684,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · 서버에 있음"
|
||||
"{name} · published version {v}" = "{name} · 게시된 버전 {v}"
|
||||
"{name} · version {v} on the server" = "{name} · 서버의 버전 {v}"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, 라이브 세션 중"
|
||||
"{name}, online, open only to you" = "{name}, 온라인, 나만 열 수 있음"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, 선택됨"
|
||||
|
||||
@@ -19,6 +19,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} regels (Δ {delta}), +{bin_added} / −{bin_deleted} binaire assets, {first} – {last}"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Er bestaat al een .figcraft-bestand met deze naam. Kies de volledige naam om de vervanging te bevestigen."
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Een verzameling van dit bestand met deze modi en variabelen: overschrijf hun waarden per modus en voeg er eigen variabelen toe"
|
||||
"A later vote" = "Een latere stemming"
|
||||
"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"
|
||||
@@ -111,6 +112,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Alfabetisch"
|
||||
"Alt+drag" = "Alt+drag"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Een analysescherm op een raster: cijfers, diagrammen getekend met vectoren en een tabel"
|
||||
"An earlier vote" = "Een eerdere stemming"
|
||||
"An invite link that opens FigCraft and joins" = "Een uitnodigingslink die FigCraft opent en waarmee je deelneemt"
|
||||
"Anchor" = "Anker"
|
||||
"Android" = "Android"
|
||||
@@ -353,6 +355,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Component maken"
|
||||
"Create component property" = "Componenteigenschap maken"
|
||||
"Create components, styles or variables to publish them as a library." = "Maak componenten, stijlen of variabelen om ze als bibliotheek te publiceren."
|
||||
"Create from color" = "Maken van kleur"
|
||||
"Create property…" = "Eigenschap maken…"
|
||||
"Create style" = "Stijl maken"
|
||||
"Create style from the selected text" = "Stijl maken van de geselecteerde tekst"
|
||||
@@ -389,6 +392,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Pagina verwijderen"
|
||||
"Delete preset" = "Preset verwijderen"
|
||||
"Delete property" = "Eigenschap verwijderen"
|
||||
"Delete results" = "Resultaten verwijderen"
|
||||
"Delete the style (layers keep their values)" = "Verwijdert de stijl (lagen behouden hun waarden)"
|
||||
"Delete thread" = "Gesprek verwijderen"
|
||||
"Delete variable" = "Variabele verwijderen"
|
||||
@@ -781,8 +785,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Hoofdmenu"
|
||||
"Make a component" = "Maak een component"
|
||||
"Make slot" = "Slot maken"
|
||||
"New collection" = "Nieuwe verzameling"
|
||||
"Pasted design layers" = "Geplakte ontwerplagen"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Ontwerplagen geplakt; het klembord bevatte de afbeeldingen van {layers} niet"
|
||||
"Remove these results from the file" = "Deze resultaten uit het bestand verwijderen"
|
||||
"Scroll wheel zoom" = "Zoomen met scrollwiel"
|
||||
"Slot name" = "Slotnaam"
|
||||
"Mar" = "mrt."
|
||||
@@ -921,6 +927,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Nog niet gelezen"
|
||||
"Check for updates" = "Controleren op updates"
|
||||
"Not checked yet" = "Nog niet gecontroleerd"
|
||||
"Someone who left" = "Iemand die vertrokken is"
|
||||
"Take the results off the board; they stay in the file" = "Resultaten van het bord halen; ze blijven in het bestand"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Dit bestand gebruikt teambibliotheken op servers die je hier nog niet hebt gebruikt. Er wordt pas contact mee gemaakt als je ze controleert."
|
||||
"Not shared" = "Niet gedeeld"
|
||||
"Note {n}" = "Notitie {n}"
|
||||
@@ -1766,6 +1774,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · op een server"
|
||||
"{name} · published version {v}" = "{name} · gepubliceerde versie {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versie {v} op de server"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, in een live sessie"
|
||||
"{name}, online, open only to you" = "{name}, online, alleen voor jou toegankelijk"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} bij {h}, geselecteerd"
|
||||
|
||||
@@ -17,6 +17,7 @@ name = "Polski"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie."
|
||||
"A later vote" = "Późniejsze głosowanie"
|
||||
"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"
|
||||
@@ -109,6 +110,7 @@ name = "Polski"
|
||||
"Alphabetical" = "Alfabetycznie"
|
||||
"Alt+drag" = "Alt+przeciągnięcie"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela"
|
||||
"An earlier vote" = "Wcześniejsze głosowanie"
|
||||
"An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji"
|
||||
"Anchor" = "Punkt zaczepienia"
|
||||
"Android" = "Android"
|
||||
@@ -352,6 +354,7 @@ name = "Polski"
|
||||
"Create component" = "Utwórz komponent"
|
||||
"Create component property" = "Utwórz właściwość komponentu"
|
||||
"Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę."
|
||||
"Create from color" = "Utwórz z koloru"
|
||||
"Create property…" = "Utwórz właściwość…"
|
||||
"Create style" = "Utwórz styl"
|
||||
"Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu"
|
||||
@@ -388,6 +391,7 @@ name = "Polski"
|
||||
"Delete page" = "Usuń stronę"
|
||||
"Delete preset" = "Usuń ustawienie"
|
||||
"Delete property" = "Usuń właściwość"
|
||||
"Delete results" = "Usuń wyniki"
|
||||
"Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)"
|
||||
"Delete thread" = "Usuń wątek"
|
||||
"Delete variable" = "Usuń zmienną"
|
||||
@@ -783,8 +787,10 @@ name = "Polski"
|
||||
"Main menu" = "Menu główne"
|
||||
"Make a component" = "Utwórz komponent"
|
||||
"Make slot" = "Utwórz gniazdo (slot)"
|
||||
"New collection" = "Nowa kolekcja"
|
||||
"Pasted design layers" = "Wklejone warstwy projektu"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Wklejono warstwy projektu; schowek nie zawierał obrazów warstw: {layers}"
|
||||
"Remove these results from the file" = "Usuń te wyniki z pliku"
|
||||
"Scroll wheel zoom" = "Powiększanie kółkiem myszy"
|
||||
"Slot name" = "Nazwa gniazda"
|
||||
"Mar" = "Mar"
|
||||
@@ -923,6 +929,8 @@ name = "Polski"
|
||||
"Not read yet" = "Jeszcze nieprzeczytane"
|
||||
"Check for updates" = "Sprawdź aktualizacje"
|
||||
"Not checked yet" = "Jeszcze nie sprawdzono"
|
||||
"Someone who left" = "Ktoś, kto wyszedł"
|
||||
"Take the results off the board; they stay in the file" = "Zdejmij wyniki z tablicy; zostają w pliku"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ten plik korzysta z bibliotek zespołu na serwerach, których tu jeszcze nie używano. Nie są kontaktowane, dopóki ich nie sprawdzisz."
|
||||
"Not shared" = "Nieudostępnione"
|
||||
"Note {n}" = "Notatka {n}"
|
||||
@@ -1779,6 +1787,7 @@ name = "Polski"
|
||||
"{name} · on a server" = "{name} · na serwerze"
|
||||
"{name} · published version {v}" = "{name} · opublikowana wersja {v}"
|
||||
"{name} · version {v} on the server" = "{name} · wersja {v} na serwerze"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, w sesji na żywo"
|
||||
"{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono"
|
||||
|
||||
@@ -17,6 +17,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias"
|
||||
"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 later vote" = "Uma votação posterior"
|
||||
"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"
|
||||
@@ -102,6 +103,7 @@ status = "draft — needs native review"
|
||||
"Along the top" = "Ao longo da borda superior"
|
||||
"Alpha" = "Alfa"
|
||||
"Alt+drag" = "Alt+arrastar"
|
||||
"An earlier vote" = "Uma votação anterior"
|
||||
"An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão"
|
||||
"Anchor" = "Âncora"
|
||||
"Android" = "Android"
|
||||
@@ -337,6 +339,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Criar componente"
|
||||
"Create component property" = "Criar propriedade de componente"
|
||||
"Create components, styles or variables to publish them as a library." = "Crie componentes, estilos ou variáveis para publicá-los como biblioteca."
|
||||
"Create from color" = "Criar a partir da cor"
|
||||
"Create property…" = "Criar propriedade…"
|
||||
"Create style" = "Criar estilo"
|
||||
"Create style from the selected text" = "Criar estilo a partir do texto selecionado"
|
||||
@@ -369,6 +372,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Excluir página"
|
||||
"Delete preset" = "Excluir predefinição"
|
||||
"Delete property" = "Excluir propriedade"
|
||||
"Delete results" = "Excluir resultados"
|
||||
"Delete the style (layers keep their values)" = "Excluir o estilo (as camadas mantêm seus valores)"
|
||||
"Delete thread" = "Excluir conversa"
|
||||
"Delete variable" = "Excluir variável"
|
||||
@@ -738,8 +742,10 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "Componente principal excluído"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make slot" = "Transformar em slot"
|
||||
"New collection" = "Nova coleção"
|
||||
"Pasted design layers" = "Camadas de design coladas"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Camadas de design coladas; a área de transferência não trouxe as imagens de: {layers}"
|
||||
"Remove these results from the file" = "Remover estes resultados do arquivo"
|
||||
"Scroll wheel zoom" = "Zoom com a roda do mouse"
|
||||
"Slot name" = "Nome do slot"
|
||||
"Margin" = "Margem"
|
||||
@@ -870,6 +876,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Ainda não lido"
|
||||
"Check for updates" = "Verificar atualizações"
|
||||
"Not checked yet" = "Ainda não verificada"
|
||||
"Someone who left" = "Alguém que saiu"
|
||||
"Take the results off the board; they stay in the file" = "Tirar os resultados do quadro; eles ficam no arquivo"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este arquivo usa bibliotecas de equipe em servidores que você não usou aqui. Eles só são contatados quando você os verifica."
|
||||
"Not shared" = "Não compartilhado"
|
||||
"Note {n}" = "Nota {n}"
|
||||
@@ -1674,6 +1682,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · em um servidor"
|
||||
"{name} · published version {v}" = "{name} · versão publicada {v}"
|
||||
"{name} · version {v} on the server" = "{name} · versão {v} no servidor"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, em uma sessão ao vivo"
|
||||
"{name}, online, open only to you" = "{name}, online, aberto somente para você"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, selecionado"
|
||||
|
||||
@@ -17,6 +17,7 @@ name = "Русский"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя."
|
||||
"A later vote" = "Более позднее голосование"
|
||||
"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" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
|
||||
@@ -109,6 +110,7 @@ name = "Русский"
|
||||
"Alphabetical" = "По алфавиту"
|
||||
"Alt+drag" = "Alt+перетаскивание"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Экран аналитики на сетке: показатели, векторные диаграммы и таблица"
|
||||
"An earlier vote" = "Более раннее голосование"
|
||||
"An invite link that opens FigCraft and joins" = "Ссылка приглашения, которая открывает FigCraft и подключает к сессии"
|
||||
"Anchor" = "Привязка"
|
||||
"Android" = "Android"
|
||||
@@ -352,6 +354,7 @@ name = "Русский"
|
||||
"Create component" = "Создать компонент"
|
||||
"Create component property" = "Создать свойство компонента"
|
||||
"Create components, styles or variables to publish them as a library." = "Создайте компоненты, стили или переменные, чтобы опубликовать их как библиотеку."
|
||||
"Create from color" = "Создать из цвета"
|
||||
"Create property…" = "Создать свойство…"
|
||||
"Create style" = "Создать стиль"
|
||||
"Create style from the selected text" = "Создать стиль из выделенного текста"
|
||||
@@ -388,6 +391,7 @@ name = "Русский"
|
||||
"Delete page" = "Удалить страницу"
|
||||
"Delete preset" = "Удалить пресет"
|
||||
"Delete property" = "Удалить свойство"
|
||||
"Delete results" = "Удалить результаты"
|
||||
"Delete the style (layers keep their values)" = "Удалить стиль (значения слоёв сохранятся)"
|
||||
"Delete thread" = "Удалить обсуждение"
|
||||
"Delete variable" = "Удалить переменную"
|
||||
@@ -783,8 +787,10 @@ name = "Русский"
|
||||
"Main menu" = "Главное меню"
|
||||
"Make a component" = "Создать компонент"
|
||||
"Make slot" = "Сделать слотом"
|
||||
"New collection" = "Новая коллекция"
|
||||
"Pasted design layers" = "Вставленные слои дизайна"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Слои дизайна вставлены; в буфере обмена не было изображений слоёв: {layers}"
|
||||
"Remove these results from the file" = "Удалить эти результаты из файла"
|
||||
"Scroll wheel zoom" = "Масштабирование колесом мыши"
|
||||
"Slot name" = "Имя слота"
|
||||
"Mar" = "мар."
|
||||
@@ -923,6 +929,8 @@ name = "Русский"
|
||||
"Not read yet" = "Ещё не прочитано"
|
||||
"Check for updates" = "Проверить обновления"
|
||||
"Not checked yet" = "Ещё не проверено"
|
||||
"Someone who left" = "Тот, кто вышел"
|
||||
"Take the results off the board; they stay in the file" = "Убрать результаты с доски; они останутся в файле"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Этот файл использует командные библиотеки на серверах, которые вы здесь не использовали. К ним не обращаются, пока вы их не проверите."
|
||||
"Not shared" = "Не опубликовано"
|
||||
"Note {n}" = "Заметка {n}"
|
||||
@@ -1779,6 +1787,7 @@ name = "Русский"
|
||||
"{name} · on a server" = "{name} · на сервере"
|
||||
"{name} · published version {v}" = "{name} · опубликована версия {v}"
|
||||
"{name} · version {v} on the server" = "{name} · версия {v} на сервере"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, в совместной сессии"
|
||||
"{name}, online, open only to you" = "{name}, онлайн, доступен только вам"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} на {h}, выделено"
|
||||
|
||||
@@ -17,6 +17,7 @@ name = "Svenska"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära resurser, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: ange overrides för deras värden per läge och lägg till egna variabler"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts."
|
||||
"A later vote" = "En senare omröstning"
|
||||
"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"
|
||||
@@ -111,6 +112,7 @@ name = "Svenska"
|
||||
"Alphabetical" = "I bokstavsordning"
|
||||
"Alt+drag" = "Alt+dra"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "En analysskärm på ett rutnät: figurer, diagram ritade med vektorer och en tabell"
|
||||
"An earlier vote" = "En tidigare omröstning"
|
||||
"An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter"
|
||||
"Anchor" = "Ankare"
|
||||
"Android" = "Android"
|
||||
@@ -239,6 +241,7 @@ name = "Svenska"
|
||||
"Convert to frame" = "Konvertera till frame"
|
||||
"Convert to section" = "Konvertera till avsnitt"
|
||||
"CSS custom properties…" = "Anpassade CSS-egenskaper…"
|
||||
"Create from color" = "Skapa från färg"
|
||||
"Dart constants for Flutter…" = "Dart-konstanter för Flutter…"
|
||||
"Can edit" = "Kan redigera"
|
||||
"Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}"
|
||||
@@ -408,6 +411,7 @@ name = "Svenska"
|
||||
"Delete page" = "Radera sida"
|
||||
"Delete preset" = "Radera förinställning"
|
||||
"Delete property" = "Radera egenskap"
|
||||
"Delete results" = "Ta bort resultat"
|
||||
"Delete the style (layers keep their values)" = "Radera stilen (lagren behåller sina värden)"
|
||||
"Delete thread" = "Radera tråd"
|
||||
"Delete variable" = "Radera variabel"
|
||||
@@ -818,8 +822,10 @@ name = "Svenska"
|
||||
"Main menu" = "Huvudmeny"
|
||||
"Make a component" = "Skapa en komponent"
|
||||
"Make slot" = "Gör till plats"
|
||||
"New collection" = "Ny samling"
|
||||
"Pasted design layers" = "Inklistrade designlager"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designlager inklistrade; urklippet innehöll inte bilderna för: {layers}"
|
||||
"Remove these results from the file" = "Ta bort de här resultaten från filen"
|
||||
"Scroll wheel zoom" = "Zooma med scrollhjulet"
|
||||
"Slot name" = "Platsens namn"
|
||||
"Mar" = "Mar"
|
||||
@@ -1292,6 +1298,8 @@ name = "Svenska"
|
||||
"Search files and templates" = "Sök efter filer och mallar"
|
||||
"Search results" = "Sökresultat"
|
||||
"Search fonts" = "Sök typsnitt"
|
||||
"Someone who left" = "Någon som har lämnat"
|
||||
"Take the results off the board; they stay in the file" = "Ta bort resultaten från tavlan; de finns kvar i filen"
|
||||
"Use fonts installed on this computer…" = "Använd typsnitt som är installerade på den här datorn…"
|
||||
"Search layers" = "Sök lager"
|
||||
"Search pages" = "Sök sidor"
|
||||
@@ -1849,6 +1857,7 @@ name = "Svenska"
|
||||
"{name} · on a server" = "{name} · på en server"
|
||||
"{name} · published version {v}" = "{name} · publicerad version {v}"
|
||||
"{name} · version {v} on the server" = "{name} · version {v} på servern"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, i en livesession"
|
||||
"{name}, online, open only to you" = "{name}, online, öppen bara för dig"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} gånger {h}, markerad"
|
||||
|
||||
@@ -22,6 +22,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} คอมมิต, +{added} / −{deleted} บรรทัด (Δ {delta}), แอสเซ็ตไบนารี +{bin_added} / −{bin_deleted}, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "คอลเลกชันของไฟล์นี้พร้อมโหมดและตัวแปรเหล่านี้: เขียนทับค่าตามโหมดและเพิ่มตัวแปรของตัวเองได้"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "มีไฟล์ .figcraft ชื่อนี้อยู่แล้ว เลือกชื่อเต็มของไฟล์เพื่อยืนยันการแทนที่"
|
||||
"A later vote" = "การโหวตครั้งถัดมา"
|
||||
"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" = "สปริงเล่นจนนิ่ง: มวล ความแข็ง และการหน่วงกำหนดระยะเวลา"
|
||||
@@ -110,6 +111,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "ตามตัวอักษร"
|
||||
"Alt+drag" = "Alt+ลาก"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "หน้าจอวิเคราะห์ข้อมูลบนกริด: ตัวเลข แผนภูมิที่วาดจากเวกเตอร์ และตาราง"
|
||||
"An earlier vote" = "การโหวตครั้งก่อน"
|
||||
"An invite link that opens FigCraft and joins" = "ลิงก์เชิญที่เปิด FigCraft และเข้าร่วม"
|
||||
"Anchor" = "จุดยึด"
|
||||
"Android" = "Android"
|
||||
@@ -350,6 +352,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "สร้างคอมโพเนนต์"
|
||||
"Create component property" = "สร้างพร็อพเพอร์ตีของคอมโพเนนต์"
|
||||
"Create components, styles or variables to publish them as a library." = "สร้างคอมโพเนนต์ สไตล์ หรือตัวแปรเพื่อเผยแพร่เป็นไลบรารี"
|
||||
"Create from color" = "สร้างจากสี"
|
||||
"Create property…" = "สร้างพร็อพเพอร์ตี…"
|
||||
"Create style" = "สร้างสไตล์"
|
||||
"Create style from the selected text" = "สร้างสไตล์จากข้อความที่เลือก"
|
||||
@@ -386,6 +389,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "ลบหน้า"
|
||||
"Delete preset" = "ลบพรีเซ็ต"
|
||||
"Delete property" = "ลบพร็อพเพอร์ตี"
|
||||
"Delete results" = "ลบผลลัพธ์"
|
||||
"Delete the style (layers keep their values)" = "ลบสไตล์ (เลเยอร์ยังคงค่าเดิม)"
|
||||
"Delete thread" = "ลบเธรด"
|
||||
"Delete variable" = "ลบตัวแปร"
|
||||
@@ -777,8 +781,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "เมนูหลัก"
|
||||
"Make a component" = "สร้างคอมโพเนนต์"
|
||||
"Make slot" = "สร้างสล็อต"
|
||||
"New collection" = "คอลเลกชันใหม่"
|
||||
"Pasted design layers" = "เลเยอร์ดีไซน์ที่วาง"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "วางเลเยอร์ดีไซน์แล้ว คลิปบอร์ดไม่มีภาพของ: {layers}"
|
||||
"Remove these results from the file" = "ลบผลลัพธ์นี้ออกจากไฟล์"
|
||||
"Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์"
|
||||
"Slot name" = "ชื่อสล็อต"
|
||||
"Mar" = "มี.ค."
|
||||
@@ -915,6 +921,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "ยังไม่ได้อ่าน"
|
||||
"Check for updates" = "ตรวจหาอัปเดต"
|
||||
"Not checked yet" = "ยังไม่ได้ตรวจ"
|
||||
"Someone who left" = "คนที่ออกไปแล้ว"
|
||||
"Take the results off the board; they stay in the file" = "นำผลลัพธ์ออกจากบอร์ด โดยยังเก็บไว้ในไฟล์"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ไฟล์นี้ใช้ไลบรารีทีมบนเซิร์ฟเวอร์ที่คุณยังไม่เคยใช้ที่นี่ จะไม่มีการติดต่อเซิร์ฟเวอร์เหล่านั้นจนกว่าคุณจะตรวจ"
|
||||
"Not shared" = "ไม่ได้แชร์"
|
||||
"Note {n}" = "โน้ต {n}"
|
||||
@@ -1757,6 +1765,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · อยู่บนเซิร์ฟเวอร์"
|
||||
"{name} · published version {v}" = "{name} · เผยแพร่เวอร์ชัน {v}"
|
||||
"{name} · version {v} on the server" = "{name} · เวอร์ชัน {v} บนเซิร์ฟเวอร์"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name} อยู่ในเซสชันสด"
|
||||
"{name}, online, open only to you" = "{name} ออนไลน์ เปิดได้เฉพาะคุณ"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name} {kind} {w} คูณ {h} เลือกอยู่"
|
||||
|
||||
@@ -19,6 +19,7 @@ name = "Türkçe"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin"
|
||||
"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 later vote" = "Daha sonraki oylama"
|
||||
"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ı"
|
||||
@@ -108,6 +109,7 @@ name = "Türkçe"
|
||||
"Alphabetical" = "Alfabetik"
|
||||
"Alt+drag" = "Alt+sürükle"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Izgara üzerinde bir analiz ekranı: rakamlar, vektörlerden çizilmiş grafikler ve bir tablo"
|
||||
"An earlier vote" = "Daha önceki oylama"
|
||||
"An invite link that opens FigCraft and joins" = "FigCraft'ı açıp katılan bir davet bağlantısı"
|
||||
"Anchor" = "Bağlantı noktası"
|
||||
"Android" = "Android"
|
||||
@@ -345,6 +347,7 @@ name = "Türkçe"
|
||||
"Create component" = "Bileşen oluştur"
|
||||
"Create component property" = "Bileşen özelliği oluştur"
|
||||
"Create components, styles or variables to publish them as a library." = "Kitaplık olarak yayımlamak için bileşen, stil veya değişken oluşturun."
|
||||
"Create from color" = "Renkten oluştur"
|
||||
"Create property…" = "Özellik oluştur…"
|
||||
"Create style" = "Stil oluştur"
|
||||
"Create style from the selected text" = "Seçili metinden stil oluştur"
|
||||
@@ -381,6 +384,7 @@ name = "Türkçe"
|
||||
"Delete page" = "Sayfayı sil"
|
||||
"Delete preset" = "Ön ayarı sil"
|
||||
"Delete property" = "Özelliği sil"
|
||||
"Delete results" = "Sonuçları sil"
|
||||
"Delete the style (layers keep their values)" = "Stili sil (katmanlar değerlerini korur)"
|
||||
"Delete thread" = "Konuyu sil"
|
||||
"Delete variable" = "Değişkeni sil"
|
||||
@@ -768,8 +772,10 @@ name = "Türkçe"
|
||||
"Main menu" = "Ana menü"
|
||||
"Make a component" = "Bileşen yap"
|
||||
"Make slot" = "Yuva yap"
|
||||
"New collection" = "Yeni koleksiyon"
|
||||
"Pasted design layers" = "Yapıştırılan tasarım katmanları"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Tasarım katmanları yapıştırıldı; pano şunların görsellerini taşımıyordu: {layers}"
|
||||
"Remove these results from the file" = "Bu sonuçları dosyadan kaldır"
|
||||
"Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma"
|
||||
"Slot name" = "Yuva adı"
|
||||
"Mar" = "Mar"
|
||||
@@ -905,6 +911,8 @@ name = "Türkçe"
|
||||
"Not read yet" = "Henüz okunmadı"
|
||||
"Check for updates" = "Güncellemeleri denetle"
|
||||
"Not checked yet" = "Henüz denetlenmedi"
|
||||
"Someone who left" = "Ayrılan biri"
|
||||
"Take the results off the board; they stay in the file" = "Sonuçları panodan kaldır; dosyada kalır"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Bu dosya, burada kullanmadığınız sunuculardaki ekip kitaplıklarını kullanıyor. Siz denetleyene kadar bu sunuculara bağlanılmaz."
|
||||
"Not shared" = "Paylaşılmadı"
|
||||
"Note {n}" = "Not {n}"
|
||||
@@ -1731,6 +1739,7 @@ name = "Türkçe"
|
||||
"{name} · on a server" = "{name} · bir sunucuda"
|
||||
"{name} · published version {v}" = "{name} · yayımlanan sürüm {v}"
|
||||
"{name} · version {v} on the server" = "{name} · sunucuda sürüm {v}"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, canlı oturumda"
|
||||
"{name}, online, open only to you" = "{name}, çevrimiçi, yalnızca size açık"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, seçili"
|
||||
|
||||
@@ -18,6 +18,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last}"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Đã tồn tại tệp .figcraft có tên này. Hãy chọn tên đầy đủ của tệp để xác nhận thay thế."
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Một bộ sưu tập của tệp này với các chế độ và biến sau: ghi đè giá trị theo từng chế độ và thêm các biến riêng"
|
||||
"A later vote" = "Lần bình chọn sau"
|
||||
"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"
|
||||
@@ -101,6 +102,7 @@ status = "draft — needs native review"
|
||||
"Alphabetical" = "Theo bảng chữ cái"
|
||||
"Alt+drag" = "Alt+kéo"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Màn hình phân tích trên lưới: số liệu, biểu đồ vẽ bằng vector và bảng dữ liệu"
|
||||
"An earlier vote" = "Lần bình chọn trước"
|
||||
"An invite link that opens FigCraft and joins" = "Liên kết mời giúp mở FigCraft và tham gia ngay"
|
||||
"Anchor" = "Điểm neo"
|
||||
"Android" = "Android"
|
||||
@@ -336,6 +338,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "Tạo thành phần"
|
||||
"Create component property" = "Tạo thuộc tính thành phần"
|
||||
"Create components, styles or variables to publish them as a library." = "Hãy tạo các thành phần, kiểu hoặc biến để xuất bản thành thư viện."
|
||||
"Create from color" = "Tạo từ màu"
|
||||
"Create property…" = "Tạo thuộc tính…"
|
||||
"Create style" = "Tạo kiểu"
|
||||
"Create style from the selected text" = "Tạo kiểu từ văn bản đã chọn"
|
||||
@@ -372,6 +375,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "Xóa trang"
|
||||
"Delete preset" = "Xóa thiết lập sẵn"
|
||||
"Delete property" = "Xóa thuộc tính"
|
||||
"Delete results" = "Xóa kết quả"
|
||||
"Delete the style (layers keep their values)" = "Xóa kiểu (các lớp vẫn giữ nguyên giá trị)"
|
||||
"Delete thread" = "Xóa luồng bình luận"
|
||||
"Delete variable" = "Xóa biến"
|
||||
@@ -756,8 +760,10 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Trình đơn chính"
|
||||
"Make a component" = "Tạo thành phần"
|
||||
"Make slot" = "Tạo ô chứa"
|
||||
"New collection" = "Bộ sưu tập mới"
|
||||
"Pasted design layers" = "Các lớp thiết kế đã dán"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Đã dán các lớp thiết kế; bộ nhớ tạm không có hình ảnh của: {layers}"
|
||||
"Remove these results from the file" = "Xóa kết quả này khỏi tệp"
|
||||
"Scroll wheel zoom" = "Thu phóng bằng con lăn chuột"
|
||||
"Slot name" = "Tên ô chứa"
|
||||
"Mar" = "Thg 3"
|
||||
@@ -891,6 +897,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "Chưa đọc"
|
||||
"Check for updates" = "Kiểm tra cập nhật"
|
||||
"Not checked yet" = "Chưa kiểm tra"
|
||||
"Someone who left" = "Người đã rời đi"
|
||||
"Take the results off the board; they stay in the file" = "Gỡ kết quả khỏi bảng; vẫn giữ trong tệp"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tệp này dùng thư viện nhóm trên các máy chủ bạn chưa dùng ở đây. Các máy chủ đó không được liên hệ cho đến khi bạn kiểm tra."
|
||||
"Not shared" = "Chưa chia sẻ"
|
||||
"Note {n}" = "Ghi chú {n}"
|
||||
@@ -1708,6 +1716,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · trên máy chủ"
|
||||
"{name} · published version {v}" = "{name} · đã xuất bản phiên bản {v}"
|
||||
"{name} · version {v} on the server" = "{name} · phiên bản {v} trên máy chủ"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name}, trong phiên trực tiếp"
|
||||
"{name}, online, open only to you" = "{name}, trực tuyến, chỉ mình bạn mở được"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, đã chọn"
|
||||
|
||||
@@ -20,6 +20,7 @@ status = "draft — needs native review"
|
||||
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。"
|
||||
"A later vote" = "较新的投票"
|
||||
"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} 列:其余内容未粘贴"
|
||||
@@ -105,6 +106,7 @@ status = "draft — needs native review"
|
||||
"Along the top" = "沿顶边"
|
||||
"Alpha" = "Alpha"
|
||||
"Alt+drag" = "Alt+拖动"
|
||||
"An earlier vote" = "较早的投票"
|
||||
"An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接"
|
||||
"Anchor" = "锚点"
|
||||
"Android" = "Android"
|
||||
@@ -342,6 +344,7 @@ status = "draft — needs native review"
|
||||
"Create component" = "创建组件"
|
||||
"Create component property" = "创建组件属性"
|
||||
"Create components, styles or variables to publish them as a library." = "创建组件、样式或变量,即可将其发布为库。"
|
||||
"Create from color" = "从颜色创建"
|
||||
"Create property…" = "创建属性…"
|
||||
"Create style" = "创建样式"
|
||||
"Create style from the selected text" = "从所选文本创建样式"
|
||||
@@ -374,6 +377,7 @@ status = "draft — needs native review"
|
||||
"Delete page" = "删除页面"
|
||||
"Delete preset" = "删除预设"
|
||||
"Delete property" = "删除属性"
|
||||
"Delete results" = "删除结果"
|
||||
"Delete the style (layers keep their values)" = "删除样式(图层保留其值)"
|
||||
"Delete thread" = "删除讨论"
|
||||
"Delete variable" = "删除变量"
|
||||
@@ -744,8 +748,10 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "主组件已删除"
|
||||
"Main menu" = "主菜单"
|
||||
"Make slot" = "设为插槽"
|
||||
"New collection" = "新建集合"
|
||||
"Pasted design layers" = "粘贴的设计图层"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "已粘贴设计图层;剪贴板未包含以下图层的图像:{layers}"
|
||||
"Remove these results from the file" = "从文件中删除这些结果"
|
||||
"Scroll wheel zoom" = "滚轮缩放"
|
||||
"Slot name" = "插槽名称"
|
||||
"Margin" = "边距"
|
||||
@@ -876,6 +882,8 @@ status = "draft — needs native review"
|
||||
"Not read yet" = "尚未读取"
|
||||
"Check for updates" = "检查更新"
|
||||
"Not checked yet" = "尚未检查"
|
||||
"Someone who left" = "已离开的人"
|
||||
"Take the results off the board; they stay in the file" = "从白板上移除结果;结果仍保留在文件中"
|
||||
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此文件使用了你在此处未用过的服务器上的团队库。在你检查之前,不会连接这些服务器。"
|
||||
"Not shared" = "未分享"
|
||||
"Note {n}" = "备注 {n}"
|
||||
@@ -1684,6 +1692,7 @@ status = "draft — needs native review"
|
||||
"{name} · on a server" = "{name} · 在服务器上"
|
||||
"{name} · published version {v}" = "{name} · 已发布版本 {v}"
|
||||
"{name} · version {v} on the server" = "{name} · 服务器上的版本 {v}"
|
||||
"{name} ×{n}" = "{name} ×{n}"
|
||||
"{name}, in a live session" = "{name},正在实时会话中"
|
||||
"{name}, online, open only to you" = "{name},在线,只有你能打开"
|
||||
"{name}, {kind}, {w} by {h}, selected" = "{name},{kind},{w} × {h},已选中"
|
||||
|
||||
@@ -126,6 +126,7 @@ impl FigApp {
|
||||
}
|
||||
"dialog.preferences" => self.ui.preferences = true,
|
||||
"dialog.missingFonts" => missing_fonts::request(self),
|
||||
"dialog.createFromColor" => crate::sampled_color::request(self, &r.data),
|
||||
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
||||
"palette.actions" => menus::toggle_palette(&mut self.ui),
|
||||
"panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, RichText, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_doc::{BoardTimer, NodeId};
|
||||
use figcraft_doc::BoardTimer;
|
||||
use figcraft_engine::cmd::facilitate::{self, BADGE_PX};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -20,22 +20,22 @@ const PRESETS: [u32; 5] = [1, 3, 5, 10, 15];
|
||||
/// What a vote can be on, as `board.vote {on}` takes it.
|
||||
const SCOPES: [(&str, &str); 3] = [("all", tr_noop("All objects")), ("stickies", tr_noop("Sticky notes")), ("selection", tr_noop("Selection"))];
|
||||
|
||||
/// What the pill shows of the vote: none, running (your votes left) or ended (the results).
|
||||
/// What the pill shows of the vote: none running (with Results when any are kept or on the
|
||||
/// board), or running (your votes left).
|
||||
enum VoteView {
|
||||
Off,
|
||||
Off { results: bool },
|
||||
Running { left: u32 },
|
||||
Ended { results: Vec<(usize, NodeId, String, u32)> },
|
||||
}
|
||||
|
||||
fn vote_view(app: &FigApp) -> VoteView {
|
||||
let Ok(doc) = app.session.doc() else { return VoteView::Off };
|
||||
let Some(vote) = &doc.vote else { return VoteView::Off };
|
||||
if vote.ended {
|
||||
let results = facilitate::ranked(doc, vote).into_iter().map(|(rank, id, n)| (rank, id, facilitate::object_name(doc, id), n)).collect();
|
||||
return VoteView::Ended { results };
|
||||
let Ok(doc) = app.session.doc() else { return VoteView::Off { results: false } };
|
||||
match &doc.vote {
|
||||
Some(vote) if !vote.ended => {
|
||||
let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum();
|
||||
VoteView::Running { left: vote.per_person.saturating_sub(used) }
|
||||
}
|
||||
ended => VoteView::Off { results: ended.is_some() || !doc.vote_history.is_empty() },
|
||||
}
|
||||
let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum();
|
||||
VoteView::Running { left: vote.per_person.saturating_sub(used) }
|
||||
}
|
||||
|
||||
/// The pill at the top right of a board's canvas: the timer, then the vote.
|
||||
@@ -152,11 +152,20 @@ fn timer_popup(app: &mut FigApp, anchor: &egui::Response) {
|
||||
|
||||
fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &Tokens) {
|
||||
match vote {
|
||||
VoteView::Off => {
|
||||
VoteView::Off { results } => {
|
||||
let resp = icons::button(ui, Icon::Vote, CELL, false, tr("Voting session"));
|
||||
if editable {
|
||||
vote_popup(app, &resp);
|
||||
}
|
||||
// Earlier votes' results stay in the file until deleted.
|
||||
if results {
|
||||
let resp = widgets::button(ui, tr("Results"), false, true);
|
||||
if resp.clicked() {
|
||||
// Opening shows the latest vote's results.
|
||||
ui.data_mut(|d| d.remove::<usize>(egui::Id::unique("board_vote_session")));
|
||||
}
|
||||
results_popup(app, &resp, editable);
|
||||
}
|
||||
}
|
||||
VoteView::Running { left } => {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover());
|
||||
@@ -169,12 +178,6 @@ fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &
|
||||
app.run("board.vote", json!({"action": "end"}));
|
||||
}
|
||||
}
|
||||
VoteView::Ended { results } => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text);
|
||||
let resp = widgets::button(ui, tr("Results"), false, true);
|
||||
results_popup(app, &resp, &results, editable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,26 +216,106 @@ fn vote_popup(app: &mut FigApp, anchor: &egui::Response) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The ended vote's results, most votes first: a click on a row selects that object.
|
||||
fn results_popup(app: &mut FigApp, anchor: &egui::Response, results: &[(usize, NodeId, String, u32)], editable: bool) {
|
||||
crate::right::menu_popup(anchor).width(260.0).show(|ui| {
|
||||
/// Ended votes' results, newest first with arrows to earlier ones: each object, most votes first,
|
||||
/// with who voted for it (a click on a row selects the object). Clear takes the results on the
|
||||
/// board off it; Delete removes the results shown from the file.
|
||||
fn results_popup(app: &mut FigApp, anchor: &egui::Response, editable: bool) {
|
||||
// It stays open while you step through votes, pick objects or delete results.
|
||||
crate::right::menu_popup(anchor).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
// Built only while it shows.
|
||||
let sessions = facilitate::sessions(&app.session).unwrap_or_default();
|
||||
let pick_id = egui::Id::unique("board_vote_session");
|
||||
let pick = ui.data(|d| d.get_temp::<usize>(pick_id)).unwrap_or(0).min(sessions.len().saturating_sub(1));
|
||||
let secondary = Tokens::get(ui.ctx()).text_secondary;
|
||||
ui.add_space(2.0);
|
||||
ui.label(RichText::new(tr("Results")).font(semibold(12.0)));
|
||||
if results.is_empty() {
|
||||
widgets::label(ui, tr("No votes yet"), Tokens::get(ui.ctx()).text_secondary);
|
||||
if let Some(p) = results_header(ui, pick, sessions.len(), secondary) {
|
||||
ui.data_mut(|d| d.insert_temp(pick_id, p));
|
||||
}
|
||||
for (rank, id, name, n) in results {
|
||||
let label = format!("{rank}. {}", widgets::elide(ui, name, 160.0));
|
||||
if crate::right::menu_item(ui, &label, &trn("{n} votes", *n as usize, &[])) {
|
||||
app.run("select.set", json!({"ids": [id]}));
|
||||
}
|
||||
if sessions.get(pick).is_none_or(|s| s.rows.is_empty()) {
|
||||
widgets::label(ui, tr("No votes yet"), secondary);
|
||||
}
|
||||
let Some(session) = sessions.get(pick) else { return };
|
||||
for row in &session.rows {
|
||||
result_row(app, ui, row, secondary);
|
||||
}
|
||||
if editable {
|
||||
ui.add_space(4.0);
|
||||
if widgets::button(ui, tr("Clear results"), false, true).clicked() {
|
||||
app.run("board.vote", json!({"action": "clear"}));
|
||||
ui.close();
|
||||
results_footer(app, ui, session);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The popover's title and, with several votes, the "‹ n of m ›" stepper. Returns the vote an
|
||||
/// arrow stepped to.
|
||||
fn results_header(ui: &mut Ui, pick: usize, count: usize, secondary: Color32) -> Option<usize> {
|
||||
let mut step = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(tr("Results")).font(semibold(12.0)));
|
||||
if count > 1 {
|
||||
ui.add_space(8.0);
|
||||
// Newest first: "1 of 3" is the last vote, the right arrow goes further back. Both
|
||||
// arrows always show (one dimmed at an end), so the count stays put.
|
||||
if ui.add_enabled_ui(pick > 0, |ui| widgets::icon_button(ui, Icon::ChevronLeft, tr("A later vote"))).inner {
|
||||
step = pick.checked_sub(1);
|
||||
}
|
||||
let at = trf("{n} of {total}", &[("n", &(pick + 1)), ("total", &count)]);
|
||||
ui.label(RichText::new(at).font(regular(11.0)).color(secondary));
|
||||
if ui.add_enabled_ui(pick + 1 < count, |ui| widgets::icon_button(ui, Icon::ChevronRight, tr("An earlier vote"))).inner {
|
||||
step = Some(pick + 1).filter(|p| *p < count);
|
||||
}
|
||||
}
|
||||
});
|
||||
step
|
||||
}
|
||||
|
||||
/// One object in the results (a click selects it), with who voted for it under it.
|
||||
fn result_row(app: &mut FigApp, ui: &mut Ui, row: &facilitate::ResultRow, secondary: Color32) {
|
||||
let label = format!("{}. {}", row.rank, widgets::elide(ui, &row.name, 160.0));
|
||||
if crate::right::menu_item(ui, &label, &trn("{n} votes", row.votes as usize, &[])) {
|
||||
app.run("select.set", json!({"ids": [row.id]}));
|
||||
}
|
||||
let full = voters_line(row);
|
||||
let shown = widgets::elide(ui, &full, 236.0);
|
||||
// Close under its row, apart from the next one.
|
||||
ui.add_space(-8.0);
|
||||
let resp = ui.horizontal(|ui| {
|
||||
ui.add_space(8.0);
|
||||
widgets::label(ui, &shown, secondary);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
if shown != full {
|
||||
resp.response.on_hover_text(full);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Ada, Grace ×2": a person with several votes on the object says how many.
|
||||
fn voters_line(row: &facilitate::ResultRow) -> String {
|
||||
let who: Vec<String> = row
|
||||
.voters
|
||||
.iter()
|
||||
.map(|(_, name, n)| {
|
||||
let name = name.clone().unwrap_or_else(|| tr("Someone who left").to_string());
|
||||
if *n > 1 { trf("{name} ×{n}", &[("name", &name), ("n", &n.to_string())]) } else { name }
|
||||
})
|
||||
.collect();
|
||||
who.join(", ")
|
||||
}
|
||||
|
||||
/// Clear (while the results are on the board) and Delete (for kept results).
|
||||
fn results_footer(app: &mut FigApp, ui: &mut Ui, session: &facilitate::VoteSession) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if session.on_board
|
||||
&& widgets::button(ui, tr("Clear results"), false, true)
|
||||
.on_hover_text(tr("Take the results off the board; they stay in the file"))
|
||||
.clicked()
|
||||
{
|
||||
app.run("board.vote", json!({"action": "clear"}));
|
||||
ui.close();
|
||||
}
|
||||
if let Some(index) = session.index
|
||||
&& widgets::button(ui, tr("Delete results"), false, true).on_hover_text(tr("Remove these results from the file")).clicked()
|
||||
{
|
||||
app.run("board.vote", json!({"action": "delete", "index": index}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ pub struct ControlReply {
|
||||
/// Which side has finished a control request.
|
||||
///
|
||||
/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it
|
||||
/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may
|
||||
/// change it.
|
||||
/// (the transport's timeout) or commit it (the UI's final reply) or, for a request the UI answers
|
||||
/// at once, start it (`Running`). Once settled or started, the transport can't cancel it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum RequestState {
|
||||
#[default]
|
||||
@@ -58,6 +58,9 @@ pub enum RequestState {
|
||||
Cancelled,
|
||||
/// The UI wrote the file (if any) and queued the reply before releasing the gate.
|
||||
Replied,
|
||||
/// The UI started a request it answers at once (an edit, an inspection); the transport can no
|
||||
/// longer cancel it, so a timeout now means "still running, it will take effect" (#1673).
|
||||
Running,
|
||||
}
|
||||
|
||||
/// The gate the transport's timeout and the UI's final reply share for one request.
|
||||
@@ -102,6 +105,17 @@ impl RequestGate {
|
||||
*state
|
||||
}
|
||||
|
||||
/// Start a request the UI answers at once, unless the transport already gave up on it: then
|
||||
/// it must not run at all. `false` when it was cancelled (or already settled).
|
||||
pub fn start(&self) -> bool {
|
||||
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if *state != RequestState::Open {
|
||||
return false;
|
||||
}
|
||||
*state = RequestState::Running;
|
||||
true
|
||||
}
|
||||
|
||||
/// Run the final commit (file write and reply enqueue) only while the request is still open,
|
||||
/// then mark it replied before releasing the gate. `None` means the transport cancelled it or
|
||||
/// it was already committed; the closure must not have run then.
|
||||
@@ -131,6 +145,13 @@ impl ControlRequest {
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.state.is_cancelled()
|
||||
}
|
||||
|
||||
/// Mark the request started just before the UI runs it; `false` when the transport timed out
|
||||
/// first, and then it must not run. `ui.input` is acknowledged by a later frame, so it stays
|
||||
/// open (cancellable) like a screenshot.
|
||||
fn begin(&self) -> bool {
|
||||
self.method == "ui.input" || self.state.start()
|
||||
}
|
||||
}
|
||||
|
||||
/// One screenshot's completion slot.
|
||||
@@ -519,6 +540,9 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
|
||||
let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) });
|
||||
continue;
|
||||
}
|
||||
if !r.begin() {
|
||||
continue;
|
||||
}
|
||||
// Outside a frame, so outside the frame's guard: within the window's swap, a panic still
|
||||
// ends in recovery copies and leaves every window its own state.
|
||||
let res = crate::windows::as_window(app, target, |app| {
|
||||
@@ -719,6 +743,9 @@ fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now
|
||||
admit(app, r, target, now);
|
||||
return;
|
||||
}
|
||||
if !r.begin() {
|
||||
return;
|
||||
}
|
||||
// Every control-channel gesture is fenced from the user's system clipboard (the flag is
|
||||
// restored so a batch already in a frame's raw input keeps its own provenance).
|
||||
let before = app.control_synthetic;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//! The stroke settings' Dash and Gap fields edit the first pair of a dash pattern and keep the
|
||||
//! rest of it (#1681): an imported `[4, 2, 1, 2]` used to collapse to `[dash, gap]` on the first edit.
|
||||
|
||||
use egui::{Event, Id, Key, Modifiers};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A selected rectangle with a stroke dashed `dashes`, its advanced stroke settings shown.
|
||||
fn scene(dashes: serde_json::Value) -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2, "dashes": dashes}})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::unique("stroke_adv"), true));
|
||||
u.frame(vec![]);
|
||||
(u, r)
|
||||
}
|
||||
|
||||
fn dashes(u: &Ui, r: NodeId) -> Vec<f64> {
|
||||
u.app.session.doc().unwrap().node(r).unwrap().stroke.dashes.clone()
|
||||
}
|
||||
|
||||
/// Type `text` into the number field `id` and press Enter.
|
||||
fn type_into(u: &mut Ui, id: &str, text: &str) {
|
||||
let at = u.ctx.read_response(Id::unique(id).with("field")).unwrap_or_else(|| panic!("{id} shows")).rect.center();
|
||||
u.button(at, true);
|
||||
u.button(at, false);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::Text(text.into())]);
|
||||
u.frame(vec![
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
|
||||
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_the_dash_or_gap_keeps_the_rest_of_the_pattern() {
|
||||
let (mut u, r) = scene(json!([4, 2, 1, 2]));
|
||||
type_into(&mut u, "stroke_dash", "6");
|
||||
assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 2.0]);
|
||||
type_into(&mut u, "stroke_gap", "3");
|
||||
assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_single_value_pattern_is_the_dash_and_the_gap() {
|
||||
let (mut u, r) = scene(json!([5]));
|
||||
type_into(&mut u, "stroke_gap", "2");
|
||||
assert_eq!(dashes(&u, r), [5.0, 2.0]);
|
||||
}
|
||||
|
||||
/// An odd-length pattern draws twice over (`[4, 2, 1]` is `[4, 2, 1, 4, 2, 1]`): editing its Dash
|
||||
/// writes the repeat out, so only the first dash changes and every gap stays as drawn.
|
||||
#[test]
|
||||
fn an_odd_length_pattern_keeps_its_repeat() {
|
||||
let (mut u, r) = scene(json!([4, 2, 1]));
|
||||
type_into(&mut u, "stroke_dash", "6");
|
||||
assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 4.0, 2.0, 1.0]);
|
||||
type_into(&mut u, "stroke_gap", "3");
|
||||
assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 4.0, 2.0, 1.0]);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ pub mod credits;
|
||||
pub mod import_report;
|
||||
pub mod missing_fonts;
|
||||
pub mod plugins_ui;
|
||||
pub mod sampled_color;
|
||||
pub mod updates_ui;
|
||||
pub mod variables_panel;
|
||||
|
||||
@@ -209,6 +210,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
crate::pending_open::pending_opens(app, ctx);
|
||||
crate::import_report::show(app, ctx);
|
||||
crate::missing_fonts::show(app, ctx);
|
||||
crate::sampled_color::show(app, ctx);
|
||||
let t = Tokens::get(ctx);
|
||||
if app.ui.about {
|
||||
let mut open = true;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! A colour picked with the eyedropper while holding Cmd/Ctrl+Shift becomes a colour style or a
|
||||
//! colour variable, as in the reference app: the eyedropper asks for this window
|
||||
//! (`dialog.createFromColor`), which asks which to make, what to call it and (a variable) in which
|
||||
//! collection, then runs `style.create` or `variable.create`.
|
||||
|
||||
use egui::{Context, RichText};
|
||||
use figcraft_color::{Color, Paint};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::window;
|
||||
use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// The window's state: the colour, what to make of it and its name.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SampledColor {
|
||||
/// The document it was picked in: the window goes when another is in front.
|
||||
pub uid: u64,
|
||||
/// `#rrggbb`.
|
||||
pub color: String,
|
||||
/// A variable (else a style).
|
||||
pub variable: bool,
|
||||
pub name: String,
|
||||
/// The local collection a variable goes in (`None`: a new one).
|
||||
pub collection: Option<String>,
|
||||
}
|
||||
|
||||
/// `dialog.createFromColor {color}` from the eyedropper: the window for the document in front, with
|
||||
/// the next free style name ("Color 1"…) and its first collection.
|
||||
pub fn request(app: &mut FigApp, data: &Value) {
|
||||
let Some(color) = data.get("color").and_then(Value::as_str).and_then(Color::from_hex) else { return };
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let collection = figcraft_engine::library::local_collections(&ds.doc).next().map(|c| c.id.clone());
|
||||
app.ui.sampled_color = Some(SampledColor {
|
||||
uid: ds.uid,
|
||||
color: color.to_css_hex(),
|
||||
variable: false,
|
||||
name: crate::style_picker::next_name(&ds.doc, "Color"),
|
||||
collection,
|
||||
});
|
||||
}
|
||||
|
||||
/// The command that makes what `s` asks for: a paint style, a colour variable in its collection,
|
||||
/// or (no collection) a new collection and the variable in it, one undo step.
|
||||
pub fn create_params(app: &FigApp, s: &SampledColor) -> (&'static str, Value) {
|
||||
let name = s.name.trim();
|
||||
if !s.variable {
|
||||
return ("style.create", json!({"type": "paint", "name": name, "value": s.color}));
|
||||
}
|
||||
let variable = |collection: Value| json!({"collection": collection, "name": name, "type": "color", "value": s.color});
|
||||
match &s.collection {
|
||||
Some(c) => ("variable.create", variable(json!(c))),
|
||||
None => {
|
||||
let taken = |n: &str| app.session.doc().is_ok_and(|d| d.variables.collections.iter().any(|c| c.name == n));
|
||||
let collection = crate::variables_panel::unique("Collection", taken);
|
||||
let new = json!({"command": "variable.createCollection", "params": {"name": collection, "modes": ["Mode 1"]}, "as": "collection"});
|
||||
let add = json!({"command": "variable.create", "params": variable(json!({"$ref": "collection"}))});
|
||||
("command.batch", json!({"label": "Create variable", "commands": [new, add]}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A variable's collection: one of the file's, or a new one.
|
||||
fn collection_picker(ui: &mut egui::Ui, s: &mut SampledColor, collections: &[(String, String)]) {
|
||||
let shown = s.collection.as_ref().and_then(|id| collections.iter().find(|c| &c.0 == id)).map_or(tr("New collection"), |c| c.1.as_str());
|
||||
let resp = widgets::dropdown(ui, shown, 260.0, None);
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for (id, label) in collections {
|
||||
if crate::right::menu_item(ui, label, "") {
|
||||
s.collection = Some(id.clone());
|
||||
}
|
||||
}
|
||||
if crate::right::menu_item(ui, tr("New collection"), "") {
|
||||
s.collection = None;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The window: a swatch of the colour, Style or Variable, its name, a variable's collection,
|
||||
/// Cancel and Create (Enter creates).
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
let Some(mut s) = app.ui.sampled_color.clone() else { return };
|
||||
if app.session.ds().map(|d| d.uid).ok() != Some(s.uid) {
|
||||
app.ui.sampled_color = None;
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let collections: Vec<(String, String)> = app
|
||||
.session
|
||||
.doc()
|
||||
.map(|d| figcraft_engine::library::local_collections(d).map(|c| (c.id.clone(), c.name.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
let (mut open, mut cancel, mut create) = (true, false, false);
|
||||
window(tr("Create from color"), ctx).id(egui::Id::unique("Create from color")).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(280.0);
|
||||
ui.horizontal(|ui| {
|
||||
let paint = Color::from_hex(&s.color).map(Paint::solid);
|
||||
if let Some(p) = &paint {
|
||||
widgets::swatch(ui, p, 24.0);
|
||||
}
|
||||
ui.label(RichText::new(s.color.trim_start_matches('#').to_uppercase()).color(t.text));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if widgets::radio(ui, !s.variable, tr("Style")) {
|
||||
s.variable = false;
|
||||
}
|
||||
if widgets::radio(ui, s.variable, tr("Variable")) {
|
||||
s.variable = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let name = ui.add(egui::TextEdit::singleline(&mut s.name).hint_text(tr("Name")).desired_width(260.0));
|
||||
widgets::select_all_on_focus(ui, &name, &s.name);
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
name.request_focus();
|
||||
}
|
||||
if s.variable {
|
||||
ui.add_space(6.0);
|
||||
collection_picker(ui, &mut s, &collections);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let ready = !s.name.trim().is_empty();
|
||||
ui.horizontal(|ui| {
|
||||
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready;
|
||||
});
|
||||
});
|
||||
let made = create && {
|
||||
let (cmd, params) = create_params(app, &s);
|
||||
app.run(cmd, params).is_some()
|
||||
};
|
||||
// A name already taken leaves the window open to change it (the error says why).
|
||||
app.ui.sampled_color = (open && !cancel && !made).then_some(s);
|
||||
}
|
||||
@@ -281,7 +281,7 @@ fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect
|
||||
}
|
||||
|
||||
/// The first of `base`, `base 2`, `base 3`… that `taken` doesn't hold.
|
||||
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
pub(crate) fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
(1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ fn export_rows_offer_a_size_for_images_only() {
|
||||
}
|
||||
|
||||
/// The text painted this frame, with the centre of each piece.
|
||||
fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
|
||||
pub(crate) fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
|
||||
@@ -112,7 +112,7 @@ fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
|
||||
out
|
||||
}
|
||||
|
||||
fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) {
|
||||
pub(crate) fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) {
|
||||
u.frame(vec![egui::Event::PointerMoved(p)]);
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
|
||||
@@ -344,6 +344,47 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() {
|
||||
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL);
|
||||
}
|
||||
|
||||
/// #1673: the UI runs a queued request only if the transport hasn't given up on it, and marks it
|
||||
/// started first, so a timeout can tell "not applied" from "still running". Both from a frame and
|
||||
/// from a hidden window's logic pass.
|
||||
#[test]
|
||||
fn a_request_the_transport_gave_up_on_is_never_applied() {
|
||||
let (mut h, text) = document();
|
||||
h.settle();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let rename = |id: u64, name: &str| ControlRequest {
|
||||
id: json!(id),
|
||||
method: "engine.execute".into(),
|
||||
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
|
||||
reply: tx.clone(),
|
||||
state: Default::default(),
|
||||
};
|
||||
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
|
||||
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
|
||||
let before = name(&h);
|
||||
let mut hidden = RawInput::default();
|
||||
hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() });
|
||||
for on_screen in [true, false] {
|
||||
let gave_up = rename(1, "late");
|
||||
gave_up.state.cancel_if_open();
|
||||
let wanted = rename(2, "wanted");
|
||||
let gate = wanted.state.clone();
|
||||
h.app.control_queue.extend([gave_up, wanted]);
|
||||
if on_screen {
|
||||
h.frame(vec![]);
|
||||
} else {
|
||||
let ctx = h.ctx.clone();
|
||||
let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx));
|
||||
}
|
||||
let reply = rx.try_recv().expect("the wanted request is answered");
|
||||
assert_eq!(reply.id, json!(2), "on screen: {on_screen}");
|
||||
assert!(rx.try_recv().is_err(), "nothing answers the cancelled one");
|
||||
assert_eq!(gate.state(), crate::RequestState::Running, "started before it ran");
|
||||
assert_eq!(name(&h), "wanted");
|
||||
h.app.session.execute("node.set", &json!({"ids": [text.clone()], "props": {"name": before.clone()}})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Asserts a hidden window answers edits and inspection at once, while a screenshot is
|
||||
/// parked with a deadline and times out once instead of waiting for a frame.
|
||||
#[test]
|
||||
|
||||
@@ -90,6 +90,8 @@ mod crop_image_tests;
|
||||
#[cfg(test)]
|
||||
mod cursors_tests;
|
||||
#[cfg(test)]
|
||||
mod dash_pattern_tests;
|
||||
#[cfg(test)]
|
||||
mod design_paste_tests;
|
||||
#[cfg(test)]
|
||||
mod dev_mode_tests;
|
||||
@@ -221,6 +223,8 @@ mod proto_responsive_tests;
|
||||
#[cfg(test)]
|
||||
mod resources_tests;
|
||||
#[cfg(test)]
|
||||
mod sampled_color_tests;
|
||||
#[cfg(test)]
|
||||
mod save_as_tests;
|
||||
#[cfg(test)]
|
||||
mod select_all_tests;
|
||||
@@ -257,6 +261,10 @@ mod vector_panel_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_tail_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod vote_history_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod vote_voters_tests;
|
||||
#[cfg(test)]
|
||||
mod web_clipboard_tests;
|
||||
#[cfg(test)]
|
||||
mod web_save_tests;
|
||||
@@ -284,6 +292,7 @@ pub use dialogs::credits;
|
||||
pub use dialogs::import_report;
|
||||
pub use dialogs::missing_fonts;
|
||||
pub use dialogs::plugins_ui;
|
||||
pub use dialogs::sampled_color;
|
||||
pub use dialogs::updates_ui;
|
||||
pub use dialogs::variables_panel;
|
||||
pub use home::home_recents;
|
||||
@@ -498,6 +507,8 @@ pub struct UiState {
|
||||
/// Files the user opened whose fonts are yet to be checked (by document uid).
|
||||
pub font_checks: Vec<u64>,
|
||||
pub missing_fonts: Option<missing_fonts::Shown>,
|
||||
/// The eyedropper's Cmd/Ctrl+Shift pick, to make a colour style or variable of.
|
||||
pub sampled_color: Option<sampled_color::SampledColor>,
|
||||
pub screenshot_requests: Vec<(u64, String)>,
|
||||
pub frame_ms: f64,
|
||||
pub render_ms: f64,
|
||||
@@ -665,6 +676,7 @@ impl Default for UiState {
|
||||
import_report: None,
|
||||
font_checks: Vec::new(),
|
||||
missing_fonts: None,
|
||||
sampled_color: None,
|
||||
screenshot_requests: Vec::new(),
|
||||
frame_ms: 0.0,
|
||||
render_ms: 0.0,
|
||||
|
||||
@@ -187,6 +187,22 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option
|
||||
});
|
||||
}
|
||||
|
||||
/// Dash pattern `dashes` with its first dash (`i` 0) or first gap (`i` 1) set to `v`, keeping the
|
||||
/// rest of the pattern: the panel shows only the first pair, and editing it must not collapse an
|
||||
/// imported `[4, 2, 1, 2]` to two values (#1681). An odd-length pattern is drawn repeated
|
||||
/// (`figcraft_pathops::width::dash_pattern`: `[4, 2, 1]` is `[4, 2, 1, 4, 2, 1]`), so it is
|
||||
/// written out that way first and only the edited entry changes; `[5]` becomes `[5, 5]`.
|
||||
fn set_dash(dashes: &[f64], i: usize, v: f64) -> Vec<f64> {
|
||||
let mut d = if dashes.is_empty() { vec![4.0] } else { dashes.to_vec() };
|
||||
if d.len() % 2 == 1 {
|
||||
d = d.repeat(2);
|
||||
}
|
||||
if let Some(x) = d.get_mut(i) {
|
||||
*x = v.max(0.0);
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
|
||||
pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let ids = sel.to_vec();
|
||||
@@ -214,9 +230,9 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
|
||||
widgets::sublabels(ui, tr("Dash"), Some(tr("Gap")));
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::unique("stroke_dash"), Lead::None, Some(dash), "", half, 1.0);
|
||||
app.field(&ids, "dashes", e, |v| json!([v.max(0.0), gap]));
|
||||
app.field(&ids, "dashes", e, |v| json!(set_dash(&d, 0, v)));
|
||||
let e = widgets::number_field(ui, Id::unique("stroke_gap"), Lead::None, Some(gap), "", half, 1.0);
|
||||
app.field(&ids, "dashes", e, |v| json!([dash, v.max(0.0)]));
|
||||
app.field(&ids, "dashes", e, |v| json!(set_dash(&d, 1, v)));
|
||||
});
|
||||
}
|
||||
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! The window that makes a colour the eyedropper picked (with Cmd/Ctrl+Shift) into a colour style
|
||||
//! or variable: it opens from the eyedropper's request, Create makes what it says, and a variable
|
||||
//! goes into the chosen collection or, with none, a new one, in one undo step.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::export_preview_tests::{click, labelled};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::sampled_color::{SampledColor, create_params};
|
||||
|
||||
fn opened(s: figcraft_engine::Session) -> Ui {
|
||||
let mut u = Ui::new(s);
|
||||
u.app.session.request_ui("dialog.createFromColor", json!({"color": "#ff8800"}));
|
||||
u.app.handle_ui_requests();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
u
|
||||
}
|
||||
|
||||
fn press(u: &mut Ui, text: &str) {
|
||||
let shown = labelled(u);
|
||||
let at = shown.iter().find(|(t, _)| t == text).map(|x| x.1).unwrap_or_else(|| panic!("{text:?} in {shown:?}"));
|
||||
click(u, at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eyedroppers_request_opens_the_window_and_create_makes_a_style() {
|
||||
let mut u = opened(figcraft_engine::Session::new());
|
||||
let s = u.app.ui.sampled_color.clone().expect("the window is open");
|
||||
assert_eq!((s.color.as_str(), s.variable, s.name.as_str()), ("#FF8800", false, "Color 1"));
|
||||
let texts: Vec<String> = labelled(&mut u).into_iter().map(|(t, _)| t).collect();
|
||||
assert!(texts.iter().any(|t| t == "Create from color") && texts.iter().any(|t| t == "FF8800"), "{texts:?}");
|
||||
press(&mut u, "Create");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let style = doc.styles.iter().find(|s| s.name == "Color 1").expect("a style");
|
||||
let colour = match &style.kind {
|
||||
figcraft_doc::StyleKind::Paint { paints } => paints.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(colour.as_deref(), Some("FF8800"), "a paint style of the colour: {style:?}");
|
||||
assert!(u.app.ui.sampled_color.is_none(), "it closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_variable_goes_into_a_new_or_the_chosen_collection() {
|
||||
// No collection yet: one is made with it, one undo step.
|
||||
let mut u = opened(figcraft_engine::Session::new());
|
||||
let undo = u.app.session.undo_depth().0;
|
||||
if let Some(s) = u.app.ui.sampled_color.as_mut() {
|
||||
s.variable = true;
|
||||
s.name = "Accent".into();
|
||||
}
|
||||
u.frame(vec![]);
|
||||
press(&mut u, "Create");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let v = doc.variables.variables.iter().find(|v| v.name == "Accent").expect("a variable");
|
||||
let c = doc.variables.collection(&v.collection).expect("its collection");
|
||||
assert_eq!(c.name, "Collection");
|
||||
assert_eq!(u.app.session.undo_depth().0, undo + 1, "one step");
|
||||
// With collections: the first is chosen, and the variable goes there.
|
||||
let mut s = figcraft_engine::Session::new();
|
||||
s.execute("variable.createCollection", &json!({"name": "Brand"})).unwrap();
|
||||
let u = opened(s);
|
||||
let state = u.app.ui.sampled_color.clone().unwrap();
|
||||
let brand = u.app.session.doc().unwrap().variables.collections[0].id.clone();
|
||||
assert_eq!(state.collection.as_deref(), Some(brand.as_str()));
|
||||
let (cmd, params) = create_params(&u.app, &SampledColor { variable: true, name: "Accent".into(), ..state });
|
||||
assert_eq!((cmd, params["collection"].as_str()), ("variable.create", Some(brand.as_str())));
|
||||
assert_eq!((params["type"].as_str(), params["value"].as_str()), (Some("color"), Some("#FF8800")));
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The Results popover steps through the votes kept in the file, newest first, and deletes the one
|
||||
//! it shows; Results stays on the pill while any are kept, after they're cleared off the board.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::export_preview_tests::{click, labelled};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A board where "Ship it" won a first vote and "Wait" a second one, both kept, then cleared off
|
||||
/// the board.
|
||||
fn two_votes() -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
let sticky =
|
||||
|s: &mut Session, x: i32, text: &str| crate::present_tests::id(&s.execute("board.sticky", &json!({"x": x, "y": 0, "text": text})).unwrap());
|
||||
let (a, b) = (sticky(&mut s, 0, "Ship it"), sticky(&mut s, 300, "Wait"));
|
||||
for on in [a, b] {
|
||||
s.execute("board.vote", &json!({"action": "start"})).unwrap();
|
||||
s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": "Ada"})).unwrap();
|
||||
s.execute("board.vote", &json!({"action": "end"})).unwrap();
|
||||
}
|
||||
s.execute("board.vote", &json!({"action": "clear"})).unwrap();
|
||||
Ui::new(s)
|
||||
}
|
||||
|
||||
fn texts(u: &mut Ui) -> Vec<String> {
|
||||
labelled(u).into_iter().map(|(t, _)| t).collect()
|
||||
}
|
||||
|
||||
fn at(u: &mut Ui, text: &str) -> egui::Pos2 {
|
||||
let shown = labelled(u);
|
||||
shown.iter().find(|(t, _)| t == text).map(|x| x.1).unwrap_or_else(|| panic!("{text:?} in {shown:?}"))
|
||||
}
|
||||
|
||||
/// Where the text `text` is drawn.
|
||||
fn rect_of(u: &mut Ui, text: &str) -> egui::Rect {
|
||||
fn walk(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
|
||||
match shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2())),
|
||||
egui::Shape::Vec(v) => v.iter().find_map(|s| walk(s, text)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
u.frame(vec![]);
|
||||
let frame = u.frame_output(vec![]);
|
||||
frame.shapes.iter().find_map(|c| walk(&c.shape, text)).unwrap_or_else(|| panic!("no {text:?}"))
|
||||
}
|
||||
|
||||
/// Clicks the stepper's left (later vote) or right (earlier vote) arrow, either side of "n of m".
|
||||
fn step(u: &mut Ui, now: &str, right: bool) {
|
||||
let r = rect_of(u, now);
|
||||
// The 24-pixel arrows sit next to the count.
|
||||
let x = if right { r.max.x + 14.0 } else { r.min.x - 14.0 };
|
||||
click(u, egui::pos2(x, r.center().y));
|
||||
}
|
||||
|
||||
fn has(u: &mut Ui, text: &str) -> bool {
|
||||
texts(u).iter().any(|t| t == text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_results_step_through_kept_votes_and_delete_one() {
|
||||
let mut u = two_votes();
|
||||
let results = at(&mut u, "Results");
|
||||
click(&mut u, results);
|
||||
let shown = texts(&mut u);
|
||||
assert!(shown.iter().any(|t| t == "1 of 2"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "1. Wait"), "the latest first: {shown:?}");
|
||||
assert!(!shown.iter().any(|t| t == "Clear results"), "nothing on the board to clear");
|
||||
// At either end that arrow is dimmed and does nothing; the other steps.
|
||||
step(&mut u, "1 of 2", false);
|
||||
assert!(has(&mut u, "1 of 2"), "no later vote than the latest");
|
||||
step(&mut u, "1 of 2", true);
|
||||
assert!(has(&mut u, "2 of 2") && has(&mut u, "1. Ship it"), "the earlier vote");
|
||||
step(&mut u, "2 of 2", true);
|
||||
assert!(has(&mut u, "2 of 2"), "no earlier vote than the first");
|
||||
step(&mut u, "2 of 2", false);
|
||||
assert!(has(&mut u, "1 of 2") && has(&mut u, "1. Wait"), "back to the latest");
|
||||
step(&mut u, "1 of 2", true);
|
||||
// A row selects its object.
|
||||
let row = at(&mut u, "1. Ship it");
|
||||
click(&mut u, row);
|
||||
let earlier = figcraft_engine::cmd::facilitate::sessions(&u.app.session).unwrap()[1].rows[0].id;
|
||||
assert_eq!(u.app.session.selection(), [earlier]);
|
||||
let delete = at(&mut u, "Delete results");
|
||||
click(&mut u, delete);
|
||||
assert_eq!(u.app.session.doc().unwrap().vote_history.len(), 1, "the earlier vote is deleted");
|
||||
// It stays open, on what's left.
|
||||
let shown = texts(&mut u);
|
||||
assert!(shown.iter().any(|t| t == "1. Wait") && !shown.iter().any(|t| t == "1 of 2"), "one left: {shown:?}");
|
||||
let delete = at(&mut u, "Delete results");
|
||||
click(&mut u, delete);
|
||||
assert!(u.app.session.doc().unwrap().vote_history.is_empty() && !has(&mut u, "Results"), "none left, and no Results to open");
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! An ended vote's results say who voted for each object (as the reference board app shows how
|
||||
//! each participant voted once a vote ends); while it runs, nobody's votes but your own show.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::export_preview_tests::{click, labelled};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A board with two stickies voted on by Ada (twice on the first) and Bo (once on each).
|
||||
fn voted() -> Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newBoard", &json!({})).unwrap();
|
||||
let sticky =
|
||||
|s: &mut Session, x: i32, text: &str| crate::present_tests::id(&s.execute("board.sticky", &json!({"x": x, "y": 0, "text": text})).unwrap());
|
||||
let (a, b) = (sticky(&mut s, 0, "Ship it"), sticky(&mut s, 300, "Wait"));
|
||||
s.execute("board.vote", &json!({"action": "start", "votes": 2})).unwrap();
|
||||
for (who, on) in [("Ada", a), ("Ada", a), ("Bo", a), ("Bo", b)] {
|
||||
s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who})).unwrap();
|
||||
}
|
||||
Ui::new(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_results_say_who_voted_for_each_object() {
|
||||
let mut u = voted();
|
||||
let running = labelled(&mut u);
|
||||
assert!(!running.iter().any(|(t, _)| t.contains("Ada")), "nobody's names while it runs");
|
||||
u.app.run("board.vote", json!({"action": "end"}));
|
||||
let shown = labelled(&mut u);
|
||||
let results = shown.iter().find(|(t, _)| t == "Results").map(|r| r.1).unwrap_or_else(|| panic!("a Results button in {shown:?}"));
|
||||
click(&mut u, results);
|
||||
let texts: Vec<String> = labelled(&mut u).into_iter().map(|(t, _)| t).collect();
|
||||
let row = |name: &str| texts.iter().position(|t| t.ends_with(name)).unwrap_or_else(|| panic!("a row for {name} in {texts:?}"));
|
||||
let (first, second) = (row("Ship it"), row("Wait"));
|
||||
assert!(first < second, "most votes first");
|
||||
assert!(texts[first..second].iter().any(|t| t == "Ada ×2, Bo"), "under the first: {texts:?}");
|
||||
assert!(texts[second..].iter().any(|t| t == "Bo"), "under the second: {texts:?}");
|
||||
// Clear takes the results off the board and keeps them in the file.
|
||||
let shown = labelled(&mut u);
|
||||
let clear = shown.iter().find(|(t, _)| t == "Clear results").map(|r| r.1).unwrap_or_else(|| panic!("Clear results in {shown:?}"));
|
||||
click(&mut u, clear);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(doc.vote.is_none() && doc.vote_history.len() == 1, "cleared off the board, kept in the file");
|
||||
}
|
||||
@@ -333,7 +333,7 @@ fn leaf(name: &str) -> &str {
|
||||
}
|
||||
|
||||
/// `base N` with the smallest N no style of the document is named.
|
||||
fn next_name(doc: &Document, base: &str) -> String {
|
||||
pub(crate) fn next_name(doc: &Document, base: &str) -> String {
|
||||
(1..).map(|n| format!("{base} {n}")).find(|n| doc.styles.iter().all(|s| &s.name != n)).unwrap_or_else(|| base.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boards parity
|
||||
|
||||
> **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)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; 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
|
||||
@@ -49,7 +49,7 @@ all end with the same geometry.
|
||||
| Timer | done | a pill at the top right of the board: presets (1, 3, 5, 10, 15 min) or any whole minutes, pause, resume, a minute more, stop; red at 0:00 until stopped; no sound. `board.timer {action, minutes?}`, `board.timerState`. Synced as its end time (or the time left while paused), so every client counts down on its own clock: nothing ticks over the network. Not an undo step |
|
||||
| Timer music, timer sounds | missing | |
|
||||
| Voting sessions | done | one at a time: votes per person (1–99), on every object, on sticky notes or on the selection; a click on an object places a vote (several on one object allowed), a click on your badge or an Alt-click takes it back; your own votes show as badges while it runs, everyone's once it ends, with a ranked list (ties share a rank) that selects an object from its row; Clear results. `board.vote {action: start\|cast\|retract\|end\|clear}`, `board.voteResults`. Ballots sync per voter (keyed by the live-session client id, or your name offline), so people voting at once never overwrite each other. Not undo steps |
|
||||
| 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 |
|
||||
| Voting results: who voted for what, earlier sessions' results | partial | once a vote ends, its results list who voted for each object and how many times (by name in a live session, kept with the results so someone who has left still shows by name), as the reference app shows how each participant voted; each ended vote's results are kept in the file (the last 20), listed newest first in the Results popover with arrows to earlier ones, and deleted one at a time (`board.vote {action: delete, index}`; Clear results only takes them off the board), as the reference app keeps each session's results until deleted; saved, synced in live sessions, and like the vote not undo steps. Anyone who can edit the board ends or clears a vote, as the reference app's help describes. A vote has no prompt (question) of its own |
|
||||
| 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?}`; 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 |
|
||||
@@ -64,6 +64,8 @@ all end with the same geometry.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. |
|
||||
| 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). |
|
||||
| 2026-10-30 | minor | The object a stamp will stick to is outlined while the stamp tool is over it and while a stamp is dragged over it, as the incumbent highlights it; a drag no longer drops anything into a sticky, shape with text or table (board objects take no layers in). Numbers unchanged |
|
||||
| 2026-10-30 | minor | Stamps stick to the object they land on and move with it, as the incumbent's help describes; dragged onto another object they stick to that one, and they stay put, on nothing, when their object is deleted (the incumbent's help doesn't say). Numbers unchanged |
|
||||
| 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 |
|
||||
|
||||
@@ -162,9 +162,9 @@ As built:
|
||||
logged replacement or removal is left out too — rather than failing, and a file published to a
|
||||
server is kept without them; a restarted server that left image entries out, or whose saved log
|
||||
puts images, catches every resuming client up with its snapshot instead of that log); a
|
||||
board's timer and vote (whole values, `DocProp::Timer` and
|
||||
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
|
||||
at once write different entries). The timer travels as its end time (or, paused, the time left),
|
||||
board's timer, vote and the results of earlier votes (whole values, `DocProp::Timer`,
|
||||
`DocProp::Vote` and `DocProp::VoteHistory`; the app keeps the last 20 votes' results) and the vote's ballots
|
||||
(`Entry::Ballot(voter)`, one per voter, so people voting at once write different entries). The timer travels as its end time (or, paused, the time left),
|
||||
never as ticks: each client counts down on its own clock. The page order is the pages'
|
||||
positions.
|
||||
- **Next**: this file's published library (it embeds a snapshot document; it moves with an asset
|
||||
@@ -272,7 +272,7 @@ Server → client:
|
||||
|
||||
| Message | Meaning |
|
||||
|---|---|
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit`, `view` (a view link: every batch is refused) or `comment` (a comment link: a batch is refused unless every change in it is to comment threads: a thread's `put` or `remove`, their order or the whole list). |
|
||||
| `Welcome {session, client_id, seq, catch_up: Snapshot, Delta or Packed, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; `Packed` is the full document compressed (deflate of the snapshot's postcard bytes), sent only to clients whose hello announced `packed`, which read it back within the size an uncompressed welcome could have had; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit`, `view` (a view link: every batch is refused) or `comment` (a comment link: a batch is refused unless every change in it is to comment threads: a thread's `put` or `remove`, their order or the whole list). |
|
||||
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
|
||||
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
|
||||
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
|
||||
@@ -910,7 +910,10 @@ Decisions, as built:
|
||||
10 000, `max_log_changes`, default 1 000 000, and `max_log_bytes`, default 64 MiB, which a
|
||||
desktop host uses as they are and `figcraft-server` takes from its `max_log_mb`). If every batch
|
||||
after the client's sequence number is still in it, the welcome carries them (`CatchUp::Delta`);
|
||||
otherwise the whole document (`CatchUp::Snapshot`).
|
||||
otherwise the whole document (`CatchUp::Snapshot`), compressed for a client whose hello
|
||||
announced `packed` (`CatchUp::Packed`, `message::pack` / `unpack`: deflate at its fastest level
|
||||
on the hub's thread; a document's changes shrink about sevenfold, e.g. 397 KB to 53 KB for 4 040
|
||||
layers). Older clients, and servers from before, keep the uncompressed snapshot.
|
||||
- **What happened to the batches in flight.** The server remembers per client the last batch id it
|
||||
processed and the latest refused ids (at most 1 024). Batches up to that id that the catch-up
|
||||
doesn't confirm were refused (the user is told); the others are sent again, in order, keeping
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. |
|
||||
|
||||
@@ -17,7 +17,11 @@ reply: {"id": 1, "ok": true, "result": ...}
|
||||
```
|
||||
|
||||
Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
|
||||
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
|
||||
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error that says what
|
||||
happened to it: one the app never started (it was busy, or its window wasn't drawing) was dropped
|
||||
and not applied; one it had started (a long `batch`, say) can't be stopped, so the error says it is
|
||||
still running and will take effect, and to check the document before retrying; only `ui.screenshot`
|
||||
and `ui.input`, which need a drawn frame, point at the window being minimised or covered. A line that is
|
||||
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}` and ends the connection (so an
|
||||
HTTP request a web page makes the browser send here never runs). The implementation is in
|
||||
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
|
||||
@@ -35,8 +39,8 @@ before the newline** and drops the connection on a longer one (see [MCP server](
|
||||
Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting
|
||||
for another window's frame. A full socket queue replies with `"control request queue is full"`
|
||||
instead of dispatching the new request. These limits apply to the opt-in loopback control channel;
|
||||
requests still queued after the UI timeout are discarded, including requests deferred to a hidden
|
||||
window. A command already running is not interrupted.
|
||||
requests still queued after the UI timeout are discarded (never run), including requests deferred to
|
||||
a hidden window. A command already running is not interrupted: it takes effect when it finishes.
|
||||
|
||||
A minimised or covered window (the main one or another, see Windows below) draws no frames, but
|
||||
still answers every method that acts on the app's state (`engine.execute`, `document.inspect`,
|
||||
@@ -89,7 +93,11 @@ read or write root and written only inside a write root; anything else is an ord
|
||||
resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder
|
||||
stops the app before its window opens, and so does any option the app doesn't know (a mistyped
|
||||
flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined).
|
||||
Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore
|
||||
Confined work reaches no FigCraft or plugin server either: hosting, joining, publishing and
|
||||
sharing files, team libraries on a server and plugin directories are refused (`automation roots
|
||||
are set: commands don't reach the network …`). A root on a network share (a UNC path such as
|
||||
`\\server\share\…`, or a mounted network drive) is the exception: files read and written inside it,
|
||||
documents saved there included, go to that file server. Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore
|
||||
them and confine nothing.
|
||||
|
||||
Not confined: opening the files given on the command line when the app starts and the documents
|
||||
|
||||
+21
-6
@@ -93,6 +93,16 @@ Each may be given more than once, as `--flag DIR` or `--flag=DIR`:
|
||||
compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is
|
||||
inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones,
|
||||
and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused.
|
||||
- **The network:** a confined call reaches no FigCraft or plugin server, so it can't send a
|
||||
document anywhere through one. Hosting, joining, publishing and sharing a file (`collab.host`,
|
||||
`collab.join`, `collab.publish`, `collab.share` …), team libraries on a server (`library.check`
|
||||
refuses, and `library.updates` / `library.list` report the refusal for each instead of the last
|
||||
version fetched) and plugin directories are refused with `automation roots are set: commands
|
||||
don't reach the network …`. The app started with the flags confines its own window too, so its
|
||||
sharing is off as well. One exception: a root on a network share (a UNC path such as
|
||||
`\\server\share\…`, or a mounted network drive) is still a root, so reading and writing inside
|
||||
it reaches that file server, and saving there sends the document to it. Give only roots you would
|
||||
let the agent's files go to.
|
||||
- Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies,
|
||||
plugins installed from a plugin directory, the system fonts).
|
||||
- `--attach` with either flag is a usage error: the app runs the commands, so only the app can
|
||||
@@ -523,20 +533,25 @@ design file they refuse.
|
||||
client counts down on its own clock. `board.timerState {}` reads it: `state` is `off`,
|
||||
`running`, `paused` or `done` (it ran out: 0:00 until someone stops it), `left` milliseconds,
|
||||
`text` as `m:ss` (or `h:mm:ss`).
|
||||
- `board.vote {action: start|cast|retract|end|clear, …}` → the vote as `board.voteResults` gives
|
||||
- `board.vote {action: start|cast|retract|end|clear|delete, …}` → the vote as `board.voteResults` gives
|
||||
it. One vote at a time: `start {votes? (per person, 1–99, default 3), on?: all|stickies|selection
|
||||
(default all), ids? (with selection; default the selected layers)}` (a vote that ended may be
|
||||
replaced; one still running is refused), `cast {id}` puts one of your votes on an object (several
|
||||
on the same object are fine; once yours are used up it refuses), `retract {id}` takes one back,
|
||||
`end` shows everyone's votes, `clear` removes the vote and its results. `voter` (any action)
|
||||
`end` shows everyone's votes and keeps the results in the file (the last 20 votes'), `clear` takes the vote and its
|
||||
results off the board (kept results stay), `delete {index}` removes kept results (0 the oldest). `voter` (any action)
|
||||
votes as someone else, for scripts and tests; by default you are your live-session client, or
|
||||
your name (`pref.userName`) when the file isn't shared. In the app a click on an object votes
|
||||
while a vote runs, and a click on your badge (or an Alt-click) takes the vote back.
|
||||
- `board.voteResults {voter?}` → `{state: off|running|ended, votesPerPerson, on, by, left, mine:
|
||||
[{id, votes}], voters, results?}`: while it runs you see your own votes only (`mine`, `left`) and
|
||||
how many have voted; once it ends `results` lists `[{id, name, votes, rank}]`, most votes first
|
||||
(ties share a rank), with `name` the sticky's text or the layer's name. Votes on deleted objects
|
||||
don't count.
|
||||
[{id, votes}], voters, results?, sessions}` (off: `{state, sessions}`): while it runs you see your own votes only (`mine`, `left`) and
|
||||
how many have voted; once it ends `results` lists `[{id, name, votes, rank, voters}]`, most votes
|
||||
first (ties share a rank), with `name` the sticky's text or the layer's name and `voters` who
|
||||
voted for it: `[{voter, name, votes}]`, `voter` as the ballots key it (a live-session client as
|
||||
`peer <id>`, else a name) and `name` what they go by (`null` for someone no longer in the live
|
||||
session). `sessions` lists every ended vote's results, newest first:
|
||||
`[{index (in the kept results, 0 the oldest; null for a vote that ended before results were kept), onBoard, ended (ms
|
||||
since 1970), votesPerPerson, on, by, results}]`. Votes on deleted objects don't count.
|
||||
|
||||
```json
|
||||
{"command": "board.timer", "params": {"action": "start", "minutes": 10}}
|
||||
|
||||
+6
-1
File diff suppressed because one or more lines are too long
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -89,7 +89,7 @@ interaction feel not yet compared.
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only |
|
||||
| Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar |
|
||||
@@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. |
|
||||
| 2026-10-30 | minor | M1: sidebar number and text fields keep their text vertically centered when focused, including typography spacing. Editors use the full 24 pt field height instead of adding a 4 pt top inset to an editor already at least 24 pt tall. Regression: `field_tests::sidebar_field_text_stays_in_place_on_focus` compares painted text positions before and after focus. Readiness estimates unchanged. |
|
||||
| 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. |
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
|
||||
Reference in New Issue
Block a user