Files
figcraft/crates/engine/src/cmd/facilitate.rs
T
BFlat 4fad29a66e M12: board timer and voting sessions (#1361)
* M12: board timer and voting

A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the
time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on
everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes
show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document
properties and ballots as one entry per voter; neither is an undo step. A pill at the top right
of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs.

* M12: board timer and voting on egui 0.37

Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique.

* M12: board.vote's start and ballot changes in functions of their own (Sonar)
2026-10-26 12:28:59 +07:00

374 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Running a session on a board: a countdown everyone sees and votes on objects.
//!
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
use std::sync::Arc;
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
use figcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::board::{require, text_of};
use crate::cmd::CommandSpec;
use crate::params::{id, num, req_string, req_targets, string};
use crate::tools::PointerEvent;
use crate::{EngineError, Result, Session, bad, spec};
/// Minutes a timer starts with, and adds, when none are given.
const START_MINUTES: f64 = 5.0;
const ADD_MINUTES: f64 = 1.0;
/// Votes each person gets when none are given.
const VOTES: u32 = 3;
/// A vote badge's radius on screen (logical pixels).
pub const BADGE_PX: f64 = 11.0;
/// The longest a voter name is kept.
const MAX_VOTER: usize = 64;
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.timer",
"Timer",
"{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session",
false,
timer_cmd
),
spec!(
"board.timerState",
"Timer state",
"{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}",
false,
|s, _| {
require(s, "board.timerState")?;
Ok(timer_json(s.doc()?.timer, now_ms()))
}
),
spec!(
"board.vote",
"Voting session",
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
false,
vote_cmd
),
spec!(
"board.voteResults",
"Voting results",
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
false,
|s, p| {
require(s, "board.voteResults")?;
let who = voter(s, p);
Ok(vote_json(s.doc()?, &who))
}
),
]
}
/// Milliseconds since the Unix epoch (0 if the clock is before it).
pub fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up).
pub fn clock(left: u64) -> String {
let s = left.div_ceil(1000);
let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it).
pub fn timer_state(t: Option<BoardTimer>, now: u64) -> &'static str {
match t {
None => "off",
Some(BoardTimer::Paused { .. }) => "paused",
Some(t) if t.left(now) == 0 => "done",
Some(_) => "running",
}
}
fn timer_json(t: Option<BoardTimer>, now: u64) -> Value {
let left = t.map_or(0, |t| t.left(now));
let ends = match t {
Some(BoardTimer::Running { ends }) => Some(ends),
_ => None,
};
json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends})
}
/// `minutes` (or `default`) as milliseconds: more than 0, at most a day.
fn duration(cmd: &str, p: &Value, default: f64) -> Result<u64> {
let m = match p.get("minutes").filter(|v| !v.is_null()) {
Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?,
None => default,
};
let ms = (m * 60_000.0).round();
if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) {
return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes"));
}
Ok(ms as u64)
}
/// The timer after `action` at `now` (`Ok(None)`: stopped).
pub fn timer_after(cmd: &str, t: Option<BoardTimer>, action: &str, p: &Value, now: u64) -> Result<Option<BoardTimer>> {
let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into());
Ok(match action {
"start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }),
"stop" => None,
"pause" => match t {
Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())),
},
"resume" => match t {
Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())),
},
"add" => {
let more = duration(cmd, p, ADD_MINUTES)?;
let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS);
match t {
Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }),
_ => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
}
}
other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))),
})
}
fn timer_cmd(s: &mut Session, p: &Value) -> Result<Value> {
require(s, "board.timer")?;
let action = req_string("board.timer", p, "action")?;
let now = now_ms();
let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?;
if s.doc()?.timer != t {
s.doc_mut()?.timer = t;
}
Ok(timer_json(t, now))
}
/// Who votes: `voter` from the params, else you as the live session knows you (your client id
/// there, so two people of the same name vote apart), else your name.
pub fn voter(s: &Session, p: &Value) -> String {
if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::<String>())
&& !v.is_empty()
{
return v;
}
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref());
match live {
Some(info) => format!("peer {}", info.me),
None => crate::cmd::comment::user_name(s),
}
}
fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> {
match &doc.vote {
Some(v) if !v.ended => Ok(v),
_ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())),
}
}
fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "board.vote";
require(s, CMD)?;
let action = req_string(CMD, p, "action")?;
let who = voter(s, p);
match action.as_str() {
"start" => start_vote(s, p, CMD)?,
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
"end" => {
running(CMD, s.doc()?)?;
if let Some(v) = s.doc_mut()?.vote.as_mut() {
v.ended = true;
}
}
"clear" => {
let doc = s.doc()?;
if doc.vote.is_some() || !doc.votes.is_empty() {
let doc = s.doc_mut()?;
doc.vote = None;
doc.votes.clear();
}
}
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
}
Ok(vote_json(s.doc()?, &who))
}
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) {
return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into()));
}
let per = match p.get("votes").filter(|v| !v.is_null()) {
Some(_) => num(p, "votes")
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n))
.ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?,
None => f64::from(VOTES),
} as u32;
let scope = match string(p, "on").as_deref().unwrap_or("all") {
"all" => VoteScope::All,
"stickies" => VoteScope::Stickies,
"selection" => {
let doc = s.doc()?;
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect();
if ids.is_empty() {
return Err(bad(cmd, "pick the objects to vote on (not pages)"));
}
VoteScope::Only { ids }
}
other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))),
};
let by = crate::board::author(s);
let doc = s.doc_mut()?;
doc.votes.clear();
doc.vote = Some(Vote { per_person: per, scope, ended: false, by });
Ok(())
}
/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back
/// from an object, within the running vote's scope and votes per person.
fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> {
let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?;
let doc = s.doc()?;
let vote = running(cmd, doc)?;
let mut ballot: Vec<NodeId> = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect();
if cast {
if !vote.scope.takes(doc, target) {
return Err(match doc.node(target) {
None => EngineError::NoNode(target),
Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()),
});
}
if ballot.len() >= vote.per_person as usize {
return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into()));
}
ballot.push(target);
} else {
let i =
ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?;
ballot.remove(i);
}
let votes = &mut s.doc_mut()?.votes;
if ballot.is_empty() {
votes.remove(who);
} else {
votes.insert(who.to_string(), ballot);
}
Ok(())
}
/// `who`'s votes per object, in the order first voted on.
pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> {
let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() };
let mut out: Vec<(NodeId, u32)> = Vec::new();
for id in vote.counted(doc, ballot) {
match out.iter_mut().find(|(i, _)| *i == id) {
Some((_, n)) => *n += 1,
None => out.push((id, 1)),
}
}
out
}
/// What an object is called in the results: its text (a sticky's note), else its name.
pub fn object_name(doc: &Document, id: NodeId) -> String {
let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string());
let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default();
let line = name.lines().next().unwrap_or_default();
let short: String = line.chars().take(60).collect();
if short.len() < name.len() { format!("{short}…") } else { short }
}
fn vote_json(doc: &Document, who: &str) -> Value {
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
let on = match &vote.scope {
VoteScope::All => json!("all"),
VoteScope::Stickies => json!("stickies"),
VoteScope::Only { ids } => json!({"selection": ids}),
};
let mine = mine(doc, who);
let used: u32 = mine.iter().map(|m| m.1).sum();
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
let mut out = json!({
"state": if vote.ended { "ended" } else { "running" },
"votesPerPerson": vote.per_person,
"on": on,
"by": vote.by,
"left": vote.per_person.saturating_sub(used),
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
"voters": voters,
});
if vote.ended {
let results: Vec<Value> =
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
out["results"] = json!(results);
}
out
}
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
let mut last = None;
let mut rank = 0;
vote.tally(doc, &doc.votes)
.into_iter()
.enumerate()
.map(|(i, (id, n))| {
if last != Some(n) {
rank = i + 1;
last = Some(n);
}
(rank, id, n)
})
.collect()
}
/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at
/// `zoom` screen pixels per unit.
pub fn badge(b: Rect, zoom: f64) -> (Point, f64) {
(Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6))
}
impl Session {
/// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it
/// takes that vote back; on an object the vote is on, it puts a vote there. False when the
/// press is for the tool (no vote running, or nothing to vote on there).
pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result<bool> {
let doc = self.doc()?;
let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) };
let who = voter(self, &Value::Null);
let p = ev.p();
let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| {
doc.world_bounds(*id).is_some_and(|b| {
let (c, r) = badge(b, ev.zoom);
(p - c).hypot() <= r
})
});
let hit = || {
let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?;
vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false))
};
let (action, target) = match on_badge {
Some(id) => ("retract", id),
None => match hit() {
Some(id) => (if ev.alt { "retract" } else { "cast" }, id),
None => return Ok(false),
},
};
self.execute("board.vote", &json!({"action": action, "id": target}))?;
Ok(true)
}
}
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
/// keep them as they are now (`from`).
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
let d = Arc::make_mut(to);
d.timer = from.timer;
d.vote = from.vote.clone();
d.votes = from.votes.clone();
}
}