T22: Collaboration

- presence: avatars ringed in each peer's color with where they are, press to
  follow; a breathing live dot; follow mode frames the view and stops on Escape
- remote cursors and selections painted over a field from its own layout
- comment threads with replies, reactions and resolve; a marker that opens a
  thread in a bubble; a sidebar of open and resolved threads
- annotations washed in their authors' colors, notes on hover
- suggestion mode: edits become suggestions word by word; track changes shows
  them struck and underlined, to accept or reject
- sharing: invite by email with checked chips, roles, the access list and who
  the link lets in, in one dialog
- primitives::hold_focus gives a mode its own focus, shared with ZenMode
- AvatarGroup's tuck and count shared; Avatar::ring_color; TagInput::check;
  TextInput::bounds_for_range; Popover::size
- icons: smile-plus, user-plus, message-square-diff
- gallery: the Collaboration page with scripted states
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 05:10:37 -04:00
1 parent 0623855f38
commit 153cf95b99
34 files changed
+3297 -86

No files matched your search

+2 -1
View File
@@ -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<Rc<dyn Fn(..)>>`. 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.
+1 -1
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-message-square-diff"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
<path d="M10 15h4" />
<path d="M10 9h4" />
<path d="M12 7v4" />
</svg>

After

Width:  |  Height:  |  Size: 499 B

+20
View File
@@ -0,0 +1,20 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-smile-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13.267 2.08a10 10 0 108.653 8.653" />
<path d="M15 10V9" />
<path d="M16 5h6" />
<path d="M16.472 15a6 6 0 01-8.943 0" />
<path d="M19 2v6" />
<path d="M9 10V9" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.48.0 - ISC -->
<svg
class="lucide lucide-user-plus"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="19" x2="19" y1="8" y2="14" />
<line x1="22" x2="16" y1="11" y2="11" />
</svg>

After

Width:  |  Height:  |  Size: 463 B

+57
View File
@@ -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()
}
+116
View File
@@ -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<Peer> {
[
("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<TextInput> {
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::<SharedString>, 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)),
),
),
),
)
}
+168
View File
@@ -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<Member> {
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::<Role>, 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)),
),
)
}
+342
View File
@@ -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<Thread> {
let people = peers();
let comment = |ix: usize, hours: i64, body: &str, reactions: Vec<Reaction>| Comment {
author: people[ix].clone(),
at: now - hours.hours(),
body: body.to_string().into(),
reactions,
};
let reaction = |emoji: &str, count: usize, mine: bool| Reaction {
emoji: emoji.to_string().into(),
count,
mine,
};
vec![
Thread {
key: "gamma".into(),
quote: Some("blends in gamma space".into()),
comments: vec![
comment(
0,
5,
"Should this say gamma? Most readers won't know the word.",
vec![reaction("👀", 2, false)],
),
comment(
2,
3,
"Keep it, and link the glossary entry beside it.",
vec![reaction("👍", 1, true)],
),
],
resolved: false,
},
Thread {
key: "weight".into(),
quote: Some("read with the same weight".into()),
comments: vec![comment(
1,
26,
"Could we show a light and a dark pair side by side here?",
Vec::new(),
)],
resolved: false,
},
Thread {
key: "title".into(),
quote: Some("On Lift".into()),
comments: vec![
comment(3, 50, "The title reads well now.", Vec::new()),
comment(0, 49, "Agreed, closing this.", Vec::new()),
],
resolved: true,
},
]
}
/// Changes the thread keyed `key`.
fn change(
all: &Entity<Vec<Thread>>,
key: &SharedString,
cx: &mut App,
edit: impl FnOnce(&mut Thread),
) {
all.update(cx, |all, cx| {
let thread = all
.iter_mut()
.find(|thread| &thread.key == key)
.expect("the demo keeps its threads");
edit(thread);
cx.notify();
});
}
/// Adds the field's words to the thread keyed `key` as yours, and clears it.
fn answer(all: &Entity<Vec<Thread>>, key: &SharedString, field: &Entity<TextInput>, cx: &mut App) {
let body = field.read(cx).text().trim().to_string();
field.update(cx, |field, cx| field.set_text("", cx));
change(all, key, cx, |thread| {
thread.comments.push(Comment {
author: you(),
at: Timestamp::now(),
body: body.into(),
reactions: Vec::new(),
})
});
}
fn reply_field(key: &'static str, window: &mut Window, cx: &mut App) -> Entity<TextInput> {
window.use_keyed_state(key, cx, |window, cx| {
TextInput::new(window, cx).placeholder("Reply")
})
}
pub fn comments(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let all = keep("collab-threads", || threads(Timestamp::now()), window, cx);
let now = all.read(cx).clone();
let (one, marked, side) = (
reply_field("collab-reply-one", window, cx),
reply_field("collab-reply-marker", window, cx),
reply_field("collab-reply-side", window, cx),
);
let active = keep(
"collab-active",
|| Some(SharedString::from("gamma")),
window,
cx,
);
let picked = active.read(cx).clone();
let thread = |key: &str| {
now.iter()
.find(|thread| thread.key.as_ref() == key)
.expect("the demo keeps its threads")
.clone()
};
let key = |key: &'static str| SharedString::from(key);
let (sent, resolved, reacted) = (all.clone(), all.clone(), all.clone());
let first = CommentThread::new("collab-thread", thread("gamma"))
.reply(&one, {
let one = one.clone();
move |_, cx| answer(&sent, &key("gamma"), &one, cx)
})
.on_resolve(move |on, _, cx| {
change(&resolved, &key("gamma"), cx, |thread| thread.resolved = on)
})
.on_react(move |ix, emoji, _, cx| {
change(&reacted, &key("gamma"), cx, |thread| {
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
})
});
let (sent, resolved) = (all.clone(), all.clone());
let marker = CommentMarker::new("collab-marker", thread("weight"))
.reply(&marked, {
let marked = marked.clone();
move |_, cx| answer(&sent, &key("weight"), &marked, cx)
})
.on_resolve(move |on, _, cx| {
change(&resolved, &key("weight"), cx, |thread| thread.resolved = on)
});
let (sent, resolved, reacted, pick) = (all.clone(), all.clone(), all, active.clone());
let sidebar = CommentSidebar::new("collab-sidebar", now.clone())
.active(picked.clone(), move |key, _, cx| {
set(&pick, Some(key.clone()), cx)
})
.reply(&side, {
let side = side.clone();
move |_, cx| {
if let Some(key) = active.read(cx).clone() {
answer(&sent, &key, &side, cx)
}
}
})
.on_resolve(move |key, on, _, cx| change(&resolved, key, cx, |thread| thread.resolved = on))
.on_react(move |key, ix, emoji, _, cx| {
change(&reacted, key, cx, |thread| {
thread.comments[ix].reactions = toggled(&thread.comments[ix].reactions, emoji)
})
});
let theme = cx.theme();
div()
.child(
section(
"CommentThread / Reply / Reactions / ReactionPicker",
"A conversation on the words it quotes: each comment with its author, time and reactions; a reply below, Enter to send; resolve above. Press a reaction to add yours or take it back, or pick another.",
cx,
)
.child(probe("collab-thread", div().w(px(440.)).child(first))),
)
.child(
section(
"CommentBubble / CommentMarker",
"A mark beside the words a thread is about, with its count; a press opens the thread in a bubble over the page.",
cx,
)
.child(
div()
.w(px(560.))
.flex()
.items_center()
.gap_2()
.child(div().text_color(theme.colors.fg).child("In dark, accents lift further so they read with the same weight."))
.child(probe("collab-marker", marker)),
),
)
.child(
section(
"CommentSidebar",
"Every conversation down a column, open or resolved; a press picks one, and the one picked takes replies.",
cx,
)
.child(probe(
"collab-sidebar",
div()
.w(px(400.))
.h(px(560.))
.rounded(theme.radius(ely_gpui_component::theme::Radius::Lg))
.border_1()
.border_color(theme.colors.border)
.bg(theme.colors.sunken)
.child(sidebar),
)),
)
}
const PASSAGE: &str = "Every accent in the library is a base color and a lift. The base carries the hue; the lift carries how loud the color is. Hover, press and focus are lifts of the accent.";
pub fn annotations(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let picked = keep("collab-picked", || None::<usize>, window, cx);
let now = *picked.read(cx);
let people = peers();
let mark = |words: &str, author: usize, note: Option<&str>| {
let start = PASSAGE.find(words).expect("the passage holds its marks");
Annotation {
range: start..start + words.len(),
author: people[author].clone(),
note: note.map(|note| note.to_string().into()),
}
};
section(
"Annotation / Highlight",
"Stretches people marked, washed in their colors; the ones with a note are underlined, and resting on one shows it. A press picks one. A plain mark with no author is typography::Highlight.",
cx,
)
.child(probe(
"collab-annotations",
div().w(px(560.)).child(
AnnotatedText::new(
"collab-annotated",
PASSAGE,
[
mark("a base color and a lift", 0, Some("The one idea to remember.")),
mark("how loud the color is", 1, None),
mark("lifts of the accent", 2, Some("Link to the motion page.")),
],
)
.picked(now, move |ix, _, cx| set(&picked, Some(ix), cx)),
),
))
}
const BASE: &str = "A lift blends color toward white. Half a lift sits midway between.";
/// `base` with every one of `suggestions` taken.
fn applied(base: &str, suggestions: Vec<Suggestion>) -> String {
let (mut text, mut left) = (base.to_string(), suggestions);
while !left.is_empty() {
(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),
))
}
+2
View File
@@ -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,
];
+132
View File
@@ -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<usize>,
pub author: Peer,
pub note: Option<SharedString>,
}
/// 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<Vec<Annotation>>,
picked: Option<usize>,
on_pick: Option<Pick>,
}
impl AnnotatedText {
/// `annotations` in order, none overlapping.
pub fn new(
id: impl Into<ElementId>,
text: impl Into<SharedString>,
annotations: impl IntoIterator<Item = Annotation>,
) -> Self {
let text = text.into();
let annotations: Vec<Annotation> = 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<usize>,
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<usize>, 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<Range<usize>> = 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,
}
}
}
+492
View File
@@ -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<Reaction>,
}
/// 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<SharedString>,
pub comments: Vec<Comment>,
pub resolved: bool,
}
type OnReact = Rc<dyn Fn(usize, &SharedString, &mut Window, &mut App)>;
type OnThreadFlag = Rc<dyn Fn(&SharedString, bool, &mut Window, &mut App)>;
type OnThreadReact = Rc<dyn Fn(&SharedString, usize, &SharedString, &mut Window, &mut App)>;
/// 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<TextInput>,
on_send: Run,
}
impl Reply {
pub fn new(
id: impl Into<ElementId>,
field: &Entity<TextInput>,
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<TextInput>, Run)>,
on_resolve: Option<OnFlag>,
on_react: Option<OnReact>,
}
impl CommentThread {
pub fn new(id: impl Into<ElementId>, 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<TextInput>,
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<TextInput>, Run)>,
on_resolve: Option<OnFlag>,
}
impl CommentMarker {
pub fn new(id: impl Into<ElementId>, thread: Thread) -> Self {
Self {
id: id.into(),
thread,
reply: None,
on_resolve: None,
}
}
pub fn reply(
mut self,
field: &Entity<TextInput>,
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<Thread>,
active: Option<SharedString>,
reply: Option<(Entity<TextInput>, Run)>,
on_select: Option<OnValue>,
on_resolve: Option<OnThreadFlag>,
on_react: Option<OnThreadReact>,
}
impl CommentSidebar {
pub fn new(id: impl Into<ElementId>, threads: impl IntoIterator<Item = Thread>) -> 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<SharedString>,
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<TextInput>,
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<Thread> = 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)
}
}
+17
View File
@@ -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};
+327
View File
@@ -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<SharedString>,
pub place: Option<SharedString>,
pub hue: usize,
}
impl Peer {
pub fn new(key: impl Into<SharedString>, name: impl Into<SharedString>, 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<ElementId>, 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<Peer>,
max: usize,
following: Option<SharedString>,
on_follow: Option<OnValue>,
}
impl PresenceAvatars {
pub fn new(id: impl Into<ElementId>, peers: impl IntoIterator<Item = Peer>) -> 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<SharedString>,
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<AnyElement> = 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<ElementId>, 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<Peer>,
on_stop: Run,
body: SmallVec<[AnyElement; 2]>,
}
impl FollowMode {
/// `peer` is whom the view follows, when anyone.
pub fn new(
id: impl Into<ElementId>,
peer: Option<Peer>,
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<Item = AnyElement>) {
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)
}
}
+216
View File
@@ -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<Reaction> {
let mut next: Vec<Reaction> = 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<ElementId>,
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<Reaction>,
on_toggle: Option<OnValue>,
}
impl Reactions {
pub fn new(id: impl Into<ElementId>, reactions: impl IntoIterator<Item = Reaction>) -> 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)));
}
}
+135
View File
@@ -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<TextInput>,
offset: usize,
}
impl RemoteCursor {
pub fn new(peer: &Peer, input: &Entity<TextInput>, 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<TextInput>,
range: Range<usize>,
}
impl RemoteSelection {
pub fn new(peer: &Peer, input: &Entity<TextInput>, range: Range<usize>) -> 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()
}
}
+483
View File
@@ -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<dyn Fn(Role, &mut Window, &mut App)>;
/// 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<ElementId>,
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<SharedString>,
pub role: Role,
pub owner: bool,
}
type OnMemberRole = Rc<dyn Fn(usize, Role, &mut Window, &mut App)>;
/// 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<Member>,
on_role: Option<OnMemberRole>,
on_remove: Option<Pick>,
}
impl AccessList {
pub fn new(id: impl Into<ElementId>, members: impl IntoIterator<Item = Member>) -> 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<SharedString>,
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<ElementId>,
emails: impl IntoIterator<Item = impl Into<SharedString>>,
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<dyn Fn(Option<Role>, &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<Role>,
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<ElementId>,
link: impl Into<SharedString>,
anyone: Option<Role>,
on_change: impl Fn(Option<Role>, &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<AnyElement>,
access: Option<AnyElement>,
link: Option<AnyElement>,
}
impl ShareDialog {
/// Render it while open; `on_close` runs on Done, Escape or a press outside.
pub fn new(
id: impl Into<ElementId>,
title: impl Into<SharedString>,
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<AnyElement>| {
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))
})
}
}
+100
View File
@@ -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<TextInput>,
field: Entity<TextInput>,
following: bool,
sent: usize,
stopped: usize,
}
impl Render for Desk {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Desk>, &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");
}
+456
View File
@@ -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<dyn Fn(EditMode, &mut Window, &mut App)>;
/// 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<ElementId>,
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<usize>,
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>, 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<Suggestion> {
let diff = TextDiff::configure().diff_unicode_words(base, edited);
let mut found = Vec::new();
let mut open: Option<Suggestion> = 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<Suggestion>) {
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<dyn Fn(Decision, &mut Window, &mut App)>;
/// 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<Suggestion>,
on_decide: Option<OnDecide>,
}
impl TrackChanges {
/// `suggestions` in order, none overlapping, their ranges in `base`.
pub fn new(
id: impl Into<ElementId>,
base: impl Into<SharedString>,
suggestions: impl IntoIterator<Item = Suggestion>,
) -> Self {
let base = base.into();
let suggestions: Vec<Suggestion> = 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<usize>, 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<usize>, 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.");
}
}
+45 -28
View File
@@ -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<Hsla>,
presence: Option<Presence>,
}
@@ -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)
}
}
+1
View File
@@ -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;
+1
View File
@@ -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;
+6 -28
View File
@@ -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<FocusHandle>,
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);
}
})
+3 -1
View File
@@ -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};
+14 -5
View File
@@ -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<SharedString>,
placeholder: SharedString,
check: Option<fn(&str) -> bool>,
on_change: Option<OnChange>,
}
@@ -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<SharedString>, &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());
+21 -1
View File
@@ -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"
);
}
+1 -1
View File
@@ -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,
+31 -1
View File
@@ -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<usize>) -> Vec<Bounds<Pixels>> {
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<Point<Pixels>> {
let layout = self.layout.as_ref()?;
+1
View File
@@ -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;
+9
View File
@@ -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<dyn FnOnce(&mut Window, &mut App) -> AnyElement>;
@@ -39,6 +40,7 @@ pub struct Popover {
label: SharedString,
icon: Option<IconName>,
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 {
+40
View File
@@ -170,6 +170,46 @@ pub(crate) fn give_back(state: &Entity<Takeover>, 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<FocusHandle>,
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<ElementId>,
on: bool,
window: &mut Window,
cx: &mut App,
) -> Entity<Held> {
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<Held>, 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,
+3
View File
@@ -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",
+1 -1
View File
@@ -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;
+18 -18
View File
@@ -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`