From 210a6f5dddfd04ccbf9a7b93eeedd4b4b9dfce10 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:20:14 +0200 Subject: [PATCH] Controllers: focus follows scrolled lists, clipped targets, pad status shows focus Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/beacon.rs | 11 ++ crates/horizon-client/src/game/loom.rs | 18 ++ crates/horizon-client/src/game/pad.rs | 6 + crates/horizon-client/src/game/pad_menu.rs | 144 ++++++++++++---- crates/horizon-client/src/game/recipe_book.rs | 60 +++++++ crates/horizon-client/src/game/stations.rs | 32 ++++ crates/horizon-client/src/game/trading.rs | 10 ++ crates/horizon-client/src/gamepad.rs | 87 ++++++++-- crates/horizon-client/src/multiplayer.rs | 4 + crates/horizon-client/src/options_horizon.rs | 4 + crates/horizon-client/src/title.rs | 4 + crates/horizon-client/src/ui_theme.rs | 4 + crates/horizon-client/src/widgets.rs | 32 ++++ docs/controllers.md | 155 +++++++++++++++--- 14 files changed, 500 insertions(+), 71 deletions(-) diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index 943fb9e9d..fafb2787d 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -274,6 +274,17 @@ impl Game<'_> { } } /// Clicks on the power, confirm and cancel buttons. + /// The effect, upgrade, confirm and cancel buttons a controller can + /// focus (GUI origin relative). + pub(super) fn beacon_targets(&self) -> Vec<[f32; 4]> { + let (ux, uy) = upgrade_button(); + buttons() + .into_iter() + .map(|(_, x, y, _)| (x, y)) + .chain([(ux, uy), (164.0, 107.0), (190.0, 107.0)]) + .map(|(x, y)| [x + SHIFT, y, 22.0, 22.0]) + .collect() + } pub(super) fn beacon_click(&mut self, local: (f32, f32)) -> bool { let l = (local.0 - SHIFT, local.1); let levels = self.beacon_levels(); diff --git a/crates/horizon-client/src/game/loom.rs b/crates/horizon-client/src/game/loom.rs index f6648c43b..c0827b9a6 100644 --- a/crates/horizon-client/src/game/loom.rs +++ b/crates/horizon-client/src/game/loom.rs @@ -128,6 +128,24 @@ impl Game<'_> { } false } + /// The pattern buttons a controller can focus (GUI origin relative). + pub(super) fn loom_targets(&self) -> Vec<[f32; 4]> { + let Some(loom) = self.station.as_ref().map(|s| &s.loom).filter(|l| l.display) else { + return Vec::new(); + }; + (0..16) + .filter(|i| (i / 4 + loom.start_row) * 4 + i % 4 < loom.patterns.len()) + .map(|i| { + let (row, column) = (i / 4, i % 4); + [ + 60.0 + column as f32 * BUTTON, + 13.0 + row as f32 * BUTTON, + BUTTON, + BUTTON, + ] + }) + .collect() + } /// `LoomScreen.mouseDragged` on the scroller. pub(super) fn loom_drag(&mut self) { let Some(size) = self diff --git a/crates/horizon-client/src/game/pad.rs b/crates/horizon-client/src/game/pad.rs index 111567117..a9009db35 100644 --- a/crates/horizon-client/src/game/pad.rs +++ b/crates/horizon-client/src/game/pad.rs @@ -315,6 +315,11 @@ impl Game<'_> { /// B and Start: what Escape does where the pad is. pub(super) fn pad_back(&mut self) -> Result<()> { + // The options open over the title screen too: they come first, as + // for the keyboard's Escape. + if self.screen == Screen::Options { + return self.options_key(KeyCode::Escape, None); + } if let Some(title) = self.title.as_mut() { title.escape(); return Ok(()); @@ -469,6 +474,7 @@ impl Game<'_> { "reason": why, "strong": p.strong, "weak": p.weak, "ms": p.millis, })), "in_use": self.pad.in_use, + "menu": self.pad_menu_status(), "held": held, "left": s.left, "right": s.right, diff --git a/crates/horizon-client/src/game/pad_menu.rs b/crates/horizon-client/src/game/pad_menu.rs index a321697ef..1a249fe5e 100644 --- a/crates/horizon-client/src/game/pad_menu.rs +++ b/crates/horizon-client/src/game/pad_menu.rs @@ -45,13 +45,16 @@ const STICK_STEP: f32 = 0.55; const SCROLL_TRIES: u8 = 4; /// A focus step that ran off the visible part of a list: the list was -/// scrolled and the step runs again once a new frame is drawn. -#[derive(Clone, Copy)] +/// scrolled and the step runs again once a new frame is drawn. When the +/// targets didn't move (nothing scrolled), the step takes vanilla's vague +/// pass instead. +#[derive(Clone)] struct Pending { button: Button, from: Rect, generation: u64, tries: u8, + before: Vec, } #[derive(Default)] @@ -111,8 +114,20 @@ impl Game<'_> { /// Where focus can land: buttons drawn last frame, every registered /// target (sliders, fields, rows, Horizon-style widgets), and the slots - /// and tabs of inventories (GUI pixels). + /// and tabs of inventories (GUI pixels), on screen. fn pad_targets(&self) -> Vec { + let mut targets = self.pad_all_targets(); + if let Some(batch) = self.gui_scale() { + let (w, h) = (batch.width + 0.5, batch.height + 0.5); + targets.retain(|t| { + let [x, y, tw, th] = t.rect; + x >= -0.5 && y >= -0.5 && x + tw <= w && y + th <= h + }); + } + targets + } + + fn pad_all_targets(&self) -> Vec { let mut targets: Vec = widgets::last() .iter() .filter(|w| w.enabled) @@ -146,6 +161,15 @@ impl Game<'_> { let (x, y) = self.slot_position(i); slot(ox + x, oy + y) })); + targets.extend( + self.station_targets() + .into_iter() + .map(|[x, y, w, h]| Target { + rect: [ox + x, oy + y, w, h], + kind: Kind::Button, + }), + ); + targets.extend(self.recipe_book_targets()); } Screen::Creative => { let (ox, oy) = Self::creative_origin(&batch); @@ -500,21 +524,25 @@ impl Game<'_> { return; } let rects: Vec = targets.iter().map(|t| t.rect).collect(); - match gamepad::focus_step_rect(focus.rect, button, &rects) { + let vertical = matches!(button, Button::DPadUp | Button::DPadDown); + let step = if vertical { + gamepad::focus_step_strict(focus.rect, button, &rects) + } else { + gamepad::focus_step_rect(focus.rect, button, &rects) + }; + match step { Some(i) => self.pad_focus_on(event_loop, targets[i]), - None if matches!(button, Button::DPadUp | Button::DPadDown) => { + None if vertical => { // Off the visible rows of a list: scroll it and try again on // the next frame. - self.pad_wheel( - event_loop, - 0.0, - if button == Button::DPadUp { 1.0 } else { -1.0 }, - ); + let up = button == Button::DPadUp; + self.pad_wheel(event_loop, 0.0, if up { 1.0 } else { -1.0 }); self.pad.menu.pending = Some(Pending { button, from: focus.rect, generation: widgets::generation(), tries: 1, + before: rects, }); } None => {} @@ -720,34 +748,58 @@ impl Game<'_> { /// and keep the hidden pointer on the focused widget. fn pad_follow_focus(&mut self, event_loop: &ActiveEventLoop, targets: &[Target]) { let rects: Vec = targets.iter().map(|t| t.rect).collect(); - if let Some(focus) = self.pad.menu.focus { - self.pad.menu.focus = gamepad::refind(focus.rect, &rects, 12.0).map(|i| targets[i]); - if self.pad.menu.focus.is_none() && self.pad.menu.pos.is_some() { - // The widget went away (a page changed): start again. - self.pad.menu.focus = self.pad_first_target(targets); - } - } else if self.pad.menu.pos.is_some() { - // The pad drives but nothing is focused (switched from the pointer). - let centres: Vec<(f32, f32)> = targets.iter().map(Target::center).collect(); - self.pad.menu.focus = - gamepad::nearest(self.cursor, ¢res, f32::INFINITY).map(|i| targets[i]); - } - if let Some(p) = self.pad.menu.pending - && widgets::generation() > p.generation + let ready = self + .pad + .menu + .pending + .as_ref() + .map(|p| widgets::generation() > p.generation); + if ready == Some(true) + && let Some(p) = self.pad.menu.pending.take() { - self.pad.menu.pending = None; - match gamepad::focus_step_rect(p.from, p.button, &rects) { - Some(i) => self.pad_focus_on(event_loop, targets[i]), - None if p.tries < SCROLL_TRIES => { - let up = p.button == Button::DPadUp; - self.pad_wheel(event_loop, 0.0, if up { 1.0 } else { -1.0 }); - self.pad.menu.pending = Some(Pending { - generation: widgets::generation(), - tries: p.tries + 1, - ..p - }); + // A step scrolled a list: carry on from where the focused row + // moved, or take the vague pass when nothing scrolled. + let step = match gamepad::scroll_shift(&p.before, &rects) { + Some(dy) => { + let mut from = p.from; + from[1] += dy; + self.pad.menu.focus = gamepad::refind(from, &rects, 2.0).map(|i| targets[i]); + gamepad::focus_step_strict(from, p.button, &rects).map(|i| (i, Some(from))) } - None => {} + None => gamepad::focus_step_vague(p.from, p.button, &rects).map(|i| (i, None)), + }; + match step { + Some((i, _)) => self.pad_focus_on(event_loop, targets[i]), + None => { + if let Some(dy) = gamepad::scroll_shift(&p.before, &rects) + && p.tries < SCROLL_TRIES + { + let up = p.button == Button::DPadUp; + self.pad_wheel(event_loop, 0.0, if up { 1.0 } else { -1.0 }); + let mut from = p.from; + from[1] += dy; + self.pad.menu.pending = Some(Pending { + from, + generation: widgets::generation(), + tries: p.tries + 1, + before: rects.clone(), + ..p + }); + } + } + } + } else if ready.is_none() { + if let Some(focus) = self.pad.menu.focus { + self.pad.menu.focus = gamepad::refind(focus.rect, &rects, 12.0).map(|i| targets[i]); + if self.pad.menu.focus.is_none() && self.pad.menu.pos.is_some() { + // The widget went away (a page changed): start again. + self.pad.menu.focus = self.pad_first_target(targets); + } + } else if self.pad.menu.pos.is_some() { + // The pad drives but nothing is focused (switched from the pointer). + let centres: Vec<(f32, f32)> = targets.iter().map(Target::center).collect(); + self.pad.menu.focus = + gamepad::nearest(self.cursor, ¢res, f32::INFINITY).map(|i| targets[i]); } } let Some(focus) = self.pad.menu.focus else { @@ -775,6 +827,26 @@ impl Game<'_> { } } + /// `pad status`: the menu mode, the focused target and every target. + pub(super) fn pad_menu_status(&self) -> serde_json::Value { + let kind = |k: Kind| match k { + Kind::Button => "button", + Kind::Slider { .. } => "slider", + Kind::Field => "field", + Kind::Row => "row", + Kind::Slot => "slot", + Kind::Tab(true) => "tab*", + Kind::Tab(false) => "tab", + }; + let target = |t: &Target| serde_json::json!({ "rect": t.rect, "kind": kind(t.kind) }); + serde_json::json!({ + "mode": if self.pad_focus_mode() { "focus" } else { "pointer" }, + "focus": self.pad.menu.focus.as_ref().map(target), + "keyboard": self.pad.menu.osk.is_some(), + "targets": self.pad_targets().iter().map(target).collect::>(), + }) + } + // ---- text fields ---- /// A text field has keyboard focus: chat, a sign, a book being written, diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 6c24fef40..67e79eb6a 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -629,6 +629,66 @@ impl Game<'_> { } false } + /// The recipe book's buttons a controller can focus: the book button, + /// recipes, page arrows, the craftable filter and the tabs (GUI pixels; + /// the search box registers itself as a field). + pub(super) fn recipe_book_targets(&self) -> Vec { + use horizon_client::widgets::{Kind, Target}; + let (Some(book_type), Some(batch)) = (self.active_book(), self.batch_for_size()) else { + return Vec::new(); + }; + let button = |rect| Target { + rect, + kind: Kind::Button, + }; + let (ox, _) = self.inventory_origin(&batch); + let (bx, by) = self.book_button_pos(book_type, ox, &batch); + let mut out = vec![button([bx, by, 20.0, 18.0])]; + if !self.book_open(book_type) || self.spectator() { + return out; + } + let (xo, yo) = self.book_origin(&batch); + if let Some(overlay) = &self.recipe_ui.overlay { + // The alternatives overlay takes every click while open. + let maxrow = if overlay.entries.len() <= 16 { 4 } else { 5 }; + return (0..overlay.entries.len()) + .map(|n| { + let x = overlay.x + 4.0 + 25.0 * (n % maxrow) as f32; + let y = overlay.y + 5.0 + 25.0 * (n / maxrow) as f32; + button([x, y, 24.0, 24.0]) + }) + .collect(); + } + let first = self.recipe_ui.page * 20; + let shown = self + .recipe_ui + .view + .buttons + .len() + .saturating_sub(first) + .min(20); + out.extend((0..shown).map(|n| { + let (x, y) = Self::button_pos(xo, yo, n); + button([x, y, 25.0, 25.0]) + })); + let pages = self.recipe_ui.view.buttons.len().div_ceil(20); + if pages > 1 && self.recipe_ui.page + 1 < pages { + out.push(button([xo + 93.0, yo + 137.0, 12.0, 17.0])); + } + if pages > 1 && self.recipe_ui.page > 0 { + out.push(button([xo + 38.0, yo + 137.0, 12.0, 17.0])); + } + out.push(button([xo + 110.0, yo + 12.0, 26.0, 16.0])); + let selected = self.recipe_ui.tab.unwrap_or(Tab::Search); + for (n, tab) in self.recipe_ui.view.tabs.iter().enumerate() { + let (x, y) = self.tab_pos(xo, yo, n); + out.push(Target { + rect: [x, y, 35.0, 27.0], + kind: Kind::Tab(*tab == selected), + }); + } + out + } fn shift_down(&self) -> bool { self.has_shift_down() } diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 17374f9eb..5642a24c7 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -1015,6 +1015,38 @@ impl Game<'_> { self.station_changed(); } /// Mouse click while a station or the brewing stand is open. True when handled. + /// A station's own buttons a controller can focus besides its slots + /// (GUI origin relative): offers, patterns, effects, enchantments and + /// stonecutter recipes. + pub(super) fn station_targets(&self) -> Vec<[f32; 4]> { + let Some(s) = self.station.as_ref() else { + return Vec::new(); + }; + match s.kind { + Kind::Merchant => self.merchant_targets(), + Kind::Loom => self.loom_targets(), + Kind::Beacon => self.beacon_targets(), + Kind::Enchanting => (0..3) + .map(|i| { + let (x, y, w, h) = enchant_button(i); + [x, y, w, h] + }) + .collect(), + Kind::Stonecutter if s.inputs[0].is_some() => (s.start + ..(s.start + 12).min(s.recipes.len())) + .map(|index| { + let p = index - s.start; + [ + 52.0 + (p % 4) as f32 * 16.0, + 14.0 + (p / 4) as f32 * 18.0, + 16.0, + 18.0, + ] + }) + .collect(), + _ => Vec::new(), + } + } pub(super) fn station_click(&mut self, right: bool) -> bool { if self.station.is_none() && !self.brewing_open() { return false; diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 738e3965b..97ef469a8 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -507,6 +507,16 @@ impl Game<'_> { } false } + /// The offer buttons a controller can focus (GUI origin relative). + pub(super) fn merchant_targets(&self) -> Vec<[f32; 4]> { + let Some(m) = self.station.as_ref().and_then(|s| s.merchant.as_ref()) else { + return Vec::new(); + }; + (0..7) + .filter(|i| i + m.scroll < m.session.offers.len()) + .map(|i| [5.0 + SHIFT, 18.0 + 20.0 * i as f32, 88.0, 20.0]) + .collect() + } pub(super) fn merchant_scroll(&mut self, d: f32) { let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) else { return; diff --git a/crates/horizon-client/src/gamepad.rs b/crates/horizon-client/src/gamepad.rs index e2bc7eefd..d67121fbf 100644 --- a/crates/horizon-client/src/gamepad.rs +++ b/crates/horizon-client/src/gamepad.rs @@ -684,14 +684,24 @@ pub fn stick_direction(stick: [f32; 2], threshold: f32) -> Option