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::{...@@ -20,13 +20,15 @@ use std::{
20 path::{Path, PathBuf},20 path::{Path, PathBuf},
21 sync::{Arc, Mutex, OnceLock, Weak, mpsc},21 sync::{Arc, Mutex, OnceLock, Weak, mpsc},
22};22};
23use ui::{Flags, Spec, children, fill, fit, px};23use ui::{Flags, Id, Spec, children, fill, fit, px};
2424
25/// The side of the picture sent, in pixels.25/// The side of the picture sent, in pixels.
26const PICTURE: u32 = 96;26const PICTURE: u32 = 96;
27const AVATAR: f32 = 22.0;27const 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.
29const FLAG: f32 = 15.0;29const FLAG: f32 = 15.0;
30const FLAG_PAD: f32 = 4.0;
31const NAMED: std::time::Duration = std::time::Duration::from_secs(3);
30/// The relay used where Options names none.32/// The relay used where Options names none.
31pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";33pub(crate) const DEFAULT_RELAY: &str = "wss://relay.snowbound.paperclover.net";
32/// How long opening a notebook joined for the first time waits for the computer sharing it.34/// How long opening a notebook joined for the first time waits for the computer sharing it.
...@@ -263,6 +265,8 @@ pub(crate) struct Peers {...@@ -263,6 +265,8 @@ pub(crate) struct Peers {
263 reached: HashMap<String, bool>,265 reached: HashMap<String, bool>,
264 /// Each peer's picture as drawn, cut to a circle, decoded once.266 /// Each peer's picture as drawn, cut to a circle, decoded once.
265 pictures: HashMap<[u8; 16], Option<draw::RasterImage>>,267 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)>,
266 pub(crate) share: Option<crate::share::ShareDialog>,270 pub(crate) share: Option<crate::share::ShareDialog>,
267 pub(crate) join: Option<crate::share::JoinDialog>,271 pub(crate) join: Option<crate::share::JoinDialog>,
268}272}
...@@ -768,8 +772,39 @@ impl State {...@@ -768,8 +772,39 @@ impl State {
768 if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top {772 if !(0.0..right - left).contains(&x0) || y1 < 0.0 || y0 > bottom - top {
769 continue;773 continue;
770 }774 }
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];
771 self.ui.leaf(806 self.ui.leaf(
772 (peer.hello.peer, "caret"),807 (id, "caret"),
773 Spec {808 Spec {
774 flags: Flags::FLOAT,809 flags: Flags::FLOAT,
775 position: [x0 - 1.0, y0],810 position: [x0 - 1.0, y0],
...@@ -778,28 +813,47 @@ impl State {...@@ -778,28 +813,47 @@ impl State {
778 ..Spec::default()813 ..Spec::default()
779 },814 },
780 );815 );
781 let name = peer816 // The caret's cap, where its name is not shown.
782 .hello
783 .name
784 .split_whitespace()
785 .next()
786 .unwrap_or("Someone");
787 self.ui.leaf(817 self.ui.leaf(
788 (peer.hello.peer, "flag"),818 (id, "cap"),
789 Spec {819 Spec {
790 flags: Flags::FLOAT,820 flags: Flags::FLOAT,
791 position: [x0 - 1.0, y0 - FLAG],821 position: [x0 - 3.0, if below { y1 - 3.0 } else { y0 - 3.0 }],
792 size: [fit(), px(FLAG)],822 size: [px(6.0), px(6.0)],
793 text: Some(name),823 fill: Some(faded(color, 1.0 - shown_flag)),
794 font_size: Some(10.0),
795 bold: true,
796 color: Some([1.0; 4]),
797 fill: Some(color),
798 radius: 3.0,824 radius: 3.0,
799 pad: [4.0, 0.0],
800 ..Spec::default()825 ..Spec::default()
801 },826 },
802 );827 );
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 }
803 }857 }
804 }858 }
805}859}