Skip to content

Commit 50dc4cd

Browse files
committed
wip: add example for testing controller windows
1 parent c12ce5f commit 50dc4cd

5 files changed

Lines changed: 328 additions & 55 deletions

File tree

sandpolis/examples/client_gui_basic.rs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
/// Basic GUI example demonstrating:
2-
/// - Node spawning from database
2+
/// - Node spawning from database with multiple nodes
33
/// - Minimap rendering
44
/// - Layer indicator
55
/// - Camera controls (pan, zoom)
66
/// - Layer switching (F/P/D keys)
7+
/// - Force-directed graph layout
78
use anyhow::Result;
89
use sandpolis::{InstanceState, MODELS, config::Configuration};
10+
use sandpolis_core::{InstanceId, InstanceType, RealmName};
911
use sandpolis_database::{DatabaseLayer, config::DatabaseConfig};
12+
use sandpolis_network::ConnectionData;
1013

1114
#[tokio::main]
1215
async fn main() -> Result<()> {
@@ -22,8 +25,46 @@ async fn main() -> Result<()> {
2225

2326
// Create instance state
2427
// The local instance will be spawned automatically
25-
let state = InstanceState::new(config.clone(), database).await?;
28+
let state = InstanceState::new(config.clone(), database.clone()).await?;
2629

27-
// Run the GUI
30+
// Populate the database with test nodes to demonstrate the GUI
31+
// This creates several agent and server connections that will appear in the world view
32+
{
33+
let db = database.realm(RealmName::default())?;
34+
let rw = db.rw_transaction()?;
35+
36+
// Create several test agent connections
37+
for i in 1..=5 {
38+
rw.insert(ConnectionData {
39+
_instance_id: state.instance.instance_id,
40+
remote_instance: InstanceId::new(&[InstanceType::Agent]),
41+
read_bytes: (i * 1024) as u64,
42+
write_bytes: (i * 512) as u64,
43+
read_throughput: (i * 100) as u64,
44+
write_throughput: (i * 50) as u64,
45+
..Default::default()
46+
})?;
47+
}
48+
49+
// Create a couple of server connections
50+
for i in 1..=2 {
51+
rw.insert(ConnectionData {
52+
_instance_id: state.instance.instance_id,
53+
remote_instance: InstanceId::new(&[InstanceType::Server]),
54+
read_bytes: (i * 2048) as u64,
55+
write_bytes: (i * 1024) as u64,
56+
read_throughput: (i * 200) as u64,
57+
write_throughput: (i * 100) as u64,
58+
..Default::default()
59+
})?;
60+
}
61+
62+
rw.commit()?;
63+
}
64+
65+
// Run the GUI - you should see 8 nodes total:
66+
// - 1 local instance (automatically created)
67+
// - 5 agent instances
68+
// - 2 server instances
2869
sandpolis::client::gui::main(config, state).await
2970
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/// Isolated test harness for the node controller window egui components.
2+
/// This example tests the controller rendering without starting Bevy.
3+
///
4+
/// Environment variables (all optional):
5+
/// - CONTROLLER: Controller to display (FileBrowser, Terminal, SystemInfo, PackageManager, DesktopViewer) [default: SystemInfo]
6+
///
7+
/// Usage: cargo run --example client_gui_node_controller --features client-gui
8+
///
9+
/// Examples:
10+
/// CONTROLLER=FileBrowser cargo run --example client_gui_node_controller --features client-gui
11+
/// CONTROLLER=Terminal cargo run --example client_gui_node_controller --features client-gui
12+
/// CONTROLLER=SystemInfo cargo run --example client_gui_node_controller --features client-gui
13+
use eframe::egui;
14+
use sandpolis::{InstanceState, config::Configuration, MODELS};
15+
use sandpolis::client::gui::controller::ControllerType;
16+
use sandpolis_core::InstanceId;
17+
use sandpolis_database::{DatabaseLayer, config::DatabaseConfig};
18+
use std::env;
19+
20+
#[tokio::main]
21+
async fn main() -> eframe::Result<()> {
22+
// Set up tracing
23+
tracing_subscriber::fmt::init();
24+
25+
// Read configuration from environment
26+
let controller = env::var("CONTROLLER")
27+
.ok()
28+
.and_then(|s| parse_controller(&s))
29+
.unwrap_or(ControllerType::SystemInfo);
30+
31+
// Create minimal configuration
32+
let config = Configuration::default();
33+
34+
// Create in-memory database
35+
let db_config = DatabaseConfig {
36+
storage: None,
37+
ephemeral: true,
38+
};
39+
let database = DatabaseLayer::new(db_config, &*MODELS).unwrap();
40+
41+
// Create instance state
42+
let state = InstanceState::new(config.clone(), database).await.unwrap();
43+
let instance_id = state.instance.instance_id;
44+
45+
println!("Testing node controller with:");
46+
println!(" Controller: {:?}", controller);
47+
println!(" Instance ID: {}", instance_id);
48+
49+
let options = eframe::NativeOptions {
50+
viewport: egui::ViewportBuilder::default()
51+
.with_inner_size([650.0, 500.0])
52+
.with_title("Node Controller Test Harness"),
53+
..Default::default()
54+
};
55+
56+
eframe::run_native(
57+
"Node Controller Test",
58+
options,
59+
Box::new(move |_cc| Ok(Box::new(NodeControllerTestApp::new(controller, instance_id, state)))),
60+
)
61+
}
62+
63+
fn parse_controller(s: &str) -> Option<ControllerType> {
64+
match s {
65+
"FileBrowser" => Some(ControllerType::FileBrowser),
66+
"Terminal" => Some(ControllerType::Terminal),
67+
"SystemInfo" => Some(ControllerType::SystemInfo),
68+
"PackageManager" => Some(ControllerType::PackageManager),
69+
"DesktopViewer" => Some(ControllerType::DesktopViewer),
70+
_ => None,
71+
}
72+
}
73+
74+
struct NodeControllerTestApp {
75+
controller_type: ControllerType,
76+
instance_id: InstanceId,
77+
state: InstanceState,
78+
}
79+
80+
impl NodeControllerTestApp {
81+
fn new(controller_type: ControllerType, instance_id: InstanceId, state: InstanceState) -> Self {
82+
Self {
83+
controller_type,
84+
instance_id,
85+
state,
86+
}
87+
}
88+
}
89+
90+
impl eframe::App for NodeControllerTestApp {
91+
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
92+
egui::CentralPanel::default().show(ctx, |ui| {
93+
ui.heading("Node Controller Component Test");
94+
ui.separator();
95+
96+
ui.label(format!("Controller: {}", self.controller_type.display_name()));
97+
ui.label(format!("Instance: {}", self.instance_id));
98+
99+
ui.add_space(20.0);
100+
101+
// Render the actual controller component in a frame
102+
egui::Frame::new()
103+
.fill(ui.visuals().window_fill())
104+
.stroke(ui.visuals().window_stroke())
105+
.corner_radius(ui.visuals().window_corner_radius)
106+
.inner_margin(8.0)
107+
.show(ui, |ui| {
108+
ui.set_width(600.0);
109+
ui.set_height(400.0);
110+
111+
// Call the actual render function with real state
112+
match self.controller_type {
113+
ControllerType::FileBrowser => {
114+
#[cfg(feature = "layer-filesystem")]
115+
sandpolis::client::gui::controller::file_browser::render(
116+
ui,
117+
&self.state,
118+
self.instance_id,
119+
);
120+
#[cfg(not(feature = "layer-filesystem"))]
121+
ui.label("Filesystem layer not enabled");
122+
}
123+
ControllerType::Terminal => {
124+
#[cfg(feature = "layer-shell")]
125+
sandpolis::client::gui::controller::terminal::render(
126+
ui,
127+
&self.state,
128+
self.instance_id,
129+
);
130+
#[cfg(not(feature = "layer-shell"))]
131+
ui.label("Shell layer not enabled");
132+
}
133+
ControllerType::SystemInfo => {
134+
#[cfg(feature = "layer-inventory")]
135+
sandpolis::client::gui::controller::system_info::render(
136+
ui,
137+
&self.state,
138+
self.instance_id,
139+
);
140+
#[cfg(not(feature = "layer-inventory"))]
141+
ui.label("Inventory layer not enabled");
142+
}
143+
ControllerType::PackageManager => {
144+
sandpolis::client::gui::controller::package_manager::render(
145+
ui,
146+
&self.state,
147+
self.instance_id,
148+
);
149+
}
150+
ControllerType::DesktopViewer => {
151+
#[cfg(feature = "layer-desktop")]
152+
sandpolis::client::gui::controller::desktop_viewer::render(
153+
ui,
154+
&self.state,
155+
self.instance_id,
156+
);
157+
#[cfg(not(feature = "layer-desktop"))]
158+
ui.label("Desktop layer not enabled");
159+
}
160+
ControllerType::None => {
161+
ui.label("No controller selected");
162+
}
163+
}
164+
});
165+
});
166+
}
167+
}

sandpolis/src/client/gui/input.rs

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ use std::ops::Range;
1313
#[derive(Resource)]
1414
pub struct MousePressed(pub bool);
1515

16+
#[derive(Resource, Default)]
17+
pub struct PanningState {
18+
/// Whether we're actively panning (started panning and haven't released yet)
19+
pub is_panning: bool,
20+
}
21+
1622
#[derive(Resource, Deref, DerefMut)]
1723
pub struct LayerChangeTimer(pub Timer);
1824

@@ -175,12 +181,30 @@ pub fn handle_zoom(
175181
mut mouse_wheel_input: EventReader<MouseWheel>,
176182
mut zoom_level: ResMut<ZoomLevel>,
177183
mut camera_query: Query<(&mut Projection, &Transform), With<Camera2d>>,
184+
controller_state: Res<super::controller::NodeControllerState>,
178185
) {
179-
// Don't handle zoom if egui wants the input
180186
let Ok(ctx) = contexts.ctx_mut() else {
181187
return;
182188
};
183-
if ctx.wants_pointer_input() || ctx.is_pointer_over_area() {
189+
190+
// Check if we're hovering over a controller window (which may have scrollable content)
191+
// Controller windows have expandable_node set
192+
let over_controller = controller_state.expanded_node.is_some() && ctx.is_pointer_over_area();
193+
194+
// Allow zoom if:
195+
// 1. Not over any egui area, OR
196+
// 2. Over a preview window (not a controller)
197+
// Preview windows are identified by having is_pointer_over_area() true but not being the controller
198+
let should_handle_zoom = if ctx.is_pointer_over_area() {
199+
// We're over some egui area
200+
// Only block zoom if we're over the controller window specifically
201+
!over_controller
202+
} else {
203+
// Not over any egui area - always handle zoom
204+
true
205+
};
206+
207+
if !should_handle_zoom {
184208
// Clear the events so they don't accumulate
185209
mouse_wheel_input.clear();
186210
return;
@@ -214,7 +238,9 @@ pub fn handle_camera(
214238
mut mouse_button_events: EventReader<MouseButtonInput>,
215239
mut mouse_motion_events: EventReader<MouseMotion>,
216240
mut mouse_pressed: ResMut<MousePressed>,
241+
mut panning_state: ResMut<PanningState>,
217242
mut cameras: Query<&mut Transform, With<Camera2d>>,
243+
drag_state: Res<super::drag::DragState>,
218244
) {
219245
// Don't handle camera movement if egui wants the input
220246
let Ok(ctx) = contexts.ctx_mut() else {
@@ -241,28 +267,42 @@ pub fn handle_camera(
241267
}
242268
}
243269

244-
// Handle mouse events only if egui doesn't want them
245-
if !egui_wants_pointer {
246-
// Store left-pressed state in the MousePressed resource
247-
for button_event in mouse_button_events.read() {
248-
if button_event.button != MouseButton::Left {
249-
continue;
250-
}
251-
*mouse_pressed = MousePressed(button_event.state.is_pressed());
270+
// Process mouse button events
271+
for button_event in mouse_button_events.read() {
272+
if button_event.button != MouseButton::Left {
273+
continue;
252274
}
253275

254-
if mouse_pressed.0 {
255-
let displacement = mouse_motion_events
256-
.read()
257-
.fold(Vec2::ZERO, |acc, mouse_motion| acc + mouse_motion.delta);
258-
camera_transform.translation -= Vec3::new(displacement.x, -displacement.y, 0.0);
259-
}
260-
} else {
261-
// If egui wants pointer, clear mouse events and reset pressed state
262-
mouse_button_events.clear();
263-
mouse_motion_events.clear();
264-
if mouse_pressed.0 {
276+
if button_event.state.is_pressed() {
277+
// Mouse button pressed
278+
if !egui_wants_pointer {
279+
// Only set pressed if egui doesn't want it initially
280+
*mouse_pressed = MousePressed(true);
281+
}
282+
} else {
283+
// Mouse button released - always clear panning state
265284
*mouse_pressed = MousePressed(false);
285+
panning_state.is_panning = false;
286+
}
287+
}
288+
289+
// Handle mouse motion for panning
290+
let motion_delta = mouse_motion_events
291+
.read()
292+
.fold(Vec2::ZERO, |acc, mouse_motion| acc + mouse_motion.delta);
293+
294+
if motion_delta != Vec2::ZERO {
295+
// Check if we should start or continue panning
296+
if mouse_pressed.0 && drag_state.dragging_entity.is_none() {
297+
if !panning_state.is_panning && !egui_wants_pointer {
298+
// Start panning (mouse is pressed, not over egui initially, and not dragging a node)
299+
panning_state.is_panning = true;
300+
}
301+
302+
// Continue panning once started, even if pointer crosses over egui
303+
if panning_state.is_panning {
304+
camera_transform.translation -= Vec3::new(motion_delta.x, -motion_delta.y, 0.0);
305+
}
266306
}
267307
}
268308
}

sandpolis/src/client/gui/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use self::{
44
DatabaseUpdateChannel, DatabaseUpdateSender, LayerIndicatorState, MinimapViewport,
55
SelectionSet, WorldView,
66
},
7-
input::{HelpScreenState, LayerChangeTimer, LoginDialogState, MousePressed},
7+
input::{HelpScreenState, LayerChangeTimer, LoginDialogState, MousePressed, PanningState},
88
node::spawn_node,
99
theme::{CurrentTheme, ThemePickerState},
1010
};
@@ -117,6 +117,7 @@ pub async fn main(config: Configuration, state: InstanceState) -> Result<()> {
117117
.insert_resource(state)
118118
.insert_resource(config)
119119
.insert_resource(MousePressed(false))
120+
.insert_resource(PanningState::default())
120121
.add_systems(Startup, setup)
121122
.add_systems(Startup, install_egui_loaders)
122123
.add_systems(Startup, theme::initialize_theme)

0 commit comments

Comments
 (0)