T22: Collaboration

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

No files matched your search

+3 -1
View File
@@ -79,7 +79,9 @@ pub(crate) use mention::{Suggestions, active_trigger, handles_matching};
pub use multi::MultiSelect;
pub use number::{NumberInput, ScrubInput};
pub use options::Choice;
pub(crate) use options::{OnFlag, OnValue, Pick, Run, float, float_height, step, surface};
pub(crate) use options::{
OnFlag, OnValue, OnValues, Pick, Run, float, float_height, step, surface,
};
pub use pairs::{FieldArray, KeyValueInput, ListInput};
pub use path::PathInput;
pub use pattern::{RegexInput, regex_highlights};
+14 -5
View File
@@ -7,7 +7,7 @@ use gpui::{
use super::{InputEvent, TextInput, text::Backspace};
use crate::{
data_display::Tag,
data_display::{Tag, Tone},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
@@ -50,6 +50,7 @@ pub struct TagInput {
id: ElementId,
tags: Vec<SharedString>,
placeholder: SharedString,
check: Option<fn(&str) -> bool>,
on_change: Option<OnChange>,
}
@@ -62,6 +63,7 @@ impl TagInput {
id: id.into(),
tags: tags.into_iter().map(Into::into).collect(),
placeholder: "Add a tag".into(),
check: None,
on_change: None,
}
}
@@ -71,6 +73,12 @@ impl TagInput {
self
}
/// Marks each tag that fails `check` as danger, as text that is not an address.
pub fn check(mut self, check: fn(&str) -> bool) -> Self {
self.check = Some(check);
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<SharedString>, &mut Window, &mut App) + 'static,
@@ -112,16 +120,17 @@ impl RenderOnce for TagInput {
let owner = self.id.clone();
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()).on_remove(
move |window, cx| {
let failed = self.check.is_some_and(|check| !check(tag));
Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone())
.when(failed, |tag| tag.tone(Tone::Danger))
.on_remove(move |window, cx| {
let mut next = tags.clone();
next.remove(ix);
log::info!("tag input: removed one, {} left", next.len());
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
},
)
})
});
let (tags, on_change, empty_check) =
(self.tags.clone(), self.on_change.clone(), input.clone());
+21 -1
View File
@@ -7,7 +7,7 @@ mod upload;
use gpui::{
AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke,
ParentElement, Render, TestAppContext, VisualTestContext, Window, div,
ParentElement, Render, TestAppContext, VisualTestContext, Window, div, px,
};
use super::{Input, MaskedInput, NumberInput, PinInput, TextInput, bind_keys};
@@ -279,3 +279,23 @@ fn a_composition_inside_a_masked_field_fits_from_where_it_began(cx: &mut TestApp
});
assert_eq!(text(&state, cx), "(192) 345");
}
#[gpui::test]
fn a_range_gives_a_box_per_line_it_spans(cx: &mut TestAppContext) {
let (fields, cx) = open_with(1, false, |input| input.multi_line(2, 4), cx);
fields[0].update(cx, |input, cx| input.set_text("lift\ntone", cx));
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
let (one, two) = fields[0].read_with(cx, |input, _| {
(input.bounds_for_range(1..3), input.bounds_for_range(2..7))
});
assert_eq!(one.len(), 1);
assert!(one[0].size.width > px(0.0));
assert_eq!(two.len(), 2, "{two:?}");
assert!(two[1].top() > two[0].top());
assert!(
two[1].left() < two[0].left(),
"the second line starts at the edge"
);
}
+1 -1
View File
@@ -358,7 +358,7 @@ fn keep_in_view(
}
/// Boxes covering display offsets `start..end`, relative to the text origin.
fn selection_rects(
pub(super) fn selection_rects(
lines: &[(usize, WrappedLine)],
start: usize,
end: usize,
+31 -1
View File
@@ -1,6 +1,8 @@
use std::ops::Range;
use gpui::{Bounds, Pixels, Point};
use super::TextInput;
use super::{TextInput, element::selection_rects};
impl TextInput {
/// The caret's box in window coordinates, once laid out.
@@ -22,6 +24,34 @@ impl TextInput {
))
}
/// A range's boxes in the window, a line each, from the last layout; later offsets clamp to its end.
pub fn bounds_for_range(&self, range: Range<usize>) -> Vec<Bounds<Pixels>> {
let Some(layout) = self.layout.as_ref() else {
return Vec::new();
};
if layout.placeholder {
return Vec::new();
}
let laid = layout
.lines
.last()
.map_or(0, |(start, line)| start + line.len());
let (start, end) = (
self.display_offset(range.start).min(laid),
self.display_offset(range.end).min(laid),
);
selection_rects(
&layout.lines,
start,
end,
layout.line_height,
layout.bounds.size.width,
)
.into_iter()
.map(|rect| Bounds::new(rect.origin + layout.bounds.origin - self.scroll, rect.size))
.collect()
}
/// Top-left of a display offset, relative to the text's origin.
pub(crate) fn position_for(&self, display: usize) -> Option<Point<Pixels>> {
let layout = self.layout.as_ref()?;