use std::collections::HashSet;
use std::rc::Rc;
use gtk::gio;
use gtk::prelude::*;
use gtk::{
Box as GtkBox, Button, DropDown, Entry, EventControllerMotion, Fixed, Image, Label,
Orientation, Overlay, PopoverMenuBar,
};
use crate::app::ShellChrome;
use crate::commands::CommandRegistry;
use crate::spec::{
command_name, menu_action_ref, MenuItemSpec, ShellSpec, ToolbarDisplayMode, ToolbarItemSpec,
};
use crate::theme;
use maruzzella_sdk::attach_text_tooltip;
struct IconButtonTooltip {
button: Button,
label: Label,
}
pub struct TopBar {
pub root: GtkBox,
pub search: Entry,
tooltips: Vec<IconButtonTooltip>,
}
impl TopBar {
pub fn install_tooltip_overlay(&self, app_overlay: &Overlay) {
if self.tooltips.is_empty() {
return;
}
let fixed = Fixed::new();
fixed.set_can_target(false);
fixed.set_overflow(gtk::Overflow::Visible);
for tooltip in &self.tooltips {
tooltip.label.set_visible(false);
tooltip.label.set_can_target(false);
fixed.put(&tooltip.label, 0.0, 0.0);
}
app_overlay.add_overlay(&fixed);
for tooltip in &self.tooltips {
let button = tooltip.button.clone();
let label = tooltip.label.clone();
let fixed_ref = fixed.clone();
let hover = EventControllerMotion::new();
let label_enter = label.clone();
let button_enter = button.clone();
let fixed_enter = fixed_ref.clone();
hover.connect_enter(move |_, _, _| {
if let Some((bx, by)) = button_enter.translate_coordinates(&fixed_enter, 0.0, 0.0) {
let bw = button_enter.width() as f64;
let bh = button_enter.height() as f64;
let lw = label_enter.preferred_size().1.width() as f64;
let x = bx + (bw - lw) / 2.0;
let y = by + bh + 4.0;
fixed_enter.move_(&label_enter, x, y);
}
label_enter.set_visible(true);
});
let label_leave = label.clone();
hover.connect_leave(move |_| {
label_leave.set_visible(false);
});
button.add_controller(hover);
}
}
}
pub fn build(
spec: &ShellSpec,
chrome: ShellChrome,
registry: Option<&CommandRegistry>,
) -> Option<TopBar> {
if !chrome.show_menu_bar && !chrome.show_toolbar && !chrome.show_search {
return None;
}
let root = GtkBox::new(Orientation::Vertical, 0);
root.add_css_class("topbar-shell");
root.add_css_class(&theme::surface_css_class(&spec.topbar_appearance_id));
let search = Entry::new();
search.set_placeholder_text(Some(&spec.search_placeholder));
search.add_css_class(&theme::input_css_class(&spec.search_input_appearance_id));
let mut tooltips = Vec::new();
if chrome.show_menu_bar {
let masthead = GtkBox::new(Orientation::Horizontal, 12);
masthead.add_css_class("topbar-masthead");
masthead.add_css_class(&theme::surface_css_class(&spec.topbar_appearance_id));
let menu_model = build_menu_model(spec);
let menu_bar = PopoverMenuBar::from_model(Some(&menu_model));
menu_bar.add_css_class("menu-bar");
menu_bar.add_css_class(&theme::surface_css_class(&spec.menu_appearance_id));
menu_bar.set_hexpand(true);
masthead.append(&menu_bar);
root.append(&masthead);
}
if chrome.show_toolbar || chrome.show_search {
let toolbar = GtkBox::new(Orientation::Horizontal, 12);
toolbar.add_css_class("studio-toolbar");
toolbar.add_css_class(&theme::surface_css_class(&spec.toolbar_appearance_id));
if chrome.show_search {
let search_cluster = GtkBox::new(Orientation::Horizontal, 0);
search_cluster.add_css_class("toolbar-search-cluster");
search_cluster.set_hexpand(true);
search.add_css_class("toolbar-search");
search.set_hexpand(true);
search_cluster.append(&search);
toolbar.append(&search_cluster);
}
if chrome.show_toolbar {
if !chrome.show_search {
let spacer = GtkBox::new(Orientation::Horizontal, 0);
spacer.set_hexpand(true);
toolbar.append(&spacer);
}
let actions_group = GtkBox::new(Orientation::Horizontal, 8);
actions_group.add_css_class("toolbar-actions");
for item in spec.toolbar_items.iter().filter(|item| !item.secondary) {
actions_group.append(&action_bar_item_widget(item, &mut tooltips, registry));
}
toolbar.append(&actions_group);
let utility_group = GtkBox::new(Orientation::Horizontal, 6);
utility_group.add_css_class("toolbar-utility-group");
for item in spec.toolbar_items.iter().filter(|item| item.secondary) {
utility_group.append(&action_bar_item_widget(item, &mut tooltips, registry));
}
toolbar.append(&utility_group);
}
root.append(&toolbar);
}
Some(TopBar {
root,
search,
tooltips,
})
}
fn action_bar_item_widget(
item: &ToolbarItemSpec,
tooltips: &mut Vec<IconButtonTooltip>,
registry: Option<&CommandRegistry>,
) -> gtk::Widget {
let action_ref = menu_action_ref(&item.id);
match item.display_mode {
ToolbarDisplayMode::IconOnly => {
let icon_name = item.icon_name.as_deref().unwrap_or_else(|| {
panic!("toolbar item '{}' is IconOnly but has no icon", item.id)
});
let tooltip = item.label.as_deref().unwrap_or(&item.id);
icon_button(
icon_name,
&action_ref,
tooltip,
&item.appearance_id,
tooltips,
)
.upcast()
}
ToolbarDisplayMode::IconAndText => {
let label = item.label.as_deref().unwrap_or(&item.id);
let icon_name = item
.icon_name
.as_deref()
.unwrap_or("applications-system-symbolic");
toolbar_button(icon_name, label, &action_ref, &item.appearance_id).upcast()
}
ToolbarDisplayMode::TextOnly => {
let label = item.label.as_deref().unwrap_or(&item.id);
text_button(label, &action_ref, &item.appearance_id).upcast()
}
ToolbarDisplayMode::Dropdown => dropdown_item(item, registry).upcast(),
}
}
pub fn standalone_toolbar_item_button(item: &ToolbarItemSpec) -> Button {
match item.display_mode {
ToolbarDisplayMode::IconOnly => {
let icon_name = item
.icon_name
.as_deref()
.unwrap_or("applications-system-symbolic");
let tooltip = item.label.as_deref().unwrap_or(&item.id);
standalone_icon_button(icon_name, tooltip, &item.appearance_id)
}
ToolbarDisplayMode::IconAndText => {
let label = item.label.as_deref().unwrap_or(&item.id);
let icon_name = item
.icon_name
.as_deref()
.unwrap_or("applications-system-symbolic");
standalone_toolbar_button(icon_name, label, &item.appearance_id)
}
ToolbarDisplayMode::TextOnly | ToolbarDisplayMode::Dropdown => {
let label = item.label.as_deref().unwrap_or(&item.id);
standalone_text_button(label, &item.appearance_id)
}
}
}
fn dropdown_item(item: &ToolbarItemSpec, registry: Option<&CommandRegistry>) -> DropDown {
let labels = if item.options.is_empty() {
vec![item.label.clone().unwrap_or_else(|| item.id.clone())]
} else {
item.options
.iter()
.map(|option| option.label.clone())
.collect::<Vec<_>>()
};
let label_refs = labels.iter().map(String::as_str).collect::<Vec<_>>();
let dropdown = DropDown::from_strings(&label_refs);
dropdown.add_css_class("toolbar-dropdown");
dropdown.set_width_request(176);
if let Some(label) = &item.label {
dropdown.set_tooltip_text(Some(label));
}
let selected_index = item
.selected_index
.min(item.options.len().saturating_sub(1) as u32);
dropdown.set_selected(selected_index);
if item.options.is_empty() {
dropdown.set_sensitive(false);
return dropdown;
}
if registry
.map(|registry| !registry.is_enabled(&item.command_id))
.unwrap_or(false)
{
dropdown.set_sensitive(false);
return dropdown;
}
if let Some(handler) = registry.and_then(|registry| registry.handler_for(&item.command_id)) {
let options = item.options.clone();
dropdown.connect_selected_notify(move |dropdown| {
let selected = dropdown.selected();
if selected == gtk::INVALID_LIST_POSITION {
return;
}
if let Some(option) = options.get(selected as usize) {
handler(&option.payload);
}
});
} else {
dropdown.set_sensitive(false);
}
dropdown
}
fn toolbar_button(icon_name: &str, label: &str, action_name: &str, appearance_id: &str) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class(&theme::button_css_class(appearance_id));
button.set_action_name(Some(action_name));
let content = GtkBox::new(Orientation::Horizontal, 6);
let icon = Image::from_icon_name(icon_name);
icon.set_icon_size(gtk::IconSize::Normal);
content.append(&icon);
let text = Label::new(Some(label));
text.add_css_class("toolbar-button-label");
text.add_css_class(&theme::text_css_class("body"));
content.append(&text);
button.set_child(Some(&content));
button
}
fn text_button(label: &str, action_name: &str, appearance_id: &str) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class(&theme::button_css_class(appearance_id));
button.set_action_name(Some(action_name));
let text = Label::new(Some(label));
text.add_css_class("toolbar-button-label");
text.add_css_class(&theme::text_css_class("body"));
button.set_child(Some(&text));
button
}
fn standalone_toolbar_button(icon_name: &str, label: &str, appearance_id: &str) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class(&theme::button_css_class(appearance_id));
let content = GtkBox::new(Orientation::Horizontal, 6);
let icon = Image::from_icon_name(icon_name);
icon.set_icon_size(gtk::IconSize::Normal);
content.append(&icon);
let text = Label::new(Some(label));
text.add_css_class("toolbar-button-label");
text.add_css_class(&theme::text_css_class("body"));
content.append(&text);
button.set_child(Some(&content));
button
}
fn standalone_text_button(label: &str, appearance_id: &str) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class(&theme::button_css_class(appearance_id));
let text = Label::new(Some(label));
text.add_css_class("toolbar-button-label");
text.add_css_class(&theme::text_css_class("body"));
button.set_child(Some(&text));
button
}
fn icon_button(
icon_name: &str,
action_name: &str,
tooltip: &str,
appearance_id: &str,
tooltips: &mut Vec<IconButtonTooltip>,
) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class("toolbar-icon-button");
button.add_css_class(&theme::button_css_class(appearance_id));
button.set_action_name(Some(action_name));
let icon = Image::from_icon_name(icon_name);
icon.set_icon_size(gtk::IconSize::Normal);
button.set_child(Some(&icon));
let tip_label = Label::new(Some(tooltip));
tip_label.add_css_class("maruzzella-tooltip-label");
tip_label.add_css_class(&theme::text_css_class("meta"));
tooltips.push(IconButtonTooltip {
button: button.clone(),
label: tip_label,
});
button
}
fn standalone_icon_button(icon_name: &str, tooltip: &str, appearance_id: &str) -> Button {
let button = Button::new();
button.add_css_class("toolbar-button");
button.add_css_class("toolbar-icon-button");
button.add_css_class(&theme::button_css_class(appearance_id));
let icon = Image::from_icon_name(icon_name);
icon.set_icon_size(gtk::IconSize::Normal);
button.set_child(Some(&icon));
attach_text_tooltip(&button, tooltip);
button
}
fn build_menu_model(spec: &ShellSpec) -> gio::Menu {
let menu = gio::Menu::new();
for root in &spec.menu_roots {
let submenu = submenu(
&spec
.menu_items
.iter()
.filter(|item| item.root_id == root.id)
.cloned()
.collect::<Vec<_>>(),
);
menu.append_submenu(Some(&root.label), &submenu);
}
menu
}
fn submenu(items: &[MenuItemSpec]) -> gio::Menu {
let submenu = gio::Menu::new();
let mut section = gio::Menu::new();
for item in items {
if item.command_id.is_empty() {
if section.n_items() > 0 {
submenu.append_section(None, §ion);
section = gio::Menu::new();
}
continue;
}
section.append(Some(&item.label), Some(&menu_action_ref(&item.id)));
}
if section.n_items() > 0 {
submenu.append_section(None, §ion);
}
submenu
}
pub fn install_actions(
window: >k::ApplicationWindow,
spec: &ShellSpec,
registry: &CommandRegistry,
) -> Vec<String> {
let mut installed = Vec::new();
let action_bindings = Rc::new(action_bindings(spec, registry));
for (action_name, action_id) in action_bindings.iter() {
let simple = gio::SimpleAction::new(action_name, None);
simple.set_enabled(registry.is_enabled(action_id));
let handler = registry.handler_for(action_id);
let title = action_id.clone();
let window_for_activate = window.clone();
let registry = registry.clone();
let action_bindings = Rc::clone(&action_bindings);
simple.connect_activate(move |_, _| {
if let Some(handler) = handler.as_ref() {
handler(&[]);
} else {
eprintln!("unhandled command: {title}");
}
refresh_action_enabled(&window_for_activate, ®istry, &action_bindings);
});
window.add_action(&simple);
installed.push(action_name.clone());
}
installed
}
fn action_bindings(spec: &ShellSpec, registry: &CommandRegistry) -> Vec<(String, String)> {
let mut seen = HashSet::new();
let mut bindings = Vec::new();
for action_id in spec
.commands
.iter()
.map(|command| command.id.as_str())
.chain(spec.menu_items.iter().map(|item| item.id.as_str()))
.chain(spec.toolbar_items.iter().map(|item| item.id.as_str()))
{
let action_name = command_name(action_id);
if seen.insert(action_name.clone()) && registry.handler_for(action_id).is_some() {
bindings.push((action_name, action_id.to_string()));
}
}
bindings
}
fn refresh_action_enabled(
window: >k::ApplicationWindow,
registry: &CommandRegistry,
action_bindings: &[(String, String)],
) {
for (action_name, action_id) in action_bindings {
if let Some(action) = window
.lookup_action(action_name)
.and_then(|action| action.downcast::<gio::SimpleAction>().ok())
{
action.set_enabled(registry.is_enabled(action_id));
}
}
}