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:
34 files changed
+3297
-86
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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()
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
))
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user