use crate::geometry::{Rect, Size, clamp_u16};
use crate::text;
use crate::theme::State;
use crate::widget::PaintCx;
use super::cells;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContextItem<Msg> {
label: String,
icon: Option<String>,
shortcut: Option<String>,
message: Option<Msg>,
submenu: Vec<ContextItem<Msg>>,
disabled: bool,
danger: bool,
gap: bool,
}
impl<Msg> ContextItem<Msg> {
fn plain(label: String) -> Self {
Self {
label,
icon: None,
shortcut: None,
message: None,
submenu: Vec::new(),
disabled: false,
danger: false,
gap: false,
}
}
#[must_use]
pub fn new(label: impl Into<String>, message: Msg) -> Self {
Self { message: Some(message), ..Self::plain(label.into()) }
}
#[must_use]
pub fn submenu(label: impl Into<String>, items: impl IntoIterator<Item = Self>) -> Self {
Self { submenu: items.into_iter().collect(), ..Self::plain(label.into()) }
}
#[must_use]
pub fn gap() -> Self {
Self { gap: true, ..Self::plain(String::new()) }
}
#[must_use]
pub fn icon(mut self, key: impl Into<String>) -> Self {
self.icon = Some(key.into());
self
}
#[must_use]
pub fn shortcut(mut self, label: impl Into<String>) -> Self {
self.shortcut = Some(label.into());
self
}
#[must_use]
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
#[must_use]
pub fn danger(mut self, danger: bool) -> Self {
self.danger = danger;
self
}
pub(crate) fn selectable(&self) -> bool {
!self.gap && !self.disabled
}
pub(crate) fn children(&self) -> &[Self] {
&self.submenu
}
pub(crate) fn has_submenu(&self) -> bool {
!self.submenu.is_empty()
}
pub(crate) fn message(&self) -> Option<&Msg> {
self.message.as_ref()
}
}
pub(crate) fn keyed<Msg>(items: Vec<ContextItem<Msg>>) -> (Vec<ContextItem<usize>>, Vec<Msg>) {
fn key<Msg>(items: Vec<ContextItem<Msg>>, messages: &mut Vec<Msg>) -> Vec<ContextItem<usize>> {
items
.into_iter()
.map(|item| {
let message = item.message.map(|message| {
messages.push(message);
messages.len() - 1
});
ContextItem {
label: item.label,
icon: item.icon,
shortcut: item.shortcut,
message,
submenu: key(item.submenu, messages),
disabled: item.disabled,
danger: item.danger,
gap: item.gap,
}
})
.collect()
}
let mut messages = Vec::new();
let items = key(items, &mut messages);
(items, messages)
}
fn icon_column<Msg>(cx: &PaintCx<'_>, items: &[ContextItem<Msg>]) -> u16 {
let icons = cx.env().icons();
items
.iter()
.filter_map(|item| item.icon.as_deref())
.map(|key| text::width(&icons.glyph(key)).saturating_add(1))
.max()
.unwrap_or(0)
}
pub(crate) fn size<Msg>(cx: &PaintCx<'_>, items: &[ContextItem<Msg>]) -> Size {
let icons = cx.env().icons();
let lead =
cells::sum([items.iter().map(|item| text::width(&item.label)).max().unwrap_or(0), icon_column(cx, items)]);
let trail = items
.iter()
.map(|item| {
let shortcut = item.shortcut.as_deref().map_or(0, text::width);
let chevron = if item.has_submenu() { text::width(&icons.glyph("chevron-right")) } else { 0 };
shortcut.max(chevron)
})
.max()
.unwrap_or(0);
let width = cells::sum([2, lead, 1, if trail > 0 { trail.saturating_add(3) } else { 2 }, 2]);
Size::new(width.max(18), clamp_u16(i32::try_from(items.len()).unwrap_or(i32::MAX)))
}
pub(crate) fn step<Msg>(items: &[ContextItem<Msg>], from: Option<usize>, forward: bool) -> Option<usize> {
let len = items.len();
if len == 0 {
return None;
}
let start = from.unwrap_or(if forward { len - 1 } else { 0 });
(1..=len)
.map(|offset| if forward { (start + offset) % len } else { (start + len * 2 - offset) % len })
.find(|index| items[*index].selectable())
}
pub(crate) fn edge<Msg>(items: &[ContextItem<Msg>], last: bool) -> Option<usize> {
if last { items.iter().rposition(ContextItem::selectable) } else { items.iter().position(ContextItem::selectable) }
}
pub(crate) fn type_ahead<Msg>(items: &[ContextItem<Msg>], from: Option<usize>, typed: char) -> Option<usize> {
let start = from.unwrap_or(items.len().saturating_sub(1));
super::popup_menu::type_ahead_by(items.len(), start, typed, |index| {
let item = &items[index];
item.selectable().then_some(item.label.as_str())
})
}
pub(crate) fn paint<Msg>(
cx: &mut PaintCx<'_>,
items: &[ContextItem<Msg>],
full: Rect,
shown: Rect,
highlight: Option<usize>,
) {
let background = cx.style("context-menu", None, &[]).text().bg.unwrap_or_else(|| cx.color("overlay"));
cx.clear(shown, background);
cx.register_hit(shown);
let slide = cx.env().slide();
let chevron = cx.env().icons().glyph("chevron-right").into_owned();
let icon_column = icon_column(cx, items);
cx.with_clip(shown, |cx| {
for (row, item) in items.iter().enumerate().take(usize::from(full.height)) {
if item.gap {
continue;
}
let rect = full.row(clamp_u16(i32::try_from(row).unwrap_or(i32::MAX)));
let mut states = Vec::new();
if item.disabled {
states.push(State::Disabled);
} else if highlight == Some(row) {
states.push(State::Hover);
}
let variant = item.danger.then_some("danger");
let style = cx.style("context-item", variant, &states);
let text_style = style.text();
if let Some(bg) = text_style.bg {
cx.fill(rect, bg);
}
if let Some(color) = style.color("pillar") {
cx.pillar(rect.x, rect.y, color);
}
let right = rect.right() - 2;
let mut trail_x = right;
if let Some(shortcut) = &item.shortcut {
let width = text::width(shortcut);
trail_x = right - i32::from(width);
let shortcut_style = cx.style("context-item-shortcut", None, &states).text();
cx.text(trail_x, rect.y, shortcut, shortcut_style, width);
} else if item.has_submenu() {
let width = text::width(&chevron);
trail_x = right - i32::from(width);
let chevron_style = cx.style("context-item-chevron", None, &states).text();
cx.text(trail_x, rect.y, &chevron, chevron_style, width);
}
let shift = i32::from(slide && states.contains(&State::Hover));
let mut x = rect.x + 2 + shift;
let limit = trail_x - 2;
let mut label_style = text_style;
label_style.bg = None;
if let Some(icon) = &item.icon {
let glyph = cx.env().icons().glyph(icon).into_owned();
cx.text(x, rect.y, &glyph, label_style, clamp_u16(limit - x));
}
x += i32::from(icon_column);
let budget = clamp_u16(limit - x);
let label = text::truncate(&item.label, budget).into_owned();
cx.text(x, rect.y, &label, label_style, budget);
}
});
}