From f4d3ad9d7f86c4a0e511e4c2a218529781f8443a Mon Sep 17 00:00:00 2001 From: Mollusk Date: Sat, 6 Jun 2026 17:14:25 -0400 Subject: [PATCH] feat(ui): canvas-drawn icon set replacing emoji MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an 18-icon set drawn on iced canvas (no image/font dep, recolors with the theme) — consistent with the existing LayoutThumb/GateMeter/Divider widgets. Icons: mic, mic-off, headphones, deafen, speaker, speaker-off, monitor (share), eye (watch), record, stop, chat, people, clock, settings, copy, leave, create, live. Each authored in a 24x24 space, scaled to the widget, stroked with round caps/joins. Wired throughout the UI in place of emoji + added to the icon-less primary buttons (mute/deafen/leave/copy/settings/create): self + peer cards (live badge, watch, per-peer speaker mute), header (participants/timer/REC), controls (mute/deafen/record/share/leave), launch (create/settings), settings (test mic), drawer chat toggle. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 427 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 357 insertions(+), 70 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 5f638be..2496e5e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -901,7 +901,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .on_press(AppMessage::OpenLayoutPicker) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8), - button(text("⚙ Settings").size(14)) + button( + row![ + icon(IconKind::Settings, 15.0, color_text), + text("Settings").size(14), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center) + ) .on_press(AppMessage::NavigateToSettings) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(8) @@ -937,12 +944,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let mic_test_control: Element<'_, AppMessage> = if in_call { text("Live (in call)").size(11).color(color_green).into() } else { - let (label, bg) = if state.mic_test_active { - ("⏹ Stop mic test", color_red) + let (mic_kind, label, bg) = if state.mic_test_active { + (IconKind::Stop, "Stop mic test", color_red) } else { - ("🎙 Test mic", color_surface) + (IconKind::Mic, "Test mic", color_surface) }; - button(text(label).size(12)) + button( + row![ + icon(mic_kind, 13.0, color_text), + text(label).size(12), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center) + ) .on_press(AppMessage::ToggleMicTest(!state.mic_test_active)) .style(b_style(bg, color_blue, color_text, 6.0)) .padding(6) @@ -1153,11 +1167,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .padding(10) ]; - let create_btn = button( - text("Create New Room") - .size(16) - .align_x(iced::alignment::Horizontal::Center) - ) + let create_btn = button(btn_content(IconKind::Create, "Create New Room", color_crust)) .on_press(AppMessage::CreatePressed) .style(b_style(color_blue, color_lavender, color_crust, 8.0)) .padding(12) @@ -1231,18 +1241,27 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .size(20) .color(color_blue), horizontal_space(), - text(format!("👥 {participant_count} in room")) - .size(14) - .color(color_subtext), - text(format!("⏱ {}", format_duration(call_secs))) - .size(14) - .color(color_subtext), + row![ + icon(IconKind::People, 15.0, color_subtext), + text(format!("{participant_count} in room")).size(14).color(color_subtext), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), + row![ + icon(IconKind::Clock, 15.0, color_subtext), + text(format_duration(call_secs)).size(14).color(color_subtext), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), if state.recording { let rec_secs = state.recording_started.map(|t| t.elapsed().as_secs()).unwrap_or(0); container( - text(format!("● REC {}", format_duration(rec_secs))) - .size(13) - .color(color_red) + row![ + icon(IconKind::Record, 12.0, color_red), + text(format!("REC {}", format_duration(rec_secs))).size(13).color(color_red), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center) ) .style(c_style(color_crust, color_red, 6.0)) .padding(6) @@ -1253,7 +1272,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text(format!("My ID: {}", &state.self_id[..8])) .size(14) .color(color_subtext), - button(text("Copy Ticket").size(12)) + button( + row![ + icon(IconKind::Copy, 14.0, color_text), + text("Copy Ticket").size(12), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center) + ) .on_press(AppMessage::CopyToClipboard) .style(b_style(color_surface, color_blue, color_text, 6.0)) .padding(6), @@ -1262,11 +1288,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let el: Element<'_, AppMessage> = if state.config.room_layout == RoomLayout::Drawer { let (lbl, bg, fg) = if state.drawer_chat_open { - ("💬 Hide chat", color_blue, color_crust) + ("Hide chat", color_blue, color_crust) } else { - ("💬 Chat", color_surface, color_text) + ("Chat", color_surface, color_text) }; - button(text(lbl).size(12)) + button( + row![ + icon(IconKind::Chat, 14.0, fg), + text(lbl).size(12), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center) + ) .on_press(AppMessage::ToggleDrawerChat) .style(b_style(bg, color_blue, fg, 6.0)) .padding(6) @@ -1307,7 +1340,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Live "you're sharing" badge — only present while sharing. { let el: Element<'_, AppMessage> = if state.self_sharing { - text("🔴 Sharing your screen").size(13).color(color_red).into() + row![ + icon(IconKind::Live, 14.0, color_red), + text("Sharing your screen").size(13).color(color_red), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center) + .into() } else { iced::widget::Space::new().width(0.0).height(0.0).into() }; @@ -1350,12 +1389,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let is_locally_muted = state.locally_muted.contains(peer_id); // Local-mute toggle (silences this peer for us only). - let (mute_label, mute_bg, mute_fg) = if is_locally_muted { - ("🔇", color_red, color_crust) + let (mute_kind, mute_bg, mute_fg) = if is_locally_muted { + (IconKind::SpeakerOff, color_red, color_crust) } else { - ("🔊", color_surface, color_text) + (IconKind::Speaker, color_surface, color_text) }; - let mute_btn = button(text(mute_label).size(14)) + let mute_btn = button(icon(mute_kind, 16.0, mute_fg)) .on_press(AppMessage::TogglePeerMute(peer_id_clone)) .style(b_style(mute_bg, color_blue, mute_fg, 6.0)) .padding(6); @@ -1363,14 +1402,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { // Screen-share "Live" badge + Watch button when this peer is sharing. // Watch is enabled only if pixelpass is installed locally. let share_el: Element<'_, AppMessage> = if let Some(ticket) = peer.sharing.clone() { - let mut watch_btn = button(text("👁 Watch").size(13)) - .style(b_style(color_blue, color_lavender, color_crust, 6.0)) - .padding(6); + let mut watch_btn = button( + row![ + icon(IconKind::Eye, 14.0, color_crust), + text("Watch").size(13), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), + ) + .style(b_style(color_blue, color_lavender, color_crust, 6.0)) + .padding(6); if state.pixelpass_available { watch_btn = watch_btn.on_press(AppMessage::WatchShare(ticket)); } row![ - text("🔴 Live").size(13).color(color_red), + row![ + icon(IconKind::Live, 13.0, color_red), + text("Live").size(13).color(color_red), + ] + .spacing(5) + .align_y(iced::alignment::Vertical::Center), watch_btn, ] .spacing(6) @@ -1456,22 +1507,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { let deafen_hover = if state.is_deafened { color_maroon } else { color_blue }; let deafen_fg = if state.is_deafened { color_crust } else { color_text }; + let mute_kind = if state.is_muted { IconKind::MicOff } else { IconKind::Mic }; + let deafen_kind = if state.is_deafened { IconKind::Deafen } else { IconKind::Headphones }; let ctrl_buttons = column![ - button( - text(mute_text) - .size(16) - .align_x(iced::alignment::Horizontal::Center) - ) + button(btn_content(mute_kind, mute_text, mute_fg)) .on_press(AppMessage::ToggleMutePressed) .style(b_style(mute_bg, mute_hover, mute_fg, 8.0)) .padding(14) .width(iced::Length::Fill), vertical_space(10.0), - button( - text(deafen_text) - .size(16) - .align_x(iced::alignment::Horizontal::Center) - ) + button(btn_content(deafen_kind, deafen_text, deafen_fg)) .on_press(AppMessage::ToggleDeafenPressed) .style(b_style(deafen_bg, deafen_hover, deafen_fg, 8.0)) .padding(14) @@ -1493,16 +1538,12 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { }, vertical_space(20.0), { - let (rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { - ("⏹ Stop Recording", color_red, color_maroon, color_crust) + let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { + (IconKind::Stop, "Stop Recording", color_red, color_maroon, color_crust) } else { - ("⏺ Record Call", color_surface, color_blue, color_text) + (IconKind::Record, "Record Call", color_surface, color_blue, color_text) }; - button( - text(rec_label) - .size(16) - .align_x(iced::alignment::Horizontal::Center) - ) + button(btn_content(rec_kind, rec_label, rec_fg)) .on_press(AppMessage::ToggleRecording) .style(b_style(rec_bg, rec_hover, rec_fg, 8.0)) .padding(14) @@ -1512,32 +1553,25 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { { // Screen share. Disabled (no on_press) when pixelpass is absent, // with the label saying so — a normal, handled state. - let (share_label, share_bg, share_hover, share_fg) = if !state.pixelpass_available { - ("🖥 Needs pixelpass", color_surface, color_surface, color_subtext) - } else if state.self_sharing { - ("🛑 Stop Sharing", color_red, color_maroon, color_crust) - } else { - ("🖥 Share Screen", color_surface, color_blue, color_text) - }; - let mut share_btn = button( - text(share_label) - .size(16) - .align_x(iced::alignment::Horizontal::Center), - ) - .style(b_style(share_bg, share_hover, share_fg, 8.0)) - .padding(14) - .width(iced::Length::Fill); + let (share_kind, share_label, share_bg, share_hover, share_fg) = + if !state.pixelpass_available { + (IconKind::Monitor, "Needs pixelpass", color_surface, color_surface, color_subtext) + } else if state.self_sharing { + (IconKind::Stop, "Stop Sharing", color_red, color_maroon, color_crust) + } else { + (IconKind::Monitor, "Share Screen", color_surface, color_blue, color_text) + }; + let mut share_btn = button(btn_content(share_kind, share_label, share_fg)) + .style(b_style(share_bg, share_hover, share_fg, 8.0)) + .padding(14) + .width(iced::Length::Fill); if state.pixelpass_available { share_btn = share_btn.on_press(AppMessage::ToggleScreenShare); } share_btn }, vertical_space(30.0), - button( - text("Leave Room") - .size(16) - .align_x(iced::alignment::Horizontal::Center) - ) + button(btn_content(IconKind::Leave, "Leave Room", color_crust)) .on_press(AppMessage::LeavePressed) .style(b_style(color_red, color_maroon, color_crust, 8.0)) .padding(14) @@ -2186,6 +2220,259 @@ impl Program for LayoutThumb { } } +/// The PeerSpeak icon set, drawn on a `canvas` so it needs no image/font asset +/// and recolors with the theme (consistent with `LayoutThumb`/`GateMeter`). +/// Each icon is authored in a 24×24 space and scaled to the widget size. +#[derive(Clone, Copy, PartialEq)] +enum IconKind { + Mic, + MicOff, + Headphones, + Deafen, + Speaker, + SpeakerOff, + Monitor, + Eye, + Record, + Stop, + Chat, + People, + Clock, + Settings, + Copy, + Leave, + Create, + Live, +} + +struct Icon { + kind: IconKind, + color: Color, + /// Stroke weight in the 24-unit authoring space (scaled with the widget). + weight: f32, +} + +/// Build an icon element at `size` px in `color`. Used throughout the room/ +/// settings UI in place of emoji. +fn icon<'a>(kind: IconKind, size: f32, color: Color) -> Element<'a, AppMessage> { + Canvas::new(Icon { kind, color, weight: 2.0 }) + .width(size) + .height(size) + .into() +} + +/// 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> { + container( + row![icon(kind, 18.0, color), text(label).size(16)] + .spacing(8) + .align_y(iced::alignment::Vertical::Center), + ) + .center_x(iced::Length::Fill) + .into() +} + +impl Program for Icon { + type State = (); + fn draw( + &self, + _state: &(), + renderer: &Renderer, + _theme: &Theme, + bounds: Rectangle, + _cursor: mouse::Cursor, + ) -> Vec { + use std::f32::consts::PI; + let mut f = Frame::new(renderer, bounds.size()); + let s = bounds.width.min(bounds.height) / 24.0; + let col = self.color; + let sw = (self.weight * s).max(1.0); + let p = |x: f32, y: f32| Point::new(x * s, y * s); + let stk = || { + canvas::Stroke::default() + .with_color(col) + .with_width(sw) + .with_line_cap(canvas::LineCap::Round) + .with_line_join(canvas::LineJoin::Round) + }; + // A stroked polyline (or polygon when `closed`). + let poly = |pts: &[(f32, f32)], closed: bool| { + Path::new(|b| { + for (i, q) in pts.iter().enumerate() { + let pt = p(q.0, q.1); + if i == 0 { + b.move_to(pt); + } else { + b.line_to(pt); + } + } + if closed { + b.close(); + } + }) + }; + // A stroked circular arc from a0..a1 radians (0 = +x, sweeps toward +y). + let arcp = |cx: f32, cy: f32, r: f32, a0: f32, a1: f32| { + Path::new(|b| { + b.arc(iced::widget::canvas::path::Arc { + center: p(cx, cy), + radius: r * s, + start_angle: iced::Radians(a0), + end_angle: iced::Radians(a1), + }); + }) + }; + let rrect = |x: f32, y: f32, w: f32, h: f32, r: f32| { + Path::rounded_rectangle(p(x, y), Size::new(w * s, h * s), (r * s).into()) + }; + + match self.kind { + IconKind::Mic => { + f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk()); + f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk()); + f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk()); + f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk()); + } + IconKind::MicOff => { + f.stroke(&rrect(9.0, 2.5, 6.0, 11.0, 3.0), stk()); + f.stroke(&arcp(12.0, 11.0, 6.5, 0.0, PI), stk()); + f.stroke(&poly(&[(12.0, 17.5), (12.0, 21.0)], false), stk()); + f.stroke(&poly(&[(8.5, 21.0), (15.5, 21.0)], false), stk()); + f.stroke(&poly(&[(3.5, 3.5), (20.5, 20.5)], false), stk()); + } + IconKind::Headphones => { + f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk()); + f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk()); + f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk()); + } + IconKind::Deafen => { + f.stroke(&arcp(12.0, 12.5, 8.0, PI, 2.0 * PI), stk()); + f.stroke(&rrect(3.0, 13.0, 4.5, 7.0, 2.25), stk()); + f.stroke(&rrect(16.5, 13.0, 4.5, 7.0, 2.25), stk()); + f.stroke(&poly(&[(3.0, 3.0), (21.0, 21.0)], false), stk()); + } + IconKind::Speaker => { + f.stroke( + &poly( + &[ + (4.0, 9.5), + (7.5, 9.5), + (13.0, 5.0), + (13.0, 19.0), + (7.5, 14.5), + (4.0, 14.5), + ], + true, + ), + stk(), + ); + f.stroke(&arcp(14.0, 12.0, 4.0, -0.5, 0.5), stk()); + f.stroke(&arcp(14.0, 12.0, 7.5, -0.65, 0.65), stk()); + } + IconKind::SpeakerOff => { + f.stroke( + &poly( + &[ + (4.0, 9.5), + (7.5, 9.5), + (13.0, 5.0), + (13.0, 19.0), + (7.5, 14.5), + (4.0, 14.5), + ], + true, + ), + stk(), + ); + f.stroke(&poly(&[(16.5, 9.5), (21.5, 14.5)], false), stk()); + f.stroke(&poly(&[(21.5, 9.5), (16.5, 14.5)], false), stk()); + } + IconKind::Monitor => { + f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 2.0), stk()); + f.stroke(&poly(&[(9.0, 20.0), (15.0, 20.0)], false), stk()); + f.stroke(&poly(&[(12.0, 16.0), (12.0, 20.0)], false), stk()); + // up-arrow inside (the "share" cue) + f.stroke(&poly(&[(12.0, 13.0), (12.0, 7.5)], false), stk()); + f.stroke(&poly(&[(9.5, 10.0), (12.0, 7.5), (14.5, 10.0)], false), stk()); + } + IconKind::Eye => { + f.stroke( + &poly( + &[ + (2.5, 12.0), + (6.0, 8.5), + (12.0, 7.0), + (18.0, 8.5), + (21.5, 12.0), + (18.0, 15.5), + (12.0, 17.0), + (6.0, 15.5), + ], + true, + ), + stk(), + ); + f.stroke(&Path::circle(p(12.0, 12.0), 3.0 * s), stk()); + } + IconKind::Record => { + f.fill(&Path::circle(p(12.0, 12.0), 6.0 * s), col); + } + IconKind::Stop => { + f.fill(&rrect(6.0, 6.0, 12.0, 12.0, 2.5), col); + } + IconKind::Chat => { + f.stroke(&rrect(3.0, 4.0, 18.0, 12.0, 3.0), stk()); + f.stroke(&poly(&[(8.0, 16.0), (8.0, 20.5), (12.5, 16.0)], false), stk()); + } + IconKind::People => { + f.stroke(&Path::circle(p(9.0, 9.0), 3.2 * s), stk()); + f.stroke(&arcp(9.0, 20.0, 5.3, PI, 2.0 * PI), stk()); + f.stroke(&Path::circle(p(16.5, 8.0), 2.7 * s), stk()); + f.stroke(&arcp(16.5, 20.0, 4.6, PI, 2.0 * PI), stk()); + } + IconKind::Clock => { + f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk()); + f.stroke(&poly(&[(12.0, 7.0), (12.0, 12.0)], false), stk()); + f.stroke(&poly(&[(12.0, 12.0), (15.5, 14.0)], false), stk()); + } + IconKind::Settings => { + f.stroke(&poly(&[(4.0, 7.0), (20.0, 7.0)], false), stk()); + f.stroke(&poly(&[(4.0, 12.0), (20.0, 12.0)], false), stk()); + f.stroke(&poly(&[(4.0, 17.0), (20.0, 17.0)], false), stk()); + f.fill(&Path::circle(p(15.0, 7.0), 2.4 * s), col); + f.fill(&Path::circle(p(9.0, 12.0), 2.4 * s), col); + f.fill(&Path::circle(p(16.0, 17.0), 2.4 * s), col); + } + IconKind::Copy => { + f.stroke(&rrect(8.0, 8.0, 12.0, 12.0, 2.0), stk()); + f.stroke(&rrect(4.0, 4.0, 12.0, 12.0, 2.0), stk()); + } + IconKind::Leave => { + f.stroke( + &poly(&[(9.0, 4.0), (6.0, 4.0), (6.0, 20.0), (9.0, 20.0)], false), + stk(), + ); + f.stroke(&poly(&[(9.5, 12.0), (20.0, 12.0)], false), stk()); + f.stroke(&poly(&[(15.5, 8.0), (20.0, 12.0), (15.5, 16.0)], false), stk()); + } + IconKind::Create => { + f.stroke(&Path::circle(p(12.0, 12.0), 8.5 * s), stk()); + f.stroke(&poly(&[(12.0, 8.0), (12.0, 16.0)], false), stk()); + f.stroke(&poly(&[(8.0, 12.0), (16.0, 12.0)], false), stk()); + } + IconKind::Live => { + f.fill(&Path::circle(p(12.0, 12.0), 3.0 * s), col); + f.stroke(&arcp(12.0, 12.0, 5.0, -0.4 * PI, 0.4 * PI), stk()); + f.stroke(&arcp(12.0, 12.0, 5.0, 0.6 * PI, 1.4 * PI), stk()); + f.stroke(&arcp(12.0, 12.0, 8.0, -0.32 * PI, 0.32 * PI), stk()); + f.stroke(&arcp(12.0, 12.0, 8.0, 0.68 * PI, 1.32 * PI), stk()); + } + } + vec![f.into_geometry()] + } +} + #[cfg(test)] mod tests { use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};