use iced::advanced::layout::{self, Layout};
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::text::{self, Paragraph};
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Clipboard, Shell};
use iced::mouse;
use iced::{Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector, alignment};
use super::theme;
#[derive(Debug, Clone)]
pub struct RoleMenuItem<Message> {
pub role: crate::Role,
pub is_current: bool,
pub action: Option<Message>,
}
impl<Message> RoleMenuItem<Message> {
#[must_use]
pub fn new(role: crate::Role, is_current: bool, action: Option<Message>) -> Self {
Self {
role,
is_current,
action,
}
}
}
pub struct RoleMenu<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Message: Clone + 'a,
{
underlay: Element<'a, Message, Theme, Renderer>,
items: Vec<RoleMenuItem<Message>>,
show: bool,
on_close: Message,
}
impl<'a, Message, Theme, Renderer> RoleMenu<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
{
#[must_use]
pub fn new(
underlay: impl Into<Element<'a, Message, Theme, Renderer>>,
items: Vec<RoleMenuItem<Message>>,
show: bool,
on_close: Message,
) -> Self {
Self {
underlay: underlay.into(),
items,
show,
on_close,
}
}
}
#[derive(Debug, Clone)]
struct RoleMenuState {
show: bool,
hovered: Option<usize>,
}
impl RoleMenuState {
const fn new() -> Self {
Self {
show: false,
hovered: None,
}
}
}
impl<'a, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for RoleMenu<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Renderer: iced::advanced::Renderer + text::Renderer<Font = iced::Font>,
{
fn size(&self) -> Size<Length> {
self.underlay.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.underlay.as_widget().size_hint()
}
fn tag(&self) -> tree::Tag {
tree::Tag::of::<RoleMenuState>()
}
fn state(&self) -> tree::State {
tree::State::new(RoleMenuState::new())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.underlay)]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(&[&self.underlay]);
let state = tree.state.downcast_mut::<RoleMenuState>();
if self.show && !state.show {
state.hovered = None;
}
state.show = self.show;
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.underlay
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.underlay.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.underlay
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.underlay.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.underlay.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
let underlay_overlay = self.underlay.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
);
let state = tree.state.downcast_ref::<RoleMenuState>();
if !state.show {
return underlay_overlay;
}
let anchor = layout.bounds() + translation;
let state = tree.state.downcast_mut::<RoleMenuState>();
let own_overlay = overlay::Element::new(Box::new(RoleMenuOverlay {
hovered: &mut state.hovered,
anchor,
items: &self.items,
on_close: &self.on_close,
label_widths: Vec::new(),
}));
let mut overlays = Vec::new();
if let Some(underlay) = underlay_overlay {
overlays.push(underlay);
}
overlays.push(own_overlay);
Some(overlay::Group::with_children(overlays).overlay())
}
}
impl<'a, Message, Theme, Renderer> From<RoleMenu<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: 'a,
Renderer: iced::advanced::Renderer + text::Renderer<Font = iced::Font> + 'a,
{
fn from(role_menu: RoleMenu<'a, Message, Theme, Renderer>) -> Self {
Self::new(role_menu)
}
}
struct RoleMenuOverlay<'a, 'b, Message>
where
Message: Clone,
{
hovered: &'b mut Option<usize>,
anchor: Rectangle,
items: &'a [RoleMenuItem<Message>],
on_close: &'a Message,
label_widths: Vec<f32>,
}
const MENU_WIDTH: f32 = 170.0;
const MENU_PADDING: f32 = 5.0;
const ITEM_HEIGHT: f32 = 28.0;
const ITEM_SPACING: f32 = 2.0;
const BTN_PADDING: f32 = 5.0;
const ANCHOR_GAP: f32 = 6.0;
const ICON_SIZE: f32 = 13.0;
const CHECK_SIZE: f32 = 12.0;
const LABEL_FONT_SIZE: f32 = 13.0;
const ITEM_GAP: f32 = 6.0;
fn item_index_from_y(rel_y: f32, item_count: usize) -> Option<usize> {
if rel_y < 0.0 {
return None;
}
let slot = ITEM_HEIGHT + ITEM_SPACING;
#[expect(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let idx = (rel_y / slot) as usize;
if idx >= item_count {
return None;
}
#[expect(clippy::cast_precision_loss)]
let within_item = rel_y - idx as f32 * slot < ITEM_HEIGHT;
within_item.then_some(idx)
}
fn popup_position(anchor: Rectangle, menu_size: Size, viewport: Size, gap: f32) -> Point {
let x = (anchor.x + anchor.width - menu_size.width)
.clamp(0.0, (viewport.width - menu_size.width).max(0.0));
let above = anchor.y - menu_size.height - gap;
let y = if above >= 0.0 {
above
} else {
let below = anchor.y + anchor.height + gap;
if below + menu_size.height <= viewport.height {
below
} else {
(viewport.height - menu_size.height).max(0.0)
}
};
Point::new(x, y)
}
fn menu_height(item_count: usize) -> f32 {
#[expect(clippy::cast_precision_loss)]
let items = item_count as f32;
#[expect(clippy::cast_precision_loss)]
let gaps = item_count.saturating_sub(1) as f32;
MENU_PADDING * 2.0 + items * ITEM_HEIGHT + gaps * ITEM_SPACING
}
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
for RoleMenuOverlay<'_, '_, Message>
where
Message: Clone,
Renderer: text::Renderer<Font = iced::Font>,
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
let menu_height = menu_height(self.items.len());
let text_size = Pixels(LABEL_FONT_SIZE);
self.label_widths = self
.items
.iter()
.map(|item| {
let label = crate::role::role_info(&item.role).display_label;
let paragraph = Renderer::Paragraph::with_text(text::Text {
content: label,
bounds: Size::new(f32::MAX, ITEM_HEIGHT),
size: text_size,
line_height: text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Top,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::default(),
});
paragraph.min_bounds().width
})
.collect();
let menu_size = Size::new(MENU_WIDTH, menu_height);
let position = popup_position(self.anchor, menu_size, bounds, ANCHOR_GAP);
layout::Node::new(menu_size).move_to(position)
}
fn draw(
&self,
renderer: &mut Renderer,
_theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
) {
let bounds = layout.bounds();
renderer.fill_quad(
renderer::Quad {
bounds,
border: iced::Border {
radius: 4.0.into(),
width: 1.0,
color: theme::BORDER_STRONG,
},
..renderer::Quad::default()
},
theme::BG_ELEVATED,
);
let slot = ITEM_HEIGHT + ITEM_SPACING;
for (i, item) in self.items.iter().enumerate() {
#[expect(clippy::cast_precision_loss)]
let item_y = bounds.y + MENU_PADDING + i as f32 * slot;
let item_bounds = Rectangle {
x: bounds.x,
y: item_y,
width: bounds.width,
height: ITEM_HEIGHT,
};
let label_width = self.label_widths.get(i).copied().unwrap_or_default();
draw_item(
renderer,
item,
item_bounds,
label_width,
*self.hovered == Some(i),
);
}
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
_clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let bounds = layout.bounds();
match event {
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
*self.hovered = cursor
.position_in(bounds)
.and_then(|pos| item_index_from_y(pos.y - MENU_PADDING, self.items.len()));
shell.request_redraw();
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(cursor_pos) = cursor.position() {
if bounds.contains(cursor_pos) {
if let Some(idx) = item_index_from_y(
cursor_pos.y - bounds.y - MENU_PADDING,
self.items.len(),
) {
if let Some(action) = self.items[idx].action.clone() {
shell.publish(action);
shell.capture_event();
return;
}
shell.capture_event();
return;
}
shell.capture_event();
return;
}
}
shell.publish(self.on_close.clone());
shell.request_redraw();
shell.capture_event();
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
| Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
shell.publish(self.on_close.clone());
shell.request_redraw();
shell.capture_event();
}
_ => {}
}
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
) -> mouse::Interaction {
if cursor.is_over(layout.bounds()) {
mouse::Interaction::Pointer
} else {
mouse::Interaction::Idle
}
}
fn index(&self) -> f32 {
3.0
}
}
fn draw_item<Message, Renderer>(
renderer: &mut Renderer,
item: &RoleMenuItem<Message>,
item_bounds: Rectangle,
label_width: f32,
is_hovered: bool,
) where
Renderer: text::Renderer<Font = iced::Font>,
{
if !item.is_current && is_hovered {
renderer.fill_quad(
renderer::Quad {
bounds: item_bounds,
border: iced::Border {
radius: 4.0.into(),
width: 0.0,
color: Color::TRANSPARENT,
},
..renderer::Quad::default()
},
theme::HOVER,
);
}
let (icon_color, _) = theme::role_badge_color_for(&item.role);
let label_color = if item.is_current {
theme::TEXT_PRIMARY
} else {
theme::TEXT_SECONDARY
};
let icon_x = item_bounds.x + BTN_PADDING;
let label_x = icon_x + ICON_SIZE + ITEM_GAP;
let check_x = label_x + label_width + ITEM_GAP;
let item_center_y = item_bounds.center_y();
renderer.fill_text(
text::Text {
content: role_icon_glyph(item.role).to_string(),
bounds: Size::new(ICON_SIZE, ITEM_HEIGHT),
size: Pixels(ICON_SIZE),
line_height: text::LineHeight::Relative(1.3),
font: iced_fonts::LUCIDE_FONT,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Basic,
wrapping: text::Wrapping::default(),
},
Point::new(icon_x, item_center_y),
icon_color,
item_bounds,
);
let label_bounds_width =
item_bounds.width - BTN_PADDING * 2.0 - ICON_SIZE - ITEM_GAP * 2.0 - CHECK_SIZE;
renderer.fill_text(
text::Text {
content: crate::role::role_info(&item.role).display_label.to_string(),
bounds: Size::new(label_bounds_width, ITEM_HEIGHT),
size: Pixels(LABEL_FONT_SIZE),
line_height: text::LineHeight::Relative(1.3),
font: renderer.default_font(),
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::default(),
},
Point::new(label_x, item_center_y),
label_color,
item_bounds,
);
if item.is_current {
renderer.fill_text(
text::Text {
content: check_glyph().to_string(),
bounds: Size::new(CHECK_SIZE, ITEM_HEIGHT),
size: Pixels(CHECK_SIZE),
line_height: text::LineHeight::Relative(1.3),
font: iced_fonts::LUCIDE_FONT,
align_x: text::Alignment::Left,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Basic,
wrapping: text::Wrapping::default(),
},
Point::new(check_x, item_center_y),
theme::ACCENT,
item_bounds,
);
}
}
fn check_glyph() -> char {
lucide_glyph(iced_fonts::lucide::advanced_text::check)
}
fn role_icon_glyph(role: crate::Role) -> char {
let glyph = match role {
crate::Role::Manager => iced_fonts::lucide::advanced_text::bot,
crate::Role::Engineer => iced_fonts::lucide::advanced_text::wrench,
crate::Role::Analyst => iced_fonts::lucide::advanced_text::scan_search,
crate::Role::Coder => iced_fonts::lucide::advanced_text::code,
crate::Role::Qa => iced_fonts::lucide::advanced_text::gavel,
crate::Role::Reviewer => iced_fonts::lucide::advanced_text::file_check,
crate::Role::Discovery => iced_fonts::lucide::advanced_text::search,
crate::Role::Artist => iced_fonts::lucide::advanced_text::palette,
crate::Role::Maintainer => iced_fonts::lucide::advanced_text::cog,
crate::Role::Sanitation => iced_fonts::lucide::advanced_text::spray_can,
crate::Role::Assistant => iced_fonts::lucide::advanced_text::message_square,
};
lucide_glyph(glyph)
}
fn lucide_glyph(glyph: fn() -> (String, iced::Font, text::Shaping)) -> char {
glyph()
.0
.chars()
.next()
.expect("lucide glyph strings are single characters")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popup_position_opens_above_anchor_when_room() {
let anchor = Rectangle {
x: 800.0,
y: 700.0,
width: 30.0,
height: 30.0,
};
let menu = Size::new(170.0, 100.0);
let viewport = Size::new(1000.0, 800.0);
let pos = popup_position(anchor, menu, viewport, ANCHOR_GAP);
#[expect(clippy::float_cmp)] {
assert_eq!(pos.x, anchor.x + anchor.width - menu.width);
}
#[expect(clippy::float_cmp)] {
assert_eq!(pos.y, anchor.y - menu.height - ANCHOR_GAP);
}
}
#[test]
fn popup_position_flips_below_when_no_room_above() {
let anchor = Rectangle {
x: 400.0,
y: 10.0,
width: 30.0,
height: 30.0,
};
let menu = Size::new(170.0, 100.0);
let viewport = Size::new(1000.0, 800.0);
let pos = popup_position(anchor, menu, viewport, ANCHOR_GAP);
#[expect(clippy::float_cmp)] {
assert_eq!(pos.y, anchor.y + anchor.height + ANCHOR_GAP);
}
}
#[test]
fn popup_position_clamps_when_neither_side_fits() {
let anchor = Rectangle {
x: 10.0,
y: 10.0,
width: 30.0,
height: 30.0,
};
let menu = Size::new(170.0, 500.0);
let viewport = Size::new(200.0, 400.0);
let pos = popup_position(anchor, menu, viewport, ANCHOR_GAP);
#[expect(clippy::float_cmp)] {
assert_eq!(pos.y, 0.0);
}
#[expect(clippy::float_cmp)] {
assert_eq!(pos.x, 0.0);
}
}
#[test]
fn popup_position_clamps_horizontally_in_narrow_windows() {
let anchor = Rectangle {
x: 980.0,
y: 100.0,
width: 30.0,
height: 30.0,
};
let menu = Size::new(170.0, 100.0);
let viewport = Size::new(1000.0, 800.0);
let pos = popup_position(anchor, menu, viewport, ANCHOR_GAP);
#[expect(clippy::float_cmp)] {
assert_eq!(pos.x, viewport.width - menu.width);
}
#[expect(clippy::float_cmp)] {
assert_eq!(pos.x, 830.0);
}
}
#[test]
fn item_index_from_y_accounts_for_spacing() {
assert_eq!(item_index_from_y(0.0, 4), Some(0));
assert_eq!(item_index_from_y(ITEM_HEIGHT - 1.0, 4), Some(0));
assert_eq!(item_index_from_y(ITEM_HEIGHT + 1.0, 4), None);
assert_eq!(item_index_from_y(ITEM_HEIGHT + ITEM_SPACING, 4), Some(1));
assert_eq!(
item_index_from_y(ITEM_HEIGHT * 2.0 + ITEM_SPACING * 2.0, 4),
Some(2)
);
assert_eq!(
item_index_from_y(ITEM_HEIGHT * 2.0 + ITEM_SPACING * 2.0 - 1.0, 4),
None
);
assert_eq!(item_index_from_y(-1.0, 4), None);
assert_eq!(
item_index_from_y(ITEM_HEIGHT * 4.0 + ITEM_SPACING * 4.0, 4),
None
);
assert_eq!(item_index_from_y(0.0, 0), None);
}
#[test]
fn role_icon_glyphs_are_pinned_to_expected_codepoints() {
let expected = [
(crate::Role::Manager, '\u{E1BB}'),
(crate::Role::Engineer, '\u{E1B1}'),
(crate::Role::Analyst, '\u{E53C}'),
(crate::Role::Coder, '\u{E097}'),
(crate::Role::Qa, '\u{E0E4}'),
(crate::Role::Reviewer, '\u{E0C5}'),
(crate::Role::Discovery, '\u{E155}'),
(crate::Role::Artist, '\u{E1DD}'),
(crate::Role::Maintainer, '\u{E30B}'),
(crate::Role::Sanitation, '\u{E49A}'),
(crate::Role::Assistant, '\u{E11B}'),
];
for (role, codepoint) in expected {
assert_eq!(role_icon_glyph(role), codepoint, "role {role:?}");
}
}
#[test]
fn check_glyph_is_pinned_to_expected_codepoint() {
assert_eq!(check_glyph(), '\u{E070}');
}
}