mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
Merge main into #861
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}×");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
Reference in New Issue
Block a user