diff --git a/AGENTS.md b/AGENTS.md index e64b16b..8052f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - `ScrollHandle` keeps its children's bounds unscrolled: a child paints at its bounds plus the offset. A press on a scrolled child subtracts both, and an offset that brings a child to a place is set whole, not added (`documents::find::reveal`). - A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them. - Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`). -- A mode that leaves on Escape keeps its own focus handle: turned on while focus is elsewhere, it takes focus so Escape reaches it, and hands focus back when it leaves (`documents::ZenMode`). +- A mode that leaves on Escape holds its own focus through `primitives::hold_focus`: turned on while focus is elsewhere, it takes focus so Escape reaches it, and `hand_back` returns it (`documents::ZenMode`, `collab::FollowMode`). +- A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. - A component that fills its container says `w_full` on its root. As a window's root, or in a flex row, it would shrink to its text's narrowest width. - A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match. diff --git a/TASKS.md b/TASKS.md index 8e0be46..bcbfe18 100644 --- a/TASKS.md +++ b/TASKS.md @@ -71,7 +71,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T21a Documents · Editing — `tasks/ch21-30.md` - [x] T21b Documents · Reading - [x] T21c Documents · Knowledge -- [ ] T22 Collaboration +- [x] T22 Collaboration - [ ] T23a AI Chat · Messages - [ ] T23b AI Chat · Citations - [ ] T23c AI Chat · Input diff --git a/assets/icons/message-square-diff.svg b/assets/icons/message-square-diff.svg new file mode 100644 index 0000000..611914c --- /dev/null +++ b/assets/icons/message-square-diff.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/assets/icons/smile-plus.svg b/assets/icons/smile-plus.svg new file mode 100644 index 0000000..2bf9238 --- /dev/null +++ b/assets/icons/smile-plus.svg @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/assets/icons/user-plus.svg b/assets/icons/user-plus.svg new file mode 100644 index 0000000..f2007a2 --- /dev/null +++ b/assets/icons/user-plus.svg @@ -0,0 +1,18 @@ + + + + + + + diff --git a/examples/gallery/pages/collab/mod.rs b/examples/gallery/pages/collab/mod.rs new file mode 100644 index 0000000..05022a3 --- /dev/null +++ b/examples/gallery/pages/collab/mod.rs @@ -0,0 +1,57 @@ +mod people; +mod sharing; +mod talk; + +use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div}; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 22, + slug: "collab", + title: "Collaboration", + summary: "People in the same document: who is here and where, comments and reactions, suggested changes, and sharing.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::HoverAt("collab-peers", 35.0, 13.0), + Step::Wait(700), + Step::Shot("presence"), + Step::DownAt("collab-peers", 35.0, 13.0), + Step::UpAt("collab-peers", 35.0, 13.0), + Step::Wait(300), + Step::Shot("follow"), + Step::DownAt("collab-peers", 35.0, 13.0), + Step::UpAt("collab-peers", 35.0, 13.0), + Step::DownAt("collab-marker", 12.0, 12.0), + Step::UpAt("collab-marker", 12.0, 12.0), + Step::Wait(300), + Step::Shot("bubble"), + Step::Key("escape"), + Step::DownAt("collab-sidebar", 238.0, 24.0), + Step::UpAt("collab-sidebar", 238.0, 24.0), + Step::Wait(300), + Step::Shot("resolved"), + Step::DownAt("collab-sidebar", 90.0, 24.0), + Step::UpAt("collab-sidebar", 90.0, 24.0), + Step::DownAt("collab-share", 30.0, 14.0), + Step::UpAt("collab-share", 30.0, 14.0), + Step::Wait(400), + Step::Shot("share"), + Step::Key("escape"), + Step::Wait(300), +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(people::presence(window, cx)) + .child(talk::comments(window, cx)) + .child(talk::annotations(window, cx)) + .child(talk::changes(window, cx)) + .child(sharing::sharing(window, cx)) + .child(sharing::activity(cx)) + .into_any_element() +} diff --git a/examples/gallery/pages/collab/people.rs b/examples/gallery/pages/collab/people.rs new file mode 100644 index 0000000..e8f4e4c --- /dev/null +++ b/examples/gallery/pages/collab/people.rs @@ -0,0 +1,116 @@ +use ely_gpui_component::{ + collab::{FollowMode, LiveIndicator, Peer, PresenceAvatars, RemoteCursor, RemoteSelection}, + forms::{Input, TextInput}, + theme::{ActiveTheme, TextSize}, +}; +use gpui::{ + App, Entity, FontWeight, IntoElement, ParentElement, SharedString, Styled, Window, div, px, +}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +/// The people in the demo document, each somewhere in it. +pub fn peers() -> Vec { + [ + ("ada", "Ada Park", "Reading “In dark”"), + ("grace", "Grace Lin", "Writing the opening"), + ("alan", "Alan Reyes", "In the comments"), + ("mae", "Mae Okafor", "Viewing"), + ("tim", "Tim Novak", "Viewing"), + ("joan", "Joan Ito", "Viewing"), + ] + .into_iter() + .enumerate() + .map(|(hue, (key, name, place))| Peer { + place: Some(place.into()), + ..Peer::new(key, name, hue) + }) + .collect() +} + +/// The one reading the demo. +pub fn you() -> Peer { + Peer::new("you", "You", 6) +} + +const DRAFT: &str = "A lift blends a color toward white.\nHalf a lift sits midway; a full lift is white itself.\nIn dark, accents lift further so they read with the same weight."; + +fn draft(window: &mut Window, cx: &mut App) -> Entity { + window.use_keyed_state("collab-draft", cx, |window, cx| { + let mut input = TextInput::new(window, cx).multi_line(3, 6); + input.set_text(DRAFT, cx); + input + }) +} + +pub fn presence(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let following = keep("collab-following", || None::, window, cx); + let now = following.read(cx).clone(); + let people = peers(); + let followed = now + .as_ref() + .and_then(|key| people.iter().find(|peer| &peer.key == key).cloned()); + let draft = draft(window, cx); + let selected = DRAFT + .find("a full lift") + .expect("the draft names a full lift"); + let caret = DRAFT.find("accents").expect("the draft names accents"); + let theme = cx.theme(); + let (toggle, stop) = (following.clone(), following); + section( + "PresenceAvatars / LiveIndicator / FollowMode / RemoteCursor / RemoteSelection", + "Who else is here, ringed in their colors, their carets and selections live in the text. Rest on someone to see where they are; press to follow their view, and Escape to stop.", + cx, + ) + .child( + div() + .w(px(760.)) + .flex() + .flex_col() + .gap_3() + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .flex_1() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child("Handbook / Color / Lift"), + ) + .child(LiveIndicator::new("collab-live", people.len())) + .child(probe( + "collab-peers", + PresenceAvatars::new("collab-peers", people.clone()).follow( + now, + move |key, _, cx| { + let next = + (toggle.read(cx).as_ref() != Some(key)).then(|| key.clone()); + set(&toggle, next, cx) + }, + ), + )), + ) + .child( + FollowMode::new("collab-follow", followed, move |_, cx| set(&stop, None, cx)) + .child( + div().p_2().child( + div() + .relative() + .child(Input::new(&draft)) + .child(RemoteSelection::new( + &people[2], + &draft, + selected..selected + "a full lift".len(), + )) + .child(RemoteCursor::new(&people[1], &draft, caret)), + ), + ), + ), + ) +} diff --git a/examples/gallery/pages/collab/sharing.rs b/examples/gallery/pages/collab/sharing.rs new file mode 100644 index 0000000..bb219f8 --- /dev/null +++ b/examples/gallery/pages/collab/sharing.rs @@ -0,0 +1,168 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + collab::{AccessList, InviteInput, LinkAccess, Member, Role, ShareDialog}, + data_display::{Activity, ActivityFeed}, + forms::is_email, + primitives::IconName, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +fn member(name: &str, email: &str, role: Role) -> Member { + Member { + name: name.to_string().into(), + email: email.to_string().into(), + picture: None, + role, + owner: false, + } +} + +fn members() -> Vec { + vec![ + Member { + owner: true, + ..member("You", "you@example.com", Role::Edit) + }, + member("Ada Park", "ada@example.com", Role::Edit), + member("Grace Lin", "grace@example.com", Role::Comment), + member("Alan Reyes", "alan@example.com", Role::View), + ] +} + +pub fn sharing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let open = keep("collab-share-open", || false, window, cx); + let emails = keep( + "collab-emails", + || { + vec![ + SharedString::from("mae@example.com"), + SharedString::from("tim@example"), + ] + }, + window, + cx, + ); + let role = keep("collab-invite-role", || Role::Edit, window, cx); + let people = keep("collab-members", members, window, cx); + let link = keep("collab-link", || None::, window, cx); + let dialog = open.read(cx).then(|| { + let (now_emails, now_role, now_people, now_link) = ( + emails.read(cx).clone(), + *role.read(cx), + people.read(cx).clone(), + *link.read(cx), + ); + let (close, typed, invited, cleared, picked) = ( + open.clone(), + emails.clone(), + people.clone(), + emails.clone(), + role.clone(), + ); + let (roled, removed) = (people.clone(), people.clone()); + ShareDialog::new("collab-share", "Lift", move |_, cx| set(&close, false, cx)) + .invite( + InviteInput::new( + "collab-invite", + now_emails, + move |next, _, cx| set(&typed, next.to_vec(), cx), + move |_, cx| { + let (added, role) = (cleared.read(cx).clone(), *picked.read(cx)); + invited.update(cx, |people, cx| { + people.extend(added.iter().filter(|email| is_email(email)).map( + |email| { + let (name, _) = + email.split_once('@').expect("an address holds @"); + member(name, email, role) + }, + )); + cx.notify(); + }); + set(&cleared, Vec::new(), cx) + }, + ) + .role(now_role, move |next, _, cx| set(&role, next, cx)), + ) + .access( + AccessList::new("collab-access", now_people) + .on_role(move |ix, next, _, cx| { + roled.update(cx, |people, cx| { + people[ix].role = next; + cx.notify(); + }) + }) + .on_remove(move |ix, _, cx| { + removed.update(cx, |people, cx| { + people.remove(ix); + cx.notify(); + }) + }), + ) + .link(LinkAccess::new( + "collab-link", + "https://example.com/d/lift", + now_link, + move |next, _, cx| set(&link, next, cx), + )) + }); + section( + "ShareDialog / InviteInput / PermissionSelect / AccessList", + "Sharing in one place: add people by email, each chip marked until it is an address; choose what they may do; change or take away anyone's access; and choose who the link lets in.", + cx, + ) + .child(probe( + "collab-share", + div().flex().child( + Button::new("collab-share-open", "Share") + .variant(ButtonVariant::Primary) + .icon(IconName::UserPlus) + .on_click(move |_, _, cx| set(&open, true, cx)), + ), + )) + .children(dialog) +} + +pub fn activity(cx: &mut App) -> impl IntoElement + use<> { + let now = Timestamp::now(); + let entry = |key: &str, actor: &str, verb: &str, object: &str, minutes: i64| { + Activity::new( + key.to_string(), + actor.to_string(), + verb.to_string(), + now - minutes.minutes(), + ) + .object(object.to_string()) + }; + section( + "ActivityLog → data_display::ActivityFeed", + "What people did in the document, newest first.", + cx, + ) + .child( + div().w(px(560.)).child( + ActivityFeed::new("collab-activity") + .entry(entry( + "reply", + "Alan Reyes", + "replied on", + "blends in gamma space", + 4, + )) + .entry(entry( + "suggest", + "Grace Lin", + "suggested a change to", + "Lift", + 35, + )) + .entry(entry("resolve", "Ada Park", "resolved", "On Lift", 180)) + .entry(entry("share", "You", "shared", "Lift", 1440)), + ), + ) +} diff --git a/examples/gallery/pages/collab/talk.rs b/examples/gallery/pages/collab/talk.rs new file mode 100644 index 0000000..bb7c718 --- /dev/null +++ b/examples/gallery/pages/collab/talk.rs @@ -0,0 +1,342 @@ +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 { + let people = peers(); + let comment = |ix: usize, hours: i64, body: &str, reactions: Vec| 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>, + 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>, key: &SharedString, field: &Entity, 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 { + 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::, 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) -> String { + let (mut text, mut left) = (base.to_string(), suggestions); + while !left.is_empty() { + (text, left) = accept(&text, &left, 0); + } + 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) => { + set(&decided, accept(&base_text, &pending, ix).0, cx) + } + Decision::AcceptAll => set(&decided, applied(&base_text, pending), cx), + Decision::Reject(ix) => { + let mut rest = pending; + 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), + )) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index f344f25..c53d57a 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -1,5 +1,6 @@ mod buttons; mod charts; +mod collab; mod data; mod debug; mod documents; @@ -57,6 +58,7 @@ pub const ALL: &[Page] = &[ git::PAGE, debug::PAGE, documents::PAGE, + collab::PAGE, theme::PAGE, ]; diff --git a/src/collab/annotate.rs b/src/collab/annotate.rs new file mode 100644 index 0000000..84d9d4d --- /dev/null +++ b/src/collab/annotate.rs @@ -0,0 +1,132 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, HighlightStyle, InteractiveText, IntoElement, RenderOnce, SharedString, + StyledText, UnderlineStyle, Window, +}; + +use super::peers::Peer; +use crate::{forms::Pick, primitives::Tooltip, theme::ActiveTheme}; + +/// How strongly an author's color washes what they marked, and the one picked. +const WASH: f32 = 0.18; +const PICKED: f32 = 0.36; + +/// A stretch of text someone marked: its byte range, who marked it, and the note they left; without a note it is a plain highlight. +#[derive(Clone, Debug, PartialEq)] +pub struct Annotation { + pub range: Range, + pub author: Peer, + pub note: Option, +} + +/// Text with stretches people marked, each washed in its author's color and noted ones underlined: resting on a noted one shows the note and who left it; a press picks one. +#[derive(IntoElement)] +pub struct AnnotatedText { + id: ElementId, + text: SharedString, + annotations: Rc>, + picked: Option, + on_pick: Option, +} + +impl AnnotatedText { + /// `annotations` in order, none overlapping. + pub fn new( + id: impl Into, + text: impl Into, + annotations: impl IntoIterator, + ) -> Self { + let text = text.into(); + let annotations: Vec = annotations.into_iter().collect(); + for annotation in &annotations { + let range = &annotation.range; + assert!( + range.start < range.end + && range.end <= text.len() + && text.is_char_boundary(range.start) + && text.is_char_boundary(range.end), + "annotation {range:?} lies outside the text" + ); + } + assert!( + annotations + .windows(2) + .all(|pair| pair[0].range.end <= pair[1].range.start), + "annotations come in order without overlapping" + ); + Self { + id: id.into(), + text, + annotations: Rc::new(annotations), + picked: None, + on_pick: None, + } + } + + /// The annotation picked, by index, and what a press on one asks. + pub fn picked( + mut self, + picked: Option, + on_pick: impl Fn(usize, &mut Window, &mut App) + 'static, + ) -> Self { + self.picked = picked; + self.on_pick = Some(Rc::new(on_pick)); + self + } +} + +impl RenderOnce for AnnotatedText { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let thickness = cx.theme().underline_thickness(); + let highlights: Vec<(Range, HighlightStyle)> = self + .annotations + .iter() + .enumerate() + .map(|(ix, annotation)| { + let color = annotation.author.color(cx); + let wash = if self.picked == Some(ix) { + PICKED + } else { + WASH + }; + let style = HighlightStyle { + background_color: Some(color.opacity(wash)), + underline: annotation.note.is_some().then_some(UnderlineStyle { + thickness, + color: Some(color), + wavy: false, + }), + ..HighlightStyle::default() + }; + (annotation.range.clone(), style) + }) + .collect(); + let ranges: Vec> = self + .annotations + .iter() + .map(|annotation| annotation.range.clone()) + .collect(); + let notes = self.annotations.clone(); + let text = InteractiveText::new( + self.id, + StyledText::new(self.text).with_highlights(highlights), + ) + .tooltip(move |at, window, cx| { + let annotation = notes + .iter() + .find(|annotation| annotation.range.contains(&at))?; + let note = annotation.note.clone()?; + Some(Tooltip::with_meta(note, annotation.author.name.clone())( + window, cx, + )) + }); + match self.on_pick { + Some(pick) => text.on_click(ranges, move |ix, window, cx| { + log::info!("annotated text: picked {ix}"); + pick(ix, window, cx) + }), + None => text, + } + } +} diff --git a/src/collab/comments.rs b/src/collab/comments.rs new file mode 100644 index 0000000..591359b --- /dev/null +++ b/src/collab/comments.rs @@ -0,0 +1,492 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, Entity, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; +use jiff::Timestamp; + +use super::{ + peers::Peer, + reactions::{Reaction, Reactions}, +}; +use crate::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + feedback::EmptyState, + forms::{Enter, Input, OnFlag, OnValue, Run, TextInput}, + overlays::Popover, + primitives::IconName, + theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize}, + typography::RelativeTime, +}; + +/// A comment: who wrote it, when, what, and the reactions it has. +#[derive(Clone, Debug, PartialEq)] +pub struct Comment { + pub author: Peer, + pub at: Timestamp, + pub body: SharedString, + pub reactions: Vec, +} + +/// A conversation on a document: its key, the words it is about, its comments in order, and whether it is resolved. +#[derive(Clone, Debug, PartialEq)] +pub struct Thread { + pub key: SharedString, + pub quote: Option, + pub comments: Vec, + pub resolved: bool, +} + +type OnReact = Rc; +type OnThreadFlag = Rc; +type OnThreadReact = Rc; + +/// A field to answer in and a button to send; Enter sends too, never an empty answer. +#[derive(IntoElement)] +pub struct Reply { + id: ElementId, + field: Entity, + on_send: Run, +} + +impl Reply { + pub fn new( + id: impl Into, + field: &Entity, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + field: field.clone(), + on_send: Rc::new(on_send), + } + } +} + +impl RenderOnce for Reply { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let empty = self.field.read(cx).text().trim().is_empty(); + let (send, enter) = (self.on_send.clone(), self.on_send); + div() + .flex() + .items_center() + .gap_2() + .capture_action(move |_: &Enter, window, cx| { + cx.stop_propagation(); + if !empty { + log::info!("reply: sent"); + enter(window, cx); + } + }) + .child( + div() + .flex_1() + .min_w_0() + .child(Input::new(&self.field).size(ControlSize::Sm)), + ) + .child( + Button::new((self.id.clone(), "send"), "Reply") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .disabled(empty) + .on_click(move |_, window, cx| { + log::info!("reply: sent"); + send(window, cx) + }), + ) + } +} + +/// A conversation on a document: the words it is about, each comment with its author, time and reactions, a reply below, and resolve or reopen above; a resolved thread quiets down and takes no replies. +#[derive(IntoElement)] +pub struct CommentThread { + id: ElementId, + thread: Thread, + active: bool, + reply: Option<(Entity, Run)>, + on_resolve: Option, + on_react: Option, +} + +impl CommentThread { + pub fn new(id: impl Into, thread: Thread) -> Self { + assert!( + !thread.comments.is_empty(), + "thread {} has no comment", + thread.key + ); + Self { + id: id.into(), + thread, + active: false, + reply: None, + on_resolve: None, + on_react: None, + } + } + + /// Marks the thread the reader is on. + pub fn active(mut self, active: bool) -> Self { + self.active = active; + self + } + + /// Answers in `field`; `on_send` takes its text. + pub fn reply( + mut self, + field: &Entity, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.reply = Some((field.clone(), Rc::new(on_send))); + self + } + + /// Gets true to resolve, false to reopen. + pub fn on_resolve(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } + + /// Gets a comment's index and the emoji to toggle on it. + pub fn on_react( + mut self, + handler: impl Fn(usize, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_react = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommentThread { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let resolved = self.thread.resolved; + let resolve = self.on_resolve.clone().map(|resolve| { + Button::new( + (self.id.clone(), "resolve"), + if resolved { "Reopen" } else { "Resolve" }, + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .icon(if resolved { + IconName::RotateCcw + } else { + IconName::Check + }) + .on_click(move |_, window, cx| { + cx.stop_propagation(); + log::info!("comment thread: resolved {}", !resolved); + resolve(!resolved, window, cx) + }) + }); + let comments = self + .thread + .comments + .iter() + .enumerate() + .map(|(ix, comment)| { + let react = self.on_react.clone(); + let reactions = (!comment.reactions.is_empty() || react.is_some()).then(|| { + let reactions = Reactions::new( + (self.id.clone(), format!("reactions-{ix}")), + comment.reactions.clone(), + ); + match react { + Some(react) => reactions + .on_toggle(move |emoji, window, cx| react(ix, emoji, window, cx)), + None => reactions, + } + }); + div() + .flex() + .gap_2() + .child( + comment + .author + .avatar((self.id.clone(), format!("author-{ix}")), AvatarSize::Xs), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .flex() + .items_baseline() + .gap_2() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(comment.author.name.clone()), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(RelativeTime::new( + (self.id.clone(), format!("when-{ix}")), + comment.at, + )), + ), + ) + .child( + div() + .text_color(if resolved { colors.fg_muted } else { colors.fg }) + .child(comment.body.clone()), + ) + .children(reactions), + ) + }); + div() + .flex() + .flex_col() + .gap_3() + .p_3() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(if self.active { + colors.focus + } else { + colors.border + }) + .bg(colors.surface) + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .children(self.thread.quote.clone().map(|quote| { + div() + .pl_2() + .border_l_2() + .border_color(colors.border_strong) + .text_color(colors.fg_muted) + .italic() + .child(quote) + })), + ) + .children(resolve), + ) + .children(comments) + .when(!resolved, |thread| { + thread.children(self.reply.map(|(field, send)| { + Reply::new((self.id.clone(), "reply"), &field, move |window, cx| { + send(window, cx) + }) + })) + }) + } +} + +/// A comment's mark beside the words it is about, with how many comments it holds; a press opens the thread in a bubble over the page. +#[derive(IntoElement)] +pub struct CommentMarker { + id: ElementId, + thread: Thread, + reply: Option<(Entity, Run)>, + on_resolve: Option, +} + +impl CommentMarker { + pub fn new(id: impl Into, thread: Thread) -> Self { + Self { + id: id.into(), + thread, + reply: None, + on_resolve: None, + } + } + + pub fn reply( + mut self, + field: &Entity, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.reply = Some((field.clone(), Rc::new(on_send))); + self + } + + pub fn on_resolve(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommentMarker { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let prose = cx.theme().prose_width(); + let count = self.thread.comments.len().to_string(); + let (id, thread, reply, resolve) = + (self.id.clone(), self.thread, self.reply, self.on_resolve); + Popover::new(self.id, count, move |_, _| { + let mut bubble = CommentThread::new((id.clone(), "thread"), thread); + if let Some((field, send)) = reply { + bubble = bubble.reply(&field, move |window, cx| send(window, cx)); + } + if let Some(resolve) = resolve { + bubble = bubble.on_resolve(move |on, window, cx| resolve(on, window, cx)); + } + div().w(prose).child(bubble) + }) + .icon(IconName::MessageSquare) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + } +} + +/// Every conversation on a document down a column, the open ones or the resolved: a press picks one, and the one picked takes replies. +#[derive(IntoElement)] +pub struct CommentSidebar { + id: ElementId, + threads: Vec, + active: Option, + reply: Option<(Entity, Run)>, + on_select: Option, + on_resolve: Option, + on_react: Option, +} + +impl CommentSidebar { + pub fn new(id: impl Into, threads: impl IntoIterator) -> Self { + Self { + id: id.into(), + threads: threads.into_iter().collect(), + active: None, + reply: None, + on_select: None, + on_resolve: None, + on_react: None, + } + } + + /// The thread picked, by key, and what a press on another asks. + pub fn active( + mut self, + active: Option, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.active = active; + self.on_select = Some(Rc::new(on_select)); + self + } + + /// Answers the picked thread in `field`. + pub fn reply( + mut self, + field: &Entity, + on_send: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + self.reply = Some((field.clone(), Rc::new(on_send))); + self + } + + /// Gets a thread's key and true to resolve it, false to reopen. + pub fn on_resolve( + mut self, + handler: impl Fn(&SharedString, bool, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resolve = Some(Rc::new(handler)); + self + } + + /// Gets a thread's key, a comment's index and the emoji to toggle on it. + pub fn on_react( + mut self, + handler: impl Fn(&SharedString, usize, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_react = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for CommentSidebar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let showing = window.use_keyed_state((self.id.clone(), "resolved"), cx, |_, _| false); + let resolved = *showing.read(cx); + let open = self + .threads + .iter() + .filter(|thread| !thread.resolved) + .count(); + let done = self.threads.len() - open; + let filter = SegmentedControl::new( + (self.id.clone(), "filter"), + if resolved { "resolved" } else { "open" }, + ) + .size(ControlSize::Sm) + .segment("open", format!("Open {open}"), None) + .segment("resolved", format!("Resolved {done}"), None) + .on_change(move |value, _, cx| { + showing.update(cx, |showing, cx| { + *showing = value.as_ref() == "resolved"; + cx.notify(); + }) + }); + let shown: Vec = self + .threads + .into_iter() + .filter(|thread| thread.resolved == resolved) + .collect(); + let empty = shown.is_empty().then(|| { + EmptyState::new( + (self.id.clone(), "empty"), + IconName::MessageSquare, + if resolved { + "Nothing resolved yet" + } else { + "No open comments" + }, + ) + }); + let threads = shown.into_iter().map(|thread| { + let key = thread.key.clone(); + let active = self.active.as_ref() == Some(&key); + let mut item = CommentThread::new((self.id.clone(), format!("thread-{key}")), thread) + .active(active); + if active && let Some((field, send)) = &self.reply { + let send = send.clone(); + item = item.reply(field, move |window, cx| send(window, cx)); + } + if let Some(resolve) = self.on_resolve.clone() { + let key = key.clone(); + item = item.on_resolve(move |on, window, cx| resolve(&key, on, window, cx)); + } + if let Some(react) = self.on_react.clone() { + let key = key.clone(); + item = + item.on_react(move |ix, emoji, window, cx| react(&key, ix, emoji, window, cx)); + } + let select = self.on_select.clone(); + div() + .id((self.id.clone(), format!("pick-{key}"))) + .when_some(select.filter(|_| !active), |row, select| { + row.on_click(move |_, window, cx| { + log::info!("comment sidebar: {key}"); + select(&key, window, cx) + }) + }) + .child(item) + }); + div() + .id(self.id.clone()) + .size_full() + .flex() + .flex_col() + .gap_3() + .p_3() + .overflow_y_scroll() + .child(filter) + .children(empty) + .children(threads) + } +} diff --git a/src/collab/mod.rs b/src/collab/mod.rs new file mode 100644 index 0000000..ff988ee --- /dev/null +++ b/src/collab/mod.rs @@ -0,0 +1,17 @@ +mod annotate; +mod comments; +mod peers; +mod reactions; +mod remote; +mod share; +#[cfg(all(test, feature = "test-support"))] +mod tests; +mod track; + +pub use annotate::{AnnotatedText, Annotation}; +pub use comments::{Comment, CommentMarker, CommentSidebar, CommentThread, Reply, Thread}; +pub use peers::{FollowMode, LiveIndicator, Peer, PresenceAvatars}; +pub use reactions::{Reaction, ReactionPicker, Reactions, toggled}; +pub use remote::{RemoteCursor, RemoteSelection}; +pub use share::{AccessList, InviteInput, LinkAccess, Member, PermissionSelect, Role, ShareDialog}; +pub use track::{Decision, EditMode, Suggestion, SuggestionMode, TrackChanges, accept, suggested}; diff --git a/src/collab/peers.rs b/src/collab/peers.rs new file mode 100644 index 0000000..670c2e0 --- /dev/null +++ b/src/collab/peers.rs @@ -0,0 +1,327 @@ +use std::{path::Path, rc::Rc, time::Duration}; + +use gpui::{ + Animation, AnimationExt, AnyElement, App, ElementId, FontWeight, Hsla, InteractiveElement, + IntoElement, MouseButton, ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, + Styled, Window, div, prelude::*, pulsating_between, transparent_black, +}; +use smallvec::SmallVec; + +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::{Avatar, more, tucked}, + forms::{OnValue, Run}, + overlays::HoverCard, + primitives::{FocusRing, hand_back, hold_focus}, + theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize}, + typography::tabular, +}; + +/// One breath of the live dot. +const BREATH: Duration = Duration::from_millis(2000); + +/// How strongly a peer's color tints the bar over a followed view. +const TINT: f32 = 0.12; + +/// Someone in the document with you: a key, a name, a picture, where they are, and which of the theme's chart colors marks their cursor and selection. +#[derive(Clone, Debug, PartialEq)] +pub struct Peer { + pub key: SharedString, + pub name: SharedString, + pub picture: Option, + pub place: Option, + pub hue: usize, +} + +impl Peer { + pub fn new(key: impl Into, name: impl Into, hue: usize) -> Self { + Self { + key: key.into(), + name: name.into(), + picture: None, + place: None, + hue, + } + } + + /// Their color, from the theme's chart colors. + pub fn color(&self, cx: &App) -> Hsla { + let chart = &cx.theme().colors.chart; + chart[self.hue % chart.len()] + } + + pub(crate) fn avatar(&self, id: impl Into, size: AvatarSize) -> Avatar { + let avatar = Avatar::new(id, self.name.clone()).size(size); + match &self.picture { + Some(picture) => avatar.image(Path::new(picture.as_ref())), + None => avatar, + } + } +} + +/// Who else is here, their avatars overlapping, each ringed in their color: resting on one says where they are; a press follows them. Past `max`, the rest show as a count. +#[derive(IntoElement)] +pub struct PresenceAvatars { + id: ElementId, + peers: Vec, + max: usize, + following: Option, + on_follow: Option, +} + +impl PresenceAvatars { + pub fn new(id: impl Into, peers: impl IntoIterator) -> Self { + Self { + id: id.into(), + peers: peers.into_iter().collect(), + max: 4, + following: None, + on_follow: None, + } + } + + /// How many show before the rest become a count. + pub fn max(mut self, max: usize) -> Self { + assert!(max > 0, "presence shows at least one avatar"); + self.max = max; + self + } + + /// Whom the view follows now, by key; a press on someone asks to follow them. + pub fn follow( + mut self, + following: Option, + on_follow: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.following = following; + self.on_follow = Some(Rc::new(on_follow)); + self + } +} + +impl RenderOnce for PresenceAvatars { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let size = AvatarSize::Sm; + let rest = self.peers.len().saturating_sub(self.max); + let shown: Vec = self + .peers + .iter() + .take(self.max) + .enumerate() + .map(|(ix, peer)| { + let color = peer.color(cx); + let following = self.following.as_ref() == Some(&peer.key); + let (key, name, place) = (peer.key.clone(), peer.name.clone(), peer.place.clone()); + let follow = self.on_follow.clone(); + let face = tucked(ix == 0, size) + .id((self.id.clone(), format!("peer-{key}"))) + .rounded_full() + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(follow, |face, follow| { + let key = key.clone(); + face.on_click(move |_, window, cx| { + log::info!("presence: follow {key}"); + follow(&key, window, cx) + }) + }) + .child( + peer.avatar((self.id.clone(), format!("avatar-{key}")), size) + .ring_color(color), + ); + HoverCard::new( + (self.id.clone(), format!("card-{key}")), + face, + move |_, cx| { + let colors = &cx.theme().colors; + div() + .flex() + .flex_col() + .gap_0p5() + .child( + div() + .flex() + .items_center() + .gap_1p5() + .child(div().size_2().rounded_full().bg(color)) + .child( + div().font_weight(FontWeight::MEDIUM).child(name.clone()), + ), + ) + .children( + place + .clone() + .map(|place| div().text_color(colors.fg_muted).child(place)), + ) + .child(div().text_color(colors.fg_subtle).child(if following { + "Following · press to stop" + } else { + "Press to follow" + })) + }, + ) + .into_any_element() + }) + .collect(); + div() + .flex() + .items_center() + .children(shown) + .when(rest > 0, |row| { + row.child(tucked(false, size).child(more(rest, size, cx))) + }) + } +} + +/// Whether a document is live: a dot that breathes while others are here, and how many are. +#[derive(IntoElement)] +pub struct LiveIndicator { + id: ElementId, + others: usize, +} + +impl LiveIndicator { + /// `others` counts the people here besides you. + pub fn new(id: impl Into, others: usize) -> Self { + Self { + id: id.into(), + others, + } + } +} + +impl RenderOnce for LiveIndicator { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let live = self.others > 0; + let dot = div().size_2().rounded_full().bg(if live { + colors.success + } else { + colors.fg_subtle + }); + let dot: AnyElement = if live && !theme.reduced_motion { + dot.with_animation( + (self.id.clone(), "breath"), + Animation::new(BREATH) + .repeat() + .with_easing(pulsating_between(0.35, 1.0)), + |dot, t| dot.opacity(t), + ) + .into_any_element() + } else { + dot.into_any_element() + }; + div() + .id(self.id) + .flex() + .flex_none() + .items_center() + .gap_1p5() + .px_2() + .py_0p5() + .rounded_full() + .border_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(if live { colors.fg } else { colors.fg_muted }) + .child(dot) + .child(tabular(div()).child(if live { + format!("Live · {} here", self.others + 1) + } else { + "Only you".to_string() + })) + } +} + +/// Following someone's view: a frame in their color around what they see, a bar that names them, and a way to stop; Escape stops too. +#[derive(IntoElement)] +pub struct FollowMode { + id: ElementId, + peer: Option, + on_stop: Run, + body: SmallVec<[AnyElement; 2]>, +} + +impl FollowMode { + /// `peer` is whom the view follows, when anyone. + pub fn new( + id: impl Into, + peer: Option, + on_stop: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + peer, + on_stop: Rc::new(on_stop), + body: SmallVec::new(), + } + } +} + +impl ParentElement for FollowMode { + fn extend(&mut self, elements: impl IntoIterator) { + self.body.extend(elements); + } +} + +impl RenderOnce for FollowMode { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let following = self.peer.is_some(); + let held = hold_focus((self.id.clone(), "held"), following, window, cx); + let focus = held.read(cx).focus.clone(); + let theme = cx.theme(); + let (stop, escape) = (self.on_stop.clone(), self.on_stop); + let released = held.clone(); + let color = self.peer.as_ref().map(|peer| peer.color(cx)); + div() + .id(self.id.clone()) + .track_focus(&focus) + .flex() + .flex_col() + .rounded(theme.radius(Radius::Lg)) + .border_2() + .border_color(color.unwrap_or(transparent_black())) + .on_key_down(move |event, window, cx| { + if following && event.keystroke.key == "escape" { + cx.stop_propagation(); + log::info!("follow mode: stopped"); + hand_back(&held, window, cx); + escape(window, cx); + } + }) + .children(self.peer.zip(color).map(|(peer, color)| { + div() + .flex() + .items_center() + .gap_2() + .pl_3() + .pr_1() + .py_1() + .rounded_t(theme.radius(Radius::Md)) + .bg(color.opacity(TINT)) + .text_size(theme.text_size(TextSize::Sm)) + .child(div().size_2().rounded_full().bg(color)) + .child( + div() + .flex_1() + .min_w_0() + .child(format!("Following {}", peer.name)), + ) + .child( + Button::new((self.id.clone(), "stop"), "Stop following") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .shortcut("escape") + .on_click(move |_, window, cx| { + log::info!("follow mode: stopped"); + hand_back(&released, window, cx); + stop(window, cx) + }), + ) + })) + .children(self.body) + } +} diff --git a/src/collab/reactions.rs b/src/collab/reactions.rs new file mode 100644 index 0000000..e197720 --- /dev/null +++ b/src/collab/reactions.rs @@ -0,0 +1,216 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::ButtonVariant, + forms::{EmojiPicker, OnValue}, + overlays::Popover, + primitives::{FocusRing, IconName}, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::tabular, +}; + +/// Emoji most reactions use, offered before the full picker. +const QUICK: [&str; 6] = ["👍", "❤️", "🎉", "😄", "👀", "🙏"]; + +/// A reaction on a comment: its emoji, how many gave it, and whether you did. +#[derive(Clone, Debug, PartialEq)] +pub struct Reaction { + pub emoji: SharedString, + pub count: usize, + pub mine: bool, +} + +/// `reactions` after you toggle `emoji`: yours comes off, or goes on, a new one last. +pub fn toggled(reactions: &[Reaction], emoji: &str) -> Vec { + let mut next: Vec = reactions.to_vec(); + match next + .iter() + .position(|reaction| reaction.emoji.as_ref() == emoji) + { + Some(ix) if next[ix].mine => { + next[ix].count -= 1; + next[ix].mine = false; + if next[ix].count == 0 { + next.remove(ix); + } + } + Some(ix) => { + next[ix].count += 1; + next[ix].mine = true; + } + None => next.push(Reaction { + emoji: emoji.to_string().into(), + count: 1, + mine: true, + }), + } + next +} + +/// Picks an emoji to react with: a few most used, then every one. +#[derive(IntoElement)] +pub struct ReactionPicker { + id: ElementId, + on_pick: OnValue, +} + +impl ReactionPicker { + pub fn new( + id: impl Into, + on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + on_pick: Rc::new(on_pick), + } + } +} + +impl RenderOnce for ReactionPicker { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (id, on_pick) = (self.id.clone(), self.on_pick); + Popover::new(self.id, "", move |_, cx| { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (quick, all) = (on_pick.clone(), on_pick.clone()); + div() + .flex() + .flex_col() + .gap_2() + .child(div().flex().gap_1().children(QUICK.iter().map(|emoji| { + let (pick, emoji) = (quick.clone(), SharedString::from(*emoji)); + let label = emoji.clone(); + div() + .id((id.clone(), format!("quick-{emoji}"))) + .p_1() + .rounded(theme.radius(Radius::Sm)) + .cursor_pointer() + .text_size(theme.text_size(TextSize::Lg)) + .hover(|face| face.bg(colors.hover)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| pick(&emoji, window, cx)) + .child(label) + }))) + .child( + EmojiPicker::new((id.clone(), "all")).on_change(move |glyph, window, cx| { + all(&SharedString::from(glyph.to_string()), window, cx) + }), + ) + }) + .icon(IconName::SmilePlus) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + } +} + +/// Reactions under a comment: each emoji with its count, yours outlined; a press toggles yours, and the picker adds another. +#[derive(IntoElement)] +pub struct Reactions { + id: ElementId, + reactions: Vec, + on_toggle: Option, +} + +impl Reactions { + pub fn new(id: impl Into, reactions: impl IntoIterator) -> Self { + Self { + id: id.into(), + reactions: reactions.into_iter().collect(), + on_toggle: None, + } + } + + /// Gets the emoji to toggle, from a chip or the picker. + pub fn on_toggle( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_toggle = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Reactions { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_wrap() + .items_center() + .gap_1() + .children(self.reactions.iter().map(|reaction| { + let toggle = self.on_toggle.clone(); + let emoji = reaction.emoji.clone(); + div() + .id((self.id.clone(), format!("reaction-{emoji}"))) + .flex() + .items_center() + .gap_1() + .px_1p5() + .py_0p5() + .rounded_full() + .border_1() + .border_color(if reaction.mine { + colors.focus + } else { + colors.border + }) + .when(reaction.mine, |chip| chip.bg(colors.focus.opacity(0.1))) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .text_size(theme.text_size(TextSize::Xs)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .when_some(toggle, |chip, toggle| { + chip.on_click(move |_, window, cx| { + log::info!("reactions: toggle {emoji}"); + toggle(&emoji, window, cx) + }) + }) + .child(reaction.emoji.clone()) + .child( + tabular(div().text_color(colors.fg_muted)) + .child(format!("{}", reaction.count)), + ) + })) + .children(self.on_toggle.map(|toggle| { + ReactionPicker::new((self.id.clone(), "add"), move |emoji, window, cx| { + toggle(emoji, window, cx) + }) + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reaction(emoji: &str, count: usize, mine: bool) -> Reaction { + Reaction { + emoji: emoji.to_string().into(), + count, + mine, + } + } + + #[test] + fn a_toggle_adds_yours_or_takes_it_off() { + let start = [reaction("👍", 2, false), reaction("🎉", 1, true)]; + assert_eq!( + toggled(&start, "👍"), + [reaction("👍", 3, true), reaction("🎉", 1, true)] + ); + assert_eq!( + toggled(&start, "🎉"), + [reaction("👍", 2, false)], + "the last one goes" + ); + assert_eq!(toggled(&start, "👀").last(), Some(&reaction("👀", 1, true))); + } +} diff --git a/src/collab/remote.rs b/src/collab/remote.rs new file mode 100644 index 0000000..4a7807b --- /dev/null +++ b/src/collab/remote.rs @@ -0,0 +1,135 @@ +use std::ops::Range; + +use gpui::{ + App, ContentMask, Entity, FontWeight, IntoElement, RenderOnce, SharedString, Styled, TextRun, + Window, canvas, fill, point, size, +}; + +use super::peers::Peer; +use crate::{ + forms::TextInput, + theme::{ActiveTheme, Radius, TextSize}, + typography::LEADING, +}; + +/// How strongly a peer's color washes their selection. +const WASH: f32 = 0.2; + +/// Someone else's caret in a shared field: a bar in their color, their name on a flag above. Lay it over the field, in a box its size; it reads where `offset` sits as the field paints. +#[derive(IntoElement)] +pub struct RemoteCursor { + peer: Peer, + input: Entity, + offset: usize, +} + +impl RemoteCursor { + pub fn new(peer: &Peer, input: &Entity, offset: usize) -> Self { + Self { + peer: peer.clone(), + input: input.clone(), + offset, + } + } +} + +impl RenderOnce for RemoteCursor { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let rem = window.rem_size(); + let (color, ink) = (self.peer.color(cx), theme.colors.on_accent); + let bar = theme.caret_width().to_pixels(rem); + let text = theme.text_size(TextSize::Xs).to_pixels(rem); + let radius = theme.radius(Radius::Sm).to_pixels(rem); + let name: SharedString = self.peer.name.clone(); + let (input, offset) = (self.input, self.offset); + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let Some(caret) = input.read(cx).bounds_for(offset) else { + return; + }; + if caret.bottom() <= bounds.top() || caret.top() >= bounds.bottom() { + return; + } + window.with_content_mask(Some(ContentMask { bounds }), |window| { + window.paint_quad(fill( + gpui::Bounds::new(caret.origin, size(bar, caret.size.height)), + color, + )); + }); + let font = gpui::Font { + weight: FontWeight::MEDIUM, + ..window.text_style().font() + }; + let run = TextRun { + len: name.len(), + font, + color: ink, + background_color: None, + underline: None, + strikethrough: None, + }; + let line = window + .text_system() + .shape_line(name.clone(), text, &[run], None); + let (height, pad) = (text * LEADING, text / 2.0); + let flag = gpui::Bounds::new( + point(caret.left(), caret.top() - height), + size(line.width + pad * 2.0, height), + ); + window.paint_quad(fill(flag, color).corner_radii(gpui::Corners { + top_left: radius, + top_right: radius, + bottom_right: radius, + bottom_left: gpui::Pixels::ZERO, + })); + if let Err(error) = + line.paint(point(flag.left() + pad, flag.top()), height, window, cx) + { + log::error!("remote cursor: name did not paint: {error:#}"); + } + }, + ) + .absolute() + .inset_0() + } +} + +/// Someone else's selection in a shared field, washed in their color. Lay it over the field, in a box its size; it reads where `range` sits as the field paints. +#[derive(IntoElement)] +pub struct RemoteSelection { + peer: Peer, + input: Entity, + range: Range, +} + +impl RemoteSelection { + pub fn new(peer: &Peer, input: &Entity, range: Range) -> Self { + Self { + peer: peer.clone(), + input: input.clone(), + range, + } + } +} + +impl RenderOnce for RemoteSelection { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let wash = self.peer.color(cx).opacity(WASH); + let (input, range) = (self.input, self.range); + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + let lines = input.read(cx).bounds_for_range(range.clone()); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + for line in lines { + window.paint_quad(fill(line, wash)); + } + }); + }, + ) + .absolute() + .inset_0() + } +} diff --git a/src/collab/share.rs b/src/collab/share.rs new file mode 100644 index 0000000..f5f23f9 --- /dev/null +++ b/src/collab/share.rs @@ -0,0 +1,483 @@ +use std::{path::Path, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant, CopyButton, IconButton}, + data_display::Avatar, + forms::{Choice, OnValues, Pick, Run, Select, TagInput, is_email}, + overlays::Dialog, + primitives::{Icon, IconName}, + theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, TextSize}, + typography::Ellipsis, +}; + +/// What someone may do with a shared document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + View, + Comment, + Edit, +} + +const ROLES: [(Role, &str, &str); 3] = [ + (Role::View, "view", "Can view"), + (Role::Comment, "comment", "Can comment"), + (Role::Edit, "edit", "Can edit"), +]; + +type OnRole = Rc; + +/// Picks what someone may do: view, comment or edit. +#[derive(IntoElement)] +pub struct PermissionSelect { + id: ElementId, + role: Role, + on_change: OnRole, +} + +impl PermissionSelect { + pub fn new( + id: impl Into, + role: Role, + on_change: impl Fn(Role, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + role, + on_change: Rc::new(on_change), + } + } +} + +impl RenderOnce for PermissionSelect { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (_, value, _) = ROLES + .into_iter() + .find(|(role, ..)| *role == self.role) + .expect("every role is listed"); + let on_change = self.on_change; + Select::new( + self.id, + ROLES.map(|(_, value, label)| Choice::new(value, label)), + ) + .selected(value) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + let (role, ..) = ROLES + .into_iter() + .find(|(_, named, _)| *named == value.as_ref()) + .expect("a listed role"); + log::info!("permission: {role:?}"); + on_change(role, window, cx) + }) + } +} + +/// Someone with access: name, email, picture, and what they may do; the owner's access stays. +#[derive(Clone, Debug, PartialEq)] +pub struct Member { + pub name: SharedString, + pub email: SharedString, + pub picture: Option, + pub role: Role, + pub owner: bool, +} + +type OnMemberRole = Rc; + +/// Who has access, each with what they may do: change it or take it away; the owner stays. +#[derive(IntoElement)] +pub struct AccessList { + id: ElementId, + members: Vec, + on_role: Option, + on_remove: Option, +} + +impl AccessList { + pub fn new(id: impl Into, members: impl IntoIterator) -> Self { + Self { + id: id.into(), + members: members.into_iter().collect(), + on_role: None, + on_remove: None, + } + } + + /// Gets a member's index and their new role. + pub fn on_role( + mut self, + handler: impl Fn(usize, Role, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_role = Some(Rc::new(handler)); + self + } + + /// Gets the index of the member to take away. + pub fn on_remove(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for AccessList { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + div() + .flex() + .flex_col() + .gap_1() + .children(self.members.iter().enumerate().map(|(ix, member)| { + let id = |what: &str| (self.id.clone(), format!("{what}-{ix}")); + let avatar = Avatar::new(id("avatar"), member.name.clone()).size(AvatarSize::Sm); + let avatar = match &member.picture { + Some(picture) => avatar.image(Path::new(picture.as_ref())), + None => avatar, + }; + let access: AnyElement = if member.owner { + div() + .px_2() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_muted) + .child("Owner") + .into_any_element() + } else { + let (on_role, on_remove) = (self.on_role.clone(), self.on_remove.clone()); + div() + .flex() + .items_center() + .gap_1() + .children(on_role.map(|on_role| { + PermissionSelect::new( + id("role"), + member.role, + move |role, window, cx| on_role(ix, role, window, cx), + ) + })) + .children(on_remove.map(|on_remove| { + IconButton::new(id("remove"), IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Remove access") + .on_click(move |_, window, cx| { + log::info!("access list: remove {ix}"); + on_remove(ix, window, cx) + }) + })) + .into_any_element() + }; + div() + .flex() + .items_center() + .gap_3() + .py_1() + .child(avatar) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .child(Ellipsis::new(member.name.clone())), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(Ellipsis::new(member.email.clone())), + ), + ) + .child(access) + })) + } +} + +/// Whether every email reads as an address, and there is one. +pub(crate) fn ready(emails: &[SharedString]) -> bool { + !emails.is_empty() && emails.iter().all(|email| is_email(email)) +} + +/// People to invite by email, each a chip that reads as danger until it is an address, what they may do, and a button that sends once every one is. +#[derive(IntoElement)] +pub struct InviteInput { + id: ElementId, + emails: Vec, + on_change: OnValues, + on_invite: Run, + role: Option<(Role, OnRole)>, +} + +impl InviteInput { + /// `on_change` gets the emails as they are added and removed; `on_invite` sends them. + pub fn new( + id: impl Into, + emails: impl IntoIterator>, + on_change: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + on_invite: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + emails: emails.into_iter().map(Into::into).collect(), + on_change: Rc::new(on_change), + on_invite: Rc::new(on_invite), + role: None, + } + } + + /// What the people invited may do, and what a new choice asks. + pub fn role( + mut self, + role: Role, + on_role: impl Fn(Role, &mut Window, &mut App) + 'static, + ) -> Self { + self.role = Some((role, Rc::new(on_role))); + self + } +} + +impl RenderOnce for InviteInput { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (on_change, invite) = (self.on_change, self.on_invite); + let count = self.emails.len(); + let label = cx.theme().label_width(); + div() + .flex() + .flex_col() + .gap_2() + .child( + TagInput::new((self.id.clone(), "emails"), self.emails.clone()) + .placeholder("Add people by email") + .check(is_email) + .on_change(move |emails, window, cx| on_change(&emails, window, cx)), + ) + .child( + div() + .flex() + .items_center() + .justify_end() + .gap_2() + .children(self.role.map(|(role, on_role)| { + div().w(label).child(PermissionSelect::new( + (self.id.clone(), "role"), + role, + move |role, window, cx| on_role(role, window, cx), + )) + })) + .child( + Button::new((self.id.clone(), "invite"), "Invite") + .variant(ButtonVariant::Primary) + .icon(IconName::UserPlus) + .disabled(!ready(&self.emails)) + .on_click(move |_, window, cx| { + log::info!("invite: {count} people"); + invite(window, cx) + }), + ), + ) + } +} + +type OnAccess = Rc, &mut Window, &mut App)>; + +const RESTRICTED: &str = "restricted"; +const ANYONE: &str = "anyone"; + +/// Who a link lets in: only the people added, or anyone who has it, at a role; and the link to copy. +#[derive(IntoElement)] +pub struct LinkAccess { + id: ElementId, + link: SharedString, + anyone: Option, + on_change: OnAccess, +} + +impl LinkAccess { + /// `anyone` is the role the link gives, none when only people added get in. + pub fn new( + id: impl Into, + link: impl Into, + anyone: Option, + on_change: impl Fn(Option, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + link: link.into(), + anyone, + on_change: Rc::new(on_change), + } + } +} + +impl RenderOnce for LinkAccess { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let (open, role) = (self.on_change.clone(), self.on_change); + let anyone = self.anyone; + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .flex_none() + .size(theme.avatar_size(AvatarSize::Sm)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(colors.hover) + .child( + Icon::new(if anyone.is_some() { + IconName::Globe + } else { + IconName::Lock + }) + .size(IconSize::Sm) + .color(colors.fg_muted), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap_0p5() + .child( + Select::new( + (self.id.clone(), "who"), + [ + Choice::new(RESTRICTED, "Only people added"), + Choice::new(ANYONE, "Anyone with the link"), + ], + ) + .selected(if anyone.is_some() { ANYONE } else { RESTRICTED }) + .size(ControlSize::Sm) + .on_change(move |value, window, cx| { + let next = + (value.as_ref() == ANYONE).then_some(anyone.unwrap_or(Role::View)); + log::info!("link access: {next:?}"); + open(next, window, cx) + }), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_subtle) + .child(if anyone.is_some() { + "Anyone on the internet with the link" + } else { + "Only people with access can open it" + }), + ), + ) + .children(anyone.map(|now| { + PermissionSelect::new((self.id.clone(), "role"), now, move |next, window, cx| { + role(Some(next), window, cx) + }) + })) + .child(CopyButton::new( + (self.id.clone(), "copy"), + self.link.clone(), + )) + } +} + +/// Sharing a document in one place: invite people, see and change who has access, choose who the link lets in, and copy it. +#[derive(IntoElement)] +pub struct ShareDialog { + id: ElementId, + title: SharedString, + on_close: Run, + invite: Option, + access: Option, + link: Option, +} + +impl ShareDialog { + /// Render it while open; `on_close` runs on Done, Escape or a press outside. + pub fn new( + id: impl Into, + title: impl Into, + on_close: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + on_close: Rc::new(on_close), + invite: None, + access: None, + link: None, + } + } + + pub fn invite(mut self, invite: InviteInput) -> Self { + self.invite = Some(invite.into_any_element()); + self + } + + pub fn access(mut self, access: AccessList) -> Self { + self.access = Some(access.into_any_element()); + self + } + + pub fn link(mut self, link: LinkAccess) -> Self { + self.link = Some(link.into_any_element()); + self + } +} + +impl RenderOnce for ShareDialog { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let heading = |text: &'static str| { + div() + .text_size(theme.text_size(TextSize::Sm)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(text) + }; + let section = |title: &'static str, body: Option| { + body.map(|body| { + div() + .flex() + .flex_col() + .gap_2() + .child(heading(title)) + .child(body) + }) + }; + let (on_close, done) = (self.on_close.clone(), (self.id.clone(), "done")); + Dialog::new( + self.id.clone(), + format!("Share “{}”", self.title), + move |window, cx| on_close(window, cx), + ) + .child( + div() + .w_full() + .flex() + .flex_col() + .gap_5() + .children(self.invite) + .children(section("People with access", self.access)) + .children(section("General access", self.link)), + ) + .action(move |close| { + Button::new(done, "Done") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| close(window, cx)) + }) + } +} diff --git a/src/collab/tests.rs b/src/collab/tests.rs new file mode 100644 index 0000000..8915350 --- /dev/null +++ b/src/collab/tests.rs @@ -0,0 +1,100 @@ +use gpui::{ + AppContext as _, Context, Entity, Focusable, IntoElement, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, +}; + +use super::{FollowMode, Peer, Reply}; +use crate::{ + forms, + forms::{Input, TextInput}, + theme::Theme, +}; + +/// A field outside a followed view and one inside it, with what they asked: replies sent, follows stopped. +struct Desk { + outside: Entity, + field: Entity, + following: bool, + sent: usize, + stopped: usize, +} + +impl Render for Desk { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let (send, stop) = (cx.entity(), cx.entity()); + let peer = self.following.then(|| Peer::new("ada", "Ada", 0)); + div().size_full().child(Input::new(&self.outside)).child( + FollowMode::new("follow", peer, move |_, cx| { + stop.update(cx, |desk, _| desk.stopped += 1) + }) + .child(Reply::new("reply", &self.field, move |_, cx| { + send.update(cx, |desk, _| desk.sent += 1) + })), + ) + } +} + +fn open(following: bool, cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + }); + let (desk, cx) = cx.add_window_view(move |window, cx| Desk { + outside: cx.new(|cx| TextInput::new(window, cx)), + field: cx.new(|cx| TextInput::new(window, cx)), + following, + sent: 0, + stopped: 0, + }); + cx.update(|window, cx| { + window.activate_window(); + let field = desk.read(cx).field.clone(); + window.focus(&field.focus_handle(cx)); + }); + cx.run_until_parked(); + (desk, cx) +} + +#[gpui::test] +fn enter_sends_a_reply_only_with_words(cx: &mut TestAppContext) { + let (desk, cx) = open(false, cx); + cx.simulate_keystrokes("space enter"); + assert_eq!(desk.read_with(cx, |desk, _| desk.sent), 0, "blank stays"); + cx.simulate_input("agreed"); + cx.simulate_keystrokes("enter"); + assert_eq!(desk.read_with(cx, |desk, _| desk.sent), 1); +} + +#[gpui::test] +fn escape_stops_following_only_while_following(cx: &mut TestAppContext) { + let (desk, cx) = open(true, cx); + cx.simulate_keystrokes("escape"); + assert_eq!(desk.read_with(cx, |desk, _| desk.stopped), 1); + desk.update(cx, |desk, cx| { + desk.following = false; + cx.notify(); + }); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + assert_eq!(desk.read_with(cx, |desk, _| desk.stopped), 1); +} + +#[gpui::test] +fn following_begun_from_outside_still_stops_on_escape(cx: &mut TestAppContext) { + let (desk, cx) = open(false, cx); + cx.update(|window, cx| { + let outside = desk.read(cx).outside.focus_handle(cx); + window.focus(&outside); + }); + desk.update(cx, |desk, cx| { + desk.following = true; + cx.notify(); + }); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + cx.simulate_keystrokes("escape"); + assert_eq!(desk.read_with(cx, |desk, _| desk.stopped), 1); + let back = cx.update(|window, cx| desk.read(cx).outside.focus_handle(cx).is_focused(window)); + assert!(back, "focus returns to where it was"); +} diff --git a/src/collab/track.rs b/src/collab/track.rs new file mode 100644 index 0000000..00a7b8d --- /dev/null +++ b/src/collab/track.rs @@ -0,0 +1,456 @@ +use std::{ops::Range, rc::Rc}; + +use gpui::{ + App, ElementId, FontWeight, HighlightStyle, IntoElement, ParentElement, RenderOnce, + SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*, +}; + +use similar::{ChangeTag, TextDiff}; + +use super::peers::Peer; +use crate::{ + buttons::{Button, ButtonVariant, IconButton, SegmentedControl}, + documents::Change, + primitives::IconName, + theme::{ActiveTheme, AvatarSize, ControlSize, Radius, TextSize}, + typography::format::plural, +}; + +/// How strongly an author's color washes the words they would add. +const WASH: f32 = 0.14; + +/// How someone works on a shared document: changing it, suggesting changes for others to take, or only reading. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditMode { + Editing, + Suggesting, + Viewing, +} + +const MODES: [(EditMode, &str, &str, IconName); 3] = [ + (EditMode::Editing, "editing", "Editing", IconName::Pencil), + ( + EditMode::Suggesting, + "suggesting", + "Suggesting", + IconName::MessageSquareDiff, + ), + (EditMode::Viewing, "viewing", "Viewing", IconName::Eye), +]; + +type OnMode = Rc; + +/// Picks how you work on a shared document: edit it, suggest changes for others to take, or only read. +#[derive(IntoElement)] +pub struct SuggestionMode { + id: ElementId, + mode: EditMode, + on_change: OnMode, +} + +impl SuggestionMode { + pub fn new( + id: impl Into, + mode: EditMode, + on_change: impl Fn(EditMode, &mut Window, &mut App) + 'static, + ) -> Self { + Self { + id: id.into(), + mode, + on_change: Rc::new(on_change), + } + } +} + +impl RenderOnce for SuggestionMode { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let on_change = self.on_change; + let (_, value, _, _) = MODES + .into_iter() + .find(|(mode, ..)| *mode == self.mode) + .expect("every mode is listed"); + MODES + .into_iter() + .fold( + SegmentedControl::new(self.id, value).size(ControlSize::Sm), + |control, (_, value, label, icon)| control.segment(value, label, Some(icon)), + ) + .on_change(move |value, window, cx| { + let (mode, ..) = MODES + .into_iter() + .find(|(_, named, ..)| *named == value.as_ref()) + .expect("a listed mode"); + log::info!("suggestion mode: {mode:?}"); + on_change(mode, window, cx) + }) + } +} + +/// A change someone suggests: who, the stretch of the text it replaces, empty to insert, and what goes there, empty to delete. +#[derive(Clone, Debug, PartialEq)] +pub struct Suggestion { + pub author: Peer, + pub range: Range, + pub text: SharedString, +} + +/// What a reviewer decides: take or drop one suggestion, or all of them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Decision { + Accept(usize), + Reject(usize), + AcceptAll, + RejectAll, +} + +/// The text with every suggestion shown in place: what each removes kept and marked, what it adds after it, by suggestion. +pub(crate) fn tracked( + base: &str, + suggestions: &[Suggestion], +) -> (String, Vec<(Range, usize, Change)>) { + let mut text = String::new(); + let mut marks = Vec::new(); + let mut at = 0; + for (ix, suggestion) in suggestions.iter().enumerate() { + text.push_str(&base[at..suggestion.range.start]); + for (part, change) in [ + (&base[suggestion.range.clone()], Change::Removed), + (suggestion.text.as_ref(), Change::Added), + ] { + if !part.is_empty() { + let start = text.len(); + text.push_str(part); + marks.push((start..text.len(), ix, change)); + } + } + at = suggestion.range.end; + } + text.push_str(&base[at..]); + (text, marks) +} + +/// The changes that turn `base` into `edited`, word by word, as `author`'s suggestions. +pub fn suggested(base: &str, edited: &str, author: &Peer) -> Vec { + let diff = TextDiff::configure().diff_unicode_words(base, edited); + let mut found = Vec::new(); + let mut open: Option = None; + let mut at = 0; + for change in diff.iter_all_changes() { + let value = change.value(); + if change.tag() == ChangeTag::Equal { + found.extend(open.take()); + at += value.len(); + continue; + } + let suggestion = open.get_or_insert_with(|| Suggestion { + author: author.clone(), + range: at..at, + text: SharedString::default(), + }); + if change.tag() == ChangeTag::Delete { + at += value.len(); + suggestion.range.end = at; + } else { + suggestion.text = format!("{}{value}", suggestion.text).into(); + } + } + found.extend(open); + found +} + +/// `base` with suggestion `ix` taken, and the others moved to match. +pub fn accept(base: &str, suggestions: &[Suggestion], ix: usize) -> (String, Vec) { + let taken = &suggestions[ix]; + let text = format!( + "{}{}{}", + &base[..taken.range.start], + taken.text, + &base[taken.range.end..] + ); + let shift = taken.text.len() as isize - taken.range.len() as isize; + let rest = suggestions + .iter() + .enumerate() + .filter(|(at, _)| *at != ix) + .map(|(at, suggestion)| { + let mut suggestion = suggestion.clone(); + if at > ix { + suggestion.range = suggestion.range.start.saturating_add_signed(shift) + ..suggestion.range.end.saturating_add_signed(shift); + } + suggestion + }) + .collect(); + (text, rest) +} + +/// What a suggestion does, in a line: replace, add or delete, with the words. +fn described(base: &str, suggestion: &Suggestion) -> String { + let (removed, added) = ( + base[suggestion.range.clone()].trim(), + suggestion.text.trim(), + ); + match (removed.is_empty(), added.is_empty()) { + (true, true) => "Change the spacing".to_string(), + (true, false) => format!("Add “{added}”"), + (false, true) => format!("Delete “{removed}”"), + (false, false) => format!("Replace “{removed}” with “{added}”"), + } +} + +type OnDecide = Rc; + +/// Suggested changes in place: removed words struck and added ones underlined, each in its author's color, then each change with who made it, to accept or reject, one by one or all at once. +#[derive(IntoElement)] +pub struct TrackChanges { + id: ElementId, + base: SharedString, + suggestions: Vec, + on_decide: Option, +} + +impl TrackChanges { + /// `suggestions` in order, none overlapping, their ranges in `base`. + pub fn new( + id: impl Into, + base: impl Into, + suggestions: impl IntoIterator, + ) -> Self { + let base = base.into(); + let suggestions: Vec = suggestions.into_iter().collect(); + for suggestion in &suggestions { + let range = &suggestion.range; + assert!( + range.end <= base.len() + && base.is_char_boundary(range.start) + && base.is_char_boundary(range.end), + "suggestion {range:?} lies outside the text" + ); + assert!( + !(range.is_empty() && suggestion.text.is_empty()), + "suggestion at {range:?} changes nothing" + ); + } + assert!( + suggestions + .windows(2) + .all(|pair| pair[0].range.end <= pair[1].range.start), + "suggestions come in order without overlapping" + ); + Self { + id: id.into(), + base, + suggestions, + on_decide: None, + } + } + + pub fn on_decide( + mut self, + handler: impl Fn(Decision, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_decide = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TrackChanges { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let thickness = theme.underline_thickness(); + let (text, marks) = tracked(&self.base, &self.suggestions); + let styles: Vec<(Range, HighlightStyle)> = marks + .into_iter() + .map(|(range, ix, change)| { + let color = self.suggestions[ix].author.color(cx); + let style = match change { + Change::Removed => HighlightStyle { + color: Some(color), + strikethrough: Some(StrikethroughStyle { + thickness, + color: Some(color), + }), + ..HighlightStyle::default() + }, + Change::Added => HighlightStyle { + background_color: Some(color.opacity(WASH)), + underline: Some(UnderlineStyle { + thickness, + color: Some(color), + wavy: false, + }), + ..HighlightStyle::default() + }, + }; + (range, style) + }) + .collect(); + let decide = |decision: Decision| { + let on_decide = self.on_decide.clone(); + move |_: &gpui::ClickEvent, window: &mut Window, cx: &mut App| { + log::info!("track changes: {decision:?}"); + if let Some(on_decide) = &on_decide { + on_decide(decision, window, cx); + } + } + }; + let pending = !self.suggestions.is_empty() && self.on_decide.is_some(); + let rows = self.suggestions.iter().enumerate().map(|(ix, suggestion)| { + let id = |what: &str| (self.id.clone(), format!("{what}-{ix}")); + div() + .flex() + .items_center() + .gap_2() + .py_1p5() + .child(suggestion.author.avatar(id("author"), AvatarSize::Xs)) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(suggestion.author.name.clone()), + ) + .child( + div() + .text_color(colors.fg_muted) + .child(described(&self.base, suggestion)), + ), + ) + .when(self.on_decide.is_some(), |row| { + row.child( + IconButton::new(id("accept"), IconName::Check) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Accept") + .on_click(decide(Decision::Accept(ix))), + ) + .child( + IconButton::new(id("reject"), IconName::X) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .tooltip("Reject") + .on_click(decide(Decision::Reject(ix))), + ) + }) + }); + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .items_center() + .gap_2() + .child(div().flex_1().text_color(colors.fg_muted).child(plural( + self.suggestions.len() as u64, + "suggestion", + "suggestions", + ))) + .when(pending, |bar| { + bar.child( + Button::new((self.id.clone(), "reject-all"), "Reject all") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(decide(Decision::RejectAll)), + ) + .child( + Button::new((self.id.clone(), "accept-all"), "Accept all") + .size(ControlSize::Sm) + .on_click(decide(Decision::AcceptAll)), + ) + }), + ) + .child( + div() + .p_3() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Base)) + .child(StyledText::new(text).with_highlights(styles)), + ) + .child(div().flex().flex_col().children(rows)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn suggestion(range: Range, text: &str) -> Suggestion { + Suggestion { + author: Peer::new("ada", "Ada", 0), + range, + text: text.to_string().into(), + } + } + + #[test] + fn tracked_text_keeps_both_sides_of_each_change() { + let base = "A lift blends color toward white."; + let (text, marks) = tracked( + base, + &[ + suggestion(2..6, "tint"), + suggestion(14..14, "every "), + suggestion(19..26, ""), + ], + ); + assert_eq!(text, "A lifttint blends every color toward white."); + let shown: Vec<(&str, usize, Change)> = marks + .iter() + .map(|(range, ix, change)| (&text[range.clone()], *ix, *change)) + .collect(); + assert_eq!( + shown, + [ + ("lift", 0, Change::Removed), + ("tint", 0, Change::Added), + ("every ", 1, Change::Added), + (" toward", 2, Change::Removed), + ] + ); + } + + #[test] + fn edits_become_suggestions_word_by_word() { + let found = suggested( + "A lift blends color toward white.", + "A tint blends every color toward white.", + &Peer::new("ada", "Ada", 0), + ); + assert_eq!( + found, + [suggestion(2..6, "tint"), suggestion(14..14, "every ")] + ); + let (text, rest) = accept("A lift blends color toward white.", &found, 0); + assert_eq!( + accept(&text, &rest, 0).0, + "A tint blends every color toward white." + ); + } + + #[test] + fn accepting_one_moves_the_rest() { + let base = "A lift blends color."; + let all = [suggestion(2..6, "tint"), suggestion(14..14, "every ")]; + let (text, rest) = accept(base, &all, 0); + assert_eq!(text, "A tint blends color."); + assert_eq!( + rest, + [suggestion(14..14, "every ")], + "same length, same place" + ); + let (text, rest) = accept(base, &[suggestion(2..6, "shade of"), all[1].clone()], 0); + assert_eq!(text, "A shade of blends color."); + let (text, _) = accept(&text, &rest, 0); + assert_eq!(text, "A shade of blends every color."); + } +} diff --git a/src/data_display/avatar.rs b/src/data_display/avatar.rs index 4c71cfe..bee0ddc 100644 --- a/src/data_display/avatar.rs +++ b/src/data_display/avatar.rs @@ -1,5 +1,5 @@ use gpui::{ - AnyElement, App, ElementId, FontWeight, Hsla, ImageSource, IntoElement, ObjectFit, + AnyElement, App, Div, ElementId, FontWeight, Hsla, ImageSource, IntoElement, ObjectFit, ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, }; @@ -57,6 +57,7 @@ pub struct Avatar { size: AvatarSize, square: bool, ring: bool, + ring_color: Option, presence: Option, } @@ -71,6 +72,7 @@ impl Avatar { size: AvatarSize::default(), square: false, ring: false, + ring_color: None, presence: None, } } @@ -109,6 +111,13 @@ impl Avatar { self } + /// A ring in `color`, as someone's own color marks them. + pub fn ring_color(mut self, color: Hsla) -> Self { + self.ring = true; + self.ring_color = Some(color); + self + } + pub fn presence(mut self, presence: Presence) -> Self { self.presence = Some(presence); self @@ -177,13 +186,44 @@ impl RenderOnce for Avatar { .text_color(tone) .text_size(theme.text_size(text)) .font_weight(FontWeight::SEMIBOLD) - .when(self.ring, |face| face.border_2().border_color(colors.bg)) + .when(self.ring, |face| { + face.border_2() + .border_color(self.ring_color.unwrap_or(colors.bg)) + }) .child(face), ) .children(dot) } } +/// An avatar's slot in a row, tucked under the one before unless `first`. +pub(crate) fn tucked(first: bool, size: AvatarSize) -> Div { + match (first, size) { + (true, _) => div(), + (false, AvatarSize::Xs | AvatarSize::Sm) => div().ml_neg_1(), + (false, AvatarSize::Md) => div().ml_neg_2(), + (false, AvatarSize::Lg | AvatarSize::Xl) => div().ml_neg_3(), + } +} + +/// The count that stands for `rest` people past the ones shown. +pub(crate) fn more(rest: usize, size: AvatarSize, cx: &App) -> Div { + let theme = cx.theme(); + div() + .flex() + .items_center() + .justify_center() + .size(theme.avatar_size(size)) + .rounded_full() + .border_2() + .border_color(theme.colors.bg) + .bg(theme.colors.hover) + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.colors.fg_muted) + .child(format!("+{rest}")) +} + /// People side by side, each tucked under the next, and a count of the rest. #[derive(IntoElement)] pub struct AvatarGroup { @@ -216,39 +256,16 @@ impl AvatarGroup { impl RenderOnce for AvatarGroup { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); let rest = self.people.len().saturating_sub(self.max); let size = self.size; - let tuck = |element: gpui::Div, first: bool| match (first, size) { - (true, _) => element, - (false, AvatarSize::Xs | AvatarSize::Sm) => element.ml_neg_1(), - (false, AvatarSize::Md) => element.ml_neg_2(), - (false, AvatarSize::Lg | AvatarSize::Xl) => element.ml_neg_3(), - }; let shown = self .people .into_iter() .take(self.max) .enumerate() - .map(|(ix, person)| tuck(div(), ix == 0).child(person.size(size).ring())); - let more = (rest > 0).then(|| { - tuck(div(), false).child( - div() - .flex() - .items_center() - .justify_center() - .size(theme.avatar_size(size)) - .rounded_full() - .border_2() - .border_color(theme.colors.bg) - .bg(theme.colors.hover) - .text_size(theme.text_size(TextSize::Xs)) - .font_weight(FontWeight::MEDIUM) - .text_color(theme.colors.fg_muted) - .child(format!("+{rest}")), - ) - }); - div().flex().items_center().children(shown).children(more) + .map(|(ix, person)| tucked(ix == 0, size).child(person.size(size).ring())); + let extra = (rest > 0).then(|| tucked(false, size).child(more(rest, size, cx))); + div().flex().items_center().children(shown).children(extra) } } diff --git a/src/data_display/mod.rs b/src/data_display/mod.rs index edb1543..9a875b3 100644 --- a/src/data_display/mod.rs +++ b/src/data_display/mod.rs @@ -18,6 +18,7 @@ mod timeline; mod watermark; pub use avatar::{Avatar, AvatarGroup, Presence, UserChip}; +pub(crate) use avatar::{more, tucked}; pub use badge::{Badge, CountBadge, DotBadge, Tag, Tone}; pub use before_after::BeforeAfter; pub use carousel::Carousel; diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 58a7864..78c7019 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -29,6 +29,7 @@ pub use blocks::{Align, BlockData, BlockEditor, BlockEvent, BlockKind, Media}; pub use counts::{ReadingTime, WordCount}; pub use epub::{Chapter, EpubReader}; pub use format::{Format, format}; +pub(crate) use history::Change; pub use history::{PageHistoryDiff, Version, VersionHistory}; pub use knowledge::{Backlink, Backlinks, Favorite, Favorites, TrashBin, Trashed}; pub use link::LinkEditor; diff --git a/src/documents/reading.rs b/src/documents/reading.rs index f8dcef4..3598944 100644 --- a/src/documents/reading.rs +++ b/src/documents/reading.rs @@ -1,9 +1,8 @@ use std::{collections::BTreeMap, rc::Rc}; use gpui::{ - AnyElement, App, Bounds, ElementId, FocusHandle, FontWeight, InteractiveElement, IntoElement, - ParentElement, RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, fill, - prelude::*, size, + AnyElement, App, Bounds, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, ScrollHandle, SharedString, Styled, Window, canvas, div, fill, prelude::*, size, }; use smallvec::SmallVec; @@ -11,6 +10,7 @@ use crate::{ forms::Run, layout::Collapsible, overlays::HoverCard, + primitives::{hand_back, hold_focus}, theme::{ActiveTheme, ContainerSize, TextSize}, }; @@ -272,31 +272,11 @@ impl ParentElement for ZenMode { } } -/// Zen's own focus, where focus was before zen took it, and whether zen was on last frame. -struct Quiet { - focus: FocusHandle, - previous: Option, - was_on: bool, -} - impl RenderOnce for ZenMode { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (on, exit) = (self.on, self.on_exit); - let quiet = window.use_keyed_state((self.id.clone(), "quiet"), cx, |_, cx| Quiet { - focus: cx.focus_handle(), - previous: None, - was_on: false, - }); - let (focus, was_on) = (quiet.read(cx).focus.clone(), quiet.read(cx).was_on); - if on && !was_on && !focus.contains_focused(window, cx) { - log::info!("zen mode: takes focus, so Escape reaches it"); - let previous = window.focused(cx); - window.focus(&focus); - quiet.update(cx, |quiet, _| quiet.previous = previous); - } - if on != was_on { - quiet.update(cx, |quiet, _| quiet.was_on = on); - } + let held = hold_focus((self.id.clone(), "held"), on, window, cx); + let focus = held.read(cx).focus.clone(); let theme = cx.theme(); div() .id(self.id.clone()) @@ -310,9 +290,7 @@ impl RenderOnce for ZenMode { { cx.stop_propagation(); log::info!("zen mode: leaves"); - if let Some(previous) = quiet.update(cx, |quiet, _| quiet.previous.take()) { - window.focus(&previous); - } + hand_back(&held, window, cx); exit(window, cx); } }) diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 689146c..5ba966f 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -79,7 +79,9 @@ pub(crate) use mention::{Suggestions, active_trigger, handles_matching}; pub use multi::MultiSelect; pub use number::{NumberInput, ScrubInput}; pub use options::Choice; -pub(crate) use options::{OnFlag, OnValue, Pick, Run, float, float_height, step, surface}; +pub(crate) use options::{ + OnFlag, OnValue, OnValues, Pick, Run, float, float_height, step, surface, +}; pub use pairs::{FieldArray, KeyValueInput, ListInput}; pub use path::PathInput; pub use pattern::{RegexInput, regex_highlights}; diff --git a/src/forms/tags.rs b/src/forms/tags.rs index 1361e22..5bf98e1 100644 --- a/src/forms/tags.rs +++ b/src/forms/tags.rs @@ -7,7 +7,7 @@ use gpui::{ use super::{InputEvent, TextInput, text::Backspace}; use crate::{ - data_display::Tag, + data_display::{Tag, Tone}, theme::{ActiveTheme, ControlSize, Radius, TextSize}, }; @@ -50,6 +50,7 @@ pub struct TagInput { id: ElementId, tags: Vec, placeholder: SharedString, + check: Option bool>, on_change: Option, } @@ -62,6 +63,7 @@ impl TagInput { id: id.into(), tags: tags.into_iter().map(Into::into).collect(), placeholder: "Add a tag".into(), + check: None, on_change: None, } } @@ -71,6 +73,12 @@ impl TagInput { self } + /// Marks each tag that fails `check` as danger, as text that is not an address. + pub fn check(mut self, check: fn(&str) -> bool) -> Self { + self.check = Some(check); + self + } + pub fn on_change( mut self, handler: impl Fn(Vec, &mut Window, &mut App) + 'static, @@ -112,16 +120,17 @@ impl RenderOnce for TagInput { let owner = self.id.clone(); let chips = self.tags.iter().enumerate().map(|(ix, tag)| { let (tags, on_change) = (self.tags.clone(), self.on_change.clone()); - Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()).on_remove( - move |window, cx| { + let failed = self.check.is_some_and(|check| !check(tag)); + Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()) + .when(failed, |tag| tag.tone(Tone::Danger)) + .on_remove(move |window, cx| { let mut next = tags.clone(); next.remove(ix); log::info!("tag input: removed one, {} left", next.len()); if let Some(on_change) = &on_change { on_change(next, window, cx); } - }, - ) + }) }); let (tags, on_change, empty_check) = (self.tags.clone(), self.on_change.clone(), input.clone()); diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index ea497ba..2cea789 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -7,7 +7,7 @@ mod upload; use gpui::{ AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke, - ParentElement, Render, TestAppContext, VisualTestContext, Window, div, + ParentElement, Render, TestAppContext, VisualTestContext, Window, div, px, }; use super::{Input, MaskedInput, NumberInput, PinInput, TextInput, bind_keys}; @@ -279,3 +279,23 @@ fn a_composition_inside_a_masked_field_fits_from_where_it_began(cx: &mut TestApp }); assert_eq!(text(&state, cx), "(192) 345"); } + +#[gpui::test] +fn a_range_gives_a_box_per_line_it_spans(cx: &mut TestAppContext) { + let (fields, cx) = open_with(1, false, |input| input.multi_line(2, 4), cx); + fields[0].update(cx, |input, cx| input.set_text("lift\ntone", cx)); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let (one, two) = fields[0].read_with(cx, |input, _| { + (input.bounds_for_range(1..3), input.bounds_for_range(2..7)) + }); + assert_eq!(one.len(), 1); + assert!(one[0].size.width > px(0.0)); + assert_eq!(two.len(), 2, "{two:?}"); + assert!(two[1].top() > two[0].top()); + assert!( + two[1].left() < two[0].left(), + "the second line starts at the edge" + ); +} diff --git a/src/forms/text/element.rs b/src/forms/text/element.rs index 79c77c8..eb3649a 100644 --- a/src/forms/text/element.rs +++ b/src/forms/text/element.rs @@ -358,7 +358,7 @@ fn keep_in_view( } /// Boxes covering display offsets `start..end`, relative to the text origin. -fn selection_rects( +pub(super) fn selection_rects( lines: &[(usize, WrappedLine)], start: usize, end: usize, diff --git a/src/forms/text/geometry.rs b/src/forms/text/geometry.rs index eb302a4..da3e24e 100644 --- a/src/forms/text/geometry.rs +++ b/src/forms/text/geometry.rs @@ -1,6 +1,8 @@ +use std::ops::Range; + use gpui::{Bounds, Pixels, Point}; -use super::TextInput; +use super::{TextInput, element::selection_rects}; impl TextInput { /// The caret's box in window coordinates, once laid out. @@ -22,6 +24,34 @@ impl TextInput { )) } + /// A range's boxes in the window, a line each, from the last layout; later offsets clamp to its end. + pub fn bounds_for_range(&self, range: Range) -> Vec> { + let Some(layout) = self.layout.as_ref() else { + return Vec::new(); + }; + if layout.placeholder { + return Vec::new(); + } + let laid = layout + .lines + .last() + .map_or(0, |(start, line)| start + line.len()); + let (start, end) = ( + self.display_offset(range.start).min(laid), + self.display_offset(range.end).min(laid), + ); + selection_rects( + &layout.lines, + start, + end, + layout.line_height, + layout.bounds.size.width, + ) + .into_iter() + .map(|rect| Bounds::new(rect.origin + layout.bounds.origin - self.scroll, rect.size)) + .collect() + } + /// Top-left of a display offset, relative to the text's origin. pub(crate) fn position_for(&self, display: usize) -> Option> { let layout = self.layout.as_ref()?; diff --git a/src/lib.rs b/src/lib.rs index 71b6a5d..6dc9afd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod assets; pub mod buttons; pub mod charts; +pub mod collab; pub mod data_display; pub mod debug; pub mod documents; diff --git a/src/overlays/popover.rs b/src/overlays/popover.rs index 7f7c19d..c90f4db 100644 --- a/src/overlays/popover.rs +++ b/src/overlays/popover.rs @@ -7,6 +7,7 @@ use crate::{ buttons::{Button, ButtonVariant}, forms::{float_height, surface}, primitives::{FocusScope, IconName, Takeover, give_back, take_focus}, + theme::ControlSize, }; type Content = Box AnyElement>; @@ -39,6 +40,7 @@ pub struct Popover { label: SharedString, icon: Option, variant: ButtonVariant, + size: ControlSize, content: Content, } @@ -54,6 +56,7 @@ impl Popover { label: label.into(), icon: None, variant: ButtonVariant::Secondary, + size: ControlSize::default(), content: Box::new(move |window, cx| content(window, cx).into_any_element()), } } @@ -68,6 +71,11 @@ impl Popover { self.variant = variant; self } + + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } } impl RenderOnce for Popover { @@ -77,6 +85,7 @@ impl RenderOnce for Popover { let toggle = state.clone(); let trigger = Button::new((self.id.clone(), "trigger"), self.label) .variant(self.variant) + .size(self.size) .when_some(self.icon, |button, icon| button.icon(icon)) .on_click(move |_, window, cx| { if open { diff --git a/src/primitives/focus.rs b/src/primitives/focus.rs index e35ab64..e934266 100644 --- a/src/primitives/focus.rs +++ b/src/primitives/focus.rs @@ -170,6 +170,46 @@ pub(crate) fn give_back(state: &Entity, window: &mut Window, cx: &mut } } +/// A mode's own focus, where focus was before the mode took it, and whether the mode was on last frame. +pub(crate) struct Held { + pub focus: FocusHandle, + previous: Option, + was_on: bool, +} + +/// The focus a mode keyed `key` holds: turned on while focus is elsewhere, it takes focus so Escape reaches it. +pub(crate) fn hold_focus( + key: impl Into, + on: bool, + window: &mut Window, + cx: &mut App, +) -> Entity { + let held = window.use_keyed_state(key, cx, |_, cx| Held { + focus: cx.focus_handle(), + previous: None, + was_on: false, + }); + let (focus, was_on) = (held.read(cx).focus.clone(), held.read(cx).was_on); + if on && !was_on && !focus.contains_focused(window, cx) { + log::info!("focus: a mode takes it, so Escape reaches it"); + let previous = window.focused(cx); + window.focus(&focus); + held.update(cx, |held, _| held.previous = previous); + } + if on != was_on { + held.update(cx, |held, _| held.was_on = on); + } + held +} + +/// Hands focus back to where it was before the mode took it. +pub(crate) fn hand_back(held: &Entity, window: &mut Window, cx: &mut App) { + if let Some(previous) = held.update(cx, |held, _| held.previous.take()) { + log::info!("focus: handed back after a mode"); + window.focus(&previous); + } +} + /// A focus handle kept for `id`, a Tab stop while enabled. pub(crate) fn tab_stop( id: ElementId, diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 146bf41..f4ac49b 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -187,6 +187,7 @@ icons! { Menu => "menu", Merge => "merge", MessageSquare => "message-square", + MessageSquareDiff => "message-square-diff", MicOff => "mic-off", Mic => "mic", Minimize2 => "minimize-2", @@ -242,6 +243,7 @@ icons! { SlidersHorizontal => "sliders-horizontal", Smartphone => "smartphone", Smile => "smile", + SmilePlus => "smile-plus", Sparkles => "sparkles", Spline => "spline", SquareCheck => "square-check", @@ -267,6 +269,7 @@ icons! { Undo2 => "undo-2", Upload => "upload", User => "user", + UserPlus => "user-plus", Users => "users", Variable => "variable", VideoOff => "video-off", diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index d345dff..1e09c95 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -14,7 +14,7 @@ pub use backdrop::{Backdrop, Place}; pub use disclosure::Disclosure; pub use divider::Divider; pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope}; -pub(crate) use focus::{Takeover, give_back, tab_stop, take_focus}; +pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus}; pub use ghost::DragGhost; pub use icon::{Icon, IconName}; pub use image::Image; diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index e650ab6..a0de7e9 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -55,24 +55,24 @@ ## T22 Collaboration — `src/collab` -- [ ] PresenceAvatars -- [ ] RemoteCursor -- [ ] RemoteSelection -- [ ] CommentThread -- [ ] CommentBubble / CommentMarker -- [ ] CommentSidebar -- [ ] Reply / ReactionPicker -- [ ] Reactions -- [ ] Annotation / Highlight -- [ ] SuggestionMode -- [ ] TrackChanges -- [ ] ShareDialog -- [ ] PermissionSelect -- [ ] InviteInput -- [ ] AccessList -- [ ] ActivityLog → `data_display::ActivityFeed` -- [ ] FollowMode -- [ ] LiveIndicator +- [x] PresenceAvatars +- [x] RemoteCursor → over a `forms::TextInput`, placed where its offset sits as the field paints +- [x] RemoteSelection → over a `forms::TextInput`, from `TextInput::bounds_for_range` +- [x] CommentThread +- [x] CommentBubble / CommentMarker → `collab::CommentMarker`, its thread in an `overlays::Popover` +- [x] CommentSidebar +- [x] Reply / ReactionPicker +- [x] Reactions +- [x] Annotation / Highlight → `collab::AnnotatedText`; a plain mark is `typography::Highlight` +- [x] SuggestionMode → edits become suggestions through `collab::suggested` +- [x] TrackChanges +- [x] ShareDialog → with `collab::LinkAccess` for who the link lets in +- [x] PermissionSelect +- [x] InviteInput → a `forms::TagInput` that checks each address +- [x] AccessList +- [x] ActivityLog → `data_display::ActivityFeed` +- [x] FollowMode +- [x] LiveIndicator ## T23a AI Chat · Messages — `src/chat`