vibe networking ui

This commit is contained in:
2026-08-11 00:35:03 -05:00
parent 962ba52e8e
commit 2fafd25167
2 changed files with 468 additions and 7 deletions
+9 -6
View File
@@ -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);
}
+458
View File
@@ -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::<InputFocus>()
.init_resource::<NetUi>()
.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::<NetUi>),
// React to button presses and form fields.
handle_buttons,
// Repaint the status + log text from the resource.
paint_log.run_if(resource_changed::<NetUi>),
),
);
}
}
// ---------------------------------------------------------------------------
// 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<String>,
}
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<String>) {
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<NetUi>,
roots: Query<Entity, With<NetUiRoot>>,
) {
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<NetUi>,
url_field: Query<&EditableText, With<ConnectUrlField>>,
port_field: Query<&EditableText, With<HostPortField>>,
q_host: Query<&Interaction, (With<HostButton>, Changed<Interaction>)>,
q_client: Query<&Interaction, (With<ClientButton>, Changed<Interaction>)>,
q_back: Query<&Interaction, (With<BackButton>, Changed<Interaction>)>,
q_start: Query<&Interaction, (With<StartHostButton>, Changed<Interaction>)>,
q_connect: Query<&Interaction, (With<ConnectButton>, Changed<Interaction>)>,
q_stop: Query<&Interaction, (With<StopButton>, Changed<Interaction>)>,
) {
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<NetUi>,
mut texts: Query<(Entity, &mut Text, Has<StatusText>, Has<LogText>)>,
) {
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(),
])
}