mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 10:39:46 -10:00
Compare commits
19
Commits
No files matched your search
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -706,7 +727,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -72,7 +72,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -132,6 +131,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -30,7 +30,6 @@ horizon-render-pipeline = { path = "../horizon-render-pipeline" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -82,5 +81,7 @@ test-support = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
|
||||
}
|
||||
|
||||
/// The mark and the screen's name over the search field.
|
||||
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
|
||||
let (x, y) = (cols.side_x, cols.side_y);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
|
||||
let horizon = Text::literal("Horizon");
|
||||
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
|
||||
let title = Text::literal(title);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
|
||||
}
|
||||
|
||||
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
|
||||
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
|
||||
}
|
||||
if open {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
|
||||
}
|
||||
let color = match (open || hover, has) {
|
||||
(true, _) => style::WHITE,
|
||||
(false, true) => style::SOFT,
|
||||
(false, false) => style::MUTED,
|
||||
(false, true) => style::soft(),
|
||||
(false, false) => style::muted(),
|
||||
};
|
||||
let ty = py + ((ph - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
|
||||
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
|
||||
search.draw(batch, atlas, None);
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
}
|
||||
if !search.value.is_empty() {
|
||||
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
let color = if hover { style::WHITE } else { style::MUTED };
|
||||
let color = if hover { style::WHITE } else { style::muted() };
|
||||
let cy = y + ((h - 8.0) / 2.0).floor();
|
||||
ui::centered_text(
|
||||
batch,
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
batch,
|
||||
atlas,
|
||||
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
|
||||
style::HEAD_RULE,
|
||||
style::head_rule(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
|
||||
None => (page_name(line.page), style::ACCENT, 4.0),
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
let name = Text::literal(name);
|
||||
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
};
|
||||
if !after_name {
|
||||
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
|
||||
style::fill(batch, atlas, rule, style::ROW_RULE);
|
||||
style::fill(batch, atlas, rule, style::row_rule());
|
||||
}
|
||||
row(batch, setting, y);
|
||||
}
|
||||
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
&none,
|
||||
cols.list_x + 2.0,
|
||||
cols.top + 4.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
let active = live && (hover || focused || held);
|
||||
if active {
|
||||
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
|
||||
style::fill(batch, atlas, lit, style::HOVER);
|
||||
style::fill(batch, atlas, lit, style::hover());
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
// The control socket's widget tree, the narrator and a controller's focus.
|
||||
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
if live {
|
||||
crate::widgets::target_as(rect, kind);
|
||||
}
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let soft = if live { style::SOFT } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let soft = if live { style::soft() } else { style::muted() };
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let lit = live && (hover || held);
|
||||
let left = rect[0] + style::INSET;
|
||||
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
style::hover(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ fn read_row(
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
@@ -799,7 +799,7 @@ fn side_lines(
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
|
||||
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
|
||||
crate::widgets::record(text, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, text, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
|
||||
// The row's page is open: lit as the list's open page is.
|
||||
if list.lit == Some(*shown) {
|
||||
let row @ [x, y, _, h] = strip.area;
|
||||
style::fill(batch, atlas, row, style::OPEN);
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, row, style::open());
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
|
||||
}
|
||||
tabs::draw(batch, atlas, strip);
|
||||
}
|
||||
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
|
||||
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
rect[0] + style::INSET,
|
||||
ty,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
let color = if why.is_some() {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
style::row_rule()
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ pub use horizon_client_render::{
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
|
||||
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
|
||||
|
||||
@@ -13,6 +13,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
|
||||
@@ -98,9 +98,8 @@ impl Loading {
|
||||
.map_or([1280, 720], |o| o.window_size)
|
||||
.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
@@ -130,22 +129,25 @@ impl Loading {
|
||||
// redraw the same design when they start. GDI is unsafe:
|
||||
// horizon-threads holds it (this crate forbids unsafe code).
|
||||
#[cfg(target_os = "windows")]
|
||||
let boot_paint_stop = hwnd(&window).map(|hwnd| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
horizon_theme::fonts::GEIST_MONO,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
// In the fonts given to `menu_style`; with none, the window stays as it is.
|
||||
let boot_paint_stop = hwnd(&window)
|
||||
.zip(horizon_client::menu_style::get().fonts)
|
||||
.map(|(hwnd, fonts)| {
|
||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let repaint_stop = stop.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
horizon_threads::boot_paint::paint(
|
||||
hwnd,
|
||||
fonts.semibold,
|
||||
fonts.mono,
|
||||
&horizon_client::logging::recent_lines(),
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||
}
|
||||
});
|
||||
stop
|
||||
});
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
|
||||
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
}
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
)
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
|
||||
let made = event_loop
|
||||
.create_window(attributes)
|
||||
.map_err(Into::into)
|
||||
|
||||
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
|
||||
continue;
|
||||
}
|
||||
// Opened without the focus: whoever types in the game's window keeps the keyboard.
|
||||
let attributes =
|
||||
horizon_client::window::with_horizon_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
let attributes = horizon_client::window::with_icon(Window::default_attributes())
|
||||
.with_active(false)
|
||||
.with_visible(window_shown(true, self.offscreen));
|
||||
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
|
||||
// Space of its own that hides the other windows as it slides in.
|
||||
let placement = self.placement(*key).cloned();
|
||||
|
||||
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
|
||||
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
|
||||
let size = self.options.window_size.map(f64::from);
|
||||
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
|
||||
let attributes = horizon_client::window::with_horizon_icon(
|
||||
Window::default_attributes().with_title("Horizon"),
|
||||
);
|
||||
let attributes =
|
||||
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
|
||||
// Wayland names the window by its app id, which the horizon.desktop entry matches.
|
||||
#[cfg(target_os = "linux")]
|
||||
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
|
||||
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
|
||||
//! (`docs/design-system.md`).
|
||||
//! Its own module so it can leave the engine crate as a pure file move, for the official
|
||||
//! executable to call.
|
||||
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
|
||||
use horizon_theme as theme;
|
||||
|
||||
/// Give the engine Horizon's look; call it once, before the first window opens.
|
||||
pub(crate) fn give() {
|
||||
horizon_client::window::set_icon_png(theme::ICON_PNG);
|
||||
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
|
||||
horizon_client::menu_style::install(MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: blocks.page,
|
||||
ink: blocks.ink,
|
||||
ink_soft: blocks.ink_soft,
|
||||
muted: blocks.muted,
|
||||
outline: blocks.outline,
|
||||
accent: blocks.accent,
|
||||
face: blocks.face,
|
||||
// The face's hover, lighter than `face`: the settings screen's own shade.
|
||||
face_hover: 0x6a50e6,
|
||||
success: blocks.success,
|
||||
warn: blocks.warn,
|
||||
danger: blocks.danger,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: dark.page,
|
||||
backdrop_muted: theme::BACKDROP_MUTED,
|
||||
ink: dark.ink,
|
||||
ink_soft: dark.ink_soft,
|
||||
rule: dark.rule,
|
||||
accent: dark.accent,
|
||||
on_accent: dark.on_accent,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: theme::CAPTION.px,
|
||||
label: theme::LABEL.px,
|
||||
ui: theme::UI.px,
|
||||
body: theme::BODY.px,
|
||||
title: theme::TITLE.px,
|
||||
display: theme::DISPLAY.px,
|
||||
micro: theme::MICRO.px,
|
||||
radius: theme::RADIUS,
|
||||
radius_plate: theme::RADIUS_PLATE,
|
||||
control_large: theme::CONTROL_LARGE,
|
||||
},
|
||||
fonts: Some(Fonts {
|
||||
regular: theme::fonts::GEIST_REGULAR,
|
||||
medium: theme::fonts::GEIST_MEDIUM,
|
||||
semibold: theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: theme::fonts::GEIST_MONO,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
|
||||
pub use horizon_client_lib::*;
|
||||
pub mod entry;
|
||||
pub mod game;
|
||||
mod horizon_look;
|
||||
pub mod mod_api;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -46,11 +46,11 @@ impl NoResources {
|
||||
let x = ((ui.w - width) / 2.0).round();
|
||||
let lines: Vec<Vec<String>> = PARAGRAPHS
|
||||
.iter()
|
||||
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
|
||||
.map(|p| wrap(ui, Face::Regular, body(), p, width))
|
||||
.collect();
|
||||
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
|
||||
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
|
||||
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
|
||||
let y = ((ui.h - height) / 2.0).max(24.0).round();
|
||||
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
|
||||
let mut buttons = Vec::new();
|
||||
@@ -59,7 +59,7 @@ impl NoResources {
|
||||
("Download the launcher", Action::Download),
|
||||
("Quit", Action::Quit),
|
||||
] {
|
||||
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
|
||||
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
|
||||
buttons.push(Button {
|
||||
label,
|
||||
action,
|
||||
@@ -80,21 +80,29 @@ impl NoResources {
|
||||
|
||||
pub fn draw(&self, ui: &mut Ui) {
|
||||
let layout = self.layout(ui);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
|
||||
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
|
||||
let mut y = layout.y;
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
TITLE_SIZE,
|
||||
title_size(),
|
||||
TITLE_LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::TEXT,
|
||||
color::text(),
|
||||
TITLE,
|
||||
);
|
||||
y += TITLE_LINE + 16.0;
|
||||
for paragraph in &layout.lines {
|
||||
for line in paragraph {
|
||||
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
body(),
|
||||
LINE,
|
||||
layout.x,
|
||||
y,
|
||||
color::text(),
|
||||
line,
|
||||
);
|
||||
y += LINE;
|
||||
}
|
||||
y += PARAGRAPH_GAP;
|
||||
@@ -102,20 +110,20 @@ impl NoResources {
|
||||
for button in &layout.buttons {
|
||||
let hovered = button.contains(ui.cursor);
|
||||
let (plate, ink) = match (button.action, hovered) {
|
||||
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
|
||||
(Action::Download, false) => (color::accent(), color::on_accent()),
|
||||
(Action::Download, true) => {
|
||||
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
|
||||
(color::with_alpha(color::accent(), 0.85), color::on_accent())
|
||||
}
|
||||
(Action::Quit, false) => (color::RAISED, color::TEXT),
|
||||
(Action::Quit, true) => (color::HOVER, color::TEXT),
|
||||
(Action::Quit, false) => (color::raised(), color::text()),
|
||||
(Action::Quit, true) => (color::hover(), color::text()),
|
||||
};
|
||||
let h = CONTROL;
|
||||
let h = control();
|
||||
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
|
||||
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
|
||||
let lw = ui.measure(Face::Medium, button_text(), button.label);
|
||||
let lx = button.x + ((button.w - lw) / 2.0).round();
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
BUTTON_TEXT,
|
||||
button_text(),
|
||||
h,
|
||||
lx,
|
||||
button.y,
|
||||
@@ -123,11 +131,11 @@ impl NoResources {
|
||||
button.label,
|
||||
);
|
||||
}
|
||||
let small_y = layout.buttons[0].y + CONTROL + 20.0;
|
||||
let small_y = layout.buttons[0].y + control() + 20.0;
|
||||
for (i, line) in wrap(
|
||||
ui,
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
&self.looked_in,
|
||||
layout.width,
|
||||
)
|
||||
@@ -137,11 +145,11 @@ impl NoResources {
|
||||
let line_y = small_y + i as f32 * SMALL_LINE;
|
||||
ui.text_line(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
SMALL_LINE,
|
||||
layout.x,
|
||||
line_y,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
line,
|
||||
);
|
||||
}
|
||||
@@ -157,14 +165,22 @@ impl NoResources {
|
||||
}
|
||||
}
|
||||
|
||||
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
|
||||
fn title_size() -> f32 {
|
||||
ui_theme::sizes().title
|
||||
}
|
||||
const TITLE_LINE: f32 = 40.0;
|
||||
const BODY: f32 = horizon_theme::BODY.px;
|
||||
fn body() -> f32 {
|
||||
ui_theme::sizes().body
|
||||
}
|
||||
const LINE: f32 = 24.0;
|
||||
const PARAGRAPH_GAP: f32 = 10.0;
|
||||
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
|
||||
fn button_text() -> f32 {
|
||||
ui_theme::sizes().ui
|
||||
}
|
||||
const SMALL_LINE: f32 = 18.0;
|
||||
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
|
||||
fn control() -> f32 {
|
||||
ui_theme::sizes().control_large
|
||||
}
|
||||
|
||||
struct Layout {
|
||||
x: f32,
|
||||
@@ -184,7 +200,7 @@ struct Button {
|
||||
|
||||
impl Button {
|
||||
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
|
||||
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +248,7 @@ mod tests {
|
||||
for size in [[1280, 720], [800, 600], [2560, 1440]] {
|
||||
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
|
||||
let layout = screen.layout(ui);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
|
||||
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
|
||||
(
|
||||
center(&layout.buttons[0]),
|
||||
center(&layout.buttons[1]),
|
||||
@@ -255,7 +271,7 @@ mod tests {
|
||||
let layout = screen.layout(ui);
|
||||
let last = layout.buttons.last().unwrap();
|
||||
assert!(
|
||||
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
|
||||
last.x + last.w <= ui.w && last.y + control() <= ui.h,
|
||||
"{size:?}"
|
||||
);
|
||||
});
|
||||
@@ -266,10 +282,10 @@ mod tests {
|
||||
fn text_wraps_within_the_column_and_keeps_every_word() {
|
||||
with_ui([1280, 720], (0.0, 0.0), |ui| {
|
||||
for paragraph in PARAGRAPHS {
|
||||
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
|
||||
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
|
||||
assert!(lines.len() > 1, "{paragraph}");
|
||||
for line in &lines {
|
||||
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
|
||||
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
|
||||
}
|
||||
assert_eq!(lines.join(" "), paragraph);
|
||||
}
|
||||
|
||||
@@ -293,16 +293,16 @@ impl Keyboard {
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::with_alpha(color::BG, 0.94),
|
||||
ui_theme::radius_plate(),
|
||||
color::with_alpha(color::bg(), 0.94),
|
||||
);
|
||||
ui.border(
|
||||
x0 - 12.0,
|
||||
y0 - 12.0,
|
||||
width + 24.0,
|
||||
height,
|
||||
ui_theme::RADIUS_PLATE,
|
||||
color::BORDER,
|
||||
ui_theme::radius_plate(),
|
||||
color::border(),
|
||||
);
|
||||
for (r, row) in self.rows().iter().enumerate() {
|
||||
let y = y0 + r as f32 * (KEY_H + GAP);
|
||||
@@ -314,13 +314,13 @@ impl Keyboard {
|
||||
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|
||||
|| matches!(key, Key::Layer) && self.symbols;
|
||||
let (plate, ink) = if focused {
|
||||
(color::ACCENT, color::ON_ACCENT)
|
||||
(color::accent(), color::on_accent())
|
||||
} else if lit {
|
||||
(color::HOVER, color::TEXT)
|
||||
(color::hover(), color::text())
|
||||
} else {
|
||||
(color::RAISED, color::TEXT)
|
||||
(color::raised(), color::text())
|
||||
};
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
|
||||
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
|
||||
let label = match key {
|
||||
Key::Char(ch) => self.letter(*ch),
|
||||
Key::Shift if self.caps => "CAPS".into(),
|
||||
@@ -331,10 +331,10 @@ impl Keyboard {
|
||||
Key::Backspace => tr("horizon.osk.delete", "Delete"),
|
||||
Key::Done => tr("gui.done", "Done"),
|
||||
};
|
||||
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
|
||||
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
|
||||
ui.text_line(
|
||||
Face::Medium,
|
||||
ui_theme::BODY,
|
||||
ui_theme::body(),
|
||||
KEY_H,
|
||||
x + ((w - lw) / 2.0).round(),
|
||||
y,
|
||||
@@ -359,10 +359,10 @@ impl Keyboard {
|
||||
.collect();
|
||||
ui.text_centered(
|
||||
Face::Regular,
|
||||
ui_theme::SMALL,
|
||||
ui_theme::small(),
|
||||
x0 + width / 2.0,
|
||||
y0 + 5.0 * (KEY_H + GAP) + 2.0,
|
||||
color::MUTED,
|
||||
color::muted(),
|
||||
&line.join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -408,7 +408,7 @@ impl TimelineUi {
|
||||
let tx = start_x + sec * px_per_sec;
|
||||
if tx <= start_x + w {
|
||||
// Major tick line
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
|
||||
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
|
||||
// Label every 2 or 5 seconds depending on width
|
||||
let is_labeled = if total_secs > 60.0 {
|
||||
(sec as u32).is_multiple_of(10)
|
||||
@@ -424,7 +424,7 @@ impl TimelineUi {
|
||||
"horizon.studio.timeline.seconds",
|
||||
&[Text::literal(seconds)],
|
||||
);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
|
||||
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
|
||||
}
|
||||
}
|
||||
sec += sec_step;
|
||||
@@ -459,7 +459,7 @@ impl TimelineUi {
|
||||
// Base lane well
|
||||
let lane_rect = [start_x, y, track_w, bar_h];
|
||||
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
|
||||
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
|
||||
style::ring(batch, atlas, lane_rect, style::row_rule());
|
||||
|
||||
let total_dur = self.duration_ms.max(1) as f32;
|
||||
|
||||
@@ -514,7 +514,7 @@ impl TimelineUi {
|
||||
&scene_title,
|
||||
seg_x + 5.0,
|
||||
y + 5.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -577,7 +577,7 @@ impl TimelineUi {
|
||||
let px = start_x + norm * track_w;
|
||||
|
||||
// Scrubber line from ruler through bottom of tracks
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
|
||||
|
||||
// Inverted triangle marker at ruler top
|
||||
let tri_color = style::WHITE;
|
||||
|
||||
@@ -178,7 +178,7 @@ impl TopBarUi {
|
||||
);
|
||||
}
|
||||
let color = if open {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
@@ -220,7 +220,7 @@ impl TopBarUi {
|
||||
if pause_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
@@ -339,7 +339,11 @@ impl TopBarUi {
|
||||
&Text::translate(key, &[]),
|
||||
x + 8.0,
|
||||
y + 6.0,
|
||||
if hovered { style::WHITE } else { style::MUTED },
|
||||
if hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::muted()
|
||||
},
|
||||
true,
|
||||
);
|
||||
if !shortcut.is_empty() {
|
||||
@@ -350,7 +354,7 @@ impl TopBarUi {
|
||||
&shortcut,
|
||||
x + menu_width - shortcut_width - 8.0,
|
||||
y + 6.0,
|
||||
style::SOFT,
|
||||
style::soft(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
|
||||
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
|
||||
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! The design system's widgets drawn in the game: palette, type scale, radii,
|
||||
//! control sizes and fonts from `menu_style`, given at startup (in the official
|
||||
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
|
||||
//! here draws Horizon's own overlays that vanilla has no screen for: the
|
||||
//! on-screen keyboard a controller types with (`osk`), plus the
|
||||
//! `horizon.txt` preferences.
|
||||
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
|
||||
let _ = std::fs::write(dir.join("horizon.txt"), out);
|
||||
}
|
||||
|
||||
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
|
||||
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
|
||||
/// translucent plates the overlays draw over the game.
|
||||
pub(crate) mod color {
|
||||
use horizon_theme::DARK as P;
|
||||
pub const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
use horizon_client_render::menu_style::{self, OverlayColours};
|
||||
pub use menu_style::hud_colour as hex;
|
||||
fn p() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
/// The page, under every veil.
|
||||
pub const BG: u32 = hex(P.page, 255);
|
||||
pub fn bg() -> u32 {
|
||||
hex(p().page, 255)
|
||||
}
|
||||
/// Hover plates, secondary buttons, fields, the segmented track: the
|
||||
/// dark palette's soft plate, as ink over the veil so the plates read the
|
||||
/// same over any backdrop (over the page colour they are `plate_soft`).
|
||||
pub const RAISED: u32 = hex(P.ink, 20);
|
||||
pub fn raised() -> u32 {
|
||||
hex(p().ink, 20)
|
||||
}
|
||||
/// Pressed plates and a switch that is off (`plate`, the same way).
|
||||
pub const HOVER: u32 = hex(P.ink, 36);
|
||||
pub fn hover() -> u32 {
|
||||
hex(p().ink, 36)
|
||||
}
|
||||
/// One-pixel rules between rows, slider tracks.
|
||||
pub const BORDER: u32 = hex(P.rule, 255);
|
||||
pub fn border() -> u32 {
|
||||
hex(p().rule, 255)
|
||||
}
|
||||
/// Text and selection (`ink`).
|
||||
pub const TEXT: u32 = hex(P.ink, 255);
|
||||
pub fn text() -> u32 {
|
||||
hex(p().ink, 255)
|
||||
}
|
||||
/// Values and secondary text (`ink_soft`).
|
||||
pub const MUTED: u32 = hex(P.ink_soft, 255);
|
||||
pub const ACCENT: u32 = hex(P.accent, 255);
|
||||
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(p().ink_soft, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(p().accent, 255)
|
||||
}
|
||||
pub fn on_accent() -> u32 {
|
||||
hex(p().on_accent, 255)
|
||||
}
|
||||
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
|
||||
let base = (c >> 24) as f32;
|
||||
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
|
||||
}
|
||||
}
|
||||
|
||||
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
|
||||
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
|
||||
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
|
||||
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
|
||||
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
|
||||
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
|
||||
/// Fields; plates (hover rows, selection); pills (buttons, segments).
|
||||
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
|
||||
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
|
||||
/// The type scale and shapes (`menu_style`), in dp.
|
||||
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
|
||||
&horizon_client_render::menu_style::get().sizes
|
||||
}
|
||||
pub(crate) fn small() -> f32 {
|
||||
sizes().caption
|
||||
}
|
||||
pub(crate) fn body() -> f32 {
|
||||
sizes().label
|
||||
}
|
||||
/// The boot screen's wordmark (`display`) and console lines (`micro`).
|
||||
fn display() -> f32 {
|
||||
sizes().display
|
||||
}
|
||||
fn micro() -> f32 {
|
||||
sizes().micro
|
||||
}
|
||||
/// Fields; plates (hover rows, selection).
|
||||
pub(crate) fn radius() -> f32 {
|
||||
sizes().radius
|
||||
}
|
||||
pub(crate) fn radius_plate() -> f32 {
|
||||
sizes().radius_plate
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum Face {
|
||||
@@ -87,20 +119,34 @@ pub(crate) enum Face {
|
||||
/// Counts, timestamps and the boot screen's console lines.
|
||||
Mono,
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
|
||||
#[cfg(test)]
|
||||
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
|
||||
Some(horizon_client_render::menu_style::Fonts {
|
||||
regular: horizon_theme::fonts::GEIST_REGULAR,
|
||||
medium: horizon_theme::fonts::GEIST_MEDIUM,
|
||||
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
|
||||
mono: horizon_theme::fonts::GEIST_MONO,
|
||||
});
|
||||
struct Fonts {
|
||||
faces: [FontRef<'static>; 4],
|
||||
}
|
||||
impl Fonts {
|
||||
fn load() -> Self {
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
|
||||
Self {
|
||||
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
|
||||
fn load() -> Option<Self> {
|
||||
let given = horizon_client_render::menu_style::get()
|
||||
.fonts
|
||||
.or(TEST_FONTS)?;
|
||||
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
|
||||
Some(Self {
|
||||
faces: [
|
||||
f(horizon_theme::fonts::GEIST_REGULAR),
|
||||
f(horizon_theme::fonts::GEIST_MEDIUM),
|
||||
f(horizon_theme::fonts::GEIST_SEMIBOLD),
|
||||
f(horizon_theme::fonts::GEIST_MONO),
|
||||
f(given.regular),
|
||||
f(given.medium),
|
||||
f(given.semibold),
|
||||
f(given.mono),
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
fn face(&self, f: Face) -> &FontRef<'static> {
|
||||
&self.faces[f as usize]
|
||||
@@ -142,7 +188,8 @@ struct Cached {
|
||||
}
|
||||
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
|
||||
struct Cache {
|
||||
fonts: Fonts,
|
||||
/// None: no fonts were given, and text draws nothing.
|
||||
fonts: Option<Fonts>,
|
||||
entries: HashMap<Key, Cached>,
|
||||
shelf: (u32, u32, u32),
|
||||
pending: Vec<crate::font::Patch>,
|
||||
@@ -230,9 +277,16 @@ impl Cache {
|
||||
if let Some(c) = self.entries.get(&key) {
|
||||
return *c;
|
||||
}
|
||||
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
|
||||
let Some(fonts) = &self.fonts else {
|
||||
return Cached {
|
||||
rect: None,
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
};
|
||||
};
|
||||
let scale = fonts.scale(face, f32::from(size) / 4.0);
|
||||
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
|
||||
let made = match self.fonts.face(face).outline_glyph(glyph) {
|
||||
let made = match fonts.face(face).outline_glyph(glyph) {
|
||||
Some(outline) => {
|
||||
let b = outline.px_bounds();
|
||||
let (w, h) = (b.width() as u32, b.height() as u32);
|
||||
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
|
||||
let atlas = self.atlas;
|
||||
let gui = self.scale;
|
||||
with_cache(atlas, |c| {
|
||||
let scale = c.fonts.scale(face, px);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
return (0.0, 0.0, 0.0, Vec::new());
|
||||
};
|
||||
let scale = fonts.scale(face, px);
|
||||
let (ascent, descent) = {
|
||||
let f = c.fonts.face(face).as_scaled(scale);
|
||||
let f = fonts.face(face).as_scaled(scale);
|
||||
(f.ascent(), f.descent())
|
||||
};
|
||||
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
|
||||
.map(|ch| {
|
||||
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
|
||||
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
|
||||
.then(|| atlas.font.glyph(ch))
|
||||
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
|
||||
.filter(|g| g.rect.is_some() || g.right <= 0.0);
|
||||
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
|
||||
continue;
|
||||
}
|
||||
let (id, advance, kern) = {
|
||||
let font = c.fonts.face(face);
|
||||
let Some(fonts) = &c.fonts else {
|
||||
break;
|
||||
};
|
||||
let font = fonts.face(face);
|
||||
let id = font.glyph_id(ch);
|
||||
let f = font.as_scaled(scale);
|
||||
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
|
||||
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
/// The wordmark with its tagline, centred.
|
||||
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
|
||||
let small = (size * 0.25).clamp(MICRO, 18.0);
|
||||
let size = (ui.w / 16.0).clamp(display(), 96.0);
|
||||
let small = (size * 0.25).clamp(micro(), 18.0);
|
||||
let block = size * 1.45 + small * 0.6 + small * 1.5;
|
||||
let top = (ui.h - block) / 2.0;
|
||||
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
|
||||
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
|
||||
return;
|
||||
}
|
||||
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
|
||||
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
|
||||
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
|
||||
let max_w = ui.w / 2.0;
|
||||
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
|
||||
let mut y = ui.h - pad - line;
|
||||
@@ -696,7 +756,12 @@ mod tests {
|
||||
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
|
||||
reset_atlas();
|
||||
let draw = |cache: &mut Cache| {
|
||||
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
|
||||
let glyph = cache
|
||||
.fonts
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.face(Face::Regular)
|
||||
.glyph_id('A');
|
||||
assert!(
|
||||
cache
|
||||
.glyph(&atlas, Face::Regular, glyph, 16.0)
|
||||
@@ -782,7 +847,7 @@ mod tests {
|
||||
let lines = wrapping_lines(
|
||||
&ui,
|
||||
Face::Mono,
|
||||
MICRO,
|
||||
micro(),
|
||||
"the quick brown fox jumps over the lazy dog",
|
||||
90.0,
|
||||
);
|
||||
@@ -790,7 +855,7 @@ mod tests {
|
||||
assert!(
|
||||
lines
|
||||
.iter()
|
||||
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
|
||||
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
|
||||
"every wrapped line fits"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -812,7 +877,7 @@ mod tests {
|
||||
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
|
||||
let mut batch = HudBatch::new([1280, 720]);
|
||||
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
|
||||
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
|
||||
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
|
||||
let quads = batch
|
||||
.vertices
|
||||
.iter()
|
||||
|
||||
+1
-4
@@ -95,7 +95,6 @@
|
||||
"horizon-client -> horizon-shaders",
|
||||
"horizon-client -> horizon-telemetry",
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
@@ -107,13 +106,11 @@
|
||||
"horizon-client-lib -> horizon-shader-api",
|
||||
"horizon-client-lib -> horizon-shaders",
|
||||
"horizon-client-lib -> horizon-telemetry",
|
||||
"horizon-client-lib -> horizon-theme",
|
||||
"horizon-client-render -> horizon-cosmetics",
|
||||
"horizon-client-render -> horizon-hdr",
|
||||
"horizon-client-render -> horizon-optifine",
|
||||
"horizon-client-render -> horizon-polytone",
|
||||
"horizon-client-render -> horizon-shader-api",
|
||||
"horizon-client-render -> horizon-shaders",
|
||||
"horizon-client-render -> horizon-theme"
|
||||
"horizon-client-render -> horizon-shaders"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user