authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 23:03:09-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:26:36-07:00
loga312c05b23b42586a70cb0be2c71d96e2b51eaf8
treeb2c89872768d22502cd48f924a85e8ddd176a4e0
parent8d1b56375d7289dbb12b8ac754cf673adcb8e19e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the avatar stack overlaps the first three and folds the rest into +N; clicking a person goes to their caret

A menu of everyone, most recently active first, with the page each has open; choosing one opens that page and scrolls their caret to the middle of the window. Assisted-by: claude-opus-5.5

1 files changed, 142 insertions(+), 12 deletions(-)

crates/snowbound/src/live.rs+142-12
......@@ -25,6 +25,8 @@ use ui::{Flags, Id, Spec, children, fill, fit, px};
2525/// The side of the picture sent, in pixels.
2626const PICTURE: u32 = 96;
2727const AVATAR: f32 = 22.0;
28/// The avatars shown before the rest bundle into a chip.
29const STACKED: usize = 3;
2830/// The name flag above a caret, and how long it shows after the caret last moved.
2931const FLAG: f32 = 15.0;
3032const FLAG_PAD: f32 = 4.0;
......@@ -259,6 +261,13 @@ pub(crate) fn joined(cache: &Path, welcome: live::wire::Welcome) -> io::Result<S
259261 Ok(location)
260262}
261263
264/// A peer to go to, since when, and the section and page already asked to open.
265type Going = (
266 [u8; 16],
267 std::time::Instant,
268 Option<(Option<[u8; 16]>, Option<live::Guid>)>,
269);
270
262271/// What a thread of its own sends the frame, and where the frame takes it.
263272pub(crate) type Channel<T> = (mpsc::Sender<T>, mpsc::Receiver<T>);
264273
......@@ -286,6 +295,10 @@ pub(crate) struct Peers {
286295 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,
287296 /// Where each peer's caret was last seen, and when it got there.
288297 moved: HashMap<[u8; 16], (Caret, std::time::Instant)>,
298 /// Where each peer was last seen, and when they last moved, for ordering the avatars.
299 active: HashMap<[u8; 16], (Option<live::Presence>, std::time::Instant)>,
300 /// The peer an avatar's click goes to, since when, and the place already asked to open.
301 going: Option<Going>,
289302 pub(crate) share: Option<crate::share::ShareDialog>,
290303 pub(crate) join: Option<crate::share::JoinDialog>,
291304}
......@@ -295,6 +308,7 @@ impl State {
295308 /// window is: in the shown notebook's room, and in each Live Share's.
296309 pub(crate) fn follow_peers(&mut self) {
297310 self.follow_shares();
311 self.go_to_peer();
298312 let shown = self
299313 .session
300314 .as_ref()
......@@ -587,23 +601,86 @@ impl State {
587601 pages
588602 }
589603
590 /// The others with the notebook open, as avatars leftward from the search box: a click
591 /// opens the page someone has open in this section.
604 /// The others with the notebook open, as avatars leftward from the search box, the most
605 /// lately active nearest it: past `STACKED` the rest are a chip that lists everyone. A
606 /// click goes to where someone is.
592607 pub(crate) fn avatars(&mut self) {
593 let peers = self.connected();
608 let mut peers = self.connected();
594609 if peers.is_empty() {
595610 return;
596611 }
612 let now = std::time::Instant::now();
613 for peer in &peers {
614 let id = peer.hello.peer;
615 let seen = self
616 .peers
617 .active
618 .entry(id)
619 .or_insert((peer.presence.clone(), now));
620 if seen.0 != peer.presence {
621 *seen = (peer.presence.clone(), now);
622 }
623 }
624 let active = |peer: &Peer| self.peers.active.get(&peer.hello.peer).map(|(_, at)| *at);
625 peers.sort_by_key(|peer| std::cmp::Reverse(active(peer)));
626 let shown = if peers.len() > STACKED + 1 {
627 STACKED
628 } else {
629 peers.len()
630 };
597631 self.ui.open(
598632 "peers",
599633 Spec {
600634 size: [children(), px(TAB_ROW)],
601635 pad: [6.0, (TAB_ROW - AVATAR) / 2.0],
602 gap: 4.0,
636 gap: -6.0,
603637 ..Spec::default()
604638 },
605639 );
606 for peer in peers.iter().rev() {
640 let mut chosen = None;
641 if peers.len() > shown {
642 let more = format!("+{}", peers.len() - shown);
643 let chip = self.ui.leaf(
644 "more",
645 Spec {
646 flags: Flags::CLICKABLE,
647 size: [fit(), px(AVATAR)],
648 text: Some(&more),
649 font_size: Some(10.0),
650 bold: true,
651 center: true,
652 fill: Some(self.ui.theme.chip),
653 radius: AVATAR / 2.0,
654 pad: [6.0, 0.0],
655 role: Some(accesskit::Role::Button),
656 ..Spec::default()
657 },
658 );
659 let label = format!("{} people here", peers.len());
660 ui::popup::tooltip(&mut self.ui, &label, "", None);
661 let menu = Id::ROOT.child("peers-menu");
662 if chip.clicked {
663 self.ui.open_popup(menu);
664 }
665 let places: Vec<String> = (peers.iter())
666 .map(|peer| self.place_of(peer).unwrap_or_default())
667 .collect();
668 let items: Vec<ui::popup::Item> = (peers.iter().zip(&places))
669 .map(|(peer, place)| ui::popup::Item {
670 text: &peer.hello.name,
671 shortcut: place,
672 ..ui::popup::Item::default()
673 })
674 .collect();
675 let rect = self.ui.rect(self.ui.id("more"));
676 if let Some(rect) = rect
677 && let Some(index) =
678 ui::popup::menu(&mut self.ui, menu, ui::Anchor::Below(rect), &items, None)
679 {
680 chosen = Some(peers[index].hello.peer);
681 }
682 }
683 for peer in peers[..shown].iter().rev() {
607684 let hello = &peer.hello;
608685 let picture = (self.peers.pictures)
609686 .entry(hello.peer)
......@@ -615,6 +692,8 @@ impl State {
615692 flags: Flags::CLICKABLE,
616693 size: [px(AVATAR); 2],
617694 fill: Some(color(&hello.peer)),
695 // Parted from the avatar it overlaps by the bar's own colour.
696 border: Some(self.ui.theme.strip),
618697 radius: AVATAR / 2.0,
619698 pad: [2.0, 2.0],
620699 role: Some(accesskit::Role::Button),
......@@ -649,19 +728,70 @@ impl State {
649728 }
650729 let place = self.place_of(peer);
651730 ui::popup::tooltip(&mut self.ui, &hello.name, "", place.as_deref());
652 if self.ui.signal(avatar).clicked
653 && let Some(session) = &self.session
654 && let Some(presence) = peer.presence.as_ref()
655 && presence.section == section(session)
656 && let Some(page) = presence.page
657 {
658 self.commands.push(Command::OpenPage(page.into()));
731 if self.ui.signal(avatar).clicked {
732 chosen = Some(hello.peer);
659733 }
660734 }
661735 if self.live_options.presence {
662736 self.seen();
663737 }
664738 self.ui.close();
739 if let Some(peer) = chosen {
740 self.peers.going = Some((peer, now, None));
741 }
742 }
743
744 /// Goes to where the peer an avatar's click chose is: their section, their page, then
745 /// their caret or selection in the middle of the view; given up after a few seconds.
746 fn go_to_peer(&mut self) -> Option<()> {
747 let (id, since, asked) = self.peers.going?;
748 if since.elapsed() > std::time::Duration::from_secs(10) {
749 self.peers.going = None;
750 return None;
751 }
752 let peer = self
753 .connected()
754 .into_iter()
755 .find(|peer| peer.hello.peer == id)?;
756 let presence = peer.presence?;
757 let session = self.session.as_ref()?;
758 let place = (presence.section, presence.page);
759 if presence.section != section(session) {
760 let tab = (session.tabs.iter())
761 .find(|tab| session.library.section_identity(&tab.path) == presence.section)?;
762 if asked != Some(place) {
763 let library = Arc::clone(&session.library);
764 self.commands
765 .push(Command::OpenSection(library, tab.path.clone()));
766 }
767 } else if presence.page != Some(session.space.into()) {
768 if asked != Some(place) {
769 self.commands.push(Command::OpenPage(presence.page?.into()));
770 }
771 } else {
772 self.peers.going = None;
773 let caret = presence.caret?;
774 let (outline, focus) = find(&self.view.editor, caret.focus)?;
775 let rect = outline
776 .caret_at(focus, parley::Affinity::Downstream, 0.0)
777 .ok()?;
778 let [x, y] = outline.origin();
779 let viewport = self.view.viewport;
780 let [width, height] = viewport.size.map(|side| side as f32);
781 let middle = ((rect.y0 + rect.y1) as f32 / 2.0 + y) * viewport.scale;
782 let mut responses = vec![self.view.scroll_to(1, middle - height / 2.0).ok()?];
783 let across = (rect.x0 as f32 + x) * viewport.scale + viewport.origin[0];
784 if !(0.0..width).contains(&across) {
785 let left = (rect.x0 as f32 + x) * viewport.scale - width / 2.0;
786 responses.push(self.view.scroll_to(0, left).ok()?);
787 }
788 for response in responses {
789 self.respond(response);
790 }
791 return Some(());
792 }
793 self.peers.going = Some((id, since, Some(place)));
794 Some(())
665795 }
666796
667797 /// The mark beside the avatars that says the others see this window too, which opens