authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 21:55:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-03 05:26:36-07:00
logb528713ecc49c4ad7d46c039ac7d0acc2f9e72fe
treec21c0d13727f10299cd874c799d637938327d980
parent9e7bdc20de0836d4c71aaf9014f4130ec523a635
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: a peer's name sits on their caret while they type, then fades to a capped caret

The flag naming a peer floated above their caret as a box apart. It now sits flush on the caret, its corner there square, so caret and flag read as one shape, as in Google Docs or Figma. It shows while the caret moves and for three seconds after, then fades, leaving the caret with a small cap; hovering the caret brings the name back. At the page's top it hangs below the caret. Assisted-by: claude-opus-5.5

1 files changed, 72 insertions(+), 18 deletions(-)

crates/snowbound/src/live.rs+72-18
......@@ -20,13 +20,15 @@ use std::{
2020 path::{Path, PathBuf},
2121 sync::{Arc, Mutex, OnceLock, Weak, mpsc},
2222};
23use ui::{Flags, Spec, children, fill, fit, px};
23use ui::{Flags, Id, Spec, children, fill, fit, px};
2424
2525/// The side of the picture sent, in pixels.
2626const PICTURE: u32 = 96;
2727const AVATAR: f32 = 22.0;
28/// The name flag above a caret.
28/// The name flag above a caret, and how long it shows after the caret last moved.
2929const FLAG: f32 = 15.0;
30const FLAG_PAD: f32 = 4.0;
31const NAMED: std::time::Duration = std::time::Duration::from_secs(3);
3032/// The relay used where Options names none.
3133pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";
3234/// How long opening a notebook joined for the first time waits for the computer sharing it.
......@@ -263,6 +265,8 @@ pub(crate) struct Peers {
263265 reached: HashMap<String, bool>,
264266 /// Each peer's picture as drawn, cut to a circle, decoded once.
265267 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,
268 /// Where each peer's caret was last seen, and when it got there.
269 moved: HashMap<[u8; 16], (Caret, std::time::Instant)>,
266270 pub(crate) share: Option<crate::share::ShareDialog>,
267271 pub(crate) join: Option<crate::share::JoinDialog>,
268272}
......@@ -768,8 +772,39 @@ impl State {
768772 if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top {
769773 continue;
770774 }
775 let id = peer.hello.peer;
776 let now = std::time::Instant::now();
777 let moved = match self.peers.moved.get(&id) {
778 Some((at, when)) if *at == caret => *when,
779 _ => {
780 self.peers.moved.insert(id, (caret, now));
781 now
782 }
783 };
784 let quiet = now.saturating_duration_since(moved);
785 if quiet < NAMED {
786 self.ui.wake_after(NAMED - quiet);
787 }
788 let name = (peer.hello.name.split_whitespace().next()).unwrap_or("Someone");
789 // Below the caret where the page's top would cut the flag off.
790 let below = y0 < FLAG;
791 let flag_top = if below { y1 } else { y0 - FLAG };
792 // About the flag's width, for hovering it.
793 let width = FLAG_PAD * 2.0 + self.ui.measure(name)[0] * 10.0 / self.ui.theme.font_size;
794 let hovered = self.ui.pointer().is_some_and(|[px, py]| {
795 let [px, py] = [px - left, py - top];
796 (x0 - 4.0..=x0 + 4.0).contains(&px) && (y0..=y1).contains(&py)
797 || (x0 - 1.0..=x0 - 1.0 + width).contains(&px)
798 && (flag_top..=flag_top + FLAG).contains(&py)
799 });
800 let shown_flag = self.ui.animate(
801 Id::ROOT.child((id, "flag-shown")),
802 if quiet < NAMED || hovered { 1.0 } else { 0.0 },
803 );
804 let faded =
805 |[red, green, blue, alpha]: [f32; 4], by: f32| [red, green, blue, alpha * by];
771806 self.ui.leaf(
772 (peer.hello.peer, "caret"),
807 (id, "caret"),
773808 Spec {
774809 flags: Flags::FLOAT,
775810 position: [x0 - 1.0, y0],
......@@ -778,28 +813,47 @@ impl State {
778813 ..Spec::default()
779814 },
780815 );
781 let name = peer
782 .hello
783 .name
784 .split_whitespace()
785 .next()
786 .unwrap_or("Someone");
816 // The caret's cap, where its name is not shown.
787817 self.ui.leaf(
788 (peer.hello.peer, "flag"),
818 (id, "cap"),
789819 Spec {
790820 flags: Flags::FLOAT,
791 position: [x0 - 1.0, y0 - FLAG],
792 size: [fit(), px(FLAG)],
793 text: Some(name),
794 font_size: Some(10.0),
795 bold: true,
796 color: Some([1.0; 4]),
797 fill: Some(color),
821 position: [x0 - 3.0, if below { y1 - 3.0 } else { y0 - 3.0 }],
822 size: [px(6.0), px(6.0)],
823 fill: Some(faded(color, 1.0 - shown_flag)),
798824 radius: 3.0,
799 pad: [4.0, 0.0],
800825 ..Spec::default()
801826 },
802827 );
828 if shown_flag > 0.0 {
829 self.ui.leaf(
830 (id, "flag"),
831 Spec {
832 flags: Flags::FLOAT,
833 position: [x0 - 1.0, flag_top],
834 size: [fit(), px(FLAG)],
835 text: Some(name),
836 font_size: Some(10.0),
837 bold: true,
838 color: Some(faded([1.0; 4], shown_flag)),
839 fill: Some(faded(color, shown_flag)),
840 radius: 3.0,
841 pad: [FLAG_PAD, 0.0],
842 ..Spec::default()
843 },
844 );
845 // The corner on the caret square, so flag and caret are one shape.
846 self.ui.leaf(
847 (id, "joint"),
848 Spec {
849 flags: Flags::FLOAT,
850 position: [x0 - 1.0, if below { y1 } else { y0 - 3.0 }],
851 size: [px(3.0), px(3.0)],
852 fill: Some(faded(color, shown_flag)),
853 ..Spec::default()
854 },
855 );
856 }
803857 }
804858 }
805859}