authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 01:09:06-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:26:36-07:00
logc1ce2e1cd08f77a471af641678a916085835c7a3
treede6a4148dae728ce46acb319ce6f5295e5387c33
parent5e6642680a2a6d3e0dcdc018cb7a021a7e010daa
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: at most five carets on a page, the rest above or below the view folded into a chip at its edge

The five who moved last show; those out of view fold into "12 below", whose tooltip names them and whose click goes to the latest. A page tab shows four dots and then +N. Assisted-by: claude-opus-5.5

2 files changed, 185 insertions(+), 91 deletions(-)

crates/snowbound/src/live.rs+168-89
......@@ -31,6 +31,10 @@ const STACKED: usize = 3;
3131const FLAG: f32 = 15.0;
3232const FLAG_PAD: f32 = 4.0;
3333const NAMED: std::time::Duration = std::time::Duration::from_secs(3);
34/// The carets drawn on a page at once: those who moved last; and the names a chip of those
35/// out of view lists.
36const CARETS: usize = 5;
37const NAMES: usize = 5;
3438/// The relay used where Options names none.
3539pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";
3640/// How long opening a notebook joined for the first time waits for the computer sharing it.
......@@ -863,8 +867,9 @@ impl State {
863867 Some(format!("In {}", tab.name))
864868 }
865869
866 /// Each peer's caret on the open page with a flag naming them, and what they have
867 /// selected, as boxes floating in the page's box.
870 /// The carets of the peers on the open page who moved last, each with a flag naming
871 /// them, and what they have selected, as boxes floating in the page's box; those above
872 /// or below the view bundle into a chip at its edge.
868873 pub(crate) fn peer_carets(&mut self) {
869874 let Some(session) = &self.session else {
870875 return;
......@@ -882,9 +887,10 @@ impl State {
882887 (y * viewport.scale + viewport.origin[1]) / scale,
883888 ]
884889 };
890 let now = std::time::Instant::now();
891 let (mut placed, mut above, mut below) = (Vec::new(), Vec::new(), Vec::new());
885892 for peer in self.connected() {
886 let Some(caret) = peer
887 .presence
893 let Some(caret) = (peer.presence.as_ref())
888894 .filter(|presence| (presence.section, presence.page) == here)
889895 .and_then(|presence| presence.caret)
890896 else {
......@@ -893,39 +899,13 @@ impl State {
893899 let Some((outline, focus)) = find(&self.view.editor, caret.focus) else {
894900 continue;
895901 };
896 let color = color(&peer.hello.peer);
897 let [x, y] = outline.origin();
898 if let Some((_, anchor)) =
899 find(&self.view.editor, caret.anchor).filter(|(other, _)| other.id == outline.id)
900 {
901 let rects = outline
902 .range_rects([anchor, focus].into())
903 .unwrap_or_default();
904 for (index, rect) in rects.into_iter().enumerate() {
905 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
906 let [x1, y1] = shown([rect.x1 as f32 + x, rect.y1 as f32 + y]);
907 self.ui.leaf(
908 (peer.hello.peer, "selection", index),
909 Spec {
910 flags: Flags::FLOAT,
911 position: [x0, y0],
912 size: [px(x1 - x0), px(y1 - y0)],
913 fill: Some([color[0], color[1], color[2], 0.25]),
914 ..Spec::default()
915 },
916 );
917 }
918 }
919902 let Ok(rect) = outline.caret_at(focus, parley::Affinity::Downstream, 0.0) else {
920903 continue;
921904 };
905 let [x, y] = outline.origin();
922906 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
923907 let [_, y1] = shown([rect.x0 as f32 + x, rect.y1 as f32 + y]);
924 if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top {
925 continue;
926 }
927908 let id = peer.hello.peer;
928 let now = std::time::Instant::now();
929909 let moved = match self.peers.moved.get(&id) {
930910 Some((at, when)) if *at == caret => *when,
931911 _ => {
......@@ -933,79 +913,178 @@ impl State {
933913 now
934914 }
935915 };
936 let quiet = now.saturating_duration_since(moved);
937 if quiet < NAMED {
938 self.ui.wake_after(NAMED - quiet);
916 if y1 < 0.0 {
917 above.push((moved, peer));
918 continue;
939919 }
940 let name = (peer.hello.name.split_whitespace().next()).unwrap_or("Someone");
941 // Below the caret where the page's top would cut the flag off.
942 let below = y0 < FLAG;
943 let flag_top = if below { y1 } else { y0 - FLAG };
944 // About the flag's width, for hovering it.
945 let width = FLAG_PAD * 2.0 + self.ui.measure(name)[0] * 10.0 / self.ui.theme.font_size;
946 let hovered = self.ui.pointer().is_some_and(|[px, py]| {
947 let [px, py] = [px - left, py - top];
948 (x0 - 4.0..=x0 + 4.0).contains(&px) && (y0..=y1).contains(&py)
949 || (x0 - 1.0..=x0 - 1.0 + width).contains(&px)
950 && (flag_top..=flag_top + FLAG).contains(&py)
951 });
952 let shown_flag = self.ui.animate(
953 Id::ROOT.child((id, "flag-shown")),
954 if quiet < NAMED || hovered { 1.0 } else { 0.0 },
920 if y0 > bottom - top {
921 below.push((moved, peer));
922 continue;
923 }
924 if !(0.0..right - left).contains(&x0) {
925 continue;
926 }
927 let selection = find(&self.view.editor, caret.anchor)
928 .filter(|(other, _)| other.id == outline.id)
929 .and_then(|(_, anchor)| outline.range_rects([anchor, focus].into()).ok())
930 .unwrap_or_default()
931 .into_iter()
932 .map(|rect| {
933 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
934 let [x1, y1] = shown([rect.x1 as f32 + x, rect.y1 as f32 + y]);
935 [x0, y0, x1, y1]
936 })
937 .collect::<Vec<_>>();
938 placed.push((moved, peer, [x0, y0, y1], selection));
939 }
940 placed.sort_by_key(|(moved, ..)| std::cmp::Reverse(*moved));
941 for (moved, peer, at, selection) in placed.into_iter().take(CARETS) {
942 self.peer_caret(&peer, now.saturating_duration_since(moved), at, &selection);
943 }
944 let mut chosen = None;
945 for (edge, mut bundle) in [("above", above), ("below", below)] {
946 if bundle.is_empty() {
947 continue;
948 }
949 bundle.sort_by_key(|(moved, _)| std::cmp::Reverse(*moved));
950 let label = format!("{} {edge}", bundle.len());
951 let width = 16.0 + self.ui.measure(&label)[0] * 10.0 / self.ui.theme.font_size;
952 let y = if edge == "above" {
953 6.0
954 } else {
955 bottom - top - FLAG - 6.0
956 };
957 let chip = self.ui.leaf(
958 ("peers", edge),
959 Spec {
960 flags: Flags::FLOAT | Flags::CLICKABLE,
961 position: [(right - left - width) / 2.0, y],
962 size: [fit(), px(FLAG + 2.0)],
963 text: Some(&label),
964 font_size: Some(10.0),
965 bold: true,
966 center: true,
967 color: Some([1.0; 4]),
968 fill: Some(color(&bundle[0].1.hello.peer)),
969 radius: (FLAG + 2.0) / 2.0,
970 pad: [8.0, 0.0],
971 role: Some(accesskit::Role::Button),
972 ..Spec::default()
973 },
955974 );
956 let faded =
957 |[red, green, blue, alpha]: [f32; 4], by: f32| [red, green, blue, alpha * by];
975 let mut names: Vec<&str> = (bundle.iter().take(NAMES))
976 .map(|(_, peer)| peer.hello.name.as_str())
977 .collect();
978 let more = format!("and {} more", bundle.len().saturating_sub(NAMES));
979 if bundle.len() > NAMES {
980 names.push(&more);
981 }
982 ui::popup::tooltip(&mut self.ui, &names.join(", "), "", None);
983 if chip.clicked {
984 chosen = Some(bundle[0].1.hello.peer);
985 }
986 }
987 if let Some(peer) = chosen {
988 self.peers.going = Some((peer, now, None));
989 }
990 }
991
992 /// `peer`'s caret at `[x, top, bottom]` in the page's box, quiet for `quiet`, with what
993 /// they have selected.
994 fn peer_caret(
995 &mut self,
996 peer: &Peer,
997 quiet: std::time::Duration,
998 [x0, y0, y1]: [f32; 3],
999 selection: &[[f32; 4]],
1000 ) {
1001 let Some([left, top, ..]) = self.ui.rect(page()) else {
1002 return;
1003 };
1004 let id = peer.hello.peer;
1005 let color = color(&id);
1006 for (index, [x0, y0, x1, y1]) in selection.iter().enumerate() {
9581007 self.ui.leaf(
959 (id, "caret"),
1008 (id, "selection", index),
9601009 Spec {
9611010 flags: Flags::FLOAT,
962 position: [x0 - 1.0, y0],
963 size: [px(2.0), px(y1 - y0)],
964 fill: Some(color),
1011 position: [*x0, *y0],
1012 size: [px(x1 - x0), px(y1 - y0)],
1013 fill: Some([color[0], color[1], color[2], 0.25]),
9651014 ..Spec::default()
9661015 },
9671016 );
968 // The caret's cap, where its name is not shown.
1017 }
1018 if quiet < NAMED {
1019 self.ui.wake_after(NAMED - quiet);
1020 }
1021 let name = (peer.hello.name.split_whitespace().next()).unwrap_or("Someone");
1022 // Below the caret where the page's top would cut the flag off.
1023 let below = y0 < FLAG;
1024 let flag_top = if below { y1 } else { y0 - FLAG };
1025 // About the flag's width, for hovering it.
1026 let width = FLAG_PAD * 2.0 + self.ui.measure(name)[0] * 10.0 / self.ui.theme.font_size;
1027 let hovered = self.ui.pointer().is_some_and(|[px, py]| {
1028 let [px, py] = [px - left, py - top];
1029 (x0 - 4.0..=x0 + 4.0).contains(&px) && (y0..=y1).contains(&py)
1030 || (x0 - 1.0..=x0 - 1.0 + width).contains(&px)
1031 && (flag_top..=flag_top + FLAG).contains(&py)
1032 });
1033 let shown_flag = self.ui.animate(
1034 Id::ROOT.child((id, "flag-shown")),
1035 if quiet < NAMED || hovered { 1.0 } else { 0.0 },
1036 );
1037 let faded = |[red, green, blue, alpha]: [f32; 4], by: f32| [red, green, blue, alpha * by];
1038 self.ui.leaf(
1039 (id, "caret"),
1040 Spec {
1041 flags: Flags::FLOAT,
1042 position: [x0 - 1.0, y0],
1043 size: [px(2.0), px(y1 - y0)],
1044 fill: Some(color),
1045 ..Spec::default()
1046 },
1047 );
1048 // The caret's cap, where its name is not shown.
1049 self.ui.leaf(
1050 (id, "cap"),
1051 Spec {
1052 flags: Flags::FLOAT,
1053 position: [x0 - 3.0, if below { y1 - 3.0 } else { y0 - 3.0 }],
1054 size: [px(6.0), px(6.0)],
1055 fill: Some(faded(color, 1.0 - shown_flag)),
1056 radius: 3.0,
1057 ..Spec::default()
1058 },
1059 );
1060 if shown_flag > 0.0 {
9691061 self.ui.leaf(
970 (id, "cap"),
1062 (id, "flag"),
9711063 Spec {
9721064 flags: Flags::FLOAT,
973 position: [x0 - 3.0, if below { y1 - 3.0 } else { y0 - 3.0 }],
974 size: [px(6.0), px(6.0)],
975 fill: Some(faded(color, 1.0 - shown_flag)),
1065 position: [x0 - 1.0, flag_top],
1066 size: [fit(), px(FLAG)],
1067 text: Some(name),
1068 font_size: Some(10.0),
1069 bold: true,
1070 color: Some(faded([1.0; 4], shown_flag)),
1071 fill: Some(faded(color, shown_flag)),
9761072 radius: 3.0,
1073 pad: [FLAG_PAD, 0.0],
1074 ..Spec::default()
1075 },
1076 );
1077 // The corner on the caret square, so flag and caret are one shape.
1078 self.ui.leaf(
1079 (id, "joint"),
1080 Spec {
1081 flags: Flags::FLOAT,
1082 position: [x0 - 1.0, if below { y1 } else { y0 - 3.0 }],
1083 size: [px(3.0), px(3.0)],
1084 fill: Some(faded(color, shown_flag)),
9771085 ..Spec::default()
9781086 },
9791087 );
980 if shown_flag > 0.0 {
981 self.ui.leaf(
982 (id, "flag"),
983 Spec {
984 flags: Flags::FLOAT,
985 position: [x0 - 1.0, flag_top],
986 size: [fit(), px(FLAG)],
987 text: Some(name),
988 font_size: Some(10.0),
989 bold: true,
990 color: Some(faded([1.0; 4], shown_flag)),
991 fill: Some(faded(color, shown_flag)),
992 radius: 3.0,
993 pad: [FLAG_PAD, 0.0],
994 ..Spec::default()
995 },
996 );
997 // The corner on the caret square, so flag and caret are one shape.
998 self.ui.leaf(
999 (id, "joint"),
1000 Spec {
1001 flags: Flags::FLOAT,
1002 position: [x0 - 1.0, if below { y1 } else { y0 - 3.0 }],
1003 size: [px(3.0), px(3.0)],
1004 fill: Some(faded(color, shown_flag)),
1005 ..Spec::default()
1006 },
1007 );
1008 }
10091088 }
10101089 }
10111090}
crates/snowbound/src/main.rs+17-2
......@@ -5312,19 +5312,34 @@ fn page_tab(
53125312 None
53135313 }
53145314 };
5315 for (index, color) in tab.peers.iter().enumerate() {
5315 // A dot for each of the first few people on the page, then how many more.
5316 const DOTS: usize = 4;
5317 for (index, dot) in tab.peers.iter().take(DOTS).enumerate() {
53165318 let inset = (ROW - ROW_GAP - 8.0) / 2.0;
53175319 ui.leaf(
53185320 ("peer", index),
53195321 Spec {
53205322 size: [px(10.0), px(ROW - ROW_GAP)],
5321 fill: Some(*color),
5323 fill: Some(*dot),
53225324 radius: 4.0,
53235325 inset: [1.0, inset, 1.0, inset],
53245326 ..Spec::default()
53255327 },
53265328 );
53275329 }
5330 if tab.peers.len() > DOTS {
5331 ui.leaf(
5332 "more peers",
5333 Spec {
5334 size: [fit(), px(ROW - ROW_GAP)],
5335 text: Some(&format!("+{}", tab.peers.len() - DOTS)),
5336 font_size: Some(10.0),
5337 color: Some(color),
5338 pad: [2.0, 0.0],
5339 ..Spec::default()
5340 },
5341 );
5342 }
53285343 if tab.conflicted {
53295344 ui.leaf(
53305345 "conflict",