2026-07-05 00:26:16 +01:00

613 lines
19 KiB
Rust

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<CrashReport>,
pub action: Option<SystemProfileAction>,
}
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<SystemProfileAction> {
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<SystemProfileAction> {
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<GfxUiCommand>,
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<GfxUiCommand>, rect: Rect, color: Color) {
commands.push(GfxUiCommand::FillRect { rect, color });
}
fn draw_rect(commands: &mut Vec<GfxUiCommand>, 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<GfxUiCommand>, 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<GfxUiCommand>, 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<GfxUiCommand>, 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<GfxUiCommand>, 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);
}
}