Mosaic Framework
The mosaic feature provides a multi-pane desktop application framework built on egui_tiles. It handles dockable tile-based layouts, serializable widgets, theming, modals, notifications, and more.
Enabling Mosaic
nightshade = { git = "...", features = ["mosaic"] }
The mosaic feature requires egui (pulled in automatically).
Architecture
| Component | Description |
|---|---|
Widget<C, M> trait | Implement for each widget type - title, ui, lifecycle hooks, closability, camera requirements, catalog |
Mosaic<W, C, M> | Manages the tile tree, rendering, widget lifecycle, modals, layout persistence, messaging, and tree walking |
MosaicConfig | Configurable behavior - tab bar height, gap width, closability, add button visibility |
TileBehavior<W, C, M> | Implements egui_tiles::Behavior with closable tabs, drag-and-drop, and an add-widget popup with search |
ViewportWidget | Built-in 3D viewport with camera rendering, selection, and multi-camera selector |
WidgetContext<C, M> | Passed to widgets during rendering with world access, modal service, viewport state, app context, and message sending |
Modals | Modal dialog service for confirm and text input dialogs |
Pane<W> | Wraps a widget instance for the tile tree |
Core Concepts
The Widget Trait
Every pane in a mosaic layout implements the Widget trait. Widgets must be Clone + serde::Serialize + serde::Deserialize + 'static so layouts can be saved and restored.
#![allow(unused)] fn main() { use nightshade::prelude::*; use nightshade::mosaic::{Widget, WidgetContext, WidgetEntry, Pane, ViewportWidget}; #[derive(Clone, serde::Serialize, serde::Deserialize)] enum AppWidget { Viewport(ViewportWidget), Inspector(InspectorWidget), } impl Widget for AppWidget { fn title(&self) -> String { match self { AppWidget::Viewport(v) => v.title(), AppWidget::Inspector(_) => "Inspector".to_string(), } } fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext) { match self { AppWidget::Viewport(v) => v.ui(ui, context), AppWidget::Inspector(v) => v.ui(ui, context), } } fn catalog() -> Vec<WidgetEntry<Self>> { vec![ WidgetEntry { name: "Viewport".to_string(), create: || AppWidget::Viewport(ViewportWidget::default()), }, WidgetEntry { name: "Inspector".to_string(), create: || AppWidget::Inspector(InspectorWidget), }, ] } } }
The catalog() method defines the list of widgets available in the "+" add-widget popup.
Full trait definition:
#![allow(unused)] fn main() { trait Widget<C = (), M = ()>: Clone + Serialize + DeserializeOwned + 'static { fn title(&self) -> String; fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<C, M>); fn on_add(&mut self, _context: &mut WidgetContext<C, M>) {} fn on_remove(&mut self, _context: &mut WidgetContext<C, M>) {} fn closable(&self) -> bool { true } fn required_camera(&self, _cached_cameras: &[Entity]) -> Option<Entity> { None } fn catalog() -> Vec<WidgetEntry<Self>>; } }
The Mosaic Struct
Mosaic<W, C, M> manages a tile tree of widgets. The three type parameters are:
W- your widget enum (implementsWidget<C, M>)C- shared application context passed to all widgets (default())M- message type for inter-widget communication (default())
#![allow(unused)] fn main() { use nightshade::mosaic::Mosaic; struct MyApp { mosaic: Mosaic<AppWidget>, } }
Construction:
#![allow(unused)] fn main() { Mosaic::new() // Empty mosaic Mosaic::with_panes(vec![...]) // Single tab group with panes Mosaic::with_tree(tree) // Custom egui_tiles::Tree Mosaic::from_window_layout(layout) // From a saved WindowLayout mosaic.with_config(config) // Builder: set MosaicConfig mosaic.with_title(title) // Builder: set window title }
Create with initial panes:
#![allow(unused)] fn main() { let mosaic = Mosaic::with_panes(vec![ AppWidget::Viewport(ViewportWidget::default()), AppWidget::Inspector(InspectorWidget), ]); }
Or with an explicit egui_tiles::Tree:
#![allow(unused)] fn main() { let mut tiles = egui_tiles::Tiles::default(); let viewport = tiles.insert_pane(Pane::new(AppWidget::Viewport(ViewportWidget::default()))); let inspector = tiles.insert_pane(Pane::new(AppWidget::Inspector(InspectorWidget))); let root = tiles.insert_tab_tile(vec![viewport, inspector]); let tree = egui_tiles::Tree::new("my_tree", root, tiles); let mosaic = Mosaic::with_tree(tree); }
Rendering
Call mosaic.show() inside your State::ui() method:
#![allow(unused)] fn main() { impl State for MyApp { fn ui(&mut self, world: &mut World, ctx: &egui::Context) { self.mosaic.show(world, ctx, &mut ()); } } }
For rendering inside a specific egui::Ui region instead of the full window, use show_inside:
#![allow(unused)] fn main() { self.mosaic.show_inside(world, ui, &mut ()); }
WidgetContext
Every widget receives a WidgetContext in its ui() method:
#![allow(unused)] fn main() { context.world() // &World context.world_mut() // &mut World context.world_and_app() // (&mut World, &mut C) context.world_app_modals() // (&mut World, &mut C, &mut Modals) context.send(message) // Send an M message to the app context.receive() // Receive messages sent to this widget context.has_incoming() // Check if there are pending messages context.viewport_textures // Rendered viewport textures context.current_tile_id // This widget's tile ID context.selected_viewport_tile // Currently selected viewport context.modals // &mut Modals for showing dialogs context.app // &mut C application context context.window_index // Optional window index context.is_active_window // Whether this is the active window context.cached_cameras // &[Entity] of all camera entities }
Application Context and Messages
For non-trivial apps, use the context and message type parameters to share state and communicate between widgets and the app.
#![allow(unused)] fn main() { struct AppContext { selected_entity: Option<Entity>, fps_counter: FpsCounter, } enum AppMessage { EntitySelected(Entity), Log(String), } struct MyApp { mosaic: Mosaic<AppWidget, AppContext, AppMessage>, context: AppContext, } impl State for MyApp { fn ui(&mut self, world: &mut World, ctx: &egui::Context) { self.mosaic.show(world, ctx, &mut self.context); for message in self.mosaic.drain_messages() { match message { AppMessage::EntitySelected(entity) => { self.context.selected_entity = Some(entity); } AppMessage::Log(text) => { // ... } } } } } }
Targeted Messaging
Send messages to specific widgets or broadcast to all:
#![allow(unused)] fn main() { mosaic.send_to(tile_id, AppMessage::Refresh); mosaic.broadcast(AppMessage::ThemeChanged); mosaic.send_matching(|w| matches!(w, AppWidget::Log(_)), AppMessage::NewEntry); }
Widgets receive targeted messages via context.receive():
#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<AppContext, AppMessage>) { for message in context.receive() { match message { AppMessage::Refresh => { /* handle */ } _ => {} } } } }
Built-in ViewportWidget
ViewportWidget renders a camera's output into a pane. It supports camera selection when multiple cameras exist, viewport selection highlighting, and automatic active camera management.
#![allow(unused)] fn main() { use nightshade::mosaic::ViewportWidget; let viewport = ViewportWidget { camera_index: 0 }; }
Implement required_camera on your widget enum to tell the mosaic which cameras need rendering. The cached_cameras slice contains all camera entities, pre-sorted by entity ID:
#![allow(unused)] fn main() { fn required_camera(&self, cached_cameras: &[Entity]) -> Option<Entity> { match self { AppWidget::Viewport(v) => v.required_camera(cached_cameras), _ => None, } } }
Configuration
MosaicConfig controls layout behavior:
#![allow(unused)] fn main() { use nightshade::mosaic::MosaicConfig; let config = MosaicConfig { tab_bar_height: 24.0, close_button_size: 16.0, gap_width: 1.0, min_size: 32.0, all_closable: true, show_add_button: true, simplification_options: egui_tiles::SimplificationOptions { all_panes_must_have_tabs: true, ..Default::default() }, }; let mosaic = Mosaic::with_panes(vec![/* ... */]).with_config(config); }
Theming
The mosaic module includes a theme system with 11 built-in presets and a visual theme editor.
Built-in presets: Dark, Light, Dracula, Nord, Gruvbox Dark, Solarized Dark, Solarized Light, Monokai, One Dark, Tokyo Night, Catppuccin Mocha.
ThemeConfig supports ~80 override fields covering all egui visual properties: colors, stroke widths, corner radii, expansion, shadow sizes, and miscellaneous flags (button frames, striped backgrounds, slider trailing fill, etc.).
The preset combo box supports hover-to-preview - hovering a preset temporarily applies its visuals so you can see the effect before committing.
#![allow(unused)] fn main() { use nightshade::mosaic::{ThemeState, ThemeConfig, apply_theme, get_active_theme_visuals, render_theme_editor_window}; let mut theme_state = ThemeState::default(); // Apply theme each frame (uses preview theme when hovering presets) apply_theme(ctx, &theme_state); // Show the editor window if render_theme_editor_window(ctx, &mut theme_state) { // theme changed - save if desired } // Switch preset by name theme_state.select_preset_by_name("Dracula"); // Get the effective visuals (preview-aware) let visuals = get_active_theme_visuals(&theme_state); }
Modals
Show confirmation dialogs and text input prompts:
#![allow(unused)] fn main() { fn ui(&mut self, ui: &mut egui::Ui, context: &mut WidgetContext<AppContext, AppMessage>) { if ui.button("Delete").clicked() { context.modals.show_confirm("delete", "Confirm Delete", "Are you sure?"); } if let Some(result) = context.modals.take_result("delete") { match result { ModalResult::Confirmed => { /* delete */ } ModalResult::Cancelled => {} _ => {} } } } }
Text input modals:
#![allow(unused)] fn main() { context.modals.show_text_input("rename", "Rename", "Enter new name:", "default"); if let Some(ModalResult::TextInput(name)) = context.modals.take_result("rename") { // use name } }
Full Modals API:
#![allow(unused)] fn main() { modals.show_confirm(id, title, body) modals.show_confirm_with_text(id, title, body, confirm_text, cancel_text) modals.show_text_input(id, title, prompt, default_text) modals.show_text_input_with_text(id, title, prompt, default_text, confirm_text, cancel_text) modals.take_result(id) -> Option<ModalResult> modals.has_open_modal() -> bool }
Toast Notifications
Four toast kinds: Info, Success, Warning, Error - each with a distinct accent color and fade-out animation.
#![allow(unused)] fn main() { use nightshade::mosaic::{Toasts, ToastKind}; let mut toasts = Toasts::new(); toasts.push(ToastKind::Success, "Project saved", 3.0); toasts.push(ToastKind::Error, "Failed to load file", 4.0); toasts.push(ToastKind::Warning, "Low disk space", 3.0); toasts.push(ToastKind::Info, "Update available", 3.0); // Each frame: toasts.tick(delta_time); toasts.render(ctx); }
Status Bar
#![allow(unused)] fn main() { use nightshade::mosaic::StatusBar; let mut status_bar = StatusBar::new(); status_bar.add_left("FPS: 60"); status_bar.add_left_colored("Ready", egui::Color32::GREEN); status_bar.add_right("Theme: Nord"); status_bar.add_right_with_tooltip("v1.0", "Application version"); status_bar.render(ctx); }
Command Palette
#![allow(unused)] fn main() { use nightshade::mosaic::CommandPalette; let mut palette = CommandPalette::new(); palette.register("New File", Some("Ctrl+N".to_string()), || { /* ... */ }); palette.register("Save", Some("Ctrl+S".to_string()), || { /* ... */ }); // Toggle with a keybinding palette.toggle(); // Each frame: palette.render(ctx); }
Keyboard Shortcuts
#![allow(unused)] fn main() { use nightshade::mosaic::{ShortcutManager, KeyBinding}; let mut shortcuts = ShortcutManager::new(); shortcuts.register("Save", KeyBinding::ctrl(egui::Key::S), || { /* ... */ }); shortcuts.register("Undo", KeyBinding::ctrl(egui::Key::Z), || { /* ... */ }); shortcuts.register("Redo", KeyBinding::ctrl_shift(egui::Key::Z), || { /* ... */ }); // Each frame: shortcuts.process(ctx); }
KeyBinding constructors: new(key), ctrl(key), shift(key), ctrl_shift(key), alt(key).
File Dialogs
Mosaic re-exports the cross-platform file I/O functions from nightshade::filesystem for convenience. See the File System chapter for full documentation of all types and functions.
Native-only functions (pick_file, pick_folder, save_file_dialog, read_file, write_file) require the file_dialog feature, which is included in engine by default:
#![allow(unused)] fn main() { use nightshade::mosaic::{FileFilter, pick_file, pick_folder, save_file_dialog, read_file, write_file}; let filters = [FileFilter { name: "JSON".to_string(), extensions: vec!["json".to_string()], }]; if let Some(path) = pick_file(&filters) { let bytes = read_file(&path).unwrap(); } if let Some(path) = save_file_dialog(&filters, Some("data.json")) { write_file(&path, data.as_bytes()).unwrap(); } if let Some(folder) = pick_folder() { // use folder path } }
For cross-platform save/load that works on both native and WASM without #[cfg] gates, use save_file and request_file_load:
#![allow(unused)] fn main() { use nightshade::filesystem::{save_file, request_file_load, FileFilter}; // Save (native: save dialog, WASM: browser download) let filters = [FileFilter { name: "JSON".to_string(), extensions: vec!["json".to_string()], }]; save_file("data.json", &bytes, &filters)?; // Load (native: file picker + sync read, WASM: file input + async read) let pending = request_file_load(&filters); // poll pending.take() each frame }
Settings Persistence
Save and load application settings to <config_dir>/app_name/settings.json. Falls back to T::default() on missing or corrupt files.
#![allow(unused)] fn main() { use nightshade::mosaic::Settings; #[derive(Default, serde::Serialize, serde::Deserialize)] struct AppSettings { theme_name: Option<String>, recent_files: Vec<String>, } let settings: Settings<AppSettings> = Settings::load("my-app-name"); // settings.data.theme_name ... settings.save().ok(); }
FPS Counter
#![allow(unused)] fn main() { use nightshade::mosaic::FpsCounter; let mut fps = FpsCounter::new(0.5); // update every 0.5 seconds // or: let mut fps = FpsCounter::default(); // same 0.5s interval // Each frame: fps.tick(delta_time); // Read smoothed FPS: let fps_value = fps.fps(); // f32 let fps_rounded = fps.fps_rounded(); // u32 }
Event Log
Timestamped event log with category coloring and automatic scroll-to-bottom.
#![allow(unused)] fn main() { use nightshade::mosaic::EventLog; let mut log = EventLog::new(500); log.log("SYS", "Application started"); log.tick(delta_time); log.render(ui, |category| match category { "SYS" => egui::Color32::GRAY, "ERR" => egui::Color32::RED, _ => egui::Color32::WHITE, }); }
Recent Files
#![allow(unused)] fn main() { use nightshade::mosaic::RecentFiles; let mut recent = RecentFiles::new(10); recent.add(path); for path in recent.paths() { /* render menu items */ } }
Clipboard
#![allow(unused)] fn main() { use nightshade::mosaic::{get_clipboard_text, set_clipboard_text}; set_clipboard_text(ctx, "copied text"); if let Some(text) = get_clipboard_text(ctx) { /* paste */ } }
Drag and Drop
#![allow(unused)] fn main() { use nightshade::mosaic::{get_dropped_files, is_file_hovering, render_drop_overlay}; render_drop_overlay(ctx, "Drop files here"); for file in get_dropped_files(ctx) { if let Some(path) = file.path { /* handle file */ } if let Some(bytes) = file.bytes { /* handle raw bytes */ } } }
Project Save/Load
Save and load entire mosaic layouts. ProjectSaveFile<W, D> supports an optional generic data field for storing application-specific state alongside the layout:
#![allow(unused)] fn main() { use nightshade::mosaic::{save_project, load_project}; // Save (with no extra data) let project = save_project("My Project", "1.0", &[&mosaic], None::<()>); let json = serde_json::to_string_pretty(&project).unwrap(); // Save (with application data) let project = save_project("My Project", "1.0", &[&mosaic], Some(app_data)); // Load let project = serde_json::from_str(&json).unwrap(); let (windows, data) = load_project(project); for window in windows { let mosaic = Mosaic::from_window_layout(window); } }
ProjectSaveFile also supports direct file I/O (native only) with JSON and binary (bincode) formats:
#![allow(unused)] fn main() { use nightshade::mosaic::ProjectSaveFile; project.save_to_path(Path::new("project.json"))?; project.save_to_path(Path::new("project.bin"))?; let project = ProjectSaveFile::load_from_path(Path::new("project.json"))?; }
Or save/load just the tile tree:
#![allow(unused)] fn main() { let tree_json = mosaic.save_tree().unwrap(); mosaic.load_tree(tree_json).unwrap(); }
Or save/load a named layout:
#![allow(unused)] fn main() { let save = mosaic.save_layout("Main", "1.0.0"); mosaic.load_layout(save); }
Cross-Platform File Save/Load
Mosaic provides convenience methods that use nightshade::filesystem under the hood. These work on both native and WASM with no #[cfg] gates:
#![allow(unused)] fn main() { // Save layout to file (native: save dialog, WASM: browser download) mosaic.save_project_to_file("my_layout.json")?; // Request file load (returns PendingFileLoad) let pending = mosaic.request_project_load(); // Poll each frame: if let Some(file) = pending.take() { mosaic.load_project_from_bytes(&file.bytes)?; } }
save_project_to_file(filename)— serializes the tile tree to JSON and callsnightshade::filesystem::save_filerequest_project_load()— callsnightshade::filesystem::request_file_loadwith a JSON filterload_project_from_bytes(bytes)— deserializes and restores the tile tree from raw bytes
Widget Management
Add and remove widgets programmatically:
#![allow(unused)] fn main() { mosaic.insert_pane(widget) // Add widget to root container mosaic.insert_pane_in(container_id, widget) // Add widget to specific container mosaic.remove_pane(tile_id) // Remove widget, returns owned widget mosaic.find_widget(|w| predicate) // Find first matching widget's TileId mosaic.get_widget(tile_id) // Get &W by TileId mosaic.get_widget_mut(tile_id) // Get &mut W by TileId mosaic.activate_tab(tile_id) // Focus a widget's tab mosaic.widget_count() // Number of widget panes }
Tree Walking
#![allow(unused)] fn main() { mosaic.for_each_widget(|widget| { ... }); mosaic.for_each_widget_mut(|widget| { ... }); mosaic.for_each_widget_with_id(|tile_id, widget| { ... }); mosaic.for_each_widget_with_id_mut(|tile_id, widget| { ... }); }
Layout State
#![allow(unused)] fn main() { mosaic.layout_modified() // Check if layout changed (drag, close, add) mosaic.take_layout_modified() // Check and reset layout flag mosaic.layout_name // Current layout name (pub field) mosaic.title // Window title (pub field) mosaic.viewport_rects() // Rendered pane rectangles by TileId mosaic.selected_viewport_tile() // Currently selected viewport Mosaic::clear_required_cameras(world) // Clear the required cameras list mosaic.modals() // Get &mut Modals }
Layout Management
Mosaic supports multiple named layouts that can be switched, created, saved, and deleted:
#![allow(unused)] fn main() { mosaic.switch_layout(index) // Switch to layout by index mosaic.create_layout("name", default_tree) // Create a new layout mosaic.save_current_layout() // Save current tree to active layout mosaic.delete_current_layout() // Delete active layout (if more than one) mosaic.rename_layout(index, name) // Rename a layout mosaic.reset_layout(default_tree) // Reset active layout to default mosaic.active_layout_name() // Name of the active layout mosaic.active_layout_index() // Index of the active layout mosaic.layout_count() // Number of layouts mosaic.layouts() // &[WindowLayout<W>] mosaic.load_layouts(layouts, active_index) // Load layouts from saved data mosaic.save_layouts() // (Vec<WindowLayout<W>>, usize) for persistence }
A built-in layout menu UI is available:
#![allow(unused)] fn main() { let events = mosaic.render_layout_section(ui, || create_default_tree()); for event in events { match event { LayoutEvent::Switched(name) => { /* layout switched */ } LayoutEvent::Created(name) => { /* new layout created */ } LayoutEvent::Saved(name) => { /* layout saved */ } LayoutEvent::Deleted(name) => { /* layout deleted */ } LayoutEvent::Reset => { /* layout reset to default */ } LayoutEvent::Renamed(name) => { /* layout renamed */ } } } }
Multi-Window Support
Create one Mosaic per window. Each has its own layout, modals, and config:
#![allow(unused)] fn main() { mosaic.is_active_window = true; // Set whether this window is active (pub field) mosaic.window_index = Some(index); // Set the window index (pub field) mosaic.set_viewport_textures(textures); // Set viewport textures for secondary windows }
Use nightshade's secondary window system to spawn additional windows:
#![allow(unused)] fn main() { impl State for MyApp { fn ui(&mut self, world: &mut World, ctx: &egui::Context) { self.primary.show(world, ctx, &mut self.context); } fn secondary_ui(&mut self, world: &mut World, window_index: usize, ctx: &egui::Context) { let mosaic = self.secondary.entry(window_index).or_insert_with(|| { let mut m = Mosaic::with_panes(vec![AppWidget::Viewport(ViewportWidget::default())]); m.window_index = Some(window_index); m }); mosaic.show(world, ctx, &mut self.context); } fn pre_render(&mut self, renderer: &mut dyn Render, world: &mut World) { let cameras = world.resources.user_interface.required_cameras.clone(); for (&window_index, mosaic) in &mut self.secondary { let textures = renderer.register_camera_viewports_for_secondary(window_index, &cameras); mosaic.set_viewport_textures(textures); } } } }
Use save_project / load_project to persist all windows together.