maruzzella 0.1.1

GTK4 desktop shell prototype in Rust with persisted layouts and plugin-backed views.
Documentation
use std::collections::HashSet;

use maruzzella_api::{MzContributionSurface, MzStartupTab, MzToolbarItem};

use crate::plugins::PluginRuntime;
use crate::spec::{
    make_workbench_tabs_closeable, plugin_tab_with_instance, BottomPanelLayout, CommandSpec,
    MenuItemSpec, MenuRootSpec, PanelResizePolicy, ShellSpec, SplitAxis, TabGroupSpec,
    ToolbarItemSpec, WorkbenchNodeSpec,
};

#[derive(Clone, Debug)]
pub struct BrandingSpec {
    pub title: String,
    pub search_placeholder: String,
    pub search_command_id: Option<String>,
    pub status_text: String,
}

#[derive(Clone, Debug)]
pub struct LayoutContribution {
    pub bottom_panel_layout: BottomPanelLayout,
    pub left_panel: TabGroupSpec,
    pub right_panel: TabGroupSpec,
    pub bottom_panel: TabGroupSpec,
    pub workbench: WorkbenchNodeSpec,
    pub left_panel_resize: PanelResizePolicy,
    pub right_panel_resize: PanelResizePolicy,
    pub bottom_panel_resize: PanelResizePolicy,
}

#[derive(Clone, Debug)]
pub struct ProductSpec {
    pub branding: BrandingSpec,
    pub menu_roots: Vec<MenuRootSpec>,
    pub menu_items: Vec<MenuItemSpec>,
    pub commands: Vec<CommandSpec>,
    pub toolbar_items: Vec<ToolbarItemSpec>,
    pub include_base_toolbar_items: bool,
    pub layout: LayoutContribution,
}

impl ProductSpec {
    pub fn shell_spec(&self) -> ShellSpec {
        let mut workbench = self.layout.workbench.clone();
        make_workbench_tabs_closeable(&mut workbench);

        ShellSpec {
            title: self.branding.title.clone(),
            search_placeholder: self.branding.search_placeholder.clone(),
            search_command_id: self.branding.search_command_id.clone(),
            status_text: self.branding.status_text.clone(),
            app_appearance_id: "app-shell".to_string(),
            topbar_appearance_id: "topbar".to_string(),
            menu_appearance_id: "menu".to_string(),
            toolbar_appearance_id: "toolbar".to_string(),
            search_input_appearance_id: "search".to_string(),
            status_appearance_id: "status".to_string(),
            button_appearance_id: "secondary".to_string(),
            text_appearance_id: "body".to_string(),
            bottom_panel_layout: self.layout.bottom_panel_layout,
            menu_roots: self.menu_roots.clone(),
            menu_items: self.menu_items.clone(),
            commands: self.commands.clone(),
            toolbar_items: self.toolbar_items.clone(),
            left_panel: self.layout.left_panel.clone(),
            right_panel: self.layout.right_panel.clone(),
            bottom_panel: self.layout.bottom_panel.clone(),
            workbench,
            left_panel_resize: self.layout.left_panel_resize,
            right_panel_resize: self.layout.right_panel_resize,
            bottom_panel_resize: self.layout.bottom_panel_resize,
        }
    }
}

pub fn merge_plugin_runtime(
    spec: &mut ShellSpec,
    runtime: &PluginRuntime,
    include_base_toolbar_items: bool,
) {
    merge_runtime_commands(spec, runtime);
    merge_runtime_menus(spec, runtime);
    merge_runtime_toolbar(spec, runtime, include_base_toolbar_items);
}

pub fn merge_runtime_startup_tabs(spec: &mut ShellSpec, runtime: &PluginRuntime) {
    for contribution in runtime
        .surface_contributions()
        .iter()
        .filter(|contribution| contribution.surface == Some(MzContributionSurface::StartupTabs))
    {
        let Ok(tab) = MzStartupTab::from_bytes(&contribution.payload) else {
            runtime.push_diagnostic(
                Some(contribution.plugin_id.clone()),
                format!(
                    "invalid startup tab contribution payload: {}",
                    contribution.contribution_id
                ),
            );
            continue;
        };
        let closable = tab.closable || find_group_in_workbench(&spec.workbench, &tab.group_id);
        let Some(group) = find_group_mut(spec, &tab.group_id) else {
            continue;
        };
        if !group.tabs.is_empty() {
            continue;
        }
        if group.tabs.iter().any(|existing| existing.id == tab.tab_id) {
            continue;
        }
        group.tabs.push(plugin_tab_with_instance(
            &tab.tab_id,
            &tab.group_id,
            &tab.title,
            &tab.plugin_view_id,
            tab.instance_key.as_deref(),
            tab.payload,
            &tab.placeholder,
            closable,
        ));
        if tab.active {
            group.active_tab_id = Some(tab.tab_id);
        }
    }
}

fn merge_runtime_commands(spec: &mut ShellSpec, runtime: &PluginRuntime) {
    let mut known_command_ids = spec
        .commands
        .iter()
        .map(|command| command.id.clone())
        .collect::<HashSet<_>>();
    for command in runtime.commands() {
        if known_command_ids.insert(command.command_id.clone()) {
            spec.commands.push(CommandSpec {
                id: command.command_id.clone(),
                title: command.title.clone(),
            });
        }
    }
}

fn merge_runtime_menus(spec: &mut ShellSpec, runtime: &PluginRuntime) {
    let mut known_root_ids = spec
        .menu_roots
        .iter()
        .map(|root| root.id.clone())
        .collect::<HashSet<_>>();
    let mut known_menu_ids = spec
        .menu_items
        .iter()
        .map(|item| item.id.clone())
        .collect::<HashSet<_>>();

    for item in runtime.menu_items() {
        let Some(parent_surface) = item.parent_surface else {
            if !known_root_ids.contains(&item.parent_id) {
                continue;
            }
            if known_menu_ids.insert(item.menu_id.clone()) {
                spec.menu_items.push(MenuItemSpec {
                    id: item.menu_id.clone(),
                    root_id: item.parent_id.clone(),
                    label: item.title.clone(),
                    command_id: item.command_id.clone(),
                    payload: item.payload.clone(),
                });
            }
            continue;
        };

        let root_id = parent_surface.root_id();
        let root_label = parent_surface.root_label();

        if known_root_ids.insert(root_id.to_string()) {
            spec.menu_roots.push(MenuRootSpec {
                id: root_id.to_string(),
                label: root_label.to_string(),
            });
        }

        if known_menu_ids.insert(item.menu_id.clone()) {
            spec.menu_items.push(MenuItemSpec {
                id: item.menu_id.clone(),
                root_id: root_id.to_string(),
                label: item.title.clone(),
                command_id: item.command_id.clone(),
                payload: item.payload.clone(),
            });
        }
    }
}

fn merge_runtime_toolbar(
    spec: &mut ShellSpec,
    runtime: &PluginRuntime,
    include_base_toolbar_items: bool,
) {
    let mut known_toolbar_ids = spec
        .toolbar_items
        .iter()
        .map(|item| item.id.clone())
        .collect::<HashSet<_>>();

    for contribution in runtime
        .surface_contributions()
        .iter()
        .filter(|contribution| contribution.surface == Some(MzContributionSurface::ToolbarItems))
    {
        if !include_base_toolbar_items && contribution.plugin_id == "maruzzella.base" {
            continue;
        }
        let Ok(item) = MzToolbarItem::from_bytes(&contribution.payload) else {
            runtime.push_diagnostic(
                Some(contribution.plugin_id.clone()),
                format!(
                    "invalid toolbar contribution payload: {}",
                    contribution.contribution_id
                ),
            );
            continue;
        };
        if known_toolbar_ids.insert(item.item_id.clone()) {
            spec.toolbar_items.push(ToolbarItemSpec {
                id: item.item_id,
                icon_name: item.icon_name,
                label: item.label,
                command_id: item.command_id,
                payload: item.payload,
                secondary: item.secondary,
                display_mode: match item.display_mode {
                    maruzzella_api::MzToolbarDisplayMode::IconOnly => {
                        crate::spec::ToolbarDisplayMode::IconOnly
                    }
                    maruzzella_api::MzToolbarDisplayMode::IconAndText => {
                        crate::spec::ToolbarDisplayMode::IconAndText
                    }
                    maruzzella_api::MzToolbarDisplayMode::TextOnly => {
                        crate::spec::ToolbarDisplayMode::TextOnly
                    }
                },
                appearance_id: if item.appearance_id.is_empty() {
                    if item.secondary {
                        "ghost".to_string()
                    } else {
                        "primary".to_string()
                    }
                } else {
                    item.appearance_id
                },
            });
        }
    }
}

pub fn default_product_spec() -> ProductSpec {
    let branding = BrandingSpec {
        title: "Maruzzella".to_string(),
        search_placeholder: "Search Maruzzella".to_string(),
        search_command_id: None,
        status_text: "Neutral GTK workspace shell".to_string(),
    };
    let commands = vec![CommandSpec {
        id: "shell.reload_theme".to_string(),
        title: "Reload Theme".to_string(),
    }];
    let toolbar_items = Vec::new();
    let menu_roots = Vec::new();
    let menu_items = Vec::new();
    let layout = LayoutContribution {
        bottom_panel_layout: BottomPanelLayout::CenterOnly,
        left_panel: TabGroupSpec::new("panel-left", None, Vec::new())
            .with_panel_appearance("primary")
            .with_panel_header_appearance("secondary")
            .with_tab_strip_appearance("utility"),
        right_panel: TabGroupSpec::new("panel-right", None, Vec::new())
            .with_panel_appearance("secondary")
            .with_panel_header_appearance("secondary")
            .with_tab_strip_appearance("utility"),
        bottom_panel: TabGroupSpec::new("panel-bottom", None, Vec::new())
            .with_panel_appearance("console")
            .with_panel_header_appearance("secondary")
            .with_tab_strip_appearance("console")
            .with_text_appearance("code"),
        workbench: WorkbenchNodeSpec::Split {
            axis: SplitAxis::Horizontal,
            children: vec![
                WorkbenchNodeSpec::Group(
                    TabGroupSpec::new("workbench-main", None, Vec::new())
                        .with_panel_appearance("workbench")
                        .with_panel_header_appearance("secondary")
                        .with_tab_strip_appearance("editor"),
                ),
                WorkbenchNodeSpec::Group(
                    TabGroupSpec::new("workbench-secondary", None, Vec::new())
                        .with_panel_appearance("workbench")
                        .with_panel_header_appearance("secondary")
                        .with_tab_strip_appearance("editor"),
                ),
            ],
        },
        left_panel_resize: PanelResizePolicy::default(),
        right_panel_resize: PanelResizePolicy::default(),
        bottom_panel_resize: PanelResizePolicy::default(),
    };

    ProductSpec {
        branding,
        menu_roots,
        menu_items,
        commands,
        toolbar_items,
        include_base_toolbar_items: true,
        layout,
    }
}

fn find_group_mut<'a>(spec: &'a mut ShellSpec, group_id: &str) -> Option<&'a mut TabGroupSpec> {
    if spec.left_panel.id == group_id {
        return Some(&mut spec.left_panel);
    }
    if spec.right_panel.id == group_id {
        return Some(&mut spec.right_panel);
    }
    if spec.bottom_panel.id == group_id {
        return Some(&mut spec.bottom_panel);
    }
    find_group_mut_in_workbench(&mut spec.workbench, group_id)
}

fn find_group_mut_in_workbench<'a>(
    node: &'a mut WorkbenchNodeSpec,
    group_id: &str,
) -> Option<&'a mut TabGroupSpec> {
    match node {
        WorkbenchNodeSpec::Group(group) => (group.id == group_id).then_some(group),
        WorkbenchNodeSpec::Split { children, .. } => children
            .iter_mut()
            .find_map(|child| find_group_mut_in_workbench(child, group_id)),
    }
}

fn find_group_in_workbench(node: &WorkbenchNodeSpec, group_id: &str) -> bool {
    match node {
        WorkbenchNodeSpec::Group(group) => group.id == group_id,
        WorkbenchNodeSpec::Split { children, .. } => children
            .iter()
            .any(|child| find_group_in_workbench(child, group_id)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::plugins::{
        PluginRuntime, RegisteredCommand, RegisteredMenuItem, RegisteredSurfaceContribution,
    };
    use maruzzella_api::MzMenuSurface;

    #[test]
    fn merges_plugin_commands_and_surface_backed_menu_roots() {
        let mut runtime = PluginRuntime::empty_for_tests();
        runtime.activation_order = vec!["maruzzella.base".to_string()];
        runtime.commands = vec![RegisteredCommand {
            plugin_id: "maruzzella.base".to_string(),
            command_id: "shell.plugins".to_string(),
            title: "Plugins".to_string(),
            invoke: None,
        }];
        runtime.menu_items = vec![RegisteredMenuItem {
            plugin_id: "maruzzella.base".to_string(),
            menu_id: "plugins".to_string(),
            parent_id: "maruzzella.menu.file.items".to_string(),
            parent_surface: Some(MzMenuSurface::FileItems),
            title: "Plugins".to_string(),
            command_id: "shell.plugins".to_string(),
            payload: Vec::new(),
        }];
        runtime.surface_contributions = vec![RegisteredSurfaceContribution {
            plugin_id: "maruzzella.base".to_string(),
            surface_id: "maruzzella.about.sections".to_string(),
            surface: Some(maruzzella_api::MzContributionSurface::AboutSections),
            contribution_id: "base.about".to_string(),
            payload: Vec::new(),
        }];

        let mut spec = default_product_spec().shell_spec();
        merge_plugin_runtime(&mut spec, &runtime, true);

        assert!(spec
            .commands
            .iter()
            .any(|command| command.id == "shell.plugins"));
        assert!(spec.menu_roots.iter().any(|root| root.id == "file"));
        assert!(spec
            .menu_items
            .iter()
            .any(|item| item.id == "plugins" && item.root_id == "file"));
    }
}