Merge main into #861

This commit is contained in:
bflatastic
2026-10-20 01:33:51 +07:00
68 changed files with 1981 additions and 284 deletions
+69 -1
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use figcraft_engine::Session;
use crate::{DialogFuture, FigApp, Services};
use crate::{ContentsFuture, DialogFuture, FigApp, Services};
struct OpenFile(PathBuf);
@@ -79,6 +79,74 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front
let _ = std::fs::remove_dir_all(dir);
}
/// The browser: the picker hands over a file's name and contents, never a path.
struct Contents(&'static str, Vec<u8>);
impl Services for Contents {
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
panic!("a platform that picks contents is never asked for a path")
}
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone()))))))
}
}
/// The browser, with a picked file it won't read.
struct Unreadable(String);
impl Services for Unreadable {
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
Some(Box::pin(std::future::ready(Some(Err(self.0.clone())))))
}
}
#[test]
fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() {
let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err();
let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone())));
app.open_dialog();
app.poll_dialog(&egui::Context::default());
assert!(app.session.docs.is_empty());
assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str()));
assert!(why.contains("Huge.fig"), "{why}");
}
#[test]
fn the_browser_reads_files_up_to_512_mib() {
let max = (512u64 << 20) as f64;
assert!(crate::browser_can_read("a", 0.0).is_ok());
assert!(crate::browser_can_read("a", max).is_ok());
assert!(crate::browser_can_read("a", max + 1.0).is_err());
assert!(crate::browser_can_read("a", f64::NAN).is_err());
assert!(crate::browser_can_read("a", f64::INFINITY).is_err());
}
#[test]
fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() {
let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap();
let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes)));
app.ui.home = true;
app.open_dialog();
assert!(app.session.docs.is_empty(), "opening waits for the picker result");
app.poll_dialog(&egui::Context::default());
assert_eq!(app.session.docs.len(), 1);
assert_eq!(app.session.docs[0].path, None, "no path to save over");
assert_eq!(app.session.docs[0].doc.name, "Picked");
assert!(!app.ui.home, "and the editor shows it");
}
#[test]
fn import_dialog_imports_picked_contents_into_the_file_in_front() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg)));
let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count();
assert_eq!(rectangles(&app), 0);
app.import_dialog(false);
app.poll_dialog(&egui::Context::default());
assert_eq!(app.session.docs.len(), 1, "imported, not opened");
assert_eq!(rectangles(&app), 1, "the SVG's content");
}
#[test]
fn a_dialog_whose_file_closed_meanwhile_does_nothing() {
let (mut app, dir, path) = two_files("closed");
+4 -8
View File
@@ -509,21 +509,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
let path = f.path().to_path_buf();
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
let params = if path.exists() {
json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})
} else if let Some(bytes) = crate::dropped_bytes(&f) {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
} else {
continue;
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
};
let Some(target) =
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
else {
continue;
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) });
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
+4 -8
View File
@@ -187,16 +187,12 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
}
// `opens` saw an extension, so there is a file name to tell the format by.
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
let params = if path.exists() {
json!({"path": path.to_string_lossy()})
} else if let Some(bytes) = crate::dropped_bytes(&f) {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
json!({"data": data, "name": name})
let (params, read) = if path.exists() {
(json!({"path": path.to_string_lossy()}), None)
} else {
continue;
(json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None });
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
}
}
+65
View File
@@ -573,6 +573,71 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() {
assert!(!path.exists(), "nothing is written");
}
/// What a drop's read gives, once it has arrived.
type Arrival = std::rc::Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived`
/// holds them.
fn later(arrived: &Arrival) -> crate::ReadFuture {
let arrived = arrived.clone();
Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() {
Some(bytes) => std::task::Poll::Ready(bytes),
None => std::task::Poll::Pending,
}))
}
#[test]
fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
let arrived = std::rc::Rc::new(std::cell::RefCell::new(None));
let mut u = first_run();
u.app.ui.home = true;
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
label: "Browser drop.svg".into(),
params: json!({"name": "Browser drop.svg"}),
command: "file.open",
target: None,
read: Some(later(&arrived)),
});
// egui lays a new card out invisibly on its first frame.
u.frame(vec![]);
for _ in 0..5 {
let s = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read");
}
assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive");
*arrived.borrow_mut() = Some(Ok(svg));
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
assert_eq!(u.app.ui.last_error, None);
assert_eq!(u.app.session.docs.len(), 1, "the contents open");
assert_eq!(u.app.session.docs[0].path, None);
assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content");
assert!(!u.app.ui.home);
}
#[test]
fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() {
let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft")))));
let mut u = first_run();
u.app.ui.home = true;
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
label: "Unreadable.figcraft".into(),
params: json!({"name": "Unreadable.figcraft"}),
command: "file.open",
target: None,
read: Some(later(&arrived)),
});
for _ in 0..3 {
u.frame(vec![]);
}
assert!(u.app.ui.pending_opens.is_empty());
assert!(u.app.session.docs.is_empty());
assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft."));
}
#[test]
fn dropping_a_native_file_on_the_home_screen_opens_it() {
let dir = Temp::new();
+73 -11
View File
@@ -421,6 +421,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
draw(&p, icon);
}
/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box.
fn draw(p: &P, icon: Icon) {
use Icon::*;
match icon {
@@ -446,18 +447,23 @@ fn draw(p: &P, icon: Icon) {
p.path(&h, false);
}
Frame => {
p.line(5.5, 2.0, 5.5, 14.0);
p.line(10.5, 2.0, 10.5, 14.0);
p.line(2.0, 5.5, 14.0, 5.5);
p.line(2.0, 10.5, 14.0, 10.5);
// Inset a unit from the box: four long hinted strokes read heavier than the
// anti-aliased outlines beside them at full size (#737).
p.line(5.5, 3.0, 5.5, 13.0);
p.line(10.5, 3.0, 10.5, 13.0);
p.line(3.0, 5.5, 13.0, 5.5);
p.line(3.0, 10.5, 13.0, 10.5);
}
Section | Section2 => {
p.rect(2.5, 5.5, 13.5, 13.5, 1.0);
p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0);
}
Slice => {
p.rect(2.5, 2.5, 13.5, 13.5, 0.0);
p.line(2.5, 13.5, 13.5, 2.5);
// A knife, one silhouette (#739): a slim blade to the top right, outlined like the
// rest of the row, and a solid handle whose end overlaps the blade's base so the two
// join at every size.
p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true);
p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3));
}
Bend => {
// A curve with a smooth point and its handle line.
@@ -523,11 +529,12 @@ fn draw(p: &P, icon: Icon) {
p.line(9.0, 4.5, 11.5, 7.0);
}
Text => {
p.line(2.0, 3.5, 13.0, 3.5);
p.line(7.5, 3.0, 7.5, 13.0);
p.line(2.5, 3.0, 2.5, 6.0);
p.line(12.5, 3.0, 12.5, 6.0);
p.line(5.0, 12.5, 10.0, 12.5);
// Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737).
p.line(3.0, 3.5, 12.0, 3.5);
p.line(7.5, 3.0, 7.5, 12.5);
p.line(3.5, 3.0, 3.5, 5.5);
p.line(11.5, 3.0, 11.5, 5.5);
p.line(5.5, 12.5, 9.5, 12.5);
}
Comment => {
let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect();
@@ -1226,4 +1233,59 @@ mod tests {
assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon");
assert_ne!(rotation, radius);
}
/// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit.
fn shapes_at_32(icon: Icon) -> Vec<Shape> {
let ctx = egui::Context::default();
let mut out =
ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE));
out.textures_delta.clear();
out.shapes.into_iter().map(|c| c.shape).collect()
}
/// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't
/// outweigh the outlines beside them in the toolbar (#737).
#[test]
fn frame_and_text_marks_are_inset() {
for icon in [Icon::Frame, Icon::Text] {
let pts: Vec<Pos2> = shapes_at_32(icon)
.iter()
.flat_map(|s| match s {
Shape::LineSegment { points, .. } => points.to_vec(),
other => panic!("{icon:?} draws straight strokes only: {other:?}"),
})
.collect();
assert!(!pts.is_empty());
for p in pts {
// Grid units 3..13, two points each.
assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}");
}
}
}
/// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of
/// the blade's base, so the two read as one mark rather than separate pieces.
#[test]
fn slice_is_one_connected_knife() {
let shapes = shapes_at_32(Icon::Slice);
let paths: Vec<_> = shapes
.iter()
.map(|s| match s {
Shape::Path(p) => p,
other => panic!("Slice draws paths only: {other:?}"),
})
.collect();
let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade");
let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle");
assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else");
// The blade's base runs between its second and third points; the handle is convex.
let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0;
let h = &handle.points;
let side = |i: usize| {
let (a, b) = (h[i], h[(i + 1) % h.len()]);
(b - a).x * (base - a).y - (b - a).y * (base - a).x
};
let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0);
assert!(inside, "the handle covers the blade's base at {base:?}");
}
}
@@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() {
params: json!({"path": path.to_string_lossy()}),
command: "file.open",
target: None,
read: None,
});
for _ in 0..6 {
u.frame(vec![]);
+127 -31
View File
@@ -236,6 +236,11 @@ pub trait Services {
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
Box::pin(std::future::ready(None))
}
/// Pick a file and read it, where the platform hands over a file's name and contents rather
/// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]).
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
None
}
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
Box::pin(std::future::ready(None))
}
@@ -290,14 +295,61 @@ pub trait Services {
}
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<PathBuf>>>>;
type DialogDone = Box<dyn FnOnce(&mut FigApp, PathBuf)>;
/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker
/// was cancelled, `Err` with what to tell the user when the file couldn't be read.
pub type ContentsFuture = Pin<Box<dyn Future<Output = Option<Result<(String, Vec<u8>), String>>>>>;
/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they
/// couldn't be read).
pub type ReadFuture = Pin<Box<dyn Future<Output = Result<Vec<u8>, String>>>>;
type PickFuture = Pin<Box<dyn Future<Output = Option<Picked>>>>;
type DialogDone = Box<dyn FnOnce(&mut FigApp, Picked)>;
/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why
/// the picked file couldn't be read.
enum Picked {
Path(PathBuf),
Contents(String, Vec<u8>),
Unreadable(String),
}
impl Picked {
/// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead).
fn file_params(self) -> Result<Value, String> {
match self {
Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})),
Picked::Contents(name, bytes) => {
use base64::Engine as _;
Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}))
}
Picked::Unreadable(why) => Err(why),
}
}
}
/// What the user is told about a file whose contents couldn't be read.
pub fn unreadable(name: &str) -> String {
i18n::trf("Couldn't read {name}.", &[("name", &name)])
}
/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents
/// are held about three times on their way to `file.open` (the bytes, base64, decoded again).
/// As large as one member of a `.figcraft` file may be.
const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64;
/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user).
pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> {
if size.is_nan() || size > MAX_BROWSER_FILE {
return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)]));
}
Ok(())
}
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
/// file and window it was opened for, not to whichever is in front when it closes.
struct PendingDialog {
future: DialogFuture,
/// The path picked, kept until the window that holds `doc` draws.
path: Option<PathBuf>,
future: PickFuture,
/// What was picked, kept until the window that holds `doc` draws.
picked: Option<Picked>,
done: DialogDone,
/// The file it acts on (`None`: none, such as Open).
doc: Option<u64>,
@@ -790,6 +842,11 @@ impl FigApp {
fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
let msg = e.to_string();
log::warn!("{id}: {msg}");
self.show_error(msg);
}
/// Show an error as a toast, kept as the last error.
pub(crate) fn show_error(&mut self, msg: String) {
self.toast(msg.clone());
self.ui.last_error = Some(msg);
}
@@ -1109,16 +1166,11 @@ impl FigApp {
pub fn open_dialog(&mut self) {
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
self.start_dialog(
|services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS),
false,
|app, p| {
let ps = p.to_string_lossy().to_string();
if app.run_user("file.open", json!({"path": ps})).is_some() {
app.ui.home = false;
}
},
);
self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| {
if app.run_user("file.open", file).is_some() {
app.ui.home = false;
}
});
}
pub fn import_dialog(&mut self, place: bool) {
@@ -1128,12 +1180,35 @@ impl FigApp {
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
};
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
self.pick_open(title, exts, move |app, p| {
let ps = p.to_string_lossy().to_string();
app.run_user("file.import", json!({"path": ps}));
self.pick_file(title, exts, true, |app, file| {
app.run_user("file.import", file);
});
}
/// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a
/// path, or the name and contents where the platform has no paths). `pin`: as
/// [`FigApp::pick_open`]'s file in front.
fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
self.start_pick(
|services| -> PickFuture {
match services.pick_open_contents(title, exts) {
Some(read) => {
Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) })
}
None => {
let pick = services.pick_open(title, exts);
Box::pin(async move { pick.await.map(Picked::Path) })
}
}
},
pin,
move |app, picked| match picked.file_params() {
Ok(file) => done(app, file),
Err(why) => app.show_error(why),
},
);
}
/// Pick a file to open. `done` runs with the file in front now back in front (and not at all
/// if that file closed meanwhile).
pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
@@ -1152,12 +1227,28 @@ impl FigApp {
/// Open a native dialog from the window drawing now, for the file in front (`pin`).
fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
self.start_pick(
|services| -> PickFuture {
let pick = start(services);
Box::pin(async move { pick.await.map(Picked::Path) })
},
pin,
move |app, picked| {
if let Picked::Path(p) = picked {
done(app, p);
}
},
);
}
/// [`FigApp::start_dialog`] for any kind of pick.
fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) {
if self.pending_dialog.is_some() {
return;
}
let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None };
let future = start(self.services.as_mut());
self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current });
self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current });
if let Some(ctx) = &self.ui_context {
ctx.request_repaint();
}
@@ -1167,12 +1258,12 @@ impl FigApp {
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
fn poll_dialog(&mut self, ctx: &egui::Context) {
let Some(d) = &mut self.pending_dialog else { return };
if d.path.is_none() {
if d.picked.is_none() {
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
d.path = path;
let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
d.picked = picked;
}
let (doc, origin, picked) = (d.doc, d.window, d.path.is_some());
let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some());
let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u));
// Cancelled, or its file closed meanwhile.
if !picked || index == Some(None) {
@@ -1188,12 +1279,12 @@ impl FigApp {
ctx.request_repaint_of(windows::viewport(window));
return;
}
let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return };
let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return };
let before = self.session.ds().ok().map(|d| d.uid);
if let Some(Some(i)) = index {
self.session.active = i;
}
done(self, path);
done(self, picked);
if doc.is_some()
&& self.session.ds().ok().map(|d| d.uid) == doc
&& let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u))
@@ -1703,17 +1794,22 @@ pub(crate) fn opens(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// The contents of a dropped file that has no file on disk to read by path.
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
f.bytes().ok()
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name))))
}
/// The contents of a dropped file that has no file on disk to read by path.
/// Reads the contents of dropped file `name`; the browser hands them over asynchronously.
#[cfg(target_arch = "wasm32")]
pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
// Browser drops arrive asynchronously; not supported yet.
None
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
let (f, name) = (f.clone(), name.to_string());
Box::pin(async move {
if let Some(file) = f.web_file() {
browser_can_read(&name, file.size())?;
}
f.bytes_async().await.map_err(|_| unreadable(&name))
})
}
/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle,
+16 -5
View File
@@ -112,6 +112,8 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
r.truncate(figcraft_engine::MAX_RECENT_ACTIONS);
}
/// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the
/// clipboard) are handled here, the rest go to the engine.
pub fn invoke(app: &mut FigApp, id: &str) {
match id {
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
@@ -219,12 +221,13 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"collab.publish" => crate::live::open_publish(app),
"edit.copy" | "edit.cut" => {
let ids = app.session.selection();
// SVG for other apps, taken before a cut removes the layers.
let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids));
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
let doc = app.session.doc().ok();
let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids)));
app.run(id, json!({}));
// Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later
// paste knows whether the clipboard still holds our layers.
let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
// Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a
// later paste knows whether the clipboard still holds our layers.
let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
app.services.clipboard_set_text(&text);
app.ui.last_copy = Some(text);
}
@@ -252,6 +255,14 @@ fn export_frames_pdf(app: &mut FigApp) {
});
}
/// The text of a selection made only of text layers, one layer per line (None for any other
/// selection, or when there is no text).
fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let texts: Option<Vec<&str>> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect();
texts.map(|t| t.join("\n")).filter(|t| !t.is_empty())
}
/// The layers as SVG text for other apps (None when the export fails).
fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
String::from_utf8(bytes).ok()
+47 -9
View File
@@ -1,13 +1,17 @@
//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what
//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen
//! before it does (rather than a window that freezes without a word).
//! before it does (rather than a window that freezes without a word). A file with no path to read
//! (every drop in the browser) waits behind the card until its contents arrive.
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll, Waker};
use figcraft_doc::NodeId;
use serde_json::Value;
use serde_json::{Value, json};
use crate::FigApp;
use crate::i18n::{tr, trf, trn};
use crate::theme::{Tokens, semibold};
use crate::{FigApp, ReadFuture, RepaintWaker};
/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported
/// (`file.import`, into the current document).
@@ -18,6 +22,8 @@ pub struct PendingOpen {
pub command: &'static str,
/// Canvas imports stay with the document and page that received the drop.
pub target: Option<ImportTarget>,
/// The file's contents still on their way; they go into `params` as `data` once read.
pub read: Option<ReadFuture>,
}
pub struct ImportTarget {
@@ -66,23 +72,55 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
/// An import that has no destination left: nothing is imported, and the user is told why.
fn cancel(app: &mut FigApp, message: String) {
app.ui.last_error = Some(message.clone());
app.toast(message);
app.show_error(message);
}
/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first
/// frame, so the second one is the first the window shows.
const FRAMES_BEFORE_OPENING: u8 = 2;
/// The card while files wait, then (once it has been on screen) open them. Called before the import
/// report, so a report for these files shows after them.
/// Polls the contents still being read: those that arrived go into their file's parameters, files
/// that couldn't be read are dropped with a message. Whether any are still on their way.
fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool {
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
let mut cx = TaskContext::from_waker(&waker);
let mut reading = false;
let mut unread = Vec::new();
app.ui.pending_opens.retain_mut(|pending| {
let Some(read) = pending.read.as_mut() else { return true };
match read.as_mut().poll(&mut cx) {
Poll::Pending => {
reading = true;
true
}
Poll::Ready(Ok(bytes)) => {
use base64::Engine as _;
pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes));
pending.read = None;
true
}
Poll::Ready(Err(why)) => {
unread.push(why);
false
}
}
});
for why in unread {
app.show_error(why);
}
reading
}
/// The card while files wait, then (once it has been on screen and their contents are read) open
/// them. Called before the import report, so a report for these files shows after them.
pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
let reading = read_contents(app, ctx);
if app.ui.pending_opens.is_empty() {
app.ui.pending_open_frames = 0;
return;
}
ctx.request_repaint();
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING {
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading {
app.ui.pending_open_frames = 0;
for pending in std::mem::take(&mut app.ui.pending_opens) {
let command = pending.command;
@@ -92,7 +130,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
}
return;
}
app.ui.pending_open_frames += 1;
app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1);
let title = match app.ui.pending_opens.first().map(|p| p.command) {
Some("file.import") => {
let name = app
+274 -59
View File
@@ -12,22 +12,93 @@ use crate::theme::{Tokens, regular};
use crate::{FigApp, TitleBar};
pub const HEIGHT: f32 = 40.0;
const MIN_TAB_WIDTH: f32 = 96.0;
const OVERFLOW_WIDTH: f32 = 32.0;
const OPEN_TIME: f64 = 0.18;
/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place
/// in egui's memory; it only allocates when the set of tabs changes.
#[derive(Clone, Default)]
struct TabMotion {
initialized: bool,
known: Vec<u64>,
opening: Vec<(u64, f64)>,
}
impl TabMotion {
/// Track tabs added since the previous frame and expire their animations.
fn begin_frame(&mut self, current: impl Iterator<Item = u64> + Clone, now: f64, reduce_motion: bool) {
if !self.known.iter().copied().eq(current.clone()) {
if self.initialized && !reduce_motion {
for uid in current.clone().filter(|uid| !self.known.contains(uid)) {
self.opening.push((uid, now));
}
}
self.known = current.collect();
}
self.initialized = true;
if reduce_motion {
self.opening.clear();
} else {
let known = &self.known;
self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME);
}
}
}
/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating.
fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 {
opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32)
}
struct TabLayout {
first: usize,
widths: Vec<f32>,
overflow: bool,
}
/// Shrink open tabs before moving the excess into the overflow menu.
fn tab_layout(preferred: &[f32], available: f32, active: Option<usize>) -> TabLayout {
let count = preferred.len();
let overflow = count as f32 * MIN_TAB_WIDTH > available;
let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count);
let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible);
let mut widths = preferred[first..first + visible].to_vec();
let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0);
let mut excess = (widths.iter().sum::<f32>() - budget).max(0.0);
while excess > 0.01 {
let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count();
if flexible == 0 {
break;
}
let share = excess / flexible as f32;
let mut removed = 0.0;
for width in &mut widths {
let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share);
*width -= shrink;
removed += shrink;
}
excess -= removed;
}
TabLayout { first, widths, overflow }
}
/// Final document index and insertion marker for a tab dropped among the visible tabs.
fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> {
let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect();
let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x);
if let Some(&(index, rect)) = others.get(slot) {
Some((index - usize::from(from < index), rect.min.x))
} else {
let &(index, rect) = others.last()?;
Some((index + usize::from(from > index), rect.max.x))
}
}
pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| {
let rect = ui.max_rect();
// Empty space moves the window (first, so the tabs and buttons on top take their clicks).
if app.ui.title_bar != TitleBar::Native {
let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
// Home.
@@ -49,28 +120,70 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec<usize> = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
let now = ui.input(|input| input.time);
let motion_id = ui.id().with("tab_motion");
let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid));
let reduce_motion = app.reduce_motion();
// Cloning `opening` is free while nothing animates (an empty Vec).
let opening = ui.ctx().data_mut(|data| {
let motion = data.get_temp_mut_or_default::<TabMotion>(motion_id);
motion.begin_frame(current_uids, now, reduce_motion);
motion.opening.clone()
});
let preferred: Vec<f32> = document_tabs
.iter()
.map(|&i| {
let d = &app.session.docs[i];
let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
(regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
})
.collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 };
let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0);
let active_tab = document_tabs.iter().position(|&i| i == app.session.active);
let layout = tab_layout(&preferred, available, active_tab);
let mut tab_rects = Vec::with_capacity(layout.widths.len());
let mut tab_x = x;
for &width in &layout.widths {
tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT)));
tab_x += width;
}
let mut switch = None;
let mut close = None;
// A tab dragged off the strip, or "Open in new window", moves to a window of its own.
let mut detach = None;
let mut dragging = None;
let mut dropped = None;
let several = !ui.ctx().embed_viewports();
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
for (slot, &w) in layout.widths.iter().enumerate() {
let i = document_tabs[layout.first + slot];
let d = &app.session.docs[i];
let active = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let name = d.doc.name.clone();
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
let mark_w = if live.is_some() { 18.0 } else { 0.0 };
// (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.)
let dirty = d.is_dirty();
let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 };
let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0);
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() });
if resp.drag_stopped()
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
if (resp.dragged() || resp.drag_stopped())
&& let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
&& (p.y - rect.center().y).abs() > HEIGHT * 2.0
{
detach = Some((i, Some(p)));
if resp.drag_stopped() {
if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
detach = Some((i, Some(p)));
} else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
dropped = Some((i, p.x));
}
} else {
dragging = Some((i, p));
}
}
if several {
resp.context_menu(|ui| {
@@ -82,49 +195,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
});
}
let label = match live {
Some(true) => trf("{name}, in a live session", &[("name", &name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
Some(true) => trf("{name}, in a live session", &[("name", name)]),
Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
None => name.clone(),
};
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
let painter = ui.painter().with_clip_rect(r);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
} else if resp.hovered() || cresp.hovered() {
painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
}
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)),
&painter,
Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
Icon::Logo,
crate::theme::ACCENT,
crate::theme::ACCENT.linear_multiply(progress),
);
// The unsaved-changes dot follows the name on screen, whichever way the name reads.
let shown = crate::bidi::paint_name(
ui.painter(),
pos2(r.min.x + 32.0, r.center().y),
&name,
&painter,
pos2(paint_r.min.x + 32.0, paint_r.center().y),
name,
regular(12.0),
t.text,
w - mark_w - if dirty { 72.0 } else { 60.0 },
t.text.linear_multiply(progress),
(w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
);
if let Some(shared) = live {
let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0));
icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary });
let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
}
if dirty {
ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text);
painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
}
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
// The close target never overlaps the draggable body, including on inactive tabs.
if resp.hovered() || cresp.hovered() || active {
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if cresp.clicked() {
close = Some(i);
painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
}
icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
}
if cresp.clicked() {
close = Some(i);
}
if resp.clicked() && close.is_none() {
switch = Some(i);
@@ -135,35 +251,66 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
x += w;
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect();
if let Some((from, p)) = dragging
&& (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y)
&& let Some((_, at)) = tab_drop_position(&visible, from, p.x)
{
ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT));
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
crate::right::menu_popup(&resp).show(|ui| {
egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
for &i in &document_tabs {
let Some(d) = app.session.docs.get(i) else { continue };
let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
if crate::right::menu_check(ui, on, &label, "") {
switch = Some(i);
ui.close();
}
}
});
});
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tab. Switching back to the design keeps the player
// state (current screen, navigation history and scroll position) for the return trip.
if app.ui.present.is_some() || app.ui.paused_present.is_some() {
let w = 128.0;
let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT));
let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present"));
let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let active = app.ui.present.is_some();
crate::a11y::tab(&resp, tr("Present"), active);
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active);
} else if resp.hovered() {
} else if resp.hovered() || close_resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon);
ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text);
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
if resp.hovered() || active {
let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview"));
let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0));
if resp.hovered() || close_resp.hovered() || active {
if close_resp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
}
icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary);
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
}
if close_resp.clicked() {
app.ui.present = None;
app.ui.paused_present = None;
app.ui.present_uid = None;
} else if resp.clicked() && !active {
resume_presentation(app);
}
@@ -195,6 +342,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
app.run(cmd, json!({}));
}
// Only the free space drags the window. A full-bar drag target steals tab drags.
if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag());
if drag.drag_started() {
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
}
if drag.double_clicked() {
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
}
}
if let Some(i) = switch {
if let Some(p) = app.ui.present.take() {
app.ui.paused_present = Some(p);
@@ -209,6 +368,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.toast(e);
}
}
if let Some((from, to)) = reorder
&& from != to
{
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
// A branch tab's edits live on in its main file: closing it loses nothing.
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
@@ -230,8 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.ui.present_uid = None;
}
}
let _ = Color32::TRANSPARENT;
let _ = Align2::LEFT_TOP;
if !opening.is_empty() {
ui.ctx().request_repaint();
}
});
}
@@ -325,3 +490,53 @@ pub fn resize_edges(ctx: &egui::Context) {
}
});
}
#[cfg(test)]
mod layout_tests {
use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout};
use egui::{Rect, pos2};
#[test]
fn tabs_shrink_to_fit_before_overflowing() {
let preferred = vec![120.0; 10];
let layout = tab_layout(&preferred, 1100.0, Some(5));
assert!(!layout.overflow);
assert_eq!(layout.first, 0);
assert_eq!(layout.widths.len(), preferred.len());
assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01));
}
#[test]
fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() {
let preferred = vec![120.0; 24];
let available = 1100.0;
let layout = tab_layout(&preferred, available, Some(23));
assert!(layout.overflow);
assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len());
assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH));
assert!(layout.widths.iter().sum::<f32>() + OVERFLOW_WIDTH <= available + 0.01);
}
#[test]
fn drop_position_places_a_tab_between_its_neighbors() {
let visible = (0..4)
.map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0))))
.collect::<Vec<_>>();
assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2));
assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0));
}
#[test]
fn new_tabs_animate_unless_motion_is_reduced() {
let mut motion = TabMotion::default();
motion.begin_frame([1].into_iter(), 0.0, false);
assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate");
motion.begin_frame([1, 2].into_iter(), 1.0, false);
assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0);
assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0);
motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false);
assert!(motion.opening.is_empty(), "the animation ends");
motion.begin_frame([1, 2, 3].into_iter(), 2.0, true);
assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once");
}
}
+75
View File
@@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() {
assert!(opened, "the new-tab button took no click");
}
#[test]
fn many_tabs_do_not_cover_the_window_buttons() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..24 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.frame(vec![]);
let before = u.app.session.docs.len();
u.click(pos2(SIZE.x - 222.0, 20.0));
assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable");
assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true)));
assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true)));
u.click(window_button(2));
assert!(u.frame(vec![]).contains(&ViewportCommand::Close));
}
#[test]
fn close_button_on_inactive_tab_closes_it_without_switching() {
let mut u = Ui::new(TitleBar::Custom);
u.app.session.execute("file.new", &json!({})).unwrap();
u.app.session.execute("file.new", &json!({})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let inactive_uid = u.app.session.docs[1].uid;
let close_button = pos2(270.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(
!u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid),
"docs={}, active={}, last_error={:?}, target_dirty={}",
u.app.session.docs.len(),
u.app.session.active,
u.app.ui.last_error,
u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty())
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn closing_a_tab_before_the_active_one_keeps_the_active_file() {
let mut u = Ui::new(TitleBar::Custom);
for _ in 0..3 {
u.app.session.execute("file.new", &json!({})).unwrap();
}
u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = u.app.session.docs[u.app.session.active].uid;
let closed_uid = u.app.session.docs[0].uid;
let close_button = pos2(156.0, 20.0);
u.frame(vec![Event::PointerMoved(close_button)]);
u.click(close_button);
assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid));
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() {
let mut u = Ui::new(TitleBar::Custom);
for name in ["Two", "Three", "Four"] {
u.app.session.execute("file.new", &json!({"name": name})).unwrap();
}
let moved_uid = u.app.session.docs[0].uid;
let active_uid = u.app.session.docs[u.app.session.active].uid;
u.frame(vec![]);
let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0));
assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}");
assert_eq!(
u.app.session.docs[2].uid,
moved_uid,
"order={:?}, active={}, error={:?}",
u.app.session.docs.iter().map(|d| d.uid).collect::<Vec<_>>(),
u.app.session.active,
u.app.ui.last_error
);
assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid);
}
#[test]
fn a_system_title_bar_leaves_the_window_alone() {
let mut u = Ui::new(TitleBar::Native);
+67 -4
View File
@@ -11,6 +11,18 @@ use crate::i18n::{tr, tr_noop, trf};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular};
/// Every mark in the toolbar row is painted in a box this size, so they share one optical size
/// and one stroke width (#737, #738).
const GLYPH: f32 = 18.0;
/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width
/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner).
const CARET: f32 = 14.0;
/// `GLYPH`'s box centred in `r`.
fn glyph_rect(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))
}
struct Group {
key: &'static str,
/// The flyout button's name for screen readers.
@@ -82,6 +94,8 @@ pub(crate) fn main_width() -> f32 {
GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0
}
/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the
/// Draw · Design · Prototype · Inspect mode switcher.
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
let (cell, gap) = (32.0, 4.0);
let width = main_width();
@@ -121,7 +135,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
if cresp.hovered() {
ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary);
if cresp.clicked() {
app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) };
}
@@ -149,7 +163,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon });
icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { Color32::WHITE } else { t.icon });
if resp.clicked() {
if id == "actions" {
crate::menus::toggle_palette(&mut app.ui);
@@ -181,7 +195,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);
}
icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary });
icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });
if resp.clicked() {
match i {
0 => {
@@ -379,7 +393,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic
Color32::TRANSPARENT
};
ui.painter().rect_filled(r, CornerRadius::same(5), bg);
icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon });
icons::paint(ui.painter(), glyph_rect(r), icon, if active { Color32::WHITE } else { t.icon });
resp
}
@@ -452,3 +466,52 @@ fn flyout_id(key: &str) -> egui::Id {
pub fn pretty_shortcut(s: &str) -> String {
figcraft_engine::keymap::display(s, crate::shortcuts::platform())
}
#[cfg(test)]
mod tests {
use super::*;
use egui::Shape;
/// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.
fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {
let ctx = egui::Context::default();
let input = || {
let mut i = egui::RawInput::default();
i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp);
i
};
ctx.run_ui(input(), |_| {}).textures_delta.clear();
let mut out =
ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE));
out.textures_delta.clear();
assert_eq!(ctx.pixels_per_point(), ppp);
let mut w: Vec<f32> = out
.shapes
.iter()
.filter_map(|c| match &c.shape {
Shape::LineSegment { stroke, .. } => Some(stroke.width),
Shape::Path(p) => Some(p.stroke.width),
Shape::Rect(r) => Some(r.stroke.width),
_ => None,
})
.filter(|w| *w > 0.0)
.collect();
w.dedup();
w
}
/// The flyout carets and the marks beside them draw with one stroke width (#737), and the
/// mode switcher's marks are the tool marks' size (#738).
#[test]
fn toolbar_marks_share_one_stroke_width() {
for ppp in [1.0, 1.5, 2.0] {
let mark = widths(ppp, Icon::Rectangle, GLYPH);
assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}");
assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×");
assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×");
for icon in [Icon::Frame, Icon::Text] {
assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×");
}
}
}
}
+18
View File
@@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() {
assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer");
}
#[test]
fn copying_text_layers_puts_their_text_on_the_clipboard() {
// #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers.
let mut p = Page::new();
let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap();
let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap();
p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
p.frame(vec![]);
p.copy();
assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer");
p.paste();
assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text");
let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap();
p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap();
p.copy();
assert!(p.clipboard.contains("<svg"), "a selection with other layers stays SVG");
}
#[test]
fn shift_cmd_v_pastes_over_once_and_no_text() {
for foreign in [false, true] {