//! The page colour of a new file follows the interface's theme when it is made (dark: #1E1E1E, //! light: #F5F5F5) and is the page's own from then on; an added page takes the colour of the page //! it was added from. A session without an interface (CLI, MCP, exports) has no theme and keeps the //! light default. Text drawn with the Text tool is white on a dark page and black on a light one. use figcraft_color::{Color, Paint}; use figcraft_doc::{NodeId, NodeKind}; use serde_json::{Value, json}; use crate::Session; use crate::params::id_of; use crate::tools::{PointerEvent, PointerKind}; fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } fn background(s: &Session, page: NodeId) -> Color { match s.doc().unwrap().node(page).unwrap().kind { NodeKind::Page { background, .. } => background, _ => panic!("{page} is not a page"), } } fn backgrounds(s: &Session) -> Vec { s.doc().unwrap().pages.iter().map(|p| background(s, *p).to_css_hex().to_uppercase()).collect() } /// Click with the Text tool at (x, y) on the current page; the new text layer. fn draw_text(s: &mut Session, x: f64, y: f64) -> NodeId { ex(s, "tool.text", json!({})); s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); let id = s.selection()[0]; ex(s, "text.edit.insert", json!({"text": "Hi"})); ex(s, "text.edit.end", json!({})); id } #[test] fn new_files_take_the_theme_they_are_made_in_and_added_pages_follow() { // (theme the interface reported, colour of a new file's page and of a page added to it) for (dark, want) in [(None, "#F5F5F5"), (Some(false), "#F5F5F5"), (Some(true), "#1E1E1E")] { let mut s = Session::empty(); if let Some(d) = dark { s.set_ui_dark(d); } ex(&mut s, "file.new", json!({})); ex(&mut s, "page.add", json!({})); assert_eq!(backgrounds(&s), [want, want], "theme {dark:?}"); // One undo step for the page, with its colour. ex(&mut s, "edit.undo", json!({})); ex(&mut s, "edit.redo", json!({})); assert_eq!(backgrounds(&s), [want, want], "theme {dark:?} after undo and redo"); } } #[test] fn a_change_of_theme_leaves_existing_pages_alone() { let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.new", json!({})); s.set_ui_dark(false); ex(&mut s, "page.add", json!({})); assert_eq!(backgrounds(&s), ["#1E1E1E", "#1E1E1E"], "an added page follows its page, not the theme"); ex(&mut s, "page.setBackground", json!({"color": "#F5F5F5"})); s.set_ui_dark(true); assert_eq!(backgrounds(&s), ["#1E1E1E", "#F5F5F5"], "each page keeps its colour"); // A copy of a page is that page's colour, whatever the theme. let first = s.doc().unwrap().pages[0]; s.set_ui_dark(false); let copy = id_of(&ex(&mut s, "page.duplicate", json!({"id": first}))["id"]).unwrap(); assert_eq!(background(&s, copy), Color::DARK_PAGE); } #[test] fn an_added_page_takes_the_current_pages_colour() { // Whatever the theme, and headless too: the page it was added from decides. for dark in [None, Some(false), Some(true)] { let mut s = Session::empty(); if let Some(d) = dark { s.set_ui_dark(d); } ex(&mut s, "file.new", json!({})); ex(&mut s, "page.setBackground", json!({"color": "#336699"})); ex(&mut s, "page.add", json!({})); assert_eq!(backgrounds(&s), ["#336699", "#336699"], "theme {dark:?}"); // From the second page, now current, after giving it its own colour. ex(&mut s, "page.setBackground", json!({"color": "#FFE08A"})); ex(&mut s, "page.add", json!({"name": "Third"})); assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?}"); // One undo step, the colour with it. ex(&mut s, "edit.undo", json!({})); assert_eq!(backgrounds(&s), ["#336699", "#FFE08A"], "theme {dark:?} after undo"); ex(&mut s, "edit.redo", json!({})); assert_eq!(backgrounds(&s), ["#336699", "#FFE08A", "#FFE08A"], "theme {dark:?} after redo"); } // From a page other than the first: go there, then add. let mut s = Session::new(); s.set_ui_dark(true); ex(&mut s, "page.add", json!({})); ex(&mut s, "page.setBackground", json!({"color": "#0B1F3A"})); ex(&mut s, "page.select", json!({"index": 0})); ex(&mut s, "page.add", json!({})); assert_eq!(backgrounds(&s), ["#1E1E1E", "#0B1F3A", "#1E1E1E"], "the current page decides, not the last"); } #[test] fn added_page_colours_survive_save_and_reopen() { let dir = std::env::temp_dir().join(format!("figcraft-added-pages-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("pages.figcraft"); let mut s = Session::new(); ex(&mut s, "page.setBackground", json!({"color": "#336699"})); ex(&mut s, "page.add", json!({})); ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); assert_eq!(backgrounds(&s), ["#336699", "#336699"]); // And a page added to the reopened file in the dark theme follows the file, not the theme. ex(&mut s, "page.add", json!({})); assert_eq!(backgrounds(&s), ["#336699", "#336699", "#336699"]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn the_file_the_app_opened_with_takes_the_theme_once_it_is_known() { // Made before the interface knew its theme, nothing done to it yet. let mut s = Session::new(); s.set_ui_dark(true); assert_eq!(backgrounds(&s), ["#1E1E1E"]); assert!(!s.ds().unwrap().is_dirty(), "still a file without changes"); assert!(s.ds().unwrap().history.undo.is_empty()); // A file with something in it, or one whose page colour was chosen, is left as it is. let mut s = Session::new(); ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); s.set_ui_dark(true); assert_eq!(backgrounds(&s), ["#F5F5F5"]); let mut s = Session::new(); ex(&mut s, "page.setBackground", json!({"color": "#336699"})); s.set_ui_dark(true); assert_eq!(backgrounds(&s), ["#336699"]); // Later changes of theme never repaint a page. let mut s = Session::new(); s.set_ui_dark(false); s.set_ui_dark(true); assert_eq!(backgrounds(&s), ["#F5F5F5"]); } #[test] fn opened_files_keep_their_page_colour() { let dir = std::env::temp_dir().join(format!("figcraft-theme-pages-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("light.figcraft"); let mut s = Session::new(); ex(&mut s, "file.saveAs", json!({"path": path.to_string_lossy()})); let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.open", json!({"path": path.to_string_lossy()})); assert_eq!(backgrounds(&s), ["#F5F5F5"]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn text_drawn_on_a_dark_page_is_white_and_on_a_light_page_black() { // (page colour, the new text's fill): by the page, not the theme. for (page, want) in [("#1E1E1E", Color::WHITE), ("#F5F5F5", Color::BLACK), ("#0B1F3A", Color::WHITE), ("#FFE08A", Color::BLACK)] { let mut s = Session::new(); ex(&mut s, "page.setBackground", json!({"color": page})); let t = draw_text(&mut s, 40.0, 40.0); assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(want)], "text drawn on {page}"); } // In a (white) frame on a dark page, readable on the frame (#362); so is text a command makes // in the app (`dark_page_text_tests`). let mut s = Session::empty(); s.set_ui_dark(true); ex(&mut s, "file.new", json!({})); ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 200})); let t = draw_text(&mut s, 50.0, 50.0); assert_eq!(s.doc().unwrap().node(t).unwrap().fills, vec![Paint::solid(Color::BLACK)]); let made = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 300}))["id"]).unwrap(); assert_eq!(s.doc().unwrap().node(made).unwrap().fills, vec![Paint::solid(Color::WHITE)]); } #[test] fn a_copy_of_a_page_keeps_its_flows_prototype_settings_and_guides() { let mut s = Session::new(); ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})); let start = ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))["id"].clone(); ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Checkout"})); ex(&mut s, "proto.settings", json!({"device": "iPhone 16", "responsive": true})); ex(&mut s, "guide.add", json!({"vertical": true, "offset": 25})); ex(&mut s, "select.none", json!({})); let source = s.page().unwrap(); let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); let frames = s.doc().unwrap().node(copy).unwrap().children.clone(); let state = |s: &Session, id: NodeId| match &s.doc().unwrap().node(id).unwrap().kind { NodeKind::Page { flows, prototype, guides, .. } => (flows.clone(), prototype.clone(), guides.clone()), _ => panic!("expected a page"), }; let (flows, prototype, guides) = state(&s, copy); let (src_flows, src_prototype, src_guides) = state(&s, source); assert_eq!(flows.len(), 1); assert_eq!(flows[0].name, "Checkout"); assert_eq!(flows[0].start, frames[1], "the flow starts at the copied frame"); assert_eq!(src_flows[0].start, id_of(&start).unwrap(), "the source keeps its own"); assert_eq!(prototype, src_prototype); assert_eq!(prototype.device, "iPhone 16"); assert!(prototype.responsive); assert_eq!(guides, src_guides); assert_eq!(guides.len(), 1); assert_eq!(ex(&mut s, "proto.present", json!({}))["start"].as_u64(), Some(frames[1].0)); } #[test] fn a_copy_of_a_page_keeps_a_flow_that_starts_in_a_section() { let mut s = Session::new(); let start = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); ex(&mut s, "object.wrapInSection", json!({"ids": [start]})); ex(&mut s, "proto.flow.add", json!({"start": start, "name": "Onboarding"})); let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); let doc = s.doc().unwrap(); let section = doc.node(copy).unwrap().children[0]; let frame = doc.node(section).unwrap().children[0]; match &doc.node(copy).unwrap().kind { NodeKind::Page { flows, .. } => assert_eq!(flows.iter().map(|f| (f.name.as_str(), f.start)).collect::>(), vec![("Onboarding", frame)]), _ => panic!("expected a page"), } }