Replace mod settings pagination with continuous scrolling

This commit is contained in:
chazmm committed 2026-09-08 19:25:44 +01:00
1 parent f73dc0f5d2
commit a2366128f4
4 files changed
+222 -72

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
param([switch]$StageOnly, [string]$DependencyTargetDirectory = (Join-Path $PSScriptRoot 'target'))
$ErrorActionPreference = 'Stop'
$privateDirectory = Join-Path $PSScriptRoot '.local/scroll-settings'
$buildDirectory = Join-Path $privateDirectory 'build'
$artifact = Join-Path $buildDirectory 'skate3rust-scroll-settings.exe'
$destination = Join-Path $privateDirectory 'skate3-scroll-settings.exe'
Push-Location $PSScriptRoot
$previousFlags = $env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS
try {
New-Item -ItemType Directory -Force -Path $buildDirectory | Out-Null
if (-not $StageOnly) {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = '-C target-feature=+crt-static'
# Dependencies may use a shared cache. The linker writes this task's
# artifact directly, never staging the cache's generic skate3rust.exe.
# Shared target fingerprints can refer to another worktree's local crates.
# Refresh only our local source timestamps; never clean the shared cache.
foreach ($crate in @('skate-core', 'skate-data', 'skate-mods')) {
(Get-Item -LiteralPath (Join-Path $PSScriptRoot "crates/$crate/src/lib.rs")).LastWriteTime = Get-Date
}
& cargo rustc --release --locked --target x86_64-pc-windows-msvc `
--target-dir $DependencyTargetDirectory -p skate-game --no-default-features `
--bin skate3rust -- -C extra-filename=-scroll-settings `
-o (Join-Path $buildDirectory 'skate3rust.exe')
if ($LASTEXITCODE -ne 0) { throw 'Lua SDK build failed.' }
}
$bytes = [System.IO.File]::ReadAllBytes($artifact)
$text = [System.Text.Encoding]::ASCII.GetString($bytes)
foreach ($required in @('--start-paused', 'Scroll wheel: more settings', 'Native trainer controls are already owned by another mod')) {
if (-not $text.Contains($required)) { throw "Wrong scroll-settings artifact: missing $required" }
}
Copy-Item -LiteralPath $artifact -Destination $destination -Force
# Only original SDK example sources are staged. Never copy extracted assets.
$modDestination = Join-Path $privateDirectory 'mods/native-trainer'
New-Item -ItemType Directory -Force -Path $modDestination | Out-Null
foreach ($name in @('mod.json','main.lua','help.txt','README.md')) {
$source = Join-Path $PSScriptRoot "mods/native-trainer/$name"
$target = Join-Path $modDestination $name
if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $source -Destination $target }
elseif ((Get-FileHash -LiteralPath $source).Hash -ne (Get-FileHash -LiteralPath $target).Hash) {
Write-Warning "Preserving existing mod file: $target. Copy the updated source there when ready."
}
}
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'mods/README.md') -Destination (Join-Path $privateDirectory 'mods/README.md') -Force
$hash = (Get-FileHash -LiteralPath $destination -Algorithm SHA256).Hash
if ($hash -ne (Get-FileHash -LiteralPath $artifact -Algorithm SHA256).Hash) {
throw 'Staged executable hash mismatch.'
}
@{ executable = $destination; sha256 = $hash; source = $PSScriptRoot;
revision = (& git rev-parse HEAD); staticChecks = 'CLI, Mods UI, Lua quota diagnostics' } |
ConvertTo-Json | Set-Content -LiteralPath (Join-Path $privateDirectory 'build-manifest.json') -Encoding UTF8
Write-Host "Ready for manual launch: $destination"
Write-Host "SHA256: $hash"
} finally {
$env:CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = $previousFlags
Pop-Location
}
+23
View File
@@ -0,0 +1,23 @@
@echo off
setlocal
cd /d "%~dp0"
if not defined SKATE3_ASSETS (
if exist "%~dp0.local\scroll-settings\assets.path" set /p "SKATE3_ASSETS="<"%~dp0.local\scroll-settings\assets.path"
)
if not defined SKATE3_ASSETS (
echo Set SKATE3_ASSETS to your prepared assets directory.
pause
exit /b 1
)
if not exist "%~dp0.local\scroll-settings\skate3-scroll-settings.exe" (
echo Build missing. Run Build-ScrollSettings.ps1 first.
pause
exit /b 1
)
set "SKATE3_MOD_SETTINGS=%~dp0.local\scroll-settings\settings"
if exist "%~dp0.local\scroll-settings\overlay.path" set /p "SKATE3_SESSION_MARKER_OVERLAY="<"%~dp0.local\scroll-settings\overlay.path"
echo Open Mods and ENABLE Native Trainer. Escape shows draggable settings windows. Drag their title bars; scroll the mouse wheel for more settings.
echo Mods folder: %~dp0.local\scroll-settings\mods
echo Trainer log: %~dp0.local\scroll-settings\session.log
"%~dp0.local\scroll-settings\skate3-scroll-settings.exe" --assets "%SKATE3_ASSETS%" --start-paused %* > "%~dp0.local\scroll-settings\session.log" 2>&1
if errorlevel 1 pause
+142 -71
View File
@@ -1,14 +1,16 @@
//! Independent, draggable enabled-mod windows. Positions survive closing Escape.
use super::{ModMenu, Mods};
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use std::collections::BTreeMap;
const PAGE: usize = 7;
#[derive(Default)]
struct Layout {
position: Vec2,
collapsed: bool,
selected: usize,
status: String,
reveal_selected: bool,
}
#[derive(Resource, Default)]
pub(crate) struct EnabledPanel {
@@ -28,6 +30,8 @@ struct Root(String);
#[derive(Component)]
struct Body(String);
#[derive(Component)]
struct Viewport(String);
#[derive(Component)]
struct Header(String);
#[derive(Component)]
struct Row(String, usize);
@@ -46,7 +50,6 @@ enum Operation {
Adjust(usize, i32),
Collapse,
Configure,
Page(i32),
}
pub(super) fn install(app: &mut App) {
app.init_resource::<EnabledPanel>()
@@ -56,7 +59,7 @@ pub(super) fn install(app: &mut App) {
.after(crate::graphics_menu::MenuInput)
.before(crate::map_transition::MapTransitionSet),
)
.add_systems(Update, (sync, draw).chain());
.add_systems(Update, (sync, draw, scroll).chain());
}
fn button(parent: &mut ChildSpawnerCommands, text: &str, action: Action) {
parent
@@ -117,7 +120,7 @@ fn sync(
panel.active = signature.first().map(|(id, _)| id.clone());
panel.focused = false;
}
for (index, (id, _)) in signature.iter().enumerate() {
for (index, (id, settings)) in signature.iter().enumerate() {
panel.layouts.entry(id.clone()).or_insert_with(|| Layout {
position: Vec2::new(16. + index as f32 * 22., 64. + index as f32 * 34.),
..default()
@@ -202,56 +205,69 @@ fn sync(
},
))
.with_children(|body| {
for i in 0..PAGE {
body.spawn((
Row(id.clone(), i),
Node {
align_items: AlignItems::Center,
column_gap: px(4.),
min_height: px(34.),
..default()
},
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
))
.with_children(|row| {
row.spawn((
Label(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
body.spawn((
Viewport(id.clone()),
ScrollPosition::default(),
Node {
height: px(280.),
max_height: Val::Vh(45.),
overflow: Overflow::scroll_y(),
flex_direction: FlexDirection::Column,
row_gap: px(6.),
..default()
},
))
.with_children(|list| {
for i in 0..settings.len() {
list.spawn((
Row(id.clone(), i),
Node {
flex_grow: 1.,
width: px(132.),
align_items: AlignItems::Center,
column_gap: px(4.),
min_height: px(34.),
flex_shrink: 0.,
..default()
},
));
button(row, "-", Action(id.clone(), Operation::Adjust(i, -1)));
row.spawn((
ValueLabel(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
width: px(51.),
..default()
},
));
button(row, "+", Action(id.clone(), Operation::Adjust(i, 1)));
});
}
BackgroundColor(Color::srgb(0.08, 0.11, 0.15)),
))
.with_children(|row| {
row.spawn((
Label(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
flex_grow: 1.,
width: px(132.),
..default()
},
));
button(row, "-", Action(id.clone(), Operation::Adjust(i, -1)));
row.spawn((
ValueLabel(id.clone(), i),
Text::new(""),
TextFont {
font_size: 14.,
..default()
},
TextColor(Color::WHITE),
Node {
width: px(51.),
..default()
},
));
button(row, "+", Action(id.clone(), Operation::Adjust(i, 1)));
});
}
});
body.spawn(Node {
column_gap: px(6.),
..default()
})
.with_children(|p| {
button(p, "<", Action(id.clone(), Operation::Page(-1)));
button(p, ">", Action(id.clone(), Operation::Page(1)));
button(p, "Manage mod", Action(id.clone(), Operation::Configure));
});
body.spawn((
@@ -349,24 +365,20 @@ fn input(
layout.selected = layout.selected.min(count - 1);
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
layout.selected = (layout.selected + count - 1) % count;
layout.reveal_selected = true;
}
if keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0 {
layout.selected = (layout.selected + 1) % count;
layout.reveal_selected = true;
}
if keys.just_pressed(KeyCode::ArrowLeft) || nav.pressed & 4 != 0 {
action = Some(Action(
id.clone(),
Operation::Adjust(layout.selected % PAGE, -1),
));
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, -1)));
}
if keys.just_pressed(KeyCode::ArrowRight)
|| keys.just_pressed(KeyCode::Enter)
|| nav.pressed & (8 | 0x1000) != 0
{
action = Some(Action(
id.clone(),
Operation::Adjust(layout.selected % PAGE, 1),
));
action = Some(Action(id.clone(), Operation::Adjust(layout.selected, 1)));
}
}
}
@@ -396,17 +408,11 @@ fn input(
layout.collapsed = !layout.collapsed;
}
Operation::Configure => menu.configure(id),
Operation::Page(direction) => {
let pages = p.manifest.settings.len().max(1).div_ceil(PAGE);
layout.selected = ((layout.selected / PAGE) as i32 + direction).rem_euclid(pages as i32)
as usize
* PAGE;
}
Operation::Adjust(row, direction) => {
if layout.collapsed {
return;
}
let selected = layout.selected / PAGE * PAGE + row;
let selected = row;
let Some((key, s)) = p.manifest.settings.iter().nth(selected) else {
return;
};
@@ -491,7 +497,7 @@ fn draw(
}
for (row, mut node, mut color) in &mut rows {
let selected = panel.layouts.get(&row.0).map_or(0, |l| l.selected);
let index = selected / PAGE * PAGE + row.1;
let index = row.1;
node.display = if mods
.manager
.packages
@@ -520,7 +526,7 @@ fn draw(
if let Some(h) = hint {
if let Some(l) = panel.layouts.get(&h.0) {
**t = format!(
"Drag title to move | +/- change values\nTab / X cycles windows; arrows adjust\n{}",
"Drag title to move | +/- change values\nScroll wheel: more settings | Tab / X: focus\n{}",
l.status
);
}
@@ -533,14 +539,7 @@ fn draw(
.manager
.packages
.get(id)
.zip(panel.layouts.get(id))
.and_then(|(p, l)| {
p.manifest
.settings
.iter()
.nth(l.selected / PAGE * PAGE + row)
.map(|(k, s)| (p, k, s))
});
.and_then(|p| p.manifest.settings.iter().nth(row).map(|(k, s)| (p, k, s)));
**t = setting
.map(|(p, key, s)| {
if !is_value {
@@ -558,3 +557,75 @@ fn draw(
}
}
}
// Scroll only the uppermost mod window under the pointer. Use computed UI
// geometry so DPI scaling, dragging and wrapped setting labels remain correct.
fn scroll(
mut wheel: MessageReader<MouseWheel>,
mut panel: ResMut<EnabledPanel>,
pause: Res<crate::graphics_menu::Menu>,
menu: Res<ModMenu>,
custom: Res<crate::customiser::Customiser>,
window: Single<&Window, With<bevy::window::PrimaryWindow>>,
roots: Query<(&Root, &ComputedNode, &UiGlobalTransform)>,
mut lists: Query<(
&Viewport,
&ComputedNode,
&UiGlobalTransform,
&mut ScrollPosition,
)>,
rows: Query<(&Row, &ComputedNode, &UiGlobalTransform)>,
) {
let delta: f32 = wheel
.read()
.map(|event| match event.unit {
MouseScrollUnit::Line => event.y * 40.,
MouseScrollUnit::Pixel => event.y,
})
.sum();
if !pause.open || menu.open || custom.open || panel.drag.is_some() {
return;
}
let hovered = window.physical_cursor_position().and_then(|cursor| {
roots
.iter()
.filter(|(_, node, transform)| node.contains_point(**transform, cursor))
.max_by_key(|(root, node, _)| {
(panel.active.as_ref() == Some(&root.0), node.stack_index)
})
.map(|(root, _, _)| root.0.clone())
});
for (viewport, node, transform, mut position) in &mut lists {
let Some(layout) = panel.layouts.get_mut(&viewport.0) else {
continue;
};
if layout.collapsed {
continue;
}
if hovered.as_ref() == Some(&viewport.0) && delta != 0. {
position.0.y -= delta;
layout.reveal_selected = false;
}
if layout.reveal_selected && node.size().y > 0. {
if let Some((_, row_node, row_transform)) = rows
.iter()
.find(|(row, _, _)| row.0 == viewport.0 && row.1 == layout.selected)
{
let center = transform
.inverse()
.transform_point2(row_transform.translation);
let top = center.y - row_node.size().y / 2. + node.size().y / 2.;
let bottom = top + row_node.size().y;
let shift = if top < 0. {
top
} else {
(bottom - node.size().y).max(0.)
};
position.0.y += shift * node.inverse_scale_factor;
layout.reveal_selected = false;
}
}
let max = ((node.content_size().y - node.size().y) * node.inverse_scale_factor).max(0.);
position.0.y = position.0.y.clamp(0., max);
}
}
+1 -1
View File
@@ -225,7 +225,7 @@ The trainer update passes 14 standalone Lua/schema tests and a static-CRT releas
Use `PLAY-MOD-WINDOWS.bat` for the latest settings-window build (`Build-ModWindows.ps1`). The ordinary Escape menu stays in its original centered position. Each enabled mod receives its own compact window, initially at the left, with a title you can drag. The title-bar minus button collapses that window; plus expands it. Each setting has separate minus/plus controls to decrease/increase numbers (within the manifest bounds), cycle choices, or toggle booleans. String controls open the full editor. Values are still saved and delivered to Lua immediately.
Positions and collapse state are retained for the current process, including closing Escape and disabling/re-enabling a mod; they are not saved across game restarts. Clicking a window brings it forward. Tab/controller X cycles focus through enabled mods and then back to the main pause controls; arrows select/adjust within the focused mod. Each window pages seven settings with its own previous/next buttons. Manage mod opens that mod's enable/disable page. The main pause controls remain mouse-clickable when a mod has keyboard focus. Dragging prevents accidental main-menu mouse actions. No game was launched to validate this UI update; its interactive behavior remains for user testing.
Positions and collapse state are retained for the current process, including closing Escape and disabling/re-enabling a mod; they are not saved across game restarts. Clicking a window brings it forward. Tab/controller X cycles focus through enabled mods and then back to the main pause controls; arrows select/adjust within the focused mod. Each window shows a continuous, clipped settings list. Use the mouse wheel over the window to scroll; keyboard/controller selection scrolls the selected setting into view. The value minus/plus buttons edit that row without changing the other rows. Manage mod opens that mod's enable/disable page. The main pause controls remain mouse-clickable when a mod has keyboard focus. Dragging prevents accidental main-menu mouse actions. No game was launched to validate this UI update; its interactive behavior remains for user testing.
## Showcase 2.0 and top-level packages