Skip to content

Commit c7d7b5a

Browse files
authored
feat: add configurable toolbar, game view zoom, and menu bar extensions (#4)
* feat(bevy_workbench): add GameViewPlugin and reorder initialization * feat(workbench): add configurable toolbar and game view * feat(workbench): add menu bar extensions and game view zoom controls
1 parent bef93aa commit c7d7b5a

7 files changed

Lines changed: 193 additions & 49 deletions

File tree

examples/control_link.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ fn main() {
4343
}),
4444
)
4545
.insert_resource(ClearColor(Color::BLACK))
46-
.add_plugins(WorkbenchPlugin::default())
47-
.add_plugins(GameViewPlugin);
46+
.add_plugins(WorkbenchPlugin::default());
4847

4948
common::register_types(&mut app);
5049
register_panels(&mut app);

examples/minimal.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ fn main() {
2929
}),
3030
)
3131
.insert_resource(ClearColor(Color::BLACK))
32-
.add_plugins(WorkbenchPlugin::default())
33-
.add_plugins(GameViewPlugin);
32+
.add_plugins(WorkbenchPlugin::default());
3433

3534
common::register_types(&mut app);
3635

src/dock.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ impl TileLayoutState {
153153
let slot = match panel.id() {
154154
id if id.contains("inspector") => PanelSlot::Right,
155155
id if id.contains("console") || id.contains("timeline") => PanelSlot::Bottom,
156-
id if id.contains("game_view") => PanelSlot::Center,
156+
id if id.contains("game_view") || id.contains("preview") => PanelSlot::Center,
157157
_ => PanelSlot::Left,
158158
};
159159
let visible = panel.default_visible();
@@ -213,7 +213,7 @@ impl TileLayoutState {
213213
let slot = match str_id.as_str() {
214214
id if id.contains("inspector") => PanelSlot::Right,
215215
id if id.contains("console") || id.contains("timeline") => PanelSlot::Bottom,
216-
id if id.contains("game_view") => PanelSlot::Center,
216+
id if id.contains("game_view") || id.contains("preview") => PanelSlot::Center,
217217
_ => PanelSlot::Left,
218218
};
219219
let tile_id = tiles.insert_pane(PaneEntry { panel_id });

src/game_view.rs

Lines changed: 67 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,22 @@ use bevy::state::prelude::DespawnOnEnter;
77

88
use crate::dock::{TileLayoutState, WorkbenchPanel};
99
use crate::mode::EditorMode;
10+
use crate::theme::gray;
1011

1112
/// Marker component for the preview camera that renders to the game view texture.
1213
#[derive(Component)]
1314
pub struct GameViewCamera;
1415

16+
/// Zoom mode for the game view panel.
17+
#[derive(Debug, Clone, Copy, PartialEq, Default)]
18+
pub enum ViewZoom {
19+
/// Fit the image within the available panel space, preserving aspect ratio.
20+
#[default]
21+
Auto,
22+
/// Display at a fixed scale factor (1.0 = 100%).
23+
Fixed(f32),
24+
}
25+
1526
/// Resource holding the game view render state.
1627
#[derive(Resource)]
1728
pub struct GameViewState {
@@ -130,6 +141,8 @@ pub struct GameViewPanel {
130141
pub is_playing: bool,
131142
/// Localized "press play" text.
132143
pub press_play_text: String,
144+
/// Current zoom mode.
145+
pub zoom: ViewZoom,
133146
}
134147

135148
impl WorkbenchPanel for GameViewPanel {
@@ -157,37 +170,77 @@ impl WorkbenchPanel for GameViewPanel {
157170
}
158171

159172
if let Some(tex_id) = self.egui_texture_id {
160-
let available = ui.available_size();
161173
let res = if self.resolution.x > 0 && self.resolution.y > 0 {
162174
self.resolution
163175
} else {
164176
UVec2::new(1280, 720)
165177
};
178+
179+
// Zoom toolbar
180+
ui.horizontal(|ui| {
181+
let zoom_label = match self.zoom {
182+
ViewZoom::Auto => "Auto".to_string(),
183+
ViewZoom::Fixed(z) => format!("{:.0}%", z * 100.0),
184+
};
185+
egui::ComboBox::from_id_salt("game_view_zoom")
186+
.selected_text(&zoom_label)
187+
.show_ui(ui, |ui| {
188+
ui.selectable_value(&mut self.zoom, ViewZoom::Auto, "Auto");
189+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(0.5), "50%");
190+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(0.75), "75%");
191+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(1.0), "100%");
192+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(1.25), "125%");
193+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(1.5), "150%");
194+
ui.selectable_value(&mut self.zoom, ViewZoom::Fixed(2.0), "200%");
195+
});
196+
197+
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
198+
ui.colored_label(gray::S550, format!("{}×{}", res.x, res.y));
199+
});
200+
});
201+
202+
ui.separator();
203+
204+
let available = ui.available_size();
166205
let aspect = res.x as f32 / res.y as f32;
167206

168-
// Fit-to-panel while preserving aspect ratio
169-
let (w, h) = {
170-
let w_from_h = available.y * aspect;
171-
if w_from_h <= available.x {
172-
(w_from_h, available.y)
173-
} else {
174-
(available.x, available.x / aspect)
207+
let display_size = match self.zoom {
208+
ViewZoom::Auto => {
209+
let w = available.x;
210+
let h = w / aspect;
211+
if h > available.y {
212+
egui::vec2(available.y * aspect, available.y)
213+
} else {
214+
egui::vec2(w, h)
215+
}
175216
}
217+
ViewZoom::Fixed(z) => egui::vec2(res.x as f32 * z, res.y as f32 * z),
176218
};
177219

178-
// Center the image within the panel
179-
let response = ui
180-
.with_layout(
220+
let padding = (available - display_size).max(egui::Vec2::ZERO) * 0.5;
221+
222+
// For fixed zoom that overflows, use a scroll area
223+
let response = if matches!(self.zoom, ViewZoom::Fixed(_))
224+
&& (display_size.x > available.x || display_size.y > available.y)
225+
{
226+
egui::ScrollArea::both()
227+
.show(ui, |ui| {
228+
ui.image(egui::load::SizedTexture::new(tex_id, display_size))
229+
})
230+
.inner
231+
} else {
232+
ui.add_space(padding.y);
233+
ui.with_layout(
181234
egui::Layout::centered_and_justified(ui.layout().main_dir()),
182-
|ui| ui.image(egui::load::SizedTexture::new(tex_id, [w, h])),
235+
|ui| ui.image(egui::load::SizedTexture::new(tex_id, display_size)),
183236
)
184-
.inner;
237+
.inner
238+
};
185239

186240
// Update focus resource
187241
let hovered = response.hovered();
188242
let image_rect = response.rect;
189243

190-
// Compute cursor position in render target coordinates
191244
let cursor_viewport_pos = if hovered {
192245
ui.ctx().pointer_latest_pos().and_then(|pointer_pos| {
193246
if image_rect.contains(pointer_pos) {
@@ -202,7 +255,6 @@ impl WorkbenchPanel for GameViewPanel {
202255
None
203256
};
204257

205-
// Show focus border when hovered
206258
if hovered {
207259
let painter = ui.painter();
208260
painter.rect_stroke(

src/lib.rs

Lines changed: 57 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ pub struct WorkbenchConfig {
3535
pub layout: layout::LayoutMode,
3636
pub show_menu_bar: bool,
3737
pub show_console: bool,
38+
/// Whether to show the built-in Play/Stop/Pause toolbar.
39+
/// Set to `false` for tools that don't use the game mode system
40+
/// (e.g., animation editors that are always "running").
41+
pub show_toolbar: bool,
42+
/// Whether to enable the built-in GameView render-to-texture pipeline.
43+
/// Set to `false` if your app has its own preview/rendering setup.
44+
pub enable_game_view: bool,
3845
}
3946

4047
impl Default for WorkbenchConfig {
@@ -43,6 +50,8 @@ impl Default for WorkbenchConfig {
4350
layout: layout::LayoutMode::Auto,
4451
show_menu_bar: true,
4552
show_console: true,
53+
show_toolbar: true,
54+
enable_game_view: true,
4655
}
4756
}
4857
}
@@ -94,39 +103,63 @@ impl Plugin for WorkbenchPlugin {
94103
})
95104
.insert_resource(i18n::I18n::new(settings.locale))
96105
.insert_resource(font::FontState::default())
106+
.add_message::<menu_bar::MenuAction>()
97107
.add_systems(Update, layout::detect_layout_system)
98-
.add_systems(Update, mode::mode_input_system)
99-
.add_systems(Update, mode::run_game_schedule_system)
100-
.add_systems(OnEnter(mode::EditorMode::Play), mode::on_enter_play)
101-
.add_systems(
102-
OnEnter(mode::EditorMode::Play),
103-
console::console_auto_clear_system,
104-
)
105-
.add_systems(OnEnter(mode::EditorMode::Pause), mode::on_enter_pause)
106-
.add_systems(OnEnter(mode::EditorMode::Edit), mode::on_enter_edit)
107108
.add_systems(Update, undo::undo_input_system)
108109
.add_systems(PreUpdate, assign_primary_egui_context_system)
109110
.add_systems(PreUpdate, console::console_drain_system)
110-
.add_systems(PreUpdate, inspector::mark_internal_entities_system)
111-
// UI systems must run in EguiPrimaryContextPass (bevy_egui 0.39 multi-pass mode)
112-
.add_systems(
113-
EguiPrimaryContextPass,
111+
.add_systems(PreUpdate, inspector::mark_internal_entities_system);
112+
113+
// Mode system (Play/Stop/Pause) — only when toolbar is enabled
114+
if self.config.show_toolbar {
115+
app.add_systems(Update, mode::mode_input_system)
116+
.add_systems(Update, mode::run_game_schedule_system)
117+
.add_systems(OnEnter(mode::EditorMode::Play), mode::on_enter_play)
118+
.add_systems(
119+
OnEnter(mode::EditorMode::Play),
120+
console::console_auto_clear_system,
121+
)
122+
.add_systems(OnEnter(mode::EditorMode::Pause), mode::on_enter_pause)
123+
.add_systems(OnEnter(mode::EditorMode::Edit), mode::on_enter_edit);
124+
}
125+
126+
// UI systems must run in EguiPrimaryContextPass (bevy_egui 0.39 multi-pass mode)
127+
{
128+
let ui_systems = (
114129
(
115-
(
116-
config::config_apply_system,
117-
font::install_fonts_system,
118-
theme::apply_theme_system,
119-
)
120-
.chain(),
121-
game_view::game_view_sync_system,
122-
menu_bar::menu_bar_system,
123-
dock::tiles_ui_system,
130+
config::config_apply_system,
131+
font::install_fonts_system,
132+
theme::apply_theme_system,
124133
)
125134
.chain(),
126-
);
135+
game_view::game_view_sync_system
136+
.run_if(resource_exists::<game_view::GameViewState>),
137+
menu_bar::menu_bar_system,
138+
)
139+
.chain();
140+
141+
if self.config.show_toolbar {
142+
app.add_systems(
143+
EguiPrimaryContextPass,
144+
(ui_systems, menu_bar::toolbar_system, dock::tiles_ui_system).chain(),
145+
);
146+
} else {
147+
app.add_systems(
148+
EguiPrimaryContextPass,
149+
(ui_systems, dock::tiles_ui_system).chain(),
150+
);
151+
}
152+
}
153+
154+
// Game view render-to-texture pipeline
155+
if self.config.enable_game_view {
156+
app.add_plugins(game_view::GameViewPlugin);
157+
}
127158

128159
// Register built-in panels
129-
app.register_panel(game_view::GameViewPanel::default());
160+
if self.config.enable_game_view {
161+
app.register_panel(game_view::GameViewPanel::default());
162+
}
130163
app.register_panel(inspector::InspectorPanel);
131164
if self.config.show_console {
132165
app.register_panel(console::ConsolePanel);

src/menu_bar.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,61 @@ use crate::dock::{TileLayoutState, WorkbenchPanel};
77
use crate::mode::EditorMode;
88
use crate::theme::gray;
99

10+
/// A custom item to inject into the File menu.
11+
pub struct MenuExtItem {
12+
/// Unique identifier for this action (e.g., "open", "save").
13+
pub id: &'static str,
14+
/// Display label (e.g., "Open...", "Save").
15+
pub label: String,
16+
/// Whether the item is clickable.
17+
pub enabled: bool,
18+
}
19+
20+
/// Resource for extending the workbench menu bar with custom items.
21+
#[derive(Resource, Default)]
22+
pub struct MenuBarExtensions {
23+
/// Custom items prepended to the File menu (before built-in "Settings").
24+
pub file_items: Vec<MenuExtItem>,
25+
/// Info text displayed after all menus (e.g., project title, resolution).
26+
pub info_text: Option<String>,
27+
}
28+
29+
/// Message sent when a custom menu item is clicked.
30+
#[derive(Message)]
31+
pub struct MenuAction {
32+
pub id: &'static str,
33+
}
34+
1035
/// System that renders the top menu bar.
1136
pub fn menu_bar_system(
1237
mut contexts: EguiContexts,
13-
current_mode: Res<State<EditorMode>>,
14-
mut next_mode: ResMut<NextState<EditorMode>>,
1538
mut tile_state: ResMut<TileLayoutState>,
1639
i18n: Res<crate::i18n::I18n>,
1740
mut undo_stack: ResMut<crate::undo::UndoStack>,
41+
extensions: Option<Res<MenuBarExtensions>>,
42+
mut menu_actions: MessageWriter<MenuAction>,
1843
) {
1944
let Ok(ctx) = contexts.ctx_mut() else { return };
2045
egui::TopBottomPanel::top("workbench_menu_bar").show(ctx, |ui| {
2146
egui::MenuBar::new().ui(ui, |ui| {
2247
// Left side: menus
2348
ui.menu_button(i18n.t("menu-file"), |ui| {
49+
// Custom file menu items (from extensions)
50+
if let Some(ref ext) = extensions {
51+
for item in &ext.file_items {
52+
if ui
53+
.add_enabled(item.enabled, egui::Button::new(&item.label))
54+
.clicked()
55+
{
56+
menu_actions.write(MenuAction { id: item.id });
57+
ui.close();
58+
}
59+
}
60+
if !ext.file_items.is_empty() {
61+
ui.separator();
62+
}
63+
}
64+
2465
if ui.button(i18n.t("menu-file-settings")).clicked() {
2566
tile_state.request_open_panel("settings");
2667
ui.close();
@@ -115,10 +156,30 @@ pub fn menu_bar_system(
115156
}
116157
}
117158
});
159+
160+
// Extension info text (e.g., project title)
161+
if let Some(ref ext) = extensions
162+
&& let Some(ref text) = ext.info_text
163+
{
164+
ui.separator();
165+
ui.label(text);
166+
}
118167
});
119168
});
120169

121170
// Secondary toolbar — centered Play/Pause/Stop
171+
}
172+
173+
/// System that renders the Play/Pause/Stop toolbar.
174+
/// Only added when `WorkbenchConfig::show_toolbar` is `true`.
175+
pub fn toolbar_system(
176+
mut contexts: EguiContexts,
177+
current_mode: Res<State<EditorMode>>,
178+
mut next_mode: ResMut<NextState<EditorMode>>,
179+
i18n: Res<crate::i18n::I18n>,
180+
) {
181+
let Ok(ctx) = contexts.ctx_mut() else { return };
182+
122183
let btn_fill = gray::S250;
123184
egui::TopBottomPanel::top("workbench_toolbar").show(ctx, |ui| {
124185
ui.horizontal_centered(|ui| {

src/prelude.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ pub use crate::console::{ConsolePanel, console_log_layer};
99
pub use crate::dock::{PanelSlot, TileLayoutState, WorkbenchPanel};
1010
pub use crate::font::FontConfig;
1111
pub use crate::game_view::{
12-
GameViewCamera, GameViewFocus, GameViewPanel, GameViewPlugin, GameViewState,
12+
GameViewCamera, GameViewFocus, GameViewPanel, GameViewPlugin, GameViewState, ViewZoom,
1313
};
1414
pub use crate::i18n::{I18n, Locale};
1515
pub use crate::inspector::InspectorPanel;
16-
pub use crate::keybind::{KeyBind, KeyBindSlot, KeyBindings};
1716
pub use crate::layout::{LayoutMode, LayoutState};
17+
pub use crate::menu_bar::{MenuAction, MenuBarExtensions, MenuExtItem};
1818
pub use crate::mode::{EditorMode, GameClock, GameSchedule, ModeController, on_fresh_play};
1919
pub use crate::theme::{ThemeConfig, ThemePreset, ThemeState};
2020
pub use crate::undo::{UndoAction, UndoStack};

0 commit comments

Comments
 (0)