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:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¢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::<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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user