feat(avatars): 6 selectable preset avatars over presence — W4 Phase 2
Adds bundled preset avatars on top of the monogram foundation. - New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded; 6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/ — swap for real designs later). +3 unit tests. - `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`); `AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join + new SetAvatar command) → every self-state announce, so it reaches the room incl. late joiners, and changing it mid-call re-announces live. - Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted. - Rendering: `avatar_view` draws the chosen preset image (iced `image` feature, now enabled) else the monogram, in the self card, peer rows, and chat (chat looks up the sender's avatar from presence by id). ⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the signature is recomputed by re-serializing the parsed struct, so a new PeerState field means old and new builds can't verify each other's presence — ALL peers must run a build >= this one (same as the S2 change). Redeploy dopedart before 2-machine testing. Build + clippy clean, 222 lib + integration tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+98
-3
@@ -163,6 +163,8 @@ pub enum AppMessage {
|
||||
SelectRoomLayout(RoomLayout),
|
||||
/// Choose a UI theme (applied live + persisted).
|
||||
SelectTheme(AppTheme),
|
||||
/// Choose our avatar (monogram or a preset); applied live + persisted (W4).
|
||||
SelectAvatar(crate::avatar::Avatar),
|
||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||
ToggleDrawerChat,
|
||||
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
|
||||
@@ -465,6 +467,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
input_device,
|
||||
output_device,
|
||||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||||
avatar: state.config.avatar.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -483,6 +486,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
input_device,
|
||||
output_device,
|
||||
echo_cancellation: state.config.echo_cancellation_enabled,
|
||||
avatar: state.config.avatar.clone(),
|
||||
});
|
||||
}
|
||||
AppMessage::LeavePressed => {
|
||||
@@ -780,6 +784,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.config.theme = theme;
|
||||
state.config.save();
|
||||
}
|
||||
AppMessage::SelectAvatar(avatar) => {
|
||||
state.config.avatar = avatar.clone();
|
||||
state.config.save();
|
||||
// Re-announce to the room if we're in a call (no-op otherwise).
|
||||
let _ = state.controller.send(CoreCommand::SetAvatar(avatar));
|
||||
}
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
@@ -1216,6 +1226,51 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill);
|
||||
|
||||
// Inline avatar chooser (W4): the monogram fallback plus the bundled
|
||||
// presets, each a clickable tile. Same SelectAvatar message, applied live
|
||||
// + persisted (and re-announced to the room).
|
||||
let avatar_choice = |a: crate::avatar::Avatar, label: String| -> Element<'_, AppMessage> {
|
||||
let selected = state.config.avatar == a;
|
||||
let preview = avatar_view(&a, &state.name, &state.self_id, 52.0);
|
||||
column![
|
||||
button(
|
||||
container(preview)
|
||||
.center_x(iced::Length::Fixed(60.0))
|
||||
.center_y(iced::Length::Fixed(60.0))
|
||||
)
|
||||
.on_press(AppMessage::SelectAvatar(a))
|
||||
.padding(2)
|
||||
.style(b_style(
|
||||
if selected { color_surface } else { Color::TRANSPARENT },
|
||||
color_surface,
|
||||
color_text,
|
||||
8.0,
|
||||
)),
|
||||
text(label)
|
||||
.size(11)
|
||||
.color(if selected { color_blue } else { color_subtext }),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
};
|
||||
let mut avatar_tiles: Vec<Element<'_, AppMessage>> =
|
||||
vec![avatar_choice(crate::avatar::Avatar::Monogram, "Monogram".to_string())];
|
||||
for i in 0..crate::avatar::PRESET_COUNT {
|
||||
avatar_tiles.push(avatar_choice(
|
||||
crate::avatar::Avatar::Preset(i),
|
||||
format!("Preset {}", i + 1),
|
||||
));
|
||||
}
|
||||
let avatar_section = column![
|
||||
iced::widget::Row::with_children(avatar_tiles).spacing(12),
|
||||
text("Shown next to your name in the room and chat. Applies live.")
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
]
|
||||
.spacing(10)
|
||||
.width(iced::Length::Fill);
|
||||
|
||||
// Reusable category header: a coloured title with a thin full-width
|
||||
// divider beneath, so each class of settings reads as its own section.
|
||||
let section_header = |title: &'static str| -> Element<'_, AppMessage> {
|
||||
@@ -1335,6 +1390,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
theme_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Avatar ---
|
||||
section_header("Avatar"),
|
||||
avatar_section,
|
||||
vertical_space(section_gap),
|
||||
|
||||
// --- Notifications & Sounds ---
|
||||
section_header("Notifications & Sounds"),
|
||||
column![
|
||||
@@ -1602,7 +1662,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let self_card = container(
|
||||
column![
|
||||
row![
|
||||
avatar_badge(&state.name, &state.self_id, 34.0),
|
||||
avatar_view(&state.config.avatar, &state.name, &state.self_id, 34.0),
|
||||
text(format!("{} (You)", &state.name)).size(16).color(color_text),
|
||||
horizontal_space(),
|
||||
if state.is_muted {
|
||||
@@ -1729,7 +1789,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
let mut card_content = column![
|
||||
row![
|
||||
avatar_badge(&peer.name, &peer_id.to_string(), 38.0),
|
||||
avatar_view(&peer.avatar, &peer.name, &peer_id.to_string(), 38.0),
|
||||
column![
|
||||
text(&peer.name).size(16).color(color_text),
|
||||
text(format!("ID: {}", short_id(&peer_id.to_string()))).size(11).color(color_subtext)
|
||||
@@ -1940,11 +2000,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.width(iced::Length::Fill);
|
||||
// Small avatar keyed on the sender's id (falls back to name); the
|
||||
// " (You)" suffix on our own echoes is stripped for clean initials.
|
||||
// Resolve the sender's chosen avatar: our own from config, a peer's
|
||||
// from their current presence (looked up by id), else monogram.
|
||||
let av_key = m.from.as_deref().unwrap_or(m.name.as_str());
|
||||
let av_name = m.name.split(" (").next().unwrap_or(m.name.as_str());
|
||||
let av = if m.mine {
|
||||
state.config.avatar.clone()
|
||||
} else {
|
||||
m.from
|
||||
.as_deref()
|
||||
.and_then(|f| {
|
||||
state
|
||||
.peers
|
||||
.iter()
|
||||
.find(|(k, _)| k.to_string() == f)
|
||||
.map(|(_, v)| v.avatar.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
chat_col = chat_col.push(
|
||||
row![
|
||||
avatar_badge(av_name, av_key, 22.0),
|
||||
avatar_view(&av, av_name, av_key, 22.0),
|
||||
text(format!("{}:", m.name)).size(12).color(name_color),
|
||||
body,
|
||||
]
|
||||
@@ -2781,6 +2857,25 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render a participant's avatar (W4): the chosen preset image if any, else the
|
||||
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
|
||||
fn avatar_view<'a>(
|
||||
avatar: &crate::avatar::Avatar,
|
||||
name: &str,
|
||||
key: &str,
|
||||
size: f32,
|
||||
) -> Element<'a, AppMessage> {
|
||||
match avatar.preset_png() {
|
||||
Some(png) => iced::widget::image(
|
||||
iced::widget::image::Handle::from_bytes(bytes::Bytes::from_static(png)),
|
||||
)
|
||||
.width(iced::Length::Fixed(size))
|
||||
.height(iced::Length::Fixed(size))
|
||||
.into(),
|
||||
None => avatar_badge(name, key, size),
|
||||
}
|
||||
}
|
||||
|
||||
/// Centered "icon + label" content for a full-width control-panel button. The
|
||||
/// icon takes the button's foreground colour so it matches the label.
|
||||
fn btn_content<'a>(kind: IconKind, label: &'a str, color: Color) -> Element<'a, AppMessage> {
|
||||
|
||||
Reference in New Issue
Block a user