Merge feat/icon-set: canvas-drawn icon set replacing emoji

This commit is contained in:
2026-06-06 17:17:56 -04:00
+357 -70
View File
@@ -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<AppMessage> 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<AppMessage> for Icon {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &Renderer,
_theme: &Theme,
bounds: Rectangle,
_cursor: mouse::Cursor,
) -> Vec<Geometry> {
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};