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;...@@ -31,6 +31,10 @@ const STACKED: usize = 3;
31const FLAG: f32 = 15.0;31const FLAG: f32 = 15.0;
32const FLAG_PAD: f32 = 4.0;32const FLAG_PAD: f32 = 4.0;
33const NAMED: std::time::Duration = std::time::Duration::from_secs(3);33const 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;
34/// The relay used where Options names none.38/// The relay used where Options names none.
35pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";39pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";
36/// How long opening a notebook joined for the first time waits for the computer sharing it.40/// How long opening a notebook joined for the first time waits for the computer sharing it.
...@@ -863,8 +867,9 @@ impl State {...@@ -863,8 +867,9 @@ impl State {
863 Some(format!("In {}", tab.name))867 Some(format!("In {}", tab.name))
864 }868 }
865869
866 /// Each peer's caret on the open page with a flag naming them, and what they have870 /// The carets of the peers on the open page who moved last, each with a flag naming
867 /// selected, as boxes floating in the page's box.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.
868 pub(crate) fn peer_carets(&mut self) {873 pub(crate) fn peer_carets(&mut self) {
869 let Some(session) = &self.session else {874 let Some(session) = &self.session else {
870 return;875 return;
...@@ -882,9 +887,10 @@ impl State {...@@ -882,9 +887,10 @@ impl State {
882 (y * viewport.scale + viewport.origin[1]) / scale,887 (y * viewport.scale + viewport.origin[1]) / scale,
883 ]888 ]
884 };889 };
890 let now = std::time::Instant::now();
891 let (mut placed, mut above, mut below) = (Vec::new(), Vec::new(), Vec::new());
885 for peer in self.connected() {892 for peer in self.connected() {
886 let Some(caret) = peer893 let Some(caret) = (peer.presence.as_ref())
887 .presence
888 .filter(|presence| (presence.section, presence.page) == here)894 .filter(|presence| (presence.section, presence.page) == here)
889 .and_then(|presence| presence.caret)895 .and_then(|presence| presence.caret)
890 else {896 else {
...@@ -893,39 +899,13 @@ impl State {...@@ -893,39 +899,13 @@ impl State {
893 let Some((outline, focus)) = find(&self.view.editor, caret.focus) else {899 let Some((outline, focus)) = find(&self.view.editor, caret.focus) else {
894 continue;900 continue;
895 };901 };
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 }
919 let Ok(rect) = outline.caret_at(focus, parley::Affinity::Downstream, 0.0) else {902 let Ok(rect) = outline.caret_at(focus, parley::Affinity::Downstream, 0.0) else {
920 continue;903 continue;
921 };904 };
905 let [x, y] = outline.origin();
922 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);906 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
923 let [_, y1] = shown([rect.x0 as f32 + x, rect.y1 as f32 + y]);907 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 }
927 let id = peer.hello.peer;908 let id = peer.hello.peer;
928 let now = std::time::Instant::now();
929 let moved = match self.peers.moved.get(&id) {909 let moved = match self.peers.moved.get(&id) {
930 Some((at, when)) if *at == caret => *when,910 Some((at, when)) if *at == caret => *when,
931 _ => {911 _ => {
...@@ -933,79 +913,178 @@ impl State {...@@ -933,79 +913,178 @@ impl State {
933 now913 now
934 }914 }
935 };915 };
936 let quiet = now.saturating_duration_since(moved);916 if y1 < 0.0 {
937 if quiet < NAMED {917 above.push((moved, peer));
938 self.ui.wake_after(NAMED - quiet);918 continue;
939 }919 }
940 let name = (peer.hello.name.split_whitespace().next()).unwrap_or("Someone");920 if y0 > bottom - top {
941 // Below the caret where the page's top would cut the flag off.921 below.push((moved, peer));
942 let below = y0 < FLAG;922 continue;
943 let flag_top = if below { y1 } else { y0 - FLAG };923 }
944 // About the flag's width, for hovering it.924 if !(0.0..right - left).contains(&x0) {
945 let width = FLAG_PAD * 2.0 + self.ui.measure(name)[0] * 10.0 / self.ui.theme.font_size;925 continue;
946 let hovered = self.ui.pointer().is_some_and(|[px, py]| {926 }
947 let [px, py] = [px - left, py - top];927 let selection = find(&self.view.editor, caret.anchor)
948 (x0 - 4.0..=x0 + 4.0).contains(&px) && (y0..=y1).contains(&py)928 .filter(|(other, _)| other.id == outline.id)
949 || (x0 - 1.0..=x0 - 1.0 + width).contains(&px)929 .and_then(|(_, anchor)| outline.range_rects([anchor, focus].into()).ok())
950 && (flag_top..=flag_top + FLAG).contains(&py)930 .unwrap_or_default()
951 });931 .into_iter()
952 let shown_flag = self.ui.animate(932 .map(|rect| {
953 Id::ROOT.child((id, "flag-shown")),933 let [x0, y0] = shown([rect.x0 as f32 + x, rect.y0 as f32 + y]);
954 if quiet < NAMED || hovered { 1.0 } else { 0.0 },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 },
955 );974 );
956 let faded =975 let mut names: Vec<&str> = (bundle.iter().take(NAMES))
957 |[red, green, blue, alpha]: [f32; 4], by: f32| [red, green, blue, alpha * by];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() {
958 self.ui.leaf(1007 self.ui.leaf(
959 (id, "caret"),1008 (id, "selection", index),
960 Spec {1009 Spec {
961 flags: Flags::FLOAT,1010 flags: Flags::FLOAT,
962 position: [x0 - 1.0, y0],1011 position: [*x0, *y0],
963 size: [px(2.0), px(y1 - y0)],1012 size: [px(x1 - x0), px(y1 - y0)],
964 fill: Some(color),1013 fill: Some([color[0], color[1], color[2], 0.25]),
965 ..Spec::default()1014 ..Spec::default()
966 },1015 },
967 );1016 );
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 {
969 self.ui.leaf(1061 self.ui.leaf(
970 (id, "cap"),1062 (id, "flag"),
971 Spec {1063 Spec {
972 flags: Flags::FLOAT,1064 flags: Flags::FLOAT,
973 position: [x0 - 3.0, if below { y1 - 3.0 } else { y0 - 3.0 }],1065 position: [x0 - 1.0, flag_top],
974 size: [px(6.0), px(6.0)],1066 size: [fit(), px(FLAG)],
975 fill: Some(faded(color, 1.0 - shown_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)),
976 radius: 3.0,1072 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)),
977 ..Spec::default()1085 ..Spec::default()
978 },1086 },
979 );1087 );
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 }
1009 }1088 }
1010 }1089 }
1011}1090}
crates/snowbound/src/main.rs+17-2
...@@ -5312,19 +5312,34 @@ fn page_tab(...@@ -5312,19 +5312,34 @@ fn page_tab(
5312 None5312 None
5313 }5313 }
5314 };5314 };
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() {
5316 let inset = (ROW - ROW_GAP - 8.0) / 2.0;5318 let inset = (ROW - ROW_GAP - 8.0) / 2.0;
5317 ui.leaf(5319 ui.leaf(
5318 ("peer", index),5320 ("peer", index),
5319 Spec {5321 Spec {
5320 size: [px(10.0), px(ROW - ROW_GAP)],5322 size: [px(10.0), px(ROW - ROW_GAP)],
5321 fill: Some(*color),5323 fill: Some(*dot),
5322 radius: 4.0,5324 radius: 4.0,
5323 inset: [1.0, inset, 1.0, inset],5325 inset: [1.0, inset, 1.0, inset],
5324 ..Spec::default()5326 ..Spec::default()
5325 },5327 },
5326 );5328 );
5327 }5329 }
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 }
5328 if tab.conflicted {5343 if tab.conflicted {
5329 ui.leaf(5344 ui.leaf(
5330 "conflict",5345 "conflict",