Merge pull request #1209 from aryann2611/mobile-layout

M6: mobile layout (phones and portrait tablets)
This commit is contained in:
BFlat
2026-10-26 10:20:07 +07:00
committed by GitHub
8 changed files with 197 additions and 30 deletions
+3 -3
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
@media (prefers-color-scheme: dark) {
+5 -2
View File
@@ -513,8 +513,11 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
return true;
}
let ctx = ui.ctx().clone();
let (mods, scroll, zoom_delta, hover_pos, space) =
ctx.input(|i| (i.modifiers, i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)));
// Two fingers pan as well as pinch (a touch screen has no scroll wheel).
let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
(i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
});
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
let typing_elsewhere = crate::a11y::field_focused(&ctx);
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
+4 -4
View File
@@ -118,10 +118,10 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) {
Some(tb) => {
// Leaving the version history shows the tab rather than collapsing it.
if active {
app.ui.left_collapsed = true;
app.ui.close_left_panel();
} else {
app.ui.left_tab = tb;
app.ui.left_collapsed = false;
app.ui.open_left_panel();
}
app.ui.versions = Default::default();
}
@@ -163,7 +163,7 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
}
icons::paint(ui.painter(), hr.shrink(5.0), Icon::Panels, t.icon);
if hresp.clicked() {
app.ui.left_collapsed = true;
app.ui.close_left_panel();
}
ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border));
match app.ui.left_tab {
@@ -637,7 +637,7 @@ pub(crate) fn start_rename(app: &mut FigApp, ids: &[NodeId]) {
let rows = listed_rows(&doc, page, &app.ui.expanded, &app.ui.layer_filter.trim().to_lowercase());
let Some(id) = rows.iter().map(|r| r.id).find(|r| ids.contains(r)) else { return };
app.ui.left_tab = LeftTab::File;
app.ui.left_collapsed = false;
app.ui.open_left_panel();
rename_layer(app, id);
}
+94 -10
View File
@@ -521,6 +521,34 @@ impl FrameClock {
pub const LEFT_PANEL: std::ops::RangeInclusive<f32> = 200.0..=480.0;
pub const RIGHT_PANEL: std::ops::RangeInclusive<f32> = 240.0..=480.0;
pub const PANEL_DEFAULT: f32 = 240.0;
/// Windows narrower than this (logical points: phones, portrait tablets) show one side panel at a
/// time over the canvas, opened from two buttons, instead of both beside it.
pub const NARROW_WIDTH: f32 = 760.0;
/// What an open side panel leaves of a narrow window: the column of panel buttons.
const MOBILE_STRIP: f32 = 48.0;
/// The side panel a narrow window shows.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum MobilePanel {
Left,
Right,
}
impl UiState {
/// Opens the left panel; a narrow window shows it in place of the right one.
pub fn open_left_panel(&mut self) {
self.left_collapsed = false;
self.mobile_panel = Some(MobilePanel::Left);
}
/// Closes the left panel.
pub fn close_left_panel(&mut self) {
self.left_collapsed = true;
if self.mobile_panel == Some(MobilePanel::Left) {
self.mobile_panel = None;
}
}
}
pub const CANVAS_MIN: f32 = 160.0;
/// The widths the side panels are drawn at in `avail` points (the window less the icon rail), from
@@ -675,6 +703,10 @@ pub struct UiState {
/// The Rename layers dialog (several layers), while open.
pub batch_rename: Option<dialogs::BatchRenameUi>,
pub left_collapsed: bool,
/// The side panel open in a narrow window (a phone), where only one shows at a time.
pub mobile_panel: Option<MobilePanel>,
/// The window was narrow (one side panel at a time) at the last frame.
pub narrow: bool,
pub comments: comments::CommentUi,
pub versions: versions::VersionUi,
/// The branch review dialog, while open.
@@ -820,6 +852,8 @@ impl Default for UiState {
find: None,
batch_rename: None,
left_collapsed: false,
mobile_panel: None,
narrow: false,
comments: comments::CommentUi::default(),
versions: versions::VersionUi::default(),
branch_review: None,
@@ -1256,7 +1290,14 @@ impl FigApp {
"panel.devMode" => {
self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect };
}
"panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed,
"panel.leftSidebar" => {
let shown = if self.ui.narrow { self.ui.mobile_panel == Some(MobilePanel::Left) } else { !self.ui.left_collapsed };
if shown {
self.ui.close_left_panel();
} else {
self.ui.open_left_panel();
}
}
"panel.widths" => {
let width = |k: &str| r.data.get(k).and_then(Value::as_f64).map(|w| w as f32);
let (left, right) = (width("left"), width("right"));
@@ -1271,7 +1312,7 @@ impl FigApp {
"panel.layers" => self.ui.left_tab = LeftTab::File,
"panel.assets" => {
self.ui.left_tab = LeftTab::Assets;
self.ui.left_collapsed = false;
self.ui.open_left_panel();
self.ui.versions = Default::default();
}
"panel.versionHistory" => versions::open(self),
@@ -1280,7 +1321,7 @@ impl FigApp {
}
"panel.libraries" => {
self.ui.left_tab = LeftTab::Assets;
self.ui.left_collapsed = false;
self.ui.open_left_panel();
self.ui.libraries = true;
}
"home" => self.ui.home = true,
@@ -1900,6 +1941,32 @@ impl FigApp {
!hidden && (!board || self.ui.board_panel)
}
/// A narrow window's two buttons for the side panels (touch-sized); the open one closes again.
fn mobile_buttons(&mut self, ctx: &egui::Context, at: egui::Pos2) {
use a11y::Tip as _;
let t = theme::Tokens::get(ctx);
egui::Area::new(egui::Id::unique("mobile_panel_buttons")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| {
for (side, icon, name) in
[(MobilePanel::Left, icons::Icon::Layers, i18n::tr("Layers")), (MobilePanel::Right, icons::Icon::Panels, i18n::tr("Design"))]
{
let open = self.ui.mobile_panel == Some(side);
let (r, resp) = ui.allocate_exact_size(egui::vec2(40.0, 40.0), egui::Sense::click());
let resp = resp.tip(name);
let fill = if open { t.accent } else { t.panel };
ui.painter().rect(r, 8.0, fill, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), r.shrink(10.0), icon, if open { t.accent_on } else { t.icon });
if resp.clicked() {
if side == MobilePanel::Left && !open {
self.ui.open_left_panel();
} else {
self.ui.mobile_panel = if open { None } else { Some(side) };
}
}
ui.add_space(4.0);
}
});
}
/// One frame of the window drawing now.
pub(crate) fn frame(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
@@ -1967,8 +2034,12 @@ impl FigApp {
return;
}
let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false);
let right_open = self.right_panel_shown(hidden);
if !hidden {
let narrow = !hidden && ui.available_width() < NARROW_WIDTH;
let mobile = self.ui.mobile_panel.filter(|_| narrow);
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow) || mobile == Some(MobilePanel::Right);
if !hidden && (!narrow || mobile == Some(MobilePanel::Left)) {
egui::Panel::left("left_rail")
.exact_size(56.0)
.resizable(false)
@@ -1979,12 +2050,19 @@ impl FigApp {
});
}
// The panels are as wide as asked, less in a window too small for them and the canvas.
let left_open = !hidden && !self.ui.left_collapsed;
let (left_w, right_w) = fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width));
let left_open = !hidden && if narrow { mobile == Some(MobilePanel::Left) } else { !self.ui.left_collapsed };
let (left_w, right_w) = if narrow {
let w = (ui.available_width() - MOBILE_STRIP).max(0.0);
(if left_open { w } else { 0.0 }, w)
} else {
fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width))
};
let rows = ui.available_rect_before_wrap().y_range();
let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new();
if left_open {
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
if !narrow {
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
}
egui::Panel::left("left_panel")
.resizable(false)
.exact_size(left_w)
@@ -2000,7 +2078,9 @@ impl FigApp {
});
}
if right_open {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
if !narrow {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
}
egui::Panel::right("right_panel")
.resizable(false)
.exact_size(right_w)
@@ -2020,13 +2100,17 @@ impl FigApp {
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
canvas::show(self, ui, &mut worker);
self.worker = Some(worker);
if !hidden {
if !hidden && mobile.is_none() {
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
a11y::container(ui, egui::accesskit::Role::Toolbar, i18n::tr("Tools"));
toolbar::show(self, ui);
});
}
});
if narrow {
let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0));
}
// The panels' edges, over the canvas and the panels (drawn after them: they take the press).
for (name, x, shown, grows) in edges {
if let Some(w) = Self::panel_edge(ui, name, x, rows, shown, grows) {
+14 -5
View File
@@ -265,7 +265,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
// The file name in the left panel's header turns into its editor, the name selected.
"file.rename" => {
app.ui.left_tab = crate::LeftTab::File;
app.ui.left_collapsed = false;
app.ui.open_left_panel();
app.ui.rename_file = true;
}
"text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())),
@@ -280,7 +280,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"tool.set" => app.ui.tool_flyout = Some("move"),
"page.select" => {
app.ui.left_tab = crate::LeftTab::File;
app.ui.left_collapsed = false;
app.ui.open_left_panel();
app.ui.pages_open = true;
}
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
@@ -710,10 +710,19 @@ fn submenu_at<R>(
if !open {
return None;
}
let pos = pos2(r.max.x + gap, r.min.y - 8.0);
let mut pos = pos2(r.max.x + gap, r.min.y - 8.0);
// No room beside the row (a phone): the submenu covers its parent instead of running off screen.
let screen = ui.ctx().content_rect();
let cramped = pos.x + MENU_W > screen.max.x;
if cramped {
pos.x = (screen.max.x - MENU_W - 8.0).max(screen.min.x + 4.0);
}
let child = At { root: at.root, path: key.to_string() };
let shown =
egui::Area::new(area).fixed_pos(pos).order(egui::Order::Foreground).constrain(constrain).show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
let shown = egui::Area::new(area)
.fixed_pos(pos)
.order(egui::Order::Foreground)
.constrain(constrain || cramped)
.show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
let panel = shown.response.rect;
with_level(ui.ctx(), &at, |l| l.panel = Some(panel));
shown.inner
+33
View File
@@ -229,3 +229,36 @@ fn the_smallest_window_keeps_its_canvas_whatever_the_widths() {
assert_eq!((app.ui.left_width, app.ui.right_width), asked, "the widths asked for come back in a wider window");
}
}
/// A phone-sized window: the canvas has the whole width, and each side panel opens alone over it,
/// leaving the strip with the panel buttons. A wide window ignores the choice.
#[test]
fn a_narrow_window_shows_one_side_panel_at_a_time() {
use crate::MobilePanel;
let ctx = egui::Context::default();
let mut app = FigApp::new(Session::new(), Box::new(NoServices));
let mut k = 0.0;
let mut canvas_width = |app: &mut FigApp, size: egui::Vec2| {
for _ in 0..4 {
k += 1.0 / 60.0;
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, size)), time: Some(k), ..Default::default() };
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
}
app.canvas.rect.width()
};
let phone = vec2(390.0, 844.0);
assert!(app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).is_ok());
assert!(canvas_width(&mut app, phone) >= 380.0, "closed panels leave the whole width");
for side in [MobilePanel::Left, MobilePanel::Right] {
app.ui.mobile_panel = Some(side);
let w = canvas_width(&mut app, phone);
assert!((1.0..=60.0).contains(&w), "{side:?}: the open panel leaves {w}");
}
// A command that opens the left panel opens it on a phone too, in place of the right one.
app.run("view.assets", json!({}));
let w = canvas_width(&mut app, phone);
assert!((1.0..=60.0).contains(&w) && app.ui.mobile_panel == Some(MobilePanel::Left), "view.assets leaves {w}");
app.ui.mobile_panel = Some(MobilePanel::Right);
let wide = canvas_width(&mut app, vec2(1440.0, 900.0));
assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})");
}
+43 -5
View File
@@ -142,9 +142,13 @@ fn main_width(groups: &[Group], board: bool) -> f32 {
/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).
fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {
let groups = if board { BOARD_GROUPS } else { GROUPS };
let rect = pill(ui, &mut app.ui.nav, canvas, main_width(groups, board), t);
let width = main_width(groups, board);
let rect = pill(ui, &mut app.ui.nav, canvas, width, t);
let cy = rect.center().y;
let mut x = rect.min.x + 8.0;
let scroll = pill_scroll(ui, rect, width);
let outer_clip = ui.clip_rect();
ui.set_clip_rect(rect.intersect(outer_clip));
let mut x = rect.min.x + 8.0 - scroll;
for g in groups {
x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);
}
@@ -161,7 +165,23 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board:
} else {
mode_switcher(app, ui, x, cy, t);
}
help_button(app, ui, canvas, t);
ui.set_clip_rect(outer_clip);
help_button(app, ui, canvas, rect, t);
}
/// How far the buttons of a pill `width` wide drawn at `rect` are scrolled: wider than the pill (a
/// phone), they slide sideways under a drag or the wheel.
fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 {
let scroll_id = ui.scope_id().with("tb_scroll");
let max_scroll = (width - rect.width()).max(0.0);
let mut scroll = 0.0;
if max_scroll > 0.0 {
let drag = ui.ctx().read_response(ui.scope_id().with("pill")).map_or(0.0, |r| r.drag_delta().x);
let wheel = if ui.rect_contains_pointer(rect) { ui.input(|i| i.smooth_scroll_delta.x + i.smooth_scroll_delta.y) } else { 0.0 };
scroll = (ui.data(|d| d.get_temp::<f32>(scroll_id)).unwrap_or(0.0) - drag - wheel).clamp(0.0, max_scroll);
}
ui.data_mut(|d| d.insert_temp(scroll_id, scroll));
scroll
}
/// A toolbar button's size.
@@ -303,8 +323,12 @@ fn pick_mode(app: &mut FigApp, i: usize) {
}
/// The floating help button at the bottom right of the canvas.
fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) {
let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
fn help_button(app: &mut FigApp, ui: &mut Ui, canvas: Rect, pill: Rect, t: &Tokens) {
let mut hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
// Above the pill when they'd overlap (a narrow window).
if hr.intersects(pill) {
hr = hr.translate(vec2(0.0, -(pill.height() + 12.0)));
}
let hresp = ui.interact(hr, ui.scope_id().with("help"), Sense::click()).tip(tr("Help and resources"));
ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20));
ui.painter().circle_filled(hr.center(), 16.0, t.panel);
@@ -447,7 +471,9 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
}
/// Where a toolbar pill `width` wide sits: centred 12 px above the canvas's bottom edge.
/// (Never wider than the canvas less a margin: on a phone the main toolbar scrolls inside it.)
pub(crate) fn pill_rect(canvas: Rect, width: f32) -> Rect {
let width = width.min((canvas.width() - 16.0).max(0.0));
Rect::from_center_size(pos2(canvas.center().x, canvas.max.y - 12.0 - 24.0), vec2(width, 48.0))
}
@@ -609,6 +635,18 @@ mod tests {
use super::*;
use egui::Shape;
#[test]
fn pill_never_outgrows_a_phone_canvas() {
let full = main_width(GROUPS, false);
let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0));
let pill = pill_rect(phone, full);
assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");
// A desktop canvas keeps the full width, and a degenerate one doesn't go negative.
let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));
assert_eq!(pill_rect(wide, full).width(), full);
assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0);
}
/// 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();
+1 -1
View File
@@ -50,7 +50,7 @@ impl Form {
/// Open the panel (File ▸ Show version history).
pub fn open(app: &mut FigApp) {
app.ui.versions.open = true;
app.ui.left_collapsed = false;
app.ui.open_left_panel();
app.ui.left_tab = crate::LeftTab::File;
}