add avatar

This commit is contained in:
2026-10-28 20:21:59 +07:00
parent 9626391403
commit 3fb431a0b8
4 changed files with 345 additions and 0 deletions
+309
View File
@@ -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<SharedString>,
picture: Option<SharedString>,
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<T>(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<N>(mut self, name: N) -> Self
where
N: Into<SharedString>,
{
self.name = Some(name.into());
self
}
/// The user's picture URL, if known. `None` clears it.
pub fn picture<P>(mut self, picture: Option<P>) -> Self
where
P: Into<SharedString>,
{
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"));
}
}
+3
View File
@@ -25,3 +25,6 @@ pub mod slider;
#[cfg(feature = "progress")] #[cfg(feature = "progress")]
pub mod progress; pub mod progress;
#[cfg(feature = "avatar")]
pub mod avatar;
+6
View File
@@ -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. 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<Size>)` 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 ## 7. Milestones
| Phase | Scope | Acceptance | | Phase | Scope | Acceptance |
+27
View File
@@ -4,6 +4,7 @@ use gpui::{
ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window, ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, Window,
WindowBounds, WindowOptions, div, px, size, WindowBounds, WindowOptions, div, px, size,
}; };
use gpui_native_core::avatar::Avatar;
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::checkbox::Checkbox; use gpui_native_core::checkbox::Checkbox;
use gpui_native_core::common::{h_flex, v_flex}; use gpui_native_core::common::{h_flex, v_flex};
@@ -409,6 +410,32 @@ impl Render for Gallery {
.accessibility_label("Working"), .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( .child(
div() div()
.flex() .flex()