From 2fafd251679d8c31e86d842177d58c371f83bd52 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Tue, 11 Aug 2026 00:35:03 -0500 Subject: [PATCH] vibe networking ui --- src/main.rs | 17 +- src/net_ui.rs | 458 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 src/net_ui.rs diff --git a/src/main.rs b/src/main.rs index 8d9b73f..5ab581a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,12 +1,15 @@ +//! Bevy 0.19 demo: a server/client session launcher. +//! +//! The launcher UI lives in its own module/plugin: [`net_ui::NetUiPlugin`]. + use bevy::prelude::*; +use net_ui::NetUiPlugin; + +mod net_ui; + fn main() { App::new() - .add_plugins(DefaultPlugins) - .add_systems(Startup, setup) + .add_plugins((DefaultPlugins, NetUiPlugin)) .run(); -} - -fn setup(mut commands: Commands) { - commands.spawn(Camera2d); -} +} \ No newline at end of file diff --git a/src/net_ui.rs b/src/net_ui.rs new file mode 100644 index 0000000..39b6948 --- /dev/null +++ b/src/net_ui.rs @@ -0,0 +1,458 @@ +//! Bevy 0.19 demo: a server/client session launcher UI, as a plugin. +//! +//! This is *UI only* — no aeronet networking is wired up yet. The Start/Connect/ +//! Stop handlers just log the aeronet calls they *would* make, so the wiring is +//! a drop-in replacement later. +//! +//! UI is built with Bevy 0.19's new scene notation (`bsn!`) composing classic +//! `bevy_ui` components (`Node`, `Button`, `Text`, `EditableText`). +//! +//! Add [`NetUiPlugin`] to any app to get the launcher: it exposes the [`NetUi`] +//! resource as the UI's source of truth, so external systems (e.g. aeronet +//! observers) can drive the UI by writing status/log/screen into it. + +use bevy::{ + input_focus::InputFocus, + prelude::*, + text::EditableText, +}; + +/// Plugin that provides the session-launcher UI. +/// +/// Registers the [`NetUi`] resource, spawns a 2D camera (required for UI), and +/// adds the systems that rebuild the screen, react to buttons, and repaint the +/// status/log text. +pub struct NetUiPlugin; + +impl Plugin for NetUiPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .init_resource::() + .add_systems(Startup, spawn_camera) + .add_systems( + Update, + ( + // Rebuild the whole UI whenever the screen/state changes. + // The `bsn!` scene is declarative, so the cheapest "re-render" + // is to despawn the previous scene root and spawn the current one. + rebuild_net_ui.run_if(resource_changed::), + // React to button presses and form fields. + handle_buttons, + // Repaint the status + log text from the resource. + paint_log.run_if(resource_changed::), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +/// Source of truth for the launcher UI. +/// +/// This is a *view-model*: it decides what the UI shows. External systems (the +/// future aeronet observers) write status/log into it; they do not read network +/// truth *from* it. The authoritative connection state lives in aeronet session +/// entities. +#[derive(Resource)] +pub struct NetUi { + /// Which screen of the launcher is showing. Changing it rebuilds the UI. + pub screen: Screen, + /// Short status line shown at the bottom of every screen. + pub status: String, + /// Chronological log of (would-be) network actions, shown at the bottom. + pub log: Vec, +} + +impl Default for NetUi { + fn default() -> Self { + Self { + screen: Screen::Menu, + status: "Idle — no networking wired yet (UI only)".into(), + log: Vec::new(), + } + } +} + +impl NetUi { + /// Append a line to the log panel. + pub fn push_log(&mut self, line: impl Into) { + self.log.push(line.into()); + } +} + +/// Which screen of the launcher is showing. Changing it rebuilds the UI. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Screen { + Menu, + Host, + Client, + Session, +} + +// --------------------------------------------------------------------------- +// Marker components (also hooked into by the BSN scene builders) +// --------------------------------------------------------------------------- + +/// Marks the scene root so it can be despawned on rebuild. +#[derive(Component, Default, Clone, Copy)] +struct NetUiRoot; + +#[derive(Component, Default, Clone, Copy)] +struct HostButton; +#[derive(Component, Default, Clone, Copy)] +struct ClientButton; +#[derive(Component, Default, Clone, Copy)] +struct BackButton; +#[derive(Component, Default, Clone, Copy)] +struct StartHostButton; +#[derive(Component, Default, Clone, Copy)] +struct ConnectButton; +#[derive(Component, Default, Clone, Copy)] +struct StopButton; + +#[derive(Component, Default, Clone, Copy)] +struct ConnectUrlField; +#[derive(Component, Default, Clone, Copy)] +struct HostPortField; + +#[derive(Component, Default, Clone, Copy)] +struct StatusText; +#[derive(Component, Default, Clone, Copy)] +struct LogText; + +// --------------------------------------------------------------------------- +// Setup +// --------------------------------------------------------------------------- + +fn spawn_camera(mut commands: Commands) { + commands.spawn(Camera2d); +} + +// --------------------------------------------------------------------------- +// Rebuild +// --------------------------------------------------------------------------- + +fn rebuild_net_ui( + mut commands: Commands, + ui: Res, + roots: Query>, +) { + for root in &roots { + commands.entity(root).despawn(); + } + match ui.screen { + Screen::Menu => { + commands.spawn_scene(menu_screen()).insert(NetUiRoot); + } + Screen::Host => { + commands.spawn_scene(host_screen()).insert(NetUiRoot); + } + Screen::Client => { + commands.spawn_scene(client_screen()).insert(NetUiRoot); + } + Screen::Session => { + commands.spawn_scene(session_screen()).insert(NetUiRoot); + } + } +} + +// --------------------------------------------------------------------------- +// Buttons + fields +// --------------------------------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn handle_buttons( + mut ui: ResMut, + url_field: Query<&EditableText, With>, + port_field: Query<&EditableText, With>, + q_host: Query<&Interaction, (With, Changed)>, + q_client: Query<&Interaction, (With, Changed)>, + q_back: Query<&Interaction, (With, Changed)>, + q_start: Query<&Interaction, (With, Changed)>, + q_connect: Query<&Interaction, (With, Changed)>, + q_stop: Query<&Interaction, (With, Changed)>, +) { + use Interaction::Pressed; + + // -------- Screen navigation ------------------------------------------- + if q_host.iter().any(|i| *i == Pressed) { + ui.screen = Screen::Host; + ui.status = "Host setup — pick a listen port.".into(); + } + if q_client.iter().any(|i| *i == Pressed) { + ui.screen = Screen::Client; + ui.status = "Client setup — enter a server URL.".into(); + } + if q_back.iter().any(|i| *i == Pressed) { + ui.screen = Screen::Menu; + ui.status = "Idle — select an action.".into(); + } + + // -------- Host: start server ------------------------------------------ + if q_start.iter().any(|i| *i == Pressed) { + let mut port = port_field + .single() + .map(|e| e.value().to_string()) + .unwrap_or_default(); + if port.is_empty() { + port = "25570".into(); + } + ui.push_log(format!( + "// TODO aeronet: server -> commands.spawn_empty().queue(\n \ + WebSocketServer::open(ServerConfig::builder().with_bind_raw(None, {port}))\n \ + );" + )); + ui.status = format!("Hosting on 0.0.0.0:{port} (UI shell only)"); + ui.screen = Screen::Session; + } + + // -------- Client: connect --------------------------------------------- + if q_connect.iter().any(|i| *i == Pressed) { + let mut url = url_field + .single() + .map(|e| e.value().to_string()) + .unwrap_or_default(); + if url.is_empty() { + url = "ws://127.0.0.1:25570".into(); + } + ui.push_log(format!( + "// TODO aeronet: client -> commands.spawn_empty().queue(\n \ + WebSocketClient::connect(ClientConfig::builder().with_no_cert_validation(), \"{url}\")\n \ + );" + )); + ui.status = format!("Connecting to {url} (UI shell only)"); + ui.screen = Screen::Session; + } + + // -------- Session: stop ----------------------------------------------- + if q_stop.iter().any(|i| *i == Pressed) { + ui.push_log("// TODO aeronet: stop -> commands.trigger(Disconnect::new(session, \"user\"))".to_string()); + ui.status = "Idle — select an action.".into(); + ui.screen = Screen::Menu; + } +} + +// --------------------------------------------------------------------------- +// Paint dynamic text +// --------------------------------------------------------------------------- + +fn paint_log( + ui: Res, + mut texts: Query<(Entity, &mut Text, Has, Has)>, +) { + for (_e, mut text, is_status, is_log) in &mut texts { + if is_status { + text.0 = ui.status.clone(); + } else if is_log { + text.0 = if ui.log.is_empty() { + "(nothing yet)".to_string() + } else { + ui.log.join("\n") + }; + } + } +} + +// --------------------------------------------------------------------------- +// Reusable BSN leaf scenes +// --------------------------------------------------------------------------- + +fn title(text: &str) -> impl Scene { + bsn! { + Text(text) + TextFont { font_size: px(30.) } + TextColor(Color::srgb(0.93, 0.94, 0.97)) + } +} + +fn subtitle(text: &str) -> impl Scene { + bsn! { + Text(text) + TextFont { font_size: px(15.) } + TextColor(Color::srgb(0.60, 0.64, 0.70)) + } +} + +fn field_label(text: &str) -> impl Scene { + bsn! { + Text(text) + TextFont { font_size: px(13.) } + TextColor(Color::srgb(0.55, 0.60, 0.66)) + } +} + +fn net_button(label: &str) -> impl Scene { + bsn! { + Button + Node { + width: px(300), + height: px(44), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border_radius: BorderRadius::MAX, + } + BackgroundColor(Color::srgb(0.16, 0.22, 0.32)) + Children [ + Text(label) + TextFont { font_size: px(16.) } + TextColor(Color::srgb(0.90, 0.92, 0.95)), + ] + } +} + +fn ghost_button(label: &str) -> impl Scene { + bsn! { + Button + Node { + width: px(300), + height: px(40), + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + border_radius: BorderRadius::MAX, + border: px(1), + } + BackgroundColor(Color::srgb(0.10, 0.12, 0.15)) + BorderColor::all(Color::srgb(0.30, 0.35, 0.40)) + Children [ + Text(label) + TextFont { font_size: px(14.) } + TextColor(Color::srgb(0.70, 0.73, 0.78)), + ] + } +} + +fn field_input() -> impl Scene { + bsn! { + EditableText { visible_width: {Some(20.)} } + Node { + width: px(300), + border: px(1), + border_radius: px(8), + padding: px(10), + } + BackgroundColor(Color::srgb(0.08, 0.10, 0.12)) + BorderColor::all(Color::srgb(0.28, 0.33, 0.40)) + } +} + +fn status_text(initial: &str) -> impl Scene { + bsn! { + Text(initial) + TextFont { font_size: px(13.) } + TextColor(Color::srgb(0.45, 0.55, 0.72)) + StatusText + } +} + +fn log_list() -> impl Scene { + bsn! { + Node { + margin: UiRect::top(px(20)), + width: px(420), + flex_direction: FlexDirection::Column, + align_items: AlignItems::Stretch, + padding: px(10), + border: px(1), + border_radius: px(8), + } + BackgroundColor(Color::srgb(0.06, 0.08, 0.10)) + BorderColor::all(Color::srgb(0.20, 0.24, 0.30)) + Children [ + ( + Text("Log") + TextFont { font_size: px(12.) } + TextColor(Color::srgb(0.50, 0.55, 0.62)) + ), + ( + Text("(nothing yet)") + LogText + TextFont { font_size: px(12.) } + TextColor(Color::srgb(0.55, 0.60, 0.66)) + TextLayout { linebreak: LineBreak::WordOrCharacter } + ), + ] + } +} + +fn spacer(height: f32) -> impl Scene { + bsn! { + Node { width: px(1), height: px(height) } + } +} + +/// A centered column that every screen shares. +fn screen_body(children: impl SceneList) -> impl Scene { + bsn! { + Node { + width: percent(100), + height: percent(100), + flex_direction: FlexDirection::Column, + justify_content: JustifyContent::Center, + align_items: AlignItems::Center, + row_gap: px(14), + } + BackgroundColor(Color::srgb(0.05, 0.06, 0.08)) + Children [ {children} ] + } +} + +// --------------------------------------------------------------------------- +// Per-screen scenes +// --------------------------------------------------------------------------- + +fn menu_screen() -> impl Scene { + screen_body(bsn_list![ + title("Aronet session launcher"), + subtitle("Host a dedicated server, or connect as a client."), + spacer(8.0), + (net_button("Host a server") HostButton), + (net_button("Connect to a client") ClientButton), + spacer(6.0), + status_text("Idle"), + log_list(), + ]) +} + +fn host_screen() -> impl Scene { + screen_body(bsn_list![ + title("Host a server"), + subtitle("WebSocket server; pick a listen port."), + spacer(8.0), + field_label("Listen port"), + (field_input() HostPortField), + spacer(10.0), + (net_button("Start server") StartHostButton), + (ghost_button("Back") BackButton), + status_text("Host setup"), + log_list(), + ]) +} + +fn client_screen() -> impl Scene { + screen_body(bsn_list![ + title("Connect to a server"), + subtitle("WebSocket URL the server is listening on."), + spacer(8.0), + field_label("Server URL (e.g. ws://127.0.0.1:25570)"), + (field_input() ConnectUrlField), + spacer(10.0), + (net_button("Connect") ConnectButton), + (ghost_button("Back") BackButton), + status_text("Client setup"), + log_list(), + ]) +} + +fn session_screen() -> impl Scene { + screen_body(bsn_list![ + title("In session"), + subtitle("UI shell only — the aeronet wiring goes here."), + spacer(10.0), + (net_button("Disconnect / Stop") StopButton), + (ghost_button("Back to menu") BackButton), + status_text("In session"), + log_list(), + ]) +}