pub const DEFAULT_APP_NAME: &str = "Frust";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopConfig {
pub app_name: Option<String>,
pub app_id: Option<String>,
pub window_icon: Option<IconData>,
pub menu_spec: Option<MenuSpec>,
pub quit_on_last_window_closed: bool,
}
impl Default for DesktopConfig {
fn default() -> Self {
Self {
app_name: None,
app_id: None,
window_icon: None,
menu_spec: None,
quit_on_last_window_closed: true,
}
}
}
impl DesktopConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_app_name(mut self, app_name: impl Into<String>) -> Self {
self.app_name = Some(app_name.into());
self
}
pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = Some(app_id.into());
self
}
pub fn with_window_icon(mut self, icon: IconData) -> Self {
self.window_icon = Some(icon);
self
}
pub fn with_menu_spec(mut self, menu_spec: MenuSpec) -> Self {
self.menu_spec = Some(menu_spec);
self
}
pub fn with_quit_on_last_window_closed(mut self, quit: bool) -> Self {
self.quit_on_last_window_closed = quit;
self
}
pub fn window_title(&self) -> &str {
self.app_name.as_deref().unwrap_or(DEFAULT_APP_NAME)
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct IconData {
rgba: Vec<u8>,
width: u32,
height: u32,
}
const MAX_ICON_SIDE: u32 = 4096;
impl IconData {
pub fn from_rgba(rgba: Vec<u8>, width: u32, height: u32) -> Option<Self> {
if width == 0 || height == 0 || width > MAX_ICON_SIDE || height > MAX_ICON_SIDE {
return None;
}
let expected = u64::from(width)
.checked_mul(u64::from(height))
.and_then(|pixels| pixels.checked_mul(4))?;
if rgba.len() as u64 != expected {
return None;
}
Some(Self {
rgba,
width,
height,
})
}
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
}
impl std::fmt::Debug for IconData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IconData")
.field("rgba_len", &self.rgba.len())
.field("width", &self.width)
.field("height", &self.height)
.finish()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MenuSpec {
pub items: Vec<MenuItemSpec>,
}
impl MenuSpec {
pub fn new() -> Self {
Self::default()
}
pub fn with_item(mut self, item: MenuItemSpec) -> Self {
self.items.push(item);
self
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuItemSpec {
Item {
id: String,
label: String,
accelerator: Option<String>,
enabled: bool,
},
Role {
role: MenuRole,
label: Option<String>,
},
Separator,
Submenu {
label: String,
menu: MenuSpec,
},
}
impl MenuItemSpec {
pub fn item(id: impl Into<String>, label: impl Into<String>) -> Self {
Self::Item {
id: id.into(),
label: label.into(),
accelerator: None,
enabled: true,
}
}
pub fn role(role: MenuRole) -> Self {
Self::Role { role, label: None }
}
pub fn separator() -> Self {
Self::Separator
}
pub fn submenu(label: impl Into<String>, menu: MenuSpec) -> Self {
Self::Submenu {
label: label.into(),
menu,
}
}
pub fn with_accelerator(mut self, accelerator: impl Into<String>) -> Self {
if let Self::Item {
accelerator: slot, ..
} = &mut self
{
*slot = Some(accelerator.into());
}
self
}
pub fn disabled(mut self) -> Self {
if let Self::Item { enabled, .. } = &mut self {
*enabled = false;
}
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
if let Self::Role { label: slot, .. } = &mut self {
*slot = Some(label.into());
}
self
}
pub fn id(&self) -> Option<&str> {
match self {
Self::Item { id, .. } => Some(id),
Self::Role { .. } | Self::Separator | Self::Submenu { .. } => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MenuRole {
About,
Hide,
HideOthers,
ShowAll,
Minimize,
CloseWindow,
Quit,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_default_config_reproduces_todays_preview_window() {
let config = DesktopConfig::default();
assert_eq!(config.window_title(), "Frust");
assert_eq!(config.app_name, None);
assert_eq!(config.app_id, None);
assert_eq!(config.window_icon, None);
assert_eq!(config.menu_spec, None);
assert!(config.quit_on_last_window_closed);
assert_eq!(DesktopConfig::new(), config);
}
#[test]
fn a_configured_app_name_becomes_the_window_title() {
let config = DesktopConfig::new().with_app_name("Huddle");
assert_eq!(config.window_title(), "Huddle");
assert_eq!(config.app_name.as_deref(), Some("Huddle"));
}
#[test]
fn the_builders_set_each_field_independently() {
let icon = IconData::from_rgba(vec![0; 4], 1, 1).expect("1x1 RGBA is valid");
let config = DesktopConfig::new()
.with_app_name("Huddle")
.with_app_id("dev.frust.huddle")
.with_window_icon(icon.clone())
.with_menu_spec(MenuSpec::new().with_item(MenuItemSpec::role(MenuRole::Quit)))
.with_quit_on_last_window_closed(false);
assert_eq!(config.app_id.as_deref(), Some("dev.frust.huddle"));
assert_eq!(config.window_icon, Some(icon));
assert_eq!(config.menu_spec.map(|m| m.items.len()), Some(1));
assert!(!config.quit_on_last_window_closed);
}
#[test]
fn icon_data_accepts_exactly_width_times_height_times_four_bytes() {
let icon = IconData::from_rgba(vec![7; 2 * 3 * 4], 2, 3).expect("2x3 RGBA is valid");
assert_eq!(icon.width(), 2);
assert_eq!(icon.height(), 3);
assert_eq!(icon.rgba().len(), 24);
}
#[test]
fn icon_data_rejects_a_buffer_that_does_not_match_its_dimensions() {
assert_eq!(IconData::from_rgba(vec![0; 15], 2, 2), None);
assert_eq!(IconData::from_rgba(vec![0; 17], 2, 2), None);
}
#[test]
fn icon_data_rejects_a_zero_dimension() {
assert_eq!(IconData::from_rgba(Vec::new(), 0, 4), None);
assert_eq!(IconData::from_rgba(Vec::new(), 4, 0), None);
}
#[test]
fn icon_data_rejects_a_size_product_that_overflows_u64() {
assert_eq!(IconData::from_rgba(Vec::new(), 1 << 31, 1 << 31), None);
}
#[test]
fn icon_data_rejects_a_dimension_just_over_the_cap() {
assert_eq!(IconData::from_rgba(Vec::new(), MAX_ICON_SIDE + 1, 1), None);
assert_eq!(IconData::from_rgba(Vec::new(), 1, MAX_ICON_SIDE + 1), None);
}
#[test]
fn icon_data_accepts_the_max_allowed_dimension() {
let icon = IconData::from_rgba(vec![0; MAX_ICON_SIDE as usize * 4], MAX_ICON_SIDE, 1)
.expect("MAX_ICON_SIDE is inclusive, not an exclusive bound");
assert_eq!(icon.width(), MAX_ICON_SIDE);
assert_eq!(icon.height(), 1);
}
#[test]
fn a_fresh_menu_spec_is_empty() {
let menu = MenuSpec::new();
assert!(menu.is_empty());
assert_eq!(menu, MenuSpec::default());
}
#[test]
fn menu_items_build_the_full_item_vocabulary() {
let file = MenuSpec::new()
.with_item(MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O"))
.with_item(MenuItemSpec::item("file.export", "Export").disabled())
.with_item(MenuItemSpec::separator())
.with_item(MenuItemSpec::role(MenuRole::Quit));
let bar = MenuSpec::new().with_item(MenuItemSpec::submenu("File", file.clone()));
assert!(!bar.is_empty());
assert_eq!(
bar.items,
vec![MenuItemSpec::Submenu {
label: "File".to_string(),
menu: file,
}]
);
}
#[test]
fn an_app_item_carries_its_id_accelerator_and_enabled_state() {
let item = MenuItemSpec::item("file.open", "Open…").with_accelerator("CmdOrCtrl+O");
assert_eq!(
item,
MenuItemSpec::Item {
id: "file.open".to_string(),
label: "Open…".to_string(),
accelerator: Some("CmdOrCtrl+O".to_string()),
enabled: true,
}
);
assert_eq!(item.id(), Some("file.open"));
assert!(matches!(
item.disabled(),
MenuItemSpec::Item { enabled: false, .. }
));
}
#[test]
fn only_app_items_report_an_activation_id() {
assert_eq!(MenuItemSpec::role(MenuRole::About).id(), None);
assert_eq!(MenuItemSpec::separator().id(), None);
assert_eq!(MenuItemSpec::submenu("File", MenuSpec::new()).id(), None);
}
#[test]
fn the_item_builders_leave_the_wrong_variant_untouched() {
let role = MenuItemSpec::role(MenuRole::Quit);
assert_eq!(role.clone().with_accelerator("CmdOrCtrl+Q"), role);
assert_eq!(role.clone().disabled(), role);
assert_eq!(MenuItemSpec::separator().with_label("nope"), {
MenuItemSpec::Separator
});
}
#[test]
fn a_role_item_can_override_the_platform_label() {
assert_eq!(
MenuItemSpec::role(MenuRole::About).with_label("About Huddle"),
MenuItemSpec::Role {
role: MenuRole::About,
label: Some("About Huddle".to_string()),
}
);
}
}