maruzzella 0.1.1

GTK4 desktop shell prototype in Rust with persisted layouts and plugin-backed views.
Documentation
#![doc = include_str!("../README.md")]

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

pub mod app;
pub mod base_plugin;
pub mod commands;
pub mod layout;
mod plugin_tabs;
pub mod plugins;
pub mod product;
pub mod shell;
pub mod spec;
pub mod theme;

use gtk::prelude::*;
use gtk::Application;

pub use app::{
    LauncherSpec, MaruzzellaHandle, ModeSwitchError, ShellChrome, ShellMode, WindowPolicy,
    WorkspaceSession,
};
pub use maruzzella_sdk::attach_text_tooltip;
pub use plugins::{
    diagnostic_for_load_error, diagnostic_for_runtime_error, load_plugin, load_static_plugin,
    resolve_load_order, LoadedPlugin, PluginDependencySpec, PluginDescriptor, PluginDiagnostic,
    PluginDiagnosticLevel, PluginHost, PluginLoadError, PluginLogEntry, PluginResolveError,
    PluginRuntime, PluginRuntimeError, RegisteredCommand, RegisteredMenuItem, RegisteredService,
    RegisteredSurfaceContribution, RegisteredViewFactory, Version as PluginVersion,
};
pub use product::{default_product_spec, BrandingSpec, LayoutContribution, ProductSpec};
pub use spec::{
    plugin_tab, plugin_tab_with_instance, text_tab, BottomPanelLayout, CommandSpec, MenuItemSpec,
    MenuRootSpec, PanelContentKind, ShellSpec, SplitAxis, TabGroupSpec, TabSpec,
    PanelResizePolicy, ToolbarDisplayMode, ToolbarItemSpec,
    WorkbenchNodeSpec,
};
pub use theme::{
    ButtonAppearance, ButtonStyle, InputAppearance, SurfaceAppearance, SurfaceLevel,
    TabStripAppearance, TabStripStyle, TextAppearance, TextRole, ThemeAppearances, ThemeDensity,
    ThemePalette, ThemeSpec, ThemeStylesheet, ThemeTypography, Tone,
};

#[derive(Clone, Debug)]
pub struct MaruzzellaConfig {
    pub application_id: String,
    pub persistence_id: String,
    pub product: ProductSpec,
    pub startup_mode: ShellMode,
    pub launcher: Option<LauncherSpec>,
    pub workspace_window_policy: Option<WindowPolicy>,
    pub launcher_window_policy: Option<WindowPolicy>,
    pub theme: ThemeSpec,
    pub plugin_paths: Vec<PathBuf>,
    pub plugin_dirs: Vec<PathBuf>,
    pub enable_default_plugin_discovery: bool,
    pub builtin_plugins: Vec<fn() -> Result<plugins::LoadedPlugin, plugins::PluginLoadError>>,
}

impl Default for MaruzzellaConfig {
    fn default() -> Self {
        Self::new("com.lelloman.maruzzella")
    }
}

impl MaruzzellaConfig {
    pub fn new(application_id: &str) -> Self {
        Self {
            application_id: application_id.to_string(),
            persistence_id: "maruzzella".to_string(),
            product: default_product_spec(),
            startup_mode: ShellMode::Workspace,
            launcher: None,
            workspace_window_policy: None,
            launcher_window_policy: None,
            theme: ThemeSpec::default(),
            plugin_paths: Vec::new(),
            plugin_dirs: Vec::new(),
            enable_default_plugin_discovery: true,
            builtin_plugins: Vec::new(),
        }
    }

    pub fn with_persistence_id(mut self, persistence_id: &str) -> Self {
        self.persistence_id = persistence_id.to_string();
        self
    }

    pub fn with_product(mut self, product: ProductSpec) -> Self {
        self.product = product;
        self
    }

    pub fn with_startup_mode(mut self, startup_mode: ShellMode) -> Self {
        self.startup_mode = startup_mode;
        self
    }

    pub fn with_launcher(mut self, launcher: LauncherSpec) -> Self {
        self.launcher = Some(launcher);
        self
    }

    pub fn with_workspace_window_policy(mut self, policy: WindowPolicy) -> Self {
        self.workspace_window_policy = Some(policy);
        self
    }

    pub fn with_launcher_window_policy(mut self, policy: WindowPolicy) -> Self {
        self.launcher_window_policy = Some(policy);
        self
    }

    pub fn with_theme(mut self, theme: ThemeSpec) -> Self {
        self.theme = theme;
        self
    }

    pub fn with_plugin_path(mut self, path: impl AsRef<Path>) -> Self {
        self.plugin_paths.push(path.as_ref().to_path_buf());
        self
    }

    pub fn with_plugin_paths<I, P>(mut self, paths: I) -> Self
    where
        I: IntoIterator<Item = P>,
        P: AsRef<Path>,
    {
        self.plugin_paths = paths
            .into_iter()
            .map(|path| path.as_ref().to_path_buf())
            .collect();
        self
    }

    pub fn with_plugin_dir(mut self, path: impl AsRef<Path>) -> Self {
        self.plugin_dirs.push(path.as_ref().to_path_buf());
        self
    }

    pub fn with_plugin_dirs<I, P>(mut self, paths: I) -> Self
    where
        I: IntoIterator<Item = P>,
        P: AsRef<Path>,
    {
        self.plugin_dirs = paths
            .into_iter()
            .map(|path| path.as_ref().to_path_buf())
            .collect();
        self
    }

    pub fn with_default_plugin_discovery(mut self) -> Self {
        self.enable_default_plugin_discovery = true;
        self
    }

    pub fn without_default_plugin_discovery(mut self) -> Self {
        self.enable_default_plugin_discovery = false;
        self
    }

    pub fn with_builtin_plugin(
        mut self,
        loader: fn() -> Result<plugins::LoadedPlugin, plugins::PluginLoadError>,
    ) -> Self {
        self.builtin_plugins.push(loader);
        self
    }

    pub fn with_builtin_plugins<I>(mut self, loaders: I) -> Self
    where
        I: IntoIterator<Item = fn() -> Result<plugins::LoadedPlugin, plugins::PluginLoadError>>,
    {
        self.builtin_plugins = loaders.into_iter().collect();
        self
    }
}

pub fn build_application(config: MaruzzellaConfig) -> Application {
    build_application_with_handle(config).0
}

pub fn build_application_with_handle(config: MaruzzellaConfig) -> (Application, MaruzzellaHandle) {
    let config_for_activate = config.clone();
    let handle = MaruzzellaHandle::default();
    let handle_for_activate = handle.clone();
    let application = build_application_with_activate(&config.application_id, move |application| {
        app::build(application, &config_for_activate, &handle_for_activate);
    });
    (application, handle)
}

pub fn build_application_with_activate<F>(application_id: &str, activate: F) -> Application
where
    F: Fn(&Application) + 'static,
{
    let application = Application::builder()
        .application_id(application_id)
        .build();

    application.connect_activate(activate);

    application
}

pub fn run_default() {
    run(MaruzzellaConfig::default());
}

pub fn run(config: MaruzzellaConfig) {
    build_application(config).run();
}

pub fn default_plugin_discovery_dirs(persistence_id: &str) -> Vec<PathBuf> {
    let mut dirs = vec![plugin_config_root(persistence_id).join("plugins"), PathBuf::from("plugins")];
    dirs.retain(|dir| !dir.as_os_str().is_empty());
    let mut seen = HashSet::new();
    dirs.into_iter()
        .filter(|dir| seen.insert(dir.clone()))
        .collect()
}

pub fn discover_plugin_paths_in_dir(dir: impl AsRef<Path>) -> Vec<PathBuf> {
    let Ok(entries) = fs::read_dir(dir) else {
        return Vec::new();
    };
    let mut paths = entries
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .filter(|path| path.is_file() && is_dynamic_plugin_library(path))
        .collect::<Vec<_>>();
    paths.sort();
    paths
}

pub fn is_dynamic_plugin_library(path: impl AsRef<Path>) -> bool {
    let Some(extension) = path.as_ref().extension().and_then(|ext| ext.to_str()) else {
        return false;
    };
    #[cfg(target_os = "windows")]
    {
        extension.eq_ignore_ascii_case("dll")
    }
    #[cfg(target_os = "macos")]
    {
        extension.eq_ignore_ascii_case("dylib")
    }
    #[cfg(all(unix, not(target_os = "macos")))]
    {
        extension.eq_ignore_ascii_case("so")
    }
}

fn plugin_config_root(persistence_id: &str) -> PathBuf {
    let mut root = if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
        PathBuf::from(dir)
    } else if let Ok(home) = std::env::var("HOME") {
        PathBuf::from(home).join(".config")
    } else {
        PathBuf::from(".")
    };
    root.push(persistence_id);
    root
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_plugin_discovery_includes_config_and_local_dirs() {
        let dirs = default_plugin_discovery_dirs("maruzzella-test");
        assert!(dirs.iter().any(|dir| dir.ends_with("maruzzella-test/plugins")));
        assert!(dirs.iter().any(|dir| dir == Path::new("plugins")));
    }

    #[test]
    fn discovery_filters_for_platform_plugin_libraries() {
        let temp = std::env::temp_dir().join(format!(
            "maruzzella-discovery-{}",
            std::process::id()
        ));
        let _ = fs::create_dir_all(&temp);
        let plugin_name = if cfg!(target_os = "windows") {
            "example_plugin.dll"
        } else if cfg!(target_os = "macos") {
            "libexample_plugin.dylib"
        } else {
            "libexample_plugin.so"
        };
        let plugin_path = temp.join(plugin_name);
        let non_plugin_path = temp.join("README.md");
        let _ = fs::write(&plugin_path, []);
        let _ = fs::write(&non_plugin_path, []);

        let discovered = discover_plugin_paths_in_dir(&temp);
        assert_eq!(discovered, vec![plugin_path.clone()]);
        assert!(is_dynamic_plugin_library(&plugin_path));
        assert!(!is_dynamic_plugin_library(&non_plugin_path));

        let _ = fs::remove_file(plugin_path);
        let _ = fs::remove_file(non_plugin_path);
        let _ = fs::remove_dir(temp);
    }
}