Controllers: focus follows scrolled lists, clipped targets, pad status shows focus

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-15 01:20:14 +02:00
parent 9927d06049
commit 210a6f5ddd
14 changed files with 500 additions and 71 deletions
+11
View File
@@ -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();
+18
View File
@@ -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
+6
View File
@@ -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,
+108 -36
View File
@@ -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<Rect>,
}
#[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<Target> {
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<Target> {
let mut targets: Vec<Target> = 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<Rect> = 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<Rect> = 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, &centres, 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, &centres, 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::<Vec<_>>(),
})
}
// ---- text fields ----
/// A text field has keyboard focus: chat, a sign, a book being written,
@@ -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<horizon_client::widgets::Target> {
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()
}
@@ -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;
+10
View File
@@ -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;
+76 -11
View File
@@ -684,14 +684,24 @@ pub fn stick_direction(stick: [f32; 2], threshold: f32) -> Option<Button> {
/// the focused edge's centre (`nextFocusPathVaguelyInDirection`). Bounds are
/// inclusive as in `ScreenRectangle` (a right edge is `x + w - 1`).
pub fn focus_step_rect(from: Rect, button: Button, targets: &[Rect]) -> Option<usize> {
// (axis: 0 = x, 1 = y; positive direction)
let (axis, positive) = match button {
Button::DPadUp => (1, false),
Button::DPadDown => (1, true),
Button::DPadLeft => (0, false),
Button::DPadRight => (0, true),
_ => return None,
};
focus_step_strict(from, button, targets).or_else(|| focus_step_vague(from, button, targets))
}
/// The direction as (axis: 0 = x, 1 = y; positive).
fn direction(button: Button) -> Option<(usize, bool)> {
match button {
Button::DPadUp => Some((1, false)),
Button::DPadDown => Some((1, true)),
Button::DPadLeft => Some((0, false)),
Button::DPadRight => Some((0, true)),
_ => None,
}
}
/// The first pass of [`focus_step_rect`]: targets overlapping the focused
/// one on the other axis (a row, a column).
pub fn focus_step_strict(from: Rect, button: Button, targets: &[Rect]) -> Option<usize> {
let (axis, positive) = direction(button)?;
let other = 1 - axis;
let lo = |r: &Rect, a: usize| r[a];
let hi = |r: &Rect, a: usize| r[a] + r[a + 2] - 1.0;
@@ -725,9 +735,22 @@ pub fn focus_step_rect(from: Rect, button: Button, targets: &[Rect]) -> Option<u
};
primary.then(a.2.total_cmp(&b.2))
});
if let Some((i, ..)) = strict.first() {
return Some(*i);
}
strict.first().map(|s| s.0)
}
/// The second pass of [`focus_step_rect`]: the target whose facing edge's
/// centre is nearest the focused edge's centre.
pub fn focus_step_vague(from: Rect, button: Button, targets: &[Rect]) -> Option<usize> {
let (axis, positive) = direction(button)?;
let other = 1 - axis;
let lo = |r: &Rect, a: usize| r[a];
let hi = |r: &Rect, a: usize| r[a] + r[a + 2] - 1.0;
let bound = |r: &Rect, a: usize, pos: bool| if pos { hi(r, a) } else { lo(r, a) };
let after = |a: f32, b: f32| if positive { a > b + 1e-3 } else { b > a + 1e-3 };
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
let mut strip = from;
strip[axis] = bound(&from, axis, positive) + if positive { 1.0 } else { -1.0 };
strip[axis + 2] = 1.0;
let centre = |r: &Rect, a: usize| (hi(r, a) + lo(r, a)) / 2.0;
let edge = bound(&strip, axis, positive);
let mid = centre(&strip, other);
@@ -746,6 +769,37 @@ pub fn focus_step_rect(from: Rect, button: Button, targets: &[Rect]) -> Option<u
.map(|(i, _)| i)
}
/// How far a list scrolled between two frames' targets: the vertical shift
/// most targets of the same position and size agree on (targets that didn't
/// move, outside the list, don't count). `None` when nothing moved.
pub fn scroll_shift(before: &[Rect], after: &[Rect]) -> Option<f32> {
let same = |a: &Rect, b: &Rect| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.5);
if before.len() == after.len() && before.iter().zip(after).all(|(a, b)| same(a, b)) {
return None;
}
let mut votes: Vec<(f32, usize)> = Vec::new();
for b in before {
for a in after {
let dy = a[1] - b[1];
if (a[0] - b[0]).abs() < 0.5
&& (a[2] - b[2]).abs() < 0.5
&& (a[3] - b[3]).abs() < 0.5
&& dy.abs() >= 0.5
{
match votes.iter_mut().find(|v| (v.0 - dy).abs() < 0.5) {
Some(v) => v.1 += 1,
None => votes.push((dy, 1)),
}
}
}
}
// Most votes; among equals the smallest move (the next row, not a far one).
votes
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then(b.0.abs().total_cmp(&a.0.abs())))
.map(|v| v.0)
}
/// The target a remembered focus rectangle stands for in a new frame: the
/// same rectangle, else the nearest centre within `radius` (the screen
/// moved it a little: a scrolled list, a resized window).
@@ -1091,6 +1145,17 @@ mod tests {
// A list scrolled by 4 pixels keeps the row.
assert_eq!(refind([0.0, 24.0, 10.0, 10.0], &rects, 8.0), Some(1));
assert_eq!(refind([90.0, 90.0, 10.0, 10.0], &rects, 8.0), None);
// Rows 24 apart scrolled up by 24 under a fixed button.
let before: Vec<Rect> = (0..5)
.map(|i| [10.0, 40.0 + 24.0 * i as f32, 100.0, 20.0])
.chain([[10.0, 200.0, 50.0, 20.0]])
.collect();
let after: Vec<Rect> = (0..5)
.map(|i| [10.0, 16.0 + 24.0 * i as f32, 100.0, 20.0])
.chain([[10.0, 200.0, 50.0, 20.0]])
.collect();
assert_eq!(scroll_shift(&before, &after), Some(-24.0));
assert_eq!(scroll_shift(&before, &before), None);
let tabs = [
([60.0, 0.0, 26.0, 32.0], false),
([0.0, 0.0, 26.0, 32.0], true),
+4
View File
@@ -240,6 +240,10 @@ impl Screen {
blink: bool,
) {
let w = batch.width;
crate::widgets::target_as(
[w / 2.0 - 100.0, y + 11.0, 200.0, 20.0],
crate::widgets::Kind::Field,
);
batch.text(
atlas,
label,
@@ -308,6 +308,7 @@ impl OptionsUi {
y -= self.hscroll;
let vstart = ui.batch.vertices.len();
let hstart = ui.hits.len();
let tstart = crate::widgets::targets_mark();
let mut sliders = Vec::new();
ui.divider(x, y, cw);
let control_x = x + cw;
@@ -458,6 +459,9 @@ impl OptionsUi {
let k = ui.density / scale;
let (y0, y1) = ((top - 1.0 + oy) * k, (bottom + oy) * k);
ui.batch.clip_since(vstart, 0.0, y0, ui.batch.width, y1);
if ui.is_real() {
crate::widgets::clip_targets(tstart, y0, y1);
}
let (cy0, cy1) = (top - 1.0 + oy, bottom + oy);
let kept: Vec<_> = ui
.hits
+4
View File
@@ -854,6 +854,10 @@ impl Title {
.enumerate()
{
let y = 40.0 + i as f32 * 40.0;
crate::widgets::target_as(
[(w / 2.0).floor() - 100.0, y + 11.0, 200.0, 20.0],
crate::widgets::Kind::Field,
);
batch.text(
atlas,
label,
+4
View File
@@ -477,6 +477,10 @@ impl<'a> Ui<'a> {
ui.real = false;
ui
}
/// A drawn frame, not a hit-testing pass.
pub fn is_real(&self) -> bool {
self.real
}
/// Move rasterized glyphs into the batch's atlas uploads (real frames only).
pub fn finish(self) {
if self.real {
+32
View File
@@ -74,6 +74,27 @@ pub fn target_as(rect: [f32; 4], kind: Kind) {
});
}
/// How many targets the frame has so far, for [`clip_targets`].
pub fn targets_mark() -> usize {
TARGETS.with_borrow(|(current, _, _)| current.len())
}
/// Drop the targets registered since `mark` that aren't inside `y0..y1`
/// (GUI pixels): rows a scrolling list clipped away.
pub fn clip_targets(mark: usize, y0: f32, y1: f32) {
TARGETS.with_borrow_mut(|(current, _, _)| {
let mut i = mark.min(current.len());
while i < current.len() {
let [_, y, _, h] = current[i].rect;
if y < y0 - 0.5 || y + h > y1 + 0.5 {
current.remove(i);
} else {
i += 1;
}
}
});
}
/// The focus targets of the last drawn frame.
pub fn last_targets() -> Vec<Target> {
TARGETS.with_borrow(|(_, last, _)| last.clone())
@@ -198,5 +219,16 @@ mod tests {
assert_eq!(generation(), before + 1);
end_frame(2);
assert!(last().is_empty() && last_targets().is_empty());
target([0.0, 0.0, 5.0, 5.0]);
let mark = targets_mark();
target([0.0, 10.0, 5.0, 5.0]);
target([0.0, 40.0, 5.0, 5.0]);
clip_targets(mark, 8.0, 30.0);
end_frame(3);
assert_eq!(
last_targets().len(),
2,
"the clipped row is gone, earlier ones stay"
);
}
}
+131 -24
View File
@@ -70,25 +70,128 @@ hold-to-sneak.
## Menus and inventories
Vanilla's screens are all mouse-driven, and Horizon draws them 1:1, so a pad
drives them through a **virtual cursor** (as Bedrock's classic inventory UI
and Java's controller mods do), not focus navigation:
Vanilla's screens are all mouse-driven, and Horizon draws them 1:1. A pad
drives them in one of two ways, picked in the controller options ("Menu
Navigation: Focus / Pointer", `menuNavigation:focus|pointer`):
- **Focus** (the default): a console-style highlight that the D-pad and the
left stick move between buttons, sliders, switches, text fields, list
rows, tabs and inventory slots.
- **Pointer**: a virtual cursor the left stick moves, as Bedrock's classic
inventory UI and Java's controller mods do.
Focus is the default because it is what a pad player expects from a console
game, it needs no aiming (every widget is one D-pad press away, which also
suits the Steam Deck's small screen), and it works from the couch. The
pointer stays one option away, and a **right stick click swaps the two for
the open screen**, for the few places a pointer is better (dragging the
advancements map, painting a crafting grid with a held stack).
Both modes feed the screens the same pointer events a mouse sends: in focus
mode the (hidden) pointer sits on the focused widget. So every screen keeps
its mouse behaviour (drags, sliders, scrollbars, hover highlights) with no
controller code of its own, and both modes reach every screen. Moving the
real mouse hands control back at once.
### Focus navigation
Every interactive rectangle a screen draws registers itself as a focus
target with its kind (`widgets::target_as`): classic buttons, sliders, edit
boxes and list rows (worlds, servers, languages), every Horizon-style widget
(`ui_theme::Ui::interact`: sidebar items, switches, segments, sliders,
fields, buttons), inventory and container slots, creative and recipe-book
tabs, and the stations' own buttons (trades, loom patterns, beacon effects,
enchantments, stonecutter recipes, the recipe book's recipes, pages and
filter). A screen that registers nothing falls back to the pointer.
The step itself is **vanilla's arrow-key navigation**
(`ContainerEventHandler.nextFocusPathInDirection`, 26.3), applied to every
target of the screen at once: from a strip one pixel past the focused edge,
the nearest target that overlaps it on the other axis wins (ties go to the
leftmost or topmost); without one, the target whose facing edge's centre is
nearest the focused edge's centre (`nextFocusPathVaguelyInDirection`). That
gives vanilla-like jumps in containers too: hotbar to inventory, the grid to
the crafting result, armour to the player's grid.
| Input | Focus mode |
|---|---|
| D-pad, left stick | move focus (held: repeats after 0.35 s, every 0.09 s) |
| A | press the focused widget (held: drag); on a text field, type with the on-screen keyboard |
| B, Menu | back (Escape) |
| X | right click (inventories: take half, place one) |
| Y | quick move (shift-click) in inventories |
| View | throw one item from the focused slot (Q) |
| Left stick click | throw the focused stack (Ctrl+Q) |
| LB / RB | previous / next tab (creative, recipe book, the Horizon options' sections) |
| Right stick | scroll |
| Right stick click | swap focus and pointer for this screen |
- **Sliders**: left and right move the value (one pixel per press like
vanilla's arrow keys, faster while held); A does nothing on a slider, as a
click would jump the handle.
- **Lists** follow focus: a step past the last visible row scrolls the list
and lands on the next row once it is drawn.
- **Inventories**: the carried stack is drawn on the focused slot (vanilla
draws it under the pointer). A takes or places, X splits or places one, Y
quick-moves, View and the left stick click throw.
- **Where focus lands** when a screen opens: the held hotbar slot in an
inventory, else the first button.
How it looks follows the menu style:
- **Classic screens** look as vanilla's own keyboard focus does: buttons,
sliders and slots show their highlighted sprite (`isHoveredOrFocused`,
drawn because the hidden pointer is over them), and list rows and text
fields get vanilla's white one-pixel outline (`AbstractSelectionList`'s
focused selection, `EditBox`'s highlighted border).
- **Horizon-style screens** get the design system's focus colour: a two-dp
ring in the accent colour around the focused widget, pill-shaped on
buttons, tabs and sliders.
### Pointer
- Left stick moves the cursor with acceleration; in container screens the
cursor is gently pulled toward the nearest slot centre.
- D-pad steps one slot (18 GUI pixels in containers) or one widget row in
menus and snaps to it.
- A = left click (take / place stack, press a button), X = right click
(half stack / place one), Y = quick move (shift-click), B = back / close.
- LB / RB switch creative tabs and recipe book pages; right stick scrolls
(creative grid, lists, chat).
- D-pad steps to the nearest target that way (a slot, a button) and snaps to
it.
- A = left click, X = right click, Y = quick move, B = back; View and the left
stick click throw, as in focus mode.
- LB / RB switch tabs; right stick scrolls.
- Sliders: hold A on the slider and move the stick, or nudge with the D-pad.
- Text fields: the Steam Deck's keyboard (Steam + X) types into them like a
real keyboard. Other platforms have no on-screen keyboard yet; text input
needs a keyboard.
The cursor is drawn only while the pad is the last-used input; moving the
mouse hands control back immediately.
The cursor is drawn only while the pad is the last-used input.
### Text fields
A on a focused text field (A in pointer mode clicks into it) opens the
**on-screen keyboard**: a QWERTY grid and a symbols grid in Horizon's design
language, at the bottom of the screen (at the top when the field is in the
lower half, as chat is). D-pad or stick to move, A types, X deletes, Y is a
space, LT is Shift (twice: caps lock), RB switches to symbols and accents,
Menu is Enter, B closes it. It opens by itself when the pad opens chat or a
sign. It types into every field the keyboard reaches: chat (local and
server), signs, books, the title screens' fields, the options' searches, the
creative and recipe-book searches, the anvil, the LAN port.
On a Steam Deck under Steam (`SteamDeck=1`), A asks Steam for its own
keyboard instead (`steam://open/keyboard`), which types like a real keyboard;
Steam + X opens it at any time. "On-Screen Keyboard: Auto / Horizon / Steam"
(`keyboard:`) picks one.
### Screens covered
Both modes reach every screen through the pointer events. 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 (classic pages and the Horizon-style sections, key and controller binds,
language list), pause, 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,
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 right stick click gives the pointer for that screen.
## Glyphs
@@ -161,14 +264,18 @@ no force feedback, so it is silently skipped there.
## PR plan
1. **Input layer and gameplay** (this note, gilrs, defaults, movement,
camera, actions, hotbar, B/Start to leave screens, `horizon-ctl pad`,
`horizon-controller.txt` with the look/deadzone settings).
2. **Menus and inventories**: virtual cursor, slot snapping, clicks, quick
move, tabs, scrolling, sliders.
3. **Glyphs and the Controller options page**: glyph drawing per pad type,
the options page, remapping UI.
4. **Rumble**.
The first round (input layer, menus, glyphs and options, rumble) shipped in
PRs #120, #129, #145 and #149. The second round, each shippable alone:
Each is shippable alone. Testing is with unit tests, the injected pad and
vscreen screenshots; each PR says what still needs a real controller.
1. **Focus navigation** (this section above): the menu navigation option,
focus targets and vanilla's arrow-key step, the focus highlight in both
menu styles, the on-screen keyboard.
2. **Button prompts**: contextual hints with the pad's glyphs in menus and
optionally in game, and rebinding from them.
3. **DualSense output**: adaptive triggers, lightbar, player LEDs and richer
rumble over HID, off-safe.
4. **Launcher**: the same focus navigation in the gpui launcher.
Each is tested with unit tests (focus steps, prompt contexts, report bytes),
the injected pad and vscreen screenshots; each PR says what still needs a
real controller.