maruzzella 0.1.1

GTK4 desktop shell prototype in Rust with persisted layouts and plugin-backed views.
Documentation

Maruzzella

Maruzzella is an original GTK4 desktop shell prototype built in Rust.

It focuses on the shell itself rather than on any specific product domain: custom tabbed workbench areas, side panels, bottom panels, split layouts, lightweight command handling, and persisted workspace state.

Current Status

Today the project is in an intermediate but coherent state:

  • the GTK shell itself is working
  • layout persistence is working
  • the public crate API is usable by downstream apps
  • the first plugin ABI/runtime slice is working for loading plugins, resolving dependencies, merging commands and menus, and dispatching plugin commands
  • a built-in maruzzella.base plugin now provides core About and Plugins shell commands/menu entries
  • plugin-backed GTK views can now be mounted into tabs
  • the default app now boots into a coherent base-plugin-backed shell slice instead of placeholder-first ProductSpec text tabs
  • plugin configuration persistence and plugin settings-summary surfaces are wired through the host
  • plugin runtime services, host events, and discovery conventions are now available to downstreams
  • the built-in base plugin now ships a real editor tab with untitled buffers, file-backed documents, native open/save-as flows, and draft restore for dirty buffers
  • the host can now start in a dedicated launcher mode and switch between launcher and workspace shells at runtime

That means the plugin architecture is proven and the shell now exercises it in its default startup experience, but many shared shell contracts are still not formalized.

What It Does

  • renders a multi-pane desktop shell with left, right, bottom, and central workbench regions
  • supports a compact launcher shell distinct from the normal workspace shell
  • uses custom tab groups instead of GtkNotebook
  • supports tab activation, reordering, and workbench split previews
  • persists tab arrangement and pane sizes to a local JSON layout file
  • exposes shell commands for theme reload, command palette style actions, and editor buffer workflows
  • exposes semantic appearance roles for panels, buttons, typography, inputs, and tab strips
  • ships with a built-in workspace slice that can be replaced or extended by downstream products

Editor Tabs

The built-in maruzzella.base plugin now includes an editor workbench view.

  • New Buffer opens an untitled in-memory buffer
  • Open File In Editor accepts an explicit path payload or uses the native file picker when invoked without one
  • Save Buffer saves file-backed documents in place and falls back to Save As for untitled buffers
  • Save Buffer As uses the native save dialog and converts the current tab into a file-backed document
  • dirty editor drafts are stored through the host-backed plugin config record and restored on restart

This is still a text editor slice, not a full document system: there is no syntax highlighting, multi-document search, or conflict resolution yet.

Project Structure

  • src/app.rs: application bootstrap, shell construction, pane persistence wiring
  • src/product.rs: default branding, menus, toolbar actions, and starter layout
  • src/shell/: shell UI components including top bar, tabbed panels, and custom workbench tabs
  • src/layout.rs: load/save persisted shell state
  • src/spec.rs: serializable shell, tab, menu, and workbench layout model
  • src/theme.rs: runtime theme loading and token rendering
  • resources/style.css: default theme template

Run

Requirements:

  • Rust toolchain
  • GTK4 development libraries available on the system

Start the app with:

cargo run

Run the example app with:

cargo run --example notebook

Run the semantic appearance demo with:

cargo run --example semantic_appearance

Run the plugin view demo with:

cargo build -p example_plugin
cargo run --example plugin_view

Public API

The intended integration surface is the crate root:

  • MaruzzellaConfig: application id, persistence namespace, and product definition
  • MaruzzellaConfig::with_startup_mode(...): choose launcher or workspace as the initial shell mode
  • MaruzzellaConfig::with_launcher(...): provide a dedicated launcher shell spec
  • MaruzzellaConfig::with_launcher_window_policy(...): set launcher-specific default size/maximize behavior
  • MaruzzellaConfig::with_workspace_window_policy(...): override workspace window startup policy
  • build_application_with_handle(...): build the GTK application and keep a runtime handle for mode switching
  • MaruzzellaHandle::switch_to_workspace(...): replace launcher mode with a real workspace shell and optional project handle
  • MaruzzellaHandle::switch_to_launcher(): return from a workspace to the configured launcher without quitting
  • ThemeSpec: configurable palette, typography, density, semantic appearance registry, and optional external stylesheet template
  • MaruzzellaConfig::with_plugin_path(...): register a dynamic plugin library to load at startup
  • MaruzzellaConfig::with_plugin_dir(...): add a directory to plugin discovery
  • MaruzzellaConfig::without_default_plugin_discovery(): opt out of the built-in discovery convention
  • MaruzzellaConfig::with_theme(...): swap semantic appearances, typography, palette, sizing, or the whole stylesheet
  • default_plugin_discovery_dirs(...): inspect the built-in plugin discovery convention
  • discover_plugin_paths_in_dir(...): enumerate loadable plugin artifacts in a directory
  • run(config): launch a configured shell
  • build_application(config): build a GTK application without running it yet
  • ProductSpec and related spec types: define branding, menus, toolbar actions, panels, and workbench layout

Minimal example:

use maruzzella::{default_product_spec, run, MaruzzellaConfig, ThemeSpec};

fn main() {
    let mut product = default_product_spec();
    product.branding.title = "My App".to_string();

    let config = MaruzzellaConfig::new("com.example.my-app")
        .with_persistence_id("my-app")
        .with_theme(ThemeSpec::default())
        .with_product(product);

    run(config);
}

Dynamic plugins can also be attached through config:

use maruzzella::MaruzzellaConfig;

let config = MaruzzellaConfig::new("com.example.my-app")
    .with_plugin_path("plugins/libexample_plugin.so");

Launcher-mode startup:

use gtk::prelude::ApplicationExtManual;
use maruzzella::{
    build_application_with_handle, plugin_tab, LauncherSpec, MaruzzellaConfig, ShellMode,
    TabGroupSpec,
};

let launcher = LauncherSpec::new(
    "Sim RNS",
    TabGroupSpec::new(
        "launcher-home",
        Some("launcher"),
        vec![plugin_tab(
            "launcher",
            "launcher-home",
            "Launcher",
            "com.example.sim_rns.launcher",
            "Launcher plugin view failed to load.",
            false,
        )],
    )
    .with_tab_strip_hidden(),
);

let config = MaruzzellaConfig::new("com.example.sim-rns")
    .with_startup_mode(ShellMode::Launcher)
    .with_launcher(launcher);

let (app, handle) = build_application_with_handle(config);
// Later, when a project is opened:
// handle.switch_to_workspace(WorkspaceSession::new(product.shell_spec()))?;
app.run();

Discovery can also be directory-based. By default Maruzzella now scans:

  • $XDG_CONFIG_HOME/<persistence_id>/plugins
  • ./plugins

You can add more directories or opt out of the built-in convention:

use maruzzella::MaruzzellaConfig;

let config = MaruzzellaConfig::new("com.example.my-app")
    .with_plugin_dir("/opt/my-app/plugins")
    .without_default_plugin_discovery();

Styling And Theming

The preferred downstream styling path is now semantic, not selector-driven.

  • ThemeSpec::default() gives the bundled Maruzzella look
  • ThemeSpec exposes typed palette, typography, density, and semantic appearance registries
  • downstream apps can define or override named appearances such as primary, secondary, workbench, console, toolbar, title, or danger
  • ShellSpec, TabGroupSpec, TabSpec, and ToolbarItemSpec reference those appearances by stable ids
  • ThemeDensity also controls window defaults and panel minimum sizes
  • typography is standardized through named text roles such as title, body, meta, section-label, tab-label, and code
  • buttons are standardized through named button roles such as primary, secondary, ghost, toolbar, icon, and danger
  • ThemeSpec::with_stylesheet_path(...) points at an external GTK CSS template for full theme swapping
  • ThemeSpec::with_override(key, value) injects extra {{token}} values for custom templates
  • the stylesheet/template path remains an escape hatch for product-specific edge cases, not the normal downstream customization path

Minimal semantic appearance override:

use maruzzella::{
    ButtonAppearance, ButtonStyle, MaruzzellaConfig, SurfaceAppearance, SurfaceLevel,
    TextRole, ThemeSpec, Tone,
};

let theme = ThemeSpec::default()
    .with_surface_appearance(
        "primary",
        SurfaceAppearance::new(Tone::Secondary, SurfaceLevel::Raised, TextRole::Body),
    )
    .with_button_appearance(
        "primary",
        ButtonAppearance::new(Tone::Accent, ButtonStyle::Solid, TextRole::BodyStrong),
    );

let config = MaruzzellaConfig::new("com.example.my-app").with_theme(theme);

Minimal shell spec usage:

use maruzzella::{default_product_spec, MaruzzellaConfig};

let mut product = default_product_spec();
product.layout.left_panel = product
    .layout
    .left_panel
    .clone()
    .with_panel_appearance("secondary")
    .with_panel_header_appearance("secondary")
    .with_tab_strip_appearance("utility");

for item in &mut product.toolbar_items {
    item.appearance_id = "primary".to_string();
}

let config = MaruzzellaConfig::new("com.example.my-app").with_product(product);

Minimal theme override:

use maruzzella::{MaruzzellaConfig, ThemeSpec};

let mut theme = ThemeSpec::default();
theme.typography.font_family = "\"IBM Plex Sans\", \"Cantarell\", sans-serif".to_string();
theme.typography.mono_font_family = "\"IBM Plex Mono\", monospace".to_string();
theme.palette.accent = "#d06b2f".to_string();
theme.density.radius_medium = 14;
theme.density.toolbar_height = 44;
theme.density.window_default_width = 1680;
theme.density.min_side_panel_width = 260;

let config = MaruzzellaConfig::new("com.example.my-app").with_theme(theme);

See docs/appearance-api.md for the semantic styling model and built-in appearance ids.

Full stylesheet swap:

use maruzzella::{MaruzzellaConfig, ThemeSpec};

let theme = ThemeSpec::default()
    .with_stylesheet_path("themes/sand/style.css")
    .with_override("hero_glow", "alpha(#d06b2f, 0.18)");

let config = MaruzzellaConfig::new("com.example.my-app").with_theme(theme);

The bundled Reload Theme command now reloads the active theme spec, including the external stylesheet file if one is configured.

Persistence

By default, Maruzzella stores its workspace layout at:

$XDG_CONFIG_HOME/maruzzella/layout.json

If XDG_CONFIG_HOME is not set, it falls back to:

$HOME/.config/maruzzella/layout.json

If you set MaruzzellaConfig::with_persistence_id(...), the directory name changes accordingly.

Launcher and workspace modes persist independent shell layouts so switching modes does not overwrite the other mode's layout.

Deleting the layout file resets the shell to the default layout supplied in the config's ProductSpec.

Extending It

The easiest way to evolve the shell is to change the default ProductSpec in src/product.rs:

  • rename branding and window text
  • add commands, menus, and toolbar items
  • assign semantic appearances to panels, tab strips, buttons, and tab content
  • replace placeholder tabs with real views
  • reshape the central workbench tree with horizontal or vertical splits

The shell model in src/spec.rs is serializable, so layouts can be generated or persisted without coupling them to widget code.

Plugin Author Workflow

The repeatable plugin author path is now:

  1. Create a cdylib crate that depends on maruzzella_sdk.
  2. Export the plugin with export_plugin!(YourPlugin).
  3. Build the library for the current platform.
  4. Either point the host at the exact library with with_plugin_path(...) or place it in one of the discovery directories.

The sample plugin in plugins/example_plugin demonstrates:

  • command, menu, toolbar, settings, and view contributions
  • host-owned config persistence
  • service registration
  • host event subscription

See docs/plugin-author-workflow.md for a concrete workflow.

Packaging Notes

Maruzzella currently loads raw platform-native plugin libraries.

  • Linux plugins are expected as .so
  • macOS plugins are expected as .dylib
  • Windows plugins are expected as .dll

The current recommended packaging convention is to install plugin libraries into a product-managed plugin directory and point Maruzzella at that directory with discovery, rather than relying on ad hoc working-directory copies in production.

Versioning Policy

maruzzella_api now follows a simple policy:

  • additive changes that preserve MZ_ABI_VERSION_V1 keep the ABI version stable
  • any breaking C-ABI layout or semantic incompatibility requires a new ABI constant and corresponding host/plugin upgrade
  • maruzzella_sdk is expected to track the API crate closely and should be upgraded together in downstream plugin work

Design Notes

The first plugin ABI draft lives in docs/plugin-abi-rfc.md.

The workspace now also contains:

  • maruzzella_api: ABI-safe plugin boundary types
  • maruzzella_sdk: ergonomic Rust helpers and export macro for plugin authors
  • maruzzella.base: built-in plugin providing core shell commands and menu contributions
  • plugins/example_plugin: sample cdylib plugin using the SDK

On the host side, maruzzella now exposes the first loading and activation primitives:

  • load_plugin(path): open a dynamic library and decode its descriptor
  • resolve_load_order(&plugins): dependency-aware ordering
  • PluginRuntime::activate(plugins): invoke plugin register and startup against a host API and collect contributions

Plugin commands are now executable, not just declarative metadata: a plugin can register a command together with an ABI-safe handler function, and Maruzzella will dispatch GTK menu actions back into that plugin.

What is still rough:

  • some contribution surfaces are still intentionally small
  • packaging beyond raw platform libraries is not standardized
  • the SDK can still be tightened further as more third-party plugins appear