mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 04:46:50 -06:00
refactor(ui-egui): split lib.rs into app.rs, services.rs, state.rs, clock.rs and open.rs
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
/// How often open documents get an autosave checkpoint (seconds).
|
||||
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
|
||||
|
||||
/// Most changed layers whose names are checked for CJK scripts one by one (beyond: all of them).
|
||||
pub(crate) const CJK_CHANGES: usize = 10_000;
|
||||
|
||||
/// With auto-save on: how long after a file last changed it is saved, and how long a failed save
|
||||
/// waits before trying again (seconds).
|
||||
pub const AUTO_SAVE_SECS: f64 = 3.0;
|
||||
|
||||
pub(crate) const AUTO_SAVE_RETRY_SECS: f64 = 60.0;
|
||||
|
||||
/// How long a toast shows (seconds).
|
||||
pub(crate) const TOAST_SECS: f64 = 3.0;
|
||||
|
||||
/// How long a toast has been showing ([`UiState::toast`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ToastClock {
|
||||
/// Raised, not drawn yet.
|
||||
Raised,
|
||||
/// Drawn once; the next frame starts the clock.
|
||||
Drawn,
|
||||
/// Showing since this time (UI clock, seconds).
|
||||
Since(f64),
|
||||
}
|
||||
|
||||
/// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws
|
||||
/// no frames).
|
||||
#[track_caller]
|
||||
pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) {
|
||||
if secs.is_finite() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs.max(0.0)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A monotonic clock for background work (autosave, recovery, capture deadlines).
|
||||
///
|
||||
/// `Context::input(|i| i.time)` is the UI pass's time and is stale in `run_logic`; wall-clock time
|
||||
/// can jump. `web_time::Instant` is monotonic on native and in the browser. Tests drive deadlines
|
||||
/// directly with [`FrameClock::set_test_now`].
|
||||
pub struct FrameClock {
|
||||
origin: web_time::Instant,
|
||||
test_now: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for FrameClock {
|
||||
/// Start the frame clock at the current web time with no injected test time.
|
||||
fn default() -> Self {
|
||||
Self { origin: web_time::Instant::now(), test_now: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameClock {
|
||||
/// Seconds since the app started (or the injected test time).
|
||||
pub fn now(&self) -> f64 {
|
||||
self.test_now.unwrap_or_else(|| self.origin.elapsed().as_secs_f64())
|
||||
}
|
||||
|
||||
/// Set a fake monotonic time (tests); `None` returns to the real clock.
|
||||
pub fn set_test_now(&mut self, secs: Option<f64>) {
|
||||
self.test_now = secs;
|
||||
}
|
||||
}
|
||||
+13
-1666
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use super::services::browser_can_read;
|
||||
use super::services::{ReadFuture, unreadable};
|
||||
use super::*;
|
||||
|
||||
/// The file types File ▸ Open and a drop on the home screen open.
|
||||
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
|
||||
|
||||
/// Whether `path` names a file type that opens as a document (the extension, in any case).
|
||||
pub(crate) fn opens(path: &Path) -> bool {
|
||||
path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.sketch`, file JSON) rather than an image or SVG:
|
||||
/// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one.
|
||||
pub(crate) fn is_document(path: &Path) -> bool {
|
||||
path.extension().and_then(OsStr::to_str).is_some_and(|e| ["figcraft", "fig", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// 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, name: &str) -> ReadFuture {
|
||||
Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name))))
|
||||
}
|
||||
|
||||
/// 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, 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,
|
||||
/// over everything else, saying what a drop does there.
|
||||
pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) {
|
||||
if ctx.input(|i| i.raw.hovered_files.is_empty()) {
|
||||
return;
|
||||
}
|
||||
let t = theme::Tokens::get(ctx);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop_overlay")));
|
||||
let area = rect.shrink(4.0);
|
||||
painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12));
|
||||
painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside);
|
||||
let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text);
|
||||
let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0));
|
||||
painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel);
|
||||
painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, theme::ACCENT), egui::StrokeKind::Inside);
|
||||
painter.galley(pill.center() - galley.size() / 2.0, galley, t.text);
|
||||
// Hovering sends no pointer events on every platform: keep the overlay current.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use super::*;
|
||||
|
||||
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
|
||||
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))
|
||||
}
|
||||
/// The platform saves files by handing them over as downloads (the browser), not through
|
||||
/// save dialogs.
|
||||
fn downloads(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Hand `bytes` over as a download named `name`; false when nothing was delivered.
|
||||
fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
/// A folder to write several files into.
|
||||
fn pick_folder(&mut self, _title: &str) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
fn clipboard_set_text(&mut self, _s: &str) {}
|
||||
/// Give native services access to the window context for platform clipboard output.
|
||||
fn set_egui_context(&mut self, _ctx: &egui::Context) {}
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn clipboard_get_html(&mut self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {}
|
||||
/// An image on the system clipboard as (width, height, straight RGBA).
|
||||
fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
None
|
||||
}
|
||||
fn open_url(&mut self, _url: &str) {}
|
||||
/// Show the file at `path` in the platform's file manager (Finder, Explorer), selected where the
|
||||
/// platform can.
|
||||
fn reveal_path(&mut self, _path: &std::path::Path) {}
|
||||
/// The system asks for reduced motion (its accessibility setting).
|
||||
fn reduce_motion(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// The system asks for the dark interface (the freedesktop colour scheme): for platforms whose
|
||||
/// windowing stack doesn't report a theme (winit gives none on Linux and the BSDs).
|
||||
fn system_dark(&self) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
/// After a panic in a frame: keep recovery copies of unsaved files.
|
||||
fn emergency_save(&mut self, _session: &Session) {}
|
||||
/// Keep the preferences now (the installed plugins or their settings changed), as the app
|
||||
/// does when it quits.
|
||||
fn save_prefs(&mut self, _prefs: &figcraft_engine::Prefs) {}
|
||||
/// The OS or browser language as a tag ("es-ES"), for the "system" language preference.
|
||||
fn system_language(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
/// The controller buttons held now on any controller: bit i is standard gamepad layout index i
|
||||
/// (0 the bottom face button … 16 home; `figcraft_doc::proto::GAMEPAD_BUTTONS`). The prototype
|
||||
/// player asks every frame while its prototype has gamepad triggers.
|
||||
fn gamepad_held(&mut self) -> u32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<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>>>>;
|
||||
|
||||
pub(crate) type PickFuture = Pin<Box<dyn Future<Output = Option<Picked>>>>;
|
||||
|
||||
pub(crate) 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.
|
||||
pub(crate) 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).
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
/// Wakes the main window, which hands the result on to the window it belongs to.
|
||||
pub(crate) struct RepaintWaker(pub(crate) egui::Context);
|
||||
|
||||
impl Wake for RepaintWaker {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
|
||||
fn wake_by_ref(self: &Arc<Self>) {
|
||||
self.0.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op services (tests, headless screenshots).
|
||||
pub struct NoServices;
|
||||
|
||||
impl Services for NoServices {}
|
||||
|
||||
/// Put `text` on the system clipboard through egui's platform output. Native integrations handle
|
||||
/// it directly; the browser shell writes it while handling the copy event.
|
||||
pub fn egui_copy_text(ctx: &egui::Context, text: &str) {
|
||||
ctx.copy_text(text.to_string());
|
||||
}
|
||||
|
||||
/// Put a straight-RGBA image on the system clipboard through egui's output (see
|
||||
/// [`egui_copy_text`]); a buffer that doesn't match its size is dropped.
|
||||
pub fn egui_copy_png(ctx: &egui::Context, w: u32, h: u32, rgba: &[u8]) {
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) {
|
||||
return;
|
||||
}
|
||||
ctx.copy_image(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum RightTab {
|
||||
Design,
|
||||
Prototype,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum LeftTab {
|
||||
File,
|
||||
Assets,
|
||||
}
|
||||
|
||||
/// The window's title bar.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum TitleBar {
|
||||
/// The system draws it (and the browser): the tab strip sits below.
|
||||
#[default]
|
||||
Native,
|
||||
/// A frameless window: the tab strip is the title bar, with our own window buttons and edges.
|
||||
Custom,
|
||||
/// macOS: the system's window buttons inset at the left of the tab strip.
|
||||
Inset,
|
||||
}
|
||||
|
||||
/// A file being encoded for auto-save: its snapshot and, once done, its bytes.
|
||||
pub type AutoSaveEncoding = std::thread::JoinHandle<(figcraft_engine::cmd::file::FileSnapshot, figcraft_engine::Result<Vec<u8>>)>;
|
||||
|
||||
/// The side panels' widths: the least and most a drag of their edge gives, what a double-click on
|
||||
/// the edge puts back, and how much of the window they leave the canvas.
|
||||
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.
|
||||
pub(crate) const MOBILE_STRIP: f32 = 48.0;
|
||||
|
||||
/// The side panel a narrow window shows.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum MobilePanel {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
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
|
||||
/// the widths asked for (none while a panel is closed, which takes no room): each within its
|
||||
/// limits, and narrower in a small window so the canvas keeps [`CANVAS_MIN`] (the right panel
|
||||
/// gives way last).
|
||||
pub fn fit_panels(avail: f32, left: Option<f32>, right: Option<f32>) -> (f32, f32) {
|
||||
let fit = |w: f32, range: &std::ops::RangeInclusive<f32>, room: f32| {
|
||||
let w = if w.is_finite() { w } else { PANEL_DEFAULT };
|
||||
w.clamp(*range.start(), room.clamp(*range.start(), *range.end()))
|
||||
};
|
||||
let left_min = left.map_or(0.0, |_| *LEFT_PANEL.start());
|
||||
let right = right.map_or(0.0, |r| fit(r, &RIGHT_PANEL, avail - left_min - CANVAS_MIN));
|
||||
(left.map_or(0.0, |l| fit(l, &LEFT_PANEL, avail - right - CANVAS_MIN)), right)
|
||||
}
|
||||
Reference in New Issue
Block a user