Files
Ely-GPUI-Components/examples/gallery/pages/collab/talk.rs
T

351 lines
13 KiB
Rust

use ely_gpui_component::{
collab::{
AnnotatedText, Annotation, Comment, CommentMarker, CommentSidebar, CommentThread, Decision,
EditMode, Reaction, Suggestion, SuggestionMode, Thread, TrackChanges, accept, suggested,
toggled,
},
forms::{Input, TextInput},
theme::ActiveTheme,
};
use gpui::{App, Entity, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
use jiff::{Timestamp, ToSpan};
use super::people::{peers, you};
use crate::{
probe::probe,
ui::{keep, section, set},
};
fn threads(now: Timestamp) -> Vec<Thread> {
let people = peers();
let comment = |ix: usize, hours: i64, body: &str, reactions: Vec<Reaction>| Comment {
author: people[ix].clone(),
at: now - hours.hours(),
body: body.to_string().into(),
reactions,
};
let reaction = |emoji: &str, count: usize, mine: bool| Reaction {
emoji: emoji.to_string().into(),
count,
mine,
};
vec![
Thread {
key: "gamma".into(),
quote: Some("blends in gamma space".into()),
comments: vec![
comment(
0,
5,
"Should this say gamma? Most readers won't know the word.",
vec![reaction("👀", 2, false)],
),
comment(
2,
3,
"Keep it, and link the glossary entry beside it.",
vec![reaction("👍", 1, true)],
),
],
resolved: false,
},
Thread {
key: "weight".into(),
quote: Some("read with the same weight".into()),
comments: vec![comment(
1,
26,
"Could we show a light and a dark pair side by side here?",
Vec::new(),
)],
resolved: false,
},
Thread {
key: "title".into(),
quote: Some("On Lift".into()),
comments: vec![
comment(3, 50, "The title reads well now.", Vec::new()),
comment(0, 49, "Agreed, closing this.", Vec::new()),
],
resolved: true,
},
]
}
/// Changes the thread keyed `key`.
fn change(
all: &Entity<Vec<Thread>>,
key: &SharedString,
cx: &mut App,
edit: impl FnOnce(&mut Thread),
) {
all.update(cx, |all, cx| {
let thread = all
.iter_mut()
.find(|thread| &thread.key == key)
.expect("the demo keeps its threads");
edit(thread);
cx.notify();
});
}
/// Adds the field's words to the thread keyed `key` as yours, and clears it.
fn answer(all: &Entity<Vec<Thread>>, key: &SharedString, field: &Entity<TextInput>, cx: &mut App) {
let body = field.read(cx).text().trim().to_string();
field.update(cx, |field, cx| field.set_text("", cx));
change(all, key, cx, |thread| {
thread.comments.push(Comment {
author: you(),
at: Timestamp::now(),
body: body.into(),
reactions: Vec::new(),
})
});
}
fn reply_field(key: &'static str, window: &mut Window, cx: &mut App) -> Entity<TextInput> {
window.use_keyed_state(key, cx, |window, cx| {
TextInput::new(window, cx).placeholder("Reply")
})
}
pub fn comments(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let all = keep("collab-threads", || threads(Timestamp::now()), window, cx);
let now = all.read(cx).clone();
let (one, marked, side) = (
reply_field("collab-reply-one", window, cx),
reply_field("collab-reply-marker", window, cx),
reply_field("collab-reply-side", window, cx),
);
let active = keep(
"collab-active",
|| Some(SharedString::from("gamma")),
window,
cx,
);
let picked = active.read(cx).clone();
let thread = |key: &str| {
now.iter()
.find(|thread| thread.key.as_ref() == key)
.expect("the demo keeps its threads")
.clone()
};
let key = |key: &'static str| SharedString::from(key);
let (sent, resolved, reacted) = (all.clone(), all.clone(), all.clone());
let first = CommentThread::new("collab-thread", thread("gamma"))
.reply(&one, {
let one = one.clone();
move |_, cx| answer(&sent, &key("gamma"), &one, cx)
})
.on_resolve(move |on, _, cx| {
change(&resolved, &key("gamma"), cx, |thread| thread.resolved = on)
})
.on_react(move |ix, emoji, _, cx| {
change(&reacted, &key("gamma"), cx, |thread| {
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
})
});
let (sent, resolved) = (all.clone(), all.clone());
let marker = CommentMarker::new("collab-marker", thread("weight"))
.reply(&marked, {
let marked = marked.clone();
move |_, cx| answer(&sent, &key("weight"), &marked, cx)
})
.on_resolve(move |on, _, cx| {
change(&resolved, &key("weight"), cx, |thread| thread.resolved = on)
});
let (sent, resolved, reacted, pick) = (all.clone(), all.clone(), all, active.clone());
let sidebar = CommentSidebar::new("collab-sidebar", now.clone())
.active(picked.clone(), move |key, _, cx| {
set(&pick, Some(key.clone()), cx)
})
.reply(&side, {
let side = side.clone();
move |_, cx| {
if let Some(key) = active.read(cx).clone() {
answer(&sent, &key, &side, cx)
}
}
})
.on_resolve(move |key, on, _, cx| change(&resolved, key, cx, |thread| thread.resolved = on))
.on_react(move |key, ix, emoji, _, cx| {
change(&reacted, key, cx, |thread| {
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
})
});
let theme = cx.theme();
div()
.child(
section(
"CommentThread / Reply / Reactions / ReactionPicker",
"A conversation on the words it quotes: each comment with its author, time and reactions; a reply below, Enter to send; resolve above. Press a reaction to add yours or take it back, or pick another.",
cx,
)
.child(probe("collab-thread", div().w(px(440.)).child(first))),
)
.child(
section(
"CommentBubble / CommentMarker",
"A mark beside the words a thread is about, with its count; a press opens the thread in a bubble over the page.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.items_center()
.gap_2()
.child(div().text_color(theme.colors.fg).child("In dark, accents lift further so they read with the same weight."))
.child(probe("collab-marker", marker)),
),
)
.child(
section(
"CommentSidebar",
"Every conversation down a column, open or resolved; a press picks one, and the one picked takes replies.",
cx,
)
.child(probe(
"collab-sidebar",
div()
.w(px(400.))
.h(px(560.))
.rounded(theme.radius(ely_gpui_component::theme::Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.child(sidebar),
)),
)
}
const PASSAGE: &str = "Every accent in the library is a base color and a lift. The base carries the hue; the lift carries how loud the color is. Hover, press and focus are lifts of the accent.";
pub fn annotations(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let picked = keep("collab-picked", || None::<usize>, window, cx);
let now = *picked.read(cx);
let people = peers();
let mark = |words: &str, author: usize, note: Option<&str>| {
let start = PASSAGE.find(words).expect("the passage holds its marks");
Annotation {
range: start..start + words.len(),
author: people[author].clone(),
note: note.map(|note| note.to_string().into()),
}
};
section(
"Annotation / Highlight",
"Stretches people marked, washed in their colors; the ones with a note are underlined, and resting on one shows it. A press picks one. A plain mark with no author is typography::Highlight.",
cx,
)
.child(probe(
"collab-annotations",
div().w(px(560.)).child(
AnnotatedText::new(
"collab-annotated",
PASSAGE,
[
mark("a base color and a lift", 0, Some("The one idea to remember.")),
mark("how loud the color is", 1, None),
mark("lifts of the accent", 2, Some("Link to the motion page.")),
],
)
.picked(now, move |ix, _, cx| set(&picked, Some(ix), cx)),
),
))
}
const BASE: &str = "A lift blends color toward white. Half a lift sits midway between.";
/// `base` with every one of `suggestions` taken.
fn applied(base: &str, suggestions: Vec<Suggestion>) -> String {
let (mut text, mut left) = (base.to_string(), suggestions);
while !left.is_empty() {
let Some(taken) = accept(&text, &left, 0) else {
break;
};
(text, left) = taken;
}
text
}
pub fn changes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let mode = keep("collab-mode", || EditMode::Suggesting, window, cx);
let now_mode = *mode.read(cx);
let base = keep("collab-base", || BASE.to_string(), window, cx);
let now_base = base.read(cx).clone();
let draft = window.use_keyed_state("collab-suggesting", cx, |window, cx| {
let mut input = TextInput::new(window, cx);
input.set_text(
"A tint blends every color toward white. Half a lift sits midway.",
cx,
);
input
});
let edited = draft.read(cx).text().to_string();
let (switch_base, switch_draft) = (base.clone(), draft.clone());
let switch = SuggestionMode::new("collab-mode", now_mode, move |next, _, cx| {
if *mode.read(cx) == EditMode::Editing {
let text = switch_draft.read(cx).text().to_string();
set(&switch_base, text, cx);
}
switch_draft.update(cx, |input, cx| {
input.set_disabled(next == EditMode::Viewing, cx)
});
set(&mode, next, cx)
});
let track = match now_mode {
EditMode::Editing => TrackChanges::new("collab-track", edited, Vec::new()),
EditMode::Viewing => {
let pending = suggested(&now_base, &edited, &you());
TrackChanges::new("collab-track", now_base, pending)
}
EditMode::Suggesting => {
let pending = suggested(&now_base, &edited, &you());
let (decided, field) = (base.clone(), draft.clone());
TrackChanges::new("collab-track", now_base, pending).on_decide(
move |decision, _, cx| {
let base_text = decided.read(cx).clone();
let pending = suggested(&base_text, field.read(cx).text(), &you());
match decision {
Decision::Accept(ix) => {
if let Some((text, _)) = accept(&base_text, &pending, ix) {
set(&decided, text, cx)
}
}
Decision::AcceptAll => set(&decided, applied(&base_text, pending), cx),
Decision::Reject(ix) => {
let mut rest = pending;
if ix >= rest.len() {
return;
}
rest.remove(ix);
let text = applied(&base_text, rest);
field.update(cx, |input, cx| input.set_text(text, cx));
}
Decision::RejectAll => {
field.update(cx, |input, cx| input.set_text(base_text, cx))
}
}
},
)
}
};
section(
"SuggestionMode / TrackChanges",
"Editing changes the words; suggesting keeps them and proposes the change, removed words struck and added ones underlined in the author's color, each to accept or reject; viewing only reads. Type in the field while suggesting.",
cx,
)
.child(probe(
"collab-changes",
div()
.w(px(560.))
.flex()
.flex_col()
.gap_4()
.child(switch)
.child(Input::new(&draft))
.child(track),
))
}