Controllers: Replay Studio's recordings and buttons take the focus

Its rows and buttons only painted, so a pad reached them through the crosshair alone. The D-pad now
walks the recordings to the buttons, the right stick scrolls them, and Done declares its button.

A notch is now one whole row, since nothing clips a row that's partly scrolled off, and the scroll
limit counts the rows that fit, so the last recording can be reached with the mouse too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-06 18:39:17 -05:00
1 parent 13f70ad482
commit f8a7a59085
4 files changed
+98 -40

No files matched your search

@@ -4459,6 +4459,10 @@ impl OptionsUi {
self.graphics.scroll(s, size, amount);
return;
}
if self.page() == Page::Studio {
self.studio.wheel(amount);
return;
}
if Self::controller_page(self.page()).is_some() {
self.controller.scroll(s, size, amount);
return;
+38 -34
View File
@@ -103,6 +103,12 @@ pub struct StudioUi {
pub record_dir: PathBuf,
}
/// A recording's row and the gap under it, in GUI pixels.
const ROW_STEP: f32 = 40.0;
const ROW_GAP: f32 = 4.0;
/// The panel's margins above and below its rows.
const LIST_MARGINS: f32 = 10.0;
impl Default for StudioUi {
fn default() -> Self {
let dir = PathBuf::from("recordings");
@@ -270,18 +276,19 @@ impl StudioUi {
);
} else {
// Render scrollable cards
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let visible_cards = (panel_h / row_step).floor() as usize;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
// Rows that fit whole between the panel's margins, as the loop below draws them.
let visible_cards = ((panel_h - LIST_MARGINS + ROW_GAP) / row_step).floor() as usize;
let max_scroll =
(self.recordings.len().saturating_sub(visible_cards) as f32 * row_step).max(0.0);
self.scroll = self.scroll.clamp(0.0, max_scroll);
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step) as usize;
// Whole rows: nothing here clips one that's partly scrolled off.
let mut y = panel_y + 6.0;
let start_idx = (self.scroll / row_step).round() as usize;
for (idx, item) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -289,6 +296,8 @@ impl StudioUi {
}
let rect = [card_x, y, card_w, card_h];
crate::widgets::record_as(&item.title, rect, true, crate::widgets::Kind::Row);
crate::widgets::target_as(rect, crate::widgets::Kind::Row);
let is_hovered = ui::hovered(cursor, card_x, y, card_w, card_h);
let is_selected = self.selected == Some(idx);
@@ -334,6 +343,12 @@ impl StudioUi {
y += row_step;
}
crate::widgets::scroll_area(
[panel_x, panel_y, panel_w, panel_h],
self.scroll,
max_scroll,
);
// Scrollbar track & thumb if scrollable
if max_scroll > 0.0 {
let track_x = panel_x + panel_w - 4.0;
@@ -357,35 +372,24 @@ impl StudioUi {
// Button 1: Open Folder (Left)
let f_btn = [panel_x, btn_y, 130.0, btn_h];
let f_hover = ui::hovered(cursor, f_btn[0], f_btn[1], f_btn[2], f_btn[3]);
style::button(
batch,
atlas,
f_btn,
&Text::tr("horizon.studio.open_folder", &[]),
(f_hover, true, false),
);
let label = Text::tr("horizon.studio.open_folder", &[]);
crate::widgets::record(&label.plain(), f_btn, true);
style::button(batch, atlas, f_btn, &label, (f_hover, true, false));
// Button 2: Refresh (Center-Left)
let r_btn = [panel_x + 136.0, btn_y, 65.0, btn_h];
let r_hover = ui::hovered(cursor, r_btn[0], r_btn[1], r_btn[2], r_btn[3]);
style::button(
batch,
atlas,
r_btn,
&Text::tr("horizon.studio.refresh", &[]),
(r_hover, true, false),
);
let label = Text::tr("horizon.studio.refresh", &[]);
crate::widgets::record(&label.plain(), r_btn, true);
style::button(batch, atlas, r_btn, &label, (r_hover, true, false));
// Button 3: Done / Back (Right)
let d_btn = [panel_x + panel_w - 64.0, btn_y, 64.0, btn_h];
let d_hover = ui::hovered(cursor, d_btn[0], d_btn[1], d_btn[2], d_btn[3]);
style::button(
batch,
atlas,
d_btn,
&Text::tr("gui.done", &[]),
(d_hover, true, false),
);
let label = Text::tr("gui.done", &[]);
crate::widgets::record(&label.plain(), d_btn, true);
style::button(batch, atlas, d_btn, &label, (d_hover, true, false));
crate::widgets::declare_action(crate::widgets::Action::Done, d_btn, true);
}
/// Process mouse click on the Studio Browser screen.
@@ -418,13 +422,12 @@ impl StudioUi {
}
// Card clicks
let card_h = 36.0;
let card_gap = 4.0;
let row_step = card_h + card_gap;
let card_h = ROW_STEP - ROW_GAP;
let row_step = ROW_STEP;
let card_w = panel_w - 12.0;
let card_x = panel_x + 6.0;
let start_idx = (self.scroll / row_step) as usize;
let mut y = panel_y + 6.0 - (self.scroll % row_step);
let start_idx = (self.scroll / row_step).round() as usize;
let mut y = panel_y + 6.0;
for (idx, _) in self.recordings.iter().enumerate().skip(start_idx) {
if y + card_h > panel_y + panel_h - 4.0 {
@@ -444,7 +447,8 @@ impl StudioUi {
/// Scroll list by mouse wheel delta.
pub fn wheel(&mut self, amount: f32) {
self.scroll = (self.scroll - amount * 24.0).max(0.0);
// A notch is a row, as a focus step needs (`gamepad::list_step`).
self.scroll = (self.scroll - amount * ROW_STEP).max(0.0);
}
}
@@ -527,6 +531,6 @@ mod tests {
assert_eq!(ui.scroll, 0.0); // clamped at 0
ui.scroll = 100.0;
ui.wheel(1.0);
assert_eq!(ui.scroll, 76.0);
assert_eq!(ui.scroll, 100.0 - ROW_STEP);
}
}
+2 -2
View File
@@ -369,7 +369,7 @@ Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
Both modes send the same pointer events, but neither reaches everything. Focus mode
registers targets on: the title menus (main, world list and its search, create
and edit world, delete, multiplayer list and its edit screens), every options
page (vanilla's and Horizon Settings with the pages it opens except Replay Studio, key and
page (vanilla's and Horizon Settings with the pages it opens, key and
controller binds, language list), pause and Play with Friends, death, chat, signs and books (their buttons), the
survival and creative inventories, every container and station (chests,
furnaces, brewing, crafter, crafting table with its recipe book, anvil,
@@ -377,7 +377,7 @@ grindstone, smithing, stonecutter, loom, cartography, enchanting, beacon,
villager and wandering-trader trades, mount inventories), and the LAN /
world options. Where a screen draws something clickable no target names
(the advancements map, the statistics lists, the pack screen's rows, the
book's page text), the pad drives the pointer instead, as it does on Replay Studio, which registers nothing yet.
book's page text), the pad drives the pointer instead.
## Glyphs
+54 -4
View File
@@ -32,6 +32,7 @@ Cases:
- long_label: a label too wide for its button slides, beside its glyph.
- every_screen_has_targets: the title's submenus and every options page give the focus something to land on.
- pause_icon: the pause menu's icons are targets, and A presses one.
- studio_rows: the D-pad walks Replay Studio's recordings to its buttons, and B leaves.
- erase_checkbox: the D-pad reaches Optimize World's Erase Cached Data box, and A ticks it.
Needs built fast binaries (`cargo build --profile fast -p horizon-client -p horizon-client-lib --bins`)
and prepared resources in .local/horizon-client/26.3. Exit status 0 when every case passes.
@@ -45,6 +46,7 @@ import subprocess
import sys
import tempfile
import time
import zipfile
import zlib
from pathlib import Path
@@ -1486,14 +1488,12 @@ OPTIONS_PAGES = ('main', 'video', 'sound', 'controls', 'keybinds', 'controller',
'accessibility', 'language', 'font', 'packs', 'mods', 'shader-packs', 'graphics', 'graphics-advanced',
'graphics-hdr', 'extras', 'pvp', 'credits', 'horizon', 'agent')
# Screens allowed to draw no target, and why. The pad falls back to the crosshair on these.
NO_TARGETS = {'studio': 'its buttons and rows only paint, nothing registers them yet'}
TARGETS_WAIT = 5
STUDIO_RECORDINGS, STUDIO_BUTTONS = 12, 3
def check_has_targets(game, check, screen):
"""A focus-mode screen draws a target soon after it opens; pointer screens (packs) use the crosshair by design."""
if screen in NO_TARGETS:
return
end = time.time() + TARGETS_WAIT
while True:
menu = game.menu()
@@ -1532,6 +1532,56 @@ def every_screen_has_targets(game, check, single, options, quit_game):
check_has_targets(game, check, 'studio')
def seed_recordings(game_home):
"""More saved recordings than the Studio page shows at once."""
folder = game_home / 'recordings'
folder.mkdir()
for i in range(STUDIO_RECORDINGS):
with zipfile.ZipFile(folder / f'run{i:02}.mcpr', 'w') as archive:
archive.writestr('metaData.json', json.dumps({'serverName': f'Server{i:02}', 'date': i, 'duration': 1000}))
def studio_rows(args, name):
"""The D-pad walks Replay Studio's recordings, scrolling the hidden ones, to its buttons."""
game = Game(args, name, seed=seed_recordings)
check = Check()
try:
game.wait_targets()
game.ctl('options', 'horizon')
time.sleep(SETTLE)
click_widget(game, widget(game, 'studio'))
time.sleep(SETTLE)
game.wait_targets()
first = game.rows()[0]['label']
for _ in range(4):
game.stick()
check(game.rows()[0]['label'] != first, f'the right stick did not scroll the list: {first} still on top')
for _ in range(8):
game.stick(1)
check(game.rows()[0]['label'] == first, f'the right stick did not scroll back: {game.rows()[0]["label"]} on top')
kinds = [t['kind'] for t in game.menu()['targets']]
check(kinds.count('row') > 0 and kinds.count('row') < STUDIO_RECORDINGS, f'recordings in view: {kinds}')
check(kinds.count('button') == STUDIO_BUTTONS, f'its buttons: {kinds}')
game.pad('down')
walked = set()
for _ in range(STUDIO_RECORDINGS + STUDIO_BUTTONS):
walked.add(game.focused_label())
before = game.focus()
kind = next((t['kind'] for t in game.menu()['targets'] if tuple(t['rect']) == before), None)
if kind == 'button':
break
game.pad('down')
check(kind == 'button', f'the D-pad never left the list for the buttons: on {kind}')
check(len(walked) >= STUDIO_RECORDINGS, f'the D-pad skipped hidden recordings: {sorted(map(str, walked))}')
check(game.menu()['actions'], 'Done declares no button')
game.pad('east')
time.sleep(SETTLE)
check(not any(w['id'] == 'open-folder' for w in game.widgets()), 'B stayed on the Studio page')
finally:
game.quit()
return check.failures
def pause_icon(args, name):
game = Game(args, name, world=True)
check = Check()
@@ -1984,7 +2034,7 @@ CASES = {'idle_a': idle_a, 'idle_b': idle_b, 'idle_x': idle_x, 'release': releas
'controller_settings_keys': controller_settings_keys,
'controller_notices': controller_notices,
'every_screen_has_targets': every_screen_has_targets,
'pause_icon': pause_icon,
'studio_rows': studio_rows, 'pause_icon': pause_icon,
'erase_checkbox': erase_checkbox,
'glyph_art': glyph_art,
'glyph_art_world': glyph_art_world,