use crate::task::TaskId; use crate::{CrashReport, GameLibrary, SystemOS}; use prometeu_hal::color::Color; use prometeu_hal::primitives::Rect; use prometeu_hal::{ FrameId, GfxUiCommand, InputPlatform, InputSignals, RenderSubmission, RuntimePlatform, ShellUiFramePacket, }; use std::path::PathBuf; const SHELL_A_BUTTON: Rect = Rect { x: 112, y: 172, w: 112, h: 32 }; const SHELL_B_BUTTON: Rect = Rect { x: 256, y: 172, w: 112, h: 32 }; const CLOSE_BUTTON: Rect = Rect { x: 420, y: 24, w: 16, h: 16 }; const VIEWPORT: Rect = Rect { x: 0, y: 0, w: 480, h: 270 }; const HOME_PANEL: Rect = Rect { x: 48, y: 58, w: 384, h: 154 }; const TITLE_PANEL: Rect = Rect { x: 72, y: 34, w: 336, h: 28 }; const GAME_ROW_X: i32 = 72; const GAME_ROW_Y: i32 = 90; const GAME_ROW_W: i32 = 336; const GAME_ROW_H: i32 = 22; const GAME_ROW_GAP: i32 = 6; const MAX_HOME_GAME_ROWS: usize = 3; #[cfg(test)] const SHELL_FRAME: Rect = Rect { x: 40, y: 20, w: 400, h: 220 }; const COLOR_BG: Color = Color::rgb(12, 14, 22); const COLOR_PANEL: Color = Color::rgb(25, 31, 48); const COLOR_PANEL_DARK: Color = Color::rgb(8, 10, 16); const COLOR_BORDER: Color = Color::rgb(180, 218, 222); const COLOR_HILITE: Color = Color::rgb(252, 226, 122); const COLOR_TEXT: Color = Color::rgb(230, 236, 224); const COLOR_MUTED: Color = Color::rgb(91, 112, 128); const COLOR_BUTTON: Color = Color::rgb(45, 59, 82); const COLOR_BUTTON_ACTIVE: Color = Color::rgb(72, 91, 122); /// PrometeuHub: Launcher and system UI environment. pub struct PrometeuHub { home_input_armed: bool, game_library: GameLibrary, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NativeShellApp { ShellA, ShellB, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct HubButtonSpec { app: NativeShellApp, rect: Rect, label: &'static str, } const HOME_BUTTONS: [HubButtonSpec; 2] = [ HubButtonSpec { app: NativeShellApp::ShellA, rect: SHELL_A_BUTTON, label: "ShellA" }, HubButtonSpec { app: NativeShellApp::ShellB, rect: SHELL_B_BUTTON, label: "ShellB" }, ]; impl NativeShellApp { pub fn app_id(self) -> u32 { match self { Self::ShellA => 10_001, Self::ShellB => 10_002, } } pub fn title(self) -> &'static str { match self { Self::ShellA => "ShellA", Self::ShellB => "ShellB", } } pub fn color(self) -> Color { match self { Self::ShellA => Color::GREEN, Self::ShellB => Color::BLUE, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SystemProfileAction { LaunchNativeShell(NativeShellApp), LaunchGame { path: PathBuf }, CloseShell, } pub struct SystemProfileUpdate { pub crash: Option, pub action: Option, } impl Default for PrometeuHub { fn default() -> Self { Self::new() } } impl PrometeuHub { pub fn new() -> Self { Self { home_input_armed: false, game_library: GameLibrary::empty() } } pub fn init(&mut self) { self.home_input_armed = false; } pub fn set_game_library(&mut self, game_library: GameLibrary) { self.game_library = game_library; } pub fn game_library(&self) -> &GameLibrary { &self.game_library } pub fn gui_update( &mut self, os: &mut SystemOS, platform: &mut dyn RuntimePlatform, ) -> Option { let in_shell = os.windows().focused_window().is_some(); if !in_shell { if !self.home_input_armed { self.home_input_armed = !activation_input_down(platform.input()); return None; } } else { self.home_input_armed = false; } let input = platform.input(); if let Some(action) = action_for_click( in_shell, input.touch().x(), input.touch().y(), input.touch().f().pressed, self.game_library.entries(), ) { return Some(action); } None } pub fn render(&mut self, os: &mut SystemOS, platform: &mut dyn RuntimePlatform) { let pointer_x = platform.input().touch().x(); let pointer_y = platform.input().touch().y(); let packet = if os.windows().window_count() == 0 { render_home_packet(pointer_x, pointer_y, self.game_library.entries()) } else { let mut commands = Vec::new(); for window in os.windows().windows() { append_shell_window_commands( &mut commands, window.viewport, window.color, &window.title, pointer_x, pointer_y, ); } ShellUiFramePacket::new(commands) }; platform .render_submission_sink() .submit_render_submission(RenderSubmission::shell_ui(FrameId::ZERO, packet)) .expect("hub render submission should publish"); } pub fn update_shell_profile( &mut self, os: &mut SystemOS, platform: &mut dyn RuntimePlatform, ) -> SystemProfileUpdate { let mut action = self.gui_update(os, platform); if os.windows().focused_window().is_some() && platform.input().pad().start().down { action = Some(SystemProfileAction::CloseShell); } self.render(os, platform); SystemProfileUpdate { crash: None, action } } pub fn update_vm_shell_profile( &mut self, os: &mut SystemOS, task_id: TaskId, signals: &InputSignals, platform: &mut dyn RuntimePlatform, ) -> SystemProfileUpdate { let mut crash = None; let mut action = self.gui_update(os, platform); if os.windows().focused_window().is_some() { if platform.input().pad().start().down { action = Some(SystemProfileAction::CloseShell); } else if action != Some(SystemProfileAction::CloseShell) { crash = os.vm().tick_session(task_id, signals, platform); } } self.render(os, platform); SystemProfileUpdate { crash, action } } pub fn update_native_shell_profile( &mut self, os: &mut SystemOS, platform: &mut dyn RuntimePlatform, ) -> SystemProfileUpdate { let mut action = self.gui_update(os, platform); if os.windows().focused_window().is_some() && platform.input().pad().start().down { action = Some(SystemProfileAction::CloseShell); } self.render(os, platform); SystemProfileUpdate { crash: None, action } } } fn action_for_click( in_shell: bool, x: i32, y: i32, pressed: bool, game_entries: &[crate::GameLibraryEntry], ) -> Option { if !pressed { return None; } if in_shell { return rect_contains(CLOSE_BUTTON, x, y).then_some(SystemProfileAction::CloseShell); } if let Some(entry) = game_entries .iter() .take(MAX_HOME_GAME_ROWS) .enumerate() .find(|(index, _entry)| rect_contains(game_row_rect(*index), x, y)) .map(|(_index, entry)| entry) { return Some(SystemProfileAction::LaunchGame { path: entry.path.clone() }); } HOME_BUTTONS .iter() .find(|button| rect_contains(button.rect, x, y)) .map(|button| SystemProfileAction::LaunchNativeShell(button.app)) } fn game_row_rect(index: usize) -> Rect { Rect { x: GAME_ROW_X, y: GAME_ROW_Y + (GAME_ROW_H + GAME_ROW_GAP) * index as i32, w: GAME_ROW_W, h: GAME_ROW_H, } } fn rect_contains(rect: Rect, x: i32, y: i32) -> bool { x >= rect.x && x < rect.x + rect.w && y >= rect.y && y < rect.y + rect.h } fn activation_input_down(input: &dyn InputPlatform) -> bool { input.touch().f().down || input.pad().a().down || input.pad().b().down || input.pad().start().down } fn render_home_packet( pointer_x: i32, pointer_y: i32, game_entries: &[crate::GameLibraryEntry], ) -> ShellUiFramePacket { let mut commands = Vec::new(); commands.push(GfxUiCommand::Clear { color: COLOR_BG }); fill_rect(&mut commands, VIEWPORT, COLOR_BG); draw_panel(&mut commands, TITLE_PANEL, COLOR_PANEL_DARK, COLOR_BORDER); draw_buffer_text( &mut commands, TITLE_PANEL.x + 58, TITLE_PANEL.y + 9, "PROMETEU HUB", COLOR_TEXT, ); draw_panel(&mut commands, HOME_PANEL, COLOR_PANEL, COLOR_BORDER); draw_buffer_text(&mut commands, HOME_PANEL.x + 26, HOME_PANEL.y + 22, "Games", COLOR_TEXT); draw_buffer_text( &mut commands, HOME_PANEL.x + 26, HOME_PANEL.y + 40, "local library", COLOR_MUTED, ); if game_entries.is_empty() { draw_buffer_text(&mut commands, GAME_ROW_X, GAME_ROW_Y + 6, "No games found", COLOR_MUTED); } else { for (index, entry) in game_entries.iter().take(MAX_HOME_GAME_ROWS).enumerate() { let rect = game_row_rect(index); let hovered = rect_contains(rect, pointer_x, pointer_y); let text = format!("{} #{} {}", entry.title, entry.app_id, entry.app_version); draw_game_row(&mut commands, rect, &text, hovered); } } for button in HOME_BUTTONS { let hovered = rect_contains(button.rect, pointer_x, pointer_y); draw_button(&mut commands, button.rect, button.label, hovered); } ShellUiFramePacket::new(commands) } fn append_shell_window_commands( commands: &mut Vec, viewport: Rect, color: Color, title: &str, pointer_x: i32, pointer_y: i32, ) { fill_rect(commands, VIEWPORT, COLOR_BG); draw_panel(commands, viewport, COLOR_PANEL_DARK, COLOR_BORDER); fill_rect( commands, Rect { x: viewport.x + 4, y: viewport.y + 24, w: viewport.w - 8, h: viewport.h - 28 }, color, ); draw_buffer_text(commands, viewport.x + 10, viewport.y + 8, title, COLOR_TEXT); draw_buffer_text( commands, viewport.x + 18, viewport.y + 44, "native fake shell", COLOR_PANEL_DARK, ); let close_hovered = rect_contains(CLOSE_BUTTON, pointer_x, pointer_y); let close_fill = if close_hovered { COLOR_HILITE } else { COLOR_BUTTON }; fill_rect(commands, CLOSE_BUTTON, close_fill); draw_rect(commands, CLOSE_BUTTON, COLOR_BORDER); draw_buffer_text(commands, CLOSE_BUTTON.x + 5, CLOSE_BUTTON.y + 4, "x", COLOR_TEXT); } fn fill_rect(commands: &mut Vec, rect: Rect, color: Color) { commands.push(GfxUiCommand::FillRect { rect, color }); } fn draw_rect(commands: &mut Vec, rect: Rect, color: Color) { commands.push(GfxUiCommand::DrawLine { x0: rect.x, y0: rect.y, x1: rect.x + rect.w - 1, y1: rect.y, color, }); commands.push(GfxUiCommand::DrawLine { x0: rect.x, y0: rect.y + rect.h - 1, x1: rect.x + rect.w - 1, y1: rect.y + rect.h - 1, color, }); commands.push(GfxUiCommand::DrawLine { x0: rect.x, y0: rect.y, x1: rect.x, y1: rect.y + rect.h - 1, color, }); commands.push(GfxUiCommand::DrawLine { x0: rect.x + rect.w - 1, y0: rect.y, x1: rect.x + rect.w - 1, y1: rect.y + rect.h - 1, color, }); } fn draw_panel(commands: &mut Vec, rect: Rect, fill: Color, border: Color) { fill_rect(commands, rect, fill); draw_rect(commands, rect, border); draw_rect( commands, Rect { x: rect.x + 2, y: rect.y + 2, w: rect.w - 4, h: rect.h - 4 }, COLOR_MUTED, ); } fn draw_button(commands: &mut Vec, rect: Rect, label: &str, hovered: bool) { let fill = if hovered { COLOR_BUTTON_ACTIVE } else { COLOR_BUTTON }; let border = if hovered { COLOR_HILITE } else { COLOR_BORDER }; fill_rect(commands, rect, fill); draw_rect(commands, rect, border); draw_rect( commands, Rect { x: rect.x + 3, y: rect.y + 3, w: rect.w - 6, h: rect.h - 6 }, COLOR_MUTED, ); fill_rect(commands, Rect { x: rect.x + 12, y: rect.y + 8, w: 14, h: 16 }, border); draw_rect(commands, Rect { x: rect.x + 34, y: rect.y + 8, w: 62, h: 16 }, COLOR_PANEL_DARK); draw_buffer_text(commands, rect.x + 40, rect.y + 13, label, COLOR_TEXT); } fn draw_game_row(commands: &mut Vec, rect: Rect, label: &str, hovered: bool) { let fill = if hovered { COLOR_BUTTON_ACTIVE } else { COLOR_PANEL_DARK }; let border = if hovered { COLOR_HILITE } else { COLOR_MUTED }; fill_rect(commands, rect, fill); draw_rect(commands, rect, border); draw_buffer_text(commands, rect.x + 8, rect.y + 7, label, COLOR_TEXT); } fn draw_buffer_text(commands: &mut Vec, x: i32, y: i32, text: &str, color: Color) { commands.push(GfxUiCommand::DrawText { x, y, text: text.to_string(), color }); } #[cfg(test)] mod tests { use super::*; use crate::task::TaskId; use crate::windows::WindowOwner; use prometeu_drivers::TestPlatform; use prometeu_hal::{InputSignals, RuntimePlatform}; fn focused_native_shell_fixture( signals: &InputSignals, ) -> (PrometeuHub, SystemOS, TestPlatform) { let hub = PrometeuHub::new(); let mut os = SystemOS::new(None); let mut platform = TestPlatform::new(); { let mut windows = os.windows(); let id = windows.add_window( "ShellA".to_string(), WindowOwner::Task(TaskId(7)), SHELL_FRAME, Color::GREEN, ); windows.set_focus(id); } platform.input_mut().pad_mut().begin_frame(signals); platform.input_mut().touch_mut().begin_frame(signals); (hub, os, platform) } #[test] fn shell_a_button_click_emits_launch_action() { assert_eq!( action_for_click(false, HOME_BUTTONS[0].rect.x, HOME_BUTTONS[0].rect.y, true, &[]), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellA)) ); } #[test] fn shell_b_button_click_emits_launch_action() { assert_eq!( action_for_click( false, SHELL_B_BUTTON.x + SHELL_B_BUTTON.w - 1, SHELL_B_BUTTON.y, true, &[] ), Some(SystemProfileAction::LaunchNativeShell(NativeShellApp::ShellB)) ); } #[test] fn button_hit_test_excludes_right_and_bottom_edges() { assert_eq!( action_for_click( false, SHELL_A_BUTTON.x + SHELL_A_BUTTON.w, SHELL_A_BUTTON.y, true, &[] ), None ); assert_eq!( action_for_click( false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y + SHELL_A_BUTTON.h, true, &[] ), None ); } #[test] fn held_pointer_does_not_emit_click_action() { assert_eq!(action_for_click(false, SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, false, &[]), None); } #[test] fn hub_starts_with_home_input_disarmed() { let hub = PrometeuHub::new(); assert!(!hub.home_input_armed); } #[test] fn game_row_click_emits_launch_game_action() { let entry = crate::GameLibraryEntry { manifest: prometeu_hal::cartridge::CartridgeManifest { magic: "PMTU".to_string(), cartridge_version: 1, app_id: 42, title: "Stress".to_string(), app_version: "1.0.0".to_string(), app_mode: prometeu_hal::app_mode::AppMode::Game, capabilities: vec![], }, title: "Stress".to_string(), app_id: 42, app_version: "1.0.0".to_string(), path: PathBuf::from("test-cartridges/stress-console"), discovered_at: std::time::SystemTime::UNIX_EPOCH, }; assert_eq!( action_for_click(false, GAME_ROW_X, GAME_ROW_Y, true, std::slice::from_ref(&entry)), Some(SystemProfileAction::LaunchGame { path: entry.path.clone() }) ); } #[test] fn close_button_click_emits_close_only_in_shell() { assert_eq!( action_for_click(true, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), Some(SystemProfileAction::CloseShell) ); assert_eq!(action_for_click(false, CLOSE_BUTTON.x, CLOSE_BUTTON.y, true, &[]), None); } #[test] fn home_layout_exposes_exactly_two_manual_shell_buttons() { assert_eq!(HOME_BUTTONS.len(), 2); assert_eq!(HOME_BUTTONS[0].label, "ShellA"); assert_eq!(HOME_BUTTONS[0].app, NativeShellApp::ShellA); assert_eq!(HOME_BUTTONS[1].label, "ShellB"); assert_eq!(HOME_BUTTONS[1].app, NativeShellApp::ShellB); } #[test] fn home_render_produces_shell_ui_packet_commands() { let packet = render_home_packet(SHELL_A_BUTTON.x, SHELL_A_BUTTON.y, &[]); assert!(matches!(packet.commands.first(), Some(GfxUiCommand::Clear { .. }))); assert!(packet.commands.iter().any(|command| { matches!(command, GfxUiCommand::DrawText { text, .. } if text == "PROMETEU HUB") })); } #[test] fn shell_close_button_sits_inside_shell_frame() { assert!(rect_contains(SHELL_FRAME, CLOSE_BUTTON.x, CLOSE_BUTTON.y)); assert!(rect_contains( SHELL_FRAME, CLOSE_BUTTON.x + CLOSE_BUTTON.w - 1, CLOSE_BUTTON.y + CLOSE_BUTTON.h - 1 )); } #[test] fn native_shell_profile_start_close_does_not_tick_vm() { let signals = InputSignals { start_signal: true, ..Default::default() }; let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals); let tick_index_before_update = os.vm().tick_index(); let outcome = hub.update_native_shell_profile(&mut os, &mut platform); assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell)); assert!(outcome.crash.is_none()); assert_eq!(os.vm().tick_index(), tick_index_before_update); } #[test] fn native_shell_profile_close_button_does_not_tick_vm() { let signals = InputSignals { f_signal: true, x_pos: CLOSE_BUTTON.x, y_pos: CLOSE_BUTTON.y, ..Default::default() }; let (mut hub, mut os, mut platform) = focused_native_shell_fixture(&signals); let tick_index_before_update = os.vm().tick_index(); let outcome = hub.update_native_shell_profile(&mut os, &mut platform); assert_eq!(outcome.action, Some(SystemProfileAction::CloseShell)); assert!(outcome.crash.is_none()); assert_eq!(os.vm().tick_index(), tick_index_before_update); } }