mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 06:46:50 -06:00
refactor(ui-egui): split app.rs into app/
This commit is contained in:
+9
-969
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
/// Put `text` on the system clipboard. Input injected through the control channel never
|
||||
/// replaces what the user copied: this is the one write path every copy command goes through.
|
||||
pub(crate) fn clipboard_set_text(&mut self, text: &str) {
|
||||
if self.control_synthetic {
|
||||
return;
|
||||
}
|
||||
self.services.clipboard_set_text(text);
|
||||
}
|
||||
|
||||
/// [`Self::clipboard_set_text`] for an image (straight RGBA).
|
||||
pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) {
|
||||
if self.control_synthetic {
|
||||
return;
|
||||
}
|
||||
self.services.clipboard_set_png(width, height, rgba);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
/// Dismiss the composer after a successful switch away from the Comment tool.
|
||||
pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) {
|
||||
if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment {
|
||||
self.ui.comments.draft = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command, surfacing errors as a toast. Returns the result.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Option<Value> {
|
||||
let layers_generation = self.session.clipboard.layers_generation;
|
||||
let r = self.session.execute(id, ¶ms);
|
||||
self.clear_comment_draft_after_tool_set(id, r.is_ok());
|
||||
self.handle_ui_requests();
|
||||
self.invalidate_synthetic_copy(layers_generation);
|
||||
// A command may change the file the window shows (`doc.switch`, `doc.nextTab`,
|
||||
// `file.open`/`file.close`). Record that presentation before returning, so an in-flight
|
||||
// screenshot admitted on the old file cannot accept pixels from the new one.
|
||||
self.observe_presentation();
|
||||
r.map_err(|e| self.failed(id, &e)).ok()
|
||||
}
|
||||
|
||||
/// A programmatic command that changed the engine clipboard never wrote the system clipboard:
|
||||
/// forget the old system mirror so a later user paste does not trust it as our own copy.
|
||||
/// `before` is the layers generation captured before the command ran.
|
||||
pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) {
|
||||
if self.control_synthetic && self.session.clipboard.layers_generation != before {
|
||||
self.ui.last_copy = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the file the window drawing now shows, with a monotonic generation that changes on
|
||||
/// every switch (an A→B→A switch is two generations, never the admitted one again).
|
||||
pub(crate) fn observe_presentation(&mut self) {
|
||||
let window = self.windows.current;
|
||||
self.windows.presentation(&self.session, window);
|
||||
}
|
||||
|
||||
/// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file):
|
||||
/// a file it imports shows its import report. Programmatic calls use `run` and never open one.
|
||||
pub fn run_user(&mut self, id: &str, params: Value) -> Option<Value> {
|
||||
let result = self.run(id, params.clone());
|
||||
if let Some(result) = &result {
|
||||
import_report::capture(self, id, ¶ms, result);
|
||||
}
|
||||
missing_fonts::check_later(self, id, result.as_ref());
|
||||
result
|
||||
}
|
||||
|
||||
/// Show command `id`'s error.
|
||||
pub(crate) 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);
|
||||
}
|
||||
|
||||
/// Run a command that may talk to a server without waiting for it (the engine's
|
||||
/// [`start_background`](Session::start_background)): `done` gets the result, at once or when
|
||||
/// the answer arrives (the network wakes the window then); errors show as with [`run`](Self::run).
|
||||
/// [`Session::waiting_for`] tells the panels to show that it's on its way.
|
||||
pub fn run_background(&mut self, ctx: &egui::Context, id: &str, params: Value, done: Done) {
|
||||
self.waiting.retain(|(c, _)| c != id);
|
||||
let r = self.session.start_background(id, params);
|
||||
self.handle_ui_requests();
|
||||
match r {
|
||||
Ok(Some(v)) => done(self, ctx, v),
|
||||
Ok(None) => self.waiting.push((id.to_string(), done)),
|
||||
Err(e) => self.failed(id, &e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop waiting for command `id` (Cancel, or its window closed): its answer is dropped.
|
||||
pub fn cancel_background(&mut self, id: &str) {
|
||||
self.session.cancel_background(id);
|
||||
self.waiting.retain(|(c, _)| c != id);
|
||||
}
|
||||
|
||||
/// Hand the results of background commands that finished to what's waiting for them.
|
||||
fn background_done(&mut self, ctx: &egui::Context) {
|
||||
while let Some((id, r)) = self.session.take_background_done() {
|
||||
self.handle_ui_requests();
|
||||
let done = self.waiting.iter().position(|(c, _)| *c == id).map(|i| self.waiting.remove(i).1);
|
||||
match (r, done) {
|
||||
(Ok(v), Some(done)) => done(self, ctx, v),
|
||||
(Ok(_), None) => {}
|
||||
(Err(e), _) => self.failed(&id, &e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing;
|
||||
/// entering it ends editing text or vectors.
|
||||
pub(super) fn sync_dev_mode(&mut self) {
|
||||
let dev = self.ui.right_tab == RightTab::Inspect;
|
||||
if dev && !self.session.tool.dev_mode {
|
||||
let _ = self.session.drop_gesture();
|
||||
let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default();
|
||||
for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] {
|
||||
if on {
|
||||
self.run(cmd, json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.session.tool.dev_mode = dev;
|
||||
}
|
||||
|
||||
/// Act on UI requests queued by commands (dialogs, panels).
|
||||
pub fn handle_ui_requests(&mut self) {
|
||||
let reqs = std::mem::take(&mut self.session.ui_requests);
|
||||
for r in reqs {
|
||||
match r.kind.as_str() {
|
||||
"dialog.about" => {
|
||||
self.ui.about = true;
|
||||
self.ui.about_tab = dialogs::AboutTab::About;
|
||||
}
|
||||
"dialog.preferences" => self.ui.preferences = true,
|
||||
"dialog.missingFonts" => missing_fonts::request(self),
|
||||
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
||||
"palette.actions" => menus::toggle_palette(&mut self.ui),
|
||||
"panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel,
|
||||
"panel.design" => self.ui.right_tab = RightTab::Design,
|
||||
"board.editText" => {
|
||||
if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of)
|
||||
&& let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id)
|
||||
{
|
||||
self.toast(e.to_string());
|
||||
}
|
||||
}
|
||||
"panel.prototype" => self.ui.right_tab = RightTab::Prototype,
|
||||
"panel.designPrototype" => {
|
||||
self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype };
|
||||
}
|
||||
"panel.devMode" => {
|
||||
self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect };
|
||||
}
|
||||
"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"));
|
||||
let reset = left.is_none() && right.is_none();
|
||||
self.set_panel_widths(
|
||||
left.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.left_width }),
|
||||
right.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.right_width }),
|
||||
);
|
||||
self.services.save_prefs(&self.session.prefs);
|
||||
}
|
||||
"focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32),
|
||||
"panel.layers" => self.ui.left_tab = LeftTab::File,
|
||||
"panel.assets" => {
|
||||
self.ui.left_tab = LeftTab::Assets;
|
||||
self.ui.open_left_panel();
|
||||
self.ui.versions = Default::default();
|
||||
}
|
||||
"panel.versionHistory" => versions::open(self),
|
||||
"view.devCompare" => {
|
||||
self.ui.dev.compare = dev_compare::CompareView::requested(&r.data);
|
||||
}
|
||||
"panel.libraries" => {
|
||||
self.ui.left_tab = LeftTab::Assets;
|
||||
self.ui.open_left_panel();
|
||||
self.ui.libraries = true;
|
||||
}
|
||||
"home" => self.ui.home = true,
|
||||
"home.templates" => {
|
||||
self.ui.home = true;
|
||||
self.ui.home_view.section = home::Section::Templates;
|
||||
}
|
||||
"home.gettingStarted" => {
|
||||
self.ui.home = true;
|
||||
self.ui.home_view.section = home::Section::Recents;
|
||||
self.services.save_prefs(&self.session.prefs);
|
||||
}
|
||||
"dialog.saveAs" => self.save_as_dialog(),
|
||||
"dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile),
|
||||
"present" => {
|
||||
if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) {
|
||||
tabs::open_present(self, start);
|
||||
}
|
||||
}
|
||||
"present.options" => {
|
||||
let v = &mut self.ui.present_view;
|
||||
if let Some(s) = r.data.get("scale").and_then(|s| s.as_str()).and_then(present::PresentScale::parse) {
|
||||
v.scale = s;
|
||||
}
|
||||
if let Some(f) = r.data.get("deviceFrame").and_then(|f| f.as_bool()) {
|
||||
v.device_frame = f;
|
||||
}
|
||||
if let Some(h) = r.data.get("hotspotHints").and_then(|h| h.as_bool()) {
|
||||
v.hotspot_hints = h;
|
||||
}
|
||||
if let Some(i) = r.data.get("inspect").and_then(|i| i.as_bool()) {
|
||||
v.inspect = i;
|
||||
}
|
||||
if let Some(p) = self.ui.present.as_mut() {
|
||||
p.view.device_frame = v.device_frame;
|
||||
p.view.hotspot_hints = v.hotspot_hints;
|
||||
p.view.inspect = v.inspect;
|
||||
p.set_scale(v.scale);
|
||||
}
|
||||
}
|
||||
"preview" => {
|
||||
if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) {
|
||||
self.ui.preview = Some(present::Pane::new(start, &self.session));
|
||||
}
|
||||
}
|
||||
"preview.close" => self.ui.preview = None,
|
||||
"open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"),
|
||||
"layers.collapseAll" => layers::collapse_all(self),
|
||||
"contextMenu" => self.ui.key_menu = true,
|
||||
"selectArea" => self.start_key_box(),
|
||||
"canvas.renameLabel" => {
|
||||
self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true });
|
||||
}
|
||||
"layers.rename" => {
|
||||
let ids: Vec<NodeId> =
|
||||
r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
|
||||
if ids.len() > 1 {
|
||||
dialogs::open_batch_rename(self, &ids);
|
||||
} else {
|
||||
layers::start_rename(self, &ids);
|
||||
}
|
||||
}
|
||||
"comment.open" => {
|
||||
if let Some(id) = r.data.get("id").and_then(Value::as_u64) {
|
||||
self.ui.comments.show(id);
|
||||
}
|
||||
}
|
||||
"comment.new" => {
|
||||
let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
comments::start_draft(self, x, y);
|
||||
}
|
||||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
"dialog.pluginSave" => plugins_ui::save_file(self, &r.data),
|
||||
"dialog.cursorChat" => live::open_chat(self),
|
||||
"window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(m) = self.session.message.take() {
|
||||
self.toast(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from
|
||||
/// a normal UI pass: consumed control work, live polling, background completions, autosave and
|
||||
/// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or
|
||||
/// lay out anything.
|
||||
pub fn background_tick(&mut self, ctx: &egui::Context) {
|
||||
crate::control::background(self, ctx);
|
||||
// What happened in a live session (the network woke us for it).
|
||||
if self.session.live_poll() {
|
||||
if let Some(m) = self.session.message.take() {
|
||||
self.toast(live::failure_text(&m));
|
||||
}
|
||||
self.background_done(ctx);
|
||||
}
|
||||
// Files that arrived without a command (joined, recovered) take the kept View toggles too.
|
||||
self.session.apply_view_prefs();
|
||||
let now = self.clock.now();
|
||||
self.autosave_tick(ctx, now);
|
||||
self.auto_save_tick(ctx, now);
|
||||
updates_ui::tick(self, ctx);
|
||||
self.logic_ticked = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
/// Queue raw input injected through the control channel for the window drawing next, tagged
|
||||
/// with the document it belongs to (a batch is dropped if another file is active by then).
|
||||
pub(crate) fn push_control_input(&mut self, events: Vec<egui::Event>) -> bool {
|
||||
if events.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let doc = self.session.docs.get(self.session.active).map(|d| d.uid);
|
||||
if !self.control_input.is_empty() && self.control_input_doc != doc {
|
||||
// A queued batch belongs to the file that was open when it was injected; it cannot be
|
||||
// merged with one for the file showing now. Its acknowledgement says so.
|
||||
self.control_input.clear();
|
||||
let viewport = crate::windows::viewport(self.windows.current);
|
||||
crate::windows::fail_input_acks(self, viewport);
|
||||
}
|
||||
// Always the live pane vector: while a child window draws, `windows::enter` has swapped
|
||||
// its pane into the app, and `leave` swaps these events back into that pane.
|
||||
self.control_input.extend(events);
|
||||
self.control_input_doc = doc;
|
||||
self.control_synthetic = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs).
|
||||
pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec<PendingAck> {
|
||||
let mut mine = Vec::new();
|
||||
for ack in std::mem::take(&mut self.input_acks) {
|
||||
if ack.viewport == viewport {
|
||||
mine.push(ack);
|
||||
} else {
|
||||
self.input_acks.push(ack);
|
||||
}
|
||||
}
|
||||
mine
|
||||
}
|
||||
|
||||
/// UI state as JSON (control channel `ui.inspect`).
|
||||
pub fn inspect(&self) -> Value {
|
||||
let mut state = json!({
|
||||
"rightTab": self.ui.right_tab,
|
||||
"leftTab": self.ui.left_tab,
|
||||
"home": self.ui.home,
|
||||
"online": online::inspect(self),
|
||||
"tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::<Vec<_>>(),
|
||||
"homeSection": self.ui.home_view.section.id(),
|
||||
"homeRecents": home_recents::inspect(self),
|
||||
"dark": self.ui.dark,
|
||||
"focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard},
|
||||
"reduceMotion": self.reduce_motion(),
|
||||
"presenting": self.ui.present.is_some(),
|
||||
"presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect),
|
||||
"presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect},
|
||||
"preview": self.ui.preview.as_ref().map(|p| p.player.inspect()),
|
||||
"mainMenu": self.ui.main_menu,
|
||||
"palette": self.ui.palette.is_some(),
|
||||
"variables": self.ui.variables.is_some(),
|
||||
"versionHistory": self.ui.versions.open,
|
||||
"branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})),
|
||||
"devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})),
|
||||
"devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused),
|
||||
"annotationFilter": self.ui.dev.categories.id(),
|
||||
"colorProfileDialog": self.ui.color_profile.map(|p| p.id()),
|
||||
"stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()),
|
||||
"stylePickerView": if self.ui.style_grid { "grid" } else { "list" },
|
||||
"textPreview": figcraft_engine::textpreview::active(&self.session),
|
||||
"comments": {
|
||||
"open": self.ui.comments.open,
|
||||
"draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]),
|
||||
"filter": self.ui.comments.filter.id(),
|
||||
"sort": self.ui.comments.sort.id(),
|
||||
"search": self.ui.comments.search,
|
||||
"editing": self.ui.comments.editing.as_ref().map(|e| [e.0, e.1 as u64]),
|
||||
"mentions": self.ui.comments.mention.names(),
|
||||
},
|
||||
"frameMs": self.ui.frame_ms,
|
||||
"renderMs": self.ui.render_ms,
|
||||
"framesDrawn": self.frame_stats.frames,
|
||||
"repaintCauses": self.frame_stats.causes(),
|
||||
"canvas": {
|
||||
"rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y],
|
||||
"renderWork": self.canvas.render_work.map(|w| match w {
|
||||
figcraft_render::Work::Reused => json!({"kind": "reused"}),
|
||||
figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}),
|
||||
figcraft_render::Work::Full => json!({"kind": "full"}),
|
||||
}),
|
||||
"quality": if self.canvas.tex_coarse { "preview" } else { "refined" },
|
||||
"previewLatencyMs": self.canvas.preview_latency_ms,
|
||||
"refineLatencyMs": self.canvas.refine_latency_ms,
|
||||
},
|
||||
"live": live::inspect(self),
|
||||
"window": self.windows.current,
|
||||
"windows": windows::list(self),
|
||||
"tool": self.session.tool_info(),
|
||||
"cursor": self.canvas.cursor.map(cursors::Shown::name),
|
||||
"panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed},
|
||||
"status": self.session.status(),
|
||||
"lastError": self.ui.last_error,
|
||||
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
|
||||
});
|
||||
state["homeSearch"] = json!(self.ui.home_view.file_query);
|
||||
// Set apart from the literal above, which is at the macro recursion limit.
|
||||
state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited});
|
||||
state["pendingOpens"] = json!(self.ui.pending_opens.len());
|
||||
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
|
||||
state["presentTabs"] = tabs::present_tabs_inspect(self);
|
||||
state
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
pub fn save_as_dialog(&mut self) {
|
||||
if self.services.downloads() {
|
||||
if let Some(r) = self.run("file.saveAs", json!({"data": true}))
|
||||
&& self.download(r["name"].as_str().unwrap_or_default(), &r)
|
||||
{
|
||||
self.run("file.markSaved", json!({}));
|
||||
dialogs::file_saved(self, &r);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return };
|
||||
self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| {
|
||||
if p.extension().is_none_or(OsStr::is_empty) {
|
||||
if !p.set_extension("figcraft") {
|
||||
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
|
||||
return;
|
||||
}
|
||||
// This name wasn't confirmed by the system dialog: don't replace an existing file.
|
||||
match p.try_exists() {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement."));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
app.toast(e.to_string());
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if !p.extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) {
|
||||
app.toast(i18n::tr("Use the .figcraft or .fig extension."));
|
||||
return;
|
||||
}
|
||||
if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) {
|
||||
dialogs::file_saved(app, &r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
// A branch tab saves its main file.
|
||||
let tab = figcraft_engine::branches::file_tab(&self.session);
|
||||
// A path ending in .fig is never saved over silently: a .fig destination is an export copy.
|
||||
let native_path = self
|
||||
.session
|
||||
.docs
|
||||
.get(tab)
|
||||
.and_then(|d| d.path.as_deref())
|
||||
.is_some_and(|path| !Path::new(path).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig")));
|
||||
if native_path {
|
||||
if self.run("file.save", json!({})).is_some() {
|
||||
self.toast(i18n::tr("Saved"));
|
||||
}
|
||||
} else {
|
||||
self.save_as_dialog();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
|
||||
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) {
|
||||
let exts: &[&str] = if place {
|
||||
&["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]
|
||||
} else {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"]
|
||||
};
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
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) {
|
||||
self.start_dialog(|services| services.pick_open(title, exts), true, done);
|
||||
}
|
||||
|
||||
/// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does.
|
||||
pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_dialog(|services| services.pick_save(title, name, exts), true, done);
|
||||
}
|
||||
|
||||
/// Hand a command result's base64 `data` over as a download named `name`, where the platform
|
||||
/// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived.
|
||||
pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool {
|
||||
use base64::Engine as _;
|
||||
let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok());
|
||||
if bytes.is_some_and(|b| self.services.download(name, &b)) {
|
||||
return true;
|
||||
}
|
||||
self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)]));
|
||||
false
|
||||
}
|
||||
|
||||
/// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does.
|
||||
pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_dialog(|services| services.pick_folder(title), true, done);
|
||||
}
|
||||
|
||||
/// 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, picked: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
if let Some(ctx) = &self.ui_context {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Once the dialog has a path, its continuation runs in the window that holds its file, with
|
||||
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
|
||||
pub(crate) fn poll_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(d) = &mut self.pending_dialog else { return };
|
||||
if d.picked.is_none() {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
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.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) {
|
||||
self.pending_dialog = None;
|
||||
return;
|
||||
}
|
||||
let window = match doc {
|
||||
Some(u) => self.windows.of(u),
|
||||
None if self.windows.exists(origin) => origin,
|
||||
None => 0,
|
||||
};
|
||||
if window != self.windows.current {
|
||||
ctx.request_repaint_of(windows::viewport(window));
|
||||
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, 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))
|
||||
{
|
||||
self.session.active = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version
|
||||
/// gets an autosave checkpoint in its version history.
|
||||
pub(super) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) {
|
||||
if now < self.ui.next_autosave {
|
||||
// Wake up for it even if nothing else draws a frame by then.
|
||||
repaint_in(ctx, self.ui.next_autosave - now);
|
||||
return;
|
||||
}
|
||||
if self.ui.next_autosave > 0.0 {
|
||||
let active = self.session.active;
|
||||
for i in 0..self.session.docs.len() {
|
||||
self.session.active = i;
|
||||
let _ = self.session.execute("version.autosave", &json!({}));
|
||||
}
|
||||
self.session.active = active;
|
||||
}
|
||||
self.ui.next_autosave = now + AUTOSAVE_SECS;
|
||||
repaint_in(ctx, AUTOSAVE_SECS);
|
||||
}
|
||||
|
||||
/// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last
|
||||
/// changed (not during a gesture, a live preview or while a version is shown). The file is
|
||||
/// encoded on a thread of its own, so a big one doesn't hold up the window, and written here.
|
||||
pub(super) fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) {
|
||||
let mut failed = self.auto_save_done();
|
||||
// The browser has no files to save to.
|
||||
let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save;
|
||||
let saving = &self.ui.auto_saving;
|
||||
let due = |d: &&figcraft_engine::DocState| {
|
||||
d.path.as_deref().is_some_and(|p| d.images_lost_saving_to(p).is_none())
|
||||
&& d.branch_of.is_none()
|
||||
&& d.preview.is_none()
|
||||
&& d.is_dirty()
|
||||
&& !saving.iter().any(|(uid, _)| *uid == d.uid)
|
||||
};
|
||||
let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None };
|
||||
match changes {
|
||||
None => self.ui.auto_save_at = None,
|
||||
Some(changes) => {
|
||||
let at = match self.ui.auto_save_at {
|
||||
Some((at, c)) if c == changes && failed.is_none() => at,
|
||||
_ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS },
|
||||
};
|
||||
self.ui.auto_save_at = Some((at, changes));
|
||||
if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) {
|
||||
// Due but busy: look again in a second.
|
||||
repaint_in(ctx, if now < at { at - now } else { 1.0 });
|
||||
} else {
|
||||
self.ui.auto_save_at = None;
|
||||
let files: Vec<_> =
|
||||
self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect();
|
||||
for (uid, file) in files {
|
||||
let encode = move || {
|
||||
let bytes = file.encode();
|
||||
(file, bytes)
|
||||
};
|
||||
match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) {
|
||||
Ok(h) => self.ui.auto_saving.push((uid, h)),
|
||||
Err(e) => {
|
||||
failed.get_or_insert(e.to_string());
|
||||
self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.ui.auto_saving.is_empty() {
|
||||
// Look again shortly for the files being encoded.
|
||||
repaint_in(ctx, 0.1);
|
||||
}
|
||||
if let Some(e) = failed {
|
||||
self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the files whose auto-save encoding finished; the first error, if one failed.
|
||||
fn auto_save_done(&mut self) -> Option<String> {
|
||||
if self.ui.auto_saving.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut failed = None;
|
||||
let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect();
|
||||
for (_, h) in done {
|
||||
let saved = match h.join() {
|
||||
Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Ok((_, Err(e))) => Err(e.to_string()),
|
||||
Err(_) => Err("the file couldn't be encoded".to_string()),
|
||||
};
|
||||
if let Err(e) = saved {
|
||||
failed.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
failed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
/// Show what `command params` would do on the canvas as a live preview (`text.preview`) while
|
||||
/// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame
|
||||
/// the preview should stay: it ends on the first frame nothing asks for it.
|
||||
pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) {
|
||||
self.ui.text_preview_wanted = true;
|
||||
let revision = self.session.ds().map_or(0, |d| d.revision);
|
||||
if let Some((c, p, rev)) = &self.ui.text_preview
|
||||
&& c == command
|
||||
&& *p == params
|
||||
&& (*rev == revision || figcraft_engine::textpreview::active(&self.session))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let shown = self.session.execute("text.preview", &json!({"command": command, "params": params})).is_ok();
|
||||
let revision = self.session.ds().map_or(0, |d| d.revision);
|
||||
self.ui.text_preview = shown.then(|| (command.to_string(), params, revision));
|
||||
// The canvas drew before the panels: show the preview next frame.
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// End the live text preview the panels show (Escape, or nothing hovered any more).
|
||||
pub fn end_text_preview(&mut self, ctx: &egui::Context) {
|
||||
if self.ui.text_preview.take().is_some() {
|
||||
let _ = self.session.execute("text.endPreview", &json!({}));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect
|
||||
/// don't draw (#307). Checked as the frame starts (the control channel) and after the panels
|
||||
/// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more.
|
||||
pub(super) fn end_pen_outside_design(&mut self) {
|
||||
if self.ui.right_tab == self.ui.last_right_tab {
|
||||
return;
|
||||
}
|
||||
self.ui.last_right_tab = self.ui.right_tab;
|
||||
if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen {
|
||||
self.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a frame, after the panels: a preview nothing asked for this frame ends.
|
||||
pub(super) fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) {
|
||||
if !std::mem::take(&mut self.ui.text_preview_wanted) {
|
||||
self.end_text_preview(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the frame that applies an injected batch: acknowledge it once the pass is presented,
|
||||
/// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts.
|
||||
pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_frame != Some(ctx.viewport_id()) {
|
||||
return;
|
||||
}
|
||||
self.applied_input = None;
|
||||
if ctx.will_discard() {
|
||||
// Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge)
|
||||
// the same frame without replaying the input.
|
||||
return;
|
||||
}
|
||||
self.applied_frame = None;
|
||||
for ack in self.applied_acks.drain(..) {
|
||||
let _ = ack.tx.send(ack.reply);
|
||||
}
|
||||
self.control_synthetic = false;
|
||||
}
|
||||
|
||||
/// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery,
|
||||
/// control deadlines). Never `ctx.input(i.time)`, which is stale there.
|
||||
pub fn now_secs(&self) -> f64 {
|
||||
self.clock.now()
|
||||
}
|
||||
|
||||
pub fn toast(&mut self, msg: impl Into<String>) {
|
||||
self.ui.toast = Some((msg.into(), ToastClock::Raised));
|
||||
}
|
||||
|
||||
/// Say that `n` documents left unsaved by a crash were opened again from recovery copies.
|
||||
pub fn note_recovered(&mut self, n: usize) {
|
||||
if n > 0 {
|
||||
self.toast(i18n::trn("Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them", n, &[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the widths asked for this window's side panels (within their limits); the main
|
||||
/// window's are kept in the preferences.
|
||||
pub fn set_panel_widths(&mut self, left: f32, right: f32) {
|
||||
let within = |w: f32, range: std::ops::RangeInclusive<f32>| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT };
|
||||
(self.ui.left_width, self.ui.right_width) = (within(left, LEFT_PANEL), within(right, RIGHT_PANEL));
|
||||
if self.windows.current == 0 {
|
||||
self.session.prefs.panel_widths = (self.ui.left_width, self.ui.right_width);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edge between a side panel and the canvas, at `x` down `rows`: dragging it sizes the
|
||||
/// panel (`grows`: +1 for the left panel, whose edge is on its right, −1 for the right one), a
|
||||
/// double-click puts back the default width. Returns the width asked for, when it changed.
|
||||
pub(super) fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option<f32> {
|
||||
let grab = ui.style().interaction.resize_grab_radius_side;
|
||||
let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows);
|
||||
let id = egui::Id::unique(("panel_edge", name));
|
||||
// Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the
|
||||
// panels' controls (see `a11y::focusables`).
|
||||
let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG);
|
||||
if resp.hovered() || resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
return Some(PANEL_DEFAULT);
|
||||
}
|
||||
// The edge stays under the pointer where it was grabbed (also back from past a limit).
|
||||
let at = resp.interact_pointer_pos().filter(|_| resp.dragged())?;
|
||||
if resp.drag_started() {
|
||||
let pressed = ui.input(|i| i.pointer.press_origin()).unwrap_or(at);
|
||||
ui.data_mut(|d| d.insert_temp(id, pressed.x - x));
|
||||
}
|
||||
let grabbed: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0);
|
||||
let w = width + (at.x - grabbed - x) * grows;
|
||||
(w != width).then_some(w)
|
||||
}
|
||||
|
||||
/// Reduced motion: the preference or the system's setting.
|
||||
pub fn reduce_motion(&self) -> bool {
|
||||
self.session.prefs.reduce_motion || self.services.reduce_motion()
|
||||
}
|
||||
|
||||
/// The CJK, Bengali and Thai scripts the UI shows: the language's, the language picker's (Preferences open),
|
||||
/// typed text's, and those in the open file's names and comments (read when it changes, not
|
||||
/// mid-drag). Nothing is checked once every script has a face.
|
||||
pub(crate) fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 {
|
||||
if installed == cjk::ALL {
|
||||
return installed;
|
||||
}
|
||||
let mut need = i18n::current().map_or(0, |l| cjk::language(l.code));
|
||||
if self.ui.preferences {
|
||||
need |= i18n::languages().iter().filter(|l| cjk::can_show(l.code)).fold(0, |m, l| m | cjk::language(l.code));
|
||||
}
|
||||
need |= ctx.input(|i| {
|
||||
i.events.iter().fold(0, |m, e| match e {
|
||||
egui::Event::Text(t) | egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. } | egui::ImeEvent::Commit(t)) => m | cjk::scripts(t),
|
||||
_ => m,
|
||||
})
|
||||
});
|
||||
let dragging = ctx.input(|i| i.pointer.any_down());
|
||||
// Pans and zooms change the revision too: only a new version of the document is looked
|
||||
// at, and only the layers it changed (a large file has hundreds of thousands of names).
|
||||
let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone()));
|
||||
if let Some((uid, doc)) = current {
|
||||
let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b);
|
||||
if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) {
|
||||
let mut scan = |s: &str| need |= cjk::scripts(s);
|
||||
scan(&doc.name);
|
||||
match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) {
|
||||
Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)),
|
||||
None => doc.nodes().for_each(|n| scan(&n.name)),
|
||||
}
|
||||
doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| {
|
||||
scan(&m.author);
|
||||
scan(&m.text);
|
||||
});
|
||||
}
|
||||
self.cjk_checked = Some((uid, doc));
|
||||
}
|
||||
need
|
||||
}
|
||||
|
||||
/// Whether the right panel shows: never with the UI `hidden`; always on a design file; on a
|
||||
/// board, which has no prototypes or Dev Mode (its tab stays Design), only when asked for.
|
||||
pub(super) fn right_panel_shown(&mut self, hidden: bool) -> bool {
|
||||
let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board);
|
||||
if board {
|
||||
self.ui.right_tab = RightTab::Design;
|
||||
}
|
||||
!hidden && (!board || self.ui.board_panel)
|
||||
}
|
||||
|
||||
/// A narrow window's two buttons for the side panels (touch-sized); the open one closes again.
|
||||
pub(super) 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request
|
||||
/// that frame and the frame that hides it, expire and remove it at the deadline, and place it
|
||||
/// at the bottom centre.
|
||||
pub(super) fn toast_ui(&mut self, ctx: &egui::Context) {
|
||||
let Some((msg, clock)) = &mut self.ui.toast else { return };
|
||||
let now = ctx.input(|i| i.time);
|
||||
// The clock starts on the frame after the one that first draws the toast: that frame's
|
||||
// time was taken before whatever raised it ran (a file dialog, a long open), and would
|
||||
// count that wait against it, so a failed open's message expired before it was seen.
|
||||
let left = match *clock {
|
||||
ToastClock::Raised => {
|
||||
*clock = ToastClock::Drawn;
|
||||
ctx.request_repaint();
|
||||
TOAST_SECS
|
||||
}
|
||||
ToastClock::Drawn => {
|
||||
*clock = ToastClock::Since(now);
|
||||
TOAST_SECS
|
||||
}
|
||||
ToastClock::Since(t) => TOAST_SECS - (now - t),
|
||||
};
|
||||
if left <= 0.0 {
|
||||
self.ui.toast = None;
|
||||
return;
|
||||
}
|
||||
let msg = msg.clone();
|
||||
// Nothing moves while it shows: the next frame it needs is the one that hides it.
|
||||
if matches!(clock, ToastClock::Since(_)) {
|
||||
repaint_in(ctx, left);
|
||||
}
|
||||
let screen = ctx.content_rect();
|
||||
egui::Area::new(egui::Id::unique("toast"))
|
||||
.fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0))
|
||||
.pivot(egui::Align2::CENTER_BOTTOM)
|
||||
.order(egui::Order::Tooltip)
|
||||
.show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(egui::Color32::from_rgb(0x1E, 0x1E, 0x1E))
|
||||
.corner_radius(egui::CornerRadius::same(8))
|
||||
.inner_margin(egui::Margin::symmetric(14, 10))
|
||||
.show(ui, |ui| {
|
||||
// Wrapped at a readable width (the area alone would wrap at a sliver).
|
||||
let width = (screen.width() - 64.0).clamp(120.0, 480.0);
|
||||
let galley = ui.painter().layout(msg, theme::regular(12.0), egui::Color32::WHITE, width);
|
||||
ui.label(galley);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
impl FigApp {
|
||||
/// The pass's one IME observation: the focused on-canvas editor, or an explicit idle.
|
||||
///
|
||||
/// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed
|
||||
/// by a focused observation as a context change (dropping the frame's receipts) and bump the
|
||||
/// generation. Keeping this in one place makes the fork contract structural.
|
||||
pub(super) fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.ime_observations = self.ime_observations.saturating_add(1);
|
||||
}
|
||||
if focused {
|
||||
shortcuts::ime_output(self, ctx);
|
||||
} else {
|
||||
let _ = self.ime.observe(ctx.viewport_id(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user