From ac1a192a3aa6c4acdd3108956d2e7c4b1dc6076c Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 17:25:13 +0500 Subject: [PATCH] M7: keep mobile presenter fixed and confirm window close --- ROADMAP.md | 1 + crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/present.rs | 90 ++++++++++++++++++++++++---- crates/ui-egui/src/tabs.rs | 10 +++- crates/ui-egui/src/titlebar_tests.rs | 19 +++++- crates/ui-egui/src/windows.rs | 9 ++- crates/ui-egui/src/windows_tests.rs | 19 ++++++ docs/gaps.md | 6 +- 8 files changed, 142 insertions(+), 17 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b54a43ec..ce132520 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | The full presenter now scrolls content beyond a responsive phone or tablet screen in both directions. A drag that starts inside the screen continues when the pointer leaves it; the device body stays fixed. Computer presets keep their ordinary scrolling rules. The unsaved-changes dialog appears over presentation when closing a window. The saved design is unchanged. Numbers unchanged. | | 2026-10-16 | minor | Choosing a device in the presenter now enables responsive layout at that device's screen size, clears offsets from the previous screen, and lets No device restore the design's frame size. Tablet and computer presets show a bare screen at their resolution; phones keep their device body. The saved frame geometry is unchanged. Numbers unchanged. | | 2026-10-15 | minor | Presenter has a separate tab, keeps its current screen when returning to design, and adds a frame thumbnail, device picker and scale controls. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91f7a56b..97b2be71 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1312,9 +1312,14 @@ impl FigApp { self.end_pen_outside_design(); tabs::tab_bar(self, ui); if self.ui.present.is_some() { + // A close request can arrive while the presenter fills the window. Let its unsaved + // changes dialog take Escape and draw above the player just as it does in the editor. + dialogs::keys(self, &ctx); present::show(self, ui); + dialogs::show(self, &ctx); // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); + windows::requests(self, &ctx); return; } if !self.has_files() || self.ui.home { diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9fc85337..5a9d4bf3 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -541,7 +541,13 @@ impl Presenter { /// device's screen, other frames within themselves. fn range(&self, doc: &Document, frame: NodeId) -> (f64, f64) { let vp = if frame == self.current { viewport(doc, frame) } else { None }; - scroll_range_in(doc, frame, vp) + // In a responsive preview, make content outside the device reachable even if the source + // frame has no overflow mode. Its settings and geometry stay untouched in the file. + let mobile = frame == self.current && page_settings(doc, frame).device().is_some_and(|d| d.rotates); + let overflow = doc + .node(frame) + .map_or(Overflow::None, |n| if mobile && self.fit.is_some() && n.overflow == Overflow::None { Overflow::Both } else { n.overflow }); + scroll_range_with_overflow(doc, frame, vp, overflow) } /// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the @@ -1171,8 +1177,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// frame taller than the screen scrolls through its whole height. Without a viewport the frame /// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { + let overflow = doc.node(frame).map_or(Overflow::None, |n| n.overflow); + scroll_range_with_overflow(doc, frame, viewport, overflow) +} + +fn scroll_range_with_overflow(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>, overflow: Overflow) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if overflow == Overflow::None { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1197,8 +1208,8 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let rx = if matches!(overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; + let ry = if matches!(overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -2367,7 +2378,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible)) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -2574,14 +2585,21 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke } return; } - // Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view - // bigger than the window. + // A drag begun inside a mobile screen moves its content, even if the pointer leaves the + // screen. The device body stays in place even when the drag begins outside its screen. + // Panning the whole view remains available only without a mobile device. if let Some(base) = p.doc.clone() { - let target = scrollers_at(p, &base, doc, root, &place, start) - .first() + let horizontal = d.x.abs() >= d.y.abs(); + let scrolls = scrollers_at(p, &base, doc, root, &place, start); + let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 }; + let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates); + let target = scrolls + .iter() .copied() + .find(|f| on_axis(p.range(&base, *f))) + .or_else(|| scrolls.first().copied()) .map(Scroller::Frame) - .or((p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); + .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View)); if let Some(t) = target { p.fling = None; p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t)))); @@ -2869,6 +2887,14 @@ mod tests { assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(pos2(720.0, 440.0), true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + ui.button(pos2(300.0, 300.0), false); + assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + let mut presenter = ui.app.ui.present.take().unwrap(); choose_device(&mut ui.app, &mut presenter, "None"); ui.app.ui.present = Some(presenter); @@ -2905,6 +2931,50 @@ mod tests { } } + #[test] + fn responsive_device_scrolls_content_in_both_directions_without_changing_saved_overflow() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let range = &ui.app.inspect()["presenter"]["scrollRange"]; + assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + let start = pos2(720.0, 440.0); + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(start - vec2(100.0, 120.0))]); + ui.button(start - vec2(100.0, 120.0), false); + let scroll = &ui.app.inspect()["presenter"]["scroll"]; + assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().overflow, Overflow::None); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + // Like the phone preview, the content keeps following a drag after the pointer leaves + // the screen, while the device itself stays fixed on the stage. + ui.frames(60, Vec::new); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + let during = ui.app.inspect()["presenter"].clone(); + assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); + let screen_after = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + assert_eq!(screen_after, screen_before); + ui.button(pos2(300.0, 300.0), false); + + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "Desktop"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + let desktop = ui.app.inspect()["presenter"].clone(); + assert_eq!(desktop["scrollRange"], json!([0.0, 0.0])); + } + fn doc_with_two_frames() -> (Document, NodeId, NodeId) { let mut d = Document::new("t"); let page = d.add_page("Page 1"); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 534881fc..381b09dd 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -28,7 +28,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); } } - let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(ui, rect) } else { 0.0 }; + 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. let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT)); @@ -256,7 +256,7 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option { } /// Minimise, maximise/restore and close at the right end of the title bar; returns their width. -fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { +fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 { const W: f32 = 46.0; let t = Tokens::get(ui.ctx()); let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); @@ -284,7 +284,11 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 { } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col); if resp.clicked() { - ui.ctx().send_viewport_cmd(cmd); + if close { + crate::windows::request_close(app, ui.ctx()); + } else { + ui.ctx().send_viewport_cmd(cmd); + } } } W * 3.0 diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 796cb670..e9224404 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -4,6 +4,7 @@ use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2}; use figcraft_engine::Session; +use serde_json::json; use crate::{FigApp, NoServices, TitleBar}; @@ -76,7 +77,23 @@ fn window_buttons_minimise_maximise_and_close() { let mut u = Ui::new(TitleBar::Custom); assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); - assert!(u.click(window_button(2)).contains(&ViewportCommand::Close)); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { + let mut u = Ui::new(TitleBar::Custom); + let frame = u.app.session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + u.app.session.execute("proto.present", &json!({"start": frame})).unwrap(); + u.app.handle_ui_requests(); + u.frame(vec![]); + assert!(u.app.ui.present.is_some()); + + let commands = u.click(window_button(2)); + assert!(!commands.contains(&ViewportCommand::Close)); + assert!(u.app.ui.confirm_close); + assert!(u.ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some())); } #[test] diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 510c24f6..30b71f34 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -300,7 +300,12 @@ pub fn on_close_request(app: &mut FigApp, ctx: &Context) { if app.windows.current == 0 && (unsaved || !app.windows.list.is_empty()) { ctx.send_viewport_cmd(ViewportCommand::CancelClose); } - if unsaved { + request_close(app, ctx); +} + +/// The custom title bar's Close button uses the same unsaved-file check as a system close event. +pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) { + if !app.unsaved().is_empty() { app.ui.confirm_close = true; ctx.request_repaint(); } else { @@ -325,7 +330,7 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { } let t = crate::theme::Tokens::get(ctx); let (mut open, mut discard, mut cancel) = (true, false, false); - crate::dialogs::window(tr("Unsaved changes"), ctx).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1a6e91bb..06e0a6a1 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,25 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +#[test] +fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { + let mut session = Session::new(); + let frame_id = session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(session, Box::new(NoServices)); + frame(&ctx, &mut app, false); + app.session.execute("proto.present", &json!({"start": frame_id})).unwrap(); + app.handle_ui_requests(); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some()); + + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close); + assert!(ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some()), "dialog must be on screen"); + frame(&ctx, &mut app, false); + assert!(app.ui.present.is_some(), "asking to close does not dismiss the preview"); +} + #[test] fn windows_hold_tabs_and_close_with_their_files() { let mut s = Session::empty(); diff --git a/docs/gaps.md b/docs/gaps.md index 1e2cd114..94c387e5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -144,7 +144,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-16):** choosing a presenter device reflows a copy of the frame to its screen size using constraints and auto layout, clears the previous screen's scroll and pan, and keeps the file's frame geometry unchanged; No device restores the original size. Tablet and computer - presets show the screen at their resolution without a drawn hardware body. + presets show the screen at their resolution without a drawn hardware body. In the full presenter, + dragging inside a phone or tablet scrolls content in either direction when it exceeds the screen, + even when the pointer moves outside the device. The device remains fixed; computer presets keep + their ordinary scrolling rules. These gestures do not alter the file. Closing a window from + presentation shows the unsaved-changes dialog over the presenter. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).