From 7e4012c83ec859b5fa18f6f5975e49f97719854a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:41:12 +0100 Subject: [PATCH 1/2] Mod API: a key widget, so players bind a mod's keys (ui, input) A ui page's key widget shows one of the mod's keys as Key Binds does ("Zoom: Z"). A click waits for the next key or mouse button, which becomes the key; Esc binds none. The game keeps the choice in horizon-mods.txt (key..:key.keyboard.z), applies it when the mods load and to keys registered later, and lets a held key go when it is rebound. register-key's key is now the default. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 7 +- crates/horizon-client/src/game/mods.rs | 123 +++++++++++++++++- crates/horizon-client/src/mod_list.rs | 52 +++++++- crates/horizon-mod-sdk/wit/horizon.wit | 15 ++- crates/horizon-mods/src/runtime.rs | 67 +++++++++- crates/horizon-mods/tests/sandbox.rs | 41 +++++- crates/horizon-mods/tests/ui_guest/src/lib.rs | 11 +- docs/api/mods-reference.md | 12 +- docs/mods.md | 2 +- 9 files changed, 312 insertions(+), 18 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 3f7450501..bb68dc590 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -10451,6 +10451,9 @@ impl Game<'_> { } return Ok(()); } + if self.screen == Screen::Mod && self.mod_screen_key(key) { + return Ok(()); + } if self.sort_key_press(key) { return Ok(()); } @@ -11363,9 +11366,7 @@ impl ApplicationHandler for Game<'_> { .map_or([1, 1], |r| [r.config.width, r.config.height]); self.stats_click(size); } - Screen::Mod if pressed && button == MouseButton::Left => { - self.mod_screen_click() - } + Screen::Mod if pressed => self.mod_screen_click(button), _ => {} } } diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 6211f43b1..66d00b233 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -29,6 +29,7 @@ use { }, horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget}, std::time::Instant, + winit::{event::MouseButton, keyboard::KeyCode}, }; #[derive(Default)] @@ -101,6 +102,8 @@ struct ModScreen { under: Screen, /// One scroll per `list` widget, in widget order. scrolls: Vec, + /// A key widget waiting for the key to bind: the mod key's name. + binding: Option, } impl ModsState { /// The chat's command tree: vanilla's, plus the running mods' commands. @@ -240,7 +243,10 @@ impl Game<'_> { pub(super) fn open_mod_screen(&mut self, _owner: usize, _settings: bool) {} pub(super) fn close_mod_screen(&mut self, _tell: bool) {} pub(super) fn draw_mod_screen(&mut self, _batch: &mut HudBatch) {} - pub(super) fn mod_screen_click(&mut self) {} + pub(super) fn mod_screen_click(&mut self, _button: winit::event::MouseButton) {} + pub(super) fn mod_screen_key(&mut self, _key: winit::keyboard::KeyCode) -> bool { + false + } pub(super) fn mod_screen_scroll(&mut self, _delta: f32) {} } @@ -263,6 +269,8 @@ impl Game<'_> { let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id); let mods = Mods::load(&paths, on, vanilla_command); self.mods.mods = mods; + let chosen = &self.settings.mods; + self.mods.mods.set_bindings(|id| chosen.keys_of(id)); // Entries were replaced wholesale: a screen's owner index is stale. self.mods.screen = None; self.list_mods(); @@ -429,6 +437,8 @@ impl Game<'_> { { self.mods.checked = Some(Instant::now()); if self.mods.mods.reload_changed(vanilla_command) { + let chosen = &self.settings.mods; + self.mods.mods.set_bindings(|id| chosen.keys_of(id)); self.list_mods(); self.sync_mod_commands(); self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY)); @@ -579,6 +589,72 @@ impl Game<'_> { Some((s.title.as_str(), s.widgets.as_slice())) } } + /// A key widget's label as Key Binds shows one, "Zoom: Z", marked + /// "> Zoom: Z <" in yellow while it waits for the new key. + fn mod_key_label(mods: &Mods, owner: usize, name: &str, waiting: bool) -> Text { + let (label, key) = mods + .keys_of(owner) + .iter() + .find(|k| k.name == name) + .map_or((name, "key.keyboard.unknown"), |k| { + (k.label.as_str(), k.key.as_str()) + }); + let value = format!("{label}: {}", keybinds::display_name(key).plain()); + if waiting { + Text::literal(format!("> {value} <")).with_color(text::YELLOW) + } else { + Text::literal(value) + } + } + /// The player bound the key widget that waited to `key`: the mod's key + /// follows it now and at the next start (`horizon-mods.txt`). + fn bind_mod_key(&mut self, key: String) { + let Some(ms) = self.mods.screen.as_mut() else { + return; + }; + let Some(name) = ms.binding.take() else { + return; + }; + let owner = ms.owner; + if !self.mods.mods.rebind(owner, &name, &key) { + return; + } + let Some(id) = self + .mods + .mods + .entries() + .get(owner) + .and_then(|e| e.manifest.as_ref()) + .map(|m| m.id.clone()) + else { + return; + }; + self.settings + .mods + .keys + .entry(id) + .or_default() + .insert(name, key); + self.settings.save(&Self::options_path()); + } + /// A key while a mod screen is up: true when a key widget waited for + /// it (Key Binds: Escape binds none). + pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool { + if self + .mods + .screen + .as_ref() + .is_none_or(|ms| ms.binding.is_none()) + { + return false; + } + let value = match key { + KeyCode::Escape => None, + k => keybinds::key_name(k), + }; + self.bind_mod_key(value.unwrap_or("key.keyboard.unknown").to_owned()); + true + } /// The layout the mod screen's widgets get at `h`: (widget, top, /// height), the column being `COLUMN` wide down the middle. fn mod_ui_layout(h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> { @@ -587,7 +663,7 @@ impl Game<'_> { for w in widgets { let wh = match w { UiWidget::Text(_) => 12.0, - UiWidget::Item(_) | UiWidget::Button(_) => 20.0, + UiWidget::Item(_) | UiWidget::Button(_) | UiWidget::Key(_) => 20.0, UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0, }; // Nothing may cover Done; a crowded mod loses what won't fit. @@ -617,6 +693,7 @@ impl Game<'_> { settings, under: self.screen, scrolls: Vec::new(), + binding: None, }); self.screen = Screen::Mod; if self.player.is_some() { @@ -697,6 +774,22 @@ impl Game<'_> { hover, ); } + UiWidget::Key(k) => { + let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0); + let waiting = ms.binding.as_deref() == Some(k.key.as_str()); + let label = Self::mod_key_label(&self.mods.mods, ms.owner, &k.key, waiting); + ui::button( + batch, + atlas, + x, + wy, + MOD_UI_COLUMN, + 20.0, + &label, + true, + hover, + ); + } UiWidget::Text(t) => { let color = if t.color == 0 { u32::MAX @@ -798,7 +891,20 @@ impl Game<'_> { } /// A click on the mod screen: Done leaves it, a `list` row is the mod's /// `ui-events::select`. - pub(super) fn mod_screen_click(&mut self) { + pub(super) fn mod_screen_click(&mut self, button: MouseButton) { + // Key Binds: a mouse button pressed while waiting binds it. + if self + .mods + .screen + .as_ref() + .is_some_and(|ms| ms.binding.is_some()) + { + self.bind_mod_key(keybinds::mouse_name(button)); + return; + } + if button != MouseButton::Left { + return; + } let (w, h) = self.gui_size(); let cursor = self.cursor; if ui::hovered(cursor, (w / 2.0).floor() - 100.0, h - 27.0, 200.0, 20.0) { @@ -829,6 +935,17 @@ impl Game<'_> { self.show_mod_output(); return; } + // A key widget waits for the key to bind. + if let UiWidget::Key(k) = w + && ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0) + { + self.audio.play_button_click(); + let name = k.key.clone(); + if let Some(ms) = self.mods.screen.as_mut() { + ms.binding = Some(name); + } + return; + } let UiWidget::List(l) = w else { continue }; let scroll = ms.scrolls.get(list_i).copied().unwrap_or_default(); list_i += 1; diff --git a/crates/horizon-client/src/mod_list.rs b/crates/horizon-client/src/mod_list.rs index db2ea9194..325839738 100644 --- a/crates/horizon-client/src/mod_list.rs +++ b/crates/horizon-client/src/mod_list.rs @@ -1,7 +1,11 @@ //! Which of the installed Horizon mods are on (`docs/mods.md`), kept in //! `horizon-mods.txt` beside `options.txt` as `mod.:true` lines. Every //! mod is off until the player turns it on in Options > Mods. -use std::{collections::BTreeSet, fs, path::Path}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; pub const FILE: &str = "horizon-mods.txt"; /// The folder holding `.hmod` files, beside `options.txt`. @@ -11,6 +15,9 @@ pub const DIR: &str = "horizon-mods"; pub struct ModList { /// Ids of the mods the player turned on. pub enabled: BTreeSet, + /// The keys the player chose for mods' keys, mod id → key name → key + /// (`key..:key.keyboard.x` lines). + pub keys: BTreeMap>, /// What the Mods page shows, filled by the game from the mods it found. pub installed: Vec, } @@ -45,20 +52,41 @@ impl ModList { .filter_map(|(k, _)| k.trim().strip_prefix("mod.")) .map(str::to_owned) .collect(); + let mut keys: BTreeMap> = BTreeMap::new(); + for (k, v) in text.lines().filter_map(|l| l.split_once(':')) { + // Mod ids have no dots; a key's name may. + if let Some((id, name)) = k + .trim() + .strip_prefix("key.") + .and_then(|r| r.split_once('.')) + { + let entry = keys.entry(id.to_owned()).or_default(); + entry.insert(name.to_owned(), v.trim().to_owned()); + } + } Self { enabled, + keys, installed: Vec::new(), } } pub fn serialize(&self) -> String { - self.enabled - .iter() - .map(|id| format!("mod.{id}:true\n")) - .collect() + let on = self.enabled.iter().map(|id| format!("mod.{id}:true\n")); + let keys = self.keys.iter().flat_map(|(id, keys)| { + keys.iter() + .map(move |(name, key)| format!("key.{id}.{name}:{key}\n")) + }); + on.chain(keys).collect() + } + /// Mod `id`'s chosen keys, for `Mods::set_bindings`. + pub fn keys_of(&self, id: &str) -> Vec<(&str, &str)> { + self.keys.get(id).map_or_else(Vec::new, |k| { + k.iter().map(|(n, k)| (n.as_str(), k.as_str())).collect() + }) } pub fn save(&self, dir: &Path) { let path = dir.join(FILE); - if self.enabled.is_empty() && !path.exists() { + if self.enabled.is_empty() && self.keys.is_empty() && !path.exists() { return; } let _ = fs::write(path, self.serialize()); @@ -91,6 +119,18 @@ mod tests { assert_eq!(ModList::default().serialize(), ""); } + #[test] + fn keeps_the_keys_players_chose() { + let text = "mod.zoom:true\nkey.zoom.zoom.in:key.mouse.4\nkey.zoom.out:key.keyboard.x\n"; + let list = ModList::parse(text); + assert_eq!( + list.keys_of("zoom"), + [("out", "key.keyboard.x"), ("zoom.in", "key.mouse.4")] + ); + assert!(list.keys_of("other").is_empty()); + assert_eq!(ModList::parse(&list.serialize()), list); + } + #[test] fn toggles_installed_mods_but_not_dev_ones() { let installed = |id: &str, dev| Installed { diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 820a82483..f9c147cfa 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -137,6 +137,16 @@ interface ui { /// Up to 256 characters. label: string, } + /// One of the mod's keys (`input.register-key`) as Controls shows a + /// binding, "Zoom: Z". A click waits for the next key or mouse button, + /// which becomes the key (Esc: none). The game keeps the player's + /// choice across starts; the mod's `key-down` and `key-up` follow it. + record key-widget { + /// The mod's own name for the widget. + id: u32, + /// The name the mod registered the key under. + key: string, + } variant widget { /// Text the player reads. text(text-widget), @@ -146,6 +156,8 @@ interface ui { item(item-widget), /// Rows the player picks from. %list(list-widget), + /// A key the player binds. + key(key-widget), } /// A screen the mod owns: a title over widgets stacked down the middle, /// the Done button the game adds, and Esc to leave it. @@ -220,7 +232,8 @@ interface input { claim-items: func(ids: list); /// A key of the mod's: `name` comes back in `key-down` and `key-up`, /// `label` says what it does, `key` is the key as options.txt writes - /// it (`key.keyboard.r`). At most 16; an unknown key is an error. + /// it (`key.keyboard.r`), the default: a key the player chose for it + /// (`ui`'s key widget) wins. At most 16; an unknown key is an error. register-key: func(name: string, label: string, key: string) -> result<_, string>; /// The main hand's stack, as of this tick; none when it is empty. held: func() -> option; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 0e7c079d2..f596db81c 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -37,7 +37,8 @@ use bindings::{ /// The widget model a `ui` screen or settings page shows (WIT `interface /// ui`), for the game to draw with its menus' own widgets. pub use ui_bindings::horizon::mods::ui::{ - ButtonWidget, ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget, + ButtonWidget, ItemWidget, KeyWidget, ListWidget, Screen as UiScreen, TextWidget, + Widget as UiWidget, }; use ui_bindings::{UiHost, horizon::mods::ui}; /// Capability `render`'s imports (docs/mods.md): the game draws the shapes. @@ -564,6 +565,48 @@ impl Mods { d.input.keys.iter().map(move |k| (d.name.as_str(), k)) }) } + /// Mod `i`'s keys, as it registered them (with the player's choices). + pub fn keys_of(&self, i: usize) -> &[ModKey] { + self.entries + .get(i) + .and_then(|e| e.run.as_ref()) + .map_or(&[], |r| r.store.data().input.keys.as_slice()) + } + /// The keys the player chose (`ui`'s key widget), by mod id: each + /// running mod's `name → key`. They replace the defaults registered + /// so far and later ones. Keys that aren't keys are skipped. + pub fn set_bindings<'a>(&mut self, chosen: impl Fn(&str) -> Vec<(&'a str, &'a str)>) { + for e in &mut self.entries { + let (Some(m), Some(run)) = (&e.manifest, e.run.as_mut()) else { + continue; + }; + let input = &mut run.store.data_mut().input; + input.bindings = chosen(&m.id) + .into_iter() + .filter(|(_, key)| key_ok(key)) + .map(|(n, k)| (n.to_owned(), k.to_owned())) + .collect(); + for (name, key) in input.bindings.clone() { + input.rebind(&name, key); + } + } + } + /// The player binds mod `i`'s key `name` to `key` (`key.keyboard.x`, + /// `key.mouse.4`, `key.keyboard.unknown` for none). False when the mod + /// has no such key or `key` isn't one. + pub fn rebind(&mut self, i: usize, name: &str, key: &str) -> bool { + let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) else { + return false; + }; + let input = &mut run.store.data_mut().input; + if !key_ok(key) || !input.keys.iter().any(|k| k.name == name) { + return false; + } + input.bindings.retain(|(n, _)| n != name); + input.bindings.push((name.to_owned(), key.to_owned())); + input.rebind(name, key.to_owned()); + true + } /// The use button went down with `item`: `use-start` to each mod that /// claimed it. pub fn use_start(&mut self, item: &HeldItem) { @@ -813,6 +856,10 @@ fn clip_widgets(widgets: Vec) -> Vec { l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len()); UiWidget::List(l) } + UiWidget::Key(mut k) => { + k.key = clip(k.key); + UiWidget::Key(k) + } }) .collect() } @@ -873,6 +920,9 @@ struct InputState { down: Vec, /// It holds the mouse wheel. wheel: bool, + /// The keys the player chose, `name → key`: they win over + /// `register-key`'s. + bindings: Vec<(String, String)>, } /// Events kept for `poll`, items claimed and keys, per mod. const INPUT_EVENTS: usize = 256; @@ -893,6 +943,17 @@ fn key_ok(key: &str) -> bool { } impl InputState { + /// Key `name` is `key` now; held, it lets go first. + fn rebind(&mut self, name: &str, key: String) { + let Some(k) = self.keys.iter_mut().find(|k| k.name == name) else { + return; + }; + k.key = key; + if let Some(i) = self.down.iter().position(|n| n == name) { + let name = self.down.swap_remove(i); + self.push(InputEvent::KeyUp(name)); + } + } fn push(&mut self, event: InputEvent) { if self.events.len() == INPUT_EVENTS { self.events.pop_front(); @@ -912,6 +973,10 @@ impl input::Host for State { )); } let (name, label) = (clip(name), clip(label)); + let key = match self.input.bindings.iter().find(|(n, _)| *n == name) { + Some((_, chosen)) => chosen.clone(), + None => key, + }; if let Some(k) = self.input.keys.iter_mut().find(|k| k.name == name) { (k.label, k.key) = (label, key); return Ok(()); diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 29577a9c6..793d03db6 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -449,7 +449,7 @@ fn ui_screens_settings_and_events() { assert_eq!(mods.settings_widgets(0).len(), 3); assert!(mods.entries()[0].has_ui_settings()); mods.ui_settings_opened(0); - assert_eq!(mods.settings_widgets(0).len(), 2); + assert_eq!(mods.settings_widgets(0).len(), 3); // `/ui_close` closes the mod's own screen. assert!(mods.command("ui_close")); assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 })); @@ -553,6 +553,45 @@ fn input_claims_keys_and_events() { assert_eq!(chat(&mut mods), ["key-down wheel", "key-up wheel"]); } +#[test] +fn the_players_key_choice_wins() { + let chat = |mods: &mut Mods| -> Vec { + mods.drain() + .into_iter() + .filter_map(|o| match o { + Output::Chat { text, .. } => Some(text), + _ => None, + }) + .collect() + }; + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + assert!(mods.command("input_claim")); + assert!(matches!( + &mods.settings_widgets(0)[0], + horizon_mods::UiWidget::Key(k) if k.id == 9 && k.key == "wheel" + )); + // Rebinding while held lets the old key go. + mods.key("key.keyboard.r", true); + assert!(mods.rebind(0, "wheel", "key.mouse.4")); + assert_eq!(mods.keys_of(0)[0].key, "key.mouse.4"); + mods.key("key.keyboard.r", false); + mods.key("key.mouse.4", true); + assert!(!mods.rebind(0, "wheel", "R") && !mods.rebind(0, "nope", "key.keyboard.g")); + chat(&mut mods); + assert!(mods.command("input_poll")); + assert_eq!( + chat(&mut mods), + ["key-down wheel", "key-up wheel", "key-down wheel"] + ); + // A choice saved earlier replaces the default, registered before or after. + let mut mods = load("horizon_mods_test_ui_guest.hmod"); + mods.set_bindings(|_| vec![("wheel", "key.keyboard.g"), ("bad", "G")]); + assert!(mods.command("input_claim")); + assert_eq!(mods.keys_of(0)[0].key, "key.keyboard.g"); + mods.set_bindings(|_| vec![("wheel", "key.keyboard.h")]); + assert_eq!(mods.keys_of(0)[0].key, "key.keyboard.h"); +} + #[test] fn input_wheel_is_the_mods_while_claimed() { let mut mods = load("horizon_mods_test_ui_guest.hmod"); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index 9bede11f9..f2765e8da 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -9,7 +9,7 @@ use horizon_mod_sdk::{ Mod, Registration, chat, input::{self, Event}, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, - ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, + ui::{self, ItemWidget, KeyWidget, ListWidget, Screen, TextWidget, Widget}, }; /// One of each shape, the text too long by 44 characters. @@ -162,6 +162,10 @@ impl Mod for Guest { // Not a key: refused. let bad = input::register_key("bad", "Bad", "R").unwrap_err(); chat::show(&format!("claimed; {bad}")); + ui::set_settings(&[Widget::Key(KeyWidget { + id: 9, + key: "wheel".into(), + })]); } "input_poll" => { for event in input::poll() { @@ -225,6 +229,11 @@ impl Mod for Guest { id: 3, label: "Corner: Top Right".into(), }), + // `/input_claim`'s key; drawn as its name until registered. + Widget::Key(KeyWidget { + id: 9, + key: "wheel".into(), + }), ]); chat::show("settings opened"); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 0deeb0ddd..f525ace89 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -197,6 +197,15 @@ A thing on a mod's screen or settings page. A button as the options pages have t | `id` | `u32` | The mod's own name for the widget. | | `label` | `string` | Up to 256 characters. | +### record `key-widget` + +One of the mod's keys (`input.register-key`) as Controls shows a binding, "Zoom: Z". A click waits for the next key or mouse button, which becomes the key (Esc: none). The game keeps the player's choice across starts; the mod's `key-down` and `key-up` follow it. + +| Field | Type | | +| --- | --- | --- | +| `id` | `u32` | The mod's own name for the widget. | +| `key` | `string` | The name the mod registered the key under. | + ### variant `widget` | Case | Payload | | @@ -205,6 +214,7 @@ A thing on a mod's screen or settings page. A button as the options pages have t | `button` | `button-widget` | A button. | | `item` | `item-widget` | An item's icon and name. | | `list` | `list-widget` | Rows the player picks from. | +| `key` | `key-widget` | A key the player binds. | ### record `screen` @@ -278,7 +288,7 @@ A mapping as the player has bound it. | Function | Signature | What it does | | --- | --- | --- | | `claim-items` | `func(ids: list)` | The items whose use the mod handles instead of the game, by id (at most 64). Replaces the previous list. | -| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`). At most 16; an unknown key is an error. | +| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`), the default: a key the player chose for it (`ui`'s key widget) wins. At most 16; an unknown key is an error. | | `held` | `func() -> option` | The main hand's stack, as of this tick; none when it is empty. | | `poll` | `func() -> list` | The events since the last call, oldest first. | | `claim-wheel` | `func(on: bool)` | Takes the mouse wheel while playing (true) or gives it back: while taken, its steps are the mod's `wheel` events and the hotbar doesn't move. The game gives it back when the mod stops. | diff --git a/docs/mods.md b/docs/mods.md index 235574d6c..108e854be 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -87,7 +87,7 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item, list and key widgets the game lays out and draws with its own menus' code. A key widget binds one of the mod's keys (`input.register-key`) as Key Binds does: click, then press the key or mouse button (Esc: none); the game keeps the choice in `horizon-mods.txt` (`key..:key.keyboard.z`) and the mod's `key-down` follows it. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | | `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | From 3e3c0cebb51fa5deb0022ff1c0ca8fa4ba9fc42d Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:34:49 +0100 Subject: [PATCH 2/2] Mod keys: rows the game adds to the settings page, not a WIT widget A new case in ui's widget variant stops every ui mod built before it from loading: the runtime links a component only when each imported function's types match exactly ("expected variant of 5 cases, found 4"), so the published FPS Counter and Keystrokes would break. Instead, the game lists a mod's keys first on its page in Options > Mods, as Key Binds rows, and a mod with keys gets a page even without ui. No WIT type changes. docs/api/stability.md said a new case the mod sends isn't breaking; it is, and now says so. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/mods.rs | 113 ++++++++++-------- crates/horizon-mod-sdk/wit/horizon.wit | 17 +-- crates/horizon-mods/src/runtime.rs | 17 ++- crates/horizon-mods/tests/sandbox.rs | 8 +- crates/horizon-mods/tests/ui_guest/src/lib.rs | 11 +- docs/api/mods-reference.md | 12 +- docs/api/stability.md | 9 +- docs/mods.md | 4 +- 8 files changed, 86 insertions(+), 105 deletions(-) diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index 66d00b233..e55a87d46 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -27,7 +27,7 @@ use { text::{self, Text}, ui, }, - horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget}, + horizon_mods::{Clock, ModKey, Mods, Output, Player, Snapshot, Status, UiScreen, UiWidget}, std::time::Instant, winit::{event::MouseButton, keyboard::KeyCode}, }; @@ -102,7 +102,7 @@ struct ModScreen { under: Screen, /// One scroll per `list` widget, in widget order. scrolls: Vec, - /// A key widget waiting for the key to bind: the mod key's name. + /// A key row waiting for the key to bind: the mod key's name. binding: Option, } impl ModsState { @@ -575,7 +575,7 @@ impl Game<'_> { fn mod_ui_view<'a>(mods: &'a Mods, ms: &ModScreen) -> Option<(&'a str, &'a [UiWidget])> { if ms.settings { let widgets = mods.settings_widgets(ms.owner); - if widgets.is_empty() { + if widgets.is_empty() && mods.keys_of(ms.owner).is_empty() { return None; } let name = mods @@ -589,24 +589,25 @@ impl Game<'_> { Some((s.title.as_str(), s.widgets.as_slice())) } } - /// A key widget's label as Key Binds shows one, "Zoom: Z", marked + /// The keys a settings page lists first: the mod's own (`input`). + fn mod_page_keys<'a>(mods: &'a Mods, ms: &ModScreen) -> &'a [ModKey] { + if ms.settings { + mods.keys_of(ms.owner) + } else { + &[] + } + } + /// A key row's label as Key Binds shows one, "Zoom: Z", marked /// "> Zoom: Z <" in yellow while it waits for the new key. - fn mod_key_label(mods: &Mods, owner: usize, name: &str, waiting: bool) -> Text { - let (label, key) = mods - .keys_of(owner) - .iter() - .find(|k| k.name == name) - .map_or((name, "key.keyboard.unknown"), |k| { - (k.label.as_str(), k.key.as_str()) - }); - let value = format!("{label}: {}", keybinds::display_name(key).plain()); + fn mod_key_label(k: &ModKey, waiting: bool) -> Text { + let value = format!("{}: {}", k.label, keybinds::display_name(&k.key).plain()); if waiting { Text::literal(format!("> {value} <")).with_color(text::YELLOW) } else { Text::literal(value) } } - /// The player bound the key widget that waited to `key`: the mod's key + /// The player bound the key row that waited to `key`: the mod's key /// follows it now and at the next start (`horizon-mods.txt`). fn bind_mod_key(&mut self, key: String) { let Some(ms) = self.mods.screen.as_mut() else { @@ -637,7 +638,7 @@ impl Game<'_> { .insert(name, key); self.settings.save(&Self::options_path()); } - /// A key while a mod screen is up: true when a key widget waited for + /// A key while a mod screen is up: true when a key row waited for /// it (Key Binds: Escape binds none). pub(super) fn mod_screen_key(&mut self, key: KeyCode) -> bool { if self @@ -657,13 +658,13 @@ impl Game<'_> { } /// The layout the mod screen's widgets get at `h`: (widget, top, /// height), the column being `COLUMN` wide down the middle. - fn mod_ui_layout(h: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> { + fn mod_ui_layout(h: f32, top: f32, widgets: &[UiWidget]) -> Vec<(f32, f32)> { let mut out = Vec::with_capacity(widgets.len()); - let mut y = MOD_UI_TOP; + let mut y = top; for w in widgets { let wh = match w { UiWidget::Text(_) => 12.0, - UiWidget::Item(_) | UiWidget::Button(_) | UiWidget::Key(_) => 20.0, + UiWidget::Item(_) | UiWidget::Button(_) => 20.0, UiWidget::List(l) => (l.rows.len().min(6) as f32 * 12.0) + 8.0, }; // Nothing may cover Done; a crowded mod loses what won't fit. @@ -683,7 +684,8 @@ impl Game<'_> { } if settings { self.mods.mods.ui_settings_opened(owner); - if self.mods.mods.settings_widgets(owner).is_empty() { + let keys = self.mods.mods.keys_of(owner); + if self.mods.mods.settings_widgets(owner).is_empty() && keys.is_empty() { self.show_mod_output(); return; } @@ -755,7 +757,27 @@ impl Game<'_> { ); let cx = (batch.width / 2.0).floor(); let x = cx - MOD_UI_COLUMN / 2.0; - let layout = Self::mod_ui_layout(batch.height, widgets); + // A settings page starts with the mod's keys, as Key Binds rows. + let keys = Self::mod_page_keys(&self.mods.mods, ms); + for (i, k) in keys.iter().enumerate() { + let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW; + let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0); + let waiting = ms.binding.as_deref() == Some(k.name.as_str()); + let label = Self::mod_key_label(k, waiting); + ui::button( + batch, + atlas, + x, + wy, + MOD_UI_COLUMN, + 20.0, + &label, + true, + hover, + ); + } + let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW; + let layout = Self::mod_ui_layout(batch.height, top, widgets); let mut list_i = 0usize; for (w, &(wy, wh)) in widgets.iter().zip(&layout) { match w { @@ -774,22 +796,6 @@ impl Game<'_> { hover, ); } - UiWidget::Key(k) => { - let hover = ui::hovered(self.cursor, x, wy, MOD_UI_COLUMN, 20.0); - let waiting = ms.binding.as_deref() == Some(k.key.as_str()); - let label = Self::mod_key_label(&self.mods.mods, ms.owner, &k.key, waiting); - ui::button( - batch, - atlas, - x, - wy, - MOD_UI_COLUMN, - 20.0, - &label, - true, - hover, - ); - } UiWidget::Text(t) => { let color = if t.color == 0 { u32::MAX @@ -920,7 +926,22 @@ impl Game<'_> { }; let owner = ms.owner; let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0; - let layout = Self::mod_ui_layout(h, widgets); + // A key row waits for the key to bind. + let keys = Self::mod_page_keys(&self.mods.mods, ms); + let picked = (0..keys.len()).find(|&i| { + let wy = MOD_UI_TOP + i as f32 * MOD_UI_KEY_ROW; + ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0) + }); + if let Some(i) = picked { + let name = keys[i].name.clone(); + self.audio.play_button_click(); + if let Some(ms) = self.mods.screen.as_mut() { + ms.binding = Some(name); + } + return; + } + let top = MOD_UI_TOP + keys.len() as f32 * MOD_UI_KEY_ROW; + let layout = Self::mod_ui_layout(h, top, widgets); let mut list_i = 0usize; // The `ui_select` call needs `&mut`: find the pick first. let mut hit = None; @@ -935,17 +956,6 @@ impl Game<'_> { self.show_mod_output(); return; } - // A key widget waits for the key to bind. - if let UiWidget::Key(k) = w - && ui::hovered(cursor, x, wy, MOD_UI_COLUMN, 20.0) - { - self.audio.play_button_click(); - let name = k.key.clone(); - if let Some(ms) = self.mods.screen.as_mut() { - ms.binding = Some(name); - } - return; - } let UiWidget::List(l) = w else { continue }; let scroll = ms.scrolls.get(list_i).copied().unwrap_or_default(); list_i += 1; @@ -975,7 +985,9 @@ impl Game<'_> { return; }; let x = (w / 2.0).floor() - MOD_UI_COLUMN / 2.0; - let layout = Self::mod_ui_layout(h, widgets); + let top = + MOD_UI_TOP + Self::mod_page_keys(&self.mods.mods, ms).len() as f32 * MOD_UI_KEY_ROW; + let layout = Self::mod_ui_layout(h, top, widgets); let mut list_i = 0usize; for (w, &(wy, wh)) in widgets.iter().zip(&layout) { let UiWidget::List(l) = w else { continue }; @@ -995,3 +1007,6 @@ impl Game<'_> { const MOD_UI_TOP: f32 = 40.0; #[cfg(feature = "mods")] const MOD_UI_COLUMN: f32 = 200.0; +/// A key row's height with its gap, as the widgets below it space out. +#[cfg(feature = "mods")] +const MOD_UI_KEY_ROW: f32 = 26.0; diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index f9c147cfa..9088c0c39 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -137,16 +137,6 @@ interface ui { /// Up to 256 characters. label: string, } - /// One of the mod's keys (`input.register-key`) as Controls shows a - /// binding, "Zoom: Z". A click waits for the next key or mouse button, - /// which becomes the key (Esc: none). The game keeps the player's - /// choice across starts; the mod's `key-down` and `key-up` follow it. - record key-widget { - /// The mod's own name for the widget. - id: u32, - /// The name the mod registered the key under. - key: string, - } variant widget { /// Text the player reads. text(text-widget), @@ -156,8 +146,6 @@ interface ui { item(item-widget), /// Rows the player picks from. %list(list-widget), - /// A key the player binds. - key(key-widget), } /// A screen the mod owns: a title over widgets stacked down the middle, /// the Done button the game adds, and Esc to leave it. @@ -232,8 +220,9 @@ interface input { claim-items: func(ids: list); /// A key of the mod's: `name` comes back in `key-down` and `key-up`, /// `label` says what it does, `key` is the key as options.txt writes - /// it (`key.keyboard.r`), the default: a key the player chose for it - /// (`ui`'s key widget) wins. At most 16; an unknown key is an error. + /// it (`key.keyboard.r`), the default: the player can bind another + /// one on the mod's page in Options > Mods, and that one wins. At most + /// 16; an unknown key is an error. register-key: func(name: string, label: string, key: string) -> result<_, string>; /// The main hand's stack, as of this tick; none when it is empty. held: func() -> option; diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index f596db81c..0011fa99d 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -37,8 +37,7 @@ use bindings::{ /// The widget model a `ui` screen or settings page shows (WIT `interface /// ui`), for the game to draw with its menus' own widgets. pub use ui_bindings::horizon::mods::ui::{ - ButtonWidget, ItemWidget, KeyWidget, ListWidget, Screen as UiScreen, TextWidget, - Widget as UiWidget, + ButtonWidget, ItemWidget, ListWidget, Screen as UiScreen, TextWidget, Widget as UiWidget, }; use ui_bindings::{UiHost, horizon::mods::ui}; /// Capability `render`'s imports (docs/mods.md): the game draws the shapes. @@ -153,11 +152,13 @@ pub struct Entry { } impl Entry { /// The running mod has a settings page (capability `ui` set widgets - /// with `ui.set-settings`): its row in Options > Mods gains a button. + /// with `ui.set-settings`, or `input` registered keys the player may + /// bind there): its row in Options > Mods gains a button. pub fn has_ui_settings(&self) -> bool { - self.run - .as_ref() - .is_some_and(|r| !r.store.data().ui.settings.is_empty()) + self.run.as_ref().is_some_and(|r| { + let d = r.store.data(); + !d.ui.settings.is_empty() || !d.input.keys.is_empty() + }) } } @@ -856,10 +857,6 @@ fn clip_widgets(widgets: Vec) -> Vec { l.selected = l.selected.filter(|&s| (s as usize) < l.rows.len()); UiWidget::List(l) } - UiWidget::Key(mut k) => { - k.key = clip(k.key); - UiWidget::Key(k) - } }) .collect() } diff --git a/crates/horizon-mods/tests/sandbox.rs b/crates/horizon-mods/tests/sandbox.rs index 793d03db6..3b33ab4b2 100644 --- a/crates/horizon-mods/tests/sandbox.rs +++ b/crates/horizon-mods/tests/sandbox.rs @@ -449,7 +449,7 @@ fn ui_screens_settings_and_events() { assert_eq!(mods.settings_widgets(0).len(), 3); assert!(mods.entries()[0].has_ui_settings()); mods.ui_settings_opened(0); - assert_eq!(mods.settings_widgets(0).len(), 3); + assert_eq!(mods.settings_widgets(0).len(), 2); // `/ui_close` closes the mod's own screen. assert!(mods.command("ui_close")); assert!(mods.drain().contains(&Output::CloseScreen { owner: 0 })); @@ -566,10 +566,8 @@ fn the_players_key_choice_wins() { }; let mut mods = load("horizon_mods_test_ui_guest.hmod"); assert!(mods.command("input_claim")); - assert!(matches!( - &mods.settings_widgets(0)[0], - horizon_mods::UiWidget::Key(k) if k.id == 9 && k.key == "wheel" - )); + // Its keys alone give it a page in Options > Mods, where the game lists them. + assert!(mods.settings_widgets(0).is_empty() && mods.entries()[0].has_ui_settings()); // Rebinding while held lets the old key go. mods.key("key.keyboard.r", true); assert!(mods.rebind(0, "wheel", "key.mouse.4")); diff --git a/crates/horizon-mods/tests/ui_guest/src/lib.rs b/crates/horizon-mods/tests/ui_guest/src/lib.rs index f2765e8da..9bede11f9 100644 --- a/crates/horizon-mods/tests/ui_guest/src/lib.rs +++ b/crates/horizon-mods/tests/ui_guest/src/lib.rs @@ -9,7 +9,7 @@ use horizon_mod_sdk::{ Mod, Registration, chat, input::{self, Event}, render::{self, FillShape, ItemShape, Point, QuadShape, Rect, Shape, SpriteShape, TextShape}, - ui::{self, ItemWidget, KeyWidget, ListWidget, Screen, TextWidget, Widget}, + ui::{self, ItemWidget, ListWidget, Screen, TextWidget, Widget}, }; /// One of each shape, the text too long by 44 characters. @@ -162,10 +162,6 @@ impl Mod for Guest { // Not a key: refused. let bad = input::register_key("bad", "Bad", "R").unwrap_err(); chat::show(&format!("claimed; {bad}")); - ui::set_settings(&[Widget::Key(KeyWidget { - id: 9, - key: "wheel".into(), - })]); } "input_poll" => { for event in input::poll() { @@ -229,11 +225,6 @@ impl Mod for Guest { id: 3, label: "Corner: Top Right".into(), }), - // `/input_claim`'s key; drawn as its name until registered. - Widget::Key(KeyWidget { - id: 9, - key: "wheel".into(), - }), ]); chat::show("settings opened"); } diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index f525ace89..a51fb904b 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -197,15 +197,6 @@ A thing on a mod's screen or settings page. A button as the options pages have t | `id` | `u32` | The mod's own name for the widget. | | `label` | `string` | Up to 256 characters. | -### record `key-widget` - -One of the mod's keys (`input.register-key`) as Controls shows a binding, "Zoom: Z". A click waits for the next key or mouse button, which becomes the key (Esc: none). The game keeps the player's choice across starts; the mod's `key-down` and `key-up` follow it. - -| Field | Type | | -| --- | --- | --- | -| `id` | `u32` | The mod's own name for the widget. | -| `key` | `string` | The name the mod registered the key under. | - ### variant `widget` | Case | Payload | | @@ -214,7 +205,6 @@ One of the mod's keys (`input.register-key`) as Controls shows a binding, "Zoom: | `button` | `button-widget` | A button. | | `item` | `item-widget` | An item's icon and name. | | `list` | `list-widget` | Rows the player picks from. | -| `key` | `key-widget` | A key the player binds. | ### record `screen` @@ -288,7 +278,7 @@ A mapping as the player has bound it. | Function | Signature | What it does | | --- | --- | --- | | `claim-items` | `func(ids: list)` | The items whose use the mod handles instead of the game, by id (at most 64). Replaces the previous list. | -| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`), the default: a key the player chose for it (`ui`'s key widget) wins. At most 16; an unknown key is an error. | +| `register-key` | `func(name: string, label: string, key: string) -> result<_, string>` | A key of the mod's: `name` comes back in `key-down` and `key-up`, `label` says what it does, `key` is the key as options.txt writes it (`key.keyboard.r`), the default: the player can bind another one on the mod's page in Options > Mods, and that one wins. At most 16; an unknown key is an error. | | `held` | `func() -> option` | The main hand's stack, as of this tick; none when it is empty. | | `poll` | `func() -> list` | The events since the last call, oldest first. | | `claim-wheel` | `func(on: bool)` | Takes the mouse wheel while playing (true) or gives it back: while taken, its steps are the mod's `wheel` events and the hotbar doesn't move. The game gives it back when the mod stops. | diff --git a/docs/api/stability.md b/docs/api/stability.md index 132c971f9..d5bd0e545 100644 --- a/docs/api/stability.md +++ b/docs/api/stability.md @@ -26,12 +26,13 @@ promise, how something is deprecated and removed, and the tests that keep old mo ## What counts as a breaking change Breaking: removing or renaming anything; changing a type, a default, a unit or a meaning; making something optional -required; refusing input that was accepted; a new case in a WIT `enum` or `variant` the game sends to mods (an older -component cannot decode it); a JSON field that changes type. +required; refusing input that was accepted; any change to a WIT type a function uses, a new `enum` or `variant` case +included, in either direction (the runtime links a component only when each imported function's types match exactly: +a mod built before the change no longer loads); a JSON field that changes type. Not breaking: a new function, interface, capability, command, endpoint, tool or `server.toml` key; a new optional -argument or JSON field (readers must ignore fields they don't know); a new WIT case the mod *sends* (the game decodes -every case it ever served); faster, or fixing behaviour to what the reference already says. +argument or JSON field (readers must ignore fields they don't know); a new WIT function or type (mods built earlier +don't import it); faster, or fixing behaviour to what the reference already says. ## Stability levels diff --git a/docs/mods.md b/docs/mods.md index 108e854be..a8d8da851 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -87,9 +87,9 @@ folder, loads no runtime and plays exactly like vanilla 26.3. | `hud` | `hud` | `set-panel(corner, lines)`, `clear()`: up to 16 lines of 256 characters | | `storage` | `storage` | `get(key)`, `set(key, value)`, `delete(key)`: a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call | | `command` | `command` | `run(line)`: a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run | -| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item, list and key widgets the game lays out and draws with its own menus' code. A key widget binds one of the mod's keys (`input.register-key`) as Key Binds does: click, then press the key or mouse button (Esc: none); the game keeps the choice in `horizon-mods.txt` (`key..:key.keyboard.z`) and the mod's `key-down` follows it. **Experimental:** the manifest needs `experimental = ["ui"]` | +| `ui` | `ui` | `open-screen`, `close-screen`, `set-settings`: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code. **Experimental:** the manifest needs `experimental = ["ui"]` | | `render` | `render` | `set-hud(shapes)`: up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; `set-corner(corner, w, h, shapes)`: a box the game places in a corner, stacked with its overlays and other panels, as `hud`'s panels are; `text-width(text)`; `frame-stats()`: the game's FPS, min and 1% low; `gui-size()`; `shake(strength, seconds)`: the view turns, the player doesn't, scaled by Distortion Effects; `set-zoom(zoom)`: the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts). The game's own HUD renderer draws them: a mod never supplies shader code. **Experimental:** `experimental = ["render"]` | -| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | +| `input` | `input` | `claim-items(ids)`: the use button on those items is the mod's (`use-start`, `use-stop` with the ticks held; the game doesn't place, eat or swing); `register-key(name, label, key)`: up to 16 keys (`key.keyboard.r`), heard while playing as `key-down` / `key-up` (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as `key.mouse.4`. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without `ui`; the choice is kept in `horizon-mods.txt` (`key..:key.keyboard.z`) and wins over `register-key`'s default; `claim-wheel(on)`: the wheel's steps become `wheel` events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); `held()`; `mapping(name)`: a movement or click mapping's key label and whether it is held; `clicks-per-second()`; `poll()` returns the events, at most 256 kept. **Experimental:** `experimental = ["input"]` | | Hook (export) | When | |---|---|