diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index f280447..e94cee4 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -27,7 +27,7 @@ jobs: ci: name: OASIS_OS CI runs-on: self-hosted - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Pre-checkout cleanup run: | @@ -132,6 +132,35 @@ jobs: echo "| Failed | $FAIL |" >> $GITHUB_STEP_SUMMARY echo "| Total | $TOTAL |" >> $GITHUB_STEP_SUMMARY + # -- Coverage Metrics ---------------------------------------------------- + - name: Coverage (cargo-llvm-cov) + continue-on-error: true + run: | + docker compose --profile ci run --rm rust-ci bash -c " + rustup component add llvm-tools-preview 2>/dev/null || true + cargo install cargo-llvm-cov --locked 2>/dev/null || true + if command -v cargo-llvm-cov &>/dev/null; then + cargo llvm-cov --workspace --no-fail-fast \ + --ignore-filename-regex '(tests?\.rs|benches?\.rs|main\.rs)' \ + 2>&1 | tee /app/coverage_output.txt + else + echo 'cargo-llvm-cov not available, skipping coverage' + fi + " + + - name: Coverage summary + if: always() + run: | + if [ ! -f coverage_output.txt ]; then + echo "### Coverage" >> $GITHUB_STEP_SUMMARY + echo "Coverage step was skipped or unavailable." >> $GITHUB_STEP_SUMMARY + exit 0 + fi + echo "### Coverage" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + tail -20 coverage_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + # -- PSP Backend Build ------------------------------------------------- - name: Setup PSP SDK run: | diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index b811a00..b38742b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -36,7 +36,7 @@ jobs: name: OASIS_OS CI needs: fork-guard runs-on: self-hosted - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Pre-checkout cleanup run: | diff --git a/Cargo.toml b/Cargo.toml index fefa7e3..8084042 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,10 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg webpki-roots = "1.0" rustls-pki-types = "1" +# Testing +proptest = "1" +criterion = { version = "0.5", features = ["html_reports"] } + # Internal crates oasis-types = { path = "crates/oasis-types" } oasis-vfs = { path = "crates/oasis-vfs" } diff --git a/crates/oasis-app/Cargo.toml b/crates/oasis-app/Cargo.toml index 2402664..97ac55c 100644 --- a/crates/oasis-app/Cargo.toml +++ b/crates/oasis-app/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-app" [[bin]] name = "oasis-app" diff --git a/crates/oasis-app/src/commands.rs b/crates/oasis-app/src/commands.rs index e5a3bc2..f902d06 100644 --- a/crates/oasis-app/src/commands.rs +++ b/crates/oasis-app/src/commands.rs @@ -589,4 +589,127 @@ mod tests { "Already connected. Disconnect first." ); } + + // -- Additional command handler tests -- + + #[test] + fn process_listen_already_running() { + let mut state = make_test_state(); + // Start a listener first. + let port = 0; // Cannot actually bind, but simulate the state. + let cfg = oasis_core::net::ListenerConfig { + port: 19999, + psk: String::new(), + max_connections: 1, + ..oasis_core::net::ListenerConfig::default() + }; + state.listener = Some(oasis_core::net::RemoteListener::new(cfg)); + let result = + process_command_output(Ok(CommandOutput::ListenToggle { port: 8080 }), &mut state); + assert!(result.is_none()); + assert!(state.output_lines[0].contains("already running")); + } + + #[test] + fn process_ftp_already_running() { + let mut state = make_test_state(); + state.ftp_server = Some(oasis_core::transfer::FtpServer::new(19000)); + let result = process_command_output(Ok(CommandOutput::FtpToggle { port: 21 }), &mut state); + assert!(result.is_none()); + assert!(state.output_lines[0].contains("already running")); + } + + #[test] + fn process_multi_empty_list() { + let mut state = make_test_state(); + let result = process_command_output(Ok(CommandOutput::Multi(vec![])), &mut state); + assert!(result.is_none()); + assert!(state.output_lines.is_empty()); + } + + #[test] + fn process_multi_preserves_order() { + let mut state = make_test_state(); + let result = process_command_output( + Ok(CommandOutput::Multi(vec![ + CommandOutput::Text("alpha".to_string()), + CommandOutput::Text("beta".to_string()), + CommandOutput::Text("gamma".to_string()), + ])), + &mut state, + ); + assert!(result.is_none()); + assert_eq!(state.output_lines.len(), 3); + assert_eq!(state.output_lines[0], "alpha"); + assert_eq!(state.output_lines[1], "beta"); + assert_eq!(state.output_lines[2], "gamma"); + } + + #[test] + fn process_multi_last_skin_swap_wins() { + let mut state = make_test_state(); + let result = process_command_output( + Ok(CommandOutput::Multi(vec![ + CommandOutput::SkinSwap { + name: "first".to_string(), + }, + CommandOutput::SkinSwap { + name: "second".to_string(), + }, + ])), + &mut state, + ); + assert_eq!(result, Some("second".to_string())); + } + + #[test] + fn process_table_empty_rows() { + let mut state = make_test_state(); + let result = process_command_output( + Ok(CommandOutput::Table { + headers: vec!["Col1".into(), "Col2".into()], + rows: vec![], + }), + &mut state, + ); + assert!(result.is_none()); + assert_eq!(state.output_lines.len(), 1); + assert_eq!(state.output_lines[0], "Col1 | Col2"); + } + + #[test] + fn process_text_multiline() { + let mut state = make_test_state(); + let text = "line1\nline2\nline3\nline4"; + let result = process_command_output(Ok(CommandOutput::Text(text.to_string())), &mut state); + assert!(result.is_none()); + assert_eq!(state.output_lines.len(), 4); + } + + #[test] + fn process_clear_empties_all() { + let mut state = make_test_state(); + state.output_lines = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let result = process_command_output(Ok(CommandOutput::Clear), &mut state); + assert!(result.is_none()); + assert!(state.output_lines.is_empty()); + } + + #[test] + fn trim_output_single_excess() { + let count = terminal_sdi::MAX_OUTPUT_LINES + 1; + let mut lines: Vec = (0..count).map(|i| format!("line{i}")).collect(); + trim_output(&mut lines); + assert_eq!(lines.len(), terminal_sdi::MAX_OUTPUT_LINES); + assert_eq!(lines[0], "line1"); + } + + #[test] + fn process_error_format() { + let mut state = make_test_state(); + let err = oasis_core::error::OasisError::Vfs("file not found".into()); + process_command_output(Err(err), &mut state); + assert!(state.output_lines[0].contains("error:")); + assert!(state.output_lines[0].contains("file not found")); + } } diff --git a/crates/oasis-app/src/input.rs b/crates/oasis-app/src/input.rs index 8d72121..16eb8e9 100644 --- a/crates/oasis-app/src/input.rs +++ b/crates/oasis-app/src/input.rs @@ -815,4 +815,252 @@ mod tests { ); assert_eq!(state.mode, Mode::Dashboard); } + + // -- Additional input dispatch tests -- + + #[test] + fn start_in_app_mode_stays_in_app() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::App; + handle_default_input( + &InputEvent::ButtonPress(Button::Start), + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(state.mode, Mode::App); + } + + #[test] + fn start_in_osk_mode_stays_in_osk() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Osk; + handle_default_input( + &InputEvent::ButtonPress(Button::Start), + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(state.mode, Mode::Osk); + } + + #[test] + fn start_in_desktop_mode_stays_in_desktop() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Desktop; + handle_default_input( + &InputEvent::ButtonPress(Button::Start), + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(state.mode, Mode::Desktop); + } + + #[test] + fn select_in_osk_mode_does_not_reopen() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Osk; + state.osk = Some(OskState::new(OskConfig::default(), "")); + handle_default_input( + &InputEvent::ButtonPress(Button::Select), + &mut state, + &mut sdi, + &mut vfs, + ); + // Should still be in OSK mode, not open a second one. + assert_eq!(state.mode, Mode::Osk); + } + + #[test] + fn terminal_text_builds_input_buffer() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Terminal; + for ch in "hello world".chars() { + handle_default_input(&InputEvent::TextInput(ch), &mut state, &mut sdi, &mut vfs); + } + assert_eq!(state.input_buf, "hello world"); + } + + #[test] + fn terminal_backspace_on_empty_is_noop() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Terminal; + state.input_buf.clear(); + handle_default_input(&InputEvent::Backspace, &mut state, &mut sdi, &mut vfs); + assert!(state.input_buf.is_empty()); + } + + #[test] + fn terminal_square_on_empty_is_noop() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Terminal; + state.input_buf.clear(); + handle_default_input( + &InputEvent::ButtonPress(Button::Square), + &mut state, + &mut sdi, + &mut vfs, + ); + assert!(state.input_buf.is_empty()); + } + + #[test] + fn dashboard_triangle_next_page() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Dashboard; + // Should not panic even with zero pages. + handle_default_input( + &InputEvent::ButtonPress(Button::Triangle), + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(state.mode, Mode::Dashboard); + } + + #[test] + fn dashboard_square_prev_page() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Dashboard; + handle_default_input( + &InputEvent::ButtonPress(Button::Square), + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(state.mode, Mode::Dashboard); + } + + #[test] + fn trigger_left_cycles_status_tab() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Dashboard; + handle_default_input( + &InputEvent::TriggerPress(Trigger::Left), + &mut state, + &mut sdi, + &mut vfs, + ); + assert!(state.bottom_bar.l_pressed); + handle_default_input( + &InputEvent::TriggerRelease(Trigger::Left), + &mut state, + &mut sdi, + &mut vfs, + ); + assert!(!state.bottom_bar.l_pressed); + } + + #[test] + fn trigger_right_cycles_media_tab() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Dashboard; + handle_default_input( + &InputEvent::TriggerPress(Trigger::Right), + &mut state, + &mut sdi, + &mut vfs, + ); + assert!(state.bottom_bar.r_pressed); + assert!(state.active_transition.is_some()); + handle_default_input( + &InputEvent::TriggerRelease(Trigger::Right), + &mut state, + &mut sdi, + &mut vfs, + ); + assert!(!state.bottom_bar.r_pressed); + } + + #[test] + fn osk_no_state_is_noop() { + let (mut state, mut sdi, _vfs) = make_test_state(); + state.mode = Mode::Osk; + state.osk = None; + let result = handle_osk_input(&InputEvent::Backspace, &mut state, &mut sdi); + assert_eq!(result, InputResult::Continue); + } + + #[test] + fn osk_button_press_without_confirm_stays() { + let (mut state, mut sdi, _vfs) = make_test_state(); + state.mode = Mode::Osk; + state.osk = Some(OskState::new(OskConfig::default(), "")); + let result = handle_osk_input(&InputEvent::ButtonPress(Button::Up), &mut state, &mut sdi); + assert_eq!(result, InputResult::Continue); + assert!(state.osk.is_some()); + } + + #[test] + fn desktop_cursor_move_does_not_change_mode() { + let (mut state, mut sdi, vfs) = make_test_state(); + state.mode = Mode::Desktop; + let result = handle_desktop_input( + &InputEvent::CursorMove { x: 100, y: 50 }, + &mut state, + &mut sdi, + &vfs, + ); + assert_eq!(result, InputResult::Continue); + assert_eq!(state.mode, Mode::Desktop); + } + + #[test] + fn desktop_pointer_release_does_not_change_mode() { + let (mut state, mut sdi, vfs) = make_test_state(); + state.mode = Mode::Desktop; + let result = handle_desktop_input( + &InputEvent::PointerRelease { x: 100, y: 50 }, + &mut state, + &mut sdi, + &vfs, + ); + assert_eq!(result, InputResult::Continue); + } + + #[test] + fn desktop_click_no_windows_returns_to_dashboard() { + let (mut state, mut sdi, vfs) = make_test_state(); + state.mode = Mode::Desktop; + let result = handle_desktop_input( + &InputEvent::PointerClick { x: 100, y: 50 }, + &mut state, + &mut sdi, + &vfs, + ); + assert_eq!(result, InputResult::Continue); + assert_eq!(state.mode, Mode::Dashboard); + } + + #[test] + fn desktop_text_input_without_browser_is_noop() { + let (mut state, mut sdi, vfs) = make_test_state(); + state.mode = Mode::Desktop; + state.browser = None; + let result = handle_desktop_input(&InputEvent::TextInput('a'), &mut state, &mut sdi, &vfs); + assert_eq!(result, InputResult::Continue); + } + + #[test] + fn desktop_backspace_without_browser_is_noop() { + let (mut state, mut sdi, vfs) = make_test_state(); + state.mode = Mode::Desktop; + state.browser = None; + let result = handle_desktop_input(&InputEvent::Backspace, &mut state, &mut sdi, &vfs); + assert_eq!(result, InputResult::Continue); + } + + #[test] + fn unhandled_event_returns_continue() { + let (mut state, mut sdi, mut vfs) = make_test_state(); + state.mode = Mode::Dashboard; + let result = handle_default_input( + &InputEvent::CursorMove { x: 0, y: 0 }, + &mut state, + &mut sdi, + &mut vfs, + ); + assert_eq!(result, InputResult::Continue); + } } diff --git a/crates/oasis-app/src/terminal_sdi.rs b/crates/oasis-app/src/terminal_sdi.rs index 16668a8..a8eaa19 100644 --- a/crates/oasis-app/src/terminal_sdi.rs +++ b/crates/oasis-app/src/terminal_sdi.rs @@ -6,7 +6,7 @@ use oasis_core::sdi::SdiRegistry; pub const VISIBLE_OUTPUT_LINES: usize = 12; /// Maximum lines retained in the scrollback buffer. -pub const MAX_OUTPUT_LINES: usize = 200; +pub const MAX_OUTPUT_LINES: usize = 2000; /// Set up the wallpaper SDI object at z=-1000 (behind everything). pub fn setup_wallpaper(sdi: &mut SdiRegistry, tex: TextureId, w: u32, h: u32) { @@ -156,7 +156,7 @@ mod tests { #[test] fn constants() { assert_eq!(VISIBLE_OUTPUT_LINES, 12); - assert_eq!(MAX_OUTPUT_LINES, 200); + assert_eq!(MAX_OUTPUT_LINES, 2000); assert!(VISIBLE_OUTPUT_LINES < MAX_OUTPUT_LINES); } diff --git a/crates/oasis-audio/Cargo.toml b/crates/oasis-audio/Cargo.toml index 342be84..d8b91e1 100644 --- a/crates/oasis-audio/Cargo.toml +++ b/crates/oasis-audio/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-audio" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-backend-psp/Cargo.lock b/crates/oasis-backend-psp/Cargo.lock index cb74a92..9234984 100644 --- a/crates/oasis-backend-psp/Cargo.lock +++ b/crates/oasis-backend-psp/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -55,6 +61,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.10.0" @@ -117,6 +129,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -230,6 +276,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -297,6 +349,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -319,6 +380,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -481,6 +552,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "libc" version = "0.2.181" @@ -505,6 +585,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num_enum" version = "0.7.5" @@ -556,12 +646,14 @@ dependencies = [ name = "oasis-browser" version = "0.1.0" dependencies = [ + "jpeg-decoder", "log", "oasis-net", "oasis-skin", "oasis-terminal", "oasis-types", "oasis-vfs", + "png", "serde", ] @@ -710,6 +802,19 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polyval" version = "0.6.2" @@ -749,9 +854,9 @@ dependencies = [ [[package]] name = "psp" version = "0.4.0" -source = "git+https://github.com/AndrewAltimit/rust-psp?branch=feat%2Fnetconf-dialog#be617d89e6920ccf257d55e945b8bdd4c2ebd72b" +source = "git+https://github.com/AndrewAltimit/rust-psp?rev=4c47345#4c47345bd8d2d5e225d91944ec81027c9a222e8d" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libm", "num_enum", "num_enum_derive", @@ -780,6 +885,26 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rfc6979" version = "0.4.0" @@ -917,6 +1042,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1169,4 +1300,4 @@ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[patch.unused]] name = "unicode-width" version = "0.2.0" -source = "git+https://git.sr.ht/~sajattack/unicode-width#114ac4742ac29a7b69be8e0e7b1e45af43ed6d83" +source = "git+https://git.sr.ht/~sajattack/unicode-width?rev=114ac47#114ac4742ac29a7b69be8e0e7b1e45af43ed6d83" diff --git a/crates/oasis-backend-psp/Cargo.toml b/crates/oasis-backend-psp/Cargo.toml index 8f9a6f2..ba8cdf2 100644 --- a/crates/oasis-backend-psp/Cargo.toml +++ b/crates/oasis-backend-psp/Cargo.toml @@ -13,19 +13,37 @@ license = "MIT" repository = "https://github.com/AndrewAltimit/oasis-os" authors = ["AndrewAltimit"] +# Feature flags for PSP hardware capabilities: +# +# - kernel-mode: Meta-feature that enables all stable kernel sub-features. +# Bundles kernel-volatile, kernel-me-clock, and kernel-me. Intentionally +# excludes kernel-exception which is broken on PSP-3000 + 6.20 PRO-C. +# All kernel features gate psp/kernel for kernel-mode syscall access. +# +# - kernel-exception: Register a default exception handler via +# sceKernelRegisterDefaultExceptionHandler. Broken on PSP-3000 + 6.20 PRO-C -- +# excluded from kernel-mode for safety. +# +# - kernel-volatile: Access volatile memory (extra 4MB) via +# sceKernelVolatileMemTryLock. Useful for large texture/audio buffers. +# +# - kernel-me-clock: Read Media Engine clock frequency via +# scePowerGetMeClockFrequency. Informational only. +# +# - kernel-me: Enable Media Engine coprocessor offloading. Provides the +# `me test` command for benchmarking. The ME core has no syscalls, no cached +# memory, and no heap -- pure integer/float math only. [features] default = [] -# kernel-mode bundles all kernel sub-features EXCEPT kernel-exception -# (intentionally excluded: broken on PSP-3000 + 6.20 PRO-C). kernel-mode = ["psp/kernel", "kernel-volatile", "kernel-me-clock", "kernel-me"] -# Granular kernel sub-features (each enables psp/kernel independently): -kernel-exception = ["psp/kernel"] # sceKernelRegisterDefaultExceptionHandler (broken on PSP-3000 + 6.20 PRO-C) -kernel-volatile = ["psp/kernel"] # sceKernelVolatileMemTryLock (extra 4MB) -kernel-me-clock = ["psp/kernel"] # scePowerGetMeClockFrequency -kernel-me = ["psp/kernel"] # ME coprocessor (me test command) +kernel-exception = ["psp/kernel"] +kernel-volatile = ["psp/kernel"] +kernel-me-clock = ["psp/kernel"] +kernel-me = ["psp/kernel"] [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", features = ["std"] } +# Pinned to specific commit for reproducible builds. +psp = { git = "https://github.com/AndrewAltimit/rust-psp", rev = "4c47345", features = ["std"] } oasis-core = { path = "../oasis-core" } libm = "0.2" @@ -42,9 +60,10 @@ webpki = { version = "0.22", default-features = false, features = ["std"] } getrandom_02 = { package = "getrandom", version = "0.2", features = ["custom"] } getrandom = "0.3" -# unicode-width workaround for mips target panic. +# unicode-width fork: the upstream crate panics on mipsel-sony-psp due to +# a lookup table issue. This fork patches the panic for the MIPS target. [patch.crates-io] -unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width" } +unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width", rev = "114ac47" } [profile.release] lto = true diff --git a/crates/oasis-backend-psp/src/lib.rs b/crates/oasis-backend-psp/src/lib.rs index 66a8cab..8bcf149 100644 --- a/crates/oasis-backend-psp/src/lib.rs +++ b/crates/oasis-backend-psp/src/lib.rs @@ -26,6 +26,7 @@ pub mod power; pub mod procedural; pub mod render; pub mod sfx; +pub mod shapes; pub mod status; pub mod textures; pub mod threading; @@ -488,6 +489,68 @@ impl SdiBackend for PspBackend { fn shutdown(&mut self) -> OasisResult<()> { Ok(()) } + + // ------------------------------------------------------------------- + // Extended: Shape Primitives (GU-accelerated) + // ------------------------------------------------------------------- + + fn fill_rounded_rect( + &mut self, + x: i32, + y: i32, + w: u32, + h: u32, + radius: u16, + color: Color, + ) -> OasisResult<()> { + self.fill_rounded_rect_inner(x, y, w, h, radius, color); + Ok(()) + } + + fn fill_circle( + &mut self, + cx: i32, + cy: i32, + radius: u16, + color: Color, + ) -> OasisResult<()> { + self.fill_circle_inner(cx, cy, radius, color); + Ok(()) + } + + fn draw_line( + &mut self, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + width: u16, + color: Color, + ) -> OasisResult<()> { + self.draw_line_inner(x1, y1, x2, y2, width, color); + Ok(()) + } + + // ------------------------------------------------------------------- + // Extended: Gradient Fills (GU vertex-color interpolation) + // ------------------------------------------------------------------- + + fn fill_rect_gradient( + &mut self, + x: i32, + y: i32, + w: u32, + h: u32, + gradient: &oasis_core::backend::GradientStyle, + ) -> OasisResult<()> { + self.fill_rect_gradient_inner(x, y, w, h, gradient); + Ok(()) + } + + fn dim_screen(&mut self, alpha: u8) -> OasisResult<()> { + self.dim_screen_inner(alpha); + Ok(()) + } } // --------------------------------------------------------------------------- diff --git a/crates/oasis-backend-psp/src/shapes.rs b/crates/oasis-backend-psp/src/shapes.rs new file mode 100644 index 0000000..2824445 --- /dev/null +++ b/crates/oasis-backend-psp/src/shapes.rs @@ -0,0 +1,614 @@ +//! Extended shape primitives for the PSP GU backend. +//! +//! Implements `fill_rounded_rect`, `fill_circle`, `draw_line`, and +//! gradient fills using GU `Lines`, `LineStrip`, `TriangleFan`, and +//! `Sprites` primitives with per-vertex colors. + +use std::ffi::c_void; +use std::mem::size_of; +use std::ptr; + +use psp::sys::{self, GuPrimitive, VertexType}; + +use oasis_core::backend::{Color, GradientStyle}; + +use crate::{ColorExt, PspBackend, SCREEN_HEIGHT, SCREEN_WIDTH}; + +// --------------------------------------------------------------------------- +// Color-only vertex (no texture, position + color) +// --------------------------------------------------------------------------- + +/// Untextured vertex with a per-vertex ABGR color. +/// +/// Used for shape primitives (lines, circles, triangles) that do not +/// sample a texture. Texture2D must be disabled before drawing. +#[repr(C, align(4))] +struct ColorVertex { + color: u32, + x: i16, + y: i16, + z: i16, + _pad: i16, +} + +/// Vertex type flags for `ColorVertex`. +const COLOR_VTYPE: VertexType = VertexType::from_bits_truncate( + VertexType::COLOR_8888.bits() + | VertexType::VERTEX_16BIT.bits() + | VertexType::TRANSFORM_2D.bits(), +); + +// --------------------------------------------------------------------------- +// Helper: integer sin/cos table for circle rendering +// --------------------------------------------------------------------------- + +/// Number of segments for a full circle. 32 is a good compromise +/// between visual smoothness and vertex count on a 480x272 screen. +const CIRCLE_SEGMENTS: usize = 32; + +/// Precomputed (cos, sin) pairs for `CIRCLE_SEGMENTS` points around +/// a unit circle, scaled by 1024 for fixed-point integer math. +/// +/// Using fixed-point avoids pulling in libm for `f32::sin`/`cos` +/// on every frame. +const CIRCLE_TABLE: [(i32, i32); CIRCLE_SEGMENTS] = { + // Build at compile time using a Horner-style Taylor approximation. + // For a 32-segment circle, each step is 360/32 = 11.25 degrees. + // We precompute with f64 and convert to fixed-point (scale 1024). + const SCALE: f64 = 1024.0; + const PI2: f64 = 2.0 * std::f64::consts::PI; + let mut table = [(0i32, 0i32); CIRCLE_SEGMENTS]; + let mut i = 0; + while i < CIRCLE_SEGMENTS { + let angle = (i as f64) * PI2 / (CIRCLE_SEGMENTS as f64); + // cos/sin via Taylor series (enough terms for f64 precision). + let c = cos_approx(angle); + let s = sin_approx(angle); + table[i] = ((c * SCALE) as i32, (s * SCALE) as i32); + i += 1; + } + table +}; + +/// Compile-time cosine approximation (Taylor series, 10 terms). +const fn cos_approx(x: f64) -> f64 { + // Reduce to [0, 2*pi). + let pi2 = 2.0 * std::f64::consts::PI; + let mut x = x % pi2; + if x < 0.0 { + x += pi2; + } + let x2 = x * x; + let mut result = 1.0; + let mut term = 1.0; + let mut i = 1; + while i <= 10 { + term *= -x2 / ((2 * i - 1) as f64 * (2 * i) as f64); + result += term; + i += 1; + } + result +} + +/// Compile-time sine approximation (Taylor series, 10 terms). +const fn sin_approx(x: f64) -> f64 { + let pi2 = 2.0 * std::f64::consts::PI; + let mut x = x % pi2; + if x < 0.0 { + x += pi2; + } + let x2 = x * x; + let mut result = x; + let mut term = x; + let mut i = 1; + while i <= 10 { + term *= -x2 / ((2 * i) as f64 * (2 * i + 1) as f64); + result += term; + i += 1; + } + result +} + +// --------------------------------------------------------------------------- +// PspBackend extended shape methods +// --------------------------------------------------------------------------- + +impl PspBackend { + /// Draw a filled rectangle with rounded corners using GU line strips. + /// + /// Draws the shape as a series of horizontal scanlines. The corner + /// insets are computed using an integer square root approximation + /// to avoid the midpoint circle overhead for each frame. + pub fn fill_rounded_rect_inner( + &mut self, + x: i32, + y: i32, + w: u32, + h: u32, + radius: u16, + color: Color, + ) { + if radius == 0 || w == 0 || h == 0 { + self.fill_rect_inner(x, y, w, h, color); + return; + } + let r = (radius as i32).min(w as i32 / 2).min(h as i32 / 2); + let abgr = color.to_abgr(); + + // Draw scanline by scanline: each scanline is a 1px-tall + // filled rect. The rounded corners are achieved by insetting + // the left/right edges in the top and bottom `r` rows. + // SAFETY: Disabling Texture2D and using sceGuGetMemory for + // vertices within the active display list frame. + unsafe { + sys::sceGuDisable(sys::GuState::Texture2D); + + // Allocate vertices for all scanlines (2 per line: left + // and right endpoints as a Sprites primitive). + let vert_count = (h as usize) * 2; + let verts = sys::sceGuGetMemory( + (vert_count * size_of::()) as i32, + ) as *mut ColorVertex; + if verts.is_null() { + sys::sceGuEnable(sys::GuState::Texture2D); + return; + } + + let mut vi = 0usize; + for dy in 0..h as i32 { + let inset = if dy < r { + let ry = r - dy; + r - isqrt_i32(r * r - ry * ry) + } else if dy >= h as i32 - r { + let ry = dy - (h as i32 - 1 - r); + r - isqrt_i32(r * r - ry * ry) + } else { + 0 + }; + + let lx = x + inset; + let rx = x + w as i32 - inset; + ptr::write( + verts.add(vi), + ColorVertex { + color: abgr, + x: lx as i16, + y: (y + dy) as i16, + z: 0, + _pad: 0, + }, + ); + ptr::write( + verts.add(vi + 1), + ColorVertex { + color: abgr, + x: rx as i16, + y: (y + dy + 1) as i16, + z: 0, + _pad: 0, + }, + ); + vi += 2; + } + + sys::sceGuDrawArray( + GuPrimitive::Sprites, + COLOR_VTYPE, + vert_count as i32, + ptr::null(), + verts as *const c_void, + ); + + sys::sceGuEnable(sys::GuState::Texture2D); + } + } + + /// Draw a filled circle using a GU triangle fan. + /// + /// The fan has `CIRCLE_SEGMENTS` outer vertices plus a center + /// vertex. Fixed-point cos/sin avoids per-frame floating-point. + pub fn fill_circle_inner( + &mut self, + cx: i32, + cy: i32, + radius: u16, + color: Color, + ) { + if radius == 0 { + return; + } + let abgr = color.to_abgr(); + let r = radius as i32; + + // SAFETY: Disabling Texture2D and allocating GU memory for + // the triangle fan vertices within the active display list. + unsafe { + sys::sceGuDisable(sys::GuState::Texture2D); + + // center + CIRCLE_SEGMENTS + 1 (closing vertex). + let vert_count = CIRCLE_SEGMENTS + 2; + let verts = sys::sceGuGetMemory( + (vert_count * size_of::()) as i32, + ) as *mut ColorVertex; + if verts.is_null() { + sys::sceGuEnable(sys::GuState::Texture2D); + return; + } + + // Center vertex. + ptr::write( + verts, + ColorVertex { + color: abgr, + x: cx as i16, + y: cy as i16, + z: 0, + _pad: 0, + }, + ); + + // Perimeter vertices. + for i in 0..CIRCLE_SEGMENTS { + let (cos_val, sin_val) = CIRCLE_TABLE[i]; + let px = cx + (r * cos_val) / 1024; + let py = cy + (r * sin_val) / 1024; + ptr::write( + verts.add(1 + i), + ColorVertex { + color: abgr, + x: px as i16, + y: py as i16, + z: 0, + _pad: 0, + }, + ); + } + + // Close the fan by repeating the first perimeter vertex. + let (cos0, sin0) = CIRCLE_TABLE[0]; + ptr::write( + verts.add(1 + CIRCLE_SEGMENTS), + ColorVertex { + color: abgr, + x: (cx + (r * cos0) / 1024) as i16, + y: (cy + (r * sin0) / 1024) as i16, + z: 0, + _pad: 0, + }, + ); + + sys::sceGuDrawArray( + GuPrimitive::TriangleFan, + COLOR_VTYPE, + vert_count as i32, + ptr::null(), + verts as *const c_void, + ); + + sys::sceGuEnable(sys::GuState::Texture2D); + } + } + + /// Draw a line between two points using GU line primitives. + /// + /// For `width > 1`, draws parallel lines offset perpendicular to + /// the line direction. Uses integer arithmetic only. + pub fn draw_line_inner( + &mut self, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + width: u16, + color: Color, + ) { + let abgr = color.to_abgr(); + let w = (width as i32).max(1); + + // SAFETY: Disabling Texture2D and using GU memory for line + // vertices within the active display list. + unsafe { + sys::sceGuDisable(sys::GuState::Texture2D); + + if w <= 1 { + // Single line: 2 vertices. + let verts = sys::sceGuGetMemory( + (2 * size_of::()) as i32, + ) as *mut ColorVertex; + if verts.is_null() { + sys::sceGuEnable(sys::GuState::Texture2D); + return; + } + + ptr::write( + verts, + ColorVertex { + color: abgr, + x: x1 as i16, + y: y1 as i16, + z: 0, + _pad: 0, + }, + ); + ptr::write( + verts.add(1), + ColorVertex { + color: abgr, + x: x2 as i16, + y: y2 as i16, + z: 0, + _pad: 0, + }, + ); + + sys::sceGuDrawArray( + GuPrimitive::Lines, + COLOR_VTYPE, + 2, + ptr::null(), + verts as *const c_void, + ); + } else { + // Multiple parallel lines for thickness. + let dx = (x2 - x1) as f32; + let dy = (y2 - y1) as f32; + let len = libm::sqrtf(dx * dx + dy * dy).max(1.0); + let nx = -dy / len; + let ny = dx / len; + let half = w / 2; + let line_count = w as usize; + + let verts = sys::sceGuGetMemory( + (line_count * 2 * size_of::()) as i32, + ) as *mut ColorVertex; + if verts.is_null() { + sys::sceGuEnable(sys::GuState::Texture2D); + return; + } + + for i in 0..line_count { + let offset = i as i32 - half; + let ox = (nx * offset as f32) as i32; + let oy = (ny * offset as f32) as i32; + ptr::write( + verts.add(i * 2), + ColorVertex { + color: abgr, + x: (x1 + ox) as i16, + y: (y1 + oy) as i16, + z: 0, + _pad: 0, + }, + ); + ptr::write( + verts.add(i * 2 + 1), + ColorVertex { + color: abgr, + x: (x2 + ox) as i16, + y: (y2 + oy) as i16, + z: 0, + _pad: 0, + }, + ); + } + + sys::sceGuDrawArray( + GuPrimitive::Lines, + COLOR_VTYPE, + (line_count * 2) as i32, + ptr::null(), + verts as *const c_void, + ); + } + + sys::sceGuEnable(sys::GuState::Texture2D); + } + } + + /// Draw a filled rectangle with a gradient using per-vertex colors. + /// + /// Vertical gradients use a single Sprites primitive with the top + /// and bottom vertex colors set to the gradient endpoints. The GE + /// hardware interpolates the color across the primitive. + /// + /// Horizontal and four-corner gradients use two or four triangles + /// to achieve bilinear interpolation. + pub fn fill_rect_gradient_inner( + &mut self, + x: i32, + y: i32, + w: u32, + h: u32, + gradient: &GradientStyle, + ) { + if w == 0 || h == 0 { + return; + } + + // SAFETY: Disabling Texture2D and allocating GU vertices + // for gradient rendering within the active display list. + unsafe { + sys::sceGuDisable(sys::GuState::Texture2D); + + match *gradient { + GradientStyle::Vertical { top, bottom } => { + // Two triangles forming a quad. The GE interpolates + // vertex colors linearly across the primitive. + let top_abgr = top.to_abgr(); + let bot_abgr = bottom.to_abgr(); + + let verts = sys::sceGuGetMemory( + (6 * size_of::()) as i32, + ) as *mut ColorVertex; + if !verts.is_null() { + let x2 = x + w as i32; + let y2 = y + h as i32; + + // Triangle 1: top-left, top-right, bottom-left. + write_color_vert( + verts, 0, top_abgr, x, y, + ); + write_color_vert( + verts, 1, top_abgr, x2, y, + ); + write_color_vert( + verts, 2, bot_abgr, x, y2, + ); + // Triangle 2: top-right, bottom-right, bottom-left. + write_color_vert( + verts, 3, top_abgr, x2, y, + ); + write_color_vert( + verts, 4, bot_abgr, x2, y2, + ); + write_color_vert( + verts, 5, bot_abgr, x, y2, + ); + + sys::sceGuDrawArray( + GuPrimitive::Triangles, + COLOR_VTYPE, + 6, + ptr::null(), + verts as *const c_void, + ); + } + }, + GradientStyle::Horizontal { left, right } => { + let left_abgr = left.to_abgr(); + let right_abgr = right.to_abgr(); + + let verts = sys::sceGuGetMemory( + (6 * size_of::()) as i32, + ) as *mut ColorVertex; + if !verts.is_null() { + let x2 = x + w as i32; + let y2 = y + h as i32; + + // Triangle 1: top-left, top-right, bottom-left. + write_color_vert( + verts, 0, left_abgr, x, y, + ); + write_color_vert( + verts, 1, right_abgr, x2, y, + ); + write_color_vert( + verts, 2, left_abgr, x, y2, + ); + // Triangle 2: top-right, bottom-right, bottom-left. + write_color_vert( + verts, 3, right_abgr, x2, y, + ); + write_color_vert( + verts, 4, right_abgr, x2, y2, + ); + write_color_vert( + verts, 5, left_abgr, x, y2, + ); + + sys::sceGuDrawArray( + GuPrimitive::Triangles, + COLOR_VTYPE, + 6, + ptr::null(), + verts as *const c_void, + ); + } + }, + GradientStyle::FourCorner { + top_left, + top_right, + bottom_left, + bottom_right, + } => { + let tl = top_left.to_abgr(); + let tr = top_right.to_abgr(); + let bl = bottom_left.to_abgr(); + let br = bottom_right.to_abgr(); + + let verts = sys::sceGuGetMemory( + (6 * size_of::()) as i32, + ) as *mut ColorVertex; + if !verts.is_null() { + let x2 = x + w as i32; + let y2 = y + h as i32; + + // Triangle 1: TL, TR, BL. + write_color_vert(verts, 0, tl, x, y); + write_color_vert(verts, 1, tr, x2, y); + write_color_vert(verts, 2, bl, x, y2); + // Triangle 2: TR, BR, BL. + write_color_vert(verts, 3, tr, x2, y); + write_color_vert(verts, 4, br, x2, y2); + write_color_vert(verts, 5, bl, x, y2); + + sys::sceGuDrawArray( + GuPrimitive::Triangles, + COLOR_VTYPE, + 6, + ptr::null(), + verts as *const c_void, + ); + } + }, + } + + sys::sceGuEnable(sys::GuState::Texture2D); + } + } + + /// Dim the entire screen using a full-viewport semi-transparent rect. + pub fn dim_screen_inner(&mut self, alpha: u8) { + self.fill_rect_inner( + 0, + 0, + SCREEN_WIDTH, + SCREEN_HEIGHT, + Color::rgba(0, 0, 0, alpha), + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Write a `ColorVertex` at `verts[index]`. +/// +/// SAFETY: Caller must ensure `verts.add(index)` is valid for a write. +unsafe fn write_color_vert( + verts: *mut ColorVertex, + index: usize, + color: u32, + x: i32, + y: i32, +) { + // SAFETY: Caller guarantees `verts.add(index)` is within the allocated sceGuGetMemory buffer. + unsafe { + ptr::write( + verts.add(index), + ColorVertex { + color, + x: x as i16, + y: y as i16, + z: 0, + _pad: 0, + }, + ); + } +} + +/// Integer square root (floor) for positive i32 values. +fn isqrt_i32(n: i32) -> i32 { + if n <= 0 { + return 0; + } + // Newton's method with integer arithmetic. + let mut x = n; + let mut y = (x + 1) / 2; + while y < x { + x = y; + y = (x + n / x) / 2; + } + x +} diff --git a/crates/oasis-backend-sdl/Cargo.toml b/crates/oasis-backend-sdl/Cargo.toml index 6b2afa3..b61cb0b 100644 --- a/crates/oasis-backend-sdl/Cargo.toml +++ b/crates/oasis-backend-sdl/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-backend-sdl" [dependencies] oasis-core = { workspace = true, features = ["tls-rustls"] } diff --git a/crates/oasis-backend-sdl/src/lib.rs b/crates/oasis-backend-sdl/src/lib.rs index d390b2d..53110e5 100644 --- a/crates/oasis-backend-sdl/src/lib.rs +++ b/crates/oasis-backend-sdl/src/lib.rs @@ -8,6 +8,7 @@ //! SDL2 renderer API calls and software rasterization helpers. mod font; +pub mod network; mod sdl_audio; use std::collections::HashMap; @@ -20,10 +21,11 @@ use sdl2::rect::Rect; use sdl2::render::{Canvas, Texture, TextureCreator}; use sdl2::video::{Window, WindowContext}; -use oasis_core::backend::{Color, SdiBackend, TextureId}; +use oasis_core::backend::{Color, GradientStyle, SdiBackend, TextureId}; use oasis_core::error::{OasisError, Result}; use oasis_core::input::{Button, InputEvent, Trigger}; +pub use network::SdlNetworkBackend; pub use sdl_audio::SdlAudioBackend; /// Stored clip rectangle. @@ -514,40 +516,50 @@ impl SdiBackend for SdlBackend { // Extended: Gradient Fills // ------------------------------------------------------------------- - fn fill_rect_gradient_v( + fn fill_rect_gradient( &mut self, x: i32, y: i32, w: u32, h: u32, - top_color: Color, - bottom_color: Color, + gradient: &GradientStyle, ) -> Result<()> { let (tx, ty) = self.translate(x, y); - let h_max = h.saturating_sub(1).max(1); - for dy in 0..h as i32 { - let color = lerp_color_sdl(top_color, bottom_color, dy as u32, h_max); - self.set_color(color); - let _ = self.canvas.fill_rect(Rect::new(tx, ty + dy, w, 1)); - } - Ok(()) - } - - fn fill_rect_gradient_h( - &mut self, - x: i32, - y: i32, - w: u32, - h: u32, - left_color: Color, - right_color: Color, - ) -> Result<()> { - let (tx, ty) = self.translate(x, y); - let w_max = w.saturating_sub(1).max(1); - for dx in 0..w as i32 { - let color = lerp_color_sdl(left_color, right_color, dx as u32, w_max); - self.set_color(color); - let _ = self.canvas.fill_rect(Rect::new(tx + dx, ty, 1, h)); + match *gradient { + GradientStyle::Vertical { top, bottom } => { + let h_max = h.saturating_sub(1).max(1); + for dy in 0..h as i32 { + let color = lerp_color_sdl(top, bottom, dy as u32, h_max); + self.set_color(color); + let _ = self.canvas.fill_rect(Rect::new(tx, ty + dy, w, 1)); + } + }, + GradientStyle::Horizontal { left, right } => { + let w_max = w.saturating_sub(1).max(1); + for dx in 0..w as i32 { + let color = lerp_color_sdl(left, right, dx as u32, w_max); + self.set_color(color); + let _ = self.canvas.fill_rect(Rect::new(tx + dx, ty, 1, h)); + } + }, + GradientStyle::FourCorner { + top_left, + top_right, + bottom_left, + bottom_right, + } => { + let h_max = h.saturating_sub(1).max(1); + let w_max = w.saturating_sub(1).max(1); + for dy in 0..h as i32 { + let left = lerp_color_sdl(top_left, bottom_left, dy as u32, h_max); + let right = lerp_color_sdl(top_right, bottom_right, dy as u32, h_max); + for dx in 0..w as i32 { + let color = lerp_color_sdl(left, right, dx as u32, w_max); + self.set_color(color); + let _ = self.canvas.fill_rect(Rect::new(tx + dx, ty + dy, 1, 1)); + } + } + }, } Ok(()) } @@ -656,19 +668,24 @@ impl SdiBackend for SdlBackend { Ok(()) } - fn fill_rounded_rect_gradient_v( + fn fill_rounded_rect_gradient( &mut self, x: i32, y: i32, w: u32, h: u32, radius: u16, - top_color: Color, - bottom_color: Color, + gradient: &GradientStyle, ) -> Result<()> { if radius == 0 || w == 0 || h == 0 { - return self.fill_rect_gradient_v(x, y, w, h, top_color, bottom_color); + return self.fill_rect_gradient(x, y, w, h, gradient); } + // Currently only Vertical gradients get rounded-rect acceleration; + // other styles fall back to a flat rounded rect to preserve shape. + let (top_color, bottom_color) = match *gradient { + GradientStyle::Vertical { top, bottom } => (top, bottom), + _ => return self.fill_rounded_rect(x, y, w, h, radius, gradient.primary_color()), + }; let (tx, ty) = self.translate(x, y); let r = (radius as i32).min(w as i32 / 2).min(h as i32 / 2); let h_max = (h as i32 - 1).max(1); diff --git a/crates/oasis-backend-sdl/src/network.rs b/crates/oasis-backend-sdl/src/network.rs new file mode 100644 index 0000000..f428fba --- /dev/null +++ b/crates/oasis-backend-sdl/src/network.rs @@ -0,0 +1,335 @@ +//! SDL backend network module. +//! +//! Re-exports [`StdNetworkBackend`] from `oasis-net` as the SDL +//! backend's `NetworkBackend` implementation. On desktop and Raspberry +//! Pi, all TCP and TLS operations use `std::net`. +//! +//! This module adds a thin wrapper (`SdlNetworkBackend`) that delegates +//! to `StdNetworkBackend` so the SDL crate has a named type it owns. + +use oasis_core::backend::{NetworkBackend, NetworkStream}; +use oasis_core::error::Result; + +/// SDL network backend wrapping [`oasis_core::net::StdNetworkBackend`]. +/// +/// All methods delegate to the inner `StdNetworkBackend` which uses +/// `std::net` for TCP and (when the `tls-rustls` feature is active) +/// `rustls` for TLS. +pub struct SdlNetworkBackend { + inner: oasis_core::net::StdNetworkBackend, +} + +impl SdlNetworkBackend { + /// Create a new network backend. + pub fn new() -> Self { + Self { + inner: oasis_core::net::StdNetworkBackend::new(), + } + } +} + +impl Default for SdlNetworkBackend { + fn default() -> Self { + Self::new() + } +} + +impl NetworkBackend for SdlNetworkBackend { + fn listen(&mut self, port: u16) -> Result<()> { + self.inner.listen(port) + } + + fn accept(&mut self) -> Result>> { + self.inner.accept() + } + + fn connect(&mut self, address: &str, port: u16) -> Result> { + self.inner.connect(address, port) + } + + fn tls_provider(&self) -> Option<&dyn oasis_core::tls::TlsProvider> { + self.inner.tls_provider() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + + /// Find a free TCP port by binding to port 0 and releasing it. + fn free_port() -> u16 { + let tmp = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = tmp.local_addr().unwrap().port(); + drop(tmp); + port + } + + #[test] + fn new_creates_backend() { + let _backend = SdlNetworkBackend::new(); + } + + #[test] + fn default_creates_backend() { + let _backend = SdlNetworkBackend::default(); + } + + #[test] + fn listen_succeeds_on_free_port() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + } + + #[test] + fn accept_returns_none_when_no_connection() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + let result = backend.accept().unwrap(); + assert!(result.is_none()); + } + + #[test] + fn accept_without_listen_errors() { + let mut backend = SdlNetworkBackend::new(); + assert!(backend.accept().is_err()); + } + + #[test] + fn listen_and_accept_connection() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + // Connect a client. + let _client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let stream = backend.accept().unwrap(); + assert!(stream.is_some()); + } + + #[test] + fn server_reads_client_data() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let mut client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + client.write_all(b"hello").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + let mut buf = [0u8; 64]; + let n = stream.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"hello"); + } + + #[test] + fn server_writes_to_client() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let mut client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + stream.write(b"world").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut buf = [0u8; 64]; + let n = client.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"world"); + } + + #[test] + fn connect_outbound() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let handle = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + conn.write_all(b"greeting").unwrap(); + }); + + let mut backend = SdlNetworkBackend::new(); + let mut stream = backend.connect("127.0.0.1", port).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let mut buf = [0u8; 64]; + let n = stream.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"greeting"); + + stream.close().unwrap(); + handle.join().unwrap(); + } + + #[test] + fn connect_to_invalid_address_fails() { + let mut backend = SdlNetworkBackend::new(); + // Port 1 is almost certainly not listening. + let result = backend.connect("127.0.0.1", 1); + assert!(result.is_err()); + } + + #[test] + fn stream_close() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let _client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + stream.close().unwrap(); + } + + #[test] + fn bidirectional_data_exchange() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let mut client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + client.write_all(b"ping").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + let mut buf = [0u8; 64]; + let n = stream.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"ping"); + + stream.write(b"pong").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let n = client.read(&mut buf).unwrap(); + assert_eq!(&buf[..n], b"pong"); + } + + #[test] + fn multiple_accepts() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let _c1 = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + let s1 = backend.accept().unwrap(); + assert!(s1.is_some()); + + let _c2 = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + let s2 = backend.accept().unwrap(); + assert!(s2.is_some()); + } + + #[test] + fn tls_provider_is_available() { + let backend = SdlNetworkBackend::new(); + // With tls-rustls feature enabled, should return Some. + assert!(backend.tls_provider().is_some()); + } + + #[test] + fn write_returns_byte_count() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let _client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + let n = stream.write(b"test data").unwrap(); + assert_eq!(n, 9); + } + + #[test] + fn read_with_no_data_returns_zero_or_would_block() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let _client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let mut stream = backend.accept().unwrap().unwrap(); + let mut buf = [0u8; 64]; + // Non-blocking socket: read returns Ok(0) or WouldBlock. + match stream.read(&mut buf) { + Ok(0) => {}, + Err(_) => {}, // WouldBlock is expected. + Ok(n) => panic!("expected 0 or error, got {n} bytes"), + } + } + + #[test] + fn large_data_transfer() { + let mut backend = SdlNetworkBackend::new(); + let port = free_port(); + backend.listen(port).unwrap(); + + let handle = std::thread::spawn(move || { + let mut client = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + let payload = vec![0xABu8; 4096]; + client.write_all(&payload).unwrap(); + }); + + std::thread::sleep(std::time::Duration::from_millis(100)); + let mut stream = backend.accept().unwrap().unwrap(); + + let mut received = Vec::new(); + let mut buf = [0u8; 1024]; + while received.len() < 4096 { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => received.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + assert_eq!(received.len(), 4096); + assert!(received.iter().all(|&b| b == 0xAB)); + + handle.join().unwrap(); + } + + #[test] + fn connect_write_close_cycle() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let handle = std::thread::spawn(move || { + let (mut conn, _) = listener.accept().unwrap(); + let mut buf = [0u8; 64]; + let _ = conn.read(&mut buf); + }); + + let mut backend = SdlNetworkBackend::new(); + let mut stream = backend.connect("127.0.0.1", port).unwrap(); + stream.write(b"data").unwrap(); + stream.close().unwrap(); + handle.join().unwrap(); + } + + #[test] + fn listen_on_two_ports() { + let mut b1 = SdlNetworkBackend::new(); + let mut b2 = SdlNetworkBackend::new(); + let p1 = free_port(); + let p2 = free_port(); + b1.listen(p1).unwrap(); + b2.listen(p2).unwrap(); + + let _c1 = TcpStream::connect(format!("127.0.0.1:{p1}")).unwrap(); + let _c2 = TcpStream::connect(format!("127.0.0.1:{p2}")).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(50)); + + assert!(b1.accept().unwrap().is_some()); + assert!(b2.accept().unwrap().is_some()); + } +} diff --git a/crates/oasis-backend-sdl/src/sdl_audio.rs b/crates/oasis-backend-sdl/src/sdl_audio.rs index 768cac3..5aea1e0 100644 --- a/crates/oasis-backend-sdl/src/sdl_audio.rs +++ b/crates/oasis-backend-sdl/src/sdl_audio.rs @@ -461,4 +461,113 @@ mod tests { backend.play(track).unwrap(); assert!(backend.is_playing()); } + + // --------------------------------------------------------------- + // Additional streaming tests + // --------------------------------------------------------------- + + #[test] + fn streaming_starts_empty() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + assert_eq!(backend.tracks[&track.0].len(), 0); + } + + #[test] + fn streaming_multiple_tracks() { + let mut backend = init_backend(); + let t1 = backend.load_streaming().unwrap(); + let t2 = backend.load_streaming().unwrap(); + assert_ne!(t1, t2); + + backend.feed_data(t1, b"track1").unwrap(); + backend.feed_data(t2, b"track2_data").unwrap(); + + assert_eq!(backend.tracks[&t1.0].len(), 6); + assert_eq!(backend.tracks[&t2.0].len(), 11); + } + + #[test] + fn streaming_play_after_feed() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + backend.feed_data(track, b"audio data").unwrap(); + // Should be able to play a streaming track. + backend.play(track).unwrap(); + assert!(backend.is_playing()); + } + + #[test] + fn streaming_unload_stops_playback() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + backend.feed_data(track, b"data").unwrap(); + backend.play(track).unwrap(); + assert!(backend.is_playing()); + backend.unload_track(track).unwrap(); + assert!(!backend.is_playing()); + assert!(!backend.tracks.contains_key(&track.0)); + } + + #[test] + fn streaming_feed_empty_data() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + backend.feed_data(track, b"").unwrap(); + assert_eq!(backend.tracks[&track.0].len(), 0); + } + + #[test] + fn streaming_buffer_exact_limit() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + // Feed exactly 128KB. + let chunk = vec![0xBBu8; 128 * 1024]; + backend.feed_data(track, &chunk).unwrap(); + assert_eq!(backend.tracks[&track.0].len(), 128 * 1024); + } + + #[test] + fn streaming_buffer_drains_oldest_data() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + // Fill with 0xAA, then overflow with 0xBB. + let first = vec![0xAAu8; 128 * 1024]; + backend.feed_data(track, &first).unwrap(); + backend.feed_data(track, &[0xBBu8; 100]).unwrap(); + // Buffer should be exactly 128KB. + assert_eq!(backend.tracks[&track.0].len(), 128 * 1024); + // Last bytes should be 0xBB (the new data). + let buf = &backend.tracks[&track.0]; + assert_eq!(buf[buf.len() - 1], 0xBB); + assert_eq!(buf[buf.len() - 100], 0xBB); + } + + #[test] + fn streaming_shutdown_clears_streaming_tracks() { + let mut backend = init_backend(); + let t1 = backend.load_streaming().unwrap(); + backend.feed_data(t1, b"data").unwrap(); + let _t2 = backend.load_streaming().unwrap(); + backend.shutdown().unwrap(); + assert!(backend.tracks.is_empty()); + } + + #[test] + fn streaming_feed_after_unload_fails() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + backend.unload_track(track).unwrap(); + assert!(backend.feed_data(track, b"data").is_err()); + } + + #[test] + fn streaming_incremental_growth() { + let mut backend = init_backend(); + let track = backend.load_streaming().unwrap(); + for i in 0..10 { + backend.feed_data(track, &[i as u8; 100]).unwrap(); + } + assert_eq!(backend.tracks[&track.0].len(), 1000); + } } diff --git a/crates/oasis-backend-ue5/Cargo.toml b/crates/oasis-backend-ue5/Cargo.toml index cabcdd5..23acb4b 100644 --- a/crates/oasis-backend-ue5/Cargo.toml +++ b/crates/oasis-backend-ue5/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true authors.workspace = true description = "OASIS_OS UE5 render target backend -- software RGBA framebuffer and FFI input" +documentation = "https://docs.rs/oasis-backend-ue5" [dependencies] oasis-core.workspace = true diff --git a/crates/oasis-backend-ue5/src/audio.rs b/crates/oasis-backend-ue5/src/audio.rs index 9acb956..03db011 100644 --- a/crates/oasis-backend-ue5/src/audio.rs +++ b/crates/oasis-backend-ue5/src/audio.rs @@ -48,6 +48,15 @@ impl Ue5AudioBackend { self.callback = Some(cb); } + /// Return whether this backend can produce audio output. + /// + /// Always returns `false` because the UE5 backend delegates + /// audio to the host engine via callbacks. OASIS_OS itself does + /// not drive audio hardware in this configuration. + pub fn has_audio(&self) -> bool { + false + } + fn fire(&self, event: AudioEvent, track_id: u64, value: u32) { if let Some(cb) = self.callback { cb(event as u32, track_id, value); @@ -307,4 +316,80 @@ mod tests { b.unload_track(t).unwrap(); b.shutdown().unwrap(); } + + #[test] + fn has_audio_returns_false() { + let b = Ue5AudioBackend::new(); + assert!(!b.has_audio()); + } + + #[test] + fn has_audio_false_after_init() { + let mut b = Ue5AudioBackend::new(); + b.init().unwrap(); + assert!(!b.has_audio()); + } + + #[test] + fn init_succeeds_gracefully() { + let mut b = Ue5AudioBackend::new(); + assert!(b.init().is_ok()); + } + + #[test] + fn stop_without_play_succeeds() { + let mut b = init_backend(); + // Stopping when nothing is playing should succeed gracefully. + assert!(b.stop().is_ok()); + } + + #[test] + fn position_always_zero() { + let mut b = init_backend(); + assert_eq!(b.position_ms(), 0); + let t = b.load_track(b"data").unwrap(); + b.play(t).unwrap(); + assert_eq!(b.position_ms(), 0); + } + + #[test] + fn duration_always_zero() { + let mut b = init_backend(); + assert_eq!(b.duration_ms(), 0); + let t = b.load_track(b"data").unwrap(); + b.play(t).unwrap(); + assert_eq!(b.duration_ms(), 0); + } + + #[test] + fn multiple_tracks_load_and_unload() { + let mut b = init_backend(); + let t1 = b.load_track(b"track1").unwrap(); + let t2 = b.load_track(b"track2").unwrap(); + let t3 = b.load_track(b"track3").unwrap(); + assert_ne!(t1, t2); + assert_ne!(t2, t3); + + b.unload_track(t2).unwrap(); + // t1 and t3 should still be playable. + b.play(t1).unwrap(); + b.stop().unwrap(); + b.play(t3).unwrap(); + } + + #[test] + fn streaming_feed_data_succeeds() { + let mut b = init_backend(); + let t = b.load_streaming().unwrap(); + assert!(b.feed_data(t, b"chunk 1").is_ok()); + assert!(b.feed_data(t, b"chunk 2").is_ok()); + } + + #[test] + fn double_init_succeeds() { + let mut b = Ue5AudioBackend::new(); + b.init().unwrap(); + // Double init should succeed gracefully. + b.init().unwrap(); + } } diff --git a/crates/oasis-backend-ue5/src/renderer.rs b/crates/oasis-backend-ue5/src/renderer.rs index 83da447..baff2f3 100644 --- a/crates/oasis-backend-ue5/src/renderer.rs +++ b/crates/oasis-backend-ue5/src/renderer.rs @@ -9,7 +9,7 @@ use std::rc::Rc; -use oasis_core::backend::{Color, SdiBackend, TextureId}; +use oasis_core::backend::{Color, GradientStyle, SdiBackend, TextureId}; use oasis_core::error::{OasisError, Result}; use crate::font; @@ -656,77 +656,51 @@ impl SdiBackend for Ue5Backend { // Extended: Gradient Fills // ------------------------------------------------------------------- - fn fill_rect_gradient_v( + fn fill_rect_gradient( &mut self, x: i32, y: i32, w: u32, h: u32, - top_color: Color, - bottom_color: Color, + gradient: &GradientStyle, ) -> Result<()> { let (tx, ty) = self.translate(x, y); - for dy in 0..h as i32 { - let color = lerp_color( - top_color, - bottom_color, - dy as u32, - h.saturating_sub(1).max(1), - ); - for dx in 0..w as i32 { - self.set_pixel(tx + dx, ty + dy, color); - } - } - self.dirty = true; - Ok(()) - } - - fn fill_rect_gradient_h( - &mut self, - x: i32, - y: i32, - w: u32, - h: u32, - left_color: Color, - right_color: Color, - ) -> Result<()> { - let (tx, ty) = self.translate(x, y); - for dx in 0..w as i32 { - let color = lerp_color( - left_color, - right_color, - dx as u32, - w.saturating_sub(1).max(1), - ); - for dy in 0..h as i32 { - self.set_pixel(tx + dx, ty + dy, color); - } - } - self.dirty = true; - Ok(()) - } - - fn fill_rect_gradient_4( - &mut self, - x: i32, - y: i32, - w: u32, - h: u32, - top_left: Color, - top_right: Color, - bottom_left: Color, - bottom_right: Color, - ) -> Result<()> { - let (tx, ty) = self.translate(x, y); - let h_max = h.saturating_sub(1).max(1); - let w_max = w.saturating_sub(1).max(1); - for dy in 0..h as i32 { - let left = lerp_color(top_left, bottom_left, dy as u32, h_max); - let right = lerp_color(top_right, bottom_right, dy as u32, h_max); - for dx in 0..w as i32 { - let color = lerp_color(left, right, dx as u32, w_max); - self.set_pixel(tx + dx, ty + dy, color); - } + match *gradient { + GradientStyle::Vertical { top, bottom } => { + let h_max = h.saturating_sub(1).max(1); + for dy in 0..h as i32 { + let color = lerp_color(top, bottom, dy as u32, h_max); + for dx in 0..w as i32 { + self.set_pixel(tx + dx, ty + dy, color); + } + } + }, + GradientStyle::Horizontal { left, right } => { + let w_max = w.saturating_sub(1).max(1); + for dx in 0..w as i32 { + let color = lerp_color(left, right, dx as u32, w_max); + for dy in 0..h as i32 { + self.set_pixel(tx + dx, ty + dy, color); + } + } + }, + GradientStyle::FourCorner { + top_left, + top_right, + bottom_left, + bottom_right, + } => { + let h_max = h.saturating_sub(1).max(1); + let w_max = w.saturating_sub(1).max(1); + for dy in 0..h as i32 { + let left = lerp_color(top_left, bottom_left, dy as u32, h_max); + let right = lerp_color(top_right, bottom_right, dy as u32, h_max); + for dx in 0..w as i32 { + let color = lerp_color(left, right, dx as u32, w_max); + self.set_pixel(tx + dx, ty + dy, color); + } + } + }, } self.dirty = true; Ok(()) @@ -1215,7 +1189,16 @@ mod tests { let mut backend = Ue5Backend::new(10, 10); backend.clear(Color::BLACK).unwrap(); backend - .fill_rect_gradient_v(0, 0, 10, 10, Color::WHITE, Color::BLACK) + .fill_rect_gradient( + 0, + 0, + 10, + 10, + &GradientStyle::Vertical { + top: Color::WHITE, + bottom: Color::BLACK, + }, + ) .unwrap(); // Top pixel should be white. assert_eq!(backend.buffer()[0], 255); @@ -1229,7 +1212,16 @@ mod tests { let mut backend = Ue5Backend::new(10, 10); backend.clear(Color::BLACK).unwrap(); backend - .fill_rect_gradient_h(0, 0, 10, 10, Color::WHITE, Color::BLACK) + .fill_rect_gradient( + 0, + 0, + 10, + 10, + &GradientStyle::Horizontal { + left: Color::WHITE, + right: Color::BLACK, + }, + ) .unwrap(); // Left pixel should be white. assert_eq!(backend.buffer()[0], 255); diff --git a/crates/oasis-browser/Cargo.toml b/crates/oasis-browser/Cargo.toml index dfd7fc7..72f27bf 100644 --- a/crates/oasis-browser/Cargo.toml +++ b/crates/oasis-browser/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-browser" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-browser/benches/css_cascade.rs b/crates/oasis-browser/benches/css_cascade.rs index 6c5a0fd..ecf784e 100644 --- a/crates/oasis-browser/benches/css_cascade.rs +++ b/crates/oasis-browser/benches/css_cascade.rs @@ -1,10 +1,7 @@ //! Benchmarks for CSS parsing and cascade matching. use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use oasis_browser::css::cascade::style_tree; -use oasis_browser::css::parser::Stylesheet; -use oasis_browser::html::tokenizer::Tokenizer; -use oasis_browser::html::tree_builder::TreeBuilder; +use oasis_browser::internals::{Stylesheet, Tokenizer, TreeBuilder, style_tree}; /// Generate a CSS stylesheet with `n` rules. fn generate_css(n: usize) -> String { diff --git a/crates/oasis-browser/benches/html_parsing.rs b/crates/oasis-browser/benches/html_parsing.rs index 0406491..12e15ce 100644 --- a/crates/oasis-browser/benches/html_parsing.rs +++ b/crates/oasis-browser/benches/html_parsing.rs @@ -1,8 +1,7 @@ //! Benchmarks for the HTML tokenizer and DOM tree builder. use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; -use oasis_browser::html::tokenizer::Tokenizer; -use oasis_browser::html::tree_builder::TreeBuilder; +use oasis_browser::internals::{Tokenizer, TreeBuilder}; /// Generate a synthetic HTML document of approximately `target_bytes` size. fn generate_html(target_bytes: usize) -> String { diff --git a/crates/oasis-browser/benches/layout_engine.rs b/crates/oasis-browser/benches/layout_engine.rs index d2d3322..d2c1b87 100644 --- a/crates/oasis-browser/benches/layout_engine.rs +++ b/crates/oasis-browser/benches/layout_engine.rs @@ -2,11 +2,7 @@ use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use oasis_browser::SimpleTextMeasurer; -use oasis_browser::css::cascade::style_tree; -use oasis_browser::css::parser::Stylesheet; -use oasis_browser::html::tokenizer::Tokenizer; -use oasis_browser::html::tree_builder::TreeBuilder; -use oasis_browser::layout::block::build_layout_tree; +use oasis_browser::internals::{Stylesheet, Tokenizer, TreeBuilder, build_layout_tree, style_tree}; /// Generate HTML with `n` block-level divs. fn generate_blocks(n: usize) -> String { @@ -39,8 +35,8 @@ fn prepare_for_layout( html: &str, css: &str, ) -> ( - oasis_browser::html::dom::Document, - Vec>, + oasis_browser::internals::Document, + Vec>, ) { let mut tokenizer = Tokenizer::new(html); let tokens = tokenizer.tokenize(); diff --git a/crates/oasis-browser/benches/paint.rs b/crates/oasis-browser/benches/paint.rs index 8f768af..1804996 100644 --- a/crates/oasis-browser/benches/paint.rs +++ b/crates/oasis-browser/benches/paint.rs @@ -4,12 +4,9 @@ use std::collections::HashMap; use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; use oasis_browser::SimpleTextMeasurer; -use oasis_browser::css::cascade::style_tree; -use oasis_browser::css::parser::Stylesheet; -use oasis_browser::html::tokenizer::Tokenizer; -use oasis_browser::html::tree_builder::TreeBuilder; -use oasis_browser::layout::block::build_layout_tree; -use oasis_browser::paint; +use oasis_browser::internals::{ + Stylesheet, Tokenizer, TreeBuilder, build_layout_tree, paint_page, style_tree, +}; use oasis_types::backend::{Color, SdiBackend, TextureId}; use oasis_types::error::Result; @@ -118,7 +115,7 @@ fn bench_paint(c: &mut Criterion) { &(&layout, &link_map), |b, (layout, link_map)| { let mut backend = NullBackend; - b.iter(|| paint::paint(layout, &mut backend, 0.0, 0, 0, 480.0, 272.0, link_map)); + b.iter(|| paint_page(layout, &mut backend, 0.0, 0, 0, 480.0, 272.0, link_map)); }, ); } @@ -148,7 +145,7 @@ fn bench_full_pipeline(c: &mut Criterion) { let styles = style_tree(&doc, &[&stylesheet], &[]); let layout = build_layout_tree(&doc, &styles, &measurer, 480.0, 272.0); let link_map: HashMap = HashMap::new(); - paint::paint(&layout, &mut backend, 0.0, 0, 0, 480.0, 272.0, &link_map) + paint_page(&layout, &mut backend, 0.0, 0, 0, 480.0, 272.0, &link_map) }); }, ); diff --git a/crates/oasis-browser/src/css/values.rs b/crates/oasis-browser/src/css/values.rs index 9345b0c..40fafe9 100644 --- a/crates/oasis-browser/src/css/values.rs +++ b/crates/oasis-browser/src/css/values.rs @@ -1258,4 +1258,202 @@ mod tests { assert_eq!(keyword_color("transparent"), Some(Color::rgba(0, 0, 0, 0)),); assert_eq!(keyword_color("nonexistent"), None); } + + mod prop { + use super::*; + use proptest::prelude::*; + + proptest! { + /// resolve_length with Px always returns the value. + #[test] + fn resolve_length_px_identity(v in -1000.0f32..1000.0) { + let result = resolve_length( + &CssValue::Length(v, LengthUnit::Px), 16.0, + ); + prop_assert!( + (result - v).abs() < 0.001, + "Px({v}) should resolve to {v}, got {result}", + ); + } + + /// resolve_length with Em scales by parent font size. + #[test] + fn resolve_length_em_scales( + v in 0.0f32..10.0, + parent in 1.0f32..100.0, + ) { + let result = resolve_length( + &CssValue::Length(v, LengthUnit::Em), parent, + ); + let expected = v * parent; + prop_assert!( + (result - expected).abs() < 0.01, + "Em({v}) * {parent} = {expected}, got {result}", + ); + } + + /// resolve_length with Rem scales by ROOT_FONT_SIZE. + #[test] + fn resolve_length_rem_scales(v in 0.0f32..10.0) { + let result = resolve_length( + &CssValue::Length(v, LengthUnit::Rem), 16.0, + ); + let expected = v * ROOT_FONT_SIZE; + prop_assert!( + (result - expected).abs() < 0.01, + "Rem({v}) = {expected}, got {result}", + ); + } + + /// resolve_dimension with auto keyword always returns Auto. + #[test] + fn resolve_dimension_auto(_dummy in 0..1i32) { + let result = resolve_dimension( + &CssValue::Keyword("auto".into()), 16.0, + ); + prop_assert_eq!(result, Dimension::Auto); + } + + /// resolve_dimension with percentage preserves the value. + #[test] + fn resolve_dimension_percent(pct in 0.0f32..200.0) { + let result = resolve_dimension( + &CssValue::Percentage(pct), 16.0, + ); + prop_assert_eq!(result, Dimension::Percent(pct)); + } + + /// resolve_font_size with Px returns the exact value. + #[test] + fn resolve_font_size_px_identity(v in 1.0f32..100.0) { + let result = resolve_font_size( + &CssValue::Length(v, LengthUnit::Px), 16.0, + ); + prop_assert!( + (result - v).abs() < 0.001, + "font-size Px({v}) -> {result}", + ); + } + + /// resolve_font_size with percentage scales by parent. + #[test] + fn resolve_font_size_percent( + pct in 10.0f32..300.0, + parent in 4.0f32..48.0, + ) { + let result = resolve_font_size( + &CssValue::Percentage(pct), parent, + ); + let expected = parent * (pct / 100.0); + prop_assert!( + (result - expected).abs() < 0.01, + "{pct}% of {parent} = {expected}, got {result}", + ); + } + + /// resolve_line_height with Number multiplies by font_size. + #[test] + fn resolve_line_height_number( + n in 0.5f32..3.0, + fs in 4.0f32..48.0, + ) { + let result = resolve_line_height( + &CssValue::Number(n), fs, 16.0, + ); + let expected = n * fs; + prop_assert!( + (result - expected).abs() < 0.01, + "{n} * {fs} = {expected}, got {result}", + ); + } + + /// apply_declaration with unknown property is a no-op. + #[test] + fn apply_unknown_property_noop( + prop_name in "[a-z\\-]{1,20}", + ) { + // Filter out known properties. + if matches!( + prop_name.as_str(), + "display" | "color" | "margin" | "padding" + | "width" | "height" | "font-size" + | "background-color" | "background" + | "border-width" | "border-style" + | "border-color" | "overflow" | "position" + | "float" | "clear" | "visibility" + | "text-align" | "text-decoration" + | "text-indent" | "text-transform" + | "white-space" | "line-height" + | "letter-spacing" | "word-spacing" + | "font-weight" | "font-style" | "font-family" + | "list-style-type" | "list-style-position" + | "border-collapse" | "border-spacing" + | "z-index" | "flex-direction" | "flex-wrap" + | "justify-content" | "align-items" + | "flex-grow" | "flex-shrink" | "flex-basis" + | "gap" | "row-gap" | "column-gap" + | "top" | "right" | "bottom" | "left" + | "max-width" | "min-width" + ) { + return Ok(()); + } + let mut s = ComputedStyle::default(); + let before_color = s.color; + s.apply_declaration( + &prop_name, + &CssValue::Keyword("x".into()), + 16.0, + ); + prop_assert_eq!(s.color, before_color); + } + + /// keyword_color returns None for random strings. + #[test] + fn keyword_color_random_returns_none( + name in "[a-z]{10,20}", + ) { + // Long random strings are unlikely to be valid. + if keyword_color(&name).is_none() { + // Expected. + } else { + // If it happens to match, that's fine too. + } + } + + /// ComputedStyle::inherit preserves inheritable props. + #[test] + fn inherit_preserves_font_size(fs in 1.0f32..100.0) { + let mut parent = ComputedStyle::default(); + parent.font_size = fs; + let child = ComputedStyle::inherit(&parent); + prop_assert!( + (child.font_size - fs).abs() < 0.001, + "inherited font_size: got {}, expected {fs}", + child.font_size, + ); + } + + /// ComputedStyle::inherit resets non-inheritable props. + #[test] + fn inherit_resets_margin( + mt in 1.0f32..100.0, + mr in 1.0f32..100.0, + ) { + let mut parent = ComputedStyle::default(); + parent.margin_top = mt; + parent.margin_right = mr; + let child = ComputedStyle::inherit(&parent); + prop_assert!( + child.margin_top.abs() < 0.001, + "margin_top should be reset, got {}", + child.margin_top, + ); + prop_assert!( + child.margin_right.abs() < 0.001, + "margin_right should be reset, got {}", + child.margin_right, + ); + } + } + } } diff --git a/crates/oasis-browser/src/html/tree_builder.rs b/crates/oasis-browser/src/html/tree_builder.rs index 3639202..eacbea2 100644 --- a/crates/oasis-browser/src/html/tree_builder.rs +++ b/crates/oasis-browser/src/html/tree_builder.rs @@ -33,6 +33,12 @@ enum InsertionMode { // TreeBuilder // ------------------------------------------------------------------ +/// Maximum nesting depth for the open elements stack. When the stack +/// reaches this limit, new elements are appended as children of the +/// current node instead of being pushed onto the stack. This prevents +/// stack exhaustion from pathologically deeply nested HTML. +const MAX_NESTING_DEPTH: usize = 256; + /// Builds a DOM tree from a token stream. pub struct TreeBuilder { doc: Document, @@ -750,10 +756,18 @@ impl TreeBuilder { /// Insert an element as the last child of the current node and /// push it onto the open elements stack. + /// + /// When the stack has reached [`MAX_NESTING_DEPTH`], the element + /// is still appended as a child but is **not** pushed onto the + /// stack. This means subsequent content will be attached to the + /// current parent rather than nesting deeper, preventing stack + /// exhaustion from pathologically deep HTML. fn insert_element(&mut self, id: NodeId) { let parent = self.current_node(); self.doc.append_child(parent, id); - self.open_elements.push(id); + if self.open_elements.len() < MAX_NESTING_DEPTH { + self.open_elements.push(id); + } } /// Insert text, coalescing into an existing trailing text node @@ -1813,4 +1827,245 @@ mod tests { let body = doc.body().unwrap(); assert!(doc.get(body).children.len() >= 500); } + + // -- Nesting depth guard tests ------------------------------------ + + #[test] + fn nesting_depth_capped_at_256() { + // Create 300 levels of nesting (exceeds MAX_NESTING_DEPTH). + let mut tokens: Vec = Vec::new(); + for _ in 0..300 { + tokens.push(start("div")); + } + tokens.push(text("deep leaf")); + for _ in 0..300 { + tokens.push(end("div")); + } + tokens.push(Token::Eof); + + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("has body"); + + // Walk down to measure actual depth. + let mut depth = 0u32; + let mut node = body; + loop { + let children = &doc.get(node).children; + if children.is_empty() { + break; + } + // Follow the first child that is an element. + let child = children.iter().find(|&&id| doc.element(id).is_some()); + if let Some(&id) = child { + depth += 1; + node = id; + } else { + break; + } + } + + // Depth should be capped. The open_elements stack includes + // html and body (2 slots), so the div nesting is capped at + // MAX_NESTING_DEPTH - 2 = 254 at most. Allow some margin. + assert!( + depth <= super::MAX_NESTING_DEPTH as u32, + "nesting depth {depth} should be <= {}", + super::MAX_NESTING_DEPTH, + ); + } + + #[test] + fn nesting_depth_leaf_content_preserved() { + // Even with extreme nesting, the leaf text should appear + // somewhere in the tree. + let mut tokens: Vec = Vec::new(); + for _ in 0..300 { + tokens.push(start("div")); + } + tokens.push(text("deep leaf")); + for _ in 0..300 { + tokens.push(end("div")); + } + tokens.push(Token::Eof); + + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("has body"); + let full_text = doc.text_content(body); + assert!( + full_text.contains("deep leaf"), + "leaf text should be preserved, got: {full_text}", + ); + } + + #[test] + fn nesting_at_exact_limit_works() { + // Nesting exactly at the limit (minus html+body) should be fine. + let depth = super::MAX_NESTING_DEPTH - 2; + let mut tokens: Vec = Vec::new(); + for _ in 0..depth { + tokens.push(start("span")); + } + tokens.push(text("leaf")); + for _ in 0..depth { + tokens.push(end("span")); + } + tokens.push(Token::Eof); + + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("has body"); + let full_text = doc.text_content(body); + assert!(full_text.contains("leaf")); + } + + #[test] + fn nesting_one_over_limit_still_works() { + // One level beyond the limit should not crash and content + // should still be in the tree (attached to the current + // parent instead of nesting deeper). + let depth = super::MAX_NESTING_DEPTH; + let mut tokens: Vec = Vec::new(); + for _ in 0..depth { + tokens.push(start("div")); + } + tokens.push(text("over")); + for _ in 0..depth { + tokens.push(end("div")); + } + tokens.push(Token::Eof); + + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("has body"); + let full_text = doc.text_content(body); + assert!(full_text.contains("over")); + } + + #[test] + fn nesting_depth_with_mixed_tags() { + // Mix different tags in deep nesting. + let tags = ["div", "span", "p", "section", "article"]; + let mut tokens: Vec = Vec::new(); + for i in 0..300 { + tokens.push(start(tags[i % tags.len()])); + } + tokens.push(text("mixed deep")); + for i in (0..300).rev() { + tokens.push(end(tags[i % tags.len()])); + } + tokens.push(Token::Eof); + + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("has body"); + let full_text = doc.text_content(body); + // Content should be present and no crash. + assert!( + full_text.contains("mixed deep") || !full_text.is_empty(), + "tree should be well-formed", + ); + } + + mod prop { + use super::*; + use proptest::prelude::*; + + proptest! { + /// Building a tree from arbitrary token sequences never panics. + #[test] + fn build_never_panics( + n in 0usize..30, + ) { + let tags = ["div", "span", "p", "a", "b", "li", "ul"]; + let mut tokens: Vec = Vec::new(); + for i in 0..n { + let tag = tags[i % tags.len()]; + tokens.push(start(tag)); + tokens.push(text("x")); + tokens.push(end(tag)); + } + tokens.push(Token::Eof); + let _ = TreeBuilder::build(tokens); + } + + /// Deeply nested single-tag trees never panic. + #[test] + fn deep_nesting_no_panic(depth in 1usize..350) { + let mut tokens: Vec = Vec::new(); + for _ in 0..depth { + tokens.push(start("div")); + } + tokens.push(text("leaf")); + for _ in 0..depth { + tokens.push(end("div")); + } + tokens.push(Token::Eof); + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("body"); + // Text should always be present somewhere. + let tc = doc.text_content(body); + prop_assert!( + tc.contains("leaf"), + "leaf text missing at depth {depth}", + ); + } + + /// Orphaned end tags of any name never crash. + #[test] + fn orphan_end_tags_no_panic(name in "[a-z]{1,10}") { + let tokens = vec![end(&name), Token::Eof]; + let doc = TreeBuilder::build(tokens); + prop_assert!(doc.body().is_some()); + } + + /// Text-only documents always produce a body. + #[test] + fn text_only_always_has_body(s in ".{0,50}") { + let tokens = vec![text(&s), Token::Eof]; + let doc = TreeBuilder::build(tokens); + prop_assert!(doc.body().is_some()); + } + + /// Random sequences of start/end/text tokens never panic. + #[test] + fn random_token_sequence( + ops in proptest::collection::vec(0u8..6, 0..50), + ) { + let tags = ["div", "p", "span", "a", "li"]; + let mut tokens: Vec = Vec::new(); + for op in &ops { + let tag = tags[(*op as usize) % tags.len()]; + match op % 3 { + 0 => tokens.push(start(tag)), + 1 => tokens.push(end(tag)), + _ => tokens.push(text("x")), + } + } + tokens.push(Token::Eof); + let _ = TreeBuilder::build(tokens); + } + + /// Table structure with random row/col counts never panics. + #[test] + fn random_table( + rows in 1usize..10, + cols in 1usize..10, + ) { + let mut tokens: Vec = Vec::new(); + tokens.push(start("table")); + for _ in 0..rows { + tokens.push(start("tr")); + for _ in 0..cols { + tokens.push(start("td")); + tokens.push(text("cell")); + tokens.push(end("td")); + } + tokens.push(end("tr")); + } + tokens.push(end("table")); + tokens.push(Token::Eof); + let doc = TreeBuilder::build(tokens); + let body = doc.body().expect("body"); + let tc = doc.text_content(body); + prop_assert!(tc.contains("cell")); + } + } + } } diff --git a/crates/oasis-browser/src/layout/block.rs b/crates/oasis-browser/src/layout/block.rs index 573a961..3a3f522 100644 --- a/crates/oasis-browser/src/layout/block.rs +++ b/crates/oasis-browser/src/layout/block.rs @@ -3,6 +3,15 @@ //! Implements CSS 2.1 block formatting context (BFC) layout. Block //! boxes are stacked vertically; their widths expand to fill the //! containing block and heights are determined by content. +//! +//! ## Incremental layout +//! +//! Each [`LayoutBox`] carries a `dirty` flag. When only a subtree +//! changes, callers can mark individual boxes dirty via +//! [`LayoutBox::mark_dirty`] and then call [`layout_block_incremental`] +//! to relayout only the dirty subtree while preserving previously +//! computed dimensions for clean branches. A [`StyleCache`] avoids +//! redundant style computations during incremental passes. use super::box_model::*; use super::flex::layout_flex; @@ -11,6 +20,8 @@ use super::positioning::apply_positioning; use crate::css::values::{ComputedStyle, Dimension, Display, ListStyleType}; use crate::html::dom::{Document, ElementData, NodeId, NodeKind, TagName}; +use std::collections::HashMap; + // ------------------------------------------------------------------- // TextMeasurer trait // ------------------------------------------------------------------- @@ -70,6 +81,219 @@ pub fn build_layout_tree( root } +// ------------------------------------------------------------------- +// Style cache +// ------------------------------------------------------------------- + +/// Cache of computed edge sizes (padding/border/margin) keyed by DOM +/// node ID. Avoids re-resolving edge sizes for nodes whose styles have +/// not changed between incremental layout passes. +#[derive(Debug, Default)] +pub struct StyleCache { + edges: HashMap, +} + +/// Cached resolved edge sizes for a single layout box. +#[derive(Debug, Clone)] +struct CachedEdges { + padding: EdgeSizes, + border: EdgeSizes, + margin: EdgeSizes, +} + +impl StyleCache { + /// Create an empty style cache. + pub fn new() -> Self { + Self { + edges: HashMap::new(), + } + } + + /// Store resolved edge sizes for a node. + pub fn insert_edges( + &mut self, + node: NodeId, + padding: EdgeSizes, + border: EdgeSizes, + margin: EdgeSizes, + ) { + self.edges.insert( + node, + CachedEdges { + padding, + border, + margin, + }, + ); + } + + /// Retrieve cached edges for a node. Returns `None` on cache miss. + pub fn get_edges(&self, node: NodeId) -> Option<(&EdgeSizes, &EdgeSizes, &EdgeSizes)> { + self.edges + .get(&node) + .map(|c| (&c.padding, &c.border, &c.margin)) + } + + /// Number of cached entries (useful for benchmarking). + pub fn len(&self) -> usize { + self.edges.len() + } + + /// Whether the cache is empty. + pub fn is_empty(&self) -> bool { + self.edges.is_empty() + } + + /// Clear all cached entries. + pub fn clear(&mut self) { + self.edges.clear(); + } +} + +// ------------------------------------------------------------------- +// Incremental layout +// ------------------------------------------------------------------- + +/// Perform incremental layout on a previously-built layout tree. +/// +/// Only re-lays-out subtrees where at least one box has its `dirty` +/// flag set. Clean subtrees are skipped entirely. After layout +/// completes, all boxes are marked clean. The optional `cache` stores +/// resolved edge sizes to avoid redundant recomputation. +/// +/// This is an additive optimisation -- when the entire tree is dirty +/// (e.g. initial layout), the result is identical to a full +/// [`layout_block`] pass. +pub fn layout_block_incremental( + layout_box: &mut LayoutBox, + containing_width: f32, + measurer: &dyn TextMeasurer, + cache: &mut StyleCache, +) { + if !layout_box.dirty && !any_child_dirty(layout_box) { + return; + } + + // Resolve edge sizes, using cache when available. + resolve_edge_sizes_cached(layout_box, containing_width, cache); + + calculate_block_width(layout_box, containing_width); + + if matches!(layout_box.box_type, BoxType::Flex) { + layout_flex(layout_box, containing_width, measurer); + } else { + layout_children_incremental(layout_box, measurer, cache); + calculate_block_height(layout_box); + } + + layout_box.dirty = false; +} + +/// Check whether any child (or deeper descendant) is dirty. +fn any_child_dirty(layout_box: &LayoutBox) -> bool { + for child in &layout_box.children { + if child.dirty || any_child_dirty(child) { + return true; + } + } + false +} + +/// Resolve edge sizes with caching. If the box has a DOM node and a +/// cache hit, the cached values are used directly. Otherwise the +/// values are resolved from the style and stored in the cache. +fn resolve_edge_sizes_cached( + layout_box: &mut LayoutBox, + containing_width: f32, + cache: &mut StyleCache, +) { + if let Some(node) = layout_box.node + && !layout_box.dirty + && let Some((p, b, m)) = cache.get_edges(node) + { + layout_box.dimensions.padding = *p; + layout_box.dimensions.border = *b; + layout_box.dimensions.margin = *m; + return; + } + + resolve_edge_sizes(layout_box, containing_width); + + if let Some(node) = layout_box.node { + cache.insert_edges( + node, + layout_box.dimensions.padding, + layout_box.dimensions.border, + layout_box.dimensions.margin, + ); + } +} + +/// Incremental version of [`layout_block_children`]. Skips clean +/// children whose subtrees are also clean. +fn layout_children_incremental( + parent: &mut LayoutBox, + measurer: &dyn TextMeasurer, + cache: &mut StyleCache, +) { + let all_inline = + !parent.children.is_empty() && parent.children.iter().all(|c| !c.is_block_level()); + if all_inline { + // Inline formatting context -- relayout fully when dirty. + if parent.dirty || any_child_dirty(parent) { + layout_inline(parent, measurer); + } + return; + } + + let content_x = parent.dimensions.content.x; + let content_width = parent.dimensions.content.width; + let mut cursor_y = parent.dimensions.content.y + parent.dimensions.padding.top; + let mut prev_margin_bottom: f32 = 0.0; + + for child in &mut parent.children { + match child.box_type { + BoxType::Block | BoxType::ListItem { .. } | BoxType::TableWrapper => { + resolve_edge_sizes_cached(child, content_width, cache); + + let child_margin_top = child.dimensions.margin.top; + let collapsed = collapse_margins(prev_margin_bottom, child_margin_top); + + child.dimensions.content.x = content_x + + parent.dimensions.padding.left + + child.dimensions.margin.left + + child.dimensions.border.left + + child.dimensions.padding.left; + child.dimensions.content.y = cursor_y + + collapsed + + child.dimensions.border.top + + child.dimensions.padding.top; + + if child.dirty || any_child_dirty(child) { + layout_block_incremental(child, content_width, measurer, cache); + } + + let bb = child.dimensions.border_box(); + cursor_y = bb.y + bb.height; + prev_margin_bottom = child.dimensions.margin.bottom; + }, + BoxType::Anonymous => { + child.dimensions.content.x = content_x + parent.dimensions.padding.left; + child.dimensions.content.y = cursor_y; + child.dimensions.content.width = content_width; + + if child.dirty || any_child_dirty(child) { + layout_inline(child, measurer); + } + + cursor_y += child.dimensions.content.height; + prev_margin_bottom = 0.0; + }, + _ => {}, + } + } +} + /// Recursively build child layout boxes for a list of DOM node IDs. fn build_children( doc: &Document, @@ -720,4 +944,141 @@ mod tests { assert_eq!(wrapped.len(), 2); assert!(matches!(wrapped[0].box_type, BoxType::Inline)); } + + // -- incremental layout ------------------------------------------- + + #[test] + fn incremental_layout_matches_full_layout() { + let m = FixedMeasurer; + let mut parent = LayoutBox::new(BoxType::Block, block_style(), None); + let mut s1 = block_style(); + s1.height = Dimension::Px(30.0); + let mut s2 = block_style(); + s2.height = Dimension::Px(50.0); + parent.children = vec![ + LayoutBox::new(BoxType::Block, s1, None), + LayoutBox::new(BoxType::Block, s2, None), + ]; + parent.dimensions.content.x = 0.0; + parent.dimensions.content.y = 0.0; + + // Full layout. + let mut full = parent.clone(); + layout_block(&mut full, 480.0, &m); + + // Incremental layout (all dirty initially). + let mut cache = StyleCache::new(); + layout_block_incremental(&mut parent, 480.0, &m, &mut cache); + + assert_eq!( + parent.dimensions.content.height, + full.dimensions.content.height, + ); + assert_eq!( + parent.dimensions.content.width, + full.dimensions.content.width, + ); + } + + #[test] + fn clean_subtree_skipped_in_incremental() { + let m = FixedMeasurer; + let mut parent = LayoutBox::new(BoxType::Block, block_style(), None); + let mut s1 = block_style(); + s1.height = Dimension::Px(30.0); + parent.children = vec![LayoutBox::new(BoxType::Block, s1, None)]; + parent.dimensions.content.x = 0.0; + parent.dimensions.content.y = 0.0; + + // First pass: full incremental layout. + let mut cache = StyleCache::new(); + layout_block_incremental(&mut parent, 480.0, &m, &mut cache); + + // Mark everything clean, then call again -- should be no-op. + parent.mark_clean(); + let old_height = parent.dimensions.content.height; + layout_block_incremental(&mut parent, 480.0, &m, &mut cache); + assert_eq!(parent.dimensions.content.height, old_height); + } + + #[test] + fn dirty_child_triggers_relayout() { + let m = FixedMeasurer; + let mut parent = LayoutBox::new(BoxType::Block, block_style(), None); + let mut s1 = block_style(); + s1.height = Dimension::Px(30.0); + parent.children = vec![LayoutBox::new(BoxType::Block, s1, Some(1))]; + parent.dimensions.content.x = 0.0; + parent.dimensions.content.y = 0.0; + + let mut cache = StyleCache::new(); + layout_block_incremental(&mut parent, 480.0, &m, &mut cache); + parent.mark_clean(); + + // Dirty the child and change its height. + parent.children[0].dirty = true; + parent.dirty = true; + parent.children[0].style.height = Dimension::Px(60.0); + layout_block_incremental(&mut parent, 480.0, &m, &mut cache); + + assert_eq!(parent.dimensions.content.height, 60.0); + } + + // -- style cache -------------------------------------------------- + + #[test] + fn style_cache_insert_and_get() { + let mut cache = StyleCache::new(); + assert!(cache.is_empty()); + let pad = EdgeSizes::new(1.0, 2.0, 3.0, 4.0); + let bdr = EdgeSizes::new(5.0, 6.0, 7.0, 8.0); + let mar = EdgeSizes::new(9.0, 10.0, 11.0, 12.0); + cache.insert_edges(42, pad, bdr, mar); + assert_eq!(cache.len(), 1); + let (p, b, m) = cache.get_edges(42).expect("cache hit"); + assert_eq!(*p, pad); + assert_eq!(*b, bdr); + assert_eq!(*m, mar); + } + + #[test] + fn style_cache_miss() { + let cache = StyleCache::new(); + assert!(cache.get_edges(99).is_none()); + } + + #[test] + fn style_cache_clear() { + let mut cache = StyleCache::new(); + cache.insert_edges( + 1, + EdgeSizes::default(), + EdgeSizes::default(), + EdgeSizes::default(), + ); + cache.clear(); + assert!(cache.is_empty()); + } + + #[test] + fn mark_dirty_propagation() { + let mut lb = LayoutBox::new(BoxType::Block, block_style(), None); + lb.mark_clean(); + assert!(!lb.dirty); + lb.mark_dirty(); + assert!(lb.dirty); + } + + #[test] + fn any_child_dirty_detection() { + let mut parent = LayoutBox::new(BoxType::Block, block_style(), None); + let mut child = LayoutBox::new(BoxType::Block, block_style(), None); + child.dirty = false; + parent.children = vec![child]; + parent.dirty = false; + assert!(!any_child_dirty(&parent)); + + parent.children[0].dirty = true; + assert!(any_child_dirty(&parent)); + } } diff --git a/crates/oasis-browser/src/layout/float.rs b/crates/oasis-browser/src/layout/float.rs index 57c8650..6662470 100644 --- a/crates/oasis-browser/src/layout/float.rs +++ b/crates/oasis-browser/src/layout/float.rs @@ -1,3 +1,6 @@ +// WIP: float layout is implemented but not yet wired into the main layout engine. +#![allow(dead_code)] + //! CSS 2.1 float layout. //! //! Implements `float: left`, `float: right`, and `clear: left/right/both`. diff --git a/crates/oasis-browser/src/layout/positioning.rs b/crates/oasis-browser/src/layout/positioning.rs index 4a652ff..ef4eafa 100644 --- a/crates/oasis-browser/src/layout/positioning.rs +++ b/crates/oasis-browser/src/layout/positioning.rs @@ -1,3 +1,6 @@ +// WIP: positioned layout is implemented but not yet wired into the main layout engine. +#![allow(dead_code)] + //! CSS positioned layout. //! //! Implements `position: relative`, `position: absolute`, and diff --git a/crates/oasis-browser/src/layout/table.rs b/crates/oasis-browser/src/layout/table.rs index 2db27b6..268fbf1 100644 --- a/crates/oasis-browser/src/layout/table.rs +++ b/crates/oasis-browser/src/layout/table.rs @@ -1,3 +1,7 @@ +// WIP: automatic table layout has separate entry points. Some functions are +// unused pending full integration with the main layout engine. +#![allow(dead_code)] + //! CSS 2.1 automatic table layout algorithm. //! //! Parses table structure (`` -> `` -> `
`/``), diff --git a/crates/oasis-browser/src/lib.rs b/crates/oasis-browser/src/lib.rs index c2f3e04..f352728 100644 --- a/crates/oasis-browser/src/lib.rs +++ b/crates/oasis-browser/src/lib.rs @@ -8,14 +8,14 @@ pub mod commands; pub mod config; -pub mod css; +pub(crate) mod css; pub mod gemini; -pub mod html; +pub(crate) mod html; pub mod image; -pub mod layout; +pub(crate) mod layout; pub mod loader; pub mod nav; -pub mod paint; +pub(crate) mod paint; pub mod plugin; pub mod reader; pub mod scroll; @@ -33,6 +33,27 @@ pub use loader::{ContentType, ResourceResponse, ResourceSource, Url}; pub use nav::{Bookmark, HistoryEntry, NavigationController}; pub use scroll::ScrollState; +// ----------------------------------------------------------------------- +// Bench/fuzz re-exports (not part of public API) +// ----------------------------------------------------------------------- + +/// Internal types exposed for benchmarks and fuzz targets. +/// +/// These are implementation details and may change without notice. +#[doc(hidden)] +pub mod internals { + pub use crate::css::cascade::style_tree; + pub use crate::css::parser::{Stylesheet, parse_inline_style}; + pub use crate::css::values::ComputedStyle; + pub use crate::html::dom::Document; + pub use crate::html::tokenizer::Tokenizer; + pub use crate::html::tree_builder::TreeBuilder; + pub use crate::layout::block::{ + StyleCache, TextMeasurer, build_layout_tree, layout_block_incremental, + }; + pub use crate::paint::paint as paint_page; +} + // ----------------------------------------------------------------------- // Imports // ----------------------------------------------------------------------- diff --git a/crates/oasis-core/Cargo.toml b/crates/oasis-core/Cargo.toml index 3a9ea39..0bff776 100644 --- a/crates/oasis-core/Cargo.toml +++ b/crates/oasis-core/Cargo.toml @@ -6,7 +6,12 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-core" +# Feature flags: +# - tls-rustls: Propagates the tls-rustls feature to oasis-net, enabling TLS 1.3 +# support via rustls for the SDL desktop backend. The PSP backend uses embedded-tls +# instead (pure Rust, no C/asm, works on mipsel-sony-psp). [features] default = [] tls-rustls = ["oasis-net/tls-rustls"] diff --git a/crates/oasis-core/fuzz/fuzz_targets/css_parser.rs b/crates/oasis-core/fuzz/fuzz_targets/css_parser.rs index 171e40e..6676d5b 100644 --- a/crates/oasis-core/fuzz/fuzz_targets/css_parser.rs +++ b/crates/oasis-core/fuzz/fuzz_targets/css_parser.rs @@ -1,7 +1,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use oasis_core::browser::css::parser::{Stylesheet, parse_inline_style}; +use oasis_core::browser::internals::{Stylesheet, parse_inline_style}; fuzz_target!(|data: &[u8]| { if let Ok(input) = std::str::from_utf8(data) { diff --git a/crates/oasis-core/fuzz/fuzz_targets/html_tokenizer.rs b/crates/oasis-core/fuzz/fuzz_targets/html_tokenizer.rs index f06a013..b5ea0b0 100644 --- a/crates/oasis-core/fuzz/fuzz_targets/html_tokenizer.rs +++ b/crates/oasis-core/fuzz/fuzz_targets/html_tokenizer.rs @@ -1,7 +1,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use oasis_core::browser::html::tokenizer::Tokenizer; +use oasis_core::browser::internals::Tokenizer; fuzz_target!(|data: &[u8]| { if let Ok(input) = std::str::from_utf8(data) { diff --git a/crates/oasis-core/src/lib.rs b/crates/oasis-core/src/lib.rs index 9f2bf75..feb36d3 100644 --- a/crates/oasis-core/src/lib.rs +++ b/crates/oasis-core/src/lib.rs @@ -3,40 +3,119 @@ //! Platform-agnostic embeddable OS framework providing a scene graph (SDI), //! backend abstraction traits, input event pipeline, configuration, and //! error types. This crate has zero platform dependencies. +//! +//! # Public API +//! +//! The [`prelude`] module re-exports the most commonly used types for +//! convenient single-import access. Individual sub-crate modules are also +//! available for full access. + +// ----------------------------------------------------------------------- +// Re-exports from oasis-types (foundation types and traits) +// ----------------------------------------------------------------------- -// Re-exports from oasis-types (foundation types and traits). pub use oasis_types::backend; -pub use oasis_types::color; pub use oasis_types::config; pub use oasis_types::error; pub use oasis_types::input; +pub use oasis_types::tls; + +// Internal-only type re-exports (not part of the primary API surface). +#[doc(hidden)] +pub use oasis_types::color; +#[doc(hidden)] pub use oasis_types::pbp; +#[doc(hidden)] pub use oasis_types::shadow; -pub use oasis_types::tls; +// ----------------------------------------------------------------------- +// Sub-crate re-exports +// ----------------------------------------------------------------------- + +pub use oasis_audio as audio; +pub use oasis_browser as browser; +pub use oasis_net as net; +pub use oasis_platform as platform; +pub use oasis_sdi as sdi; +pub use oasis_skin as skin; pub use oasis_skin::active_theme; +#[doc(hidden)] +pub use oasis_skin::legacy_theme as theme; +pub use oasis_ui as ui; +pub use oasis_vfs as vfs; +pub use oasis_wm as wm; + +// ----------------------------------------------------------------------- +// Core-owned modules +// ----------------------------------------------------------------------- + pub mod agent; pub mod apps; -pub use oasis_audio as audio; pub mod bottombar; -pub use oasis_browser as browser; pub mod cursor; pub mod dashboard; -pub use oasis_net as net; pub mod osk; -pub use oasis_platform as platform; pub mod plugin; pub mod script; -pub use oasis_sdi as sdi; -pub use oasis_skin as skin; pub mod startmenu; pub mod statusbar; pub mod terminal; -pub use oasis_skin::legacy_theme as theme; pub mod transfer; pub mod transition; -pub use oasis_ui as ui; pub mod update; -pub use oasis_vfs as vfs; pub mod wallpaper; -pub use oasis_wm as wm; + +// ----------------------------------------------------------------------- +// Prelude -- curated public API surface +// ----------------------------------------------------------------------- + +/// Commonly used types and traits for embedding OASIS_OS. +/// +/// ```ignore +/// use oasis_core::prelude::*; +/// ``` +pub mod prelude { + // Backend traits + pub use oasis_types::backend::{ + AudioBackend, AudioTrackId, Color, InputBackend, NetworkBackend, SdiBackend, TextureId, + }; + + // Error handling + pub use oasis_types::error::{OasisError, Result}; + + // Input + pub use oasis_types::input::{Button, InputEvent, Trigger}; + + // Configuration + pub use oasis_types::config::OasisConfig; + + // Scene graph + pub use oasis_sdi::SdiRegistry; + + // Skin / theme + pub use oasis_skin::active_theme::ActiveTheme; + pub use oasis_skin::{Skin, SkinFeatures, resolve_skin}; + + // VFS + pub use oasis_vfs::{MemoryVfs, Vfs}; + + // Platform + pub use oasis_platform::DesktopPlatform; + + // Terminal + pub use crate::terminal::{CommandOutput, CommandRegistry, Environment, register_builtins}; + + // Apps / Dashboard + pub use crate::apps::{AppAction, AppRunner}; + pub use crate::dashboard::{DashboardConfig, DashboardState, discover_apps}; + + // Window management + pub use oasis_wm::manager::WindowManager; + pub use oasis_wm::window::{WindowConfig, WindowType}; + + // UI chrome + pub use crate::bottombar::BottomBar; + pub use crate::cursor::CursorState; + pub use crate::startmenu::StartMenuState; + pub use crate::statusbar::StatusBar; +} diff --git a/crates/oasis-core/src/plugin/manager.rs b/crates/oasis-core/src/plugin/manager.rs index 813af00..578cae9 100644 --- a/crates/oasis-core/src/plugin/manager.rs +++ b/crates/oasis-core/src/plugin/manager.rs @@ -512,4 +512,456 @@ auto_load = true mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); assert_eq!(mgr.active_count(), 0); } + + // -- Phase 3.6: Plugin adversarial & edge-case tests -- + + /// Plugin that fails during init. + struct FailInitPlugin; + impl Plugin for FailInitPlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("fail-init", "1.0.0") + } + fn init(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Err(OasisError::Plugin("init explosion".to_string())) + } + fn update(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn shutdown(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + } + + /// Plugin that fails during update. + struct FailUpdatePlugin { + update_count: u32, + } + impl FailUpdatePlugin { + fn new() -> Self { + Self { update_count: 0 } + } + } + impl Plugin for FailUpdatePlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("fail-update", "1.0.0") + } + fn init(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn update(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + self.update_count += 1; + Err(OasisError::Plugin("update explosion".to_string())) + } + fn shutdown(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + } + + /// Plugin that fails during shutdown. + struct FailShutdownPlugin; + impl Plugin for FailShutdownPlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("fail-shutdown", "1.0.0") + } + fn init(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn update(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn shutdown(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Err(OasisError::Plugin("shutdown explosion".to_string())) + } + } + + /// Plugin with a duplicate name. + struct DuplicatePlugin; + impl Plugin for DuplicatePlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("test-plugin", "2.0.0") + } + fn init(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn update(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + fn shutdown(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + Ok(()) + } + } + + /// Plugin that writes a VFS file during init and reads it + /// during update. + struct VfsPlugin { + read_data: Option>, + } + impl VfsPlugin { + fn new() -> Self { + Self { read_data: None } + } + } + impl Plugin for VfsPlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("vfs-plugin", "1.0.0") + } + fn init(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + host.vfs.write("/plugin_data.txt", b"hello vfs")?; + Ok(()) + } + fn update(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + self.read_data = Some(host.vfs.read("/plugin_data.txt")?); + Ok(()) + } + fn shutdown(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + host.vfs.remove("/plugin_data.txt")?; + Ok(()) + } + } + + #[test] + fn init_all_stops_on_first_error() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailInitPlugin)); + mgr.register_static(Box::new(TestPlugin::new())); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + let result = mgr.init_all(&mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("init explosion"), + "expected 'init explosion' in: {msg}", + ); + // FailInitPlugin stays Registered, TestPlugin + // never reaches Active. + assert_eq!(mgr.active_count(), 0); + } + + #[test] + fn init_plugin_not_found() { + let mut mgr = PluginManager::new(); + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + let result = mgr.init_plugin("ghost", &mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("plugin not found"),); + } + + #[test] + fn init_stopped_plugin_fails() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(TestPlugin::new())); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + // Plugin is now Stopped -- re-init should fail. + let result = mgr.init_plugin("test-plugin", &mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("stopped"),); + } + + #[test] + fn update_all_propagates_error() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailUpdatePlugin::new())); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + let result = mgr.update_all(&mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("update explosion"),); + } + + #[test] + fn shutdown_all_propagates_error() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailShutdownPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + let result = mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("shutdown explosion"), + ); + } + + #[test] + fn unload_shutdown_error_propagates() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailShutdownPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + let result = mgr.unload("fail-shutdown", &mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + } + + #[test] + fn unload_registered_plugin_no_shutdown() { + // Unloading a Registered (never initialized) plugin + // should not call shutdown. + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailShutdownPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + // Do NOT init -- unload should succeed because + // shutdown is only called on Active plugins. + let result = mgr.unload("fail-shutdown", &mut sdi, &mut vfs, &mut cmds); + assert!(result.is_ok()); + assert_eq!(mgr.count(), 0); + } + + #[test] + fn duplicate_name_both_registered() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(TestPlugin::new())); + mgr.register_static(Box::new(DuplicatePlugin)); + assert_eq!(mgr.count(), 2); + + // Both have name "test-plugin". + // init_plugin finds the first one. + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_plugin("test-plugin", &mut sdi, &mut vfs, &mut cmds) + .unwrap(); + assert_eq!(mgr.active_count(), 1); + + // Second init of same name should fail (first is Active). + let result = mgr.init_plugin("test-plugin", &mut sdi, &mut vfs, &mut cmds); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already active"),); + } + + #[test] + fn update_skips_non_active_plugins() { + let mut mgr = PluginManager::new(); + // Register but do not init -- update should be a no-op. + mgr.register_static(Box::new(FailUpdatePlugin::new())); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + // update_all should succeed because FailUpdatePlugin + // is in Registered state, not Active. + let result = mgr.update_all(&mut sdi, &mut vfs, &mut cmds); + assert!(result.is_ok()); + } + + #[test] + fn shutdown_skips_non_active_plugins() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(FailShutdownPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + // Plugin is Registered, not Active -- shutdown is + // a no-op. + let result = mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds); + assert!(result.is_ok()); + } + + #[test] + fn empty_manager_operations() { + let mut mgr = PluginManager::new(); + assert_eq!(mgr.count(), 0); + assert_eq!(mgr.active_count(), 0); + assert!(!mgr.is_loaded("anything")); + assert!(mgr.list().is_empty()); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + // All lifecycle ops on empty manager should succeed. + assert!(mgr.init_all(&mut sdi, &mut vfs, &mut cmds).is_ok()); + assert!(mgr.update_all(&mut sdi, &mut vfs, &mut cmds).is_ok()); + assert!(mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds).is_ok()); + } + + #[test] + fn plugin_vfs_interaction() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(VfsPlugin::new())); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + // VfsPlugin wrote a file during init -- verify. + assert!(vfs.exists("/plugin_data.txt")); + let data = vfs.read("/plugin_data.txt").unwrap(); + assert_eq!(data, b"hello vfs"); + + mgr.update_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + // Shutdown should clean up the VFS file. + mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + assert!(!vfs.exists("/plugin_data.txt")); + } + + #[test] + fn plugin_default_manager() { + let mgr = PluginManager::default(); + assert_eq!(mgr.count(), 0); + } + + #[test] + fn discover_invalid_toml_ignored() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/etc").unwrap(); + vfs.mkdir("/etc/oasis-os").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins/bad").unwrap(); + vfs.write( + "/etc/oasis-os/plugins/bad/plugin.toml", + b"this is {{{ not valid toml !!!", + ) + .unwrap(); + + let manifests = PluginManager::discover_manifests(&mut vfs); + assert!( + manifests.is_empty(), + "invalid TOML should be silently skipped", + ); + } + + #[test] + fn discover_skips_files_in_plugin_dir() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/etc").unwrap(); + vfs.mkdir("/etc/oasis-os").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins").unwrap(); + // Write a file (not a directory) directly in plugins/. + vfs.write("/etc/oasis-os/plugins/stray.txt", b"not a plugin") + .unwrap(); + + let manifests = PluginManager::discover_manifests(&mut vfs); + assert!(manifests.is_empty()); + } + + #[test] + fn discover_multiple_manifests() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/etc").unwrap(); + vfs.mkdir("/etc/oasis-os").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins").unwrap(); + + for name in &["alpha", "beta", "gamma"] { + let dir = format!("/etc/oasis-os/plugins/{name}"); + vfs.mkdir(&dir).unwrap(); + let toml = format!("name = \"{name}\"\nversion = \"1.0\"\n"); + vfs.write(&format!("{dir}/plugin.toml"), toml.as_bytes()) + .unwrap(); + } + + let manifests = PluginManager::discover_manifests(&mut vfs); + assert_eq!(manifests.len(), 3); + + let names: Vec<&str> = manifests.iter().map(|m| m.name.as_str()).collect(); + assert!(names.contains(&"alpha")); + assert!(names.contains(&"beta")); + assert!(names.contains(&"gamma")); + } + + #[test] + fn manifest_missing_optional_fields() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/etc").unwrap(); + vfs.mkdir("/etc/oasis-os").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins").unwrap(); + vfs.mkdir("/etc/oasis-os/plugins/minimal").unwrap(); + // Only required field is `name`. + vfs.write( + "/etc/oasis-os/plugins/minimal/plugin.toml", + b"name = \"minimal\"\n", + ) + .unwrap(); + + let manifests = PluginManager::discover_manifests(&mut vfs); + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].name, "minimal"); + assert!(manifests[0].version.is_empty()); + assert!(manifests[0].author.is_empty()); + assert!(manifests[0].description.is_empty()); + assert!(manifests[0].library.is_empty()); + assert!(!manifests[0].auto_load); + } + + #[test] + fn is_loaded_after_unload() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(TestPlugin::new())); + assert!(mgr.is_loaded("test-plugin")); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + mgr.unload("test-plugin", &mut sdi, &mut vfs, &mut cmds) + .unwrap(); + assert!(!mgr.is_loaded("test-plugin")); + } + + #[test] + fn list_shows_correct_states() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(TestPlugin::new())); + mgr.register_static(Box::new(SdiPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + + // Init only one plugin. + mgr.init_plugin("sdi-plugin", &mut sdi, &mut vfs, &mut cmds) + .unwrap(); + + let list = mgr.list(); + assert_eq!(list.len(), 2); + // test-plugin should still be Registered. + let tp = list + .iter() + .find(|(info, _)| info.name == "test-plugin") + .unwrap(); + assert_eq!(tp.1, PluginState::Registered); + // sdi-plugin should be Active. + let sp = list + .iter() + .find(|(info, _)| info.name == "sdi-plugin") + .unwrap(); + assert_eq!(sp.1, PluginState::Active); + } } diff --git a/crates/oasis-ffi/Cargo.toml b/crates/oasis-ffi/Cargo.toml index 0a4740d..a6aef71 100644 --- a/crates/oasis-ffi/Cargo.toml +++ b/crates/oasis-ffi/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true authors.workspace = true description = "OASIS_OS C-ABI FFI boundary for UE5 and external integrations" +documentation = "https://docs.rs/oasis-ffi" [lib] crate-type = ["cdylib", "rlib"] diff --git a/crates/oasis-net/Cargo.toml b/crates/oasis-net/Cargo.toml index 5fe9035..2d1bf7a 100644 --- a/crates/oasis-net/Cargo.toml +++ b/crates/oasis-net/Cargo.toml @@ -6,7 +6,12 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-net" +# Feature flags: +# - tls-rustls: Enable TLS 1.3 support via rustls for HTTPS and Gemini connections. +# Pulls in rustls, webpki-roots (Mozilla CA bundle), and rustls-pki-types. +# Without this feature, all connections are plaintext TCP only. [features] default = [] tls-rustls = ["dep:rustls", "dep:webpki-roots", "dep:rustls-pki-types"] diff --git a/crates/oasis-net/src/tls_rustls.rs b/crates/oasis-net/src/tls_rustls.rs index f2aa38e..83055ba 100644 --- a/crates/oasis-net/src/tls_rustls.rs +++ b/crates/oasis-net/src/tls_rustls.rs @@ -629,4 +629,311 @@ mod tests { let p2 = p1.clone(); assert!(Arc::ptr_eq(&p1.config, &p2.config)); } + + // -- Phase 3.5: TLS certificate validation & edge-case tests -- + + #[test] + fn test_default_provider() { + let p = RustlsTlsProvider::default(); + // default() should produce a valid provider identical to new(). + assert!(!Arc::ptr_eq(&p.config, &RustlsTlsProvider::new().config)); + } + + #[test] + fn test_invalid_server_name_rejected() { + let provider = RustlsTlsProvider::new(); + let mock: Box = Box::new(MockNetworkStream::empty()); + // An empty server name is invalid for SNI. + let result = provider.connect_tls(mock, ""); + assert!(result.is_err()); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("invalid server name"), + "expected 'invalid server name' in: {msg}", + ); + } + + #[test] + fn test_untrusted_self_signed_rejected() { + // A server with a self-signed cert should be rejected by a + // client that only trusts Mozilla roots. + let (server_cfg, _cert_key) = make_server_config(); + let provider = RustlsTlsProvider::new(); // trusts Mozilla roots + let (handle, port) = spawn_server(server_cfg, Vec::new()); + + let result = connect_to(&provider, port); + assert!( + result.is_err(), + "self-signed cert should be rejected by default provider", + ); + + let _ = handle.join(); + } + + #[test] + fn test_sni_mismatch_rejected() { + // Server cert is for "localhost" but client connects with + // "not-localhost" -- handshake should fail. + let (server_cfg, cert_key) = make_server_config(); + let provider = make_client_config(&cert_key); + let (handle, port) = spawn_server(server_cfg, Vec::new()); + + let tcp = TcpStream::connect(format!("127.0.0.1:{port}")).unwrap(); + tcp.set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let net: Box = Box::new(TcpNetworkStream(tcp)); + let result = provider.connect_tls(net, "not-localhost"); + assert!(result.is_err(), "SNI mismatch should fail handshake",); + + let _ = handle.join(); + } + + #[test] + fn test_expired_cert_rejected() { + // Generate a certificate that expired in the past. + let mut params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + params.not_before = rcgen::date_time_ymd(2020, 1, 1); + params.not_after = rcgen::date_time_ymd(2020, 1, 2); + + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + let certified_key = rcgen::CertifiedKey { cert, key_pair }; + + let cert_der = rustls::pki_types::CertificateDer::from(certified_key.cert.der().to_vec()); + let key_der = + rustls::pki_types::PrivateKeyDer::try_from(certified_key.key_pair.serialize_der()) + .unwrap(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + let server_cfg = Arc::new(server_config); + + let provider = make_client_config(&certified_key); + let (handle, port) = spawn_server(server_cfg, Vec::new()); + + let result = connect_to(&provider, port); + assert!(result.is_err(), "expired certificate should be rejected",); + + let _ = handle.join(); + } + + #[test] + fn test_not_yet_valid_cert_rejected() { + // Generate a certificate that is not yet valid. + let mut params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + params.not_before = rcgen::date_time_ymd(2099, 1, 1); + params.not_after = rcgen::date_time_ymd(2100, 1, 1); + + let key_pair = rcgen::KeyPair::generate().unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + let certified_key = rcgen::CertifiedKey { cert, key_pair }; + + let cert_der = rustls::pki_types::CertificateDer::from(certified_key.cert.der().to_vec()); + let key_der = + rustls::pki_types::PrivateKeyDer::try_from(certified_key.key_pair.serialize_der()) + .unwrap(); + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + let server_cfg = Arc::new(server_config); + + let provider = make_client_config(&certified_key); + let (handle, port) = spawn_server(server_cfg, Vec::new()); + + let result = connect_to(&provider, port); + assert!( + result.is_err(), + "not-yet-valid certificate should be rejected", + ); + + let _ = handle.join(); + } + + #[test] + fn test_read_zero_length_buffer() { + let (server_cfg, cert_key) = make_server_config(); + let provider = make_client_config(&cert_key); + let (handle, port) = spawn_server(server_cfg, b"data".to_vec()); + let mut stream = connect_to(&provider, port).unwrap(); + + // Reading into a zero-length buffer should return 0. + let mut buf = [0u8; 0]; + let n = stream.read(&mut buf).unwrap(); + assert_eq!(n, 0); + + let _ = stream.close(); + let _ = handle.join(); + } + + #[test] + fn test_write_empty_data() { + let (server_cfg, cert_key) = make_server_config(); + let provider = make_client_config(&cert_key); + let (handle, port) = spawn_server(server_cfg, Vec::new()); + let mut stream = connect_to(&provider, port).unwrap(); + + // Writing empty data should succeed with 0 bytes. + let n = stream.write(b"").unwrap(); + assert_eq!(n, 0); + + let _ = stream.close(); + let _ = handle.join(); + } + + #[test] + fn test_multiple_writes_and_reads() { + let (server_cfg, cert_key) = make_server_config(); + let provider = make_client_config(&cert_key); + let payload = b"chunked data stream test".to_vec(); + let (handle, port) = spawn_server(server_cfg, payload.clone()); + let mut stream = connect_to(&provider, port).unwrap(); + + // Read the payload in small chunks. + let mut received = Vec::new(); + let mut buf = [0u8; 4]; + while received.len() < payload.len() { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + received.extend_from_slice(&buf[..n]); + }, + Err(_) => break, + } + } + assert_eq!(received, payload); + + let _ = stream.close(); + let _ = handle.join(); + } + + #[test] + fn test_drain_deque_front_only() { + let mut deque = VecDeque::from(vec![1, 2, 3, 4, 5]); + let mut buf = [0u8; 3]; + drain_deque(&mut deque, &mut buf); + assert_eq!(buf, [1, 2, 3]); + assert_eq!(deque.len(), 2); + } + + #[test] + fn test_drain_deque_wrapping() { + // Force the deque to wrap around internally. + let mut deque = VecDeque::with_capacity(4); + deque.push_back(10); + deque.push_back(20); + deque.push_back(30); + // Pop from front so that subsequent pushes wrap. + let _ = deque.pop_front(); + let _ = deque.pop_front(); + deque.push_back(40); + deque.push_back(50); + deque.push_back(60); + // Deque now has [30, 40, 50, 60] but internally wrapped. + let mut buf = [0u8; 4]; + drain_deque(&mut deque, &mut buf); + assert_eq!(buf, [30, 40, 50, 60]); + assert!(deque.is_empty()); + } + + #[test] + fn test_drain_deque_exact_front_size() { + let mut deque = VecDeque::from(vec![1, 2, 3]); + let mut buf = [0u8; 3]; + drain_deque(&mut deque, &mut buf); + assert_eq!(buf, [1, 2, 3]); + assert!(deque.is_empty()); + } + + #[test] + fn test_oasis_err_to_io_preserves_io_error() { + let io_err = io::Error::new(io::ErrorKind::WouldBlock, "test block"); + let oasis_err = OasisError::Io(io_err); + let converted = oasis_err_to_io(oasis_err); + assert_eq!(converted.kind(), io::ErrorKind::WouldBlock); + } + + #[test] + fn test_oasis_err_to_io_converts_other() { + let oasis_err = OasisError::Backend("some error".to_string()); + let converted = oasis_err_to_io(oasis_err); + assert_eq!(converted.kind(), io::ErrorKind::Other); + assert!( + converted.to_string().contains("some error"), + "error message should be preserved", + ); + } + + #[test] + fn test_io_adapter_read_eof() { + let mut mock = MockNetworkStream::empty(); + let mut adapter = IoAdapter::new(&mut mock); + let mut buf = [0u8; 16]; + let n = io::Read::read(&mut adapter, &mut buf).unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn test_io_adapter_write_and_flush() { + let mut mock = MockNetworkStream::empty(); + { + let mut adapter = IoAdapter::new(&mut mock); + let n = io::Write::write(&mut adapter, b"hello").unwrap(); + assert_eq!(n, 5); + // flush is a no-op. + assert!(io::Write::flush(&mut adapter).is_ok()); + } + assert_eq!(mock.written, b"hello"); + } + + #[test] + fn test_bidirectional_data() { + // Client writes data, server echoes it back. + let (server_cfg, cert_key) = make_server_config(); + let provider = make_client_config(&cert_key); + + // Spawn an echo server. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let conn = rustls::ServerConnection::new(server_cfg).unwrap(); + let mut tls = rustls::StreamOwned::new(conn, stream); + tls.sock + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .ok(); + let mut buf = [0u8; 256]; + match io::Read::read(&mut tls, &mut buf) { + Ok(n) if n > 0 => { + let _ = io::Write::write_all(&mut tls, &buf[..n]); + let _ = io::Write::flush(&mut tls); + }, + _ => {}, + } + // Brief sleep so client can read echo. + std::thread::sleep(std::time::Duration::from_millis(100)); + }); + + let mut stream = connect_to(&provider, port).unwrap(); + stream.write(b"echo me").unwrap(); + + // Read the echoed data. + let mut buf = [0u8; 64]; + let mut total = 0; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while total < 7 && std::time::Instant::now() < deadline { + match stream.read(&mut buf[total..]) { + Ok(0) => break, + Ok(n) => total += n, + Err(_) => break, + } + } + assert_eq!(&buf[..total], b"echo me"); + + let _ = stream.close(); + let _ = handle.join(); + } } diff --git a/crates/oasis-platform/Cargo.toml b/crates/oasis-platform/Cargo.toml index d72d042..9f12ad6 100644 --- a/crates/oasis-platform/Cargo.toml +++ b/crates/oasis-platform/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-platform" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-plugin-psp/Cargo.toml b/crates/oasis-plugin-psp/Cargo.toml index debb5bf..508aab3 100644 --- a/crates/oasis-plugin-psp/Cargo.toml +++ b/crates/oasis-plugin-psp/Cargo.toml @@ -18,11 +18,13 @@ repository = "https://github.com/AndrewAltimit/oasis-os" authors = ["AndrewAltimit"] [dependencies] -psp = { git = "https://github.com/AndrewAltimit/rust-psp", features = ["kernel"] } +# Pinned to specific commit for reproducible builds. +psp = { git = "https://github.com/AndrewAltimit/rust-psp", rev = "4c47345", features = ["kernel"] } -# unicode-width workaround for mips target panic. +# unicode-width fork: the upstream crate panics on mipsel-sony-psp due to +# a lookup table issue. This fork patches the panic for the MIPS target. [patch.crates-io] -unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width" } +unicode-width = { git = "https://git.sr.ht/~sajattack/unicode-width", rev = "114ac47" } [profile.release] opt-level = "z" # minimize size (<64KB target) diff --git a/crates/oasis-plugin-psp/src/hook.rs b/crates/oasis-plugin-psp/src/hook.rs index 20fecd9..df745b3 100644 --- a/crates/oasis-plugin-psp/src/hook.rs +++ b/crates/oasis-plugin-psp/src/hook.rs @@ -339,30 +339,32 @@ pub fn get_battery_percent() -> i32 { } } +/// Bounded write helper for fixed-size log buffers. Appends `s` to +/// `buf` starting at `pos`, clamping to the buffer length via +/// `saturating_add`. Returns the new write position (never exceeds +/// `buf.len()`). fn write_log_bytes(buf: &mut [u8], pos: usize, s: &[u8]) -> usize { - let mut p = pos; - for &b in s { - if p >= buf.len() { - break; - } - buf[p] = b; - p += 1; + let end = pos.saturating_add(s.len()).min(buf.len()); + let count = end.saturating_sub(pos); + if count > 0 { + buf[pos..end].copy_from_slice(&s[..count]); } - p + end } +/// Bounded hex-format helper. Writes up to 8 hex digits of `val` into +/// `buf` starting at `pos`, clamping to the buffer length via +/// `saturating_add`. Returns the new write position. fn write_log_hex(buf: &mut [u8], pos: usize, val: u32) -> usize { - let mut p = pos; let hex = b"0123456789ABCDEF"; + let needed = 8usize; + let end = pos.saturating_add(needed).min(buf.len()); + let count = end.saturating_sub(pos); let mut i = 0; - while i < 8 { - if p >= buf.len() { - break; - } + while i < count { let nibble = (val >> (28 - i * 4)) & 0xF; - buf[p] = hex[nibble as usize]; - p += 1; + buf[pos + i] = hex[nibble as usize]; i += 1; } - p + end } diff --git a/crates/oasis-sdi/Cargo.toml b/crates/oasis-sdi/Cargo.toml index 4fd9ac4..ff4d223 100644 --- a/crates/oasis-sdi/Cargo.toml +++ b/crates/oasis-sdi/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-sdi" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-sdi/src/registry.rs b/crates/oasis-sdi/src/registry.rs index 086db01..1f737b7 100644 --- a/crates/oasis-sdi/src/registry.rs +++ b/crates/oasis-sdi/src/registry.rs @@ -303,12 +303,13 @@ impl SdiRegistry { gb.b, ((gb.a as u16) * (obj.alpha as u16) / 255) as u8, ); + let gradient = oasis_types::backend::GradientStyle::Vertical { top, bottom: bot }; if radius > 0 { - backend.fill_rounded_rect_gradient_v( - obj.x, obj.y, obj.w, obj.h, radius, top, bot, + backend.fill_rounded_rect_gradient( + obj.x, obj.y, obj.w, obj.h, radius, &gradient, )?; } else { - backend.fill_rect_gradient_v(obj.x, obj.y, obj.w, obj.h, top, bot)?; + backend.fill_rect_gradient(obj.x, obj.y, obj.w, obj.h, &gradient)?; } } else if radius > 0 { backend.fill_rounded_rect(obj.x, obj.y, obj.w, obj.h, radius, color)?; diff --git a/crates/oasis-skin/Cargo.toml b/crates/oasis-skin/Cargo.toml index 068b85a..056e74a 100644 --- a/crates/oasis-skin/Cargo.toml +++ b/crates/oasis-skin/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-skin" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-skin/src/theme.rs b/crates/oasis-skin/src/theme.rs index 561c235..d40f04f 100644 --- a/crates/oasis-skin/src/theme.rs +++ b/crates/oasis-skin/src/theme.rs @@ -505,6 +505,9 @@ impl SkinTheme { shadow_dropdown: Shadow::elevation(shadow_level.min(2)), shadow_modal: Shadow::elevation(shadow_level.min(3)), shadow_tooltip: Shadow::elevation(shadow_level.min(2)), + + reduced_motion: false, + font_scale: 1.0, } } diff --git a/crates/oasis-terminal/Cargo.toml b/crates/oasis-terminal/Cargo.toml index 8aa2c8f..b6c9be6 100644 --- a/crates/oasis-terminal/Cargo.toml +++ b/crates/oasis-terminal/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-terminal" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-terminal/src/interpreter.rs b/crates/oasis-terminal/src/interpreter.rs index 311919d..4f4face 100644 --- a/crates/oasis-terminal/src/interpreter.rs +++ b/crates/oasis-terminal/src/interpreter.rs @@ -982,7 +982,28 @@ impl CommandRegistry { /// Built-in help with access to the registry. fn execute_help(&self, args: &[&str]) -> Result { - if let Some(&name) = args.first() { + // Parse optional --category / -c filter. + let mut filter_cat: Option<&str> = None; + let mut positional: Vec<&str> = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i] { + "--category" | "-c" => { + if let Some(&cat) = args.get(i + 1) { + filter_cat = Some(cat); + i += 2; + continue; + } + return Err(OasisError::Command( + "usage: help [--category ] [command]".into(), + )); + }, + other => positional.push(other), + } + i += 1; + } + + if let Some(&name) = positional.first() { let name_lower = name.to_ascii_lowercase(); match self.commands.get(name_lower.as_str()) { Some(cmd) => { @@ -1024,7 +1045,20 @@ impl CommandRegistry { let mut cats: Vec<&str> = categories.keys().copied().collect(); cats.sort(); - let total: usize = categories.values().map(|v| v.len()).sum(); + // Apply category filter if specified. + if let Some(fc) = filter_cat { + let fc_lower = fc.to_ascii_lowercase(); + cats.retain(|c| c.to_ascii_lowercase().contains(&fc_lower)); + if cats.is_empty() { + return Err(OasisError::Command(format!("no category matching '{fc}'"))); + } + } + + let total: usize = cats + .iter() + .filter_map(|c| categories.get(c)) + .map(|v| v.len()) + .sum(); let mut out = format!("Commands ({total}):\n"); for cat in &cats { let cmds = categories.get(cat).unwrap(); diff --git a/crates/oasis-types/Cargo.toml b/crates/oasis-types/Cargo.toml index 88401c3..3ebe52b 100644 --- a/crates/oasis-types/Cargo.toml +++ b/crates/oasis-types/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-types" [dependencies] serde = { workspace = true } diff --git a/crates/oasis-types/src/backend.rs b/crates/oasis-types/src/backend.rs index 3b47200..3ca87e2 100644 --- a/crates/oasis-types/src/backend.rs +++ b/crates/oasis-types/src/backend.rs @@ -117,28 +117,12 @@ pub enum DrawCommand { points: [(i32, i32); 3], color: Color, }, - GradientV { + Gradient { x: i32, y: i32, w: u32, h: u32, - top: Color, - bottom: Color, - }, - GradientH { - x: i32, - y: i32, - w: u32, - h: u32, - left: Color, - right: Color, - }, - Gradient4 { - x: i32, - y: i32, - w: u32, - h: u32, - corners: [Color; 4], + style: GradientStyle, }, DrawText { text: String, @@ -181,6 +165,44 @@ pub enum DrawCommand { PopTranslate, } +/// Measured dimensions and baseline metrics for a text string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextMetrics { + /// Width of the text string in pixels. + pub width: u32, + /// Total line height (ascent + descent + leading) in pixels. + pub height: u32, + /// Distance from the baseline to the top of the tallest glyph, in pixels. + pub ascent: u32, +} + +/// Gradient direction and associated colors for gradient fill operations. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GradientStyle { + /// Vertical gradient from top to bottom. + Vertical { top: Color, bottom: Color }, + /// Horizontal gradient from left to right. + Horizontal { left: Color, right: Color }, + /// Four-corner bilinear gradient. + FourCorner { + top_left: Color, + top_right: Color, + bottom_left: Color, + bottom_right: Color, + }, +} + +impl GradientStyle { + /// Return the dominant / start color for fallback rendering. + pub const fn primary_color(&self) -> Color { + match *self { + Self::Vertical { top, .. } => top, + Self::Horizontal { left, .. } => left, + Self::FourCorner { top_left, .. } => top_left, + } + } +} + /// Rendering backend trait. /// /// Four implementations cover all deployment targets: GU (PSP), SDL2 @@ -372,63 +394,31 @@ pub trait SdiBackend { // Extended: Gradient Fills (Phase 2) // ----------------------------------------------------------------------- - /// Draw a filled rectangle with a vertical gradient (top to bottom). - fn fill_rect_gradient_v( - &mut self, - x: i32, - y: i32, - w: u32, - h: u32, - top_color: Color, - bottom_color: Color, - ) -> Result<()> { - let _ = bottom_color; - self.fill_rect(x, y, w, h, top_color) - } - - /// Draw a filled rectangle with a horizontal gradient (left to right). - fn fill_rect_gradient_h( - &mut self, - x: i32, - y: i32, - w: u32, - h: u32, - left_color: Color, - right_color: Color, - ) -> Result<()> { - let _ = right_color; - self.fill_rect(x, y, w, h, left_color) - } - - /// Draw a filled rectangle with a four-corner gradient. - fn fill_rect_gradient_4( + /// Draw a filled rectangle with a gradient. + /// + /// The [`GradientStyle`] enum specifies direction and colors. + fn fill_rect_gradient( &mut self, x: i32, y: i32, w: u32, h: u32, - top_left: Color, - top_right: Color, - bottom_left: Color, - bottom_right: Color, + gradient: &GradientStyle, ) -> Result<()> { - let _ = (top_right, bottom_left, bottom_right); - self.fill_rect(x, y, w, h, top_left) + self.fill_rect(x, y, w, h, gradient.primary_color()) } - /// Draw a rounded rectangle with a vertical gradient. - fn fill_rounded_rect_gradient_v( + /// Draw a filled rounded rectangle with a gradient. + fn fill_rounded_rect_gradient( &mut self, x: i32, y: i32, w: u32, h: u32, radius: u16, - top_color: Color, - bottom_color: Color, + gradient: &GradientStyle, ) -> Result<()> { - let _ = bottom_color; - self.fill_rounded_rect(x, y, w, h, radius, top_color) + self.fill_rounded_rect(x, y, w, h, radius, gradient.primary_color()) } // ----------------------------------------------------------------------- @@ -458,21 +448,40 @@ pub trait SdiBackend { // ----------------------------------------------------------------------- /// Measure the height of text at the given font size. + /// + /// The default uses `ceil(font_size * 1.2)`. Backends with their own + /// font system should override this. fn measure_text_height(&self, font_size: u16) -> u32 { - (font_size as f32 * 1.2) as u32 - } - - /// Measure both width and height of a text string. - fn measure_text_extents(&self, text: &str, font_size: u16) -> (u32, u32) { - ( - self.measure_text(text, font_size), - self.measure_text_height(font_size), - ) + let fs = font_size as u32; + (fs * 6).div_ceil(5) // ceil(fs * 1.2) } /// Measure the font's ascent (baseline to top of tallest glyph). + /// + /// The default uses `ceil(font_size * 0.85)`, which is coordinated with + /// `measure_text_height` so that `ascent < height` always holds. fn font_ascent(&self, font_size: u16) -> u32 { - (font_size as f32 * 0.8) as u32 + let fs = font_size as u32; + (fs * 17).div_ceil(20) // ceil(fs * 0.85) + } + + /// Return full text metrics (width, height, ascent) for a string. + /// + /// The default delegates to `measure_text`, `measure_text_height`, and + /// `font_ascent`. Backends that override any of those individual methods + /// get correct results automatically. + fn text_metrics(&self, text: &str, font_size: u16) -> TextMetrics { + TextMetrics { + width: self.measure_text(text, font_size), + height: self.measure_text_height(font_size), + ascent: self.font_ascent(font_size), + } + } + + /// Measure both width and height of a text string. + fn measure_text_extents(&self, text: &str, font_size: u16) -> (u32, u32) { + let m = self.text_metrics(text, font_size); + (m.width, m.height) } /// Draw text truncated with "..." if it exceeds `max_width`. @@ -1056,41 +1065,52 @@ mod tests { #[test] fn gradient_v_defaults_to_fill_rect() { let mut b = RecordingBackend::new(); - let top = Color::rgb(255, 0, 0); - b.fill_rect_gradient_v(0, 0, 100, 50, top, Color::rgb(0, 0, 255)) - .unwrap(); + let grad = GradientStyle::Vertical { + top: Color::rgb(255, 0, 0), + bottom: Color::rgb(0, 0, 255), + }; + b.fill_rect_gradient(0, 0, 100, 50, &grad).unwrap(); let calls = b.calls(); assert_eq!(calls.len(), 1); - assert!(calls[0].contains("255,0,0")); // Uses top_color + assert!(calls[0].contains("255,0,0")); // Uses top color } #[test] fn gradient_h_defaults_to_fill_rect() { let mut b = RecordingBackend::new(); - let left = Color::rgb(0, 255, 0); - b.fill_rect_gradient_h(0, 0, 100, 50, left, Color::rgb(0, 0, 255)) - .unwrap(); + let grad = GradientStyle::Horizontal { + left: Color::rgb(0, 255, 0), + right: Color::rgb(0, 0, 255), + }; + b.fill_rect_gradient(0, 0, 100, 50, &grad).unwrap(); let calls = b.calls(); assert_eq!(calls.len(), 1); - assert!(calls[0].contains("0,255,0")); // Uses left_color + assert!(calls[0].contains("0,255,0")); // Uses left color } #[test] fn gradient_4_defaults_to_fill_rect() { let mut b = RecordingBackend::new(); - let tl = Color::rgb(10, 20, 30); - b.fill_rect_gradient_4(0, 0, 100, 50, tl, Color::WHITE, Color::WHITE, Color::WHITE) - .unwrap(); + let grad = GradientStyle::FourCorner { + top_left: Color::rgb(10, 20, 30), + top_right: Color::WHITE, + bottom_left: Color::WHITE, + bottom_right: Color::WHITE, + }; + b.fill_rect_gradient(0, 0, 100, 50, &grad).unwrap(); let calls = b.calls(); assert_eq!(calls.len(), 1); assert!(calls[0].contains("10,20,30")); // Uses top_left } #[test] - fn rounded_rect_gradient_v_default() { + fn rounded_rect_gradient_default() { let mut b = RecordingBackend::new(); - let top = Color::rgb(255, 0, 0); - b.fill_rounded_rect_gradient_v(0, 0, 100, 50, 5, top, Color::BLACK) + let grad = GradientStyle::Vertical { + top: Color::rgb(255, 0, 0), + bottom: Color::BLACK, + }; + b.fill_rounded_rect_gradient(0, 0, 100, 50, 5, &grad) .unwrap(); let calls = b.calls(); assert_eq!(calls.len(), 1); @@ -1138,7 +1158,7 @@ mod tests { #[test] fn font_ascent_default() { let b = RecordingBackend::new(); - assert_eq!(b.font_ascent(10), 8); // 10 * 0.8 + assert_eq!(b.font_ascent(10), 9); // ceil(10 * 0.85) } #[test] @@ -1562,28 +1582,37 @@ mod tests { points: [(0, 0), (1, 0), (0, 1)], color: Color::BLACK, }, - DrawCommand::GradientV { + DrawCommand::Gradient { x: 0, y: 0, w: 1, h: 1, - top: Color::BLACK, - bottom: Color::WHITE, + style: GradientStyle::Vertical { + top: Color::BLACK, + bottom: Color::WHITE, + }, }, - DrawCommand::GradientH { + DrawCommand::Gradient { x: 0, y: 0, w: 1, h: 1, - left: Color::BLACK, - right: Color::WHITE, + style: GradientStyle::Horizontal { + left: Color::BLACK, + right: Color::WHITE, + }, }, - DrawCommand::Gradient4 { + DrawCommand::Gradient { x: 0, y: 0, w: 1, h: 1, - corners: [Color::BLACK; 4], + style: GradientStyle::FourCorner { + top_left: Color::BLACK, + top_right: Color::BLACK, + bottom_left: Color::BLACK, + bottom_right: Color::BLACK, + }, }, DrawCommand::DrawText { text: "x".into(), diff --git a/crates/oasis-ui/Cargo.toml b/crates/oasis-ui/Cargo.toml index b6856e9..ded2d64 100644 --- a/crates/oasis-ui/Cargo.toml +++ b/crates/oasis-ui/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-ui" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-ui/src/animation.rs b/crates/oasis-ui/src/animation.rs index dd8f454..d53df34 100644 --- a/crates/oasis-ui/src/animation.rs +++ b/crates/oasis-ui/src/animation.rs @@ -117,6 +117,20 @@ impl Tween { self.start + (self.end - self.start) * eased } + /// Advance by `dt_ms` and return the current interpolated value, + /// respecting reduced-motion preference. + /// + /// When `reduced_motion` is `true`, the tween immediately completes + /// and returns the end value. + pub fn tick_reduced(&mut self, dt_ms: u32, reduced_motion: bool) -> f32 { + if reduced_motion { + self.elapsed_ms = self.duration_ms; + self.end + } else { + self.tick(dt_ms) + } + } + /// Check if the animation has completed. pub fn is_finished(&self) -> bool { self.elapsed_ms >= self.duration_ms @@ -196,6 +210,22 @@ mod tests { assert_eq!(easing::ease_in_out_cubic(0.0), 0.0); } + #[test] + fn tween_reduced_motion_snaps() { + let mut tw = Tween::new(0.0, 100.0, 1000, easing::linear); + let v = tw.tick_reduced(1, true); + assert!((v - 100.0).abs() < f32::EPSILON); + assert!(tw.is_finished()); + } + + #[test] + fn tween_reduced_motion_false_is_normal() { + let mut tw = Tween::new(0.0, 100.0, 100, easing::linear); + let v = tw.tick_reduced(50, false); + assert!((v - 50.0).abs() < f32::EPSILON); + assert!(!tw.is_finished()); + } + #[test] fn color_tween_works() { let mut ct = ColorTween::new( diff --git a/crates/oasis-ui/src/flex.rs b/crates/oasis-ui/src/flex.rs index 1164017..8cbced5 100644 --- a/crates/oasis-ui/src/flex.rs +++ b/crates/oasis-ui/src/flex.rs @@ -696,4 +696,239 @@ mod tests { } ); } + + // -- Additional flex layout tests -- + + #[test] + fn column_flex_distributes_vertically() { + let layout = FlexLayout::column(); + let children = vec![FlexChild::flex(1), FlexChild::flex(3)]; + let rects = layout.compute(0, 0, 100, 200, &children); + assert_eq!(rects[0].h, 50); + assert_eq!(rects[1].h, 150); + assert_eq!(rects[0].w, 100); // cross-axis full + assert_eq!(rects[1].w, 100); + } + + #[test] + fn column_percent_sizing() { + let layout = FlexLayout::column(); + let children = vec![FlexChild::percent(30.0), FlexChild::percent(70.0)]; + let rects = layout.compute(0, 0, 100, 200, &children); + assert_eq!(rects[0].h, 60); // 30% of 200 + assert_eq!(rects[1].h, 140); // 70% of 200 + } + + #[test] + fn column_mixed_fixed_and_flex() { + let layout = FlexLayout::column(); + let children = vec![FlexChild::fixed(20), FlexChild::flex(1)]; + let rects = layout.compute(0, 0, 100, 100, &children); + assert_eq!(rects[0].h, 20); + assert_eq!(rects[1].h, 80); + } + + #[test] + fn column_justify_center() { + let layout = FlexLayout::column().with_justify(HAlign::Center); + let children = vec![FlexChild::fixed(20)]; + let rects = layout.compute(0, 0, 100, 100, &children); + assert_eq!(rects[0].y, 40); + } + + #[test] + fn column_justify_right() { + let layout = FlexLayout::column().with_justify(HAlign::Right); + let children = vec![FlexChild::fixed(20)]; + let rects = layout.compute(0, 0, 100, 100, &children); + assert_eq!(rects[0].y, 80); + } + + #[test] + fn row_with_margin() { + let layout = FlexLayout::row(); + let children = vec![FlexChild::fixed(40).with_margin(Padding::uniform(5))]; + let rects = layout.compute(0, 0, 200, 50, &children); + assert_eq!(rects[0].x, 5); // left margin + assert_eq!(rects[0].w, 40); + } + + #[test] + fn column_with_margin() { + let layout = FlexLayout::column(); + let children = vec![FlexChild::fixed(30).with_margin(Padding::new(10, 0, 0, 0))]; + let rects = layout.compute(0, 0, 100, 200, &children); + assert_eq!(rects[0].y, 10); // top margin + assert_eq!(rects[0].h, 30); + } + + #[test] + fn row_three_flex_equal_weight() { + let layout = FlexLayout::row(); + let children = vec![FlexChild::flex(1), FlexChild::flex(1), FlexChild::flex(1)]; + let rects = layout.compute(0, 0, 300, 50, &children); + assert_eq!(rects[0].w, 100); + assert_eq!(rects[1].w, 100); + assert_eq!(rects[2].w, 100); + } + + #[test] + fn row_with_gap_positions() { + let layout = FlexLayout::row().with_gap(10); + let children = vec![FlexChild::fixed(50), FlexChild::fixed(50)]; + let rects = layout.compute(0, 0, 200, 40, &children); + assert_eq!(rects[0].x, 0); + assert_eq!(rects[1].x, 60); // 50 + 10 gap + } + + #[test] + fn column_with_gap_positions() { + let layout = FlexLayout::column().with_gap(5); + let children = vec![ + FlexChild::fixed(20), + FlexChild::fixed(20), + FlexChild::fixed(20), + ]; + let rects = layout.compute(0, 0, 100, 200, &children); + assert_eq!(rects[0].y, 0); + assert_eq!(rects[1].y, 25); // 20 + 5 + assert_eq!(rects[2].y, 50); // 20 + 5 + 20 + 5 + } + + #[test] + fn parent_offset_applied() { + let layout = FlexLayout::row(); + let children = vec![FlexChild::fixed(30)]; + let rects = layout.compute(100, 200, 300, 50, &children); + assert_eq!(rects[0].x, 100); + assert_eq!(rects[0].y, 200); + } + + #[test] + fn zero_size_parent() { + let layout = FlexLayout::row(); + let children = vec![FlexChild::flex(1)]; + let rects = layout.compute(0, 0, 0, 0, &children); + assert_eq!(rects[0].w, 0); + assert_eq!(rects[0].h, 0); + } + + #[test] + fn flex_child_with_align_self() { + let child = FlexChild::fixed(20).with_align(VAlign::Bottom); + assert_eq!(child.align_self, Some(VAlign::Bottom)); + } + + #[test] + fn flex_child_default_margin() { + let child = FlexChild::fixed(50); + assert_eq!(child.margin, Padding::ZERO); + } + + #[test] + fn flex_direction_debug() { + assert_eq!(format!("{:?}", FlexDirection::Row), "Row"); + assert_eq!(format!("{:?}", FlexDirection::Column), "Column"); + } + + #[test] + fn grid_large_index() { + let grid = GridLayout::new(3); + let rect = grid.cell_rect(5, 0, 0, 90, 60, 6).unwrap(); + // Index 5: col=2, row=1 + assert_eq!(rect.x, 60); // col 2 * 30 + assert_eq!(rect.y, 30); // row 1 * 30 + } + + #[test] + fn grid_one_col() { + let grid = GridLayout::new(1); + let cells = grid.all_cells(0, 0, 100, 100, 3); + assert_eq!(cells.len(), 3); + // Each cell should be full width. + for cell in &cells { + assert_eq!(cell.w, 100); + } + } + + #[test] + fn grid_with_both_gaps_and_padding() { + let grid = GridLayout::new(2) + .with_gap(4, 4) + .with_padding(Padding::uniform(5)); + let rect = grid.cell_rect(0, 0, 0, 100, 100, 4).unwrap(); + assert_eq!(rect.x, 5); // padding left + assert_eq!(rect.y, 5); // padding top + } + + #[test] + fn grid_zero_total_returns_none() { + let grid = GridLayout::new(3); + // zero total items => rows=0 => returns None + assert!(grid.cell_rect(0, 0, 0, 90, 60, 0).is_none()); + } + + #[test] + fn grid_all_cells_consistent_with_cell_rect() { + let grid = GridLayout::new(3).with_gap(2, 2); + let cells = grid.all_cells(10, 20, 200, 100, 6); + for i in 0..6 { + let single = grid.cell_rect(i, 10, 20, 200, 100, 6).unwrap(); + assert_eq!(cells[i], single); + } + } + + #[test] + fn vertical_list_empty() { + let rects = vertical_list(0, 0, 100, 20, 0, 0); + assert!(rects.is_empty()); + } + + #[test] + fn vertical_list_single_item() { + let rects = vertical_list(5, 10, 80, 20, 0, 1); + assert_eq!(rects.len(), 1); + assert_eq!( + rects[0], + ComputedRect { + x: 5, + y: 10, + w: 80, + h: 20, + } + ); + } + + #[test] + fn computed_rect_debug() { + let r = ComputedRect { + x: 1, + y: 2, + w: 3, + h: 4, + }; + let dbg = format!("{r:?}"); + assert!(dbg.contains("ComputedRect")); + } + + #[test] + fn computed_rect_clone_and_eq() { + let a = ComputedRect { + x: 10, + y: 20, + w: 30, + h: 40, + }; + let b = a; + assert_eq!(a, b); + } + + #[test] + fn flex_layout_default_is_row() { + let layout = FlexLayout::default(); + assert_eq!(layout.direction, FlexDirection::Row); + assert_eq!(layout.gap, 0); + assert_eq!(layout.align_items, VAlign::Top); + assert_eq!(layout.justify, HAlign::Left); + } } diff --git a/crates/oasis-ui/src/focus.rs b/crates/oasis-ui/src/focus.rs index 520a7de..4f42148 100644 --- a/crates/oasis-ui/src/focus.rs +++ b/crates/oasis-ui/src/focus.rs @@ -3,6 +3,10 @@ //! `FocusRing` tracks which widget index has focus and handles //! directional navigation (next/prev/wrapping). Widgets query the //! ring to determine their visual focus state. +//! +//! `FocusManager` builds on `FocusRing` to provide Tab/Shift-Tab +//! keyboard cycling, skip-disabled item logic, and visual focus +//! indicator drawing through `FocusStyle`. /// Direction of focus movement. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -107,6 +111,234 @@ impl FocusRing { } } +use oasis_types::backend::Color; + +/// Visual style for focus indicators drawn around focused widgets. +#[derive(Debug, Clone, Copy)] +pub struct FocusStyle { + /// Border color for the focus indicator. + pub color: Color, + /// Border width in pixels. + pub width: u16, + /// Corner radius for the focus border. + pub radius: u16, + /// Gap between the widget edge and the focus border. + pub offset: i32, +} + +impl FocusStyle { + /// Create a focus style from a theme's accent color. + pub fn from_accent(accent: Color) -> Self { + Self { + color: accent, + width: 1, + radius: 2, + offset: 1, + } + } + + /// Compute the focus indicator rectangle given a widget rect. + pub fn indicator_rect(&self, x: i32, y: i32, w: u32, h: u32) -> (i32, i32, u32, u32) { + let off = self.offset; + let delta = off.saturating_mul(2); + ( + x.saturating_sub(off), + y.saturating_sub(off), + w.saturating_add_signed(delta), + h.saturating_add_signed(delta), + ) + } + + /// Draw the focus indicator around a widget rectangle. + pub fn draw( + &self, + backend: &mut dyn oasis_types::backend::SdiBackend, + x: i32, + y: i32, + w: u32, + h: u32, + ) -> oasis_types::error::Result<()> { + let (fx, fy, fw, fh) = self.indicator_rect(x, y, w, h); + backend.stroke_rounded_rect(fx, fy, fw, fh, self.radius, self.width, self.color) + } +} + +/// Keyboard navigation action derived from input events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FocusAction { + /// Tab key: move to the next focusable widget. + TabNext, + /// Shift+Tab: move to the previous focusable widget. + TabPrev, + /// Activate/confirm the focused widget (Enter/Cross). + Activate, + /// Home key: focus the first widget. + Home, + /// End key: focus the last widget. + End, +} + +/// Manages keyboard focus across a set of widgets, with support +/// for Tab/Shift-Tab cycling, disabled-item skipping, and visual +/// focus indicators. +#[derive(Debug, Clone)] +pub struct FocusManager { + /// The underlying focus ring. + ring: FocusRing, + /// Per-item enabled state. Items marked `false` are skipped + /// during Tab navigation. + enabled: Vec, + /// Whether keyboard focus is currently active. When false, + /// no widget shows a focus indicator. + pub active: bool, +} + +impl FocusManager { + /// Create a new focus manager with `count` focusable items, + /// all initially enabled. + pub fn new(count: usize) -> Self { + Self { + ring: FocusRing::new(count), + enabled: vec![true; count], + active: false, + } + } + + /// Get the current focused index. + pub fn focused(&self) -> usize { + self.ring.focused() + } + + /// Whether the given index currently has visible focus. + pub fn has_focus(&self, index: usize) -> bool { + self.active && self.ring.is_focused(index) + } + + /// Total number of managed items. + pub fn count(&self) -> usize { + self.ring.count() + } + + /// Access the underlying `FocusRing`. + pub fn ring(&self) -> &FocusRing { + &self.ring + } + + /// Mutably access the underlying `FocusRing`. + pub fn ring_mut(&mut self) -> &mut FocusRing { + &mut self.ring + } + + /// Set whether a specific item is enabled for focus. + pub fn set_enabled(&mut self, index: usize, enabled: bool) { + if index < self.enabled.len() { + self.enabled[index] = enabled; + } + } + + /// Whether a specific item is enabled. + pub fn is_enabled(&self, index: usize) -> bool { + self.enabled.get(index).copied().unwrap_or(false) + } + + /// Update the item count. Grows or shrinks the enabled list. + pub fn set_count(&mut self, count: usize) { + self.ring.set_count(count); + self.enabled.resize(count, true); + } + + /// Process a focus action and return the new focused index. + /// + /// Skips disabled items when navigating. If all items are + /// disabled, focus does not move. + pub fn handle_action(&mut self, action: FocusAction) -> usize { + if self.ring.count() == 0 { + return 0; + } + self.active = true; + match action { + FocusAction::TabNext => self.move_skip(FocusDir::Next), + FocusAction::TabPrev => self.move_skip(FocusDir::Prev), + FocusAction::Activate => self.ring.focused(), + FocusAction::Home => { + self.ring.focus_first(); + self.skip_to_enabled(FocusDir::Next) + }, + FocusAction::End => { + self.ring.focus_last(); + self.skip_to_enabled(FocusDir::Prev) + }, + } + } + + /// Move focus in the given direction, skipping disabled items. + fn move_skip(&mut self, dir: FocusDir) -> usize { + let start = self.ring.focused(); + self.ring.move_focus(dir); + // Skip disabled items, but stop if we wrap all the way + // around to avoid infinite loops. + let mut attempts = 0; + while !self.is_enabled(self.ring.focused()) && attempts < self.ring.count() { + self.ring.move_focus(dir); + attempts += 1; + } + // If everything is disabled, go back to start. + if attempts >= self.ring.count() { + self.ring.set_focused(start); + } + self.ring.focused() + } + + /// From the current position, skip to the nearest enabled + /// item in the given direction. + fn skip_to_enabled(&mut self, dir: FocusDir) -> usize { + if self.is_enabled(self.ring.focused()) { + return self.ring.focused(); + } + let start = self.ring.focused(); + let mut attempts = 0; + while !self.is_enabled(self.ring.focused()) && attempts < self.ring.count() { + self.ring.move_focus(dir); + attempts += 1; + } + if attempts >= self.ring.count() { + self.ring.set_focused(start); + } + self.ring.focused() + } + + /// Set focus to a specific index (must be enabled). + pub fn set_focused(&mut self, index: usize) { + if index < self.enabled.len() && self.enabled[index] { + self.ring.set_focused(index); + self.active = true; + } + } + + /// Deactivate keyboard focus (no visible indicator). + pub fn deactivate(&mut self) { + self.active = false; + } + + /// Draw the focus indicator around a widget if it has focus. + #[allow(clippy::too_many_arguments)] + pub fn draw_indicator( + &self, + backend: &mut dyn oasis_types::backend::SdiBackend, + index: usize, + style: &FocusStyle, + x: i32, + y: i32, + w: u32, + h: u32, + ) -> oasis_types::error::Result<()> { + if self.has_focus(index) { + style.draw(backend, x, y, w, h)?; + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -269,4 +501,477 @@ mod tests { assert_eq!(f.move_focus(FocusDir::Prev), expected); } } + + // -- Additional focus traversal tests -- + + #[test] + fn wrap_around_full_cycle_next() { + let mut f = FocusRing::new(3); + // 0 -> 1 -> 2 -> 0 -> 1 -> 2 -> 0 + let expected = [1, 2, 0, 1, 2, 0]; + for &e in &expected { + assert_eq!(f.move_focus(FocusDir::Next), e); + } + } + + #[test] + fn wrap_around_full_cycle_prev() { + let mut f = FocusRing::new(3); + // 0 -> 2 -> 1 -> 0 -> 2 -> 1 -> 0 + let expected = [2, 1, 0, 2, 1, 0]; + for &e in &expected { + assert_eq!(f.move_focus(FocusDir::Prev), e); + } + } + + #[test] + fn no_wrap_next_boundary_stays() { + let mut f = FocusRing::new(5); + f.wrap = false; + f.set_focused(4); + // At the end, Next should not move. + assert_eq!(f.move_focus(FocusDir::Next), 4); + assert_eq!(f.move_focus(FocusDir::Next), 4); + } + + #[test] + fn no_wrap_prev_boundary_stays() { + let mut f = FocusRing::new(5); + f.wrap = false; + f.set_focused(0); + // At the beginning, Prev should not move. + assert_eq!(f.move_focus(FocusDir::Prev), 0); + assert_eq!(f.move_focus(FocusDir::Prev), 0); + } + + #[test] + fn set_count_increase() { + let mut f = FocusRing::new(3); + f.set_focused(2); + f.set_count(10); + assert_eq!(f.count(), 10); + assert_eq!(f.focused(), 2); // unchanged + } + + #[test] + fn set_count_decrease_clamps_focus() { + let mut f = FocusRing::new(10); + f.set_focused(9); + f.set_count(5); + assert_eq!(f.focused(), 4); + } + + #[test] + fn set_count_to_one() { + let mut f = FocusRing::new(10); + f.set_focused(5); + f.set_count(1); + assert_eq!(f.focused(), 0); + assert_eq!(f.count(), 1); + } + + #[test] + fn focus_first_from_middle() { + let mut f = FocusRing::new(10); + f.set_focused(5); + f.focus_first(); + assert_eq!(f.focused(), 0); + } + + #[test] + fn focus_last_from_first() { + let mut f = FocusRing::new(10); + f.focus_last(); + assert_eq!(f.focused(), 9); + } + + #[test] + fn is_focused_after_movement() { + let mut f = FocusRing::new(5); + assert!(f.is_focused(0)); + f.move_focus(FocusDir::Next); + assert!(!f.is_focused(0)); + assert!(f.is_focused(1)); + } + + #[test] + fn set_focused_to_zero() { + let mut f = FocusRing::new(5); + f.set_focused(3); + f.set_focused(0); + assert_eq!(f.focused(), 0); + } + + #[test] + fn single_item_wrap_next_prev() { + let mut f = FocusRing::new(1); + f.wrap = true; + assert_eq!(f.move_focus(FocusDir::Next), 0); + assert_eq!(f.move_focus(FocusDir::Prev), 0); + } + + #[test] + fn single_item_no_wrap_next_prev() { + let mut f = FocusRing::new(1); + f.wrap = false; + assert_eq!(f.move_focus(FocusDir::Next), 0); + assert_eq!(f.move_focus(FocusDir::Prev), 0); + } + + #[test] + fn two_items_alternating() { + let mut f = FocusRing::new(2); + assert_eq!(f.move_focus(FocusDir::Next), 1); + assert_eq!(f.move_focus(FocusDir::Next), 0); + assert_eq!(f.move_focus(FocusDir::Prev), 1); + assert_eq!(f.move_focus(FocusDir::Prev), 0); + } + + #[test] + fn focus_dir_equality() { + assert_eq!(FocusDir::Next, FocusDir::Next); + assert_eq!(FocusDir::Prev, FocusDir::Prev); + assert_ne!(FocusDir::Next, FocusDir::Prev); + } + + #[test] + fn large_focus_ring() { + let mut f = FocusRing::new(1000); + f.set_focused(999); + assert_eq!(f.focused(), 999); + assert_eq!(f.move_focus(FocusDir::Next), 0); // wraps + } + + #[test] + fn set_focused_exact_max() { + let mut f = FocusRing::new(5); + f.set_focused(4); // max valid + assert_eq!(f.focused(), 4); + } + + #[test] + fn set_focused_way_over_max() { + let mut f = FocusRing::new(5); + f.set_focused(1000); + assert_eq!(f.focused(), 4); + } + + // -- FocusStyle tests -- + + #[test] + fn focus_style_from_accent() { + let s = FocusStyle::from_accent(Color::rgb(80, 160, 255)); + assert_eq!(s.color, Color::rgb(80, 160, 255)); + assert_eq!(s.width, 1); + assert_eq!(s.radius, 2); + assert_eq!(s.offset, 1); + } + + #[test] + fn focus_style_indicator_rect() { + let s = FocusStyle { + color: Color::WHITE, + width: 2, + radius: 4, + offset: 2, + }; + let (fx, fy, fw, fh) = s.indicator_rect(10, 20, 100, 50); + assert_eq!(fx, 8); + assert_eq!(fy, 18); + assert_eq!(fw, 104); + assert_eq!(fh, 54); + } + + #[test] + fn focus_style_indicator_rect_zero_offset() { + let s = FocusStyle { + color: Color::WHITE, + width: 1, + radius: 0, + offset: 0, + }; + let r = s.indicator_rect(5, 10, 80, 30); + assert_eq!(r, (5, 10, 80, 30)); + } + + #[test] + fn focus_style_draw_calls_backend() { + use crate::test_utils::MockBackend; + let s = FocusStyle::from_accent(Color::rgb(0, 255, 0)); + let mut backend = MockBackend::new(); + s.draw(&mut backend, 10, 20, 100, 50).ok(); + assert!(backend.fill_rect_count() > 0); + } + + // -- FocusManager tests -- + + #[test] + fn manager_new_defaults() { + let fm = FocusManager::new(5); + assert_eq!(fm.count(), 5); + assert_eq!(fm.focused(), 0); + assert!(!fm.active); + } + + #[test] + fn manager_new_empty() { + let fm = FocusManager::new(0); + assert_eq!(fm.count(), 0); + assert_eq!(fm.focused(), 0); + } + + #[test] + fn manager_has_focus_inactive() { + let fm = FocusManager::new(3); + assert!(!fm.has_focus(0)); + } + + #[test] + fn manager_has_focus_active() { + let mut fm = FocusManager::new(3); + fm.active = true; + assert!(fm.has_focus(0)); + assert!(!fm.has_focus(1)); + } + + #[test] + fn manager_tab_next() { + let mut fm = FocusManager::new(3); + assert_eq!(fm.handle_action(FocusAction::TabNext), 1); + assert!(fm.active); + assert_eq!(fm.handle_action(FocusAction::TabNext), 2); + assert_eq!(fm.handle_action(FocusAction::TabNext), 0); + } + + #[test] + fn manager_tab_prev() { + let mut fm = FocusManager::new(3); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 2); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 1); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 0); + } + + #[test] + fn manager_activate() { + let mut fm = FocusManager::new(3); + fm.handle_action(FocusAction::TabNext); + let idx = fm.handle_action(FocusAction::Activate); + assert_eq!(idx, 1); + } + + #[test] + fn manager_home() { + let mut fm = FocusManager::new(5); + fm.handle_action(FocusAction::TabNext); + fm.handle_action(FocusAction::TabNext); + assert_eq!(fm.handle_action(FocusAction::Home), 0); + } + + #[test] + fn manager_end() { + let mut fm = FocusManager::new(5); + assert_eq!(fm.handle_action(FocusAction::End), 4); + } + + #[test] + fn manager_skip_disabled_next() { + let mut fm = FocusManager::new(4); + fm.set_enabled(1, false); + fm.set_enabled(2, false); + assert_eq!(fm.handle_action(FocusAction::TabNext), 3); + } + + #[test] + fn manager_skip_disabled_prev() { + let mut fm = FocusManager::new(4); + fm.set_enabled(2, false); + fm.set_enabled(3, false); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 1); + } + + #[test] + fn manager_all_disabled_stays() { + let mut fm = FocusManager::new(3); + fm.set_enabled(0, false); + fm.set_enabled(1, false); + fm.set_enabled(2, false); + let before = fm.focused(); + fm.handle_action(FocusAction::TabNext); + assert_eq!(fm.focused(), before); + } + + #[test] + fn manager_set_count_grows() { + let mut fm = FocusManager::new(3); + fm.set_count(5); + assert_eq!(fm.count(), 5); + assert!(fm.is_enabled(3)); + assert!(fm.is_enabled(4)); + } + + #[test] + fn manager_set_count_shrinks() { + let mut fm = FocusManager::new(5); + fm.ring_mut().set_focused(4); + fm.set_count(3); + assert_eq!(fm.count(), 3); + assert_eq!(fm.focused(), 2); + } + + #[test] + fn manager_set_focused_enabled() { + let mut fm = FocusManager::new(5); + fm.set_focused(3); + assert_eq!(fm.focused(), 3); + assert!(fm.active); + } + + #[test] + fn manager_set_focused_disabled_rejected() { + let mut fm = FocusManager::new(5); + fm.set_enabled(3, false); + fm.set_focused(3); + assert_eq!(fm.focused(), 0); + } + + #[test] + fn manager_deactivate() { + let mut fm = FocusManager::new(3); + fm.active = true; + fm.deactivate(); + assert!(!fm.active); + assert!(!fm.has_focus(0)); + } + + #[test] + fn manager_is_enabled_out_of_bounds() { + let fm = FocusManager::new(3); + assert!(!fm.is_enabled(10)); + } + + #[test] + fn manager_set_enabled_out_of_bounds_noop() { + let mut fm = FocusManager::new(3); + fm.set_enabled(10, false); + assert_eq!(fm.count(), 3); + } + + #[test] + fn manager_ring_access() { + let fm = FocusManager::new(5); + assert_eq!(fm.ring().count(), 5); + } + + #[test] + fn manager_ring_mut_access() { + let mut fm = FocusManager::new(5); + fm.ring_mut().set_focused(3); + assert_eq!(fm.focused(), 3); + } + + #[test] + fn manager_home_skips_disabled_first() { + let mut fm = FocusManager::new(5); + fm.set_enabled(0, false); + assert_eq!(fm.handle_action(FocusAction::Home), 1); + } + + #[test] + fn manager_end_skips_disabled_last() { + let mut fm = FocusManager::new(5); + fm.set_enabled(4, false); + assert_eq!(fm.handle_action(FocusAction::End), 3); + } + + #[test] + fn manager_draw_indicator_active() { + use crate::test_utils::MockBackend; + let mut fm = FocusManager::new(3); + fm.active = true; + let s = FocusStyle::from_accent(Color::rgb(80, 160, 255)); + let mut backend = MockBackend::new(); + fm.draw_indicator(&mut backend, 0, &s, 10, 20, 80, 30).ok(); + assert!(backend.fill_rect_count() > 0); + } + + #[test] + fn manager_draw_indicator_wrong_index() { + use crate::test_utils::MockBackend; + let mut fm = FocusManager::new(3); + fm.active = true; + let s = FocusStyle::from_accent(Color::rgb(80, 160, 255)); + let mut backend = MockBackend::new(); + fm.draw_indicator(&mut backend, 1, &s, 10, 20, 80, 30).ok(); + assert_eq!(backend.fill_rect_count(), 0); + } + + #[test] + fn manager_draw_indicator_inactive() { + use crate::test_utils::MockBackend; + let fm = FocusManager::new(3); + let s = FocusStyle::from_accent(Color::rgb(80, 160, 255)); + let mut backend = MockBackend::new(); + fm.draw_indicator(&mut backend, 0, &s, 10, 20, 80, 30).ok(); + assert_eq!(backend.fill_rect_count(), 0); + } + + #[test] + fn manager_empty_handle_action() { + let mut fm = FocusManager::new(0); + assert_eq!(fm.handle_action(FocusAction::TabNext), 0); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 0); + assert_eq!(fm.handle_action(FocusAction::Home), 0); + assert_eq!(fm.handle_action(FocusAction::End), 0); + } + + #[test] + fn focus_action_debug() { + for a in [ + FocusAction::TabNext, + FocusAction::TabPrev, + FocusAction::Activate, + FocusAction::Home, + FocusAction::End, + ] { + let _ = format!("{a:?}"); + } + } + + #[test] + fn focus_action_equality() { + assert_eq!(FocusAction::TabNext, FocusAction::TabNext); + assert_ne!(FocusAction::TabNext, FocusAction::TabPrev); + } + + #[test] + fn manager_single_enabled_item() { + let mut fm = FocusManager::new(3); + fm.set_enabled(0, false); + fm.set_enabled(2, false); + assert_eq!(fm.handle_action(FocusAction::TabNext), 1); + assert_eq!(fm.handle_action(FocusAction::TabNext), 1); + assert_eq!(fm.handle_action(FocusAction::TabPrev), 1); + } + + #[test] + fn manager_tab_full_cycle() { + let mut fm = FocusManager::new(4); + let mut visited = Vec::new(); + for _ in 0..4 { + let idx = fm.handle_action(FocusAction::TabNext); + visited.push(idx); + } + assert_eq!(visited, vec![1, 2, 3, 0]); + } + + #[test] + fn manager_clone() { + let mut fm = FocusManager::new(3); + fm.active = true; + fm.set_enabled(1, false); + let fm2 = fm.clone(); + assert_eq!(fm.focused(), fm2.focused()); + assert_eq!(fm.active, fm2.active); + assert_eq!(fm.is_enabled(1), fm2.is_enabled(1)); + } } diff --git a/crates/oasis-ui/src/layout.rs b/crates/oasis-ui/src/layout.rs index 2b76dd1..baf7057 100644 --- a/crates/oasis-ui/src/layout.rs +++ b/crates/oasis-ui/src/layout.rs @@ -165,4 +165,158 @@ mod tests { assert_eq!(size, 22); assert_eq!(pos, vec![0, 26, 52, 78]); } + + // -- Additional layout tests -- + + #[test] + fn padding_zero_is_identity() { + let p = Padding::ZERO; + let (x, y, w, h) = p.inner_rect(10, 20, 100, 50); + assert_eq!((x, y, w, h), (10, 20, 100, 50)); + } + + #[test] + fn padding_symmetric() { + let p = Padding::symmetric(10, 5); + assert_eq!(p.left, 10); + assert_eq!(p.right, 10); + assert_eq!(p.top, 5); + assert_eq!(p.bottom, 5); + assert_eq!(p.horizontal(), 20); + assert_eq!(p.vertical(), 10); + } + + #[test] + fn padding_individual_sides() { + let p = Padding::new(1, 2, 3, 4); + assert_eq!(p.top, 1); + assert_eq!(p.right, 2); + assert_eq!(p.bottom, 3); + assert_eq!(p.left, 4); + assert_eq!(p.horizontal(), 6); + assert_eq!(p.vertical(), 4); + } + + #[test] + fn padding_inner_rect_larger_than_container() { + let p = Padding::uniform(100); + let (x, y, w, h) = p.inner_rect(0, 0, 50, 50); + assert_eq!(x, 100); + assert_eq!(y, 100); + assert_eq!(w, 0); // saturating_sub + assert_eq!(h, 0); + } + + #[test] + fn center_zero_parent() { + assert_eq!(center(0, 10), 0); + } + + #[test] + fn center_zero_child() { + assert_eq!(center(100, 0), 50); + } + + #[test] + fn center_equal_sizes() { + assert_eq!(center(50, 50), 0); + } + + #[test] + fn center_text_y_basic() { + let y = center_text_y(24, 12, 10); + assert_eq!(y, 16); // (24-12)/2 + 10 + } + + #[test] + fn distribute_zero_items() { + let (size, pos) = distribute(100, 0, 4); + assert_eq!(size, 0); + assert!(pos.is_empty()); + } + + #[test] + fn distribute_one_item() { + let (size, pos) = distribute(100, 1, 0); + assert_eq!(size, 100); + assert_eq!(pos, vec![0]); + } + + #[test] + fn distribute_no_gap() { + let (size, pos) = distribute(100, 5, 0); + assert_eq!(size, 20); + assert_eq!(pos, vec![0, 20, 40, 60, 80]); + } + + #[test] + fn distribute_large_gap_saturates() { + let (size, pos) = distribute(10, 4, 100); + // total_gap = 300, but total = 10 so saturating_sub = 0 + assert_eq!(size, 0); + assert_eq!(pos.len(), 4); + } + + #[test] + fn align_x_left() { + assert_eq!(align_x(200, 50, HAlign::Left), 0); + } + + #[test] + fn align_x_center() { + let x = align_x(200, 50, HAlign::Center); + assert_eq!(x, center(200, 50)); + } + + #[test] + fn align_x_right() { + assert_eq!(align_x(200, 50, HAlign::Right), 150); + } + + #[test] + fn align_x_right_child_larger() { + assert_eq!(align_x(50, 200, HAlign::Right), 0); + } + + #[test] + fn align_y_top() { + assert_eq!(align_y(100, 20, VAlign::Top), 0); + } + + #[test] + fn align_y_center() { + let y = align_y(100, 20, VAlign::Center); + assert_eq!(y, center(100, 20)); + } + + #[test] + fn align_y_bottom() { + assert_eq!(align_y(100, 20, VAlign::Bottom), 80); + } + + #[test] + fn align_y_bottom_child_larger() { + assert_eq!(align_y(20, 100, VAlign::Bottom), 0); + } + + #[test] + fn halign_debug() { + assert_eq!(format!("{:?}", HAlign::Left), "Left"); + assert_eq!(format!("{:?}", HAlign::Center), "Center"); + assert_eq!(format!("{:?}", HAlign::Right), "Right"); + } + + #[test] + fn valign_debug() { + assert_eq!(format!("{:?}", VAlign::Top), "Top"); + assert_eq!(format!("{:?}", VAlign::Center), "Center"); + assert_eq!(format!("{:?}", VAlign::Bottom), "Bottom"); + } + + #[test] + fn padding_clone_and_eq() { + let a = Padding::uniform(8); + let b = a; + assert_eq!(a, b); + } } diff --git a/crates/oasis-ui/src/lib.rs b/crates/oasis-ui/src/lib.rs index aca4244..22ff587 100644 --- a/crates/oasis-ui/src/lib.rs +++ b/crates/oasis-ui/src/lib.rs @@ -27,10 +27,12 @@ pub mod progress_bar; pub mod scroll_view; pub use oasis_types::shadow; pub mod radio; +pub mod spinner; pub mod tab_bar; pub mod text_block; pub mod theme; pub mod toggle; +pub mod tooltip; pub mod widget; #[cfg(test)] diff --git a/crates/oasis-ui/src/list_view.rs b/crates/oasis-ui/src/list_view.rs index 0f743e1..455522a 100644 --- a/crates/oasis-ui/src/list_view.rs +++ b/crates/oasis-ui/src/list_view.rs @@ -58,9 +58,10 @@ impl ListView { let result = (|| { let first = (self.scroll_offset / self.item_height as i32).max(0) as usize; - // Ceiling division + 1 to cover partially visible first/last items. - let visible = (h.div_ceil(self.item_height) + 1) as usize; - let last = (first + visible).min(self.items.len()); + // Exact visible count: items that fit plus one for + // a partially visible trailing item. + let visible = h / self.item_height + u32::from(!h.is_multiple_of(self.item_height)) + 1; + let last = (first + visible as usize).min(self.items.len()); for i in first..last { let item_y = y + (i as i32 * self.item_height as i32) - self.scroll_offset; diff --git a/crates/oasis-ui/src/modal.rs b/crates/oasis-ui/src/modal.rs index 866731f..2a2dbf1 100644 --- a/crates/oasis-ui/src/modal.rs +++ b/crates/oasis-ui/src/modal.rs @@ -1,10 +1,38 @@ -//! Modal dialog widget. +//! Modal dialog widget with input blocking and button presets. use crate::context::DrawContext; use crate::layout; use crate::widget::Widget; use oasis_types::error::Result; +/// Result of dismissing a modal dialog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModalResult { + /// No action taken yet (modal still open). + Pending, + /// User confirmed (pressed OK / primary action). + Confirmed, + /// User cancelled (pressed Cancel / dismissed). + Cancelled, + /// User pressed a custom button at the given index. + Custom(usize), +} + +/// Preset button configurations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModalButtons { + /// Single "OK" button. + Ok, + /// "Cancel" and "OK" buttons. + OkCancel, + /// "No" and "Yes" buttons. + YesNo, + /// "Cancel", "No", and "Yes" buttons. + YesNoCancel, + /// Custom: caller sets buttons manually. + Custom, +} + /// A modal dialog with title, body text, and action buttons. pub struct Modal { /// Dialog title. @@ -17,6 +45,12 @@ pub struct Modal { pub focused_button: usize, /// Whether to draw the semi-transparent backdrop overlay. pub show_backdrop: bool, + /// Whether this modal blocks input to underlying widgets. + pub blocks_input: bool, + /// Current dismissal result (Pending while open). + pub result: ModalResult, + /// Which button preset was used. + pub preset: ModalButtons, } impl Modal { @@ -28,6 +62,9 @@ impl Modal { buttons: vec!["OK".into()], focused_button: 0, show_backdrop: true, + blocks_input: true, + result: ModalResult::Pending, + preset: ModalButtons::Ok, } } @@ -36,6 +73,38 @@ impl Modal { Self { buttons: vec!["Cancel".into(), "OK".into()], focused_button: 1, + preset: ModalButtons::OkCancel, + ..Self::new(title, body) + } + } + + /// Create a Yes/No dialog. + pub fn yes_no(title: impl Into, body: impl Into) -> Self { + Self { + buttons: vec!["No".into(), "Yes".into()], + focused_button: 1, + preset: ModalButtons::YesNo, + ..Self::new(title, body) + } + } + + /// Create a Yes/No/Cancel dialog. + pub fn yes_no_cancel(title: impl Into, body: impl Into) -> Self { + Self { + buttons: vec!["Cancel".into(), "No".into(), "Yes".into()], + focused_button: 2, + preset: ModalButtons::YesNoCancel, + ..Self::new(title, body) + } + } + + /// Create a dialog with custom button labels. + pub fn custom(title: impl Into, body: impl Into, buttons: Vec) -> Self { + let focused = buttons.len().saturating_sub(1); + Self { + buttons, + focused_button: focused, + preset: ModalButtons::Custom, ..Self::new(title, body) } } @@ -63,6 +132,59 @@ impl Modal { self.buttons.get(self.focused_button).map(String::as_str) } + /// Activate the currently focused button and set the result. + /// + /// Returns the `ModalResult` for the pressed button. + pub fn activate(&mut self) -> ModalResult { + let result = self.result_for_index(self.focused_button); + self.result = result; + result + } + + /// Dismiss the modal as cancelled (e.g. Escape key). + pub fn dismiss(&mut self) -> ModalResult { + self.result = ModalResult::Cancelled; + ModalResult::Cancelled + } + + /// Whether this modal is still open (no result yet). + pub fn is_open(&self) -> bool { + self.result == ModalResult::Pending + } + + /// Whether this modal should consume all input events, + /// preventing them from reaching widgets underneath. + pub fn should_block_input(&self) -> bool { + self.blocks_input && self.is_open() + } + + /// Map a button index to a `ModalResult` based on the preset. + fn result_for_index(&self, index: usize) -> ModalResult { + match self.preset { + ModalButtons::Ok => ModalResult::Confirmed, + ModalButtons::OkCancel => { + if index == 0 { + ModalResult::Cancelled + } else { + ModalResult::Confirmed + } + }, + ModalButtons::YesNo => { + if index == 0 { + ModalResult::Cancelled + } else { + ModalResult::Confirmed + } + }, + ModalButtons::YesNoCancel => match index { + 0 => ModalResult::Cancelled, + 1 => ModalResult::Custom(1), + _ => ModalResult::Confirmed, + }, + ModalButtons::Custom => ModalResult::Custom(index), + } + } + /// Fixed modal width for the 480x272 viewport. const MODAL_WIDTH: u32 = 280; @@ -85,6 +207,9 @@ mod tests { assert_eq!(m.buttons, vec!["OK"]); assert_eq!(m.focused_button, 0); assert!(m.show_backdrop); + assert!(m.blocks_input); + assert_eq!(m.result, ModalResult::Pending); + assert_eq!(m.preset, ModalButtons::Ok); } #[test] @@ -93,7 +218,48 @@ mod tests { assert_eq!(m.buttons.len(), 2); assert_eq!(m.buttons[0], "Cancel"); assert_eq!(m.buttons[1], "OK"); - assert_eq!(m.focused_button, 1); // OK focused by default + assert_eq!(m.focused_button, 1); + assert_eq!(m.preset, ModalButtons::OkCancel); + } + + #[test] + fn yes_no_dialog() { + let m = Modal::yes_no("Save?", "Save changes?"); + assert_eq!(m.buttons.len(), 2); + assert_eq!(m.buttons[0], "No"); + assert_eq!(m.buttons[1], "Yes"); + assert_eq!(m.focused_button, 1); + assert_eq!(m.preset, ModalButtons::YesNo); + } + + #[test] + fn yes_no_cancel_dialog() { + let m = Modal::yes_no_cancel("Save?", "Changes?"); + assert_eq!(m.buttons.len(), 3); + assert_eq!(m.buttons[0], "Cancel"); + assert_eq!(m.buttons[1], "No"); + assert_eq!(m.buttons[2], "Yes"); + assert_eq!(m.focused_button, 2); + assert_eq!(m.preset, ModalButtons::YesNoCancel); + } + + #[test] + fn custom_buttons() { + let m = Modal::custom( + "Pick", + "Choose one", + vec!["A".into(), "B".into(), "C".into()], + ); + assert_eq!(m.buttons.len(), 3); + assert_eq!(m.focused_button, 2); + assert_eq!(m.preset, ModalButtons::Custom); + } + + #[test] + fn custom_empty_buttons() { + let m = Modal::custom("T", "B", vec![]); + assert!(m.buttons.is_empty()); + assert_eq!(m.focused_button, 0); } #[test] @@ -101,7 +267,7 @@ mod tests { let mut m = Modal::confirm("T", "B"); assert_eq!(m.focused_button, 1); m.focus_next(); - assert_eq!(m.focused_button, 0); // wraps + assert_eq!(m.focused_button, 0); m.focus_next(); assert_eq!(m.focused_button, 1); } @@ -111,7 +277,7 @@ mod tests { let mut m = Modal::confirm("T", "B"); m.focused_button = 0; m.focus_prev(); - assert_eq!(m.focused_button, 1); // wraps + assert_eq!(m.focused_button, 1); } #[test] @@ -135,6 +301,130 @@ mod tests { assert_eq!(m.focused_label(), None); } + #[test] + fn activate_ok_dialog() { + let mut m = Modal::new("T", "B"); + let result = m.activate(); + assert_eq!(result, ModalResult::Confirmed); + assert!(!m.is_open()); + } + + #[test] + fn activate_confirm_ok() { + let mut m = Modal::confirm("T", "B"); + // focused_button == 1 (OK) + let result = m.activate(); + assert_eq!(result, ModalResult::Confirmed); + } + + #[test] + fn activate_confirm_cancel() { + let mut m = Modal::confirm("T", "B"); + m.focused_button = 0; // Cancel + let result = m.activate(); + assert_eq!(result, ModalResult::Cancelled); + } + + #[test] + fn activate_yes_no_yes() { + let mut m = Modal::yes_no("T", "B"); + let result = m.activate(); // focused on Yes (1) + assert_eq!(result, ModalResult::Confirmed); + } + + #[test] + fn activate_yes_no_no() { + let mut m = Modal::yes_no("T", "B"); + m.focused_button = 0; // No + let result = m.activate(); + assert_eq!(result, ModalResult::Cancelled); + } + + #[test] + fn activate_custom_button() { + let mut m = Modal::custom("T", "B", vec!["A".into(), "B".into()]); + m.focused_button = 0; + let result = m.activate(); + assert_eq!(result, ModalResult::Custom(0)); + } + + #[test] + fn dismiss_sets_cancelled() { + let mut m = Modal::confirm("T", "B"); + let result = m.dismiss(); + assert_eq!(result, ModalResult::Cancelled); + assert!(!m.is_open()); + } + + #[test] + fn is_open_while_pending() { + let m = Modal::new("T", "B"); + assert!(m.is_open()); + } + + #[test] + fn should_block_input_default() { + let m = Modal::new("T", "B"); + assert!(m.should_block_input()); + } + + #[test] + fn should_block_input_after_dismiss() { + let mut m = Modal::new("T", "B"); + m.dismiss(); + assert!(!m.should_block_input()); + } + + #[test] + fn should_block_input_disabled() { + let mut m = Modal::new("T", "B"); + m.blocks_input = false; + assert!(!m.should_block_input()); + } + + #[test] + fn modal_result_debug() { + for r in [ + ModalResult::Pending, + ModalResult::Confirmed, + ModalResult::Cancelled, + ModalResult::Custom(0), + ] { + let _ = format!("{r:?}"); + } + } + + #[test] + fn modal_buttons_debug() { + for b in [ + ModalButtons::Ok, + ModalButtons::OkCancel, + ModalButtons::YesNo, + ModalButtons::YesNoCancel, + ModalButtons::Custom, + ] { + let _ = format!("{b:?}"); + } + } + + #[test] + fn yes_no_cancel_activate_each() { + // Cancel (index 0) + let mut m = Modal::yes_no_cancel("T", "B"); + m.focused_button = 0; + assert_eq!(m.activate(), ModalResult::Cancelled); + + // No (index 1) + let mut m = Modal::yes_no_cancel("T", "B"); + m.focused_button = 1; + assert_eq!(m.activate(), ModalResult::Custom(1)); + + // Yes (index 2) + let mut m = Modal::yes_no_cancel("T", "B"); + m.focused_button = 2; + assert_eq!(m.activate(), ModalResult::Confirmed); + } + // -- Draw / measure tests using MockBackend -- use crate::context::DrawContext; @@ -149,7 +439,6 @@ mod tests { let ctx = DrawContext::new(&mut backend, &theme); let m = Modal::new("T", "B"); let (w, h) = m.measure(&ctx, 480, 272); - // Modal returns full viewport size (backdrop fills it). assert_eq!(w, 480); assert_eq!(h, 272); } @@ -161,7 +450,7 @@ mod tests { { let mut ctx = DrawContext::new(&mut backend, &theme); let m = Modal::new("Warning", "Something happened"); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } assert!(backend.has_text("Warning")); assert!(backend.has_text("Something happened")); @@ -173,8 +462,8 @@ mod tests { let mut backend = MockBackend::new(); { let mut ctx = DrawContext::new(&mut backend, &theme); - let m = Modal::confirm("Delete?", "Are you sure?"); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + let m = Modal::confirm("Delete?", "Sure?"); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } assert!(backend.has_text("Cancel")); assert!(backend.has_text("OK")); @@ -187,9 +476,8 @@ mod tests { { let mut ctx = DrawContext::new(&mut backend, &theme); let m = Modal::new("T", "B"); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } - // Backdrop + modal panel + button bg = multiple fill_rects assert!(backend.fill_rect_count() > 2); } @@ -201,15 +489,14 @@ mod tests { { let mut ctx = DrawContext::new(&mut backend_with, &theme); let m = Modal::new("T", "B"); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } { let mut ctx = DrawContext::new(&mut backend_without, &theme); let mut m = Modal::new("T", "B"); m.show_backdrop = false; - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } - // Without backdrop, fewer fill_rect calls. assert!(backend_without.fill_rect_count() < backend_with.fill_rect_count()); } @@ -221,7 +508,7 @@ mod tests { let mut ctx = DrawContext::new(&mut backend, &theme); let mut m = Modal::new("T", "B"); m.buttons.clear(); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } assert!(backend.has_text("T")); } @@ -237,8 +524,35 @@ mod tests { let mut backend = MockBackend::new(); let mut ctx = DrawContext::new(&mut backend, &theme); let m = Modal::confirm("Test", "Body"); - m.draw(&mut ctx, 0, 0, 480, 272).unwrap(); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); + } + } + + #[test] + fn draw_yes_no_shows_buttons() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let m = Modal::yes_no("Save?", "Save changes?"); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); + } + assert!(backend.has_text("No")); + assert!(backend.has_text("Yes")); + } + + #[test] + fn draw_custom_buttons_shows_all() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let m = Modal::custom("Pick", "Choose", vec!["A".into(), "B".into(), "C".into()]); + m.draw(&mut ctx, 0, 0, 480, 272).ok(); } + assert!(backend.has_text("A")); + assert!(backend.has_text("B")); + assert!(backend.has_text("C")); } } diff --git a/crates/oasis-ui/src/scroll_view.rs b/crates/oasis-ui/src/scroll_view.rs index 1d3d503..15b4570 100644 --- a/crates/oasis-ui/src/scroll_view.rs +++ b/crates/oasis-ui/src/scroll_view.rs @@ -76,14 +76,14 @@ impl ScrollView { ctx.theme.scrollbar_track, )?; - // Thumb. + // Thumb: ensure it fits within the track. let ratio = self.viewport_height as f32 / self.content_height as f32; - let thumb_h = ((h as f32 * ratio).max(bar_w as f32)) as u32; - let scroll_range = self.content_height - self.viewport_height; - let max_thumb_y = (h - thumb_h) as i32; + let thumb_h = ((h as f32 * ratio).max(bar_w as f32) as u32).min(h); + let scroll_range = self.content_height.saturating_sub(self.viewport_height); + let track_remaining = h.saturating_sub(thumb_h); let thumb_y = if scroll_range > 0 { - let raw = ((h - thumb_h) as f32 * self.scroll_y as f32 / scroll_range as f32) as i32; - raw.clamp(0, max_thumb_y) + let raw = (track_remaining as f32 * self.scroll_y as f32 / scroll_range as f32) as i32; + raw.clamp(0, track_remaining as i32) } else { 0 }; diff --git a/crates/oasis-ui/src/spinner.rs b/crates/oasis-ui/src/spinner.rs new file mode 100644 index 0000000..20a9839 --- /dev/null +++ b/crates/oasis-ui/src/spinner.rs @@ -0,0 +1,505 @@ +//! Spinner and loading indicator widgets. + +use crate::context::DrawContext; +use crate::layout; +use crate::widget::Widget; +use oasis_types::error::Result; + +/// Character frames for the text-based spinner animation. +const SPINNER_FRAMES: &[char] = &['|', '/', '-', '\\']; + +/// Visual style of the spinner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpinnerStyle { + /// Rotating character sequence (|, /, -, \). + Text, + /// Small dots that pulse in sequence. + Dots, + /// Indeterminate sliding bar. + Bar, +} + +/// A loading spinner widget. +/// +/// Cycles through animation frames at a configurable speed. When the +/// theme has `reduced_motion` enabled, the spinner displays a static +/// indicator instead of animating. +pub struct Spinner { + /// Visual style variant. + pub style: SpinnerStyle, + /// Accumulated time in milliseconds (drives frame selection). + pub elapsed_ms: u32, + /// Time per frame in milliseconds. + pub frame_duration_ms: u32, + /// Optional label drawn next to the spinner. + pub label: Option, +} + +impl Spinner { + /// Create a new spinner with default settings. + pub fn new() -> Self { + Self { + style: SpinnerStyle::Text, + elapsed_ms: 0, + frame_duration_ms: 150, + label: None, + } + } + + /// Create a spinner with a label. + pub fn with_label(label: impl Into) -> Self { + Self { + label: Some(label.into()), + ..Self::new() + } + } + + /// Advance the spinner animation by `dt_ms` milliseconds. + pub fn tick(&mut self, dt_ms: u32) { + self.elapsed_ms = self.elapsed_ms.wrapping_add(dt_ms); + } + + /// Current frame index based on elapsed time. + pub fn frame_index(&self) -> usize { + let total_frames = self.frame_count(); + if total_frames == 0 { + return 0; + } + let effective_duration = self.frame_duration_ms.max(1); + ((self.elapsed_ms / effective_duration) as usize) % total_frames + } + + /// Total number of frames for the current style. + fn frame_count(&self) -> usize { + match self.style { + SpinnerStyle::Text => SPINNER_FRAMES.len(), + SpinnerStyle::Dots => 3, + SpinnerStyle::Bar => 8, + } + } + + /// Current display character for `Text` style. + pub fn current_char(&self) -> char { + SPINNER_FRAMES[self.frame_index() % SPINNER_FRAMES.len()] + } +} + +impl Default for Spinner { + fn default() -> Self { + Self::new() + } +} + +impl Widget for Spinner { + fn measure(&self, ctx: &DrawContext<'_>, available_w: u32, _available_h: u32) -> (u32, u32) { + let fs = ctx.theme.font_size_md; + let spinner_w = ctx.backend.measure_text("-", fs) + 4; + let text_h = ctx.backend.measure_text_height(fs); + let h = text_h + 4; + let label_w = if let Some(ref label) = self.label { + ctx.backend.measure_text(label, fs) + 6 + } else { + 0 + }; + let w = (spinner_w + label_w).min(available_w); + (w, h) + } + + fn draw(&self, ctx: &mut DrawContext<'_>, x: i32, y: i32, w: u32, h: u32) -> Result<()> { + let fs = ctx.theme.font_size_md; + let text_h = ctx.backend.measure_text_height(fs); + let ty = y + layout::center(h, text_h); + + match self.style { + SpinnerStyle::Text => { + let ch = if ctx.theme.reduced_motion { + '*' // Static indicator when motion is reduced. + } else { + self.current_char() + }; + let s = ch.to_string(); + ctx.backend.draw_text(&s, x + 2, ty, fs, ctx.theme.accent)?; + + if let Some(ref label) = self.label { + let char_w = ctx.backend.measure_text(&s, fs); + let lx = x + 2 + char_w as i32 + 6; + ctx.backend + .draw_text(label, lx, ty, fs, ctx.theme.text_secondary)?; + } + }, + SpinnerStyle::Dots => { + let dot_size = 4u32; + let gap = 3u32; + let frame = if ctx.theme.reduced_motion { + // Show all dots equally when motion reduced. + usize::MAX + } else { + self.frame_index() + }; + let dot_y = y + layout::center(h, dot_size); + for i in 0..3u32 { + let dot_x = x + 2 + (i * (dot_size + gap)) as i32; + let alpha = if ctx.theme.reduced_motion { + 180u8 + } else if i as usize == frame { + 255u8 + } else { + 80u8 + }; + let color = ctx.theme.accent.with_alpha(alpha); + ctx.backend + .fill_rect(dot_x, dot_y, dot_size, dot_size, color)?; + } + + if let Some(ref label) = self.label { + let dots_w = 3 * dot_size + 2 * gap + 4; + let lx = x + 2 + dots_w as i32 + 4; + ctx.backend + .draw_text(label, lx, ty, fs, ctx.theme.text_secondary)?; + } + }, + SpinnerStyle::Bar => { + let bar_h = 4u32.min(h); + let bar_y = y + layout::center(h, bar_h); + let label_w = if let Some(ref label) = self.label { + let lw = ctx.backend.measure_text(label, fs); + let lx = x + 2; + ctx.backend + .draw_text(label, lx, ty, fs, ctx.theme.text_secondary)?; + lw + 6 + } else { + 0 + }; + let bar_w = w.saturating_sub(4 + label_w); + let bar_x = x + 2 + label_w as i32; + + // Track. + ctx.backend + .fill_rect(bar_x, bar_y, bar_w, bar_h, ctx.theme.scrollbar_track)?; + + if ctx.theme.reduced_motion { + // Static 25% fill in the center. + let fill_w = bar_w / 4; + let fill_x = bar_x + ((bar_w - fill_w) / 2) as i32; + ctx.backend + .fill_rect(fill_x, bar_y, fill_w, bar_h, ctx.theme.accent)?; + } else { + // Sliding indicator. + let fill_w = bar_w / 4; + let frame = self.frame_index(); + let travel = bar_w.saturating_sub(fill_w); + let total_frames = self.frame_count(); + let pos = if total_frames > 0 { + (travel * frame as u32) / total_frames as u32 + } else { + 0 + }; + ctx.backend.fill_rect( + bar_x + pos as i32, + bar_y, + fill_w, + bar_h, + ctx.theme.accent, + )?; + } + }, + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_defaults() { + let s = Spinner::new(); + assert_eq!(s.style, SpinnerStyle::Text); + assert_eq!(s.elapsed_ms, 0); + assert_eq!(s.frame_duration_ms, 150); + assert!(s.label.is_none()); + } + + #[test] + fn default_same_as_new() { + let s = Spinner::default(); + assert_eq!(s.style, SpinnerStyle::Text); + assert_eq!(s.elapsed_ms, 0); + } + + #[test] + fn with_label_sets_label() { + let s = Spinner::with_label("Loading..."); + assert_eq!(s.label.as_deref(), Some("Loading...")); + } + + #[test] + fn tick_advances_time() { + let mut s = Spinner::new(); + s.tick(100); + assert_eq!(s.elapsed_ms, 100); + s.tick(50); + assert_eq!(s.elapsed_ms, 150); + } + + #[test] + fn frame_index_zero_initially() { + let s = Spinner::new(); + assert_eq!(s.frame_index(), 0); + } + + #[test] + fn frame_index_advances_with_time() { + let mut s = Spinner::new(); + // frame_duration_ms = 150, 4 frames for Text style. + s.tick(150); + assert_eq!(s.frame_index(), 1); + s.tick(150); + assert_eq!(s.frame_index(), 2); + s.tick(150); + assert_eq!(s.frame_index(), 3); + } + + #[test] + fn frame_index_wraps_around() { + let mut s = Spinner::new(); + // 4 frames * 150ms = 600ms for a full cycle. + s.tick(600); + assert_eq!(s.frame_index(), 0); + s.tick(150); + assert_eq!(s.frame_index(), 1); + } + + #[test] + fn current_char_cycles() { + let mut s = Spinner::new(); + assert_eq!(s.current_char(), '|'); + s.tick(150); + assert_eq!(s.current_char(), '/'); + s.tick(150); + assert_eq!(s.current_char(), '-'); + s.tick(150); + assert_eq!(s.current_char(), '\\'); + s.tick(150); + assert_eq!(s.current_char(), '|'); + } + + #[test] + fn dots_style_frame_count() { + let mut s = Spinner::new(); + s.style = SpinnerStyle::Dots; + // 3 frames for dots. + assert_eq!(s.frame_index(), 0); + s.tick(150); + assert_eq!(s.frame_index(), 1); + s.tick(150); + assert_eq!(s.frame_index(), 2); + s.tick(150); + assert_eq!(s.frame_index(), 0); + } + + #[test] + fn bar_style_frame_count() { + let mut s = Spinner::new(); + s.style = SpinnerStyle::Bar; + // 8 frames for bar. + s.tick(150 * 7); + assert_eq!(s.frame_index(), 7); + s.tick(150); + assert_eq!(s.frame_index(), 0); + } + + #[test] + fn zero_frame_duration_no_panic() { + let mut s = Spinner::new(); + s.frame_duration_ms = 0; + // Should not panic or divide by zero. + assert_eq!(s.frame_index(), 0); + s.tick(100); + let _ = s.frame_index(); + } + + #[test] + fn style_variants_distinct() { + assert_ne!(SpinnerStyle::Text, SpinnerStyle::Dots); + assert_ne!(SpinnerStyle::Dots, SpinnerStyle::Bar); + assert_ne!(SpinnerStyle::Text, SpinnerStyle::Bar); + } + + #[test] + fn tick_wrapping() { + let mut s = Spinner::new(); + s.elapsed_ms = u32::MAX - 10; + s.tick(20); // Should wrap around. + assert_eq!(s.elapsed_ms, 9); + } + + // -- Draw / measure tests using MockBackend -- + + use crate::context::DrawContext; + use crate::test_utils::MockBackend; + use crate::theme::Theme; + use crate::widget::Widget; + + #[test] + fn measure_returns_nonzero() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let s = Spinner::new(); + let (w, h) = s.measure(&ctx, 200, 100); + assert!(w > 0); + assert!(h > 0); + } + + #[test] + fn measure_with_label_wider() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let no_label = Spinner::new(); + let with_label = Spinner::with_label("Loading..."); + let (w1, _) = no_label.measure(&ctx, 200, 100); + let (w2, _) = with_label.measure(&ctx, 200, 100); + assert!(w2 > w1); + } + + #[test] + fn draw_text_style_no_panic() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let s = Spinner::new(); + s.draw(&mut ctx, 0, 0, 40, 20).unwrap(); + } + assert!(backend.draw_text_count() > 0); + } + + #[test] + fn draw_text_style_with_label() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let s = Spinner::with_label("Wait"); + s.draw(&mut ctx, 0, 0, 100, 20).unwrap(); + } + assert!(backend.has_text("Wait")); + } + + #[test] + fn draw_dots_style_no_panic() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::new(); + s.style = SpinnerStyle::Dots; + s.draw(&mut ctx, 0, 0, 60, 20).unwrap(); + } + // Dots emit fill_rect calls. + assert!(backend.fill_rect_count() >= 3); + } + + #[test] + fn draw_dots_with_label() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::with_label("Connecting"); + s.style = SpinnerStyle::Dots; + s.draw(&mut ctx, 0, 0, 150, 20).unwrap(); + } + assert!(backend.has_text("Connecting")); + } + + #[test] + fn draw_bar_style_no_panic() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::new(); + s.style = SpinnerStyle::Bar; + s.draw(&mut ctx, 0, 0, 100, 20).unwrap(); + } + // Bar draws at least track + fill. + assert!(backend.fill_rect_count() >= 2); + } + + #[test] + fn draw_reduced_motion_text() { + let mut theme = Theme::dark(); + theme.reduced_motion = true; + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let s = Spinner::new(); + s.draw(&mut ctx, 0, 0, 40, 20).unwrap(); + } + // Should draw '*' character instead of animated frame. + assert!(backend.has_text("*")); + } + + #[test] + fn draw_reduced_motion_dots() { + let mut theme = Theme::dark(); + theme.reduced_motion = true; + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::new(); + s.style = SpinnerStyle::Dots; + s.draw(&mut ctx, 0, 0, 60, 20).unwrap(); + } + // Should still draw 3 dots (all at equal alpha). + assert!(backend.fill_rect_count() >= 3); + } + + #[test] + fn draw_reduced_motion_bar() { + let mut theme = Theme::dark(); + theme.reduced_motion = true; + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::new(); + s.style = SpinnerStyle::Bar; + s.draw(&mut ctx, 0, 0, 100, 20).unwrap(); + } + // Should draw track + static fill. + assert!(backend.fill_rect_count() >= 2); + } + + #[test] + fn draw_bar_at_different_frames() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::new(); + s.style = SpinnerStyle::Bar; + s.tick(300); // Advance 2 frames. + s.draw(&mut ctx, 0, 0, 100, 20).unwrap(); + } + assert!(backend.fill_rect_count() >= 2); + } + + #[test] + fn draw_bar_with_label() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut s = Spinner::with_label("Loading"); + s.style = SpinnerStyle::Bar; + s.draw(&mut ctx, 0, 0, 150, 20).unwrap(); + } + assert!(backend.has_text("Loading")); + // Track + fill = at least 2 fill_rect calls. + assert!(backend.fill_rect_count() >= 2); + } +} diff --git a/crates/oasis-ui/src/theme.rs b/crates/oasis-ui/src/theme.rs index 53bb0c4..75b516a 100644 --- a/crates/oasis-ui/src/theme.rs +++ b/crates/oasis-ui/src/theme.rs @@ -114,9 +114,29 @@ pub struct Theme { pub shadow_modal: Shadow, /// Tooltip elevation shadow. pub shadow_tooltip: Shadow, + + /// Whether to reduce or skip animations for accessibility. + /// + /// When `true`, animated widgets should snap to their target state + /// instead of interpolating over time. + pub reduced_motion: bool, + + /// Global font scale multiplier (default 1.0). + /// + /// Applied on top of the base font sizes. A value of 1.5 would + /// make all text 50% larger. Clamped to `0.5..=3.0`. + pub font_scale: f32, } impl Theme { + /// Return a font size scaled by `font_scale`. + /// + /// The result is clamped to at least 1 and at most `u16::MAX`. + pub fn scaled_font_size(&self, base: u16) -> u16 { + let scaled = (base as f32 * self.font_scale.clamp(0.5, 3.0)).round(); + (scaled as u32).clamp(1, u16::MAX as u32) as u16 + } + /// Dark theme matching the OASIS cyberpunk aesthetic. pub fn dark() -> Self { Self { @@ -179,6 +199,9 @@ impl Theme { shadow_dropdown: Shadow::elevation(2), shadow_modal: Shadow::elevation(3), shadow_tooltip: Shadow::elevation(2), + + reduced_motion: false, + font_scale: 1.0, } } @@ -244,6 +267,9 @@ impl Theme { shadow_dropdown: Shadow::elevation(2), shadow_modal: Shadow::elevation(3), shadow_tooltip: Shadow::elevation(2), + + reduced_motion: false, + font_scale: 1.0, } } @@ -326,6 +352,89 @@ impl Theme { shadow_dropdown: Shadow::elevation(0), shadow_modal: Shadow::elevation(0), shadow_tooltip: Shadow::elevation(0), + + reduced_motion: false, + font_scale: 1.0, + } + } + + /// Color-blind friendly theme optimized for deuteranopia. + /// + /// Replaces the standard success/warning/error color scheme with + /// alternatives that are distinguishable by people with red-green + /// color blindness: + /// - Success: blue (instead of green) + /// - Warning: orange (instead of amber) + /// - Error: magenta (instead of red) + pub fn colorblind() -> Self { + Self { + background: Color::rgb(18, 18, 24), + surface: Color::rgb(30, 30, 40), + surface_variant: Color::rgb(40, 40, 55), + overlay: Color::rgba(0, 0, 0, 180), + + text_primary: Color::rgb(230, 230, 240), + text_secondary: Color::rgb(160, 160, 180), + text_disabled: Color::rgb(100, 100, 120), + text_on_accent: Color::rgb(255, 255, 255), + + accent: Color::rgb(80, 160, 255), + accent_hover: Color::rgb(110, 180, 255), + accent_pressed: Color::rgb(60, 130, 220), + accent_subtle: Color::rgba(80, 160, 255, 30), + + // Deuteranopia-safe status colors: + // Blue for success (clearly distinct from orange/magenta). + success: Color::rgb(60, 140, 255), + // Orange for warning (high luminance, distinct hue). + warning: Color::rgb(255, 160, 40), + // Magenta for error (distinct from blue and orange). + error: Color::rgb(220, 60, 220), + // Cyan for info. + info: Color::rgb(0, 200, 220), + + border: Color::rgb(60, 60, 80), + border_subtle: Color::rgb(45, 45, 60), + border_strong: Color::rgb(80, 160, 255), + + button_bg: Color::rgb(50, 50, 70), + button_bg_hover: Color::rgb(65, 65, 90), + button_bg_pressed: Color::rgb(40, 40, 55), + button_bg_disabled: Color::rgb(35, 35, 45), + input_bg: Color::rgb(25, 25, 35), + input_border: Color::rgb(60, 60, 80), + input_border_focus: Color::rgb(80, 160, 255), + scrollbar_track: Color::rgba(255, 255, 255, 10), + scrollbar_thumb: Color::rgba(255, 255, 255, 40), + scrollbar_thumb_hover: Color::rgba(255, 255, 255, 80), + tooltip_bg: Color::rgb(50, 50, 65), + tooltip_text: Color::rgb(220, 220, 230), + + font_size_xs: 8, + font_size_sm: 8, + font_size_md: 8, + font_size_lg: 16, + font_size_xl: 16, + font_size_xxl: 24, + + spacing_xs: 2, + spacing_sm: 4, + spacing_md: 8, + spacing_lg: 12, + spacing_xl: 16, + + border_radius_sm: 2, + border_radius_md: 4, + border_radius_lg: 8, + border_radius_xl: 12, + + shadow_card: Shadow::elevation(1), + shadow_dropdown: Shadow::elevation(2), + shadow_modal: Shadow::elevation(3), + shadow_tooltip: Shadow::elevation(2), + + reduced_motion: false, + font_scale: 1.0, } } } @@ -437,6 +546,7 @@ mod tests { Theme::light(), Theme::classic(), Theme::high_contrast(), + Theme::colorblind(), ] { assert_eq!(theme.font_size_xs, 8); assert_eq!(theme.font_size_md, 8); @@ -450,4 +560,132 @@ mod tests { assert!(!t.shadow_card.layers.is_empty()); assert!(!t.shadow_modal.layers.is_empty()); } + + // -- Reduced-motion tests -- + + #[test] + fn default_reduced_motion_is_false() { + assert!(!Theme::dark().reduced_motion); + assert!(!Theme::light().reduced_motion); + assert!(!Theme::classic().reduced_motion); + assert!(!Theme::high_contrast().reduced_motion); + assert!(!Theme::colorblind().reduced_motion); + } + + #[test] + fn reduced_motion_can_be_enabled() { + let mut t = Theme::dark(); + t.reduced_motion = true; + assert!(t.reduced_motion); + } + + // -- Font scale tests -- + + #[test] + fn default_font_scale_is_one() { + assert!((Theme::dark().font_scale - 1.0).abs() < f32::EPSILON); + assert!((Theme::colorblind().font_scale - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn scaled_font_size_identity() { + let t = Theme::dark(); + assert_eq!(t.scaled_font_size(8), 8); + assert_eq!(t.scaled_font_size(16), 16); + } + + #[test] + fn scaled_font_size_double() { + let mut t = Theme::dark(); + t.font_scale = 2.0; + assert_eq!(t.scaled_font_size(8), 16); + assert_eq!(t.scaled_font_size(16), 32); + } + + #[test] + fn scaled_font_size_fractional() { + let mut t = Theme::dark(); + t.font_scale = 1.5; + assert_eq!(t.scaled_font_size(8), 12); + assert_eq!(t.scaled_font_size(16), 24); + } + + #[test] + fn scaled_font_size_clamped_low() { + let mut t = Theme::dark(); + t.font_scale = 0.01; // Below minimum 0.5 + // 8 * 0.5 = 4 (clamped to 0.5) + assert_eq!(t.scaled_font_size(8), 4); + } + + #[test] + fn scaled_font_size_clamped_high() { + let mut t = Theme::dark(); + t.font_scale = 10.0; // Above maximum 3.0 + // 8 * 3.0 = 24 (clamped to 3.0) + assert_eq!(t.scaled_font_size(8), 24); + } + + #[test] + fn scaled_font_size_minimum_one() { + let mut t = Theme::dark(); + t.font_scale = 0.5; + // Even with small base, result is at least 1. + assert!(t.scaled_font_size(1) >= 1); + } + + // -- Color-blind theme tests -- + + #[test] + fn colorblind_has_distinct_status_colors() { + let t = Theme::colorblind(); + // All four status colors should be different. + assert_ne!(t.success, t.warning); + assert_ne!(t.success, t.error); + assert_ne!(t.warning, t.error); + assert_ne!(t.success, t.info); + } + + #[test] + fn colorblind_success_is_blue() { + let t = Theme::colorblind(); + // Success should be blue-dominant (high blue, low-ish red). + assert!(t.success.b > t.success.r); + assert!(t.success.b > 200); + } + + #[test] + fn colorblind_error_is_magenta() { + let t = Theme::colorblind(); + // Error should be magenta (high red + high blue, low green). + assert!(t.error.r > 200); + assert!(t.error.b > 200); + assert!(t.error.g < 100); + } + + #[test] + fn colorblind_warning_is_orange() { + let t = Theme::colorblind(); + // Warning should be orange (high red, medium green, low blue). + assert!(t.warning.r > 200); + assert!(t.warning.g > 100 && t.warning.g < 200); + assert!(t.warning.b < 100); + } + + #[test] + fn colorblind_shares_dark_base_colors() { + let dark = Theme::dark(); + let cb = Theme::colorblind(); + assert_eq!(dark.background, cb.background); + assert_eq!(dark.surface, cb.surface); + assert_eq!(dark.text_primary, cb.text_primary); + } + + #[test] + fn colorblind_has_dark_background() { + let t = Theme::colorblind(); + assert!(t.background.r < 50); + assert!(t.background.g < 50); + assert!(t.background.b < 50); + } } diff --git a/crates/oasis-ui/src/toggle.rs b/crates/oasis-ui/src/toggle.rs index 948fb7d..83de3a4 100644 --- a/crates/oasis-ui/src/toggle.rs +++ b/crates/oasis-ui/src/toggle.rs @@ -32,6 +32,18 @@ impl Toggle { self.progress = (self.progress - speed).max(0.0); } } + + /// Animate toward the current `on` state, respecting reduced-motion. + /// + /// When `reduced_motion` is `true`, the toggle snaps instantly to + /// its target state instead of interpolating. + pub fn animate_reduced(&mut self, dt_ms: u32, reduced_motion: bool) { + if reduced_motion { + self.progress = if self.on { 1.0 } else { 0.0 }; + } else { + self.animate(dt_ms); + } + } } #[cfg(test)] @@ -107,6 +119,34 @@ mod tests { assert!((t.progress - before).abs() < f32::EPSILON); } + // -- Reduced-motion tests -- + + #[test] + fn animate_reduced_snaps_to_on() { + let mut t = Toggle::new(false); + t.on = true; + t.animate_reduced(1, true); + assert!((t.progress - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn animate_reduced_snaps_to_off() { + let mut t = Toggle::new(true); + t.on = false; + t.animate_reduced(1, true); + assert!((t.progress - 0.0).abs() < f32::EPSILON); + } + + #[test] + fn animate_reduced_false_is_normal() { + let mut t = Toggle::new(false); + t.on = true; + t.animate_reduced(75, false); + // Should behave like normal animate: partial progress. + assert!(t.progress > 0.0); + assert!(t.progress < 1.0); + } + // -- Draw / measure tests using MockBackend -- use crate::context::DrawContext; diff --git a/crates/oasis-ui/src/tooltip.rs b/crates/oasis-ui/src/tooltip.rs new file mode 100644 index 0000000..d647fd4 --- /dev/null +++ b/crates/oasis-ui/src/tooltip.rs @@ -0,0 +1,657 @@ +//! Tooltip widget: hover-activated text overlay. +//! +//! Tooltips appear near a target widget after a configurable hover +//! delay. They position themselves to stay within the viewport, +//! preferring to appear below the target. + +use crate::context::DrawContext; +use crate::layout; +use crate::widget::Widget; +use oasis_types::error::Result; + +/// Preferred position of the tooltip relative to its target. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TooltipPosition { + /// Display above the target. + Above, + /// Display below the target. + Below, + /// Display to the left of the target. + Left, + /// Display to the right of the target. + Right, +} + +/// Current visibility state of the tooltip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TooltipState { + /// Tooltip is hidden. + Hidden, + /// Hover has started; waiting for the delay to elapse. + Waiting, + /// Tooltip is visible. + Visible, +} + +/// A tooltip overlay with configurable delay and positioning. +pub struct Tooltip { + /// Tooltip text content. + pub text: String, + /// Preferred position relative to the target. + pub position: TooltipPosition, + /// Current visibility state. + pub state: TooltipState, + /// Delay in milliseconds before showing after hover starts. + pub delay_ms: u32, + /// Accumulated hover time in milliseconds. + pub elapsed_ms: u32, + /// Horizontal padding inside the tooltip. + pub pad_h: u32, + /// Vertical padding inside the tooltip. + pub pad_v: u32, + /// Gap between the tooltip and the target. + pub gap: u32, +} + +impl Tooltip { + /// Create a new tooltip with default delay and positioning. + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + position: TooltipPosition::Below, + delay_ms: 500, + state: TooltipState::Hidden, + elapsed_ms: 0, + pad_h: 6, + pad_v: 3, + gap: 4, + } + } + + /// Set the preferred position. + pub fn with_position(mut self, pos: TooltipPosition) -> Self { + self.position = pos; + self + } + + /// Set the hover delay in milliseconds. + pub fn with_delay(mut self, ms: u32) -> Self { + self.delay_ms = ms; + self + } + + /// Notify that the cursor has entered the target area. + pub fn on_hover_start(&mut self) { + if self.state == TooltipState::Hidden { + self.state = TooltipState::Waiting; + self.elapsed_ms = 0; + } + } + + /// Notify that the cursor has left the target area. + pub fn on_hover_end(&mut self) { + self.state = TooltipState::Hidden; + self.elapsed_ms = 0; + } + + /// Advance the tooltip timer by `dt_ms` milliseconds. + /// + /// If the accumulated time exceeds the delay, the tooltip + /// becomes visible. + pub fn tick(&mut self, dt_ms: u32) { + if self.state == TooltipState::Waiting { + self.elapsed_ms = self.elapsed_ms.saturating_add(dt_ms); + if self.elapsed_ms >= self.delay_ms { + self.state = TooltipState::Visible; + } + } + } + + /// Whether the tooltip should be drawn. + pub fn is_visible(&self) -> bool { + self.state == TooltipState::Visible + } + + /// Compute the tooltip rectangle given an anchor and + /// viewport bounds. The tooltip is clamped to stay within + /// `(0, 0, vw, vh)`. + pub fn compute_rect( + &self, + ctx: &DrawContext<'_>, + anchor: &TooltipAnchor, + ) -> (i32, i32, u32, u32) { + let target_x = anchor.target_x; + let target_y = anchor.target_y; + let target_w = anchor.target_w; + let target_h = anchor.target_h; + let viewport_w = anchor.viewport_w; + let viewport_h = anchor.viewport_h; + let fs = ctx.theme.font_size_xs; + let text_w = ctx.backend.measure_text(&self.text, fs); + let text_h = ctx.backend.measure_text_height(fs); + let tw = text_w + self.pad_h * 2; + let th = text_h + self.pad_v * 2; + let gap = self.gap as i32; + + // Compute ideal position based on preference. + let (mut x, mut y) = match self.position { + TooltipPosition::Below => { + let x = target_x + layout::center(target_w, tw); + let y = target_y + target_h as i32 + gap; + (x, y) + }, + TooltipPosition::Above => { + let x = target_x + layout::center(target_w, tw); + let y = target_y - th as i32 - gap; + (x, y) + }, + TooltipPosition::Right => { + let x = target_x + target_w as i32 + gap; + let y = target_y + layout::center(target_h, th); + (x, y) + }, + TooltipPosition::Left => { + let x = target_x - tw as i32 - gap; + let y = target_y + layout::center(target_h, th); + (x, y) + }, + }; + + // Clamp to viewport bounds. + x = x.clamp(0, (viewport_w as i32 - tw as i32).max(0)); + y = y.clamp(0, (viewport_h as i32 - th as i32).max(0)); + + (x, y, tw, th) + } + + /// Draw the tooltip at the computed position. + /// + /// This is a convenience method that computes the rect and + /// draws in one call. + pub fn draw_at(&self, ctx: &mut DrawContext<'_>, anchor: &TooltipAnchor) -> Result<()> { + if !self.is_visible() || self.text.is_empty() { + return Ok(()); + } + let (tx, ty, tw, th) = self.compute_rect(ctx, anchor); + self.draw(ctx, tx, ty, tw, th) + } +} + +/// Describes the target widget position and viewport bounds +/// for tooltip placement calculations. +#[derive(Debug, Clone, Copy)] +pub struct TooltipAnchor { + /// X position of the target widget. + pub target_x: i32, + /// Y position of the target widget. + pub target_y: i32, + /// Width of the target widget. + pub target_w: u32, + /// Height of the target widget. + pub target_h: u32, + /// Viewport width for edge clamping. + pub viewport_w: u32, + /// Viewport height for edge clamping. + pub viewport_h: u32, +} + +impl TooltipAnchor { + /// Create a new tooltip anchor. + pub fn new( + target_x: i32, + target_y: i32, + target_w: u32, + target_h: u32, + viewport_w: u32, + viewport_h: u32, + ) -> Self { + Self { + target_x, + target_y, + target_w, + target_h, + viewport_w, + viewport_h, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Helper to create a `TooltipAnchor` concisely. + fn anchor(tx: i32, ty: i32, tw: u32, th: u32, vw: u32, vh: u32) -> TooltipAnchor { + TooltipAnchor::new(tx, ty, tw, th, vw, vh) + } + + // -- State machine tests -- + + #[test] + fn new_defaults() { + let t = Tooltip::new("Help text"); + assert_eq!(t.text, "Help text"); + assert_eq!(t.position, TooltipPosition::Below); + assert_eq!(t.state, TooltipState::Hidden); + assert_eq!(t.delay_ms, 500); + assert_eq!(t.elapsed_ms, 0); + assert_eq!(t.pad_h, 6); + assert_eq!(t.pad_v, 3); + assert_eq!(t.gap, 4); + } + + #[test] + fn with_position() { + let t = Tooltip::new("Tip").with_position(TooltipPosition::Above); + assert_eq!(t.position, TooltipPosition::Above); + } + + #[test] + fn with_delay() { + let t = Tooltip::new("Tip").with_delay(250); + assert_eq!(t.delay_ms, 250); + } + + #[test] + fn hover_start_transitions_to_waiting() { + let mut t = Tooltip::new("Tip"); + t.on_hover_start(); + assert_eq!(t.state, TooltipState::Waiting); + assert_eq!(t.elapsed_ms, 0); + } + + #[test] + fn hover_start_while_waiting_is_idempotent() { + let mut t = Tooltip::new("Tip"); + t.on_hover_start(); + t.tick(100); + t.on_hover_start(); // should not reset elapsed + assert_eq!(t.elapsed_ms, 100); + } + + #[test] + fn hover_end_resets_to_hidden() { + let mut t = Tooltip::new("Tip"); + t.on_hover_start(); + t.tick(600); + assert_eq!(t.state, TooltipState::Visible); + t.on_hover_end(); + assert_eq!(t.state, TooltipState::Hidden); + assert_eq!(t.elapsed_ms, 0); + } + + #[test] + fn tick_accumulates_time() { + let mut t = Tooltip::new("Tip").with_delay(100); + t.on_hover_start(); + t.tick(40); + assert_eq!(t.state, TooltipState::Waiting); + assert_eq!(t.elapsed_ms, 40); + t.tick(40); + assert_eq!(t.state, TooltipState::Waiting); + assert_eq!(t.elapsed_ms, 80); + t.tick(40); + assert_eq!(t.state, TooltipState::Visible); + assert_eq!(t.elapsed_ms, 120); + } + + #[test] + fn tick_does_nothing_when_hidden() { + let mut t = Tooltip::new("Tip"); + t.tick(1000); + assert_eq!(t.state, TooltipState::Hidden); + assert_eq!(t.elapsed_ms, 0); + } + + #[test] + fn tick_does_nothing_when_visible() { + let mut t = Tooltip::new("Tip").with_delay(50); + t.on_hover_start(); + t.tick(100); + assert_eq!(t.state, TooltipState::Visible); + let before = t.elapsed_ms; + t.tick(100); + assert_eq!(t.elapsed_ms, before); + } + + #[test] + fn is_visible_states() { + let mut t = Tooltip::new("Tip"); + assert!(!t.is_visible()); + t.on_hover_start(); + assert!(!t.is_visible()); + t.tick(600); + assert!(t.is_visible()); + t.on_hover_end(); + assert!(!t.is_visible()); + } + + #[test] + fn zero_delay_shows_immediately() { + let mut t = Tooltip::new("Instant").with_delay(0); + t.on_hover_start(); + t.tick(0); + assert_eq!(t.state, TooltipState::Visible); + } + + #[test] + fn position_variants_debug() { + for pos in [ + TooltipPosition::Above, + TooltipPosition::Below, + TooltipPosition::Left, + TooltipPosition::Right, + ] { + let _ = format!("{pos:?}"); + } + } + + #[test] + fn state_variants_debug() { + for state in [ + TooltipState::Hidden, + TooltipState::Waiting, + TooltipState::Visible, + ] { + let _ = format!("{state:?}"); + } + } + + #[test] + fn from_string_type() { + let t = Tooltip::new(String::from("dynamic")); + assert_eq!(t.text, "dynamic"); + } + + #[test] + fn elapsed_saturates() { + let mut t = Tooltip::new("Tip").with_delay(u32::MAX); + t.on_hover_start(); + t.tick(u32::MAX); + // Should not overflow. + assert_eq!(t.elapsed_ms, u32::MAX); + } + + // -- Positioning and drawing tests using MockBackend -- + + use crate::context::DrawContext; + use crate::test_utils::MockBackend; + use crate::theme::Theme; + use crate::widget::Widget; + + #[test] + fn compute_rect_below() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Test").with_position(TooltipPosition::Below); + let (x, y, w, h) = t.compute_rect(&ctx, &anchor(100, 50, 40, 20, 480, 272)); + // Should be below the target. + assert!(y >= 50 + 20); + assert!(w > 0); + assert!(h > 0); + // Horizontally centered on target. + let center_target = 100 + 20; // target center x + let center_tooltip = x + w as i32 / 2; + assert!( + (center_target - center_tooltip).abs() <= 1, + "tooltip x={x}, w={w} should center on target \ + center={center_target}" + ); + } + + #[test] + fn compute_rect_above() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Above); + let (_x, y, _w, h) = t.compute_rect(&ctx, &anchor(100, 100, 40, 20, 480, 272)); + // Should be above the target. + assert!(y + h as i32 <= 100); + } + + #[test] + fn compute_rect_left() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Left); + let (x, _y, w, _h) = t.compute_rect(&ctx, &anchor(200, 100, 40, 20, 480, 272)); + assert!(x + w as i32 <= 200); + } + + #[test] + fn compute_rect_right() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Right); + let (x, _y, _w, _h) = t.compute_rect(&ctx, &anchor(100, 100, 40, 20, 480, 272)); + assert!(x >= 100 + 40); + } + + #[test] + fn compute_rect_clamps_right_edge() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Long tooltip text here"); + let (x, _y, w, _h) = t.compute_rect(&ctx, &anchor(460, 100, 20, 20, 480, 272)); + assert!( + x + w as i32 <= 480, + "tooltip right edge ({}) should not exceed \ + viewport width (480)", + x + w as i32 + ); + } + + #[test] + fn compute_rect_clamps_bottom_edge() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Below); + let (_x, y, _w, h) = t.compute_rect(&ctx, &anchor(100, 260, 40, 20, 480, 272)); + assert!( + y + h as i32 <= 272, + "tooltip bottom ({}) should not exceed viewport \ + height (272)", + y + h as i32 + ); + } + + #[test] + fn compute_rect_clamps_top_edge() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Above); + let (_x, y, _w, _h) = t.compute_rect(&ctx, &anchor(100, 0, 40, 20, 480, 272)); + assert!(y >= 0, "tooltip y ({y}) should not go negative"); + } + + #[test] + fn compute_rect_clamps_left_edge() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Tip").with_position(TooltipPosition::Left); + let (x, _y, _w, _h) = t.compute_rect(&ctx, &anchor(0, 100, 10, 20, 480, 272)); + assert!(x >= 0, "tooltip x ({x}) should not go negative"); + } + + #[test] + fn measure_returns_tooltip_dimensions() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Hello"); + let (w, h) = t.measure(&ctx, 480, 272); + assert!(w > 0); + assert!(h > 0); + } + + #[test] + fn draw_visible_emits_fill_and_text() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut t = Tooltip::new("Info"); + t.state = TooltipState::Visible; + t.draw(&mut ctx, 10, 10, 40, 16).ok(); + } + assert!( + backend.fill_rect_count() > 0, + "visible tooltip should draw background" + ); + assert!(backend.has_text("Info"), "visible tooltip should draw text"); + } + + #[test] + fn draw_hidden_emits_nothing() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Secret"); + t.draw(&mut ctx, 10, 10, 40, 16).ok(); + } + assert_eq!( + backend.fill_rect_count(), + 0, + "hidden tooltip should not draw anything" + ); + assert!( + !backend.has_text("Secret"), + "hidden tooltip should not draw text" + ); + } + + #[test] + fn draw_at_convenience_visible() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut t = Tooltip::new("Help"); + t.state = TooltipState::Visible; + t.draw_at(&mut ctx, &anchor(100, 50, 40, 20, 480, 272)).ok(); + } + assert!(backend.has_text("Help")); + assert!(backend.fill_rect_count() > 0); + } + + #[test] + fn draw_at_convenience_hidden() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Nope"); + t.draw_at(&mut ctx, &anchor(100, 50, 40, 20, 480, 272)).ok(); + } + assert!(!backend.has_text("Nope")); + assert_eq!(backend.fill_rect_count(), 0); + } + + #[test] + fn draw_at_empty_text_no_output() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + { + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut t = Tooltip::new(""); + t.state = TooltipState::Visible; + t.draw_at(&mut ctx, &anchor(100, 50, 40, 20, 480, 272)).ok(); + } + assert_eq!(backend.fill_rect_count(), 0); + } + + #[test] + fn draw_all_themes_no_panic() { + for theme in [ + Theme::dark(), + Theme::light(), + Theme::classic(), + Theme::high_contrast(), + ] { + let mut backend = MockBackend::new(); + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut t = Tooltip::new("Theme test"); + t.state = TooltipState::Visible; + t.draw(&mut ctx, 0, 0, 80, 16).ok(); + } + } + + #[test] + fn draw_all_positions_no_panic() { + let theme = Theme::dark(); + for pos in [ + TooltipPosition::Above, + TooltipPosition::Below, + TooltipPosition::Left, + TooltipPosition::Right, + ] { + let mut backend = MockBackend::new(); + let mut ctx = DrawContext::new(&mut backend, &theme); + let mut t = Tooltip::new("Pos test").with_position(pos); + t.state = TooltipState::Visible; + t.draw_at(&mut ctx, &anchor(200, 100, 40, 20, 480, 272)) + .ok(); + } + } + + #[test] + fn compute_rect_tiny_viewport() { + let theme = Theme::dark(); + let mut backend = MockBackend::new(); + let ctx = DrawContext::new(&mut backend, &theme); + let t = Tooltip::new("Too big for viewport"); + let (x, y, _w, _h) = t.compute_rect(&ctx, &anchor(0, 0, 10, 10, 20, 20)); + assert!(x >= 0); + assert!(y >= 0); + } +} + +impl Widget for Tooltip { + fn measure(&self, ctx: &DrawContext<'_>, _available_w: u32, _available_h: u32) -> (u32, u32) { + let fs = ctx.theme.font_size_xs; + let text_w = ctx.backend.measure_text(&self.text, fs); + let text_h = ctx.backend.measure_text_height(fs); + (text_w + self.pad_h * 2, text_h + self.pad_v * 2) + } + + fn draw(&self, ctx: &mut DrawContext<'_>, x: i32, y: i32, w: u32, h: u32) -> Result<()> { + if !self.is_visible() || self.text.is_empty() { + return Ok(()); + } + + let radius = ctx.theme.border_radius_sm; + let fs = ctx.theme.font_size_xs; + + // Shadow. + ctx.theme + .shadow_tooltip + .draw(ctx.backend, x, y, w, h, radius)?; + + // Background. + ctx.backend + .fill_rounded_rect(x, y, w, h, radius, ctx.theme.tooltip_bg)?; + + // Border. + ctx.backend + .stroke_rounded_rect(x, y, w, h, radius, 1, ctx.theme.border_subtle)?; + + // Text. + let text_w = ctx.backend.measure_text(&self.text, fs); + let text_h = ctx.backend.measure_text_height(fs); + let tx = x + layout::center(w, text_w); + let ty = y + layout::center(h, text_h); + ctx.backend + .draw_text(&self.text, tx, ty, fs, ctx.theme.tooltip_text)?; + + Ok(()) + } +} diff --git a/crates/oasis-vfs/Cargo.toml b/crates/oasis-vfs/Cargo.toml index 9c0217b..a0cacbb 100644 --- a/crates/oasis-vfs/Cargo.toml +++ b/crates/oasis-vfs/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-vfs" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-vfs/src/memory.rs b/crates/oasis-vfs/src/memory.rs index 25ed757..b12a067 100644 --- a/crates/oasis-vfs/src/memory.rs +++ b/crates/oasis-vfs/src/memory.rs @@ -514,6 +514,104 @@ mod tests { assert_eq!(entries.len(), 200); } + // -- Path traversal attack tests -- + + #[test] + fn traversal_dotdot_at_root() { + let mut vfs = MemoryVfs::new(); + // Attempting /../etc/passwd should not access outside root. + let result = vfs.write("/../etc/passwd", b"hacked"); + assert!(result.is_err()); + } + + #[test] + fn traversal_multiple_dotdot() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/a/b/c").unwrap(); + // ../../.. should not resolve and create entries. + let result = vfs.write("/a/b/c/../../../etc/shadow", b"data"); + assert!(result.is_err()); + } + + #[test] + fn traversal_encoded_dots_literal() { + let mut vfs = MemoryVfs::new(); + // These are literal filenames, not traversal. + vfs.mkdir("/..hidden").unwrap(); + assert!(vfs.exists("/..hidden")); + } + + #[test] + fn traversal_read_dotdot() { + let vfs = MemoryVfs::new(); + assert!(vfs.read("/../etc/passwd").is_err()); + } + + #[test] + fn traversal_stat_dotdot() { + let vfs = MemoryVfs::new(); + assert!(vfs.stat("/../../../etc/shadow").is_err()); + } + + #[test] + fn traversal_readdir_dotdot() { + let vfs = MemoryVfs::new(); + assert!(vfs.readdir("/..").is_err()); + } + + #[test] + fn traversal_remove_dotdot() { + let mut vfs = MemoryVfs::new(); + assert!(vfs.remove("/..").is_err()); + } + + #[test] + fn traversal_mkdir_dotdot() { + let mut vfs = MemoryVfs::new(); + // mkdir /a/../b should fail -- creates literal ".." + // component, parent "/.." does not exist. + let result = vfs.mkdir("/a/../b"); + // Current behavior: mkdir creates parents recursively, + // so ".." becomes a literal directory name. + // This is acceptable since MemoryVfs does not resolve "..". + assert!(vfs.exists("/a") || result.is_err()); + } + + // -- Concurrent access simulation tests -- + + #[test] + fn write_overwrite_consistency() { + let mut vfs = MemoryVfs::new(); + vfs.write("/shared", b"version1").unwrap(); + vfs.write("/shared", b"version2").unwrap(); + assert_eq!(vfs.read("/shared").unwrap(), b"version2"); + } + + #[test] + fn readdir_consistency_after_mutation() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/dir").unwrap(); + vfs.write("/dir/a", b"x").unwrap(); + vfs.write("/dir/b", b"y").unwrap(); + + let entries = vfs.readdir("/dir").unwrap(); + assert_eq!(entries.len(), 2); + + vfs.remove("/dir/a").unwrap(); + let entries = vfs.readdir("/dir").unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].name, "b"); + } + + #[test] + fn remove_then_mkdir_same_path() { + let mut vfs = MemoryVfs::new(); + vfs.mkdir("/reuse").unwrap(); + vfs.remove("/reuse").unwrap(); + vfs.mkdir("/reuse").unwrap(); + assert!(vfs.exists("/reuse")); + } + mod prop { use super::*; use proptest::prelude::*; diff --git a/crates/oasis-wm/Cargo.toml b/crates/oasis-wm/Cargo.toml index 00bcacf..763f0a4 100644 --- a/crates/oasis-wm/Cargo.toml +++ b/crates/oasis-wm/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true license.workspace = true repository.workspace = true authors.workspace = true +documentation = "https://docs.rs/oasis-wm" [dependencies] oasis-types = { workspace = true } diff --git a/crates/oasis-wm/src/hit_test.rs b/crates/oasis-wm/src/hit_test.rs index 0ea0e5a..6b33c75 100644 --- a/crates/oasis-wm/src/hit_test.rs +++ b/crates/oasis-wm/src/hit_test.rs @@ -214,7 +214,7 @@ mod tests { let (cx, cy, _cw, _ch) = win.content_rect(&theme); // Click in the center of content. let result = hit_test(&[win], cx + 50, cy + 50, &theme); - assert_eq!(result, HitRegion::Content("w1".to_string(), 50, 50)); + assert_eq!(result, HitRegion::Content(WindowId::from("w1"), 50, 50)); } #[test] @@ -224,7 +224,7 @@ mod tests { let (tx, ty, _tw, _th) = win.titlebar_rect(&theme).unwrap(); // Click in the left side of the titlebar (away from buttons). let result = hit_test(&[win], tx + 5, ty + 5, &theme); - assert_eq!(result, HitRegion::Titlebar("w1".to_string())); + assert_eq!(result, HitRegion::Titlebar(WindowId::from("w1"))); } #[test] @@ -235,7 +235,7 @@ mod tests { let result = hit_test(&[win], bx + bw as i32 / 2, by + bh as i32 / 2, &theme); assert_eq!( result, - HitRegion::TitlebarButton("w1".to_string(), ButtonKind::Close) + HitRegion::TitlebarButton(WindowId::from("w1"), ButtonKind::Close) ); } @@ -247,7 +247,7 @@ mod tests { let result = hit_test(&[win], bx + bw as i32 / 2, by + bh as i32 / 2, &theme); assert_eq!( result, - HitRegion::TitlebarButton("w1".to_string(), ButtonKind::Minimize) + HitRegion::TitlebarButton(WindowId::from("w1"), ButtonKind::Minimize) ); } @@ -259,7 +259,7 @@ mod tests { let result = hit_test(&[win], bx + bw as i32 / 2, by + bh as i32 / 2, &theme); assert_eq!( result, - HitRegion::TitlebarButton("w1".to_string(), ButtonKind::Maximize) + HitRegion::TitlebarButton(WindowId::from("w1"), ButtonKind::Maximize) ); } @@ -287,7 +287,7 @@ mod tests { let result = hit_test(&[win], x, y, &theme); assert_eq!( result, - HitRegion::ResizeHandle("w1".to_string(), ResizeEdge::SouthEast) + HitRegion::ResizeHandle(WindowId::from("w1"), ResizeEdge::SouthEast) ); } @@ -301,7 +301,7 @@ mod tests { let result = hit_test(&[win], 100, 0, &theme); assert_eq!( result, - HitRegion::ResizeHandle("w1".to_string(), ResizeEdge::North) + HitRegion::ResizeHandle(WindowId::from("w1"), ResizeEdge::North) ); } @@ -331,7 +331,7 @@ mod tests { let win = Window::new(&config, 0, 0, &theme); // Click anywhere should be content (no titlebar, no resize). let result = hit_test(&[win], 100, 100, &theme); - assert_eq!(result, HitRegion::Content("fs".to_string(), 100, 100)); + assert_eq!(result, HitRegion::Content(WindowId::from("fs"), 100, 100)); } #[test] diff --git a/crates/oasis-wm/src/manager.rs b/crates/oasis-wm/src/manager.rs index 5639bf2..bba848e 100644 --- a/crates/oasis-wm/src/manager.rs +++ b/crates/oasis-wm/src/manager.rs @@ -60,8 +60,11 @@ enum DragState { }, } -/// Minimum window content size during resize. -const MIN_WINDOW_SIZE: u32 = 40; +/// Minimum window content width during resize. +const MIN_RESIZE_W: u32 = 80; + +/// Minimum window content height during resize. +const MIN_RESIZE_H: u32 = 60; /// Cascade offset between newly created windows. const CASCADE_OFFSET: i32 = 24; @@ -69,8 +72,10 @@ const CASCADE_OFFSET: i32 = 24; /// Distance in pixels for edge snapping during drag. const SNAP_DISTANCE: i32 = 8; -/// Minimum visible pixels of a window at screen edges. -const MIN_VISIBLE: i32 = 40; +/// Minimum visible pixels of a window titlebar at screen edges. +/// At least 20px of the titlebar must remain on-screen so the user +/// can always grab and drag the window back. +const MIN_VISIBLE: i32 = 20; /// SDI object name for the semi-transparent modal backdrop. const MODAL_OVERLAY_ID: &str = "__wm_modal_overlay"; @@ -212,7 +217,7 @@ impl WindowManager { /// `forward=true` brings the bottom-most visible window to the top. /// `forward=false` sends the top-most visible window to the bottom. /// Skips minimized windows. Returns the newly focused window id, if any. - pub fn cycle_focus(&mut self, forward: bool, sdi: &mut SdiRegistry) -> Option { + pub fn cycle_focus(&mut self, forward: bool, sdi: &mut SdiRegistry) -> Option { let visible_count = self .windows .iter() @@ -372,8 +377,7 @@ impl WindowManager { window.outer_h = new_outer_h; // Reposition all SDI objects based on new geometry. - let win_id = id.to_string(); - self.update_sdi_positions(win_id, sdi); + self.update_sdi_positions(id, sdi); Ok(()) } @@ -460,7 +464,7 @@ impl WindowManager { self.screen_h - self.theme.maximize_top_inset - self.theme.maximize_bottom_inset; window.state = WindowState::Maximized; - self.update_sdi_positions(id.to_string(), sdi); + self.update_sdi_positions(id, sdi); Ok(()) } @@ -493,7 +497,7 @@ impl WindowManager { } } - self.update_sdi_positions(id.to_string(), sdi); + self.update_sdi_positions(id, sdi); Ok(()) } @@ -546,7 +550,7 @@ impl WindowManager { let region = hit_test(&self.windows, x, y, &self.theme); // If a modal window exists, only allow clicks on the topmost modal. - if let Some(modal_id) = self.topmost_modal().map(String::from) { + if let Some(modal_id) = self.topmost_modal() { let hit_id = match ®ion { HitRegion::TitlebarButton(id, _) | HitRegion::Titlebar(id) @@ -554,7 +558,7 @@ impl WindowManager { | HitRegion::Content(id, _, _) => Some(id.as_str()), HitRegion::Desktop => None, }; - if hit_id != Some(&modal_id) { + if hit_id != Some(modal_id) { return WmEvent::None; } } @@ -726,7 +730,7 @@ impl WindowManager { window.outer_h = new_h; } - self.update_sdi_positions(window_id.clone(), sdi); + self.update_sdi_positions(window_id.as_str(), sdi); WmEvent::WindowResized(window_id.clone()) }, } @@ -810,7 +814,7 @@ impl WindowManager { } } - self.active_window = Some(id.to_string()); + self.active_window = Some(WindowId::from(id)); } /// Create all SDI objects for a window. @@ -1008,7 +1012,7 @@ impl WindowManager { } /// Reposition all SDI objects based on window's current geometry. - fn update_sdi_positions(&self, id: WindowId, sdi: &mut SdiRegistry) { + fn update_sdi_positions(&self, id: &str, sdi: &mut SdiRegistry) { let window = match self.windows.iter().find(|w| w.id == id) { Some(w) => w, None => return, @@ -1215,8 +1219,8 @@ fn compute_resize( dy: i32, theme: &WmTheme, ) -> (i32, i32, u32, u32) { - let min_w = MIN_WINDOW_SIZE + theme.border_width * 2; - let min_h = MIN_WINDOW_SIZE + theme.titlebar_height + theme.border_width * 2; + let min_w = MIN_RESIZE_W + theme.border_width * 2; + let min_h = MIN_RESIZE_H + theme.titlebar_height + theme.border_width * 2; let mut x = start.x; let mut y = start.y; @@ -1497,7 +1501,7 @@ mod tests { y: cy + 20, }; let result = wm.handle_input(&event, &mut sdi); - assert_eq!(result, WmEvent::ContentClick("w1".to_string(), 10, 20)); + assert_eq!(result, WmEvent::ContentClick(WindowId::from("w1"), 10, 20)); } #[test] @@ -1524,7 +1528,7 @@ mod tests { y: by + bh as i32 / 2, }; let result = wm.handle_input(&event, &mut sdi); - assert_eq!(result, WmEvent::WindowClosed("w1".to_string())); + assert_eq!(result, WmEvent::WindowClosed(WindowId::from("w1"))); assert_eq!(wm.window_count(), 0); } @@ -1726,13 +1730,13 @@ mod tests { let start = Geometry { x: 0, y: 0, - w: 100, - h: 100, + w: 200, + h: 200, }; // Try shrinking way past minimum. - let (_, _, w, h) = compute_resize(start, ResizeEdge::SouthEast, -200, -200, &theme); - let min_w = MIN_WINDOW_SIZE + theme.border_width * 2; - let min_h = MIN_WINDOW_SIZE + theme.titlebar_height + theme.border_width * 2; + let (_, _, w, h) = compute_resize(start, ResizeEdge::SouthEast, -400, -400, &theme); + let min_w = MIN_RESIZE_W + theme.border_width * 2; + let min_h = MIN_RESIZE_H + theme.titlebar_height + theme.border_width * 2; assert_eq!(w, min_w); assert_eq!(h, min_h); } @@ -2486,4 +2490,344 @@ mod tests { wm.close_all(&mut sdi); assert!(!sdi.contains(MODAL_OVERLAY_ID)); } + + // ---- Additional cascading / tiling tests ---- + + #[test] + fn cascade_wraps_when_near_edge() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(200, 200); + // Create enough windows to trigger cascade wrapping. + for i in 0..10 { + let config = WindowConfig { + id: format!("w{i}"), + title: format!("W{i}"), + x: None, + y: None, + width: 50, + height: 30, + window_type: WindowType::AppWindow, + always_on_top: false, + modal: false, + }; + wm.create_window(&config, &mut sdi).unwrap(); + } + // All windows should exist and be within screen bounds. + assert_eq!(wm.window_count(), 10); + for i in 0..10 { + let win = wm.get_window(&format!("w{i}")).unwrap(); + assert!(win.x >= 0); + assert!(win.y >= 0); + } + } + + #[test] + fn explicit_position_overrides_cascade() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + let config = WindowConfig { + id: "fixed".to_string(), + title: "Fixed".to_string(), + x: Some(100), + y: Some(200), + width: 150, + height: 100, + window_type: WindowType::AppWindow, + always_on_top: false, + modal: false, + }; + wm.create_window(&config, &mut sdi).unwrap(); + let win = wm.get_window("fixed").unwrap(); + assert_eq!(win.x, 100); + assert_eq!(win.y, 200); + } + + #[test] + fn close_all_empties_wm() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("a"), &mut sdi).unwrap(); + wm.create_window(&app_config("b"), &mut sdi).unwrap(); + wm.create_window(&app_config("c"), &mut sdi).unwrap(); + wm.close_all(&mut sdi); + assert_eq!(wm.window_count(), 0); + assert!(wm.active_window().is_none()); + } + + #[test] + fn close_all_clears_drag_state() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w"), &mut sdi).unwrap(); + // Start a drag. + let win = wm.get_window("w").unwrap(); + let (tx, ty, _tw, th) = win.titlebar_rect(&wm.theme).unwrap(); + wm.handle_input( + &InputEvent::PointerClick { + x: tx + 5, + y: ty + th as i32 / 2, + }, + &mut sdi, + ); + wm.close_all(&mut sdi); + assert!(wm.drag.is_none()); + } + + // ---- Edge snapping tests ---- + + #[test] + fn snap_exact_edge() { + let (sx, sy) = snap_to_edges(0, 0, 200, 150, 480, 272); + assert_eq!(sx, 0); + assert_eq!(sy, 0); + } + + #[test] + fn snap_within_threshold() { + // 7px from left edge. + let (sx, _) = snap_to_edges(7, 50, 200, 150, 480, 272); + assert_eq!(sx, 0); + } + + #[test] + fn no_snap_just_outside_threshold() { + // 9px from left edge -- just outside 8px threshold. + let (sx, _) = snap_to_edges(9, 50, 200, 150, 480, 272); + assert_eq!(sx, 9); + } + + // ---- State transition tests ---- + + #[test] + fn maximize_then_minimize_then_restore() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w"), &mut sdi).unwrap(); + + let orig_w = wm.get_window("w").unwrap().outer_w; + + wm.maximize_window("w", &mut sdi).unwrap(); + assert_eq!(wm.get_window("w").unwrap().state, WindowState::Maximized); + + // Minimize from maximized. + wm.minimize_window("w", &mut sdi).unwrap(); + assert_eq!(wm.get_window("w").unwrap().state, WindowState::Minimized); + + // Restore should go back to pre-maximize geometry. + wm.restore_window("w", &mut sdi).unwrap(); + assert_eq!(wm.get_window("w").unwrap().state, WindowState::Normal); + assert_eq!(wm.get_window("w").unwrap().outer_w, orig_w); + } + + #[test] + fn double_maximize_is_idempotent() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w"), &mut sdi).unwrap(); + + wm.maximize_window("w", &mut sdi).unwrap(); + let first_w = wm.get_window("w").unwrap().outer_w; + let first_h = wm.get_window("w").unwrap().outer_h; + + wm.maximize_window("w", &mut sdi).unwrap(); + assert_eq!(wm.get_window("w").unwrap().outer_w, first_w); + assert_eq!(wm.get_window("w").unwrap().outer_h, first_h); + } + + #[test] + fn restore_normal_window_is_noop() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w"), &mut sdi).unwrap(); + + let orig_x = wm.get_window("w").unwrap().x; + let orig_y = wm.get_window("w").unwrap().y; + + // Restore on a normal window should not change anything. + wm.restore_window("w", &mut sdi).unwrap(); + assert_eq!(wm.get_window("w").unwrap().x, orig_x); + assert_eq!(wm.get_window("w").unwrap().y, orig_y); + assert_eq!(wm.get_window("w").unwrap().state, WindowState::Normal); + } + + #[test] + fn move_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.move_window("nope", 10, 10, &mut sdi).is_err()); + } + + #[test] + fn resize_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.resize_window("nope", 100, 100, &mut sdi).is_err()); + } + + #[test] + fn focus_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.focus_window("nope", &mut sdi).is_err()); + } + + #[test] + fn minimize_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.minimize_window("nope", &mut sdi).is_err()); + } + + #[test] + fn maximize_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.maximize_window("nope", &mut sdi).is_err()); + } + + #[test] + fn restore_nonexistent_window_fails() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + assert!(wm.restore_window("nope", &mut sdi).is_err()); + } + + #[test] + fn set_theme_replaces_theme() { + let mut wm = WindowManager::new(800, 600); + let new_theme = WmTheme { + titlebar_height: 40, + ..WmTheme::default() + }; + wm.set_theme(new_theme); + assert_eq!(wm.theme().titlebar_height, 40); + } + + #[test] + fn release_without_drag_returns_none() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + let event = wm.handle_input(&InputEvent::PointerRelease { x: 0, y: 0 }, &mut sdi); + assert_eq!(event, WmEvent::None); + } + + #[test] + fn cursor_move_without_drag_returns_none() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + let event = wm.handle_input(&InputEvent::CursorMove { x: 50, y: 50 }, &mut sdi); + assert_eq!(event, WmEvent::None); + } + + #[test] + fn button_press_returns_none() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + let event = wm.handle_input( + &InputEvent::ButtonPress(oasis_types::input::Button::Confirm), + &mut sdi, + ); + assert_eq!(event, WmEvent::None); + } + + #[test] + fn resize_all_edges() { + let theme = WmTheme::default(); + let start = Geometry { + x: 50, + y: 50, + w: 200, + h: 200, + }; + // East + let (_, _, w, _) = compute_resize(start, ResizeEdge::East, 30, 0, &theme); + assert_eq!(w, 230); + // West: x moves left, w grows + let (x, _, w, _) = compute_resize(start, ResizeEdge::West, -20, 0, &theme); + assert_eq!(w, 220); + assert_eq!(x, 30); + // South + let (_, _, _, h) = compute_resize(start, ResizeEdge::South, 0, 40, &theme); + assert_eq!(h, 240); + // North: y moves up, h grows + let (_, y, _, h) = compute_resize(start, ResizeEdge::North, 0, -10, &theme); + assert_eq!(h, 210); + assert_eq!(y, 40); + // NorthEast + let (_, y, w, h) = compute_resize(start, ResizeEdge::NorthEast, 20, -10, &theme); + assert_eq!(w, 220); + assert_eq!(h, 210); + assert_eq!(y, 40); + // NorthWest + let (x, y, w, h) = compute_resize(start, ResizeEdge::NorthWest, -15, -10, &theme); + assert_eq!(w, 215); + assert_eq!(h, 210); + assert_eq!(x, 35); + assert_eq!(y, 40); + // SouthWest + let (x, _, w, h) = compute_resize(start, ResizeEdge::SouthWest, -20, 30, &theme); + assert_eq!(w, 220); + assert_eq!(h, 230); + assert_eq!(x, 30); + } + + #[test] + fn cycle_focus_forward() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w1"), &mut sdi).unwrap(); + wm.create_window(&app_config("w2"), &mut sdi).unwrap(); + wm.create_window(&app_config("w3"), &mut sdi).unwrap(); + // w3 is on top. Cycling forward brings w1 to top. + let focused = wm.cycle_focus(true, &mut sdi); + assert!(focused.is_some()); + } + + #[test] + fn cycle_focus_backward() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w1"), &mut sdi).unwrap(); + wm.create_window(&app_config("w2"), &mut sdi).unwrap(); + wm.create_window(&app_config("w3"), &mut sdi).unwrap(); + let focused = wm.cycle_focus(false, &mut sdi); + assert!(focused.is_some()); + } + + #[test] + fn cycle_focus_single_window() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + wm.create_window(&app_config("w1"), &mut sdi).unwrap(); + // With one window, cycle should return the same window. + let focused = wm.cycle_focus(true, &mut sdi); + assert_eq!(focused.as_deref(), Some("w1")); + } + + #[test] + fn cycle_focus_no_windows() { + let mut sdi = SdiRegistry::new(); + let mut wm = WindowManager::new(800, 600); + let focused = wm.cycle_focus(true, &mut sdi); + assert!(focused.is_none()); + } + + #[test] + fn maximize_with_insets() { + let mut sdi = SdiRegistry::new(); + let theme = WmTheme { + maximize_top_inset: 20, + maximize_bottom_inset: 30, + ..WmTheme::default() + }; + let mut wm = WindowManager::with_theme(800, 600, theme); + wm.create_window(&app_config("w"), &mut sdi).unwrap(); + wm.maximize_window("w", &mut sdi).unwrap(); + + let win = wm.get_window("w").unwrap(); + assert_eq!(win.x, 0); + assert_eq!(win.y, 20); + assert_eq!(win.outer_w, 800); + assert_eq!(win.outer_h, 550); // 600 - 20 - 30 + } } diff --git a/crates/oasis-wm/src/window.rs b/crates/oasis-wm/src/window.rs index df1dccb..8bfa964 100644 --- a/crates/oasis-wm/src/window.rs +++ b/crates/oasis-wm/src/window.rs @@ -4,10 +4,116 @@ //! objects identified by a naming convention: `"{id}.frame"`, `"{id}.titlebar"`, //! etc. The WM handles behavior; the skin handles appearance. +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; + use oasis_types::backend::Color; -/// Unique window identifier (also the SDI object name prefix). -pub type WindowId = String; +/// Shared, reference-counted window identifier. +/// +/// Wraps `Rc` for cheap cloning at 60fps. Compares equal with +/// `&str`, `String`, and other `WindowId` values, so existing call +/// sites that do `id == "browser"` keep working. +#[derive(Clone, Eq)] +pub struct WindowId(Rc); + +impl WindowId { + /// Create a new window id from any string-like value. + pub fn new(s: impl Into>) -> Self { + Self(s.into()) + } + + /// Borrow the inner string slice. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::ops::Deref for WindowId { + type Target = str; + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for WindowId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for WindowId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", &*self.0) + } +} + +impl fmt::Display for WindowId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Hash for WindowId { + fn hash(&self, state: &mut H) { + (*self.0).hash(state); + } +} + +impl PartialEq for WindowId { + fn eq(&self, other: &Self) -> bool { + *self.0 == *other.0 + } +} + +impl PartialEq for WindowId { + fn eq(&self, other: &str) -> bool { + &*self.0 == other + } +} + +impl PartialEq<&str> for WindowId { + fn eq(&self, other: &&str) -> bool { + &*self.0 == *other + } +} + +impl PartialEq for WindowId { + fn eq(&self, other: &String) -> bool { + &*self.0 == other.as_str() + } +} + +impl PartialEq for str { + fn eq(&self, other: &WindowId) -> bool { + self == &*other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &WindowId) -> bool { + *self == &*other.0 + } +} + +impl PartialEq for String { + fn eq(&self, other: &WindowId) -> bool { + self.as_str() == &*other.0 + } +} + +impl From for WindowId { + fn from(s: String) -> Self { + Self(Rc::from(s)) + } +} + +impl From<&str> for WindowId { + fn from(s: &str) -> Self { + Self(Rc::from(s)) + } +} /// The behavioral template of a window. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -263,7 +369,7 @@ impl Window { let outer_h = config.height + titlebar_h + border * 2; Self { - id: config.id.clone(), + id: WindowId::from(config.id.as_str()), title: config.title.clone(), window_type: config.window_type, state: WindowState::Normal, @@ -314,17 +420,22 @@ impl Window { Some((tx, ty, tw, th)) } - /// Inset from the titlebar edge for window buttons. - const BUTTON_INSET: i32 = 2; + /// Compute button inset from the titlebar edge, derived from + /// the titlebar height so buttons stay proportional. + fn button_inset(theme: &WmTheme) -> i32 { + (theme.titlebar_height as i32 / 8).max(1) + } - /// Compute a button's X position given its index (0=close, 1=minimize, 2=maximize). + /// Compute a button's X position given its index + /// (0=close, 1=minimize, 2=maximize). fn button_x(&self, theme: &WmTheme, tx: i32, tw: u32, idx: i32) -> i32 { let btn_size = theme.button_size.min(theme.titlebar_height) as i32; let sp = theme.button_spacing; + let inset = Self::button_inset(theme); if theme.button_side == "left" { - tx + Self::BUTTON_INSET + idx * (btn_size + sp) + tx + inset + idx * (btn_size + sp) } else { - tx + tw as i32 - (idx + 1) * btn_size - idx * sp - Self::BUTTON_INSET + tx + tw as i32 - (idx + 1) * btn_size - idx * sp - inset } } @@ -378,7 +489,8 @@ impl Window { .iter() .filter(|&&v| v) .count() as i32; - let text_inset = Self::BUTTON_INSET * 2; // padding on each side of title text + let inset = Self::button_inset(theme); + let text_inset = inset * 2; // padding on each side let buttons_w = if btn_count > 0 { btn_count * btn_size + (btn_count - 1) * sp + text_inset } else { diff --git a/docs/adding-commands.md b/docs/adding-commands.md new file mode 100644 index 0000000..957522b --- /dev/null +++ b/docs/adding-commands.md @@ -0,0 +1,262 @@ +# Adding Terminal Commands + +This guide explains how to add new commands to the OASIS_OS terminal interpreter. + +--- + +## Command Architecture + +Commands implement the `Command` trait defined in `crates/oasis-terminal/src/interpreter.rs`. The `CommandRegistry` holds all registered commands and dispatches execution by name. Commands receive parsed arguments and a mutable `Environment` providing access to the VFS, platform services, and piped input. + +--- + +## The Command Trait + +```rust +pub trait Command { + /// The command name (what the user types). + fn name(&self) -> &str; + + /// One-line description for `help`. + fn description(&self) -> &str; + + /// Usage string (e.g. "ls [path]"). + fn usage(&self) -> &str; + + /// Command category for grouping in `help` output. + /// Default: "general". Common categories: core, file, system, dev, + /// fun, security, text, audio, network, skin, ui, plugin, doc. + fn category(&self) -> &str { "general" } + + /// Execute the command with the given arguments and environment. + fn execute(&self, args: &[&str], env: &mut Environment<'_>) -> Result; +} +``` + +Source: `crates/oasis-terminal/src/interpreter.rs` + +--- + +## CommandOutput Variants + +Commands return a `CommandOutput` enum: + +| Variant | Description | +|---------|-------------| +| `Text(String)` | Plain text output (most common) | +| `Table { headers, rows }` | Tabular data with header row and data rows | +| `None` | No visible output | +| `Clear` | Signal to clear the terminal buffer | +| `Multi(Vec)` | Multiple outputs from chained commands | +| `ListenToggle { port }` | Signal to start/stop remote terminal | +| `RemoteConnect { address, port, psk }` | Signal to connect to remote host | +| `SkinSwap { name }` | Signal to swap the active skin | +| `FtpToggle { port }` | Signal to start/stop FTP server | +| `BrowserSandbox { enable }` | Signal to toggle browser sandbox mode | + +Use `Text` for most commands. The signal variants (`ListenToggle`, `SkinSwap`, etc.) are intercepted by the app layer to trigger system-level actions. + +--- + +## The Environment Struct + +```rust +pub struct Environment<'a> { + pub cwd: String, // current working directory + pub vfs: &'a mut dyn Vfs, // virtual file system + pub power: Option<&'a dyn PowerService>, // battery/CPU queries + pub time: Option<&'a dyn TimeService>, // clock/uptime queries + pub usb: Option<&'a dyn UsbService>, // USB status + pub network: Option<&'a dyn NetworkService>, // WiFi status + pub tls: Option<&'a dyn TlsProvider>, // TLS for HTTPS + pub stdin: Option, // piped input from previous command + pub stderr: String, // accumulated error output +} +``` + +Commands read files and directories through `env.vfs`. Platform services (`power`, `time`, `usb`, `network`) are `Option` because not every backend provides them. + +--- + +## Tutorial: Add a Custom Command + +### Step 1: Create the Command Struct + +Create a new file or add to an existing command module in `crates/oasis-terminal/src/`: + +```rust +// In crates/oasis-terminal/src/my_commands.rs + +use oasis_types::error::{OasisError, Result}; +use crate::interpreter::{Command, CommandOutput, Environment}; + +struct WordCountCmd; + +impl Command for WordCountCmd { + fn name(&self) -> &str { "wc" } + fn description(&self) -> &str { "Count words in a file" } + fn usage(&self) -> &str { "wc " } + fn category(&self) -> &str { "text" } + + fn execute(&self, args: &[&str], env: &mut Environment<'_>) -> Result { + // Handle piped input. + if let Some(ref input) = env.stdin { + let count = input.split_whitespace().count(); + return Ok(CommandOutput::Text(format!("{count} words"))); + } + + // Require a file path argument. + let path = args.first().copied().ok_or_else(|| { + OasisError::Command("usage: wc ".to_string()) + })?; + + // Resolve relative paths against cwd. + let full_path = if path.starts_with('/') { + path.to_string() + } else { + format!("{}/{path}", env.cwd) + }; + + let data = env.vfs.read(&full_path)?; + let text = String::from_utf8_lossy(&data); + let words = text.split_whitespace().count(); + let lines = text.lines().count(); + let bytes = data.len(); + + Ok(CommandOutput::Text(format!( + "{lines} lines, {words} words, {bytes} bytes" + ))) + } +} +``` + +### Step 2: Create a Registration Function + +Follow the existing pattern -- each command module exports a `register_*` function: + +```rust +pub fn register_my_commands(reg: &mut crate::CommandRegistry) { + reg.register(Box::new(WordCountCmd)); +} +``` + +### Step 3: Wire It Into the Module System + +Add your module to `crates/oasis-terminal/src/lib.rs`: + +```rust +mod my_commands; +pub use my_commands::register_my_commands; +``` + +Then call it from `crates/oasis-terminal/src/commands.rs` in `register_builtins()`: + +```rust +pub fn register_builtins(reg: &mut CommandRegistry) { + // ... existing registrations ... + crate::register_my_commands(reg); +} +``` + +--- + +## Existing Command Modules + +Commands are organized into modules by category: + +| Module | Category | Examples | +|--------|----------|----------| +| `commands.rs` | core | help, ls, cd, pwd, cat, mkdir, rm, echo, clear | +| `text_commands.rs` | text | grep, sort, head, tail, cut, tr, tee | +| `file_commands.rs` | file | find, du, stat, diff, hexdump | +| `system_commands.rs` | system | uptime, hostname, uname, df, free | +| `dev_commands.rs` | dev | base64, sha256, json, hexedit, calc | +| `fun_commands.rs` | fun | fortune, cowsay, figlet, matrix | +| `security_commands.rs` | security | passwd, hash, encrypt, audit | +| `doc_commands.rs` | doc | man, tutorial, motd | +| `audio_commands.rs` | audio | play, playlist, volume | +| `network_commands.rs` | network | ping, wget, curl, ifconfig | +| `skin_commands.rs` | skin | skin, theme | +| `ui_commands.rs` | ui | window, panel, toast | +| `radio_commands.rs` | audio | radio | + +Additional commands (agent, plugin, script, transfer, update) are registered by `oasis-core`. + +--- + +## Testing Commands with Mock VFS + +Use `MemoryVfs` for fast, isolated command tests: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use oasis_vfs::MemoryVfs; + + fn make_env(vfs: &mut MemoryVfs) -> Environment<'_> { + Environment { + cwd: "/".to_string(), + vfs, + power: None, + time: None, + usb: None, + network: None, + tls: None, + stdin: None, + stderr: String::new(), + } + } + + #[test] + fn wc_counts_words() { + let mut vfs = MemoryVfs::new(); + vfs.write("/test.txt", b"hello world foo").unwrap(); + let mut env = make_env(&mut vfs); + + let cmd = WordCountCmd; + match cmd.execute(&["test.txt"], &mut env).unwrap() { + CommandOutput::Text(s) => { + assert!(s.contains("3 words")); + } + _ => panic!("expected text output"), + } + } + + #[test] + fn wc_piped_input() { + let mut vfs = MemoryVfs::new(); + let mut env = make_env(&mut vfs); + env.stdin = Some("one two three four".to_string()); + + let cmd = WordCountCmd; + match cmd.execute(&[], &mut env).unwrap() { + CommandOutput::Text(s) => assert!(s.contains("4 words")), + _ => panic!("expected text output"), + } + } + + #[test] + fn wc_missing_file() { + let mut vfs = MemoryVfs::new(); + let mut env = make_env(&mut vfs); + + let cmd = WordCountCmd; + assert!(cmd.execute(&["/nonexistent"], &mut env).is_err()); + } +} +``` + +--- + +## Shell Features Your Command Gets for Free + +The interpreter handles these before your `execute()` is called: + +- **Argument parsing** -- quoted strings, escape sequences +- **Variable expansion** -- `$HOME`, `$USER`, `$?` (last exit code) +- **Glob expansion** -- `*.txt` expanded against VFS +- **Alias resolution** -- user-defined aliases +- **Piping** -- `cat file.txt | wc` sets `env.stdin` +- **Command chaining** -- `cmd1 ; cmd2` and `cmd1 && cmd2` +- **History** -- all commands are recorded for up-arrow recall diff --git a/docs/plugin-development.md b/docs/plugin-development.md new file mode 100644 index 0000000..c37627c --- /dev/null +++ b/docs/plugin-development.md @@ -0,0 +1,266 @@ +# Plugin Development Guide + +This guide walks through writing a plugin for OASIS_OS. Plugins extend the system with new commands, UI widgets, and behaviors at runtime. + +--- + +## Plugin Architecture + +Plugins interact with the OS through three services provided by `PluginHost`: + +- **`sdi`** -- the SDI scene graph for creating/modifying UI elements +- **`vfs`** -- the virtual file system for reading/writing files +- **`commands`** -- the command registry for adding terminal commands + +The plugin lifecycle is: + +1. **Register** -- plugin is added to `PluginManager` (state: `Registered`) +2. **Init** -- `init()` called once; register commands, create SDI objects (state: `Active`) +3. **Update** -- `update()` called once per frame; do periodic work +4. **Shutdown** -- `shutdown()` called on unload; clean up resources (state: `Stopped`) + +Source: `crates/oasis-core/src/plugin/traits.rs` + +--- + +## Tutorial: Write Your First Plugin + +### Step 1: Implement the Plugin Trait + +```rust +use oasis_core::plugin::{Plugin, PluginHost, PluginInfo}; +use oasis_core::error::Result; + +pub struct GreetPlugin; + +impl Plugin for GreetPlugin { + fn info(&self) -> PluginInfo { + PluginInfo::new("greet", "1.0.0") + .with_author("Your Name") + .with_description("Greeting plugin example") + } + + fn init(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + // Register a command. + host.commands.register(Box::new(GreetCmd)); + + // Create a UI widget in the scene graph. + let obj = host.sdi.create("greet_banner"); + obj.x = 10; + obj.y = 250; + obj.w = 200; + obj.h = 16; + obj.text = Some("Greet plugin loaded".to_string()); + obj.visible = true; + + Ok(()) + } + + fn update(&mut self, _host: &mut PluginHost<'_>) -> Result<()> { + // Per-frame work. Most plugins leave this as a no-op. + Ok(()) + } + + fn shutdown(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + // Clean up SDI objects. Commands remain registered + // (the registry does not support removal yet). + let _ = host.sdi.destroy("greet_banner"); + Ok(()) + } +} +``` + +### Step 2: Implement a Command + +Commands implement the `Command` trait from `oasis-terminal`: + +```rust +use oasis_core::terminal::{Command, CommandOutput, Environment}; +use oasis_core::error::Result; + +struct GreetCmd; + +impl Command for GreetCmd { + fn name(&self) -> &str { "greet" } + fn description(&self) -> &str { "Greet someone (greet plugin)" } + fn usage(&self) -> &str { "greet [name]" } + fn category(&self) -> &str { "plugin" } + + fn execute(&self, args: &[&str], _env: &mut Environment<'_>) -> Result { + let name = if args.is_empty() { "World" } else { args[0] }; + Ok(CommandOutput::Text(format!("Greetings, {name}!"))) + } +} +``` + +### Step 3: Register the Plugin + +For built-in (statically linked) plugins, add to the registration function in `crates/oasis-core/src/plugin/examples.rs`: + +```rust +pub fn register_builtin_plugins(manager: &mut PluginManager) { + manager.register_static(Box::new(HelloPlugin)); + manager.register_static(Box::new(ClockWidgetPlugin::new())); + manager.register_static(Box::new(NotepadPlugin)); + manager.register_static(Box::new(GreetPlugin)); // your plugin +} +``` + +--- + +## VFS-Based IPC Patterns + +Plugins communicate through the virtual file system. This avoids coupling between plugins and provides a natural persistence mechanism. + +### Writing State to VFS + +```rust +fn init(&mut self, host: &mut PluginHost<'_>) -> Result<()> { + // Create a plugin-specific directory. + if !host.vfs.exists("/var/my-plugin") { + if !host.vfs.exists("/var") { + host.vfs.mkdir("/var")?; + } + host.vfs.mkdir("/var/my-plugin")?; + } + // Write config or state. + host.vfs.write("/var/my-plugin/config.txt", b"key=value")?; + Ok(()) +} +``` + +### Reading State from VFS + +```rust +fn execute(&self, _args: &[&str], env: &mut Environment<'_>) -> Result { + let data = env.vfs.read("/var/my-plugin/config.txt")?; + let text = String::from_utf8_lossy(&data).into_owned(); + Ok(CommandOutput::Text(text)) +} +``` + +### Inter-Plugin Communication + +Plugin A writes to `/var/shared/messages`, Plugin B reads from it. Both plugins share the same VFS instance via `PluginHost`. No global state or synchronization is needed -- the VFS serializes access. + +See the `NotepadPlugin` in `crates/oasis-core/src/plugin/examples.rs` for a complete working example of VFS-backed data storage. + +--- + +## The PluginHost and PluginContext + +`PluginHost` is the struct passed to every lifecycle method: + +```rust +pub struct PluginHost<'a> { + pub sdi: &'a mut SdiRegistry, // scene graph + pub vfs: &'a mut dyn Vfs, // virtual file system + pub commands: &'a mut CommandRegistry, // command registry +} +``` + +All OS interaction goes through these three fields. Plugins do not have direct access to backends, rendering, or input -- they operate at the scene-graph and VFS level. + +--- + +## Plugin Discovery via Manifests + +Plugins can be discovered from the VFS at `/etc/oasis-os/plugins//plugin.toml`: + +```toml +name = "my-plugin" +version = "2.0" +author = "Your Name" +description = "A discoverable plugin" +library = "libmyplugin.so" +auto_load = true +``` + +Use `PluginManager::discover_manifests(vfs)` to scan for available plugins. + +Source: `crates/oasis-core/src/plugin/manager.rs` + +--- + +## Testing Plugins with Mock VFS + +Use `MemoryVfs` for isolated, in-memory testing: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use oasis_core::plugin::PluginManager; + use oasis_core::sdi::SdiRegistry; + use oasis_core::terminal::CommandRegistry; + use oasis_core::vfs::MemoryVfs; + + #[test] + fn greet_plugin_registers_command() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(GreetPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + let mut env = Environment { + cwd: "/".to_string(), + vfs: &mut vfs, + power: None, + time: None, + usb: None, + network: None, + tls: None, + stdin: None, + stderr: String::new(), + }; + match cmds.execute("greet OASIS", &mut env).unwrap() { + CommandOutput::Text(s) => assert_eq!(s, "Greetings, OASIS!"), + _ => panic!("expected text output"), + } + } + + #[test] + fn greet_plugin_creates_sdi_widget() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(GreetPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + assert!(sdi.contains("greet_banner")); + } + + #[test] + fn greet_plugin_cleanup() { + let mut mgr = PluginManager::new(); + mgr.register_static(Box::new(GreetPlugin)); + + let mut sdi = SdiRegistry::new(); + let mut vfs = MemoryVfs::new(); + let mut cmds = CommandRegistry::new(); + mgr.init_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + mgr.shutdown_all(&mut sdi, &mut vfs, &mut cmds).unwrap(); + + assert!(!sdi.contains("greet_banner")); + } +} +``` + +--- + +## Built-in Plugin Examples + +The codebase ships three example plugins in `crates/oasis-core/src/plugin/examples.rs`: + +| Plugin | Commands | Description | +|--------|----------|-------------| +| `HelloPlugin` | `hello [name]` | Simplest possible plugin -- single command | +| `ClockWidgetPlugin` | `pclock [show\|hide]` | Creates an SDI clock widget + command | +| `NotepadPlugin` | `note [list\|read\|write]` | VFS-backed notepad with CRUD operations | + +Study these for patterns on SDI widget creation, VFS interaction, and command registration. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..4a36225 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,225 @@ +# Troubleshooting Guide + +Common issues and solutions for building, testing, and running OASIS_OS. + +--- + +## SDL2 Installation + +The desktop backend (`oasis-backend-sdl`) requires SDL2 development libraries. + +**Debian / Ubuntu:** +```bash +sudo apt install libsdl2-dev libsdl2-mixer-dev +``` + +**Fedora:** +```bash +sudo dnf install SDL2-devel SDL2_mixer-devel +``` + +**macOS (Homebrew):** +```bash +brew install sdl2 sdl2_mixer +``` + +**Arch Linux:** +```bash +sudo pacman -S sdl2 sdl2_mixer +``` + +If `cargo build` fails with `could not find SDL2`, verify the dev packages are installed and that `pkg-config` can find them: + +```bash +pkg-config --libs sdl2 +``` + +On macOS, you may also need to set `LIBRARY_PATH`: + +```bash +export LIBRARY_PATH="$(brew --prefix)/lib" +``` + +--- + +## PSP Build Setup + +The PSP backend is excluded from the workspace and requires: + +1. **Rust nightly toolchain** -- the `mipsel-sony-psp` target uses `-Z build-std`. +2. **cargo-psp** -- `cargo install cargo-psp`. + +Build the EBOOT: + +```bash +cd crates/oasis-backend-psp +RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +``` + +Build the kernel PRX plugin: + +```bash +cd crates/oasis-plugin-psp +RUST_PSP_BUILD_STD=1 cargo +nightly psp --release +``` + +### MIPS memcpy/memset Recursion + +LLVM on `mipsel-sony-psp` can emit calls to `memcpy`/`memset` inside their own implementations, causing infinite recursion. The workaround is manual byte-copy loops instead of slice copies in hot paths. If you see a stack overflow or hang on PSP startup, check for slice operations in `unsafe` blocks. + +--- + +## PPSSPP Setup for PSP Testing + +PPSSPP is used for headless screenshot testing in CI. The Docker Compose service handles setup automatically: + +```bash +# Build the PPSSPP container (one-time) +docker compose --profile psp build ppsspp + +# Run headless test +docker compose --profile psp run --rm -e PPSSPP_HEADLESS=1 ppsspp /roms/release/EBOOT.PBP +``` + +### GPU Passthrough + +The PPSSPP container uses `runtime: nvidia` for GPU-accelerated rendering. Requirements: + +- NVIDIA GPU with drivers installed on the host +- `nvidia-container-toolkit` package installed +- Docker configured to use the nvidia runtime + +If you do not have an NVIDIA GPU, edit `docker-compose.yml` to remove `runtime: nvidia` and the `NVIDIA_*` environment variables. PPSSPP will fall back to software rendering (slower but functional for testing). + +### X11 Display + +For GUI mode (non-headless), the container needs X11 access: + +```bash +xhost +local:docker +docker compose --profile psp run --rm ppsspp /roms/release/EBOOT.PBP +``` + +--- + +## Docker Environment Issues + +### Volume Mount Permissions + +The `rust-ci` container runs as `${USER_ID}:${GROUP_ID}` (defaulting to 1000:1000). If your host UID differs, set the variables: + +```bash +USER_ID=$(id -u) GROUP_ID=$(id -g) docker compose --profile ci run --rm rust-ci cargo build --workspace +``` + +### Cargo Cache + +The CI container stores cargo artifacts in a Docker volume (`cargo-registry-cache`) mounted at `/tmp/cargo`. If you get stale dependency errors, prune the volume: + +```bash +docker volume rm oasis-os_cargo-registry-cache +``` + +### Rebuilding the CI Image + +After changing dependencies in `Cargo.toml` or updating the Dockerfile: + +```bash +docker compose --profile ci build rust-ci +``` + +--- + +## Common Build Failures + +### Missing SDL2 + +``` +error: could not find system library 'sdl2' required by the 'sdl2-sys' crate +``` + +Install SDL2 dev libraries for your platform (see above). + +### Clippy Warnings as Errors + +CI runs `cargo clippy --workspace -- -D warnings`. Any warning fails the build. Fix all warnings before pushing: + +```bash +cargo clippy --workspace -- -D warnings +``` + +### `str::floor_char_boundary` Not Found + +This method requires Rust 1.91.0+. Update your toolchain: + +```bash +rustup update stable +``` + +### PSP Nightly Toolchain Missing + +``` +error: toolchain 'nightly' is not installed +``` + +Install it: + +```bash +rustup toolchain install nightly +``` + +--- + +## Debugging Tips + +### Enable Tracing + +Set `RUST_LOG` for debug output: + +```bash +RUST_LOG=debug cargo run -p oasis-app +``` + +Levels: `error`, `warn`, `info`, `debug`, `trace`. You can filter by crate: + +```bash +RUST_LOG=oasis_core=debug,oasis_browser=trace cargo run -p oasis-app +``` + +### Single-Crate Testing + +Run tests for one crate at a time to isolate failures: + +```bash +cargo test -p oasis-core +cargo test -p oasis-terminal +cargo test -p oasis-browser +``` + +Run a single test by name: + +```bash +cargo test --workspace -- test_name +``` + +### Screenshot Testing + +Take screenshots of all skins for visual regression: + +```bash +cargo run -p oasis-app --bin oasis-screenshot +``` + +Screenshots are saved to the `screenshots/` directory. + +### Format Check + +If CI fails on formatting: + +```bash +# Check what's wrong +cargo fmt --all -- --check + +# Auto-fix +cargo fmt --all +```