figcraft/crates/ui-egui/src/versions.rs

492 lines
20 KiB
Rust

//! The Version history panel, shown in the left sidebar in place of Pages and Layers: save a
//! version (title and description), the history newest first under the current version, and per
//! version Restore, Rename, Duplicate and Delete. Clicking a version shows it on the canvas
//! (read-only, with a banner to restore it or go back); clicking Current version goes back.
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::FigApp;
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
use crate::widgets;
/// Version panel state.
#[derive(Default)]
pub struct VersionUi {
pub open: bool,
/// The save form (new version) or the edit form of a version.
pub form: Option<Form>,
/// The version waiting for delete confirmation.
pub confirm_delete: Option<String>,
/// Runs of autosaves shown unfolded (keyed by their newest version's id).
pub open_groups: std::collections::BTreeSet<String>,
}
/// Title and description being written; `id` is the version being edited (`None` = a new one).
pub struct Form {
pub id: Option<String>,
pub name: String,
pub description: String,
focus: bool,
}
impl Form {
fn new(id: Option<String>, name: &str, description: &str) -> Self {
Self { id, name: name.to_string(), description: description.to_string(), focus: true }
}
}
/// 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.left_tab = crate::LeftTab::File;
}
/// Open the panel with the save form (File ▸ Save to version history…).
pub fn start_save(app: &mut FigApp) {
open(app);
app.ui.versions.confirm_delete = None;
app.ui.versions.form = Some(Form::new(None, "", ""));
}
/// One version as listed.
struct Row {
id: String,
name: String,
description: String,
time: u64,
auto: bool,
}
enum Action {
Close,
Preview(String),
ExitPreview,
ToggleGroup(String),
New,
Submit,
CancelForm,
Restore(String),
Edit(String),
Duplicate(String),
AskDelete(String),
Delete(String),
CancelDelete,
}
const DOT_X: f32 = 20.0;
const TEXT_X: f32 = 36.0;
pub fn panel(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let mut action = None;
// Header: title, save, close.
let (head, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
ui.painter().text(pos2(head.min.x + 16.0, head.center().y), Align2::LEFT_CENTER, "Version history", semibold(13.0), t.text);
let mut bar =
ui.new_child(egui::UiBuilder::new().max_rect(head.shrink2(vec2(12.0, 12.0))).layout(egui::Layout::right_to_left(egui::Align::Center)));
if icons::button(&mut bar, Icon::Close, 24.0, false, "Close version history").clicked() {
action = Some(Action::Close);
}
let new_open = app.ui.versions.form.as_ref().is_some_and(|f| f.id.is_none());
let tip = format!("Save version {}", crate::toolbar::pretty_shortcut("Alt+Cmd+S"));
if icons::button(&mut bar, Icon::Plus, 24.0, new_open, &tip).clicked() {
action = Some(Action::New);
}
ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border));
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.is_none()) {
ui.add_space(12.0);
if let Some(a) = form(ui, f, "Save", 16.0) {
action = Some(a);
}
ui.add_space(12.0);
widgets::separator(ui);
}
let rows: Vec<Row> = app
.session
.ds()
.map(|ds| {
ds.versions
.iter()
.rev()
.map(|v| Row { id: v.id.clone(), name: v.name.clone(), description: v.description.clone(), time: v.time, auto: v.auto })
.collect()
})
.unwrap_or_default();
let previewing = app.session.ds().ok().and_then(|ds| ds.preview.as_ref().map(|p| p.id.clone()));
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
// Rows touch so the timeline is continuous.
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(4.0);
if current_row(ui, !rows.is_empty(), previewing.is_none()) {
action = Some(Action::ExitPreview);
}
let last = rows.len().saturating_sub(1);
let mut i = 0;
let mut group_end = 0;
while let Some(row) = rows.get(i) {
// Two or more autosaves in a row fold into one line (open while one of them is shown).
if i >= group_end {
let run = rows.iter().skip(i).take_while(|r| r.auto).count();
if run >= 2 {
group_end = i + run;
let open = app.ui.versions.open_groups.contains(&row.id)
|| rows.iter().skip(i).take(run).any(|r| previewing.as_deref() == Some(r.id.as_str()));
if autosave_group(ui, run, open, group_end > last && !open) {
action = Some(Action::ToggleGroup(row.id.clone()));
}
if !open {
i = group_end;
continue;
}
}
}
let a = if app.ui.versions.confirm_delete.as_deref() == Some(row.id.as_str()) {
inline_row(ui, i == last, |ui| confirm_row(ui, row))
} else if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.as_deref() == Some(row.id.as_str())) {
inline_row(ui, i == last, |ui| form(ui, f, "Save", TEXT_X))
} else {
version_row(ui, row, i == last, previewing.as_deref() == Some(row.id.as_str()))
};
if a.is_some() {
action = a;
}
i += 1;
}
if rows.is_empty() {
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.add_space(16.0);
ui.add(
egui::Label::new(
egui::RichText::new("No saved versions yet. Save a version to mark a point in your work you can come back to.")
.font(regular(11.0))
.color(t.text_secondary),
)
.wrap(),
);
});
}
ui.add_space(16.0);
});
if let Some(a) = action {
act(app, a, &rows);
}
}
fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
let st = &mut app.ui.versions;
let name_of = |id: &str| rows.iter().find(|r| r.id == id).map(|r| r.name.clone()).unwrap_or_default();
match a {
Action::Close => {
*st = VersionUi::default();
app.run("version.exitPreview", json!({}));
}
Action::Preview(id) => {
app.run("version.preview", json!({"id": id}));
}
Action::ExitPreview => {
app.run("version.exitPreview", json!({}));
}
Action::ToggleGroup(id) => {
if !st.open_groups.remove(&id) {
st.open_groups.insert(id);
}
}
Action::New => {
st.confirm_delete = None;
st.form = if st.form.as_ref().is_some_and(|f| f.id.is_none()) { None } else { Some(Form::new(None, "", "")) };
}
Action::CancelForm => st.form = None,
Action::CancelDelete => st.confirm_delete = None,
Action::Edit(id) => {
st.confirm_delete = None;
st.form = rows.iter().find(|r| r.id == id).map(|r| Form::new(Some(id.clone()), &r.name, &r.description));
}
Action::AskDelete(id) => {
st.form = None;
st.confirm_delete = Some(id);
}
Action::Submit => {
let Some(f) = st.form.take() else { return };
let (cmd, params) = match &f.id {
Some(id) => ("version.rename", json!({"id": id, "name": f.name.trim(), "description": f.description.trim()})),
None => ("file.saveVersion", json!({"name": f.name.trim(), "description": f.description.trim()})),
};
if app.run(cmd, params).is_none() {
app.ui.versions.form = Some(f);
}
}
Action::Delete(id) => {
st.confirm_delete = None;
app.run("version.delete", json!({"id": id}));
}
Action::Restore(id) => {
if app.run("version.restore", json!({"id": id})).is_some() {
app.toast(format!("Restored “{}”", name_of(&id)));
}
}
Action::Duplicate(id) => {
if app.run("version.duplicate", json!({"id": id})).is_some() {
app.toast(format!("Opened “{}” in a new tab", name_of(&id)));
}
}
}
}
/// Title and description fields with Cancel / `submit` buttons, `indent` from the left.
fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option<Action> {
let t = Tokens::get(ui.ctx());
let mut out = None;
let field = |ui: &mut Ui, add: &mut dyn FnMut(&mut Ui) -> egui::Response| {
egui::Frame::NONE.fill(t.field).corner_radius(CornerRadius::same(5)).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, add).inner
};
ui.horizontal(|ui| {
ui.add_space(indent);
ui.vertical(|ui| {
ui.set_width(ui.available_width() - 16.0);
let w = ui.available_width() - 16.0;
let title = field(ui, &mut |ui| {
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text("Title").font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w))
});
if std::mem::take(&mut f.focus) {
title.request_focus();
}
ui.add_space(8.0);
let desc = field(ui, &mut |ui| {
ui.add(
egui::TextEdit::multiline(&mut f.description)
.hint_text("Describe what changed")
.font(regular(11.0))
.frame(egui::Frame::NONE)
.desired_rows(3)
.desired_width(w),
)
});
let (enter, cmd_enter, esc) = ui.input(|i| {
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Enter) && i.modifiers.command, i.key_pressed(egui::Key::Escape))
});
let editing_existing = f.id.is_some();
let can_submit = !editing_existing || !f.name.trim().is_empty();
if esc && (title.has_focus() || title.lost_focus() || desc.has_focus() || desc.lost_focus()) {
out = Some(Action::CancelForm);
} else if can_submit && ((title.lost_focus() && enter) || (desc.has_focus() && cmd_enter)) {
out = Some(Action::Submit);
}
ui.add_space(8.0);
match buttons(ui, submit, can_submit) {
Some(true) => out = Some(Action::Submit),
Some(false) => out = Some(Action::CancelForm),
None => {}
}
});
});
out
}
/// The timeline's line and dot for a row (`top`/`bottom`: the line continues above/below).
fn timeline(ui: &Ui, r: Rect, dot_y: f32, top: bool, bottom: bool, current: bool) {
let t = Tokens::get(ui.ctx());
let x = r.min.x + DOT_X;
let p = ui.painter();
let line = Stroke::new(1.0, t.border_strong);
if top {
p.line_segment([pos2(x, r.min.y), pos2(x, dot_y)], line);
}
if bottom {
p.line_segment([pos2(x, dot_y), pos2(x, r.max.y)], line);
}
if current {
p.circle_filled(pos2(x, dot_y), 4.0, t.accent);
} else {
p.circle(pos2(x, dot_y), 3.5, t.panel, line);
}
}
/// The current version's row (`active` when it's what the canvas shows); true when clicked.
fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
timeline(ui, r, r.center().y, false, more, true);
ui.painter().text(pos2(r.min.x + TEXT_X, r.center().y), Align2::LEFT_CENTER, "Current version", semibold(11.0), t.text);
!active && resp.on_hover_text("Back to the current version").clicked()
}
fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<Action> {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let text_w = (w - TEXT_X - 36.0).max(40.0);
// Up to three lines of description, then an ellipsis.
let desc = (!row.description.is_empty()).then(|| {
let mut job = egui::text::LayoutJob::simple(row.description.clone(), regular(11.0), t.text, text_w);
job.wrap.max_rows = 3;
ui.painter().layout_job(job)
});
let desc_h = desc.as_ref().map(|g| g.size().y + 4.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 50.0 + desc_h), Sense::click());
let hovered = ui.rect_contains_pointer(r);
if previewing {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row);
} else if hovered {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
let name_y = r.min.y + 17.0;
timeline(ui, r, name_y, true, !last, false);
let clip = Rect::from_min_max(pos2(r.min.x + TEXT_X, r.min.y), pos2(r.min.x + TEXT_X + text_w, r.max.y));
let p = ui.painter().with_clip_rect(clip);
p.text(pos2(clip.min.x, name_y), Align2::LEFT_CENTER, &row.name, semibold(11.0), t.text);
p.text(pos2(clip.min.x, name_y + 16.0), Align2::LEFT_CENTER, crate::comments::ago_long(row.time), regular(11.0), t.text_secondary);
if let Some(g) = desc {
p.galley(pos2(clip.min.x, name_y + 26.0), g, t.text);
}
let mut out = None;
// More menu (on hover), also on right-click.
let br = Rect::from_center_size(pos2(r.max.x - 22.0, name_y), vec2(24.0, 24.0));
let more = ui.interact(br, Id::new(("version_more", &row.id)), Sense::click());
let menu_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&more));
if hovered || menu_open {
if more.hovered() || menu_open {
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
}
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
}
let more = more.on_hover_text("More");
let mut menu = |ui: &mut Ui| {
for (label, a) in [
("Restore this version", Action::Restore(row.id.clone())),
("Rename", Action::Edit(row.id.clone())),
("Duplicate", Action::Duplicate(row.id.clone())),
("Delete", Action::AskDelete(row.id.clone())),
] {
if crate::right::menu_item(ui, label, "") {
out = Some(a);
ui.close();
}
}
};
egui::Popup::menu(&more).show(&mut menu);
egui::Popup::context_menu(&resp).show(&mut menu);
if resp.double_clicked() {
out = Some(Action::Edit(row.id.clone()));
} else if resp.clicked() && !previewing && out.is_none() {
out = Some(Action::Preview(row.id.clone()));
}
out
}
/// While a version is shown: a banner over the canvas naming it, with Restore and Back.
pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
let Some((id, name, time)) = app.session.ds().ok().and_then(|ds| ds.preview.as_ref().map(|p| (p.id.clone(), p.name.clone(), p.time))) else {
return;
};
let t = Tokens::get(ctx);
let mut action = None;
egui::Area::new(Id::new("version_preview_banner"))
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_TOP)
.fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0))
.show(ctx, |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(8))
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
icons::paint(
ui.painter(),
Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)),
Icon::Clock,
t.icon_secondary,
);
ui.add_space(20.0);
ui.label(egui::RichText::new(format!("Viewing “{name}”")).font(semibold(11.0)).color(t.text));
ui.label(egui::RichText::new(crate::comments::ago_long(time)).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if widgets::button(ui, "Back to current", false, true).clicked() {
action = Some(false);
}
if widgets::button(ui, "Restore this version", true, true).clicked() {
action = Some(true);
}
});
});
});
match action {
Some(true) => {
if app.run("version.restore", json!({"id": id})).is_some() {
app.toast(format!("Restored “{name}”"));
}
}
Some(false) => {
app.run("version.exitPreview", json!({}));
}
None => {}
}
}
/// The folded line for `n` autosaves in a row; true when clicked (fold / unfold).
fn autosave_group(ui: &mut Ui, n: usize, open: bool, last: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
timeline(ui, r, r.center().y, true, !last, false);
let chevron = Rect::from_center_size(pos2(r.min.x + TEXT_X + 6.0, r.center().y), vec2(12.0, 12.0));
icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
let label = format!("{n} autosave versions");
ui.painter().text(pos2(chevron.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary);
resp.on_hover_text(if open { "Fold autosaves" } else { "Show autosaves" }).clicked()
}
/// A row holding a form (rename, delete confirmation), with the timeline running past it.
fn inline_row(ui: &mut Ui, last: bool, add: impl FnOnce(&mut Ui) -> Option<Action>) -> Option<Action> {
let top = ui.cursor().min.y;
ui.add_space(8.0);
let a = add(ui);
ui.add_space(8.0);
let r = Rect::from_min_max(pos2(ui.min_rect().min.x, top), pos2(ui.min_rect().max.x, ui.cursor().min.y));
timeline(ui, r, top + 20.0, true, !last, false);
a
}
fn confirm_row(ui: &mut Ui, row: &Row) -> Option<Action> {
let t = Tokens::get(ui.ctx());
let mut out = None;
egui::Frame::NONE.inner_margin(egui::Margin { left: TEXT_X as i8, right: 16, top: 4, bottom: 0 }).show(ui, |ui| {
ui.add(
egui::Label::new(egui::RichText::new(format!("Delete “{}”? This can't be undone.", row.name)).font(regular(11.0)).color(t.text)).wrap(),
);
ui.add_space(8.0);
out = buttons(ui, "Delete", true).map(|delete| if delete { Action::Delete(row.id.clone()) } else { Action::CancelDelete });
});
out
}
/// Right-aligned Cancel and `primary` buttons: `Some(true)` for the primary one, `Some(false)` for
/// Cancel.
fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option<bool> {
let size = vec2(ui.available_width(), widgets::FIELD_H);
ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let ok = widgets::button(ui, primary, true, enabled).clicked();
let cancel = widgets::button(ui, "Cancel", false, true).clicked();
if ok { Some(true) } else { cancel.then_some(false) }
})
.inner
}