From 3fb431a0b806902c80c668112af0e0c8015abb6c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 28 Oct 2026 20:21:59 +0700 Subject: [PATCH] add avatar --- crates/core/src/avatar.rs | 309 +++++++++++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + docs/PLAN.md | 6 + examples/gallery/src/main.rs | 27 +++ 4 files changed, 345 insertions(+) create mode 100644 crates/core/src/avatar.rs diff --git a/crates/core/src/avatar.rs b/crates/core/src/avatar.rs new file mode 100644 index 0000000..f701c18 --- /dev/null +++ b/crates/core/src/avatar.rs @@ -0,0 +1,309 @@ +use std::fmt::Display; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, Refineable as _, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, + Window, div, +}; +use gpui_native_theme::{ActiveTheme as _, Oklch, ThemeVariant}; + +use crate::common::h_flex; + +/// Number of rows and columns in the pixel grid. +const GRID_SIZE: usize = 8; +/// Empty cells kept between the pattern and the avatar edge. +const MARGIN: usize = 1; +/// Probability that a cell in the left half of the pattern area is filled. +const FILL_PROBABILITY: f32 = 0.42; +/// Probability that a filled cell uses the shade instead of the main color. +const SHADE_PROBABILITY: f32 = 0.25; +/// Minimum number of filled left-half cells. +const MIN_FILLED: usize = 5; + +/// An avatar for a user or other entity. +#[derive(IntoElement)] +pub struct Avatar { + id: SharedString, + name: Option, + picture: Option, + style: StyleRefinement, +} + +impl Avatar { + /// Create an avatar for `id`, a public key or any other stable string. + /// + /// The id seeds the pixel fallback shown when no picture is set. + pub fn new(id: T) -> Self + where + T: Display, + { + Self { + id: id.to_string().into(), + name: None, + picture: None, + style: StyleRefinement::default(), + } + } + + /// The display name of the avatar's subject, if known. + pub fn name(mut self, name: N) -> Self + where + N: Into, + { + self.name = Some(name.into()); + self + } + + /// The user's picture URL, if known. `None` clears it. + pub fn picture

(mut self, picture: Option

) -> Self + where + P: Into, + { + self.picture = picture.map(Into::into); + self + } +} + +impl gpui::Styled for Avatar { + fn style(&mut self) -> &mut gpui::StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Avatar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let radius = theme.radius.control; + + let mut base = h_flex() + .id(ElementId::from(self.id.clone())) + .flex_none() + .overflow_hidden() + .bg(theme.colors.secondary) + .rounded(radius) + .when_some( + self.name.clone().or(Some(self.id.clone())), + |avatar, label| avatar.role(Role::Image).aria_label(label), + ); + + // Apply custom styles + base.style().refine(&self.style); + + match self.picture { + Some(url) => base.size_8().child( + gpui::img(gpui::SharedUri::from(url)) + .size_full() + .rounded(radius), + ), + None => base.size_8().child(PixelAvatar { + seed: fnv1a(self.id.as_bytes()), + style: self.style, + }), + } + .into_any_element() + } +} + +/// A deterministic, offline pixel-art avatar. +#[derive(IntoElement)] +struct PixelAvatar { + seed: u64, + style: StyleRefinement, +} + +impl gpui::Styled for PixelAvatar { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for PixelAvatar { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let radius = theme.radius.control; + let pattern = pattern(self.seed); + let mut cells = Vec::new(); + + let hue = (self.seed as f32 / u64::MAX as f32) * 360.0; + let accent = Oklch::from_hsla(theme.colors.accent); + let main = Oklch { h: hue, ..accent }.to_hsla(); + + let shade = if theme.variant == ThemeVariant::Dark { + Oklch { + l: (accent.l * 1.6).min(0.95), + ..Oklch::from_hsla(main) + } + .to_hsla() + } else { + Oklch { + l: (accent.l * 0.45).max(0.18), + ..Oklch::from_hsla(main) + } + .to_hsla() + }; + + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE { + let value = pattern[row * GRID_SIZE + col]; + if value != 0 { + let color = if value == 2 { shade } else { main }; + cells.push( + div() + .row_start(row as i16 + 1) + .row_end(row as i16 + 2) + .col_start(col as i16 + 1) + .col_end(col as i16 + 2) + .bg(color), + ); + } + } + } + + let mut grid = div() + .grid() + .grid_cols(GRID_SIZE as u16) + .grid_rows(GRID_SIZE as u16) + .size_8() + .flex_none() + .rounded(radius) + .overflow_hidden() + .bg(gpui::Hsla { a: 0.16, ..main }) + .children(cells); + grid.style().refine(&self.style); + grid + } +} + +fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] { + let mut rng = PixelRng::new(seed); + let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; + let mut filled = 0usize; + + let art_rows = GRID_SIZE - 2 * MARGIN; + let art_columns = GRID_SIZE / 2 - MARGIN; + + for row in MARGIN..GRID_SIZE - MARGIN { + for col in MARGIN..GRID_SIZE / 2 { + if rng.chance(FILL_PROBABILITY) { + let shade = rng.chance(SHADE_PROBABILITY); + set_cell(&mut pattern, row, col, if shade { 2 } else { 1 }); + filled += 1; + } + } + } + + if filled < MIN_FILLED { + let total = art_rows * art_columns; + let start = (rng.next() % total as u64) as usize; + + for offset in 0..total { + if filled >= MIN_FILLED { + break; + } + + let ix = (start + offset) % total; + let row = MARGIN + ix / art_columns; + let col = MARGIN + ix % art_columns; + + if pattern[row * GRID_SIZE + col] == 0 { + set_cell(&mut pattern, row, col, 1); + filled += 1; + } + } + } + + pattern +} + +/// Fill `cell (row, col)` and its horizontal mirror. +fn set_cell(pattern: &mut [u8; GRID_SIZE * GRID_SIZE], row: usize, col: usize, value: u8) { + pattern[row * GRID_SIZE + col] = value; + pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)] = value; +} + +/// FNV-1a 64-bit hash, stable across platforms and runs. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325u64; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// Tiny xorshift64* PRNG for deriving the pattern from the seed. +struct PixelRng(u64); + +impl PixelRng { + fn new(seed: u64) -> Self { + Self(seed.max(1)) + } + + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.0 = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn chance(&mut self, probability: f32) -> bool { + self.next() as f32 / (u64::MAX as f32) < probability + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_pattern_is_mirror_symmetric() { + for seed in [0u64, 1, 42, u64::MAX] { + let pattern = pattern(seed); + for row in 0..GRID_SIZE { + for col in 0..GRID_SIZE / 2 { + assert_eq!( + pattern[row * GRID_SIZE + col], + pattern[row * GRID_SIZE + (GRID_SIZE - 1 - col)], + "seed {seed} row {row} col {col}" + ); + } + } + } + } + + #[test] + fn the_pattern_keeps_the_outer_ring_empty() { + for seed in [0u64, 1, 42, u64::MAX] { + let pattern = pattern(seed); + for col in 0..GRID_SIZE { + assert_eq!(pattern[col], 0, "top row, seed {seed}"); + assert_eq!(pattern[(GRID_SIZE - 1) * GRID_SIZE + col], 0, "bottom row"); + } + for row in 0..GRID_SIZE { + assert_eq!(pattern[row * GRID_SIZE], 0, "left column, seed {seed}"); + assert_eq!(pattern[row * GRID_SIZE + GRID_SIZE - 1], 0, "right column"); + } + } + } + + #[test] + fn the_pattern_is_deterministic_and_fills_the_minimum() { + for seed in [0u64, 7, 42, u64::MAX] { + let first = pattern(seed); + assert_eq!(first, pattern(seed), "seed {seed} must be stable"); + + // Mirrored cells count twice, so both halves carry the minimum. + let filled = first.iter().filter(|cell| **cell != 0).count(); + assert!(filled >= MIN_FILLED * 2, "seed {seed} filled {filled}"); + } + } + + #[test] + fn fnv1a_is_stable_across_calls() { + assert_eq!(fnv1a(b"reya"), fnv1a(b"reya")); + assert_ne!(fnv1a(b"reya"), fnv1a(b"signed")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index b68e690..6d5f9a6 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -25,3 +25,6 @@ pub mod slider; #[cfg(feature = "progress")] pub mod progress; + +#[cfg(feature = "avatar")] +pub mod avatar; diff --git a/docs/PLAN.md b/docs/PLAN.md index 5e465fd..6c0bf19 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -215,6 +215,12 @@ Each row is one review unit: build it, show it in the gallery, review, then move Deferred until a real need: Markdown/TextView, Dock, VirtualList, window-wide text selection, a motion system (use gpui's built-in animations first), charts. +### 6.1 After the core units: sizing API + +Once the core components are done (units 1–10), add a **`Sizable` trait** so applications can customize component size, following gpui-kit's shape: a `Size` enum (`XSmall` / `Small` / `Medium` default / `Large`, plus a custom `Pixels` variant) and `with_size(impl Into)` with `xsmall()` / `small()` / `large()` conveniences. + +Our divergence from gpui-kit: the per-size metrics (control heights, paddings, text sizes, knob/indicator diameters) resolve from the **theme**, not from constants in the component layer — they are native-metric values (§5.5) and every component already reads its metrics through tokens. Implementation shape: the theme gains a size-class table (`Size` → control height/padding/text metrics), and each control implements `Sizable` by reading that table at paint time. Retrofits land one component per unit (Button first, as the API template) with the trait living in the ungated `common` module. + ## 7. Milestones | Phase | Scope | Acceptance | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 2e189b6..a8e75d4 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -4,6 +4,7 @@ use gpui::{ ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, WindowBounds, WindowOptions, div, px, size, }; +use gpui_native_core::avatar::Avatar; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{h_flex, v_flex}; @@ -409,6 +410,32 @@ impl Render for Gallery { .accessibility_label("Working"), ), ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label( + "avatar · native refs — NSImage (contact), WinUI PersonPicture, Adwaita Avatar, Breeze", + colors, + )) + .child( + h_flex() + .gap_3() + .child( + Avatar::new("reya@reya.info") + .name("Reya Adele") + .picture(Some("https://github.com/reya.png")), + ) + .child( + Avatar::new("ada@reya.info") + .name("Ada Lovelace") + .picture(Some("https://example.com/missing.png")), + ) + .child(Avatar::new("grace@reya.info").name("Grace Hopper")) + .child(Avatar::new("bot@reya.info")), + ), + ) .child( div() .flex()