Files
figcraft/crates/ui-egui/src/live.rs
T
BFlat a2e2012fdc M7: localization layer (#128)
UI strings come from per-language TOML catalogs compiled in (English text as the key and fallback, named placeholders, one/other plurals, context suffixes); command labels are translated at display time while ids, CLI, MCP and control output stay English; accessibility names follow. Preferences > Language (System / English / Español, live; the system language from the OS or the browser), pref.language for scripts. A key check test guards the catalogs. The Spanish catalog is a machine draft awaiting native review; docs/translating.md explains adding a language.
2026-10-11 16:21:16 +07:00

581 lines
26 KiB
Rust

//! Live sessions in the window: other people's cursors (smoothly following their pointer, with a
//! name tag) and selections on the canvas (their edits arrive through the engine's sync), their avatars at the top of the right panel (click one
//! to follow their view), the Share popover (start a session, the invite link, stop; the server,
//! file and access of a session you joined; a published file's links), the Join dialog and the
//! Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's
//! (`Session::live`). The network wakes the UI when something arrives, and cursors only ask for
//! frames while they glide, so an idle session draws nothing.
use std::collections::BTreeMap;
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2};
use figcraft_engine::live::{Access, PEOPLE, Role, Status, initials, parse_color, presence_page};
use serde_json::json;
use crate::FigApp;
use crate::a11y::{self, Tip};
use crate::i18n::{tr, tr_noop, trf, trn};
use crate::icons::{self, Icon};
use crate::right::{menu_item, menu_item_with, menu_popup};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
/// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`).
const BROWSER_NOTE: &str = tr_noop(
"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS).",
);
/// How undo works in a session (per person).
const UNDO_NOTE: &str = tr_noop("Undo takes back only your own changes; what others changed since stays.");
/// What a view link means for you.
const VIEW_NOTE: &str = tr_noop("View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit.");
/// What a published file's links are.
const PUBLISHED_NOTE: &str = tr_noop(
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them.",
);
/// How fast a remote cursor closes in on where it was last reported (per second).
const GLIDE: f64 = 20.0;
/// Most of one person's selection outlined.
const MAX_OUTLINES: usize = 500;
/// UI-only live session state.
#[derive(Default)]
pub struct LiveUi {
/// Where each remote cursor is drawn (page coordinates), gliding to where it was reported.
shown: BTreeMap<u32, (f64, f64)>,
/// The UI clock at the last cursor frame.
last: f64,
/// The pointer position last handed to the session (page coordinates).
seen: Option<(f64, f64)>,
/// The Join dialog's link field (open when `Some`).
pub join: Option<String>,
/// Put the keyboard in the Join dialog's field on its next frame.
focus_join: bool,
/// Start sessions on the local network (else this machine only).
pub lan: bool,
/// The Publish to server dialog's fields (open when `Some`).
pub publish: Option<PublishForm>,
/// Put the keyboard in the Publish dialog's first field on its next frame.
focus_publish: bool,
/// The last server address published to (kept for this run only: it carries the server key).
last_server: String,
}
/// What the Publish to server dialog asks for.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PublishForm {
/// The server's address with its key (`ws://host:47900/?key=…`).
pub server: String,
/// The file's name on the server.
pub name: String,
}
impl LiveUi {
/// The remote cursors drawn last frame (`ui.inspect`).
pub fn shown(&self) -> Vec<u32> {
self.shown.keys().copied().collect()
}
}
/// A person's colour (`#RRGGBB` from the session), or the accent for anything else.
pub fn color(s: &str) -> Color32 {
parse_color(s).map_or(ACCENT, |(r, g, b)| Color32::from_rgb(r, g, b))
}
/// The palette colour `i` (comment authors use it too).
pub fn people(i: usize) -> Color32 {
PEOPLE.get(i % PEOPLE.len()).map_or(ACCENT, |c| color(c))
}
/// Text that reads on `fill`.
pub fn text_on(fill: Color32) -> Color32 {
let l = 0.299 * f32::from(fill.r()) + 0.587 * f32::from(fill.g()) + 0.114 * f32::from(fill.b());
if l > 170.0 { Color32::from_rgb(0x1E, 0x1E, 0x1E) } else { Color32::WHITE }
}
/// An avatar: the initials in a circle of the person's colour.
pub fn avatar(painter: &egui::Painter, center: Pos2, r: f32, name: &str, fill: Color32) {
painter.circle_filled(center, r, fill);
painter.text(center, Align2::CENTER_CENTER, initials(name), semibold(r * 0.8), text_on(fill));
}
/// FigCraft's remote pointer: a notched dart pointing up-left from `tip`, filled with the
/// person's colour and edged in white so it shows on any canvas.
pub fn pointer(painter: &egui::Painter, tip: Pos2, fill: Color32) {
let at = |x: f32, y: f32| tip + vec2(x, y);
let (left, notch, right) = (at(3.5, 15.5), at(7.5, 8.5), at(15.5, 4.0));
painter.add(Shape::convex_polygon(vec![tip, left, notch], fill, Stroke::NONE));
painter.add(Shape::convex_polygon(vec![tip, notch, right], fill, Stroke::NONE));
painter.add(Shape::closed_line(vec![tip, left, notch, right], Stroke::new(1.5, Color32::WHITE)));
}
/// A name tag in a person's colour, placed by `align` at `anchor` (next to their pointer, above
/// their text caret).
pub(crate) fn name_tag(painter: &egui::Painter, anchor: Pos2, align: Align2, name: &str, fill: Color32) {
let galley = painter.layout_no_wrap(name.to_string(), semibold(11.0), text_on(fill));
let r = align.anchor_size(anchor, galley.size() + vec2(12.0, 6.0));
painter.rect_filled(r, CornerRadius::same(4), fill);
painter.galley(r.min + vec2(6.0, 3.0), galley, text_on(fill));
}
/// Hand the pointer's page position to the session when it moved (or the view under it did).
pub fn track_pointer(app: &mut FigApp, ctx: &egui::Context) {
if app.session.live.status != Status::Live {
app.ui.live.seen = None;
return;
}
let at = ctx.input(|i| i.pointer.hover_pos()).filter(|p| app.canvas.rect.contains(*p)).map(|p| app.to_page(p)).map(|p| (p.x, p.y));
if at != app.ui.live.seen {
app.ui.live.seen = at;
app.session.live.cursor = at;
}
}
/// Others' selections and cursors on the canvas, and the frame around it while following.
pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) {
let live = &app.session.live;
if live.peers.is_empty() {
app.ui.live.shown.clear();
return;
}
let Ok(ds) = app.session.ds() else { return };
let (doc, page, shared) = (ds.doc.clone(), ds.page, app.session.live_shared());
let mut cursors = Vec::new();
for (id, p) in &live.presence {
let Some(peer) = live.peers.get(id) else { continue };
let fill = color(&peer.color);
// Selections only mean something on the same page of the same (synced) document.
if shared
&& presence_page(p) == Some(page)
&& let Some(sel) = &p.selection
{
for n in sel.iter().filter(|n| doc.page_of(**n) == Some(page)).take(MAX_OUTLINES) {
if let Some(q) = crate::canvas::node_quad(app, &doc, *n) {
painter.add(Shape::closed_line(q.to_vec(), Stroke::new(1.5, fill)));
}
}
}
// Cursors: on your page of the session's document; before its welcome arrives (or with
// another document in front) they show in page coordinates on whatever page you look at.
if let Some((pg, x, y)) = p.cursor
&& (pg == page || !shared)
{
cursors.push((*id, peer.name.clone(), fill, (x, y)));
}
}
if let Some(peer) = live.follow.and_then(|f| live.peers.get(&f)) {
follow_frame(painter, app.canvas.rect, &peer.name, color(&peer.color));
}
let now = ctx.input(|i| i.time);
let dt = (now - app.ui.live.last).clamp(0.0, 0.1);
app.ui.live.last = now;
let k = if app.reduce_motion() { 1.0 } else { 1.0 - (-dt * GLIDE).exp() };
let zoom = ds.view.zoom.max(1e-9);
let mut gliding = false;
let mut shown = BTreeMap::new();
for (id, name, fill, (x, y)) in cursors {
let (mut sx, mut sy) = app.ui.live.shown.get(&id).copied().unwrap_or((x, y));
sx += (x - sx) * k;
sy += (y - sy) * k;
// Within half a pixel: there.
if ((x - sx) * zoom).hypot((y - sy) * zoom) < 0.5 {
(sx, sy) = (x, y);
} else {
gliding = true;
}
shown.insert(id, (sx, sy));
let tip = app.to_screen(sx, sy);
pointer(painter, tip, fill);
name_tag(painter, tip + vec2(12.0, 16.0), Align2::LEFT_TOP, &name, fill);
}
app.ui.live.shown = shown;
if gliding {
ctx.request_repaint();
}
}
/// A frame around the canvas in the followed person's colour, with their name at the top.
fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32) {
painter.rect_stroke(canvas.shrink(1.0), CornerRadius::ZERO, Stroke::new(2.0, fill), egui::StrokeKind::Inside);
let text = trf("Following {name} · {key} to stop", &[("name", &name), ("key", &crate::toolbar::pretty_shortcut("Escape"))]);
let galley = painter.layout_no_wrap(text, semibold(11.0), text_on(fill));
let r = Rect::from_center_size(pos2(canvas.center().x, canvas.min.y + 2.0 + (galley.size().y + 6.0) / 2.0), galley.size() + vec2(16.0, 6.0));
painter.rect_filled(r, CornerRadius { nw: 0, ne: 0, sw: 6, se: 6 }, fill);
painter.galley(r.min + vec2(8.0, 3.0), galley, text_on(fill));
}
/// You and the others at the top of the right panel, within `area`: click someone to follow
/// them (again to stop); the chevron lists everyone.
pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) {
let t = Tokens::get(ui.ctx());
const R: f32 = 12.0;
const STEP: f32 = 20.0;
let cy = area.center().y;
let live = &app.session.live;
let me_name = match live.info.as_ref() {
Some(i) if !i.name.is_empty() => i.name.clone(),
_ => app.session.prefs.user_name.clone(),
};
let me_fill = live.info.as_ref().map_or(t.success, |i| color(&i.color));
let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect();
let follow = live.follow;
// Room for the avatars after yours and the chevron.
let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1);
let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) };
let mut x = area.min.x + R + 2.0;
let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel));
avatar(ui.painter(), pos2(x, cy), R, &me_name, me_fill);
let mut clicked = None;
for (id, name, fill) in peers.iter().take(show) {
x += STEP;
let c = pos2(x, cy);
let following = follow == Some(*id);
let tip = if following { trf("Stop following {name}", &[("name", name)]) } else { trf("Follow {name}", &[("name", name)]) };
let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip);
ring(ui, c);
avatar(ui.painter(), c, R, name, *fill);
if following || resp.hovered() {
ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, *fill));
}
if resp.clicked() {
clicked = Some(*id);
}
}
if more > 0 {
x += STEP;
let c = pos2(x, cy);
ring(ui, c);
ui.painter().circle_filled(c, R, t.pressed);
ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text);
}
let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0));
let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click());
a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp));
if cresp.hovered() {
ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary);
menu_popup(&cresp).width(220.0).show(|ui| {
let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| {
menu_item_with(ui, label, if check { tr("Following") } else { "" }, 22.0, |p, r, _| {
avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill)
})
};
row(ui, &trf("{name} (you)", &[("name", &me_name)]), me_fill, false);
for (id, name, fill) in &peers {
if row(ui, name, *fill, follow == Some(*id)) {
clicked = Some(*id);
ui.close();
}
}
if peers.is_empty() {
widgets::label(
ui,
if app.session.live.status.active() { tr("Nobody else is here yet") } else { tr("Not in a live session") },
t.text_secondary,
);
}
});
if let Some(id) = clicked {
let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) };
app.run("collab.follow", params);
}
}
/// The Share button's popover: the live session (start, invite link, stop or leave) and export.
pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) {
menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let t = Tokens::get(ui.ctx());
ui.spacing_mut().item_spacing.y = 6.0;
ui.set_max_width(300.0);
// Menus lay out justified and don't wrap: the session's notes wrap, left-aligned, within
// the popover's width.
ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
ui.add_space(4.0);
ui.label(egui::RichText::new(tr("Live session")).font(semibold(12.0)));
let status = app.session.live.status.clone();
let role = app.session.live.role();
let others = app.session.live.peers.len();
let published = app.session.live.published.is_some();
// Connecting with a tab already chosen and nobody's welcome yet: the upload.
let publishing = status == Status::Connecting && role.is_none() && app.session.live.doc_uid.is_some();
let (line, col) = match (&status, role) {
(Status::Live, Some(Role::Host)) => (trf("Live · {people}", &[("people", &people_count(others))]), t.success),
(Status::Live, _) if published => (trf("Published · {people}", &[("people", &people_count(others))]), t.success),
(Status::Connecting, _) if publishing => (tr("Publishing…").to_string(), t.text_secondary),
(Status::Live, _) => {
let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default();
(if file.is_empty() { tr("Joined").to_string() } else { trf("Joined {file}", &[("file", &file)]) }, t.success)
}
(Status::Reconnecting { attempt }, _) => {
(trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary)
}
(Status::Failed { .. }, _) => (status.label(), t.danger),
_ => (tr(&status.label()).to_string(), t.text_secondary),
};
widgets::label(ui, &line, col);
match (&status, role) {
(Status::Off | Status::Failed { .. }, _) => start_ui(app, ui, &t),
(Status::Live, Some(Role::Host)) => host_ui(app, ui),
_ => guest_ui(app, ui, &t),
}
});
ui.separator();
if menu_item(ui, tr("Export…"), "") {
app.ui.export_preview = true;
ui.close();
}
});
}
fn people_count(others: usize) -> String {
match others {
0 => tr("nobody else here yet").into(),
n => trn("{n} people joined", n, &[]),
}
}
/// Not in a session: your name, where to share, start; or join one.
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
ui.horizontal(|ui| {
widgets::label(ui, tr("Your name"), t.text);
let name = app.session.prefs.user_name.clone();
if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, tr("Your name")) {
app.run("pref.userName", json!({"name": n}));
}
});
if !app.session.live.can_host() {
widgets::label(ui, tr("Hosting a session needs the desktop app; here you can join one."), t.text_secondary);
if widgets::button(ui, tr("Join a session…"), true, true).clicked() {
open_join(app, "");
ui.close();
}
return;
}
if widgets::checkbox(ui, app.ui.live.lan, tr("Share on the local network")) {
app.ui.live.lan = !app.ui.live.lan;
}
let reach = if app.ui.live.lan {
tr("Anyone on your network who has the invite link can join.")
} else {
tr("Only this computer can join (turn on network sharing to invite others).")
};
widgets::label(ui, reach, t.text_secondary);
ui.horizontal(|ui| {
if widgets::button(ui, tr("Start live session"), true, true).clicked() {
let bind = if app.ui.live.lan { "lan" } else { "local" };
app.run("collab.host", json!({"bind": bind}));
}
if widgets::button(ui, tr("Join a session…"), false, true).clicked() {
open_join(app, "");
ui.close();
}
});
ui.separator();
widgets::label(ui, tr("Or keep the file on a FigCraft server and share links to edit or to view it."), t.text_secondary);
if widgets::button(ui, tr("Publish to server…"), false, true).clicked() {
open_publish(app);
ui.close();
}
}
/// Hosting: the invite link and URL to copy, and stop.
fn host_ui(app: &mut FigApp, ui: &mut Ui) {
let Some(info) = app.session.live.info.clone() else { return };
link_rows(app, ui, &[(tr("Invite link"), &info.link, tr("Invite link copied")), (tr("WebSocket URL"), &info.url, tr("WebSocket URL copied"))]);
let t = Tokens::get(ui.ctx());
widgets::label(ui, tr("The link carries the session's secret: share it only with people you invite."), t.text_secondary);
widgets::label(ui, tr(UNDO_NOTE), t.text_secondary);
if widgets::button(ui, tr("Stop session"), false, true).clicked() {
app.run("collab.stop", json!({}));
}
}
/// Joined, or publishing: where the file is and what you may do there, a published file's links,
/// and leave (the file stays open as your copy).
fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
let live = &app.session.live;
let view = live.info.as_ref().is_some_and(|i| i.access == Access::View);
if let Some(i) = live.info.as_ref() {
widgets::label(ui, &format!("{} · {}", i.address, if view { tr("View only") } else { tr("Can edit") }), t.text_secondary);
if !i.pages.is_empty() {
let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect();
widgets::label(ui, &trf("Pages: {pages}", &[("pages", &pages.join(", "))]), t.text_secondary);
}
}
if let Some(p) = live.published.clone() {
link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]);
widgets::label(ui, tr(PUBLISHED_NOTE), t.text_secondary);
}
widgets::label(ui, tr(if view { VIEW_NOTE } else { UNDO_NOTE }), t.text_secondary);
if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy of the file stays open")).clicked() {
app.run("collab.leave", json!({}));
}
}
/// Links to copy: a label, the link (elided, monospace) and a Copy button each.
fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) {
for (label, value, what) in rows {
ui.horizontal(|ui| {
let t = Tokens::get(ui.ctx());
let w = ui.available_width() - 64.0;
ui.vertical(|ui| {
ui.set_width(w);
widgets::label(ui, label, t.text_secondary);
ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace());
});
if widgets::button(ui, tr("Copy"), false, true).on_hover_text(trf("Copy the {label}", &[("label", &label.to_lowercase())])).clicked() {
app.services.clipboard_set_text(value);
app.toast(*what);
}
});
}
}
/// Open the Join dialog, with `link` in its field (focused).
pub fn open_join(app: &mut FigApp, link: &str) {
app.ui.live.join = Some(link.to_string());
app.ui.live.focus_join = true;
}
/// An invite link the app was opened with (`figcraft figcraft://join?…`): join at once; a link
/// that can't be used shows in the Join dialog with the reason.
pub fn open_invite(app: &mut FigApp, link: &str) {
if app.run("collab.join", json!({"link": link.trim()})).is_none() {
open_join(app, link.trim());
}
}
/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't
/// deselect).
pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) {
let live = &mut app.ui.live;
if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
live.join = None;
live.publish = None;
}
}
/// File ▸ Join live session: paste an invite link.
pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
// The editor takes Escape before its shortcuts; the home screen has none.
dialog_keys(app, ctx);
let Some(mut link) = app.ui.live.join.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut cancel = false;
let mut go = false;
let focus = std::mem::take(&mut app.ui.live.focus_join);
crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
widgets::label(ui, tr("Paste the invite link the host shared with you."), t.text);
let resp = ui
.add(egui::TextEdit::singleline(&mut link).hint_text("figcraft://join?host=…").desired_width(380.0).id(egui::Id::new("live_join_link")));
a11y::text(&resp, tr("Invite link"), "");
if focus {
resp.request_focus();
}
go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
widgets::label(ui, tr("You'll get the host's file as a new tab and edit it together."), t.text_secondary);
if !app.session.live.can_host() {
widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary);
}
ui.add_space(4.0);
ui.horizontal(|ui| {
go |= widgets::button(ui, tr("Join"), true, !link.trim().is_empty()).clicked();
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
});
});
if go && !link.trim().is_empty() && app.run("collab.join", json!({"link": link.trim()})).is_some() {
return;
}
if open && !cancel {
app.ui.live.join = Some(link);
}
}
/// Open the Publish to server dialog for the open file (the last address used this run in it).
pub fn open_publish(app: &mut FigApp) {
if !app.session.live.can_host() {
app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files"));
return;
}
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
app.ui.live.publish = Some(PublishForm { server: app.ui.live.last_server.clone(), name });
app.ui.live.focus_publish = true;
}
/// Share ▸ Publish to server…: the server's address (with its key) and the file's name.
pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut form) = app.ui.live.publish.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut cancel = false;
let mut go = false;
let focus = std::mem::take(&mut app.ui.live.focus_publish);
crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| {
ui.set_width(380.0);
widgets::label(
ui,
tr("Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view."),
t.text,
);
widgets::label(ui, tr("Server address"), t.text_secondary);
let resp = ui.add(
egui::TextEdit::singleline(&mut form.server)
.hint_text("ws://server:47900/?key=…")
.desired_width(380.0)
.id(egui::Id::new("live_publish_server")),
);
// The address carries the server key: it isn't read out.
a11y::text(&resp, tr("Server address"), "");
if focus {
resp.request_focus();
}
let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter));
go = resp.lost_focus() && enter(ui);
widgets::label(ui, tr("On the server's machine, `figcraft-server key` prints it."), t.text_secondary);
widgets::label(ui, tr("File name"), t.text_secondary);
let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name")));
a11y::text(&nresp, tr("File name"), &form.name);
go |= nresp.lost_focus() && enter(ui);
ui.add_space(4.0);
ui.horizontal(|ui| {
go |= widgets::button(ui, tr("Publish"), true, !form.server.trim().is_empty()).clicked();
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
});
});
if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() {
app.ui.live.last_server = form.server.trim().to_string();
return;
}
if open && !cancel {
app.ui.live.publish = Some(form);
}
}
/// Live session state for `ui.inspect`.
pub fn inspect(app: &FigApp) -> serde_json::Value {
let l = &app.session.live;
json!({
"status": l.status.id(),
"role": l.role(),
"peers": l.peers.len(),
"follow": l.follow,
"cursorsShown": app.ui.live.shown(),
"joinDialog": app.ui.live.join.is_some(),
"publishDialog": app.ui.live.publish.is_some(),
"readOnly": app.session.active_read_only(),
})
}
/// Peer ids drawn on the canvas, by what they point at (tests).
#[cfg(test)]
pub(crate) fn shown_at(app: &FigApp) -> Vec<(u32, (f64, f64))> {
app.ui.live.shown.iter().map(|(k, v)| (*k, *v)).collect()
}