use crate::about;
use crate::app::Message;
use crate::preferences;
use crate::theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Modal {
About,
Licences,
}
impl Modal {
pub fn title(self) -> &'static str {
match self {
Modal::About => "About Bombadil",
Modal::Licences => "Bundled licences",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopMenu {
Settings,
Help,
}
impl TopMenu {
pub fn label(self) -> &'static str {
match self {
TopMenu::Settings => "Settings",
TopMenu::Help => "Help",
}
}
pub fn entries(self) -> Vec<(&'static str, Message)> {
match self {
TopMenu::Settings => preferences::Section::ALL
.iter()
.map(|section| {
(
section.menu_label(),
Message::PreferencesSectionOpened(*section),
)
})
.collect(),
TopMenu::Help => vec![
("About Bombadil", Message::ModalOpened(Modal::About)),
("Bundled licences", Message::ModalOpened(Modal::Licences)),
],
}
}
}
pub const TOP_MENUS: [TopMenu; 2] = [TopMenu::Settings, TopMenu::Help];
pub fn version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn topbar<'a>(open_menu: Option<TopMenu>) -> iced::Element<'a, Message> {
let name = iced::widget::text("Bombadil")
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::TITLE);
let mut menus = iced::widget::row![].spacing(theme::SPACE_1);
for menu in TOP_MENUS {
let open = open_menu == Some(menu);
menus = menus.push(
iced::widget::button(iced::widget::text(menu.label()).size(theme::BODY))
.on_press(Message::MenuToggled(menu))
.padding([theme::SPACE_1, theme::SPACE_2])
.style(theme::button_bare(if open {
theme::PARCHMENT
} else {
theme::SLATE
})),
);
}
let bar = iced::widget::container(
iced::widget::row![
name,
iced::widget::Space::new().width(iced::Length::Fill),
menus
]
.align_y(iced::Alignment::Center),
)
.width(iced::Length::Fill)
.height(theme::TOPBAR_HEIGHT)
.padding([0.0, theme::SPACE_3])
.style(theme::surface);
iced::widget::column![bar, theme::hairline_row()].into()
}
pub fn menu_layer<'a>(menu: TopMenu) -> iced::Element<'a, Message> {
let mut panel = iced::widget::column![].spacing(0);
for (label, message) in menu.entries() {
panel = panel.push(
iced::widget::button(iced::widget::text(label).size(theme::BODY))
.on_press(message)
.width(iced::Length::Fill)
.padding([theme::SPACE_2, theme::SPACE_3])
.style(theme::button_bare(theme::PARCHMENT)),
);
}
dismissable(
iced::widget::container(panel)
.width(theme::MENU_WIDTH)
.style(theme::panel)
.into(),
[theme::TOPBAR_HEIGHT + theme::SPACE_1, theme::SPACE_3],
iced::alignment::Horizontal::Right,
Message::MenuDismissed,
)
}
fn dismissable<'a>(
panel: iced::Element<'a, Message>,
padding: [f32; 2],
align_x: iced::alignment::Horizontal,
dismiss: Message,
) -> iced::Element<'a, Message> {
let positioner = iced::widget::container(iced::widget::opaque(panel))
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(padding)
.align_x(align_x)
.align_y(iced::alignment::Vertical::Top);
iced::widget::opaque(iced::widget::mouse_area(positioner).on_press(dismiss))
}
pub fn modal_layer<'a>(modal: Modal) -> iced::Element<'a, Message> {
let body: iced::Element<'a, Message> = match modal {
Modal::About => about_body(),
Modal::Licences => licences_body(),
};
let card = iced::widget::container(
iced::widget::column![
iced::widget::text(modal.title()).size(theme::DISPLAY),
theme::hairline_row(),
body,
iced::widget::row![
iced::widget::Space::new().width(iced::Length::Fill),
iced::widget::button(iced::widget::text("Close").size(theme::BODY))
.on_press(Message::ModalClosed)
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
],
]
.spacing(theme::SPACE_3),
)
.width(theme::MODAL_WIDTH)
.padding(theme::SPACE_4)
.style(theme::panel);
let positioner = iced::widget::container(iced::widget::center(iced::widget::opaque(card)))
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::SCRIM)),
..iced::widget::container::Style::default()
});
iced::widget::opaque(iced::widget::mouse_area(positioner).on_press(Message::ModalClosed))
}
pub fn anchored_layer<'a>(
content: iced::Element<'a, Message>,
dismiss: Message,
) -> iced::Element<'a, Message> {
dismissable(
iced::widget::container(content)
.width(theme::CONTEXT_MENU_WIDTH)
.style(theme::panel)
.into(),
[
theme::TOPBAR_HEIGHT + theme::SPACE_3,
theme::SIDEBAR_WIDTH + theme::SPACE_2,
],
iced::alignment::Horizontal::Left,
dismiss,
)
}
pub fn overlay_layer<'a>(content: iced::Element<'a, Message>) -> iced::Element<'a, Message> {
let card = iced::widget::container(iced::widget::scrollable(content))
.max_width(theme::MODAL_WIDTH)
.max_height(theme::OVERLAY_MAX_HEIGHT)
.padding(theme::SPACE_4)
.style(theme::panel);
iced::widget::opaque(
iced::widget::container(iced::widget::center(card))
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(|_theme| iced::widget::container::Style {
background: Some(iced::Background::Color(theme::SCRIM)),
..iced::widget::container::Style::default()
}),
)
}
fn about_body<'a>() -> iced::Element<'a, Message> {
iced::widget::column![
iced::widget::text("Keeps an account of uv virtual environments it does not own.")
.size(theme::BODY),
theme::labeled_value("version", version().to_string()),
iced::widget::text(about::attribution())
.size(theme::LABEL)
.color(theme::SLATE),
iced::widget::text(about::font_attribution())
.size(theme::LABEL)
.color(theme::SLATE),
iced::widget::button(iced::widget::text("Read the bundled licences").size(theme::BODY))
.on_press(Message::ModalOpened(Modal::Licences))
.padding([theme::SPACE_1, theme::SPACE_3])
.style(theme::button_quiet),
]
.spacing(theme::SPACE_2)
.into()
}
fn licences_body<'a>() -> iced::Element<'a, Message> {
let mut column = iced::widget::column![].spacing(theme::SPACE_3);
for (heading, text) in [
("uv - Apache License 2.0", about::license()),
(
"Atkinson Hyperlegible Next - SIL Open Font License 1.1",
about::atkinson_license(),
),
(
"IBM Plex Mono - SIL Open Font License 1.1",
about::plex_license(),
),
] {
column = column.push(
iced::widget::column![
iced::widget::text(heading)
.font(theme::FONT_PROSE_SEMIBOLD)
.size(theme::BODY),
iced::widget::text(text)
.font(theme::FONT_DATA)
.size(theme::LABEL)
.color(theme::SLATE),
]
.spacing(theme::SPACE_2),
);
}
iced::widget::scrollable(column)
.height(theme::MODAL_BODY_HEIGHT)
.into()
}
pub fn empty_state<'a>() -> iced::Element<'a, Message> {
iced::widget::center(
iced::widget::column![
iced::widget::text("No projects yet").size(theme::DISPLAY),
iced::widget::text(
"Choose a project's pyproject.toml. Bombadil keeps the account of its \
environment: which interpreter it runs, what is installed, and what \
has drifted from the manifest."
)
.size(theme::BODY)
.color(theme::SLATE)
.width(420.0),
iced::widget::button(iced::widget::text("Add project").size(theme::BODY))
.on_press(Message::AddProjectPickManifestRequested)
.padding([theme::SPACE_2, theme::SPACE_3])
.style(theme::button_primary),
]
.spacing(theme::SPACE_3)
.align_x(iced::Alignment::Start),
)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_menu_offers_the_bundled_licences() {
assert!(
TopMenu::Help
.entries()
.iter()
.any(|(_, message)| matches!(message, Message::ModalOpened(Modal::Licences))),
"no menu entry opens the licences"
);
}
#[test]
fn every_menu_entry_is_labelled_and_does_something_distinct() {
for menu in TOP_MENUS {
let entries = menu.entries();
assert!(
!entries.is_empty(),
"{:?} is a menu button that opens an empty panel",
menu
);
for (label, _) in entries.iter() {
assert!(!label.trim().is_empty(), "a menu entry has no label");
}
let actions: Vec<String> = entries
.iter()
.map(|(_, message)| format!("{message:?}"))
.collect();
for (i, action) in actions.iter().enumerate() {
assert!(
!actions[i + 1..].contains(action),
"{action} is reached by two entries in {menu:?}"
);
}
}
}
#[test]
fn the_settings_menu_names_every_section() {
let entries = TopMenu::Settings.entries();
assert_eq!(
entries.len(),
preferences::Section::ALL.len(),
"every section must be reachable from the Settings menu"
);
for section in preferences::Section::ALL {
assert!(
entries.iter().any(|(_, message)| matches!(
message,
Message::PreferencesSectionOpened(opened) if *opened == section
)),
"{section:?} is not in the Settings menu"
);
}
}
#[test]
fn settings_and_help_are_separate_menus() {
let settings: Vec<String> = TopMenu::Settings
.entries()
.iter()
.map(|(label, _)| label.to_string())
.collect();
assert!(
!settings.iter().any(|label| label.contains("licence")),
"the licences belong under Help"
);
let help: Vec<String> = TopMenu::Help
.entries()
.iter()
.map(|(label, _)| label.to_string())
.collect();
assert!(
!help.iter().any(|label| label.contains("variable")),
"settings belong under Settings"
);
}
#[test]
fn every_modal_has_a_title() {
for modal in [Modal::About, Modal::Licences] {
assert!(!modal.title().trim().is_empty(), "{modal:?} has no title");
}
assert_ne!(
Modal::About.title(),
Modal::Licences.title(),
"two modals sharing a title cannot be told apart once open"
);
}
#[test]
fn the_reported_version_is_this_builds_version() {
assert_eq!(version(), env!("CARGO_PKG_VERSION"));
assert!(!version().is_empty());
}
}