use std::sync::Arc;
use crate::style::{Color, Font, Weight};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub struct MenuId(pub String);
impl MenuId {
pub fn new(id: impl Into<String>) -> Self {
MenuId(id.into())
}
pub fn none() -> Self {
MenuId(String::new())
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn is_none(&self) -> bool {
self.0.is_empty()
}
}
impl From<&str> for MenuId {
fn from(s: &str) -> Self {
MenuId(s.to_owned())
}
}
impl From<String> for MenuId {
fn from(s: String) -> Self {
MenuId(s)
}
}
#[derive(Clone, Debug)]
pub struct MenuEvent {
pub id: MenuId,
pub source: crate::SurfaceId,
}
pub type ClickHandler = Box<dyn Fn(&MenuId) + Send + 'static>;
#[derive(Clone, Debug)]
pub enum Icon {
Png(Arc<[u8]>),
Svg(Arc<[u8]>),
Checkmark,
Symbol(&'static str),
}
impl Icon {
pub fn from_png_bytes(bytes: impl Into<Arc<[u8]>>) -> Self {
Icon::Png(bytes.into())
}
pub fn from_svg_bytes(bytes: impl Into<Arc<[u8]>>) -> Self {
Icon::Svg(bytes.into())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Center,
Right,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Flex {
#[default]
Fixed,
Grow,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StyleRun {
pub start: usize,
pub len: usize,
pub color: Color,
pub weight: Option<Weight>,
}
impl StyleRun {
pub fn new(start: usize, len: usize, color: Color) -> Self {
StyleRun {
start,
len,
color,
weight: None,
}
}
pub fn from_byte_range(text: &str, range: std::ops::Range<usize>, color: Color) -> Self {
let byte_len = text.len();
let start_byte = range.start.min(byte_len);
let end_byte = range.end.min(byte_len).max(start_byte);
let start_byte = (0..=start_byte)
.rev()
.find(|&i| text.is_char_boundary(i))
.unwrap_or(0);
let end_byte = (end_byte..=byte_len)
.find(|&i| text.is_char_boundary(i))
.unwrap_or(byte_len);
let start = text[..start_byte].encode_utf16().count();
let len = text[start_byte..end_byte].encode_utf16().count();
StyleRun {
start,
len,
color,
weight: None,
}
}
pub fn weight(mut self, weight: Weight) -> Self {
self.weight = Some(weight);
self
}
}
#[derive(Clone, Debug, Default)]
pub struct Segment {
pub text: String,
pub runs: Vec<StyleRun>,
pub align: Align,
pub flex: Flex,
pub font: Option<Font>,
pub color: Option<Color>,
}
impl Segment {
pub fn new(text: impl Into<String>) -> Self {
Segment {
text: text.into(),
..Segment::default()
}
}
pub fn grow(text: impl Into<String>) -> Self {
Segment::new(text).flex(Flex::Grow)
}
pub fn trailing_value(text: impl Into<String>) -> Self {
Segment::new(text).align(Align::Right)
}
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
pub fn flex(mut self, flex: Flex) -> Self {
self.flex = flex;
self
}
pub fn runs(mut self, runs: Vec<StyleRun>) -> Self {
self.runs = runs;
self
}
pub fn run(mut self, run: StyleRun) -> Self {
self.runs.push(run);
self
}
pub fn font(mut self, font: Font) -> Self {
self.font = Some(font);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
#[derive(Clone, Debug)]
pub struct Row {
pub id: MenuId,
pub segments: Vec<Segment>,
pub leading: Option<Icon>,
pub trailing: Option<Icon>,
pub enabled: bool,
pub checked: Option<bool>,
pub background: Option<Color>,
pub min_height: Option<f32>,
pub accessibility_label: Option<String>,
}
impl Default for Row {
fn default() -> Self {
Row {
id: MenuId::none(),
segments: Vec::new(),
leading: None,
trailing: None,
enabled: true,
checked: None,
background: None,
min_height: None,
accessibility_label: None,
}
}
}
impl Row {
pub fn new(id: impl Into<MenuId>) -> Self {
Row {
id: id.into(),
..Row::default()
}
}
pub fn info() -> Self {
Row::default()
}
pub fn label_only(text: impl Into<String>) -> Self {
Row::info().label(text)
}
pub fn label(mut self, text: impl Into<String>) -> Self {
self.segments.push(Segment::new(text));
self
}
pub fn label_value(self, label: impl Into<String>, value: impl Into<String>) -> Self {
self.segment(Segment::grow(label))
.segment(Segment::trailing_value(value))
}
pub fn segment(mut self, segment: Segment) -> Self {
self.segments.push(segment);
self
}
pub fn segments(mut self, segments: Vec<Segment>) -> Self {
self.segments = segments;
self
}
pub fn leading(mut self, icon: Icon) -> Self {
self.leading = Some(icon);
self
}
pub fn trailing(mut self, icon: Icon) -> Self {
self.trailing = Some(icon);
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn checked(mut self, checked: bool) -> Self {
self.checked = Some(checked);
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = Some(color);
self
}
pub fn min_height(mut self, height: f32) -> Self {
self.min_height = Some(height);
self
}
pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
self.accessibility_label = Some(label.into());
self
}
pub fn accessible_name(&self) -> String {
self.segments
.iter()
.map(|s| s.text.as_str())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Axis {
Horizontal,
Vertical,
}
#[derive(Clone, Debug)]
pub struct TextContent {
pub text: String,
pub font: Option<Font>,
pub color: Option<Color>,
pub align: Align,
}
impl TextContent {
pub fn new(text: impl Into<String>) -> Self {
TextContent {
text: text.into(),
font: None,
color: None,
align: Align::Left,
}
}
pub fn font(mut self, font: Font) -> Self {
self.font = Some(font);
self
}
pub fn color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
}
#[derive(Clone, Debug)]
pub enum Content {
Text(TextContent),
Image {
icon: Icon,
size: f32,
},
Stack(Stack),
Spacer,
}
#[derive(Clone, Debug)]
pub struct Stack {
pub axis: Axis,
pub spacing: f32,
pub align: Align,
pub children: Vec<Content>,
pub background: Option<Color>,
}
impl Stack {
pub fn horizontal(spacing: f32) -> Self {
Stack {
axis: Axis::Horizontal,
spacing,
align: Align::Left,
children: Vec::new(),
background: None,
}
}
pub fn vertical(spacing: f32) -> Self {
Stack {
axis: Axis::Vertical,
spacing,
align: Align::Left,
children: Vec::new(),
background: None,
}
}
pub fn align(mut self, align: Align) -> Self {
self.align = align;
self
}
pub fn background(mut self, color: Color) -> Self {
self.background = Some(color);
self
}
pub fn child(mut self, content: Content) -> Self {
self.children.push(content);
self
}
pub fn children(mut self, children: Vec<Content>) -> Self {
self.children = children;
self
}
}
#[derive(Clone, Debug)]
pub enum Item {
Row(Row),
Separator,
SectionHeader(Row),
Submenu {
label: Row,
menu: Menu,
},
Content(Stack),
}
impl Item {
pub fn is_interactive(&self) -> bool {
match self {
Item::Row(row) => row.enabled && !row.id.is_none(),
Item::Submenu { label, .. } => label.enabled,
Item::Separator | Item::SectionHeader(_) | Item::Content(_) => false,
}
}
}
#[cfg_attr(not(feature = "x11-popup"), allow(dead_code))]
pub(crate) fn descend(root: &Menu, parents: impl IntoIterator<Item = usize>) -> Option<&Menu> {
let mut menu = root;
for parent in parents {
menu = match menu.items.get(parent) {
Some(Item::Submenu { menu, .. }) => menu,
_ => return None,
};
}
Some(menu)
}
#[derive(Clone, Debug, Default)]
pub struct Menu {
pub items: Vec<Item>,
}
impl Menu {
pub fn new() -> Self {
Menu::default()
}
pub fn item(mut self, item: Item) -> Self {
self.items.push(item);
self
}
pub fn row(mut self, row: Row) -> Self {
self.items.push(Item::Row(row));
self
}
pub fn separator(mut self) -> Self {
self.items.push(Item::Separator);
self
}
pub fn section_header(mut self, row: Row) -> Self {
self.items.push(Item::SectionHeader(row));
self
}
pub fn submenu(mut self, label: Row, menu: Menu) -> Self {
self.items.push(Item::Submenu { label, menu });
self
}
pub fn content(mut self, stack: Stack) -> Self {
self.items.push(Item::Content(stack));
self
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn interactive_count(&self) -> usize {
self.items.iter().filter(|i| i.is_interactive()).count()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn menu_id_none_is_inert() {
assert!(MenuId::none().is_none());
assert!(MenuId::from("quit").as_str() == "quit");
assert!(!MenuId::from("quit").is_none());
}
#[test]
fn menu_id_new_accepts_str_and_string() {
assert_eq!(MenuId::new("quit").as_str(), "quit");
assert_eq!(MenuId::new(String::from("open")).as_str(), "open");
assert!(!MenuId::new("open").is_none());
}
#[test]
fn builder_produces_expected_item_sequence() {
let menu = Menu::new()
.section_header(Row::info().label("Claude"))
.row(Row::new("a").label("Account A"))
.separator()
.submenu(
Row::new("more").label("More"),
Menu::new().row(Row::new("x").label("X")),
);
assert_eq!(menu.len(), 4);
assert!(matches!(menu.items[0], Item::SectionHeader(_)));
assert!(matches!(menu.items[1], Item::Row(_)));
assert!(matches!(menu.items[2], Item::Separator));
assert!(matches!(menu.items[3], Item::Submenu { .. }));
}
#[test]
fn interactivity_rules() {
let header = Item::SectionHeader(Row::info().label("H"));
let sep = Item::Separator;
let info = Item::Row(Row::info().label("info"));
let disabled = Item::Row(Row::new("x").label("X").enabled(false));
let live = Item::Row(Row::new("x").label("X"));
assert!(!header.is_interactive());
assert!(!sep.is_interactive());
assert!(!info.is_interactive()); assert!(!disabled.is_interactive());
assert!(live.is_interactive());
}
#[test]
fn interactive_count_skips_headers_and_separators() {
let menu = Menu::new()
.section_header(Row::info().label("H"))
.row(Row::new("a").label("A"))
.row(Row::info().label("info only"))
.separator()
.row(Row::new("b").label("B"));
assert_eq!(menu.interactive_count(), 2);
}
#[test]
fn accessible_name_joins_segments() {
let row = Row::new("q").segments(vec![Segment::new("Quit"), Segment::new("usagio v1")]);
assert_eq!(row.accessible_name(), "Quit usagio v1");
}
#[test]
fn row_defaults_are_enabled_with_no_check_column() {
let row = Row::new("x");
assert!(row.enabled);
assert_eq!(row.checked, None);
assert!(row.leading.is_none());
}
#[test]
fn accessibility_label_defaults_to_none_and_is_settable() {
let row = Row::new("x");
assert_eq!(row.accessibility_label, None);
let labeled = Row::new("x").accessibility_label("Custom name");
assert_eq!(labeled.accessibility_label.as_deref(), Some("Custom name"));
}
#[test]
fn usagio_rowstyle_mapping() {
let bold = Segment::new("label").font(Font::system(13.0, Weight::Bold));
assert_eq!(bold.font.unwrap().weight, Weight::Bold);
let value = Segment::new("47% / 89%")
.align(Align::Right)
.runs(vec![StyleRun::new(6, 3, Color::SystemRed)]);
assert_eq!(value.align, Align::Right);
assert_eq!(value.runs.len(), 1);
assert_eq!(value.runs[0].color, Color::SystemRed);
let label = Segment::new("me@example.com").flex(Flex::Grow);
assert_eq!(label.flex, Flex::Grow);
let active = Row::new("switch:claude:me")
.checked(true)
.leading(Icon::Checkmark);
assert_eq!(active.checked, Some(true));
assert!(matches!(active.leading, Some(Icon::Checkmark)));
let tail = Segment::new("usagio v1").color(Color::SecondaryLabel);
assert_eq!(tail.color, Some(Color::SecondaryLabel));
let info = Row::info();
assert!(info.id.is_none());
assert!(info.enabled);
}
#[test]
fn segment_grow_and_trailing_value() {
let label = Segment::grow("me@example.com");
assert_eq!(label.flex, Flex::Grow);
assert_eq!(label.align, Align::Left);
let value = Segment::trailing_value("99%");
assert_eq!(value.align, Align::Right);
assert_eq!(value.flex, Flex::Fixed);
}
#[test]
fn segment_run_appends_single_style_run() {
let seg = Segment::new("47% / 89%")
.run(StyleRun::new(0, 3, Color::SystemRed))
.run(StyleRun::new(6, 3, Color::SystemGreen));
assert_eq!(seg.runs.len(), 2);
assert_eq!(seg.runs[0].color, Color::SystemRed);
assert_eq!(seg.runs[1].color, Color::SystemGreen);
}
#[test]
fn row_label_value_produces_grow_and_right_segments() {
let row = Row::info().label_value("me@example.com", "Active");
assert_eq!(row.segments.len(), 2);
assert_eq!(row.segments[0].text, "me@example.com");
assert_eq!(row.segments[0].flex, Flex::Grow);
assert_eq!(row.segments[1].text, "Active");
assert_eq!(row.segments[1].align, Align::Right);
}
#[test]
fn style_run_from_byte_range_ascii() {
let text = "47% / 89%";
let run = StyleRun::from_byte_range(text, 6..9, Color::SystemRed);
assert_eq!(run.start, 6);
assert_eq!(run.len, 3);
}
#[test]
fn style_run_from_byte_range_multibyte() {
let text = "café";
let run = StyleRun::from_byte_range(text, 4..6, Color::SystemRed);
assert_eq!(run.start, 3);
assert_eq!(run.len, 1);
let text = "hi 🎉!";
let emoji_byte_start = text.find('🎉').unwrap();
let emoji_byte_len = '🎉'.len_utf8();
let run = StyleRun::from_byte_range(
text,
emoji_byte_start..emoji_byte_start + emoji_byte_len,
Color::SystemRed,
);
assert_eq!(run.start, "hi ".encode_utf16().count());
assert_eq!(run.len, 2);
}
#[test]
fn style_run_from_byte_range_clamps_to_char_boundary() {
let text = "café";
let run = StyleRun::from_byte_range(text, 3..text.len() + 10, Color::SystemRed);
assert!(run.start <= text.encode_utf16().count());
assert_eq!(run.start + run.len, text.encode_utf16().count());
}
#[test]
fn row_label_only_has_no_id_and_no_checked() {
let row = Row::label_only("Section");
assert!(row.id.is_none());
assert_eq!(row.checked, None);
assert_eq!(row.accessible_name(), "Section");
assert!(row.enabled);
}
}