Merge upstream main into w1-bow

# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
This commit is contained in:
arismoko committed 2026-11-02 19:34:33 +00:00
commit d9b0388195
70 files changed
+5608 -170

No files matched your search

+4
View File
@@ -43,6 +43,10 @@ the same grid.
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
+135 -1
View File
@@ -243,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -273,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -296,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -745,6 +787,98 @@ mod tests {
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+2 -1
View File
@@ -245,7 +245,8 @@ impl GraphicsApi {
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
/// Sleep the whole wait: no CPU spent spinning, a little less precise; frames
/// keep to a fixed grid of slots, so late wake-ups don't lower the frame rate.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
+11 -3
View File
@@ -278,10 +278,18 @@ impl Column {
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
self.light_open_water_where(|state| {
let kind = kinds.get(state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
});
}
/// [`Column::light_open_water`] for a caller that knows which states are
/// water without `BlockFacts`: the save decoder, whose palette already
/// names water apart from lava. Saved columns skipping this lit their
/// water a level darker per block, which drew a band through the sea
/// where they met the generator's columns.
pub fn light_open_water_where(&mut self, is_water: impl Fn(BlockStateId) -> bool) {
let water = |span: &Span| is_water(span.state);
if !self.spans.last().is_some_and(water) {
return;
}
@@ -99,6 +99,10 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
/// Every worker has had nothing to do for [`IDLE_RELEASE`]: give back the
/// caches the workers share, which only spare work for chunks generated
/// next to each other in a run. Results do not change.
fn all_idle(&self) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -149,6 +153,16 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
/// What the generator's shared caches hold: (name, entries, bytes, if
/// known), for `horizon-ctl memory`.
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
Vec::new()
}
/// A proto chunk's heap bytes by part, and its sections by bits per
/// block, for `horizon-ctl memory` (`ProtoChunk::heap_bytes`).
fn proto_heap_bytes(&self, _proto: &Proto) -> ([usize; 6], [usize; 16]) {
([0; 6], [0; 16])
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -399,12 +413,29 @@ impl Mailbox {
done.pop_front()
}
}
/// [`Streaming::proto_census`].
#[derive(Debug, Default)]
pub struct ProtoCensus {
pub protos: usize,
/// Packed blocks, section palettes and lists, heightmaps, other terrain
/// data, biomes, the box and its structure lists.
pub bytes: [usize; 6],
pub sections_by_bits: [usize; 16],
pub light_views: usize,
pub light_view_bytes: usize,
/// `ChunkGenerator::cache_census`.
pub caches: Vec<(&'static str, usize, Option<usize>)>,
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
/// `ChunkGenerator::proto_heap_bytes`, for the memory census.
proto_bytes: Arc<dyn Fn(&Proto) -> ([usize; 6], [usize; 16]) + Send + Sync>,
/// `ChunkGenerator::cache_census`.
caches: Arc<dyn Fn() -> Vec<(&'static str, usize, Option<usize>)> + Send + Sync>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
@@ -824,6 +855,7 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
self.generator.all_idle();
}
}
impl<G: ChunkGenerator> Jobs<G> {
@@ -961,9 +993,19 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
let proto_bytes = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.proto_heap_bytes(p))
};
let caches = {
let generator = generator.clone();
Arc::new(move || generator.cache_census())
};
Self {
shared,
non_air_sections,
proto_bytes,
caches,
jobs,
workers,
order: 0,
@@ -1482,6 +1524,26 @@ impl Streaming {
&& self.finishing.is_empty()
&& self.lighting.is_empty())
}
/// The proto chunks held (not checked out): how many, their heap bytes by
/// part and their sections by bits per block; and the light views of
/// them with their bytes. For `horizon-ctl memory`.
pub fn proto_census(&self) -> ProtoCensus {
let mut census = ProtoCensus::default();
for proto in self.protos.values().flatten() {
let (bytes, bits) = (self.proto_bytes)(proto);
census.protos += 1;
for (sum, b) in census.bytes.iter_mut().zip(bytes) {
*sum += b;
}
for (sum, b) in census.sections_by_bits.iter_mut().zip(bits) {
*sum += b;
}
}
census.light_views = self.light_views.len();
census.light_view_bytes = self.light_views.values().map(|v| v.heap_bytes()).sum();
census.caches = (self.caches)();
census
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
pub fn stages(&self) -> [(&'static str, usize); 7] {
+1
View File
@@ -208,6 +208,7 @@ mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
+50 -1
View File
@@ -287,7 +287,8 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
entity_types,
enchantments: json_ids_in(&data.join("enchantment")),
@@ -364,6 +365,33 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The ids of the `.json` files in `data/<namespace>/<kind>` of every
/// namespace of `root` (vanilla's and the content mods'), sorted.
fn json_ids_of_every_namespace(root: &Path, kind: &str) -> Vec<String> {
let data = root.join("data");
let mut namespaces: std::collections::BTreeSet<String> = std::fs::read_dir(&data)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// A prepared root's namespaces live in its data file, not on the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&data) {
namespaces.extend(
files
.iter()
.filter_map(|f| Some(f.split_once('/')?.0.to_owned())),
);
}
let mut out: Vec<String> = namespaces
.iter()
.flat_map(|ns| ids_in(&data.join(ns).join(kind), "json", ns))
.collect();
out.sort();
out
}
/// The templates of every namespace folder under `root` (vanilla's and the
/// content mods'), as `namespace:path`, sorted.
fn templates_under(root: &Path) -> Vec<String> {
@@ -1579,6 +1607,27 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A content mod's loot tables are listed with vanilla's, so `/loot`
/// finds them.
#[test]
fn loot_tables_of_every_namespace_are_listed() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
for dir in [
"data/minecraft/loot_table/chests",
"data/mymod/loot_table/rooms",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("data/minecraft/loot_table/chests/a.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/rooms/b.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/notes.txt"), "").unwrap();
assert_eq!(
resource_ids(&root).loot_tables,
["minecraft:chests/a", "mymod:rooms/b"]
);
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
@@ -2342,6 +2342,37 @@ impl Game<'_> {
state,
)
}
/// `BlockInput.overwriteWithDefinedProperties`: restore explicit command
/// properties after normalizing the state against neighbouring blocks.
fn block_input_state(
&mut self,
pos: [i32; 3],
input: &BlockInput,
state: BlockStateId,
strict: bool,
) -> R<BlockStateId> {
if strict {
return Ok(state);
}
let updated = self.with_level(|b, level, ticks| {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let updated = b.update_from_neighbour_shapes(level, ticks, at, state);
if b.is_air(updated) { state } else { updated }
});
let registry = self.pack.assets.registry;
let mut descriptor = registry
.state(updated)
.expect("registered block state")
.clone();
for (key, value) in &input.properties {
if descriptor.properties.contains_key(key) {
descriptor.properties.insert(key.clone(), value.clone());
}
}
registry
.resolve(&descriptor)
.map_err(|_| err("argument.block.id.invalid", &[&input.id]))
}
/// Place a state like BlockInput.place: false when nothing changed.
pub(super) fn place_state(
&mut self,
@@ -2410,6 +2441,7 @@ impl Game<'_> {
if ctx.has("destroy") && current != self.world.air {
self.destroy_block_dropping(pos)?;
}
let state = self.block_input_state(pos, &b, state, ctx.has("strict"))?;
let placed = self.place_state(pos, state, ctx.has("strict"))?;
let loaded = match &b.nbt {
Some(nbt) => self.load_block_entity_nbt(pos, nbt),
@@ -7,7 +7,7 @@ use horizon_client::{
text::Text,
worldborder::MAX_SIZE,
};
use horizon_core::random::RandomSource;
use horizon_core::{position::ChunkPos, random::RandomSource};
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
@@ -41,7 +41,8 @@ impl Game<'_> {
match id {
"forceload.query" if ctx.get("pos").is_none() => {
let mut forced: Vec<(i32, i32)> = self.streaming.forced.iter().copied().collect();
forced.sort_unstable();
// ForceLoadCommand sorts its LongStream before formatting chunks.
forced.sort_unstable_by_key(|&(x, z)| ChunkPos::new(x, z).pack());
if forced.is_empty() {
self.command_failure(Text::translate("commands.forceload.added.none", &[dim]));
return Ok(0);
@@ -1736,6 +1736,14 @@ impl Game<'_> {
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
// ServerPlayer.onEffectAdded and onEffectUpdated send an effect
// the command gave as it was given: vanilla's console runs after
// the players' tick (`DedicatedServer.tickConnection`), so the
// next tick is the first to count it down. A player's own
// commands sync theirs the same way (`run_guest_command`).
if let Some(h) = self.lan.as_mut() {
h.hub.sync_all_effects();
}
}
}
}
+6 -1
View File
@@ -155,7 +155,12 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob("minecraft:happy_ghast", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
// DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity, so ageBoundaryReached runs the ghastling setup
// (`babyGhastSetup`) while the mob is still being hatched: its
// goals, navigation and brain are a ghastling's before its first
// tick, not an adult's until the next aiStep.
horizon_client::mobs::finalize::set_age(m, -24000);
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
+24
View File
@@ -94,6 +94,29 @@ impl Game<'_> {
.integrated
.as_ref()
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
// The server thread's generation: proto chunks waiting for their
// neighbours, and the light jobs' views of them.
let generation = self.integrated.as_ref().and_then(|i| {
i.call(|g| {
let c = g.streaming.proto_census();
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
json!({
"protos": c.protos,
"protos_mb": mb(c.bytes.iter().sum::<usize>()),
"proto_blocks_mb": mb(blocks),
"proto_section_lists_mb": mb(lists),
"proto_heightmaps_mb": mb(heightmaps),
"proto_other_terrain_mb": mb(other),
"proto_biomes_mb": mb(biomes),
"proto_records_mb": mb(records),
"proto_sections_by_bits": c.sections_by_bits,
"light_views": c.light_views,
"light_views_mb": mb(c.light_view_bytes),
"caches": c.caches.iter().map(|(name, n, bytes)| json!([name, n, bytes.map(mb)])).collect::<Vec<_>>(),
"stages": g.streaming.stages().iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
})
});
// The window's sections the server thread holds too (lan/handoff.rs).
let shared = server.as_ref().map(|(_, at)| {
let (sections, bytes) = shared(self.all_worlds(), at);
@@ -136,6 +159,7 @@ impl Game<'_> {
"client_columns": census_json(&client),
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
"shared_with_server": shared,
"server_generation": generation,
"far_tiles": self.lod.as_ref().map(|lod| {
let (active, cached) = lod.stats();
json!({ "active": active, "cached": cached })
+18
View File
@@ -495,4 +495,22 @@ impl Host for super::Game<'_> {
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.native_block_collision(pos)
}
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
self.native_item_container_slots(stack)
}
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
self.native_item_container_insert(stack, item)
}
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
}
@@ -2827,6 +2827,18 @@ impl Game<'_> {
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -0,0 +1,370 @@
//! Items held by item stacks for native mods (docs/native-mods.md): a mod's
//! backpacks, storage cells and toolboxes (`native_item_container`), read,
//! filled and emptied. NeoForge's `Capabilities.Item.ITEM` is a
//! `ResourceHandler<ItemResource>` reached through an `ItemAccess`: it acts on
//! one item of a stack at a time and may change it. So do these, as the item
//! fluid and energy helpers do: they take a stack and answer without changing
//! it, and the caller shrinks its stack by one and hands out the changed item
//! they return. The contents live in `minecraft:container`, as a shulker box
//! item keeps them. They hold no world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// `ItemContainerContents`' limit on slots.
const MAX_SLOTS: u32 = 256;
/// `Container.getMaxStackSize`.
const CONTAINER_MAX: u32 = 99;
/// The slots of one item of `stack`, empty ones included; slots past `size`
/// are dropped, as the item has no room for them.
fn contents(stack: &ItemStack, size: u32) -> Vec<Option<ItemStack>> {
let mut slots = vec![None; size as usize];
for (slot, item) in stack.data().map_or(&[][..], |d| &d.container[..]) {
if let Some(s) = slots.get_mut(usize::from(*slot))
&& item.count > 0
{
*s = Some(item.clone());
}
}
slots
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `slots`, its other data kept. An empty item
/// carries nothing, so it stacks with a new one.
fn with_contents(stack: &ItemStack, slots: &[Option<ItemStack>]) -> ItemStack {
let mut one = one_of(stack);
let data = one.data_mut();
data.container = slots
.iter()
.enumerate()
.filter_map(|(i, s)| Some((i as u8, s.clone().filter(|s| s.count > 0)?)))
.collect();
// What a server sent for the stack no longer describes it; kept, an
// emptied item would not stack with a new one.
data.wire = Default::default();
one.tidy();
one
}
/// `SimpleContainer.addItem` on one item of `stack`: `item` tops up the
/// slots that hold the same item and components, then goes into the first
/// empty ones. Unlike `SimpleContainer`, which puts the whole rest in the
/// first empty slot and cuts it to the limit, no slot takes more than its
/// limit (`limit`), and what doesn't fit stays out. A shulker box never goes
/// in (`Item.canFitInsideContainerItems`). The count that went in, and the
/// item it becomes.
fn insert_one(
stack: &ItemStack,
size: Option<u32>,
item: &ItemStack,
limit: u32,
) -> Option<(u32, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
if item.count == 0 || !horizon_client::menu::fits_inside_container_items(item) {
return Some((0, one_of(stack)));
}
let mut slots = contents(stack, size);
let mut left = item.count;
for held in slots.iter_mut().flatten() {
if left == 0 {
break;
}
if held.same_item_same_components(item) {
let room = limit.saturating_sub(held.count).min(left);
held.count += room;
left -= room;
}
}
for slot in slots.iter_mut().filter(|s| s.is_none()) {
if left == 0 {
break;
}
let put = limit.min(left);
*slot = Some(ItemStack {
count: put,
..item.clone()
});
left -= put;
}
let moved = item.count - left;
if moved == 0 {
return Some((0, one_of(stack)));
}
Some((moved, with_contents(stack, &slots)))
}
/// Up to `max` out of `slot` of one item of `stack`: what came out (none
/// when nothing did), and the item it becomes.
fn extract_one(
stack: &ItemStack,
size: Option<u32>,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
let mut slots = contents(stack, size);
let Some(Some(held)) = slots.get_mut(slot) else {
return Some((None, one_of(stack)));
};
let take = held.count.min(max);
if take == 0 {
return Some((None, one_of(stack)));
}
let taken = ItemStack {
count: take,
..held.clone()
};
held.count -= take;
Some((Some(taken), with_contents(stack, &slots)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The slots a mod gave item `item`, clamped; none for any other item.
fn item_container_size(&self, item: &str) -> Option<u32> {
if item.starts_with("minecraft:") {
return None;
}
let mut slots = None;
self.native_item_container(item, &mut slots);
slots.filter(|s| *s >= 1).map(|s| s.min(MAX_SLOTS))
}
/// `Container.getMaxStackSize(stack)`: the container's 99, or the stack's
/// own limit (its `max_stack_size` component, else the item's) when less.
fn item_container_limit(&self, stack: &ItemStack) -> u32 {
let own = match stack
.data()
.and_then(|d| d.other("minecraft:max_stack_size"))
{
Some(Tag::Int(n)) => (*n).clamp(1, 99) as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack),
};
own.min(CONTAINER_MAX)
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
pub(in crate::game) fn native_item_container_slots(
&self,
stack: &ItemStack,
) -> Option<Vec<Option<ItemStack>>> {
let size = self.item_container_size(&stack.item)?;
(stack.count > 0).then(|| contents(stack, size))
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
pub(in crate::game) fn native_item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let size = self.item_container_size(&stack.item);
insert_one(stack, size, item, self.item_container_limit(item))
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
pub(in crate::game) fn native_item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = self.item_container_size(&stack.item);
extract_one(stack, size, slot, max)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::JavaString;
use std::sync::Arc;
fn bag(count: u32) -> ItemStack {
ItemStack::new("mymod:bag", count)
}
fn of(item: &str, count: u32) -> ItemStack {
ItemStack::new(item, count)
}
/// (slot, item, count) of what one item holds.
fn held(stack: &ItemStack, size: u32) -> Vec<(usize, String, u32)> {
contents(stack, size)
.into_iter()
.enumerate()
.filter_map(|(i, s)| s.map(|s| (i, s.item.to_string(), s.count)))
.collect()
}
#[test]
fn an_insert_tops_up_equal_stacks_before_empty_slots() {
let (_, one) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 10), 64).unwrap();
let (_, one) = insert_one(&one, Some(3), &of("minecraft:dirt", 5), 64).unwrap();
let (moved, one) = insert_one(&one, Some(3), &of("minecraft:stone", 60), 64).unwrap();
// 54 top up slot 0 to 64, the other 6 take the first empty slot.
assert_eq!(moved, 60);
assert_eq!(
held(&one, 3),
[
(0, "minecraft:stone".into(), 64),
(1, "minecraft:dirt".into(), 5),
(2, "minecraft:stone".into(), 6),
]
);
}
#[test]
fn no_slot_passes_its_limit_and_the_rest_stays_out() {
// 16 per slot (ender pearls), two slots: 32 of 40 go in.
let (moved, one) =
insert_one(&bag(1), Some(2), &of("minecraft:ender_pearl", 40), 16).unwrap();
assert_eq!(moved, 32);
assert_eq!(
held(&one, 2),
[
(0, "minecraft:ender_pearl".into(), 16),
(1, "minecraft:ender_pearl".into(), 16),
]
);
// Full: nothing goes in, and the item comes back as it was.
assert_eq!(
insert_one(&one, Some(2), &of("minecraft:ender_pearl", 1), 16).unwrap(),
(0, one.clone())
);
}
#[test]
fn different_components_never_share_a_slot() {
let mut named = of("minecraft:stone", 1);
named.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Rock"))),
);
let (_, one) = insert_one(&bag(1), Some(2), &of("minecraft:stone", 1), 64).unwrap();
let (moved, one) = insert_one(&one, Some(2), &named, 64).unwrap();
assert_eq!(moved, 1);
assert_eq!(held(&one, 2).len(), 2);
}
#[test]
fn a_shulker_box_never_goes_in() {
let shulker = of("minecraft:red_shulker_box", 1);
assert_eq!(
insert_one(&bag(1), Some(9), &shulker, 1).unwrap(),
(0, bag(1))
);
// Another container item does.
assert_eq!(insert_one(&bag(1), Some(9), &bag(1), 64).unwrap().0, 1);
}
#[test]
fn an_extract_takes_up_to_max_and_an_emptied_bag_is_new_again() {
let (_, full) = insert_one(&bag(1), Some(4), &of("minecraft:apple", 20), 64).unwrap();
let (taken, rest) = extract_one(&full, Some(4), 0, 15).unwrap();
assert_eq!(taken.map(|t| t.count), Some(15));
assert_eq!(held(&rest, 4), [(0, "minecraft:apple".into(), 5)]);
let (taken, empty) = extract_one(&rest, Some(4), 0, 64).unwrap();
assert_eq!(
taken.map(|t| (t.item.to_string(), t.count)),
Some(("minecraft:apple".into(), 5))
);
assert_eq!(empty, bag(1));
// An empty slot, or one past the bag's slots, gives nothing.
assert_eq!(extract_one(&empty, Some(4), 0, 1).unwrap(), (None, bag(1)));
assert_eq!(
extract_one(&full, Some(4), 7, 1).unwrap(),
(None, full.clone())
);
}
#[test]
fn the_stack_given_stays_and_one_item_comes_back() {
let stack = bag(3);
let (moved, one) = insert_one(&stack, Some(1), &of("minecraft:stone", 1), 64).unwrap();
assert_eq!((moved, one.count, stack.count), (1, 1, 3));
assert_eq!(stack, bag(3));
}
#[test]
fn items_without_slots_hold_nothing() {
assert_eq!(
insert_one(&bag(1), None, &of("minecraft:stone", 1), 64),
None
);
assert_eq!(extract_one(&bag(1), None, 0, 1), None);
assert_eq!(
insert_one(&bag(0), Some(3), &of("minecraft:stone", 1), 64),
None
);
// Nothing to insert moves nothing.
assert_eq!(
insert_one(&bag(1), Some(3), &of("minecraft:stone", 0), 64).unwrap(),
(0, bag(1))
);
}
#[test]
fn slots_past_the_size_are_dropped() {
// A mod that shrank its bag after it was filled.
let (_, big) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 64 * 3), 64).unwrap();
assert_eq!(held(&big, 2).len(), 2);
assert_eq!(extract_one(&big, Some(2), 2, 64).unwrap().0, None);
}
#[test]
fn an_emptied_bag_from_a_server_stacks_with_a_new_one() {
let mut sent = bag(2);
sent.data_mut().wire = horizon_client::item_data::Wire::new(bag(2), Arc::from(vec![1u8]));
let (_, filled) = insert_one(&sent, Some(1), &of("minecraft:stone", 3), 64).unwrap();
let (_, emptied) = extract_one(&filled, Some(1), 0, 3).unwrap();
assert!(emptied.same_item_same_components(&bag(1)));
}
#[test]
fn a_bag_keeps_the_rest_of_its_data() {
let mut stack = bag(1);
stack.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Kit"))),
);
let (_, filled) = insert_one(&stack, Some(2), &of("minecraft:stone", 2), 64).unwrap();
assert!(
filled
.data()
.and_then(|d| d.other("minecraft:custom_name"))
.is_some()
);
assert_eq!(held(&filled, 2), [(0, "minecraft:stone".into(), 2)]);
let (_, emptied) = extract_one(&filled, Some(2), 0, 2).unwrap();
assert_eq!(emptied, stack);
}
}
+1 -1
View File
@@ -611,7 +611,7 @@ impl Game<'_> {
}
horizon_client::graphics::Pacing::Efficient => {
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
horizon_client::window::end_frame_on_grid(&mut self.limiter, limit, &mut clock);
}
}
}
+107
View File
@@ -4172,6 +4172,113 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// A hub with one placed guest, the speed effect registered, and what the
/// guest is sent.
fn hub_with_a_guest() -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
};
let mut sync = SyncData::empty();
sync.builtin.registries.insert(
"minecraft:mob_effect".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:speed".to_string()]),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let hub_config = HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let (tx, rx) = std::sync::mpsc::channel();
let mut bob = Guest::new(
crate::lan::Joined {
conn: 1,
uuid: 1,
name: "Bob".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
1,
[0.5, 64.0, 0.5],
8,
);
bob.placed = true;
bob.dimension = "minecraft:overworld".into();
hub.guests.push(bob);
(hub, rx)
}
/// The durations of the effect packets the guest was sent.
fn sent_durations(rx: &std::sync::mpsc::Receiver<crate::lan::Out>) -> Vec<i32> {
let mut durations = Vec::new();
while let Ok(out) = rx.try_recv() {
let crate::lan::Out::Packet(body) = out else {
continue;
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::UpdateMobEffect(p)) = C::decode(id, r.rest()) {
durations.push(p.duration);
}
}
durations
}
/// `/effect give Bob minecraft:speed 30 1` from a dedicated server's
/// console (#7115). Vanilla runs the console after the players' tick
/// (`DedicatedServer.tickConnection`) and `ServerPlayer.onEffectAdded`
/// sends the effect at once: 30 s is 600 ticks, and the tick after, which
/// counts it down to 599, sends nothing (`LivingEntity.tickEffects` only
/// refreshes it at a multiple of 600 left). The console syncs right after
/// its command (`console_command`) for the same packet.
#[test]
fn an_effect_synced_after_its_command_is_sent_once_at_its_full_duration() {
let (mut hub, rx) = hub_with_a_guest();
hub.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
// The console's command, then its sync.
hub.sync_all_effects();
// The next tick: the guests' effects count down (`tick_guests`), then
// the hub syncs them (`tick_level`).
hub.guests[0].effects.tick();
hub.sync_all_effects();
hub.guests[0].effects.tick();
hub.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![600]);
// Without the sync after the command, the first sync comes after the
// tick has counted it down: what #7115's transcript shows.
let (mut late, rx) = hub_with_a_guest();
late.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
late.guests[0].effects.tick();
late.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![599]);
}
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
@@ -863,6 +863,11 @@ fn age_boundary_reached(m: &mut Mob) {
Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }),
Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }),
Species::SulfurCube if !baby => sulfur_size(m, 2),
// HappyGhast.ageBoundaryReached: adultGhastSetup or babyGhastSetup,
// then Animal's no-op. A hatched ghastling needs it before it is in
// the level (DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity), or its first tick flies the adult goals.
Species::HappyGhast => super::happy_ghast::ensure_setup(m),
_ => {}
}
}
+4 -2
View File
@@ -2155,9 +2155,11 @@ impl Goal for Breed {
return false;
}
let species = m.species();
// Wolf.canMate: both tame and standing.
// Wolf.canMate and Cat.canMate: both tame. The sitting pose sits on the
// partner side only, and only for a wolf (`wolf.isInSittingPose()`), so
// a tamed mob ordered to sit still walks off to breed.
let tamable = matches!(species, Species::Wolf | Species::Cat);
if tamable && (!m.extra.tame || m.extra.sitting) {
if tamable && !m.extra.tame {
return false;
}
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
@@ -667,4 +667,40 @@ mod tests {
fn travel_speed_is_five_thirds_of_the_flying_speed() {
assert!((f64::from(AIR_SPEED) - FLYING_SPEED * 5.0 / 3.0).abs() < 1e-7);
}
/// `HappyGhast.ageBoundaryReached` runs the setup at the age crossing, not
/// at the next tick: `DriedGhastBlock.spawnGhastling` makes its ghastling a
/// baby before `addFreshEntity`, and `AgeableMob.setAge` calls
/// `ageBoundaryReached` there. Without it the hatched ghastling joins the
/// level with the adult's three goals, its ground navigation and its brain
/// stopped, and only its first `aiStep` (`ensure_setup`) fixes that.
#[test]
fn a_hatched_ghastling_is_set_up_as_one_at_the_age_crossing() {
use crate::mobs::Species;
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = Species::HappyGhast;
let mut m = Mob::new(
1,
std::sync::Arc::new(t),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
// `Mob::new` registers the adult's goals (HappyGhastFloat, its tempt
// goal and FloatAround) for the age-zero ghast it makes.
assert_eq!(m.extra.scratch[0], 2, "an adult's setup at construction");
assert!(m.ai.goals.has("HappyGhastFloatGoal"));
crate::mobs::finalize::set_age(&mut m, -24000);
assert!(m.is_baby());
assert_eq!(m.extra.scratch[0], 1, "babyGhastSetup ran");
assert!(
!m.ai.goals.has("HappyGhastFloatGoal")
&& !m.ai.goals.has("RandomFloatAroundGoal")
&& !m.ai.goals.has("ForNonPathfinders"),
"a ghastling has no goals: its brain drives it"
);
assert_eq!(m.ai.nav.kind, crate::mobs::nav::NavKind::Flying);
assert!(m.ai.nav.can_float);
}
}
+45
View File
@@ -1874,6 +1874,51 @@ mod tests {
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0])));
assert!(m.extra.sitting);
}
/// `Cat.canMate` and `Wolf.canMate`: the seeker only has to be tamed, so a
/// mob ordered to sit still goes looking for a mate. Only a wolf's
/// *partner* is refused while it sits (`wolf.isInSittingPose()`), which is
/// the one sitting check vanilla makes.
#[test]
fn a_sitting_tame_mob_still_looks_for_a_mate() {
use crate::mobs::goals::{Breed, Cx, Goal};
use crate::mobs::world::with_test_world;
let named = |species, name: &str, id: u32, sat: bool| {
let mut m = sitting(species, name);
m.id = id;
m.extra.in_love = 600;
m.extra.sitting = sat;
m
};
for (species, name, seeker_sat, partner_sat, breeds) in [
// A tamed cat pairs with a tamed cat, sitting or not.
(Species::Cat, "minecraft:cat", true, true, true),
(Species::Cat, "minecraft:cat", true, false, true),
// A wolf pairs unless the wolf it would take is sitting.
(Species::Wolf, "minecraft:wolf", true, false, true),
(Species::Wolf, "minecraft:wolf", true, true, false),
(Species::Wolf, "minecraft:wolf", false, true, false),
] {
let mut seeker = named(species, name, 1, seeker_sat);
let partner = named(species, name, 2, partner_sat);
let seen = [partner.seen(None)];
with_test_world(&seen, |w, _| {
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goal = Breed::new(1.0);
assert_eq!(
goal.can_use(&mut seeker, &mut cx),
breeds,
"{name}: seeker sits {seeker_sat}, partner sits {partner_sat}"
);
});
}
}
}
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
+38
View File
@@ -4529,6 +4529,22 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
///
/// Return `Some` to set `slots`; the first mod returning `Some` wins.
fn item_container(&self, host: &dyn Host, event: &mut ItemContainerEvent<'_>) -> Option<u32> {
let _ = host;
let _ = event;
None
}
/// `LivingEntity.getProjectile(weapon)` (NeoForge's
/// `LivingGetProjectileEvent`) for a player (`player` and `name`) or a
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
@@ -13933,6 +13949,28 @@ pub fn dispatch_compute_fov_modifier(
}
*modifier = event.modifier;
}
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemContainerEvent<'a> {
pub item: &'a str,
}
pub fn dispatch_item_container(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
item: &str,
slots: &mut Option<u32>,
) {
if slots.is_some() {
return;
}
let mut event = ItemContainerEvent { item };
for mod_hook in super::entered(mods, "item_container") {
if let Some(replacement) = mod_hook.item_container(host, &mut event) {
*slots = Some(replacement);
break;
}
}
}
/// What [`Hooks::living_get_projectile`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct LivingGetProjectileEvent<'a> {
+40
View File
@@ -1212,4 +1212,44 @@ pub trait Host {
let _ = pos;
unimplemented!("Host::block_collision")
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
let _ = stack;
unimplemented!("Host::item_container_slots")
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let _ = stack;
let _ = item;
unimplemented!("Host::item_container_insert")
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let _ = stack;
let _ = slot;
let _ = max;
unimplemented!("Host::item_container_extract")
}
}
+38
View File
@@ -4846,8 +4846,23 @@ fn decode_lod_surfaces(
}
}
}
// Spans are pushed top down, so the reverse puts the topmost last, as
// `Column::from_chunk` leaves them. Open water is then lit as open sky,
// exactly as the live path does: without it a saved column kept each
// water block's own faded skylight and drew a dark band through the sea
// where saved columns met the generator's (`grid`'s stride limit).
// Only where there is a sky to be lit by: the Nether's water must not
// gain skylight it never had.
let is_water = |state| {
sky_light
&& assets
.registry
.state(state)
.is_some_and(|block| block.name == "minecraft:water")
};
for column in &mut *columns {
column.spans.reverse();
column.light_open_water_where(is_water);
}
Some(Arc::from(columns))
}
@@ -4963,6 +4978,9 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
fn prepare_loaded(&self, worker: &mut G::Worker, chunk: &Chunk) {
self.inner.prepare_loaded(worker, chunk);
}
fn all_idle(&self) {
self.inner.all_idle();
}
fn idle(&self, worker: &mut G::Worker) {
self.inner.idle(worker);
}
@@ -4988,6 +5006,12 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
) -> Option<crate::section_mask::SectionMask> {
self.inner.non_air_sections(proto)
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
self.inner.cache_census()
}
fn proto_heap_bytes(&self, proto: &crate::streaming::Proto) -> ([usize; 6], [usize; 16]) {
self.inner.proto_heap_bytes(proto)
}
fn save_protos(&self, protos: &[&crate::streaming::Proto]) {
self.save
.tables
@@ -5639,6 +5663,20 @@ mod tests {
let underwater = surfaces[4 * 16 + 4].surface.unwrap();
assert_eq!((underwater.height, underwater.state), (55, sand));
assert_eq!(underwater.water, Some((64, water)));
// Open water reads as open sky, as the generator's far columns do.
// Saved columns that kept each block's own faded skylight drew a dark
// band through the sea where the two met.
let sea = &surfaces[4 * 16 + 4].spans;
let lit: Vec<_> = sea
.iter()
.filter(|span| span.state == water)
.map(|span| span.light & 0xf0)
.collect();
assert!(!lit.is_empty(), "the column keeps its water spans");
assert!(
lit.iter().all(|sky| *sky == 0xf0),
"saved open water is lit as open sky, got {lit:?}"
);
let chorus_column = &surfaces[2 * 16 + 12];
assert_eq!(
chorus_column
+18
View File
@@ -719,6 +719,9 @@ impl ChunkGenerator for VanillaTerrain {
fn idle(&self, worker: &mut GeneratorWorker) {
worker.release();
}
fn all_idle(&self) {
self.generator.release_caches();
}
fn lod_surface(
&self,
worker: &mut GeneratorWorker,
@@ -856,6 +859,21 @@ impl ChunkGenerator for VanillaTerrain {
_ => None,
}
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
let c = self.generator.cache_census();
vec![
("biome_chunks", c.biome_chunks, Some(c.biome_bytes)),
("carver_biomes", c.carver_entries, None),
("start_chunks", c.start_chunks, None),
("start_lists", c.start_lists, None),
]
}
fn proto_heap_bytes(&self, proto: &Proto) -> ([usize; 6], [usize; 16]) {
match proto.downcast_ref::<VanillaProto>() {
Some(VanillaProto::Ready(chunk)) => (chunk.heap_bytes(), chunk.terrain.storage_bits()),
_ => ([0; 6], [0; 16]),
}
}
fn non_air_sections(&self, proto: &Proto) -> Option<crate::section_mask::SectionMask> {
let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::<VanillaProto>() else {
return None;
+4
View File
@@ -70,6 +70,10 @@ pub struct PackedStorage {
}
impl PackedStorage {
/// The heap bytes of the packed words (`horizon-ctl memory`).
pub fn heap_bytes(&self) -> usize {
self.words.capacity() * std::mem::size_of::<u64>()
}
pub fn new(bits: u8, len: usize) -> Result<Self, StorageError> {
let per_word = Self::per_word(bits)?;
Self::from_words(bits, len, vec![0; len.div_ceil(per_word)])
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -66,6 +66,7 @@ pub mod glass_icons {
"copy-01",
"dashboard-square-01",
"delete-02",
"discord",
"download-04",
"earth",
"favourite",
+14
View File
@@ -379,6 +379,20 @@ pub struct BlockSection<'r> {
extra: Compound,
}
impl<'r> BlockSection<'r> {
/// The section's heap bytes: its packed storage, and its palette, counts
/// and free list (`horizon-ctl memory`; extra NBT not counted).
pub fn heap_bytes(&self) -> [usize; 2] {
[
self.storage.as_ref().map_or(0, PackedStorage::heap_bytes),
self.palette.capacity() * std::mem::size_of::<BlockStateId>()
+ self.counts.capacity() * std::mem::size_of::<u16>()
+ self.free.capacity() * std::mem::size_of::<u32>(),
]
}
/// Bits per block of its packed storage, 0 for a single-state section.
pub fn storage_bits(&self) -> u8 {
self.storage.as_ref().map_or(0, PackedStorage::bits)
}
pub fn registry(&self) -> &'r BlockRegistry {
self.registry
}
+4
View File
@@ -103,6 +103,10 @@ pub struct Heightmap {
}
impl Heightmap {
/// The heap bytes of its packed heights.
pub fn heap_bytes(&self) -> usize {
self.data.heap_bytes()
}
/// Bounded to the vanilla codec's positive, aligned vertical ranges.
pub fn new(kind: HeightmapType, min_y: i32, height: i32) -> Result<Self, HeightmapError> {
if !(-2032..=2031).contains(&min_y)
@@ -28,6 +28,10 @@ impl<E: std::fmt::Display> std::fmt::Display for BiomeFillError<E> {
}
impl<E: std::error::Error + 'static> std::error::Error for BiomeFillError<E> {}
impl<T: Clone> BiomeChunk<T> {
/// The heap bytes of its quart cells.
pub fn heap_bytes(&self) -> usize {
self.sections.capacity() * std::mem::size_of::<[T; 64]>()
}
/// A chunk's biome values use the same supported vertical bounds as terrain.
/// Each section stores a dense 4×4×4 array; palette/network codecs are separate.
pub fn new(
@@ -809,6 +809,24 @@ pub struct ProtoChunk {
pub entities: Vec<crate::nbt::Compound>,
}
impl ProtoChunk {
/// Its own heap bytes by part (`horizon-ctl memory`): packed blocks,
/// section palettes and lists, heightmaps, other terrain data, biomes,
/// and the box with its structure lists (the starts it shares not counted).
pub fn heap_bytes(&self) -> [usize; 6] {
let [storage, lists, heightmaps, other] = self.terrain.heap_bytes();
[
storage,
lists,
heightmaps,
other,
self.biomes.heap_bytes(),
std::mem::size_of::<Self>()
+ self.block_entities.capacity()
* std::mem::size_of::<([i32; 3], crate::nbt::Compound)>()
+ self.structure_starts.capacity() * std::mem::size_of::<usize>()
+ self.entities.capacity() * std::mem::size_of::<crate::nbt::Compound>(),
]
}
pub fn position(&self) -> ChunkPos {
self.terrain.position()
}
@@ -940,6 +958,18 @@ pub struct FlatSettings {
pub structure_overrides: Option<Vec<String>>,
}
const CACHE_LIMIT: usize = 16384;
/// What a generator's shared caches hold ([`OverworldGenerator::cache_census`]).
#[derive(Clone, Copy, Debug, Default)]
pub struct CacheCensus {
/// Chunks in the biome cache, and their bytes.
pub biome_chunks: usize,
pub biome_bytes: usize,
/// Carver origins whose biome is cached.
pub carver_entries: usize,
/// Chunks in the structure start cache, and how many of them hold starts.
pub start_chunks: usize,
pub start_lists: usize,
}
/// Coordinates only equal missing neighbour keys. Weak entries retain no
/// computed values across eviction or explicit invalidation. Only one work
/// lock may be held at a time; finish shared prerequisites before acquiring it.
@@ -2325,6 +2355,12 @@ trait SeededPipeline: Send + Sync {
region: &mut [ProtoChunk; 9],
) -> Result<DecorateStats, GeneratorError>;
fn data(&self) -> &Arc<GeneratorData>;
/// What the shared caches hold, for `horizon-ctl memory`.
fn cache_census(&self) -> CacheCensus {
CacheCensus::default()
}
/// Empty the caches that only spare work ([`OverworldGenerator::release_caches`]).
fn release_caches(&self) {}
/// The exact `end_island_decorated` origins in a chunk, without terrain.
fn end_island_origins(&self, chunk: ChunkPos) -> Vec<[i32; 3]>;
fn sea_level(&self) -> i32;
@@ -2431,6 +2467,30 @@ where
fn data(&self) -> &Arc<GeneratorData> {
&self.data
}
fn release_caches(&self) {
// Taken out under the lock, dropped after it.
let biomes = std::mem::take(&mut *self.biome_cache.lock().unwrap());
let carvers = std::mem::take(&mut *self.carver_cache.lock().unwrap());
drop((biomes, carvers));
}
fn cache_census(&self) -> CacheCensus {
let biomes = self.biome_cache.lock().unwrap();
let (start_chunks, start_lists) = self
.structures
.as_ref()
.map_or((0, 0), |s| s.start_census());
CacheCensus {
biome_chunks: biomes.len(),
biome_bytes: biomes
.values()
.map(|b| std::mem::size_of::<BiomeChunk<BiomeId>>() + b.heap_bytes())
.sum::<usize>()
+ biomes.capacity() * std::mem::size_of::<((i32, i32), usize)>(),
carver_entries: self.carver_cache.lock().unwrap().len(),
start_chunks,
start_lists,
}
}
fn end_island_origins(&self, chunk: ChunkPos) -> Vec<[i32; 3]> {
let Some(decoration) = &self.decoration else {
return Vec::new();
@@ -3187,6 +3247,17 @@ pub struct OverworldGenerator {
noise_clamped: Option<(NoiseSettings, NoiseSettings)>,
}
impl OverworldGenerator {
/// What the shared caches hold, for `horizon-ctl memory`.
pub fn cache_census(&self) -> CacheCensus {
self.pipeline.cache_census()
}
/// Empty the chunk biome and carver biome caches, while nothing generates:
/// they keep every chunk generated (up to 16384, 6 KB each) and only spare
/// computing a chunk's biomes again. The biomes come out the same: the
/// caches are emptied whole at their limit too.
pub fn release_caches(&self) {
self.pipeline.release_caches();
}
/// The chunks an older version generated, which new chunks blend with
/// (`Blender.of`); set once, before chunks are generated.
pub fn set_old_world(&self, world: Arc<dyn super::blending::OldWorld>) {
@@ -4278,6 +4349,82 @@ mod tests {
assert_eq!(Arc::strong_count(&next), 1);
}
/// Emptying the shared caches (every worker idle) changes no chunk: the
/// chunks generated after it have the same blocks and biomes.
#[test]
fn released_caches_generate_the_same_chunks() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3")
});
if !root.join("worldgen/world_presets.json").exists() {
eprintln!("skipping: no worldgen bundle at {}", root.display());
return;
}
let registry = Box::leak(Box::new(
crate::level::data::load_registry(&root).expect("registry"),
));
let data = GeneratorData::load(&root.join("worldgen"), registry).expect("worldgen data");
let preset: Value = serde_json::from_str(&data.world_presets["minecraft:normal"]).unwrap();
let generator_json = &preset["dimensions"]["minecraft:overworld"]["generator"];
let settings: Value =
serde_json::from_str(&data.settings[generator_json["settings"].as_str().unwrap()])
.unwrap();
let generator = OverworldGenerator {
pipeline: Arc::new(
Pipeline::new(
data,
12345,
XoroshiroRandom::new(12345),
generator_json,
&settings,
true,
Some((-64, 384)),
)
.expect("pipeline"),
),
noise_clamped: None,
};
let contents = |chunk: &ProtoChunk| {
let (x0, z0) = (chunk.position().x * 16, chunk.position().z * 16);
let mut out = Vec::new();
for y in (-64..320).step_by(3) {
for z in 0..16 {
for x in 0..16 {
out.push(chunk.block(x0 + x, y, z0 + z).0);
out.push(chunk.biome_id_at(x0 + x, y, z0 + z).0);
}
}
}
out
};
let mut worker = generator.worker();
let chunks = [
ChunkPos::new(11, -11),
ChunkPos::new(12, -11),
ChunkPos::new(-3, 7),
];
let before: Vec<_> = chunks
.iter()
.map(|&p| contents(&worker.generate_proto(p).unwrap()))
.collect();
assert!(generator.cache_census().biome_chunks > 0);
generator.release_caches();
worker.release();
let census = generator.cache_census();
assert_eq!((census.biome_chunks, census.carver_entries), (0, 0));
// The other way round, so a neighbour's biomes come from the cache
// where they were made before, and the reverse.
for (&p, before) in chunks.iter().zip(&before).rev() {
assert_eq!(
&contents(&worker.generate_proto(p).unwrap()),
before,
"chunk {p:?}"
);
}
}
/// Equal concurrent misses share one biome volume and the same mutable
/// structure pieces. Exercise cold ring startup before work coordination,
/// then cache eviction and explicit start invalidation with old Arcs alive.
@@ -25,6 +25,12 @@ use std::{
};
pub(super) type Starts = Arc<Vec<Arc<StructureStart>>>;
impl StructureState {
/// [`StartCache::census`].
pub(super) fn start_census(&self) -> (usize, usize) {
self.starts.census()
}
}
pub(super) struct StructureState {
pub(super) registry: StructureRegistry,
pub(super) resources: Arc<StructureResources>,
@@ -156,6 +162,16 @@ impl StartCache {
}
shard.entry(key).or_insert(starts).clone()
}
/// Chunks cached, and how many of them hold a start.
fn census(&self) -> (usize, usize) {
let mut census = (0, 0);
for shard in &self.shards {
let shard = shard.read().unwrap();
census.0 += shard.len();
census.1 += shard.values().filter(|s| !s.is_empty()).count();
}
census
}
fn replace(&self, key: (i32, i32), starts: Starts) {
self.shard(key).write().unwrap().insert(key, starts);
}
@@ -222,6 +222,46 @@ fn local_index(x: i32, y: i32, z: i32) -> usize {
}
impl<'r, B: TerrainBlockStates> TerrainChunk<'r, B> {
/// Its heap bytes by part (`horizon-ctl memory`; scheduled ticks not
/// counted): packed block storage, the sections' palettes and lists,
/// heightmaps, and the rest (post-processing marks, non-air counts).
pub fn heap_bytes(&self) -> [usize; 4] {
let blocks = &self.blocks;
let [mut storage, mut lists] = [
0,
blocks.sections.capacity() * std::mem::size_of::<BlockSection<'_>>(),
];
for section in &blocks.sections {
let [s, l] = section.heap_bytes();
storage += s;
lists += l;
}
[
storage,
lists,
self.heightmaps
.iter()
.flatten()
.map(Heightmap::heap_bytes)
.sum(),
blocks.non_air.capacity() * std::mem::size_of::<u16>()
+ self.postprocessing.capacity() * std::mem::size_of::<Vec<u16>>()
+ self
.postprocessing
.iter()
.map(|p| p.capacity() * std::mem::size_of::<u16>())
.sum::<usize>(),
]
}
/// How many of its sections pack their blocks at each bit width (index
/// = bits, 0 = a single state).
pub fn storage_bits(&self) -> [usize; 16] {
let mut bits = [0; 16];
for section in &self.blocks.sections {
bits[usize::from(section.storage_bits()).min(15)] += 1;
}
bits
}
pub fn new(
position: ChunkPos,
min_y: i32,
+1 -1
View File
@@ -30,7 +30,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
| Advanced | present mode, frame pacing, graphics API, translucent batching, performance overlay (FPS, frame/CPU/GPU times, frame-time graph) | `horizon-graphics.txt` |
| HDR | HDR, calibration, black level, peak brightness, paper white, UI brightness, a shortcut key to the page | `horizon-graphics.txt` |
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, entity and leaf culling off, no upload budgets, sky, fog and weather on, 100% resolution, no anti-aliasing, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, which changes timing, not pixels.
`options.txt` keeps exactly vanilla's keys and values, so the options harness still compares it with vanilla; Horizon's settings live in `horizon-graphics.txt` beside it. Their defaults render as vanilla does: cave culling on, entity and leaf culling off, no upload budgets, sky, fog and weather on, 100% resolution, no anti-aliasing, HDR off, the present mode following VSync. VSync on always waits for the display (Fifo), in a window and in fullscreen, as in vanilla; the present mode setting chooses how frames are shown only while VSync is off. Frame pacing defaults to Efficient: the limiter sleeps through the last half millisecond where vanilla spins, and paces frames on a fixed grid of slots (vanilla starts each slot when the thread wakes up, so every late wake-up lengthens a frame), which changes timing, not pixels.
**Entity Culling** is off by default and disabled with non-vanilla resource packs. It applies to entity models; block entities keep their section and per-block frustum culling and use the separate Block Entities caches. Synchronous mode uses the existing same-frame CPU test. Asynchronous mode checks meshes on a worker and uses a result only when the camera and mesh bounds still match and the opaque chunks and rendered sections that proved occlusion are unchanged and ready. Until then, it leaves the mesh visible. Both modes leave simulation and interaction unchanged; older `entityCulling:true` settings select Synchronous.
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Der Horizon Launcher wurde entfernt.",
"These could not be removed:": "Das konnte nicht entfernt werden:",
"Launch Horizon": "Horizon starten",
"Something went wrong.": "Etwas ist schiefgelaufen."
"Something went wrong.": "Etwas ist schiefgelaufen.",
"Instances": "Instanzen",
"Join the Discord": "Dem Discord beitreten",
"Restart to update": "Neu starten zum Aktualisieren",
"Duplicate…": "Duplizieren…",
"Create a desktop shortcut": "Desktop-Verknüpfung erstellen",
"Remove the desktop shortcut": "Desktop-Verknüpfung entfernen",
"Click again to delete": "Zum Löschen erneut klicken",
"Update available": "Update verfügbar",
"1 screenshot": "1 Screenshot",
"{count} screenshots": "{count} Screenshots",
"Choose the Minecraft folder": "Minecraft-Ordner wählen",
"Copy the launch command": "Startbefehl kopieren",
"Install, remove and pick versions": "Versionen installieren, entfernen und wählen",
"Newest · follows updates": "Neueste · folgt Updates",
"Not found": "Nicht gefunden",
"Open the instance folder": "Instanzordner öffnen",
"Open the screenshots folder": "Screenshot-Ordner öffnen",
"Play without updating": "Ohne Update spielen",
"Playing for {time} now. Have fun!": "Du spielst seit {time}. Viel Spaß!",
"Preparing": "Vorbereiten",
"Read {version}'s files from your own copy": "Die Dateien von {version} aus deiner eigenen Kopie lesen",
"Starts this instance in one click": "Startet diese Instanz mit einem Klick",
"Stay on {version}": "Bei {version} bleiben",
"You haven't played this one yet. Press Play to start.": "Du hast diese noch nicht gespielt. Drück auf Spielen, um zu starten.",
"You've played for {time}! Last played {when}.": "Du hast {time} gespielt! Zuletzt gespielt: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Alle Notizen",
"No notes yet": "Noch keine Notizen",
"No screenshots yet. Press F2 in the game.": "Noch keine Screenshots. Drück F2 im Spiel.",
"Patch log": "Änderungsprotokoll",
"See all": "Alle ansehen",
"No worlds yet. Make one in the game and it shows up here.": "Noch keine Welten. Erstelle eine im Spiel, dann erscheint sie hier.",
"No servers saved in this instance.": "In dieser Instanz sind keine Server gespeichert.",
"1 server in {name}": "1 Server in {name}",
"{count} servers in {name}": "{count} Server in {name}",
"Click again to remove": "Zum Entfernen erneut klicken"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher se ha eliminado.",
"These could not be removed:": "No se pudo eliminar:",
"Launch Horizon": "Iniciar Horizon",
"Something went wrong.": "Algo salió mal."
"Something went wrong.": "Algo salió mal.",
"Instances": "Instancias",
"Join the Discord": "Únete al Discord",
"Restart to update": "Reiniciar para actualizar",
"Duplicate…": "Duplicar…",
"Create a desktop shortcut": "Crear un acceso directo en el escritorio",
"Remove the desktop shortcut": "Quitar el acceso directo del escritorio",
"Click again to delete": "Haz clic de nuevo para eliminar",
"Update available": "Actualización disponible",
"1 screenshot": "1 captura",
"{count} screenshots": "{count} capturas",
"Choose the Minecraft folder": "Elegir la carpeta de Minecraft",
"Copy the launch command": "Copiar el comando de inicio",
"Install, remove and pick versions": "Instalar, quitar y elegir versiones",
"Newest · follows updates": "La más reciente · sigue las actualizaciones",
"Not found": "No encontrada",
"Open the instance folder": "Abrir la carpeta de la instancia",
"Open the screenshots folder": "Abrir la carpeta de capturas",
"Play without updating": "Jugar sin actualizar",
"Playing for {time} now. Have fun!": "Llevas {time} jugando. ¡Diviértete!",
"Preparing": "Preparando",
"Read {version}'s files from your own copy": "Leer los archivos de {version} de tu propia copia",
"Starts this instance in one click": "Inicia esta instancia con un clic",
"Stay on {version}": "Quedarse en {version}",
"You haven't played this one yet. Press Play to start.": "Aún no has jugado a esta. Pulsa Jugar para empezar.",
"You've played for {time}! Last played {when}.": "¡Has jugado {time}! Última vez: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Todas las notas",
"No notes yet": "Aún no hay notas",
"No screenshots yet. Press F2 in the game.": "Aún no hay capturas. Pulsa F2 en el juego.",
"Patch log": "Registro de cambios",
"See all": "Ver todo",
"No worlds yet. Make one in the game and it shows up here.": "Aún no hay mundos. Crea uno en el juego y aparecerá aquí.",
"No servers saved in this instance.": "No hay servidores guardados en esta instancia.",
"1 server in {name}": "1 servidor en {name}",
"{count} servers in {name}": "{count} servidores en {name}",
"Click again to remove": "Haz clic otra vez para quitar"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher a été supprimé.",
"These could not be removed:": "Ceci n’a pas pu être supprimé :",
"Launch Horizon": "Lancer Horizon",
"Something went wrong.": "Une erreur s’est produite."
"Something went wrong.": "Une erreur s’est produite.",
"Instances": "Instances",
"Join the Discord": "Rejoindre le Discord",
"Restart to update": "Redémarrer pour mettre à jour",
"Duplicate…": "Dupliquer…",
"Create a desktop shortcut": "Créer un raccourci sur le bureau",
"Remove the desktop shortcut": "Supprimer le raccourci du bureau",
"Click again to delete": "Cliquer encore pour supprimer",
"Update available": "Mise à jour disponible",
"1 screenshot": "1 capture d'écran",
"{count} screenshots": "{count} captures d'écran",
"Choose the Minecraft folder": "Choisir le dossier Minecraft",
"Copy the launch command": "Copier la commande de lancement",
"Install, remove and pick versions": "Installer, retirer et choisir des versions",
"Newest · follows updates": "La plus récente · suit les mises à jour",
"Not found": "Introuvable",
"Open the instance folder": "Ouvrir le dossier de l'instance",
"Open the screenshots folder": "Ouvrir le dossier des captures d'écran",
"Play without updating": "Jouer sans mettre à jour",
"Playing for {time} now. Have fun!": "Tu joues depuis {time}. Amuse-toi bien !",
"Preparing": "Préparation",
"Read {version}'s files from your own copy": "Lire les fichiers de {version} depuis ta propre copie",
"Starts this instance in one click": "Lance cette instance en un clic",
"Stay on {version}": "Rester sur {version}",
"You haven't played this one yet. Press Play to start.": "Tu n'y as pas encore joué. Appuie sur Jouer pour commencer.",
"You've played for {time}! Last played {when}.": "Tu as joué {time} ! Dernière partie : {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Toutes les notes",
"No notes yet": "Pas encore de notes",
"No screenshots yet. Press F2 in the game.": "Pas encore de captures d'écran. Appuie sur F2 en jeu.",
"Patch log": "Journal des modifications",
"See all": "Tout voir",
"No worlds yet. Make one in the game and it shows up here.": "Aucun monde pour l'instant. Crée-en un dans le jeu et il apparaîtra ici.",
"No servers saved in this instance.": "Aucun serveur enregistré dans cette instance.",
"1 server in {name}": "1 serveur dans {name}",
"{count} servers in {name}": "{count} serveurs dans {name}",
"Click again to remove": "Cliquer encore pour retirer"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher was removed.",
"These could not be removed:": "These could not be removed:",
"Launch Horizon": "Launch Horizon",
"Something went wrong.": "Something went wrong."
"Something went wrong.": "Something went wrong.",
"Instances": "インスタンス",
"Join the Discord": "Discord に参加",
"Restart to update": "再起動して更新",
"Duplicate…": "複製…",
"Create a desktop shortcut": "デスクトップにショートカットを作成",
"Remove the desktop shortcut": "デスクトップのショートカットを削除",
"Click again to delete": "もう一度クリックで削除",
"Update available": "アップデートあり",
"1 screenshot": "スクリーンショット 1 枚",
"{count} screenshots": "スクリーンショット {count} 枚",
"Choose the Minecraft folder": "Minecraft フォルダーを選ぶ",
"Copy the launch command": "起動コマンドをコピー",
"Install, remove and pick versions": "バージョンのインストール、削除、選択",
"Newest · follows updates": "最新 · アップデートに追従",
"Not found": "見つかりません",
"Open the instance folder": "インスタンスのフォルダーを開く",
"Open the screenshots folder": "スクリーンショットのフォルダーを開く",
"Play without updating": "アップデートせずにプレイ",
"Playing for {time} now. Have fun!": "{time} プレイ中。楽しんで!",
"Preparing": "準備中",
"Read {version}'s files from your own copy": "自分のコピーから {version} のファイルを読み込む",
"Starts this instance in one click": "ワンクリックでこのインスタンスを起動",
"Stay on {version}": "{version} のままにする",
"You haven't played this one yet. Press Play to start.": "まだプレイしていません。プレイを押して始めましょう。",
"You've played for {time}! Last played {when}.": "{time} プレイしました!最後のプレイ:{when}。",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "すべてのノート",
"No notes yet": "まだノートはありません",
"No screenshots yet. Press F2 in the game.": "まだスクリーンショットはありません。ゲーム内で F2 を押してください。",
"Patch log": "変更履歴",
"See all": "すべて表示",
"No worlds yet. Make one in the game and it shows up here.": "まだワールドがありません。ゲーム内で作成するとここに表示されます。",
"No servers saved in this instance.": "このインスタンスに保存されたサーバーはありません。",
"1 server in {name}": "{name} のサーバー 1 件",
"{count} servers in {name}": "{name} のサーバー {count} 件",
"Click again to remove": "もう一度クリックして削除"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher został usunięty.",
"These could not be removed:": "Nie udało się usunąć:",
"Launch Horizon": "Uruchom Horizon",
"Something went wrong.": "Coś poszło nie tak."
"Something went wrong.": "Coś poszło nie tak.",
"Instances": "Instancje",
"Join the Discord": "Dołącz do Discorda",
"Restart to update": "Uruchom ponownie, aby zaktualizować",
"Duplicate…": "Duplikuj…",
"Create a desktop shortcut": "Utwórz skrót na pulpicie",
"Remove the desktop shortcut": "Usuń skrót z pulpitu",
"Click again to delete": "Kliknij ponownie, aby usunąć",
"Update available": "Dostępna aktualizacja",
"1 screenshot": "1 zrzut ekranu",
"{count} screenshots": "Zrzuty ekranu: {count}",
"Choose the Minecraft folder": "Wybierz folder Minecrafta",
"Copy the launch command": "Kopiuj polecenie uruchomienia",
"Install, remove and pick versions": "Instaluj, usuwaj i wybieraj wersje",
"Newest · follows updates": "Najnowsza · śledzi aktualizacje",
"Not found": "Nie znaleziono",
"Open the instance folder": "Otwórz folder instancji",
"Open the screenshots folder": "Otwórz folder zrzutów ekranu",
"Play without updating": "Graj bez aktualizacji",
"Playing for {time} now. Have fun!": "Grasz od {time}. Miłej zabawy!",
"Preparing": "Przygotowywanie",
"Read {version}'s files from your own copy": "Wczytaj pliki {version} z własnej kopii",
"Starts this instance in one click": "Uruchamia tę instancję jednym kliknięciem",
"Stay on {version}": "Zostań przy {version}",
"You haven't played this one yet. Press Play to start.": "Jeszcze w to nie grałeś. Naciśnij Graj, aby zacząć.",
"You've played for {time}! Last played {when}.": "Grałeś {time}! Ostatnio: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Wszystkie informacje",
"No notes yet": "Brak informacji",
"No screenshots yet. Press F2 in the game.": "Brak zrzutów ekranu. Naciśnij F2 w grze.",
"Patch log": "Dziennik zmian",
"See all": "Zobacz wszystkie",
"No worlds yet. Make one in the game and it shows up here.": "Nie ma jeszcze światów. Utwórz jeden w grze, a pojawi się tutaj.",
"No servers saved in this instance.": "W tej instancji nie zapisano żadnych serwerów.",
"1 server in {name}": "1 serwer w {name}",
"{count} servers in {name}": "Serwery w {name}: {count}",
"Click again to remove": "Kliknij ponownie, aby usunąć"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher was removed.",
"These could not be removed:": "These could not be removed:",
"Launch Horizon": "Launch Horizon",
"Something went wrong.": "Something went wrong."
"Something went wrong.": "Something went wrong.",
"Instances": "Instâncias",
"Join the Discord": "Entre no Discord",
"Restart to update": "Reiniciar para atualizar",
"Duplicate…": "Duplicar…",
"Create a desktop shortcut": "Criar um atalho na área de trabalho",
"Remove the desktop shortcut": "Remover o atalho da área de trabalho",
"Click again to delete": "Clique de novo para excluir",
"Update available": "Atualização disponível",
"1 screenshot": "1 captura de tela",
"{count} screenshots": "{count} capturas de tela",
"Choose the Minecraft folder": "Escolher a pasta do Minecraft",
"Copy the launch command": "Copiar o comando de início",
"Install, remove and pick versions": "Instalar, remover e escolher versões",
"Newest · follows updates": "Mais recente · acompanha as atualizações",
"Not found": "Não encontrada",
"Open the instance folder": "Abrir a pasta da instância",
"Open the screenshots folder": "Abrir a pasta de capturas de tela",
"Play without updating": "Jogar sem atualizar",
"Playing for {time} now. Have fun!": "Jogando há {time}. Divirta-se!",
"Preparing": "Preparando",
"Read {version}'s files from your own copy": "Ler os arquivos da {version} da sua própria cópia",
"Starts this instance in one click": "Inicia esta instância com um clique",
"Stay on {version}": "Ficar na {version}",
"You haven't played this one yet. Press Play to start.": "Você ainda não jogou esta. Aperte Jogar para começar.",
"You've played for {time}! Last played {when}.": "Você jogou {time}! Última vez: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Todas as notas",
"No notes yet": "Ainda não há notas",
"No screenshots yet. Press F2 in the game.": "Ainda não há capturas de tela. Aperte F2 no jogo.",
"Patch log": "Registro de mudanças",
"See all": "Ver tudo",
"No worlds yet. Make one in the game and it shows up here.": "Ainda não há mundos. Crie um no jogo e ele aparecerá aqui.",
"No servers saved in this instance.": "Nenhum servidor salvo nesta instância.",
"1 server in {name}": "1 servidor em {name}",
"{count} servers in {name}": "{count} servidores em {name}",
"Click again to remove": "Clique de novo para remover"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher удалён.",
"These could not be removed:": "Не удалось удалить:",
"Launch Horizon": "Запустить Horizon",
"Something went wrong.": "Что-то пошло не так."
"Something went wrong.": "Что-то пошло не так.",
"Instances": "Сборки",
"Join the Discord": "Вступить в Discord",
"Restart to update": "Перезапустить для обновления",
"Duplicate…": "Дублировать…",
"Create a desktop shortcut": "Создать ярлык на рабочем столе",
"Remove the desktop shortcut": "Удалить ярлык с рабочего стола",
"Click again to delete": "Нажмите ещё раз, чтобы удалить",
"Update available": "Доступно обновление",
"1 screenshot": "1 скриншот",
"{count} screenshots": "Скриншотов: {count}",
"Choose the Minecraft folder": "Выбрать папку Minecraft",
"Copy the launch command": "Скопировать команду запуска",
"Install, remove and pick versions": "Установка, удаление и выбор версий",
"Newest · follows updates": "Новейшая · следует обновлениям",
"Not found": "Не найдена",
"Open the instance folder": "Открыть папку сборки",
"Open the screenshots folder": "Открыть папку скриншотов",
"Play without updating": "Играть без обновления",
"Playing for {time} now. Have fun!": "Вы играете уже {time}. Приятной игры!",
"Preparing": "Подготовка",
"Read {version}'s files from your own copy": "Прочитать файлы {version} из вашей копии",
"Starts this instance in one click": "Запускает эту сборку в один клик",
"Stay on {version}": "Остаться на {version}",
"You haven't played this one yet. Press Play to start.": "Вы ещё не играли в эту сборку. Нажмите «Играть», чтобы начать.",
"You've played for {time}! Last played {when}.": "Вы наиграли {time}! Последняя игра: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Все заметки",
"No notes yet": "Заметок пока нет",
"No screenshots yet. Press F2 in the game.": "Скриншотов пока нет. Нажмите F2 в игре.",
"Patch log": "Журнал изменений",
"See all": "Смотреть все",
"No worlds yet. Make one in the game and it shows up here.": "Миров пока нет. Создайте мир в игре, и он появится здесь.",
"No servers saved in this instance.": "В этой сборке нет сохранённых серверов.",
"1 server in {name}": "1 сервер в {name}",
"{count} servers in {name}": "Серверов в {name}: {count}",
"Click again to remove": "Нажмите ещё раз, чтобы удалить"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher was removed.",
"These could not be removed:": "These could not be removed:",
"Launch Horizon": "Launch Horizon",
"Something went wrong.": "Something went wrong."
"Something went wrong.": "Something went wrong.",
"Instances": "Örnekler",
"Join the Discord": "Discord'a katıl",
"Restart to update": "Güncellemek için yeniden başlat",
"Duplicate…": "Çoğalt…",
"Create a desktop shortcut": "Masaüstü kısayolu oluştur",
"Remove the desktop shortcut": "Masaüstü kısayolunu kaldır",
"Click again to delete": "Silmek için tekrar tıkla",
"Update available": "Güncelleme var",
"1 screenshot": "1 ekran görüntüsü",
"{count} screenshots": "{count} ekran görüntüsü",
"Choose the Minecraft folder": "Minecraft klasörünü seç",
"Copy the launch command": "Başlatma komutunu kopyala",
"Install, remove and pick versions": "Sürüm yükle, kaldır ve seç",
"Newest · follows updates": "En yeni · güncellemeleri izler",
"Not found": "Bulunamadı",
"Open the instance folder": "Örnek klasörünü aç",
"Open the screenshots folder": "Ekran görüntüleri klasörünü aç",
"Play without updating": "Güncellemeden oyna",
"Playing for {time} now. Have fun!": "{time} süredir oynuyorsun. İyi eğlenceler!",
"Preparing": "Hazırlanıyor",
"Read {version}'s files from your own copy": "{version} dosyalarını kendi kopyandan oku",
"Starts this instance in one click": "Bu örneği tek tıkla başlatır",
"Stay on {version}": "{version} sürümünde kal",
"You haven't played this one yet. Press Play to start.": "Bunu henüz oynamadın. Başlamak için Oyna'ya bas.",
"You've played for {time}! Last played {when}.": "{time} oynadın! Son oynama: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Tüm notlar",
"No notes yet": "Henüz not yok",
"No screenshots yet. Press F2 in the game.": "Henüz ekran görüntüsü yok. Oyunda F2'ye bas.",
"Patch log": "Değişiklik günlüğü",
"See all": "Tümünü gör",
"No worlds yet. Make one in the game and it shows up here.": "Henüz dünya yok. Oyunda bir tane oluşturun, burada görünür.",
"No servers saved in this instance.": "Bu kurulumda kayıtlı sunucu yok.",
"1 server in {name}": "{name} içinde 1 sunucu",
"{count} servers in {name}": "{name} içinde {count} sunucu",
"Click again to remove": "Kaldırmak için tekrar tıklayın"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher видалено.",
"These could not be removed:": "Не вдалося видалити:",
"Launch Horizon": "Запустити Horizon",
"Something went wrong.": "Щось пішло не так."
"Something went wrong.": "Щось пішло не так.",
"Instances": "Екземпляри",
"Join the Discord": "Приєднатися до Discord",
"Restart to update": "Перезапустити для оновлення",
"Duplicate…": "Дублювати…",
"Create a desktop shortcut": "Створити ярлик на робочому столі",
"Remove the desktop shortcut": "Прибрати ярлик з робочого столу",
"Click again to delete": "Натисніть ще раз, щоб видалити",
"Update available": "Доступне оновлення",
"1 screenshot": "1 знімок екрана",
"{count} screenshots": "Знімків екрана: {count}",
"Choose the Minecraft folder": "Вибрати теку Minecraft",
"Copy the launch command": "Скопіювати команду запуску",
"Install, remove and pick versions": "Встановлення, вилучення й вибір версій",
"Newest · follows updates": "Найновіша · стежить за оновленнями",
"Not found": "Не знайдено",
"Open the instance folder": "Відкрити теку екземпляра",
"Open the screenshots folder": "Відкрити теку знімків екрана",
"Play without updating": "Грати без оновлення",
"Playing for {time} now. Have fun!": "Граєте вже {time}. Гарної гри!",
"Preparing": "Підготовка",
"Read {version}'s files from your own copy": "Прочитати файли {version} з вашої копії",
"Starts this instance in one click": "Запускає цей екземпляр одним клацанням",
"Stay on {version}": "Залишитися на {version}",
"You haven't played this one yet. Press Play to start.": "Ви ще не грали в цей екземпляр. Натисніть «Грати», щоб почати.",
"You've played for {time}! Last played {when}.": "Ви зіграли {time}! Востаннє: {when}.",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "Усі нотатки",
"No notes yet": "Нотаток поки немає",
"No screenshots yet. Press F2 in the game.": "Знімків екрана поки немає. Натисніть F2 у грі.",
"Patch log": "Журнал змін",
"See all": "Переглянути всі",
"No worlds yet. Make one in the game and it shows up here.": "Світів поки немає. Створіть світ у грі, і він з'явиться тут.",
"No servers saved in this instance.": "У цій збірці немає збережених серверів.",
"1 server in {name}": "1 сервер у {name}",
"{count} servers in {name}": "Серверів у {name}: {count}",
"Click again to remove": "Натисніть ще раз, щоб вилучити"
}
+38 -1
View File
@@ -837,5 +837,42 @@
"Horizon Launcher was removed.": "Horizon Launcher 已移除。",
"These could not be removed:": "以下内容无法删除:",
"Launch Horizon": "启动 Horizon",
"Something went wrong.": "出了点问题。"
"Something went wrong.": "出了点问题。",
"Instances": "实例",
"Join the Discord": "加入 Discord",
"Restart to update": "重启以更新",
"Duplicate…": "复制…",
"Create a desktop shortcut": "创建桌面快捷方式",
"Remove the desktop shortcut": "移除桌面快捷方式",
"Click again to delete": "再次点击以删除",
"Update available": "有可用更新",
"1 screenshot": "1 张截图",
"{count} screenshots": "{count} 张截图",
"Choose the Minecraft folder": "选择 Minecraft 文件夹",
"Copy the launch command": "复制启动命令",
"Install, remove and pick versions": "安装、移除和选择版本",
"Newest · follows updates": "最新 · 跟随更新",
"Not found": "未找到",
"Open the instance folder": "打开实例文件夹",
"Open the screenshots folder": "打开截图文件夹",
"Play without updating": "不更新直接游玩",
"Playing for {time} now. Have fun!": "已游玩 {time},玩得开心!",
"Preparing": "准备中",
"Read {version}'s files from your own copy": "从你自己的副本读取 {version} 的文件",
"Starts this instance in one click": "一键启动此实例",
"Stay on {version}": "保留 {version}",
"You haven't played this one yet. Press Play to start.": "你还没玩过这个。按“开始游戏”开始。",
"You've played for {time}! Last played {when}.": "你已游玩 {time}!上次游玩:{when}。",
"Rust Edition": "Rust Edition",
"Java Edition": "Java Edition",
"All notes": "全部说明",
"No notes yet": "暂无说明",
"No screenshots yet. Press F2 in the game.": "还没有截图。在游戏中按 F2。",
"Patch log": "更新日志",
"See all": "查看全部",
"No worlds yet. Make one in the game and it shows up here.": "还没有世界。在游戏中创建一个,它就会显示在这里。",
"No servers saved in this instance.": "此实例中没有保存的服务器。",
"1 server in {name}": "{name} 中有 1 个服务器",
"{count} servers in {name}": "{name} 中有 {count} 个服务器",
"Click again to remove": "再次点击以移除"
}
+933
View File
@@ -0,0 +1,933 @@
//! The glass look's frame (the mockup's `launcher-app.tsx`): the sidebar plate with the
//! instances, the launcher's update, Sculk's card, Discord and the links; the top bar with the
//! pages in a glass tab strip, the player, Microsoft sign-in and Settings; and the menus they open.
//! Dark appearance only for now (`docs/design-system.md`, "Glass").
use super::*;
use crate::glass_ui::{self as ui, white, Tone};
use crate::instances_page::java_line;
use gpui::{anchored, canvas, deferred, point};
use horizon_launcher::account::Player;
use horizon_launcher::instances::{Icon, Instance, Kind as InstanceKind};
use horizon_theme::glass::{fill, ink, radius};
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
/// The sidebar's width and its distance from the window's edges (`w-[248px]`, `m-3.5`).
pub const SIDEBAR: f32 = 248.;
pub const MARGIN: f32 = 14.;
/// The ⋮ menu of a sidebar row while open: whose, and whether Delete… was pressed once.
#[derive(Clone, Default)]
pub struct RowMenu {
pub open: Option<(String, bool)>,
/// Where each row was drawn last, by instance id: the menu opens beside its row.
pub rows: Rc<RefCell<HashMap<String, Bounds<Pixels>>>>,
}
/// Records where the element it sits in is drawn, under `id`.
fn measure(rows: &Rc<RefCell<HashMap<String, Bounds<Pixels>>>>, id: String) -> impl IntoElement {
let rows = rows.clone();
canvas(
move |bounds, _, _| {
rows.borrow_mut().insert(id, bounds);
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
impl Launcher {
/// An instance's icon (`InstanceIcon`), `size` square with no frame: Horizon's mark tile for
/// Horizon's own instance; else its picture or the block chosen for it, round at 26 %; else
/// its loader's or Minecraft's mark; else its initial on its colour (the accent when none was
/// chosen), as `ColorTile` draws it.
pub(super) fn glass_instance_icon(&mut self, i: &Instance, size: f32) -> AnyElement {
if i.kind == InstanceKind::Default && i.icon.is_none() {
return ui::mark_tile(size, self.look.accent_soft).into_any_element();
}
if let Some(picture) = self.instance_picture(i, size) {
return instance_icons::centred(picture, size, self.scale).into_any_element();
}
let r = px((size * 0.26).round());
let mut colour = self.look.accent;
match &i.icon {
Some(Icon::Colour(hex)) => {
colour = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(colour);
}
Some(Icon::Texture(name)) => {
if let Some(image) = self.texture_image(name, size) {
return gpui::img(image)
.flex_none()
.size(px(size))
.rounded(r)
.into_any_element();
}
}
None => {
if let Some(mark) = self.instance_mark(i, size) {
return instance_icons::centred(mark, size, self.scale).into_any_element();
}
}
}
// Bricolage at the optical size a browser would pick for the letter.
let letter = (size * 0.46).round();
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(size))
.rounded(r)
.bg(ui::color(colour))
.font_family(if letter >= 40. {
horizon_theme::glass::DISPLAY_FONT
} else {
horizon_theme::glass::HEADING_FONT
})
.font_weight(FontWeight::SEMIBOLD)
.text_size(px(letter))
.line_height(px(letter))
.text_color(gpui::white())
.child(instances_page::initial(&i.name))
.into_any_element()
}
/// A sidebar row's state line and its dot (the mockup's `statusOf`), from what the launcher
/// knows of the instance.
fn row_state(&self, i: &Instance) -> (Tone, String) {
let running =
self.client.is_some() && self.selected_instance().is_some_and(|s| s.id == i.id);
if running {
return (Tone::Live, tr("Running").into());
}
let starting = self.starting_client.is_some()
&& self.selected_instance().is_some_and(|s| s.id == i.id);
if starting {
return (Tone::Busy, tr("Starting").into());
}
match i.kind {
InstanceKind::Default | InstanceKind::Own => {
let tag = self.version_of(i).cloned();
let prepared = tag.as_deref().is_some_and(backend::is_prepared);
match (&i.java, &tag, &i.version) {
(Some(java), _, _) => (Tone::Live, java_line(java)),
(None, None, Some(own)) => (
Tone::Warn,
trf("{version} is not installed", &[("version", own)]),
),
(None, None, None) => (Tone::Off, tr("No version installed").into()),
// Following the launcher's version, which has a newer one waiting.
(None, Some(_), None) if self.updates.horizon.is_some() => (
Tone::Accent(self.look.accent_soft),
tr("Update available").into(),
),
(None, Some(_), _) if !prepared => (Tone::Warn, tr("Needs preparing").into()),
(None, Some(tag), _) if self.native_build_of(tag).is_some() => {
let build = self.native_build_of(tag).unwrap_or_default();
(
Tone::Live,
trf(
"Native build · {version}",
&[("version", &build.horizon_short())],
),
)
}
(None, Some(tag), Some(_)) => (Tone::Live, format!("{} · {tag}", tr("Ready"))),
(None, Some(_), None) => (Tone::Live, tr("Ready").into()),
}
}
InstanceKind::Port => {
let slug = &i.id["port:".len()..];
let summary = self.packs.iter().find(|p| p.slug == slug);
(
Tone::Live,
summary.map_or_else(|| tr("Modpack").into(), |p| p.summary()),
)
}
InstanceKind::Pack => (Tone::Off, tr("From Horizon's registry").into()),
}
}
/// The instances by group (`sections` in the mockup): those without a group first.
fn glass_instances(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
let t = self.theme;
let selected = self.selected_instance().map(|i| i.id.clone());
let on_packs = self.page == Page::Mods && self.mods.on_modpacks();
let mut out = Vec::new();
let mut group = None;
let mut list = div().flex().flex_col().gap(px(4.));
let flush = |list: Div, out: &mut Vec<AnyElement>| {
out.push(list.into_any_element());
div().flex().flex_col().gap(px(4.))
};
for i in self.instances.list.clone() {
if i.group != group {
list = flush(list, &mut out);
group = i.group.clone();
if let Some(g) = &group {
out.push(
ui::text(12., FontWeight::MEDIUM, ink::DIM)
.px(px(8.))
.pt(px(16.))
.pb(px(6.))
.child(g.clone())
.into_any_element(),
);
}
}
// As in the mockup, the chosen instance stays marked on every page, unless a ported
// pack's page is open: that row is the one shown then.
let current = match i.kind {
InstanceKind::Default | InstanceKind::Own => {
!(on_packs && self.pack_open.is_some()) && selected.as_ref() == Some(&i.id)
}
InstanceKind::Port => {
on_packs && self.pack_open.as_deref() == Some(&i.id["port:".len()..])
}
InstanceKind::Pack => false,
};
let state = self.row_state(&i);
let icon = self.glass_instance_icon(&i, 38.);
let clicked = i.clone();
let open = self
.row_menu
.open
.as_ref()
.is_some_and(|(id, _)| *id == i.id);
let menu_id = i.id.clone();
let more = div()
.absolute()
.top_0()
.bottom_0()
.right(px(6.))
.flex()
.items_center()
.child(
ui::icon_button(
format!("side-more-{}", i.id),
"more-vertical",
32.,
18.,
radius::SMALL,
open,
&t,
)
.on_click(cx.listener(
move |this, event: &ClickEvent, _, cx| {
if this.closed_by_this_press(event) {
return;
}
let was = this.row_menu.open.take();
if was.is_none_or(|(id, _)| id != menu_id) {
this.row_menu.open = Some((menu_id.clone(), false));
}
cx.notify();
},
)),
);
list = list.child(
div()
.relative()
.child(measure(&self.row_menu.rows, i.id.clone()))
.child(
ui::row(format!("side-instance-{}", i.id), current, 40., &t)
.child(div().relative().flex_none().child(icon))
.child(ui::row_text(i.name.clone(), state))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
match clicked.kind {
InstanceKind::Default | InstanceKind::Own => {
this.select_instance(&clicked);
this.go(Page::Play);
}
InstanceKind::Port => {
this.show_modpack(Some(clicked.id["port:".len()..].into()))
}
InstanceKind::Pack => this.show_modpack(None),
}
cx.notify();
})),
)
.child(more),
);
}
out.push(list.into_any_element());
out
}
/// The glass sidebar: a plate 248 wide, 14 from the window's edges.
pub(super) fn glass_sidebar(&mut self, window: &Window, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let soft = self.look.accent_soft;
self.load_packs();
let add = ui::icon_button(
"add-instance",
"plus-sign",
36.,
16.,
radius::CONTROL,
false,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.open_instance_dialog(None);
cx.notify();
}));
let header = div()
.flex()
.flex_none()
.items_center()
.justify_between()
.px(px(16.))
.pt(px(16.))
.pb(px(8.))
.child(ui::text(13., FontWeight::MEDIUM, ink::NOTE).child(tr("Instances")))
.child(add);
let mut list = div()
.id("side-list")
.flex()
.flex_col()
.flex_1()
.min_h_0()
.overflow_y_scroll()
.px(px(10.))
.children(self.glass_instances(cx));
// The player's hosted servers: not in the mockup (its Servers page lists them), kept as
// one more section in its pattern.
let servers: Vec<_> = self
.servers
.sidebar_list()
.map(<[_]>::to_vec)
.unwrap_or_else(|| self.side_servers.clone());
if !servers.is_empty() {
list = list.child(
ui::text(12., FontWeight::MEDIUM, ink::DIM)
.px(px(8.))
.pt(px(16.))
.pb(px(6.))
.child(tr("Servers")),
);
}
let mut server_rows = div().flex().flex_col().gap(px(4.));
for e in &servers {
let up = self.servers.is_up(&e.dir);
let open = self.page == Page::Servers && self.servers.open.as_ref() == Some(&e.dir);
let dir = e.dir.clone();
server_rows = server_rows.child(
ui::row(format!("side-server-{}", e.dir.display()), open, 8., &t)
.child(
div()
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(38.))
.rounded(px(radius::CONTROL))
.bg(white(fill::BUTTON))
.child(ui::icon("computer-terminal-01", 18., white(ink::STRONG))),
)
.child(ui::row_text(
e.name.clone(),
if up {
(Tone::Live, tr("Running").into())
} else {
(Tone::Off, tr("Stopped").into())
},
))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.open_server(dir.clone());
this.go(Page::Servers);
cx.notify();
})),
);
}
let list = list.child(server_rows);
let mut foot = div()
.flex()
.flex_col()
.flex_none()
.px(px(10.))
.pt(px(16.))
.pb(px(12.));
// The launcher's own update (the mockup's card), while one downloads or waits.
let card = |title: String, right: Option<String>| {
div()
.flex()
.flex_col()
.rounded(px(radius::ROW))
.bg(white(fill::FIELD))
.p(px(14.))
.child(
div()
.flex()
.items_baseline()
.justify_between()
.child(ui::text(14., FontWeight::SEMIBOLD, ink::FULL).child(title))
.children(
right.map(|r| ui::text(13., FontWeight::NORMAL, ink::NOTE).child(r)),
),
)
};
match &self.self_update {
SelfUpdate::Downloading(version, f) => {
foot = foot.child(
card(
trf("Launcher {version}", &[("version", version)]),
Some(format!("{:.0}%", f * 100.)),
)
.child(div().mt(px(10.)).child(ui::bar(*f, soft))),
);
}
SelfUpdate::Ready(version) => {
foot = foot.child(
card(trf("Launcher {version}", &[("version", version)]), None).child(
ui::solid("restart", tr("Restart to update"), 32., 13., &t)
.mt(px(12.))
.w_full()
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.self_update = SelfUpdate::Failed(backend::restart());
cx.notify();
})),
),
);
}
_ => {}
}
// Sculk's card (not in the mockup, kept: a sponsor), where the old sidebar had it: above
// the links on every page but Servers, which shows the wide banner. Tall where the
// sidebar has the height for it besides its rows, compact otherwise.
if self.page != Page::Servers && !self.settings.partner_hidden {
let rows = self.instances.list.len() + servers.len();
let free = f32::from(window.viewport_size().height)
- 2. * MARGIN
- 52.
- 56. * rows as f32
- 190.;
let mut side = div().relative().mt(px(12.)).child(partner::card(
"partner-side",
SIDEBAR - 20.,
free < partner::TALL_HEIGHT,
&t,
));
if self.settings.partner_seen_secs >= partner::SEEN_BEFORE_HIDE {
side = side.child(
partner::hide_button("partner-hide", &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.settings.partner_hidden = true;
this.save_settings();
cx.notify();
},
)),
);
}
foot = foot.child(side);
}
if !self.links.discord.is_empty() {
let url = self.links.discord.clone();
foot = foot.child(keyboard(
div()
.id("discord")
.flex()
.items_center()
.justify_center()
.gap(px(10.))
.mt(px(12.))
.h(px(40.))
.rounded(px(radius::ROW))
.bg(ui::color(horizon_theme::glass::DISCORD))
.hover(|s| s.bg(ui::color(horizon_theme::glass::DISCORD_HOVER)))
.cursor_pointer()
.child(ui::icon("discord", 18., gpui::white()))
.child(
ui::text(14., FontWeight::SEMIBOLD, ink::FULL)
.child(tr("Join the Discord")),
)
.on_click(move |_, _, cx| cx.open_url(&url)),
&t,
));
}
let link = |id: &'static str, label: &'static str, icon: &'static str, url: String| {
let id = SharedString::from(id);
keyboard(
track_hover(
div()
.id(id.clone())
.relative()
.flex()
.items_center()
.gap(px(10.))
.h(px(36.))
.px(px(8.))
.rounded(px(radius::ROW))
.cursor_pointer()
.text_color(white(ink::SOFT))
.hover(|s| s.text_color(white(ink::FULL)))
.child(hover_layer(&id, white(fill::HOVER), radius::ROW, 0.))
.child(ui::icon(icon, 17., white(ink::SOFT)).relative())
.child(
div()
.relative()
.font_family(horizon_theme::glass::FONT)
.text_size(px(14.))
.child(label),
)
.on_click(move |_, _, cx| cx.open_url(&url)),
id,
),
&t,
)
};
let mut links = div().flex().flex_col().mt(px(8.));
if !self.links.sponsor.is_empty() {
links = links.child(link(
"sponsor",
tr("Sponsor"),
"favourite",
self.links.sponsor.clone(),
));
}
if !self.links.website.is_empty() {
links = links.child(link(
"website",
tr("Website"),
"globe-02",
self.links.website.clone(),
));
}
foot = foot.child(links).child(
ui::text(12., FontWeight::NORMAL, ink::DIM)
.id("launcher-version")
.px(px(8.))
.pt(px(8.))
.child(trf("Launcher {version}", &[("version", backend::VERSION)]))
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.version_clicked();
cx.notify();
})),
);
crate::glass::plate(radius::PANEL)
.flex()
.flex_col()
.flex_none()
.w(px(SIDEBAR))
.my(px(MARGIN))
.ml(px(MARGIN))
.overflow_hidden()
.child(header.relative())
.child(list.relative())
.child(foot.relative())
}
/// The glass top bar: the pages in a glass strip, then the player, Microsoft sign-in and
/// Settings (`px-8 pt-3.5`).
pub(super) fn glass_topbar(&mut self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let mut tabs = div().flex().items_center().gap(px(4.)).min_w_0();
for (page, label, _) in Page::ALL {
if page == Page::Settings {
continue;
}
tabs = tabs.child(
ui::tab(format!("nav-{label}"), tr(label), self.page == page, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.go(page);
cx.notify();
}),
),
);
}
let strip = crate::glass::plate(radius::LARGE)
.p(px(4.))
.min_w_0()
.flex_none()
.child(tabs.relative());
let (name, how) = self.who_plays();
let face = match self.accounts.player() {
Player::Microsoft(_) => self.accounts.selected().map(|a| a.uuid.clone()),
_ => None,
};
let open = self.account_menu;
let chip =
div()
.id("account")
.relative()
.flex()
.items_center()
.gap(px(10.))
.h(px(44.))
.pl(px(8.))
.pr(px(12.))
.rounded(px(radius::ROW))
.bg(white(if open { fill::ACTIVE } else { fill::ROW_HOVER }))
.when(!open, |d| d.hover(|s| s.bg(white(0.09))))
.cursor_pointer()
.child(
div().size(px(26.)).rounded(px(7.)).overflow_hidden().child(
self.profile_heads
.draw(face.as_deref(), 26., self.scale, &t),
),
)
.child(
div()
.flex()
.flex_col()
.max_w(px(150.))
.child(
ui::text(14., FontWeight::SEMIBOLD, ink::FULL)
.line_height(px(17.))
.truncate()
.child(name),
)
.child(
ui::text(12., FontWeight::NORMAL, ink::QUIET)
.line_height(px(15.))
.child(how),
),
)
.child(
ui::icon("arrow-down-01", 16., white(ink::FAINT))
.ml(px(4.))
.with_transformation(gpui::Transformation::rotate(gpui::radians(
if open { std::f32::consts::PI } else { 0. },
))),
)
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
if this.closed_by_this_press(event) {
return;
}
this.account_menu = !this.account_menu;
if this.account_menu {
this.accounts = Accounts::load();
this.load_profile_heads();
}
cx.notify();
}));
let account = div()
.relative()
.child(keyboard(chip, &t))
.children(self.glass_account_menu(cx));
// Signing in with Microsoft, while nobody is: the mockup's button.
let sign_in = (!matches!(self.accounts.player(), Player::Microsoft(_))).then(|| {
keyboard(
div()
.id("topbar-sign-in")
.relative()
.flex()
.items_center()
.gap(px(10.))
.h(px(44.))
.px(px(16.))
.rounded(px(radius::ROW))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(fill::BUTTON_HOVER)))
.cursor_pointer()
.child(microsoft::logo(16.))
.child(
ui::text(14., FontWeight::MEDIUM, ink::FULL)
.child(tr("Sign in with Microsoft")),
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.start_sign_in();
cx.notify();
})),
&t,
)
});
let settings = ui::icon_button(
"nav-Settings",
"sliders-horizontal",
40.,
19.,
radius::CONTROL,
self.page == Page::Settings,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.go(if this.page == Page::Settings {
Page::Play
} else {
Page::Settings
});
cx.notify();
}));
// The last native build's log (not in the mockup, kept), as a quiet button.
let build_log = self.native_log.available.then(|| {
keyboard(
div()
.id("topbar-native-log")
.flex()
.items_center()
.h(px(36.))
.px(px(14.))
.rounded(px(radius::CONTROL))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(fill::BUTTON_HOVER)))
.cursor_pointer()
.child(ui::text(14., FontWeight::MEDIUM, ink::FULL).child(tr("Build log")))
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.native_log_open();
cx.notify();
})),
&t,
)
});
div()
.flex()
.flex_none()
.items_center()
.gap(px(12.))
.px(px(32.))
.pt(px(14.))
.child(strip)
.child(
div()
.ml_auto()
.flex()
.flex_none()
.items_center()
.gap(px(6.))
.children(build_log)
.child(account)
.children(sign_in)
.child(settings),
)
}
/// The menu under the player (`Switch player`): everyone who can play, then Manage accounts.
fn glass_account_menu(&self, cx: &mut Context<Self>) -> Option<impl IntoElement> {
if !self.account_menu {
return None;
}
let t = self.theme;
let soft = self.look.accent_soft;
let now = self.accounts.player();
let mut menu = ui::popover(250.)
.id("account-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.account_menu = false;
this.menu_closed_at = Some(event.position);
cx.notify();
}));
for (i, (who, name, how)) in self.players().into_iter().enumerate() {
let chosen = who == now;
// The mockup's rows say only "Offline"; the old menu's longer line stays there.
let how = if who == Player::GameName {
tr("Offline")
} else {
how
};
let id = SharedString::from(format!("account-pick-{i}"));
menu = menu.child(keyboard(
track_hover(
div()
.id(id.clone())
.relative()
.flex()
.items_center()
.gap(px(12.))
.h(px(48.))
.px(px(10.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.child(hover_layer(&id, white(0.07), radius::CONTROL, 0.))
.child(
div()
.relative()
.size(px(30.))
.rounded(px(8.))
.overflow_hidden()
.child(self.profile_heads.draw(
match &who {
Player::Microsoft(uuid) => Some(uuid),
_ => None,
},
30.,
self.scale,
&t,
)),
)
.child(
div()
.relative()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
ui::text(14., FontWeight::MEDIUM, ink::FULL)
.truncate()
.child(name),
)
.child(ui::text(12., FontWeight::NORMAL, ink::FAINT).child(how)),
)
.when(chosen, |d| {
d.child(ui::icon("tick-02", 16., ui::color(soft)).relative())
})
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.play_as(who.clone());
cx.notify();
})),
id,
),
&t,
));
}
menu = menu.child(div().mt(px(4.)).child(
ui::menu_row("account-manage", "user", tr("Manage accounts"), false, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.account_menu = false;
this.go(Page::Settings);
cx.notify();
}),
),
));
Some(deferred(
div().absolute().top(px(52.)).right_0().child(menu),
))
}
/// The ⋮ menu of a sidebar row, beside it: Edit…, Duplicate…, Open folder, Export…, the
/// desktop shortcut, then Delete… (asked twice). Each acts on that instance.
pub(super) fn row_menu(
&mut self,
window: &Window,
cx: &mut Context<Self>,
) -> Option<AnyElement> {
let (id, confirm) = self.row_menu.open.clone()?;
let Some(instance) = self.instances.list.iter().find(|i| i.id == id).cloned() else {
self.row_menu.open = None;
return None;
};
let row = self.row_menu.rows.borrow().get(&id).copied()?;
let t = self.theme;
let height = f32::from(window.viewport_size().height);
// The mockup's `rect.right + 12`, `min(rect.top - 8, innerHeight - 330)`: its ⋮ button
// ends 6 px inside the row and sits in the row's middle.
let x = f32::from(row.origin.x + row.size.width) - 6. + 12.;
let button_top = f32::from(row.origin.y) + (f32::from(row.size.height) - 32.) / 2.;
let y = (button_top - 8.).min(height - 330.).max(8.);
let item = |key: &'static str, icon: &'static str, label: String| {
ui::menu_row(format!("row-menu-{key}"), icon, label, false, &t)
};
let pick = |this: &mut Launcher, i: &Instance| {
this.row_menu.open = None;
this.select_instance(i);
this.go(Page::Play);
};
let (edit, copy, export, shortcut) = (
instance.clone(),
instance.clone(),
instance.clone(),
instance.clone(),
);
let home = instance.home.clone();
let shortcut_label = if self.has_shortcut(&instance.id) {
tr("Remove the desktop shortcut")
} else {
tr("Create a desktop shortcut")
};
let mut menu = ui::popover(250.)
.id("row-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.row_menu.open = None;
this.menu_closed_at = Some(event.position);
cx.notify();
}))
.child(
item("edit", "pencil-edit-02", tr("Edit…").into()).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.row_menu.open = None;
this.open_instance_dialog(Some(edit.clone()));
cx.notify();
},
)),
)
.child(
item("duplicate", "copy-01", tr("Duplicate…").into()).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
pick(this, &copy);
this.start_copy();
cx.notify();
},
)),
)
.child(
item("folder", "folder-01", tr("Open folder").into()).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.row_menu.open = None;
let _ = std::fs::create_dir_all(&home);
cx.open_with_system(&home);
cx.notify();
},
)),
)
.child(
item("export", "upload-04", tr("Export…").into()).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
pick(this, &export);
if this.export.is_none() {
this.toggle_export();
}
cx.notify();
},
)),
)
.child(
item("shortcut", "link-01", shortcut_label.into()).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.row_menu.open = None;
this.toggle_shortcut(shortcut.id.clone(), shortcut.name.clone());
cx.notify();
},
)),
);
// Horizon's own instance can't be deleted, nor one while a game runs.
let can_delete = instance.kind != InstanceKind::Default
&& self.client.is_none()
&& self.starting_client.is_none();
let gone = instance.id.clone();
let delete = ui::menu_row(
"row-menu-delete",
"delete-02",
if confirm {
tr("Click again to delete")
} else {
tr("Delete…")
},
true,
&t,
);
let delete = if can_delete {
delete.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if let Some((_, confirm)) = this.row_menu.open.as_mut().filter(|(_, c)| !*c) {
*confirm = true;
} else {
this.row_menu.open = None;
this.delete_instance(&gone);
}
cx.notify();
}))
} else {
delete.opacity(0.4).cursor_default()
};
menu = menu.child(div().mt(px(4.)).child(delete));
Some(
deferred(anchored().position(point(px(x), px(y))).child(menu))
.with_priority(1)
.into_any_element(),
)
}
}
+281
View File
@@ -0,0 +1,281 @@
//! The glass look's plates (`.hzl-glass` and `.hzl-float` in the mockup): the scene behind,
//! blurred and saturated, under a little white and the accent's dark violet, with round corners.
//!
//! GPUI has no backdrop filter, but nothing sits under a plate except the scene (`scene.rs`), so
//! each plate paints the matching piece of the scene's blurred copy ([`scene::Backdrop`]) with its
//! own corners, its tint mixed in. The pieces are small images (a texel per 4 logical pixels: a
//! 24 px blur has nothing finer), kept while the pages that drew them may still be on screen.
use crate::scene::{Backdrop, Model};
use gpui::{canvas, div, prelude::*, px, Bounds, Corners, Div, Pixels, RenderImage, Size, Window};
use horizon_theme::glass::Glass;
use std::cell::Cell;
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
/// Logical pixels per texel of a plate's piece of backdrop.
const PIECE: f32 = 4.;
/// A plate's kind: `.hzl-glass` (panels, the sidebar, dialogs) or `.hzl-float` (menus and
/// popovers: twice the blur, darker).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Kind {
Glass,
Float,
}
/// A piece's place and size, its kind, the backdrop's generation, and its shape: 0 for a plate,
/// else the device scale (×100) of a [`fillet`](tabbed)'s piece.
type Key = (i32, i32, i32, i32, Kind, u32, u32);
struct Pieces {
backdrop: Option<Backdrop>,
look: Glass,
/// Bumped when the backdrop or the look changes: older pieces are thrown away.
generation: u32,
/// Bumped each time the launcher's pages are built: a piece last painted two builds ago is
/// on no frame any more (GPUI Fast copies a view's last frame, never an older one).
build: u64,
pieces: HashMap<Key, (Arc<RenderImage>, u64)>,
retired: Vec<Arc<RenderImage>>,
}
thread_local! {
static PIECES: RefCell<Pieces> = RefCell::new(Pieces {
backdrop: None,
look: Glass::default_look(),
generation: 0,
build: 0,
pieces: HashMap::new(),
retired: Vec::new(),
});
}
/// The scene was built again (a new window size): plates take their pieces from this one.
pub fn set_backdrop(model: &Model) {
PIECES.with_borrow_mut(|p| {
p.backdrop = Some(Backdrop::new(model, p.look.blur as f64));
p.generation += 1;
});
}
/// The launcher's pages are being built: pieces nothing drew in the last build are freed (call
/// at the start of the launcher's render).
pub fn begin_build(window: &mut Window) {
let retired = PIECES.with_borrow_mut(|p| {
p.build += 1;
let (build, generation) = (p.build, p.generation);
let stale: Vec<Key> = p
.pieces
.iter()
.filter(|(k, (_, used))| k.5 != generation || *used + 1 < build)
.map(|(k, _)| *k)
.collect();
for k in stale {
if let Some((image, _)) = p.pieces.remove(&k) {
p.retired.push(image);
}
}
std::mem::take(&mut p.retired)
});
for image in retired {
let _ = window.drop_image(image);
}
}
/// A plate's backdrop with its corners: put it first inside a `relative` element, the content
/// after it.
pub fn backdrop(kind: Kind, radius: f32) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, (), window, _| paint(bounds, kind, Corners::all(px(radius)), window),
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// A `relative` div on a glass plate with `radius` corners.
pub fn plate(radius: f32) -> Div {
div()
.relative()
.rounded(px(radius))
.child(backdrop(Kind::Glass, radius))
}
/// The same for a menu or popover.
pub fn float(radius: f32) -> Div {
div()
.relative()
.rounded(px(radius))
.child(backdrop(Kind::Float, radius))
}
/// The piece at `bounds` (a plate's, or with `fillet` set to the device scale, a fillet's), made
/// once and kept.
fn image_at(bounds: Bounds<Pixels>, kind: Kind, fillet: f32) -> Option<Arc<RenderImage>> {
PIECES.with_borrow_mut(|p| {
let backdrop = p.backdrop.as_ref()?;
let key = (
f32::from(bounds.origin.x).round() as i32,
f32::from(bounds.origin.y).round() as i32,
f32::from(bounds.size.width).round() as i32,
f32::from(bounds.size.height).round() as i32,
kind,
p.generation,
(fillet * 100.).round() as u32,
);
if key.2 <= 0 || key.3 <= 0 {
return None;
}
let build = p.build;
if let Some((image, used)) = p.pieces.get_mut(&key) {
*used = build;
return Some(image.clone());
}
let image = if fillet > 0. {
fillet_piece(backdrop, &p.look, kind, key, fillet)
} else {
piece(backdrop, &p.look, kind, key)
};
p.pieces.insert(key, (image.clone(), build));
Some(image)
})
}
fn paint(bounds: Bounds<Pixels>, kind: Kind, corners: Corners<Pixels>, window: &mut Window) {
if let Some(image) = image_at(bounds, kind, 0.) {
// The piece's border texels go just outside the plate (see [`piece`]).
let (w, h) = (
f32::from(bounds.size.width).round(),
f32::from(bounds.size.height).round(),
);
let (cols, rows) = texels(w, h);
let (tx, ty) = (w / cols as f32, h / rows as f32);
let outer = Bounds::new(
bounds.origin - gpui::point(px(tx), px(ty)),
bounds.size + gpui::size(px(tx * 2.), px(ty * 2.)),
);
let _ = window.paint_image(bounds, outer, corners, image, 0, false);
}
}
/// The tabbed panel's plate (the mockup's `TabbedPanel`): the tab strip's plate, round at 18 on
/// top, flows into the panel's below it, round at 20 except where the strip sits, with a 16 px
/// inward curve where the strip's right edge meets the panel's top. `strip` is the strip's size,
/// recorded by [`measure`] inside it. Close to the panel's own width, the panel is one plate
/// round at 20, as the mockup does.
pub fn tabbed(strip: Rc<Cell<Option<Size<Pixels>>>>) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, (), window, _| {
let tw = f32::from(bounds.size.width).round();
let th = f32::from(bounds.size.height).round();
let Some(s) = strip.get() else {
return paint(bounds, Kind::Glass, Corners::all(px(20.)), window);
};
let (w, h) = (f32::from(s.width).round(), f32::from(s.height).round());
if tw - w < 36. || th - h < 40. {
return paint(bounds, Kind::Glass, Corners::all(px(20.)), window);
}
let o = bounds.origin;
let at = |x: f32, y: f32, w: f32, h: f32| {
Bounds::new(o + gpui::point(px(x), px(y)), gpui::size(px(w), px(h)))
};
let r = |tl: f32, tr: f32, br: f32, bl: f32| Corners {
top_left: px(tl),
top_right: px(tr),
bottom_right: px(br),
bottom_left: px(bl),
};
paint(at(0., 0., w, h), Kind::Glass, r(18., 18., 0., 0.), window);
paint(
at(0., h, tw, th - h),
Kind::Glass,
r(0., 20., 20., 20.),
window,
);
let fillet = at(w, h - 16., 16., 16.);
if let Some(image) = image_at(fillet, Kind::Glass, window.scale_factor()) {
let _ = window.paint_image(fillet, fillet, Corners::default(), image, 0, false);
}
},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// Records the size of the element it sits in (absolute, filling it) for [`tabbed`].
pub fn measure(into: Rc<Cell<Option<Size<Pixels>>>>) -> impl IntoElement {
canvas(
move |bounds, _, _| into.set(Some(bounds.size)),
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// The inward curve's piece, a texel per device pixel: the backdrop where it lies outside the
/// circle of radius 16 centred on its top-right corner, clear inside, its edge smoothed over one
/// device pixel. Straight alpha, as GPUI Fast blends sprites.
fn fillet_piece(
backdrop: &Backdrop,
look: &Glass,
kind: Kind,
key: Key,
scale: f32,
) -> Arc<RenderImage> {
let (x0, y0, w) = (key.0 as f32, key.1 as f32, key.2 as f32);
let n = (w * scale).round().max(1.) as u32;
let tint = [16, 8, 0].map(|s| ((look.plate >> s) & 255) as f32 / 255.);
let a = look.plate_alpha;
let mut buf = image::RgbaImage::new(n, n);
for (row, line) in buf.rows_mut().enumerate() {
for (col, p) in line.enumerate() {
let (lx, ly) = ((col as f32 + 0.5) / scale, (row as f32 + 0.5) / scale);
let d = ((w - lx).powi(2) + ly.powi(2)).sqrt();
let alpha = ((d - w) * scale + 0.5).clamp(0., 1.);
let c = backdrop.at(kind == Kind::Float, (x0 + lx) as f64, (y0 + ly) as f64);
let mixed = [0, 1, 2].map(|i| ((tint[i] * a + c[i] * (1. - a)) * 255.).round() as u8);
*p = image::Rgba([mixed[2], mixed[1], mixed[0], (alpha * 255.).round() as u8]);
}
}
Arc::new(RenderImage::new([image::Frame::new(buf)]))
}
/// How many texels a piece `w` × `h` logical pixels has across and down.
fn texels(w: f32, h: f32) -> (u32, u32) {
(
(w / PIECE).ceil().max(1.) as u32,
(h / PIECE).ceil().max(1.) as u32,
)
}
/// The plate at `key`'s place: the blurred scene under `look`'s tint, one texel per [`PIECE`]
/// logical pixels, and a border of one texel more all round. GPUI samples its atlas linearly,
/// so a stretched image's outer half-texels would blend in its atlas neighbour; with the border
/// they blend in the scene just outside the plate, as a backdrop blur would.
fn piece(backdrop: &Backdrop, look: &Glass, kind: Kind, key: Key) -> Arc<RenderImage> {
let (x0, y0, w, h) = (key.0 as f32, key.1 as f32, key.2 as f32, key.3 as f32);
let (cols, rows) = texels(w, h);
let tint = [16, 8, 0].map(|s| ((look.plate >> s) & 255) as f32 / 255.);
let a = look.plate_alpha;
let mut buf = image::RgbaImage::new(cols + 2, rows + 2);
for (row, line) in buf.rows_mut().enumerate() {
for (col, p) in line.enumerate() {
let x = x0 + (col as f32 - 0.5) * w / cols as f32;
let y = y0 + (row as f32 - 0.5) * h / rows as f32;
let c = backdrop.at(kind == Kind::Float, x as f64, y as f64);
let mixed = [0, 1, 2].map(|i| ((tint[i] * a + c[i] * (1. - a)) * 255.).round() as u8);
// BGRA, opaque.
*p = image::Rgba([mixed[2], mixed[1], mixed[0], 255]);
}
}
Arc::new(RenderImage::new([image::Frame::new(buf)]))
}
+788
View File
@@ -0,0 +1,788 @@
//! Home's head in the glass look (the mockup's `HomeView`, `launcher-views.tsx`): the instance's
//! edition, its icon and name, how long it was played, then Play with its menu, the version, the
//! instance's mods and its folders, and joining a friend on the right. Dark appearance only.
use super::*;
use crate::glass_ui::{self as ui, white};
use gpui::deferred;
use horizon_theme::glass::{fill, ink, radius};
use std::time::Instant;
/// Play, the version and the big icons are this tall (`h-14`).
const TALL: f32 = 56.;
/// Which of Home's menus is open.
#[derive(Clone, Copy, PartialEq, Default)]
pub(super) enum HomeMenu {
#[default]
None,
Play,
Folder,
}
impl Launcher {
/// The head: what Home shows above its tabs. `tag`: the Horizon version it plays (empty for a
/// Java instance); `prepared`: whether that version's files are ready.
pub(super) fn glass_home_head(
&mut self,
tag: &str,
prepared: bool,
cx: &mut Context<Self>,
) -> Div {
let java = self.selected_instance().and_then(|i| i.java.clone());
// How long this game has run, for "Playing for 5 min now".
match (&self.client, self.client_since) {
(Some(_), None) => self.client_since = Some(Instant::now()),
(None, Some(_)) => self.client_since = None,
_ => {}
}
let left =
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(self.edition_tabs(java.is_some()))
.child(self.home_title())
.child(self.played_text(cx))
.child(
div()
.mt(px(32.))
.flex()
// One line, unlike the mockup's `flex-wrap`: at 1280 px the folder's ⋯
// would wrap the row away from under the pointer, which hides it again.
.items_center()
.gap(px(10.))
.child(self.play_group(tag, prepared || java.is_some(), cx))
.children(java.is_none().then(|| self.glass_picker(cx)))
.child(ui::big_icon("home-mods", "puzzle", &self.theme).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.home_tab = home_tabs::HomeTab::Mods;
cx.notify();
}),
))
.children(self.folder_group(java.is_none(), cx)),
);
div()
.flex()
.items_end()
.justify_between()
.gap(px(32.))
.pt(px(64.))
.child(left)
.child(self.glass_join_friend(tag, cx))
}
/// The edition tabs (`Tabs`, size `sm`): the instance's own lit. An instance's edition is
/// chosen when it is made, so the other one is shown but can't be picked.
fn edition_tabs(&mut self, java: bool) -> Div {
let grass = self.vanilla_icon().and_then(|path| {
self.icon_image("mark:grass".into(), 18., false, move || {
std::fs::read(&path).ok()
})
});
let tab = |label: &'static str, icon: AnyElement, open: bool| {
let d = div()
.flex()
.flex_none()
.items_center()
.gap(px(6.))
.h(px(32.))
.px(px(12.))
.rounded(px(radius::CONTROL))
.font_family(horizon_theme::glass::FONT)
.text_size(px(13.))
.line_height(px(18.))
.child(icon)
.child(tr(label));
if open {
d.bg(white(fill::ACTIVE))
.font_weight(FontWeight::MEDIUM)
.text_color(white(ink::FULL))
} else {
d.text_color(white(ink::OFF))
}
};
let java_icon = match grass {
Some(image) => gpui::img(image)
.flex_none()
.size(px(18.))
.rounded(px(5.))
.into_any_element(),
None => div().into_any_element(),
};
div()
.flex()
.items_center()
.gap(px(4.))
.child(tab(
"Rust Edition",
ui::mark_tile(18., self.look.accent_soft).into_any_element(),
!java,
))
.child(tab("Java Edition", java_icon, java))
}
/// The instance's icon and its name in Bricolage, 64 px at this width
/// (`clamp(2.75rem, 5vw, 4rem)`).
fn home_title(&mut self) -> Div {
let Some(instance) = self.selected_instance().cloned() else {
return div();
};
div()
.mt(px(20.))
.flex()
.items_center()
.gap(px(16.))
.min_w_0()
.child(self.glass_instance_icon(&instance, 56.))
.child(
div()
.min_w_0()
.truncate()
.font_family(horizon_theme::glass::DISPLAY_FONT)
.font_weight(FontWeight::SEMIBOLD)
.text_size(px(64.))
.line_height(px(64.))
.text_color(white(ink::FULL))
.child(instance.name),
)
}
/// "You've played for 41 h 06 min! Last played today.", or how long it has run now; then
/// the players counts the launcher knows (not in the mockup).
fn played_text(&self, cx: &mut Context<Self>) -> Div {
let line = match (self.client_since, self.selected_instance()) {
(Some(since), _) => trf(
"Playing for {time} now. Have fun!",
&[("time", &instances::play_time(since.elapsed()))],
),
(None, Some(i)) => match i.last_played {
Some(when) => trf(
"You've played for {time}! Last played {when}.",
&[
("time", &instances::play_time(i.played)),
("when", &worlds_page::ago(when)),
],
),
None => tr("You haven't played this one yet. Press Play to start.").into(),
},
(None, None) => String::new(),
};
let link = |id: &'static str, label: String| {
div()
.id(id)
.cursor_pointer()
.hover(|s| s.text_color(white(ink::FULL)))
.child(label)
};
let playing = self.playing.map(|count| {
let label = format!(
"· {}",
trf("{count} playing now", &[("count", &thousands(count))])
);
let url = telemetry::stats_url(&self.links.website);
link("playing-now", label).on_click(move |_: &ClickEvent, _, cx| {
if let Some(url) = &url {
cx.open_url(url);
}
})
});
let official = self.official_players.map(|count| {
link(
"official-players",
format!("· {} on the official SMP", thousands(count)),
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.go(Page::Servers);
cx.notify();
}))
});
ui::text(15., FontWeight::NORMAL, ink::STRONG)
.mt(px(16.))
.flex()
.flex_wrap()
.gap(px(4.))
.child(line)
.children(playing)
.children(official)
}
/// Play and its ⋯ menu (`PlayGroup`): Stop while the game runs, Update when the launcher's
/// version has a newer one, the progress while preparing, updating or starting.
fn play_group(&mut self, tag: &str, ready: bool, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let accent = ui::color(self.look.accent);
let running = self.client.is_some();
let follows = self
.selected_instance()
.is_some_and(|i| i.version.is_none());
let update = self
.updates
.horizon
.as_ref()
.filter(|_| follows && !tag.is_empty())
.map(|r| r.tag.clone());
let installing = update
.as_ref()
.and_then(|u| self.installing.as_ref().filter(|(t, _)| t == u))
.map(|(_, p)| *p);
let busy = match (installing, self.preparing, &self.starting_client) {
(Some(p), _, _) => Some((tr("Updating"), Some(p))),
(_, true, _) => Some((
tr("Preparing"),
horizon_launcher::mojang::progress().map(|(d, n)| d as f32 / n.max(1) as f32),
)),
(_, _, Some(_)) => Some((tr("Starting"), None)),
_ => None,
};
let open = self.home_menu == HomeMenu::Play;
let more = div()
.id("play-more")
.absolute()
.right(px(8.))
.flex()
.items_center()
.justify_center()
.size(px(40.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.when(open, |d| d.bg(gpui::black().opacity(0.2)))
.hover(|s| s.bg(gpui::black().opacity(0.2)))
.child(ui::icon("more-horizontal", 20., white(ink::FULL)))
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
if !this.closed_by_this_press(event) {
this.home_menu = match this.home_menu {
HomeMenu::Play => HomeMenu::None,
_ => HomeMenu::Play,
};
}
cx.notify();
}));
let base = div()
.relative()
.flex()
.items_center()
.w(px(200.))
.h(px(TALL))
.overflow_hidden()
.rounded(px(radius::LARGE));
let body = |label: SharedString, icon: &'static str| {
div()
.flex()
.flex_1()
.h_full()
.items_center()
.justify_center()
.gap(px(10.))
.pr(px(40.))
.font_family(horizon_theme::glass::FONT)
.text_size(px(18.))
.font_weight(FontWeight::SEMIBOLD)
.child(ui::icon(icon, 20., white(ink::FULL)))
.child(label)
};
let group = match busy {
Some((label, progress)) => base
.bg(accent.opacity(0.35))
.child(
div()
.absolute()
.top_0()
.left_0()
.h_full()
.w(gpui::relative(progress.unwrap_or(0.).clamp(0., 1.)))
.bg(accent),
)
.child(
div()
.relative()
.flex()
.flex_1()
.justify_center()
.pr(px(40.))
.font_family(horizon_theme::glass::FONT)
.text_size(px(16.))
.font_weight(FontWeight::SEMIBOLD)
.child(match progress {
Some(p) => format!("{label} {:.0}%", p * 100.),
None => format!("{label}…"),
}),
),
None if running => base.bg(white(0.14)).child(
body(tr("Stop").into(), "stop")
.id("play")
.cursor_pointer()
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
if let Some(client) = &this.client {
client.kill();
}
cx.notify();
})),
),
None => {
let (label, icon) = match &update {
Some(_) => (tr("Update"), "download-04"),
None => (tr("Play"), "play"),
};
let press = play_press(ready, self.owner, false);
let main = body(label.into(), icon).id("play").cursor_pointer();
let main = match (&update, press) {
(Some(_), _) => main.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.install_update();
cx.notify();
})),
(None, PlayPress::Play) => {
main.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play();
cx.notify();
}))
}
(None, PlayPress::PrepareThenPlay) => {
main.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.picker_open = false;
this.play_when_prepared = this.selected().cloned();
this.prepare(backend::MinecraftFrom::Install);
cx.notify();
}))
}
(None, PlayPress::Nothing) => main.opacity(0.5).cursor_default(),
};
base.bg(accent)
.text_color(ui::color(self.look.on_accent))
.hover(|s| s.opacity(0.92))
.child(main)
}
};
let mut wrap = div()
.relative()
.child(keyboard(group.id("play-group"), &t).child(more));
if open {
let locked = running || busy.is_some();
let mut items: Vec<(&'static str, SharedString, String, bool, PlayItem)> = Vec::new();
if !tag.is_empty() {
let version = backend::game_version(tag);
items.push((
"play-prepare",
tr(if backend::is_prepared(tag) {
"Prepare again"
} else {
"Prepare"
})
.into(),
trf(
"Read {version}'s files from your own copy",
&[("version", &version)],
),
locked,
PlayItem::Prepare,
));
}
if update.is_some() {
items.push((
"play-skip",
tr("Play without updating").into(),
trf(
"Stay on {version}",
&[(
"version",
&horizon_launcher::nightly::label(tag).unwrap_or(tag.to_string()),
)],
),
locked || !ready,
PlayItem::Play,
));
}
let id = self
.selected_instance()
.map(|i| (i.id.clone(), i.name.clone()));
if let Some((id, _)) = &id {
let has = self.has_shortcut(id);
items.push((
"play-shortcut",
tr(if has {
"Remove the desktop shortcut"
} else {
"Create a desktop shortcut"
})
.into(),
tr("Starts this instance in one click").into(),
false,
PlayItem::Shortcut,
));
items.push((
"play-command",
tr("Copy the launch command").into(),
launch_command(id),
false,
PlayItem::Command,
));
}
let mut menu = ui::popover(290.)
.id("play-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.home_menu = HomeMenu::None;
this.menu_closed_at = Some(event.position);
cx.notify();
}));
for (key, label, note, locked, what) in items {
let id = id.clone();
let item = ui::menu_item(key, label, note, locked, &t);
menu = menu.child(if locked {
item
} else {
item.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.home_menu = HomeMenu::None;
match what {
PlayItem::Prepare => this.prepare(backend::MinecraftFrom::Install),
PlayItem::Play => this.play(),
PlayItem::Shortcut => {
if let Some((id, name)) = id.clone() {
this.toggle_shortcut(id, name);
}
}
PlayItem::Command => {
if let Some((id, _)) = &id {
copy_to_clipboard(
cx,
gpui::ClipboardItem::new_string(launch_command(id)),
);
}
}
}
cx.notify();
}))
});
}
wrap = wrap.child(
deferred(div().absolute().top(px(TALL + 8.)).left_0().child(menu)).with_priority(1),
);
}
wrap
}
/// The version (`VersionPicker`): "Version" over the one played, a chevron that turns while
/// its list is open; the list ticks the one played and ends with Manage versions.
fn glass_picker(&self, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let soft = self.look.accent_soft;
let label = self.selected().map_or_else(
|| tr("No version").into(),
|tag| {
horizon_launcher::nightly::label(tag)
.or_else(|| build_import::label(tag))
.unwrap_or_else(|| tag.clone())
},
);
let open = self.picker_open;
let chevron = ui::icon("arrow-down-01", 18., white(ink::NOTE)).when(open, |s| {
s.with_transformation(gpui::Transformation::rotate(gpui::radians(
std::f32::consts::PI,
)))
});
let button = keyboard(
div()
.id("picker")
.flex()
.items_center()
.justify_between()
.gap(px(24.))
.h(px(TALL))
.min_w(px(220.))
.px(px(16.))
.rounded(px(radius::LARGE))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(fill::ACTIVE)))
.cursor_pointer()
.child(
div()
.flex()
.flex_col()
.child(
ui::text(12., FontWeight::NORMAL, ink::QUIET)
.line_height(px(18.))
.child(tr("Version")),
)
.child(
ui::text(15., FontWeight::MEDIUM, ink::FULL)
.line_height(px(22.))
.child(label),
),
)
.child(chevron)
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
if !this.closed_by_this_press(event) {
this.picker_open = !this.picker_open;
}
cx.notify();
})),
&t,
);
let mut wrap = div().relative().child(button);
if open {
let follows = self.launcher_version().cloned();
let mut menu = ui::popover(300.)
.id("picker-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.picker_open = false;
this.menu_closed_at = Some(event.position);
cx.notify();
}));
for tag in &self.installed {
let chosen = self.selected() == Some(tag);
let latest = follows.as_ref() == Some(tag);
let name = horizon_launcher::nightly::label(tag)
.or_else(|| build_import::label(tag))
.unwrap_or_else(|| tag.clone());
let note = if latest {
tr("Newest · follows updates").to_string()
} else {
tag.clone()
};
let pick = tag.clone();
menu = menu.child(
ui::menu_item(format!("pick-{tag}"), name.into(), note, false, &t)
.when(chosen, |d| {
d.child(ui::icon("tick-02", 17., ui::color(soft)).relative())
})
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.pin_version((!latest).then(|| pick.clone()));
this.picker_open = false;
cx.notify();
})),
);
}
menu = menu.child(
ui::menu_item(
"manage",
tr("Manage versions").into(),
tr("Install, remove and pick versions").into(),
false,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.go(Page::Versions);
this.picker_open = false;
cx.notify();
})),
);
wrap = wrap.child(
deferred(div().absolute().top(px(TALL + 8.)).left_0().child(menu)).with_priority(1),
);
}
wrap
}
/// The folder button and its ⋯ (`FolderGroup`), which shows under the pointer or while its
/// menu is open: the instance's folder, its screenshots, and (Horizon) the Minecraft folder.
fn folder_group(&self, horizon: bool, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let instance = self.selected_instance()?;
let home = instance.home.clone();
let open = self.home_menu == HomeMenu::Folder;
let opener = home.clone();
let more = div()
.id("folder-more")
.flex()
.flex_none()
.items_center()
.justify_center()
.h(px(40.))
.w(px(36.))
.mr(px(8.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.when(open, |d| {
d.w(px(36.))
.mr(px(8.))
.opacity(1.)
.bg(white(fill::SELECTED))
})
.group_hover("folder-group", |s| s.w(px(36.)).mr(px(8.)).opacity(1.))
.hover(|s| s.bg(white(fill::SELECTED)))
.child(ui::icon("more-horizontal", 20., white(ink::STRONG)))
.on_click(cx.listener(|this, event: &ClickEvent, _, cx| {
if !this.closed_by_this_press(event) {
this.home_menu = match this.home_menu {
HomeMenu::Folder => HomeMenu::None,
_ => HomeMenu::Folder,
};
}
cx.notify();
}));
let group = div()
.id("folder-group")
.flex()
.items_center()
.h(px(TALL))
.rounded(px(radius::LARGE))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(fill::ACTIVE)))
.child(
div()
.id("home-folder")
.flex()
.items_center()
.justify_center()
.size(px(TALL))
.cursor_pointer()
.child(ui::icon("folder-01", 21., white(ink::STRONG)))
.on_click(move |_, _, cx| {
let _ = std::fs::create_dir_all(&opener);
cx.open_with_system(&opener);
}),
)
.when(open || self.folder_hovered, |d| d.child(more))
.on_hover(cx.listener(|this, hovered: &bool, _, cx| {
this.folder_hovered = *hovered;
cx.notify();
}));
let mut wrap = div().relative().child(keyboard(group, &t));
if open {
let shots = home.join("screenshots");
let count = std::fs::read_dir(&shots).map_or(0, |d| d.count());
let mut items = vec![
(
"folder-instance",
tr("Open the instance folder"),
short(&home),
Some(home.clone()),
),
(
"folder-shots",
tr("Open the screenshots folder"),
if count == 1 {
tr("1 screenshot").into()
} else {
trf("{count} screenshots", &[("count", &count.to_string())])
},
Some(shots),
),
];
if horizon {
items.push((
"folder-minecraft",
tr("Choose the Minecraft folder"),
self.settings
.minecraft_dir
.as_deref()
.map_or_else(|| tr("Not found").into(), short),
None,
));
}
let mut menu = ui::popover(290.)
.id("folder-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.home_menu = HomeMenu::None;
this.menu_closed_at = Some(event.position);
cx.notify();
}));
for (key, label, note, path) in items {
menu = menu.child(ui::menu_item(key, label.into(), note, false, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.home_menu = HomeMenu::None;
match &path {
Some(p) => {
let _ = std::fs::create_dir_all(p);
cx.open_with_system(p);
}
None => this.choose_folder(cx),
}
cx.notify();
}),
));
}
wrap = wrap.child(
deferred(div().absolute().top(px(TALL + 8.)).left_0().child(menu)).with_priority(1),
);
}
Some(wrap)
}
/// The invite code field (`JoinFriend`): 340 wide, the icon inside on the left, Join inside
/// on the right.
fn glass_join_friend(&self, tag: &str, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let focused = self.friend_focused;
let field = div()
.id("friend-code")
.track_focus(&self.friend_focus)
.flex()
.items_center()
.size_full()
.pl(px(48.))
.pr(px(84.))
.rounded(px(radius::LARGE))
.bg(white(if focused { 0.09 } else { 0.06 }))
.font_family(horizon_theme::glass::FONT)
.text_size(px(14.))
.text_color(white(ink::FULL))
.cursor_text()
.overflow_hidden()
.child(line_edit::content(
&self.friend_code,
&t,
focused,
false,
false,
"Paste a friend's invite code",
))
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
window.focus(&this.friend_focus, cx);
cx.notify();
}))
.on_key_down(cx.listener(|this, ev: &KeyDownEvent, window, cx| {
match line_edit::handle(&mut this.friend_code, ev, cx) {
line_edit::Key::Pass => return,
line_edit::Key::Enter => this.join_friend(),
line_edit::Key::Escape => window.blur(cx),
_ => {}
}
cx.stop_propagation();
cx.notify();
}));
let field = line_edit::mouse(field, cx, |this| &mut this.friend_code);
let valid = horizon_friends::invite::parse(self.friend_code.text()).is_some();
let can = valid && (tag.is_empty() || self.can_start(tag));
let join = ui::quiet("join-friend", tr("Join"), 40., &t);
let join = if can {
join.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.join_friend();
cx.notify();
}))
} else {
join.opacity(0.4).cursor_default()
};
div()
.relative()
.flex_none()
.w(px(340.))
.h(px(TALL))
.child(field)
.child(
div()
.absolute()
.left(px(16.))
.top(px((TALL - 19.) / 2.))
.child(ui::icon("user-add-01", 19., white(ink::NOTE))),
)
.child(div().absolute().right(px(8.)).top(px(8.)).child(join))
}
}
/// What a Play menu row does.
#[derive(Clone, Copy)]
enum PlayItem {
Prepare,
Play,
Shortcut,
Command,
}
/// The command that plays instance `id` without the window, as its desktop shortcut runs it.
fn launch_command(id: &str) -> String {
format!("horizon-launcher play --instance {id}")
}
+461
View File
@@ -0,0 +1,461 @@
//! Home's Worlds and Multiplayer tabs in the glass look (the mockup's `Worlds` and
//! `Multiplayer`, `launcher-views.tsx`): one `ROW` per world or server. A world's row plays it,
//! its size turning into "Play →" under the pointer; its ⋯ menu holds what the launcher does
//! besides (back up, copy, data packs, folder, delete). A server's row has Join, and Remove
//! (clicked twice) under the pointer. Dark appearance only.
use super::*;
use crate::glass_ui::{self as ui, white};
use gpui::{deferred, ExternalPaths};
use horizon_launcher::worlds::World;
use horizon_theme::glass::{fill, ink, radius};
use std::path::Path;
use worlds_page::{ago, Action};
/// A listener of the launcher's, boxed so a helper can return it.
type Listener<E> = Box<dyn Fn(&E, &mut Window, &mut App)>;
/// The 36 px picture at a row's start (`PixelIcon` at 36), round at `round(36 · 0.26)`.
const PICTURE: f32 = 36.;
const PICTURE_RADIUS: f32 = 9.;
impl Launcher {
/// Records whether the pointer is over the row `key`; the row then shows its actions.
fn list_hover_listener(&self, key: SharedString, cx: &mut Context<Self>) -> Listener<bool> {
Box::new(cx.listener(move |this, hovered: &bool, _, cx| {
if *hovered {
this.list_hover = Some(key.clone());
} else if this.list_hover.as_ref() == Some(&key) {
this.list_hover = None;
}
cx.notify();
}))
}
/// Home's Worlds tab: the selected instance's worlds, the last played first.
pub(super) fn glass_worlds(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let home = self.selected_instance()?.home.clone();
self.load_worlds(home.clone(), cx);
let list = self.worlds.list.clone();
let mut body = div().flex().flex_col();
// What the last back up, copy or delete did, where the mockup's toast would say it.
match self.worlds.done.clone() {
Some(Ok((message, zip))) => {
let open = zip.map(|zip| {
let dir = zip.parent().map(Path::to_path_buf).unwrap_or(zip);
ui::quiet("world-backups", tr("Open backups"), 36., &t)
.on_click(move |_, _, cx| cx.open_with_system(&dir))
});
body = body.child(ui::list_head(message).children(open));
}
Some(Err(e)) => body = body.child(danger_line(e)),
None => {}
}
if list.is_empty() {
let saves = home.join("saves");
return Some(
body.child(
ui::empty()
.flex()
.gap(px(4.))
.child(tr(
"No worlds yet. Make one in the game and it shows up here.",
))
.child(ui::link("worlds-folder", tr("Open folder")).on_click(
move |_, _, cx| {
let _ = std::fs::create_dir_all(&saves);
cx.open_with_system(&saves);
},
)),
),
);
}
// Pictures line up: a world without one gets a plain tile when another has one.
let pictures = list.iter().any(|w| w.icon.is_some());
for w in list {
let row = self.glass_world_row(&w, pictures, cx);
body = body.child(row).children(self.world_packs_panel(&w, cx));
}
Some(body)
}
fn glass_world_row(&mut self, w: &World, pictures: bool, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let key = SharedString::from(format!("world-{}", w.folder));
let hovered = self.list_hover.as_ref() == Some(&key);
let menu_open = self.list_menu.as_ref().is_some_and(|(k, _)| *k == key);
let busy = self.worlds.busy.is_some();
let this_busy = self.worlds.busy.as_ref() == Some(&w.folder);
let can_play = self.can_start_selected() && !busy;
let mut facts = vec![tr(w.mode).to_string()];
facts.extend(w.version.clone());
facts.push(ago(w.last_played));
let hover = self.list_hover_listener(key.clone(), cx);
let mut row = ui::list_row(key.clone(), hovered || menu_open, hover, &t);
if can_play {
let folder = w.folder.clone();
row = row
.cursor_pointer()
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.quick_play("--world", folder.clone());
cx.notify();
}));
}
// Data packs dropped on a world go into it.
let path = w.path.clone();
row = row
.drag_over::<ExternalPaths>(|s, _, _, _| s.bg(white(fill::SELECTED)))
.on_drop(cx.listener(move |this, paths: &ExternalPaths, _, cx| {
this.add_world_packs(path.clone(), paths.paths().to_vec());
cx.stop_propagation();
cx.notify();
}));
if pictures {
row = row.child(picture(
w.icon.clone().map(gpui::ImageSource::from),
"earth",
));
}
let size = if this_busy {
ui::text(13., FontWeight::NORMAL, ink::QUIET).child(tr("Working…"))
} else if hovered && can_play {
div()
.flex()
.items_center()
.gap(px(4.))
.child(ui::text(13., FontWeight::MEDIUM, ink::FULL).child(tr("Play")))
.child(ui::icon("arrow-right-01", 15., white(ink::FULL)))
} else {
ui::text(13., FontWeight::NORMAL, ink::QUIET).child(megabytes(w.size))
};
let more = ui::icon_button(
SharedString::from(format!("{key}-more")),
"more-horizontal",
36.,
17.,
radius::CONTROL,
menu_open,
&t,
)
.on_click(cx.listener({
let key = key.clone();
move |this, event: &ClickEvent, _, cx| {
if !this.closed_by_this_press(event) {
this.list_menu = match this.list_menu.take() {
Some((k, _)) if k == key => None,
_ => Some((key.clone(), false)),
};
}
cx.stop_propagation();
cx.notify();
}
}));
// Under the pointer only, as the mockup's row actions: the size keeps the row's end.
let mut more = div().relative().child(more);
if menu_open {
more = more.child(
deferred(
div()
.absolute()
.top(px(44.))
.right_0()
.child(self.world_menu(w, cx)),
)
.with_priority(1),
);
}
div().child(
row.child(ui::list_text(w.name.clone(), facts.join(" · ")))
.child(size.relative())
.when(hovered || menu_open, |r| r.child(more)),
)
}
/// A world's ⋯ menu: Back up, Copy, its data packs, Open folder, then Delete… (asked twice).
fn world_menu(&mut self, w: &World, cx: &mut Context<Self>) -> Stateful<Div> {
let t = self.theme;
let confirm = self.list_menu.as_ref().is_some_and(|(_, c)| *c);
let act = |this: &mut Self, action: Action, w: &World, cx: &mut Context<Self>| {
this.list_menu = None;
this.world_action(action, w.clone(), cx);
cx.notify();
};
let (backup, copy, packs, gone) = (w.clone(), w.clone(), w.clone(), w.clone());
let folder = w.path.clone();
let packs_label = if self.world_packs_open(w) {
tr("Hide data packs")
} else {
tr("Data packs")
};
let delete = ui::menu_row(
"world-menu-delete",
"delete-02",
if confirm {
tr("Click again to delete")
} else {
tr("Delete…")
},
true,
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
match this.list_menu.as_mut() {
Some((_, confirm)) if !*confirm => *confirm = true,
_ => act(this, Action::Delete, &gone, cx),
}
cx.notify();
}));
ui::popover(230.)
.id("world-menu")
.occlude()
.on_mouse_down_out(cx.listener(|this, event: &MouseDownEvent, _, cx| {
this.list_menu = None;
this.menu_closed_at = Some(event.position);
cx.notify();
}))
.child(
ui::menu_row("world-menu-backup", "download-04", tr("Back up"), false, &t)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
act(this, Action::Backup, &backup, cx)
})),
)
.child(
ui::menu_row("world-menu-copy", "copy-01", tr("Copy"), false, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
act(this, Action::Copy, &copy, cx)
}),
),
)
.child(
ui::menu_row("world-menu-packs", "package-01", packs_label, false, &t).on_click(
cx.listener(move |this, _: &ClickEvent, _, cx| {
this.list_menu = None;
this.toggle_world_packs(&packs);
cx.notify();
}),
),
)
.child(
ui::menu_row(
"world-menu-folder",
"folder-01",
tr("Open folder"),
false,
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.list_menu = None;
cx.open_with_system(&folder);
cx.notify();
})),
)
.child(div().mt(px(4.)).child(delete))
}
/// Home's Multiplayer tab: the selected instance's saved servers (the game's own list), and
/// a line to add one.
pub(super) fn glass_multiplayer(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let instance = self.selected_instance()?;
let (home, name) = (instance.home.clone(), instance.name.clone());
self.load_servers(home);
let list = self.multiplayer.list.clone();
let adding = self.multiplayer.adding;
let mut body = div().flex().flex_col();
if list.is_empty() && !adding {
return Some(
body.child(
ui::empty()
.flex()
.gap(px(4.))
.child(tr("No servers saved in this instance."))
.child(ui::link("server-add", tr("Add server")).on_click(toggle_add(cx))),
),
);
}
let count = list.len().to_string();
let note = match list.len() {
1 => trf("1 server in {name}", &[("name", &name)]),
_ => trf(
"{count} servers in {name}",
&[("count", &count), ("name", &name)],
),
};
body = body.child(
ui::list_head(note).child(
ui::quiet_icon("server-add", Some("plus-sign"), tr("Add server"), 36., &t)
.on_click(toggle_add(cx)),
),
);
if let Some(e) = self.multiplayer.error.clone() {
body = body.child(danger_line(e));
}
if adding {
body = body.child(
div()
.flex()
.items_center()
.gap(px(8.))
.px(px(12.))
.pb(px(8.))
.child(self.field(
servers_page::Field::ServerName,
"Minecraft Server",
220.,
cx,
))
.child(self.field(
servers_page::Field::ServerAddress,
"play.example.com",
300.,
cx,
))
.child(ui::solid("server-save", tr("Add"), 44., 14., &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.add_server();
cx.notify();
}),
)),
);
}
let pictures = list.iter().any(|(_, icon)| icon.is_some());
for (i, (server, icon)) in list.into_iter().enumerate() {
body = body.child(self.glass_server_row(i, server, icon, pictures, cx));
}
Some(body)
}
fn glass_server_row(
&mut self,
i: usize,
server: horizon_launcher::server_list::Server,
icon: Option<std::sync::Arc<gpui::Image>>,
pictures: bool,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let t = self.theme;
let key = SharedString::from(format!("server-{i}"));
let hovered = self.list_hover.as_ref() == Some(&key);
let confirming = self.multiplayer.confirm_remove == Some(i);
let hover = self.list_hover_listener(key.clone(), cx);
let ip = server.ip.clone();
let join = ui::quiet(
SharedString::from(format!("{key}-join")),
tr("Join"),
36.,
&t,
);
let join = if self.can_join_selected() {
join.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.quick_play("--server", ip.clone());
cx.notify();
}))
} else {
join.opacity(0.4).cursor_default()
};
let remove = (hovered || confirming).then(|| {
if confirming {
danger_button(
SharedString::from(format!("{key}-remove")),
tr("Click again to remove").into(),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.edit_servers(|list| list.remove(i).map(|_| ()));
cx.notify();
}))
.into_any_element()
} else {
div()
.child(
ui::icon_button(
SharedString::from(format!("{key}-delete")),
"delete-02",
36.,
17.,
radius::CONTROL,
false,
&t,
)
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.multiplayer.confirm_remove = Some(i);
cx.notify();
},
)),
)
.into_any_element()
}
});
ui::list_row(key, hovered || confirming, hover, &t)
.when(pictures, |r| {
r.child(picture(icon.map(gpui::ImageSource::from), "globe-02"))
})
.child(ui::list_text(server.name, server.ip))
.child(join.relative())
.children(remove.map(|r| div().relative().child(r)))
}
}
/// Add server: opens or closes the line to add one, its address field focused.
fn toggle_add(cx: &mut Context<Launcher>) -> Listener<ClickEvent> {
Box::new(cx.listener(|this, _: &ClickEvent, _, cx| {
this.multiplayer.adding = !this.multiplayer.adding;
this.multiplayer.error = None;
if this.multiplayer.adding {
this.servers.focus_soon = Some(servers_page::Field::ServerAddress);
}
cx.notify();
}))
}
/// A row's 36 px picture, or a plain tile with `fallback`'s icon.
fn picture(image: Option<gpui::ImageSource>, fallback: &'static str) -> AnyElement {
match image {
Some(image) => gpui::img(image)
.relative()
.flex_none()
.size(px(PICTURE))
.rounded(px(PICTURE_RADIUS))
.into_any_element(),
None => div()
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(PICTURE))
.rounded(px(PICTURE_RADIUS))
.bg(white(fill::BUTTON))
.child(ui::icon(fallback, 17., white(ink::QUIET)))
.into_any_element(),
}
}
/// Why an action failed, in the menu's Delete red, over the list.
fn danger_line(message: String) -> Div {
div().px(px(12.)).pb(px(8.)).child(
ui::text(13., FontWeight::NORMAL, ink::FULL)
.text_color(ui::color(horizon_theme::glass::DANGER))
.child(message),
)
}
/// A destructive button's second step (the menu's "Click again to delete"): its red text on
/// red at 12 % under the pointer.
fn danger_button(id: SharedString, label: SharedString) -> Stateful<Div> {
div()
.id(id)
.flex()
.flex_none()
.items_center()
.h(px(36.))
.px(px(12.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.hover(|s| s.bg(gpui::rgba(horizon_theme::glass::DANGER_HOVER << 8 | 0x1f)))
.child(
ui::text(13., FontWeight::MEDIUM, ink::FULL)
.text_color(ui::color(horizon_theme::glass::DANGER))
.child(label),
)
}
+400
View File
@@ -0,0 +1,400 @@
//! Home's tabbed panel in the glass look (the mockup's `TabbedPanel` and `Overview`,
//! `launcher-views.tsx`): one glass plate whose top-left corner is the tab strip, and under it
//! the open tab. Overview shows the instance's last screenshots beside the newest patch log,
//! then what Play still needs (the launcher's rows). Dark appearance only.
use super::*;
use crate::glass_ui::{self as ui, white};
use home_tabs::HomeTab;
use horizon_theme::glass::{ink, radius};
/// The tabbed panel's own body is at least this tall (`min-h-[340px]`), and so is the patch log.
const BODY: f32 = 340.;
impl Launcher {
/// The tabbed panel: the strip, then the open tab. `rows`: what Overview shows under its
/// screenshots and patch log (what Play needs, the open export or launch panel).
pub(super) fn glass_home_tabs(&mut self, rows: Div, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let tabs = self.home_tab_list(cx);
let open = tabs
.iter()
.map(|(tab, _)| *tab)
.find(|tab| *tab == self.home_tab)
.unwrap_or(HomeTab::Overview);
let strip = div()
.relative()
.flex()
.flex_none()
.items_center()
.gap(px(4.))
.px(px(8.))
.pt(px(8.))
.pb(px(4.))
.child(crate::glass::measure(self.home_strip.clone()))
.children(tabs.into_iter().map(|(tab, count)| {
let (name, icon) = match tab {
HomeTab::Overview => (tr("Overview"), Some("dashboard-square-01")),
HomeTab::Mods => (tr("Mods"), Some("puzzle")),
HomeTab::Worlds => (tr("Worlds"), Some("earth")),
HomeTab::Multiplayer => (tr("Multiplayer"), Some("user-multiple-02")),
HomeTab::Packs => (tr("Packs"), None),
HomeTab::Screenshots => (tr("Screenshots"), None),
HomeTab::Console => (tr("Console"), Some("computer-terminal-01")),
};
ui::count_tab(
format!("home-tab-{tab:?}"),
icon,
name,
count.filter(|n| *n > 0),
tab == open,
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.home_tab = tab;
cx.notify();
}))
}));
// The open tab's own buttons head its body, at the right.
let (actions, body): (Option<Div>, Option<AnyElement>) = match open {
HomeTab::Overview => (None, Some(self.glass_overview(rows, cx).into_any_element())),
HomeTab::Mods => {
let (actions, body) = self.instance_mods_tab(cx);
(Some(actions), Some(body.into_any_element()))
}
HomeTab::Worlds => (
None,
self.glass_worlds(cx).map(IntoElement::into_any_element),
),
HomeTab::Multiplayer => (
None,
self.glass_multiplayer(cx)
.map(IntoElement::into_any_element),
),
HomeTab::Packs => split(self.packs_section(cx)),
HomeTab::Screenshots => split(self.screenshots_section(cx)),
HomeTab::Console => split(self.console_section(cx)),
};
div()
.relative()
// `mt-12` under the head, of which Home's own gap gives 28.
.mt(px(20.))
.child(crate::glass::tabbed(self.home_strip.clone()))
.child(div().relative().flex().child(strip))
.child(
div().relative().p(px(8.)).child(
div()
.flex()
.flex_col()
.gap(px(12.))
.min_h(px(BODY))
.px(px(4.))
.pt(px(8.))
.pb(px(4.))
.children(actions.map(|a| div().flex().justify_end().px(px(12.)).child(a)))
.children(body),
),
)
}
/// Overview: the last screenshots and the patch log side by side, then `rows`.
fn glass_overview(&mut self, rows: Div, cx: &mut Context<Self>) -> Div {
div()
.flex()
.flex_col()
.gap(px(20.))
.child(
div()
.flex()
.gap(px(20.))
.child(self.overview_shots(cx))
.child(
div()
.relative()
.flex_none()
.w(px(380.))
.min_h(px(BODY))
.child(self.patch_log(cx)),
),
)
.child(rows)
}
/// The instance's three newest screenshots (the first twice as big when there are three),
/// "See all" opening the Screenshots tab; a line about F2 while there are none.
fn overview_shots(&mut self, cx: &mut Context<Self>) -> Div {
let count = self.screenshots_count();
let shots: Vec<_> = self.screenshots.list.iter().take(3).cloned().collect();
let see_all = || {
cx.listener(
|this: &mut Self, _: &ClickEvent, _, cx: &mut Context<Self>| {
this.home_tab = HomeTab::Screenshots;
cx.notify();
},
)
};
let head = div()
.flex()
.items_center()
.justify_between()
.gap(px(12.))
.h(px(32.))
.child(
div()
.flex()
.gap(px(6.))
.child(card_title(tr("Screenshots")))
.children((count > 0).then(|| {
ui::text(16., FontWeight::NORMAL, ink::DIM).child(count.to_string())
})),
)
.children((count > 0).then(|| {
ui::text(13., FontWeight::NORMAL, ink::NOTE)
.id("shots-see-all")
.cursor_pointer()
.hover(|s| s.text_color(white(ink::FULL)))
.child(tr("See all"))
.on_click(see_all())
}));
let shot = |i: usize, path: std::path::PathBuf| {
div()
.id(SharedString::from(format!("overview-shot-{i}")))
.overflow_hidden()
.rounded(px(radius::ROW))
.cursor_pointer()
.child(
gpui::img(path)
.size_full()
.rounded(px(radius::ROW))
.object_fit(gpui::ObjectFit::Cover),
)
};
let body = match shots.len() {
0 => ui::text(14., FontWeight::NORMAL, ink::NOTE)
.mt(px(8.))
.child(tr("No screenshots yet. Press F2 in the game."))
.into_any_element(),
n if n >= 3 => {
let mut it = shots.into_iter().enumerate();
let (i0, first) = it.next().unwrap();
div()
.mt(px(12.))
.flex()
.gap(px(8.))
.w_full()
.aspect_ratio(8. / 3.)
.child(shot(i0, first.path).flex_1().h_full().on_click(see_all()))
.child(
div()
.flex()
.flex_col()
.gap(px(8.))
.w(gpui::relative(1. / 3.))
.flex_none()
.h_full()
.children(it.map(|(i, s)| {
shot(i, s.path).flex_1().w_full().on_click(see_all())
})),
)
.into_any_element()
}
_ => div()
.mt(px(12.))
.flex()
.gap(px(8.))
.children(shots.into_iter().enumerate().map(|(i, s)| {
shot(i, s.path)
.flex_1()
.aspect_ratio(16. / 9.)
.on_click(see_all())
}))
.into_any_element(),
};
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.px(px(12.))
.child(head)
.child(body)
}
/// The patch log (`PatchLog`): the newest launcher release's changes by section, "All notes"
/// opening Patch notes. It fills its column and scrolls inside.
fn patch_log(&self, cx: &mut Context<Self>) -> Stateful<Div> {
let release = match &self.releases {
Load::Ready(list) => list
.iter()
.find(|r| r.nightly.is_none() && !r.prerelease)
.cloned(),
_ => None,
};
let note = release.as_ref().map_or_else(
|| tr("No notes yet").to_string(),
|r| {
let version = r
.tag
.rsplit('@')
.next()
.unwrap_or(&r.tag)
.trim_start_matches('v');
format!(
"{} · {}",
trf("Launcher {version}", &[("version", version)]),
r.date
)
},
);
let sections = release.map(|r| patch_sections(&r.body)).unwrap_or_default();
let many = sections.len() > 1;
let list =
div()
.flex()
.flex_col()
.gap(px(16.))
.mt(px(16.))
.children(sections.into_iter().map(|(section, items)| {
div()
.flex()
.flex_col()
.children(
many.then(|| {
ui::text(13., FontWeight::MEDIUM, ink::QUIET).child(section)
}),
)
.child(div().mt(px(6.)).flex().flex_col().gap(px(6.)).children(
items.into_iter().map(|item| {
div()
.flex()
.gap(px(10.))
.child(
div()
.flex_none()
.mt(px(9.))
.size(px(5.))
.rounded_full()
.bg(white(0.4)),
)
.child(
ui::text(14., FontWeight::NORMAL, ink::STRONG)
.line_height(px(21.))
.min_w_0()
.child(item),
)
}),
))
}));
div()
.id("patch-log")
.absolute()
.top_0()
.left_0()
.size_full()
.flex()
.flex_col()
.p(px(16.))
.rounded(px(radius::LARGE))
.bg(white(0.04))
.overflow_y_scroll()
.child(
div()
.flex()
.items_baseline()
.justify_between()
.gap(px(12.))
.child(card_title(tr("Patch log")))
.child(
ui::text(13., FontWeight::NORMAL, ink::NOTE)
.id("patch-log-all")
.cursor_pointer()
.hover(|s| s.text_color(white(ink::FULL)))
.child(tr("All notes"))
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.go(Page::Notes);
cx.notify();
})),
),
)
.child(
ui::text(13., FontWeight::NORMAL, ink::NOTE)
.mt(px(2.))
.child(note),
)
.child(list)
}
}
/// A card's title (`CARD_TITLE`): 16 px semibold.
fn card_title(label: impl Into<SharedString>) -> Div {
ui::text(16., FontWeight::SEMIBOLD, ink::FULL).child(label.into())
}
/// A tab's buttons and body, as the panel takes them.
fn split(tab: Option<(Div, impl IntoElement)>) -> (Option<Div>, Option<AnyElement>) {
match tab {
Some((actions, body)) => (Some(actions), Some(body.into_any_element())),
None => (None, None),
}
}
/// A release's changes by section ("### New", "### Fixed"), each line without its pull request
/// numbers; the contributors' thanks and what follows the notes are left out.
fn patch_sections(body: &str) -> Vec<(String, Vec<String>)> {
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for line in body.lines() {
let line = line.trim();
if let Some(name) = line.strip_prefix("### ") {
out.push((name.trim().to_string(), Vec::new()));
} else if line == "---" || line.starts_with("Your launcher updates itself") {
break;
} else if let (Some(item), Some((section, items))) =
(line.strip_prefix("- "), out.last_mut())
{
if section.as_str() != "Contributors" {
items.push(strip_refs(item));
}
}
}
out.retain(|(name, items)| name != "Contributors" && !items.is_empty());
out
}
/// "Fix the picker (#6389, fixes #6342)" → "Fix the picker".
fn strip_refs(item: &str) -> String {
let mut s = item.trim();
while let Some(open) = s.rfind(" (") {
let tail = &s[open..];
if tail.ends_with(')') && tail.contains('#') {
s = s[..open].trim_end();
} else {
break;
}
}
s.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_release_body_becomes_its_sections_without_refs_or_thanks() {
let body = "### New\n- Native mods: export a headless engine (#6462)\n\n### Fixed\n- Play with friends: any port (#6363, fixes #6342)\n- Keep (this) aside\n\n### Contributors\nThanks to @a.\n\nYour launcher updates itself. New here?\n\n---\nNot an official Minecraft product.";
assert_eq!(
patch_sections(body),
vec![
(
"New".to_string(),
vec!["Native mods: export a headless engine".to_string()]
),
(
"Fixed".to_string(),
vec![
"Play with friends: any port".to_string(),
"Keep (this) aside".to_string()
]
),
]
);
}
}
+593
View File
@@ -0,0 +1,593 @@
//! The glass look's controls ("Glass" in `docs/design-system.md`): the parts of the mockup's
//! `components/launcher/launcher-ui.tsx` and its shell (`launcher-app.tsx`), at its sizes. White
//! text and fills at a few opacities over [`glass`](crate::glass) plates; Geist throughout.
use crate::{hover_fade, hover_is, keyboard, note_hover, theme::Theme, track_hover};
use gpui::{
div, prelude::*, px, rgb, rgba, svg, App, Div, FontWeight, Hsla, SharedString, Stateful, Svg,
Window,
};
use horizon_theme::glass::{fill, ink, radius};
/// White at `a`.
pub fn white(a: f32) -> Hsla {
gpui::white().opacity(a)
}
/// A colour of the look (`0xrrggbb`).
pub fn color(c: u32) -> Hsla {
rgb(c).into()
}
/// Text at `size` px in Geist, white at `a`, at `weight`.
pub fn text(size: f32, weight: FontWeight, a: f32) -> Div {
div()
.font_family(horizon_theme::glass::FONT)
.text_size(px(size))
.line_height(px((size * 1.4).round()))
.font_weight(weight)
.text_color(white(a))
}
/// One of the mockup's Hugeicons, stroked in `color`.
pub fn icon(name: &'static str, size: f32, color: Hsla) -> Svg {
svg()
.path(format!("icons/glass/{name}.svg"))
.flex_none()
.size(px(size))
.text_color(color)
}
/// A layer filling its parent, round at `r`, for hover fills.
fn layer(r: f32) -> impl Fn() -> Div {
move || div().absolute().top_0().left_0().size_full().rounded(px(r))
}
/// `id`'s fill eased between `from` (white at that opacity) and `to` while hovered, as the
/// mockup's `transition-colors` does.
fn hover_fill(id: &SharedString, r: f32, from: f32, to: f32) -> gpui::AnyElement {
hover_fade(id, layer(r), move |d, t| d.bg(white((to - from) * t)))
}
/// The state dot before a row's second line (`Dot`): live green, amber for busy (the mockup
/// pulses it) and for a warning, the accent's soft shade (given) for an update, white at 35 % for
/// nothing yet.
#[derive(Clone, Copy, PartialEq)]
pub enum Tone {
Live,
Busy,
Warn,
Accent(u32),
Off,
}
pub fn dot(tone: Tone) -> Div {
let c = match tone {
Tone::Live => color(horizon_theme::glass::LIVE),
Tone::Busy | Tone::Warn => color(horizon_theme::glass::WARN),
Tone::Accent(soft) => color(soft),
Tone::Off => white(0.35),
};
div().flex_none().size(px(7.)).rounded_full().bg(c)
}
/// The progress bar (`Bar`): 4 px, white at 12 %, filled in the accent's soft shade.
pub fn bar(value: f32, accent_soft: u32) -> Div {
div()
.h(px(4.))
.w_full()
.rounded_full()
.overflow_hidden()
.bg(white(fill::ACTIVE))
.child(
div()
.h_full()
.rounded_full()
.w(gpui::relative(value.clamp(0., 1.)))
.bg(color(accent_soft)),
)
}
/// A sidebar row (an instance, a server): `rounded-[12px] p-2`, white at 10 % when it is the one
/// shown, 5 % under the pointer. Its icon and lines go in as children; `pr` leaves room for a
/// button laid over its right end.
pub fn row(id: impl Into<SharedString>, current: bool, pr: f32, t: &Theme) -> Stateful<Div> {
let id = id.into();
let d = div()
.id(id.clone())
.relative()
.flex()
.items_center()
.gap(px(12.))
.w_full()
.p(px(8.))
.pr(px(pr))
.rounded(px(radius::ROW))
.cursor_pointer();
let d = if current {
d.bg(white(fill::SELECTED))
} else {
d.child(hover_fill(&id, radius::ROW, 0., fill::HOVER))
};
keyboard(track_hover(d, id), t)
}
/// A row's two lines: the name (14 px semibold) and its state after a dot (12.5 px, white at
/// 65 %), both at the page's 1.5 line height.
pub fn row_text(name: impl Into<SharedString>, state: (Tone, String)) -> Div {
div()
.relative()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
text(14., FontWeight::SEMIBOLD, ink::FULL)
.line_height(px(21.))
.truncate()
.child(name.into()),
)
.child(
div()
.flex()
.items_center()
.gap(px(6.))
.min_w_0()
.child(dot(state.0))
.child(
text(12.5, FontWeight::NORMAL, ink::MUTED)
.line_height(px(18.75))
.truncate()
.child(state.1),
),
)
}
/// A square button with one icon (`ICON_BUTTON`): white at 65 %, white with a 8 % fill under
/// the pointer, white with 12 % while `active`.
pub fn icon_button(
id: impl Into<SharedString>,
name: &'static str,
size: f32,
icon_size: f32,
r: f32,
active: bool,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
let group = SharedString::from(format!("{id}-group"));
let d = div()
.id(id.clone())
.group(group.clone())
.relative()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(size))
.rounded(px(r))
.cursor_pointer();
let d = if active {
d.bg(white(fill::ACTIVE))
} else {
d.child(hover_fill(&id, r, 0., fill::BUTTON))
};
let glyph = icon(
name,
icon_size,
white(if active { ink::FULL } else { ink::MUTED }),
)
.relative()
.group_hover(group, |s| s.text_color(white(ink::FULL)));
keyboard(track_hover(d.child(glyph), id), t)
}
/// One tab of a tab strip (`Tabs`, size `md`): 36 px tall, 15 px; the open one white on 12 %,
/// medium; the others white at 70 %, white under the pointer.
pub fn tab(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
open: bool,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
let d = div()
.id(id.clone())
.flex()
.flex_none()
.items_center()
.gap(px(6.))
.h(px(36.))
.px(px(14.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.font_family(horizon_theme::glass::FONT)
.text_size(px(15.))
.line_height(px(20.));
let d = if open {
d.bg(white(fill::ACTIVE))
.font_weight(FontWeight::MEDIUM)
.text_color(white(ink::FULL))
} else {
d.text_color(white(ink::BODY))
.hover(|s| s.text_color(white(ink::FULL)))
};
let label: SharedString = label.into();
let d = d.when(!label.is_empty(), |d| d.child(label));
keyboard(track_hover(d, id), t)
}
/// A tab with an icon and a count (`Tabs`, size `md`): as [`tab`], the icon 16 px before the
/// label, the count after it in white at 45 %.
pub fn count_tab(
id: impl Into<SharedString>,
icon_name: Option<&'static str>,
label: impl Into<SharedString>,
count: Option<usize>,
open: bool,
t: &Theme,
) -> Stateful<Div> {
let colour = white(if open { ink::FULL } else { ink::BODY });
tab(id, "", open, t)
.children(icon_name.map(|n| icon(n, 16., colour)))
.child(label.into())
.children(count.map(|n| div().text_color(white(ink::DIM)).child(n.to_string())))
}
/// A popover's row (`MENU_ROW`): 40 px, 14 px text after a 17 px icon at 60 %, white at 7 %
/// under the pointer. `danger` colours it as the mockup's Delete.
pub fn menu_row(
id: impl Into<SharedString>,
name: &'static str,
label: impl Into<SharedString>,
danger: bool,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
let (fg, icon_color) = if danger {
let c = color(horizon_theme::glass::DANGER);
(c, c)
} else {
(white(ink::FULL), white(ink::NOTE))
};
let hover_color = if danger {
rgba(horizon_theme::glass::DANGER_HOVER << 8 | 0x1f).into()
} else {
white(0.07)
};
let d = div()
.id(id.clone())
.relative()
.flex()
.items_center()
.gap(px(12.))
.h(px(40.))
.w_full()
.px(px(12.))
.rounded(px(radius::CONTROL))
.cursor_pointer()
.child(hover_fade(&id, layer(radius::CONTROL), move |d, a| {
d.bg(hover_color.opacity(hover_color.a * a))
}))
.child(icon(name, 17., icon_color).relative())
.child(
text(14., FontWeight::NORMAL, 1.)
.relative()
.text_color(fg)
.child(label.into()),
);
keyboard(track_hover(d, id), t)
}
/// A popover (`POPOVER`): a float plate round at 16 with 6 px around its rows.
pub fn popover(width: f32) -> Div {
crate::glass::float(radius::POPOVER)
.w(px(width))
.p(px(6.))
.flex()
.flex_col()
}
/// Text on a white button (`SOLID`): `#100a1f`, medium.
pub fn solid(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
h: f32,
size: f32,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
keyboard(
div()
.id(id)
.flex()
.flex_none()
.items_center()
.justify_center()
.gap(px(8.))
.h(px(h))
.px(px(16.))
.rounded(px(radius::CONTROL))
.bg(gpui::white())
.cursor_pointer()
.hover(|s| s.opacity(0.9))
.font_family(horizon_theme::glass::FONT)
.text_size(px(size))
.font_weight(FontWeight::MEDIUM)
.text_color(color(horizon_theme::glass::ON_LIGHT))
.child(label.into()),
t,
)
}
/// Horizon's mark on its dark tile (`MarkTile`): the mark 64 % of the tile, round at 26 %; the
/// stems white, their reflection white at 40 %, the horizon in the accent's soft shade.
pub fn mark_tile(size: f32, accent_soft: u32) -> Div {
let inner = size * 0.64;
let u = inner / 16.;
let bar = |x: f32, y: f32, w: f32, h: f32, c: Hsla| {
div()
.absolute()
.left(px(x * u))
.top(px(y * u))
.w(px(w * u))
.h(px(h * u))
.bg(c)
};
div()
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(size))
.rounded(px((size * 0.26).round()))
.bg(color(0x0c0818))
.child(
div()
.relative()
.size(px(inner))
.child(bar(4., 3., 2., 4., white(1.)))
.child(bar(10., 3., 2., 4., white(1.)))
.child(bar(4., 8., 2., 4., white(0.4)))
.child(bar(10., 8., 2., 4., white(0.4)))
.child(bar(1., 7., 14., 1., color(accent_soft))),
)
}
/// A big square button with one icon (`BIG_ICON`): 56 px, white at 8 %, 13 % under the pointer;
/// the glyph white at 80 %, white under the pointer.
pub fn big_icon(id: impl Into<SharedString>, name: &'static str, t: &Theme) -> Stateful<Div> {
let id = id.into();
let group = SharedString::from(format!("{id}-group"));
let glyph = icon(name, 21., white(ink::STRONG))
.group_hover(group.clone(), |s| s.text_color(white(ink::FULL)));
keyboard(
div()
.id(id)
.group(group)
.flex()
.flex_none()
.items_center()
.justify_center()
.size(px(56.))
.rounded(px(radius::LARGE))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(0.13)))
.cursor_pointer()
.child(glyph),
t,
)
}
/// A menu row with a note under its label (`MENU_ITEM`): 14 px medium over 12 px at 55 %, white
/// at 7 % under the pointer; at 40 % when `locked`. A mark (a tick) can follow on the right.
pub fn menu_item(
id: impl Into<SharedString>,
label: SharedString,
note: String,
locked: bool,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
let d = div()
.id(id.clone())
.relative()
.flex()
.items_center()
.justify_between()
.gap(px(12.))
.w_full()
.px(px(12.))
.py(px(8.))
.rounded(px(radius::CONTROL))
.when(!locked, |d| {
d.cursor_pointer()
.child(hover_fade(&id, layer(radius::CONTROL), |d, a| {
d.bg(white(0.07 * a))
}))
})
.child(
div()
.relative()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
text(14., FontWeight::MEDIUM, ink::FULL)
.line_height(px(21.))
.child(label),
)
.child(
text(12., FontWeight::NORMAL, ink::QUIET)
.line_height(px(18.))
.truncate()
.child(note),
),
);
if locked {
return d.opacity(0.4);
}
keyboard(track_hover(d, id), t)
}
/// A quiet button (`QUIET`): white at 8 %, 14 % under the pointer, 14 px medium, `h` tall.
pub fn quiet(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
h: f32,
t: &Theme,
) -> Stateful<Div> {
quiet_icon(id, None, label, h, t)
}
/// [`quiet`] with a 16 px icon before its label.
pub fn quiet_icon(
id: impl Into<SharedString>,
name: Option<&'static str>,
label: impl Into<SharedString>,
h: f32,
t: &Theme,
) -> Stateful<Div> {
keyboard(
div()
.id(id.into())
.flex()
.flex_none()
.items_center()
.justify_center()
.gap(px(8.))
.h(px(h))
.px(px(14.))
.rounded(px(radius::CONTROL))
.bg(white(fill::BUTTON))
.hover(|s| s.bg(white(0.14)))
.cursor_pointer()
.font_family(horizon_theme::glass::FONT)
.text_size(px(14.))
.font_weight(FontWeight::MEDIUM)
.text_color(white(ink::FULL))
.children(name.map(|n| icon(n, 16., white(ink::FULL))))
.child(label.into()),
t,
)
}
/// A list row (`ROW`): `rounded-[12px] px-3 py-2.5`, white at 6 % under the pointer. `on_hover`
/// hears the pointer come and go too, for rows that show their actions then. `held`: the row
/// keeps its fill while the pointer is on one of its buttons or its menu is open, as CSS's
/// `:hover` stays on a parent.
pub fn list_row(
id: impl Into<SharedString>,
held: bool,
on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static,
t: &Theme,
) -> Stateful<Div> {
let id = id.into();
let key = id.clone();
let fill = if held && !hover_is(&id) {
layer(radius::ROW)()
.bg(white(fill::ROW_HOVER))
.into_any_element()
} else {
hover_fill(&id, radius::ROW, 0., fill::ROW_HOVER)
};
let d = div()
.id(id.clone())
.relative()
.flex()
.items_center()
.gap(px(12.))
.w_full()
.px(px(12.))
.py(px(10.))
.rounded(px(radius::ROW))
.child(fill)
.on_hover(move |hovered, window, cx| {
note_hover(&key, *hovered, window);
on_hover(hovered, window, cx);
});
keyboard(d, t)
}
/// A list row's two lines: the name (14 px medium) and a note (13 px, white at 55 %).
pub fn list_text(name: impl Into<SharedString>, note: impl Into<SharedString>) -> Div {
div()
.relative()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
text(14., FontWeight::MEDIUM, ink::FULL)
.line_height(px(21.))
.truncate()
.child(name.into()),
)
.child(
text(13., FontWeight::NORMAL, ink::QUIET)
.line_height(px(19.5))
.truncate()
.child(note.into()),
)
}
/// A tab's line while it has nothing to list (`Empty`): 14 px, white at 60 %, `px-3 py-10`.
pub fn empty() -> Div {
text(14., FontWeight::NORMAL, ink::NOTE)
.line_height(px(21.))
.px(px(12.))
.py(px(40.))
}
/// The line over a tab's list (`px-3 pb-2`): its note at 13 px, white at 60 %, then buttons.
pub fn list_head(note: impl Into<SharedString>) -> Div {
div()
.flex()
.items_center()
.justify_between()
.gap(px(12.))
.px(px(12.))
.pb(px(8.))
.child(
text(13., FontWeight::NORMAL, ink::NOTE)
.line_height(px(19.5))
.min_w_0()
.truncate()
.child(note.into()),
)
}
/// A link in a line of text (`font-medium text-white underline-offset-4 hover:underline`).
pub fn link(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Stateful<Div> {
div()
.id(id.into())
.font_weight(FontWeight::MEDIUM)
.text_color(white(ink::FULL))
.cursor_pointer()
.hover(|s| s.underline())
.child(label.into())
}
/// A field (`WIDE_FIELD`): 44 px, round at 12, white at 7 %, 11 % with the focus, 14 px. Red at
/// 12 % while what it holds is refused.
pub fn field(focused: bool, invalid: bool) -> Div {
let bg: Hsla = if invalid {
rgba(horizon_theme::glass::DANGER_HOVER << 8 | 0x1f).into()
} else {
white(if focused {
fill::FIELD_FOCUS
} else {
fill::FIELD
})
};
div()
.flex()
.items_center()
.h(px(44.))
.px(px(16.))
.overflow_hidden()
.rounded(px(radius::ROW))
.bg(bg)
.font_family(horizon_theme::glass::FONT)
.text_size(px(14.))
}
+4 -1
View File
@@ -34,7 +34,10 @@ impl HomeTab {
impl Launcher {
/// The tabs the selected instance has now, with how many things each holds (None: not known
/// yet, or nothing to count). Screenshots and Console only once there is something to show.
fn home_tab_list(&mut self, cx: &mut Context<Self>) -> Vec<(HomeTab, Option<usize>)> {
pub(super) fn home_tab_list(
&mut self,
cx: &mut Context<Self>,
) -> Vec<(HomeTab, Option<usize>)> {
let mut tabs = vec![(HomeTab::Overview, None)];
if self.selected_instance().is_none() {
return tabs;
+1 -1
View File
@@ -83,7 +83,7 @@ impl Launcher {
/// The image under `key` at `size` points, made from `bytes` the first time. A missing one is
/// remembered only when `remember_missing` (a file that may never exist); else it is asked
/// again next frame (a logo still downloading).
fn icon_image(
pub(super) fn icon_image(
&mut self,
key: String,
size: f32,
+20 -16
View File
@@ -120,7 +120,7 @@ impl Launcher {
}
/// A texture from the selected version's prepared files, its first frame, drawn as pixels.
fn texture_image(&mut self, name: &str, size: f32) -> Option<Arc<RenderImage>> {
pub(super) fn texture_image(&mut self, name: &str, size: f32) -> Option<Arc<RenderImage>> {
let tag = self.selected().filter(|t| backend::is_prepared(t))?.clone();
let scale = self.scale;
self.instances
@@ -280,6 +280,20 @@ impl Launcher {
self.servers.text(Field::InstanceVersion).set(String::new());
}
/// Opens the dialog for a copy of the selected instance, named after it.
pub(super) fn start_copy(&mut self) {
let Some(name) = self.selected_instance().map(|i| i.name.clone()) else {
return;
};
self.open_instance_dialog(None);
self.servers
.text(Field::InstanceName)
.set(format!("{name} copy"));
if let Some(d) = &mut self.instances.dialog {
d.copy = true;
}
}
/// Escape closes the dialog. Whether it did.
pub(super) fn instance_escape(&mut self) -> bool {
if self.instances.dialog.as_ref().is_some_and(|d| !d.busy) {
@@ -676,11 +690,7 @@ impl Launcher {
pub(super) fn instance_row(&mut self, n: usize, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let instance = self.selected_instance()?.clone();
let (home, edit, name) = (
instance.home.clone(),
instance.clone(),
instance.name.clone(),
);
let (home, edit) = (instance.home.clone(), instance.clone());
// Under the name, wrapping: six actions don't fit beside it at the window's narrowest.
let actions = div()
.flex()
@@ -699,13 +709,7 @@ impl Launcher {
.child(
button("instance-copy", "Copy…", Kind::Ghost, &t).on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.open_instance_dialog(None);
this.servers
.text(Field::InstanceName)
.set(format!("{name} copy"));
if let Some(d) = &mut this.instances.dialog {
d.copy = true;
}
this.start_copy();
cx.notify();
},
)),
@@ -845,7 +849,7 @@ impl Launcher {
)
}
fn delete_instance(&mut self, id: &str) {
pub(super) fn delete_instance(&mut self, id: &str) {
if self.client.is_some() || self.starting_client.is_some() {
return;
}
@@ -882,7 +886,7 @@ pub(super) fn played_line(instance: &Instance) -> String {
}
/// A Java instance's line in the sidebar: "Java 1.21.4", and its loader ("· Fabric 0.16.9").
fn java_line(j: &instances::JavaGame) -> String {
pub(super) fn java_line(j: &instances::JavaGame) -> String {
match &j.loader {
Some(l) => format!("Java {} · {} {}", j.minecraft, l.kind.name(), l.version),
None => format!("Java {}", j.minecraft),
@@ -890,7 +894,7 @@ fn java_line(j: &instances::JavaGame) -> String {
}
/// The first letter or digit of a name, for an icon without a picture.
fn initial(name: &str) -> String {
pub(super) fn initial(name: &str) -> String {
name.chars()
.find(|c| c.is_alphanumeric())
.unwrap_or('?')
+161 -85
View File
@@ -15,8 +15,14 @@ mod cull;
mod datapacks_page;
mod dev_options;
mod export_page;
mod frame;
mod game_tools;
mod github_mods_page;
mod glass;
mod glass_home;
mod glass_home_lists;
mod glass_home_tabs;
mod glass_ui;
mod home;
mod home_tabs;
mod import_page;
@@ -236,6 +242,10 @@ struct Launcher {
language_menu: bool,
/// The top bar's quick switch of who plays (`switch_page.rs`).
account_menu: bool,
/// The glass sidebar's ⋮ menu (`frame.rs`).
row_menu: frame::RowMenu,
/// The glass look's colours for the player's accent (`horizon_theme::glass`).
look: horizon_theme::glass::Glass,
/// Where the mouse went down to close a menu by pressing outside it, so that the
/// same press, ending on the menu's own button, doesn't open it again.
menu_closed_at: Option<Point<Pixels>>,
@@ -333,6 +343,20 @@ struct Launcher {
download_seen: Option<(Instant, usize)>,
/// The tab open under Home's hero (`home_tabs.rs`).
home_tab: home_tabs::HomeTab,
/// Home's Play or folder menu, while open (glass look).
home_menu: glass_home::HomeMenu,
/// Whether the pointer is over Home's folder button, which then shows its ⋯.
folder_hovered: bool,
/// The list row under the pointer in Home's glass tabs (a world, a server): it shows its
/// actions then.
list_hover: Option<SharedString>,
/// A Home list row's ⋯ menu, while open, and whether its Delete… was clicked once.
list_menu: Option<(SharedString, bool)>,
/// The size of Home's tab strip, measured as it is laid out: the glass panel's plate cuts
/// its corner around it.
home_strip: std::rc::Rc<std::cell::Cell<Option<gpui::Size<Pixels>>>>,
/// When the running game was first seen running, for Home's "Playing for…".
client_since: Option<Instant>,
/// The selected instance's mods, for Home's Mods tab (`instance_mods.rs`).
instance_mods: instance_mods::InstanceMods,
/// Modrinth projects' icon URLs (`instance_mods.rs`).
@@ -503,6 +527,8 @@ impl Launcher {
hue_dragging: false,
language_menu: false,
account_menu: false,
row_menu: Default::default(),
look: horizon_theme::glass::Glass::default_look(),
menu_closed_at: None,
skin: None,
background_error: None,
@@ -567,6 +593,12 @@ impl Launcher {
prepare_started: None,
download_seen: None,
home_tab: home_tabs::HomeTab::from_env(),
home_menu: Default::default(),
folder_hovered: false,
list_hover: None,
list_menu: None,
home_strip: Default::default(),
client_since: None,
instance_mods: Default::default(),
project_icons: Default::default(),
play_pressed: None,
@@ -1735,18 +1767,32 @@ static HOVER: Mutex<HoverState> = Mutex::new(HoverState {
});
fn track_hover(el: Stateful<Div>, id: SharedString) -> Stateful<Div> {
el.on_hover(move |hovered, window, _| {
let mut state = HOVER.lock().unwrap();
let is_on = state.on.as_ref().is_some_and(|(h, _)| *h == id);
if *hovered && !is_on {
state.off = state.on.take();
state.on = Some((id.clone(), Instant::now()));
} else if !*hovered && is_on {
state.off = Some((id.clone(), Instant::now()));
state.on = None;
}
window.refresh();
})
el.on_hover(move |hovered, window, _| note_hover(&id, *hovered, window))
}
/// Whether `id` is the element [`hover_fade`] shows hovered now.
fn hover_is(id: &SharedString) -> bool {
HOVER
.lock()
.unwrap()
.on
.as_ref()
.is_some_and(|(h, _)| h == id)
}
/// Records that `id` is now hovered or not, for [`hover_fade`]: an element whose own hover
/// listener does more calls it.
fn note_hover(id: &SharedString, hovered: bool, window: &mut Window) {
let mut state = HOVER.lock().unwrap();
let is_on = state.on.as_ref().is_some_and(|(h, _)| h == id);
if hovered && !is_on {
state.off = state.on.take();
state.on = Some((id.clone(), Instant::now()));
} else if !hovered && is_on {
state.off = Some((id.clone(), Instant::now()));
state.on = None;
}
window.refresh();
}
/// A layer under an element's content that fades `color` in while it is hovered. `bleed` lets
@@ -2926,18 +2972,6 @@ impl Launcher {
let version = java
.as_ref()
.map_or_else(|| backend::game_version(&tag), |j| j.minecraft.clone());
let hero = match &java {
// Its own game: the Minecraft version and the loader, never Horizon's.
Some(j) => {
let game = match &j.loader {
Some(l) => format!("{version} · {} {}", l.kind.name(), l.version),
None => version.clone(),
};
self.hero("", &game, true, cx)
}
None => self.hero(&tag, &version, prepared, cx),
};
let actions = self.home_actions(prepared || java.is_some(), java.is_some(), cx);
let left = self.download_left();
let steps = self.preparing.then(|| self.steps(&version, left));
@@ -3047,7 +3081,8 @@ impl Launcher {
let mut rows = div()
.flex()
.flex_col()
.children(self.instance_row(1, cx))
// The glass look's sidebar ⋮ menu holds what the Instance row offers.
.children((!t.dark).then(|| self.instance_row(1, cx)).flatten())
.children(self.export_panel(cx))
.children(self.launch_panel(cx))
.children(self.memory_row(cx));
@@ -3130,73 +3165,100 @@ impl Launcher {
}
}
// The banner: the hero and the Play row over the sky (`sky.rs`), or over the player's
// own picture under a veil of the page colour. It reaches the content's edges and the top
// bar.
let veil = |angle: f32, from: f32, to: f32, at: f32| {
// The glass look's head (the mockup's `HomeView`) replaces the banner; the preparation's
// steps stay right under it, where the player looks after pressing Play.
let banner = if t.dark {
div()
.absolute()
.top_0()
.left_0()
.size_full()
.bg(gpui::linear_gradient(
angle,
gpui::linear_color_stop(t.page.opacity(from), 0.),
gpui::linear_color_stop(t.page.opacity(to), at),
))
};
let picture = self.home_background().map(|path| {
[
gpui::img(path)
.flex()
.flex_col()
.gap(px(24.))
.child(self.glass_home_head(&tag, prepared, cx))
.children(steps)
} else {
let hero = match &java {
// Its own game: the Minecraft version and the loader, never Horizon's.
Some(j) => {
let game = match &j.loader {
Some(l) => format!("{version} · {} {}", l.kind.name(), l.version),
None => version.clone(),
};
self.hero("", &game, true, cx)
}
None => self.hero(&tag, &version, prepared, cx),
};
let actions = self.home_actions(prepared || java.is_some(), java.is_some(), cx);
// The banner: the hero and the Play row over the sky (`sky.rs`), or over the player's
// own picture under a veil of the page colour. It reaches the content's edges and the top
// bar.
let veil = |angle: f32, from: f32, to: f32, at: f32| {
div()
.absolute()
.top_0()
.left_0()
.size_full()
.object_fit(gpui::ObjectFit::Cover)
.into_any_element(),
veil(90., 0.82, 0., 0.62).into_any_element(),
veil(180., 0., 0.75, 1.).into_any_element(),
]
});
let banner = div()
.relative()
.flex()
.flex_col()
.justify_end()
.min_h(px(340.))
.mx(px(-theme::PAGE_GUTTER))
.px(px(theme::PAGE_GUTTER))
.pb(px(28.))
.gap(px(24.))
.overflow_hidden()
.children(picture.into_iter().flatten())
.child(
div()
.relative()
.flex()
.items_end()
.gap(px(32.))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap(px(28.))
.child(hero)
.child(actions)
// The preparation's steps right under Play, where the player looks
// after pressing it, not below the fold (#666).
.children(steps),
)
// Notes and friends are Horizon's.
.children(java.is_none().then(|| self.home_card(&tag, cx))),
);
.bg(gpui::linear_gradient(
angle,
gpui::linear_color_stop(t.page.opacity(from), 0.),
gpui::linear_color_stop(t.page.opacity(to), at),
))
};
let picture = self.home_background().map(|path| {
[
gpui::img(path)
.absolute()
.top_0()
.left_0()
.size_full()
.object_fit(gpui::ObjectFit::Cover)
.into_any_element(),
veil(90., 0.82, 0., 0.62).into_any_element(),
veil(180., 0., 0.75, 1.).into_any_element(),
]
});
div()
.relative()
.flex()
.flex_col()
.justify_end()
.min_h(px(340.))
.mx(px(-theme::PAGE_GUTTER))
.px(px(theme::PAGE_GUTTER))
.pb(px(28.))
.gap(px(24.))
.overflow_hidden()
.children(picture.into_iter().flatten())
.child(
div()
.relative()
.flex()
.items_end()
.gap(px(32.))
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap(px(28.))
.child(hero)
.child(actions)
// The preparation's steps right under Play, where the player looks
// after pressing it, not below the fold (#666).
.children(steps),
)
// Notes and friends are Horizon's.
.children(java.is_none().then(|| self.home_card(&tag, cx))),
)
};
let mut page = div().flex().flex_col().gap(px(28.)).child(banner);
if let Some(e) = self.prepare_error.clone() {
page = page.child(notice(e, &t));
}
page = page.child(self.home_tabs(rows, cx));
page = page.child(if t.dark {
self.glass_home_tabs(rows, cx)
} else {
self.home_tabs(rows, cx)
});
if let Some(e) = self.client_error.clone() {
page = page.child(notice(e, &t));
}
@@ -4718,6 +4780,7 @@ impl Render for Launcher {
}
// Dark: the new design's scene; Light keeps the dawn sky until the design has a light
// look.
glass::begin_build(window);
let sky = if t.dark {
self.scene.clone().into_any_element()
} else {
@@ -4733,8 +4796,20 @@ impl Render for Launcher {
}
sky.into_any_element()
};
let topbar = self.topbar(cx);
let sidebar = self.sidebar(window, cx);
// Dark: the glass frame over the scene; Light keeps the night one over the dawn.
let (topbar, sidebar) = if t.dark {
(
self.glass_topbar(cx),
self.glass_sidebar(window, cx).into_any_element(),
)
} else {
(self.topbar(cx), self.sidebar(window, cx).into_any_element())
};
let row_menu = if t.dark {
self.row_menu(window, cx)
} else {
None
};
let mut root = div()
.id("root")
.track_focus(&self.root_focus)
@@ -4818,6 +4893,7 @@ impl Render for Launcher {
),
),
);
root = root.children(row_menu);
if self.page == Page::Servers {
if let Some(dialog) = self.create_dialog(cx) {
root = root.child(dialog);
+17
View File
@@ -31,6 +31,23 @@ pub(super) fn symbol() -> Div {
}))
}
/// The symbol at `size` pixels square (the glass top bar's button draws it at 16).
pub(super) fn logo(size: f32) -> Div {
let u = size / 21.;
div()
.relative()
.flex_none()
.size(px(size))
.children(SQUARES.map(|(x, y, colour)| {
div()
.absolute()
.left(px(x * u))
.top(px(y * u))
.size(px(9. * u))
.bg(gpui::rgb(colour))
}))
}
/// The button, in the dark or light form the launcher's look calls for.
pub(super) fn sign_in_button(id: &'static str, t: &Theme) -> Stateful<Div> {
let (fill, ink, border) = if t.dark {
+9 -6
View File
@@ -13,11 +13,11 @@ use std::sync::Arc;
pub(super) struct Multiplayer {
/// The game folder the list is of, and the list as last read with each picture decoded.
home: Option<PathBuf>,
list: Vec<(Server, Option<Arc<Image>>)>,
pub(super) list: Vec<(Server, Option<Arc<Image>>)>,
fresh: bool,
adding: bool,
confirm_remove: Option<usize>,
error: Option<String>,
pub(super) adding: bool,
pub(super) confirm_remove: Option<usize>,
pub(super) error: Option<String>,
}
impl Multiplayer {
@@ -28,7 +28,7 @@ impl Multiplayer {
}
impl Launcher {
fn load_servers(&mut self, home: PathBuf) {
pub(super) fn load_servers(&mut self, home: PathBuf) {
if self.multiplayer.home.as_ref() != Some(&home) {
self.multiplayer = Multiplayer {
home: Some(home.clone()),
@@ -59,7 +59,10 @@ impl Launcher {
self.multiplayer.fresh = true;
}
fn edit_servers(&mut self, edit: impl FnOnce(&mut server_list::List) -> Result<(), String>) {
pub(super) fn edit_servers(
&mut self,
edit: impl FnOnce(&mut server_list::List) -> Result<(), String>,
) {
let Some(home) = self.multiplayer.home.clone() else {
return;
};
+130
View File
@@ -8,6 +8,9 @@
//! once per window size; what moves is a few hundred quads a frame: the stars, a meteor, the
//! clouds and the sun in runs of one colour, and the fireflies. The scene is its own view, so a
//! frame of it doesn't build the launcher's pages again: GPUI Fast draws them from the last frame.
//!
//! [`Backdrop`] is the same picture blurred, for the glass plates (`glass.rs`): GPUI has no
//! backdrop filter, but the only thing under a plate is this scene.
use crate::art;
use crate::sky::{motion, Clock, Motion};
use gpui::{
@@ -837,6 +840,7 @@ impl Render for Scene {
let scale = window.scale_factor();
if self.baked.as_ref().map(|b| b.key) != Some(key(width, height, scale)) {
let baked = Baked::new(width, height, scale);
crate::glass::set_backdrop(&baked.model);
if let Some(old) = self.baked.replace(baked) {
self.replaced.extend(old.images());
}
@@ -1055,6 +1059,132 @@ fn meteor(time: f64, width: f64, sky_height: f64) -> Vec<(Bounds<Pixels>, f32, H
.collect()
}
/// The scene blurred as `.hzl-glass` and `.hzl-float` see it through their backdrop filters
/// (blur, then `saturate(140%)`, and `brightness(0.62)` for the float): one colour per texel at
/// the scene's first frame, without the stars (a 24 px blur leaves nothing of them).
pub struct Backdrop {
pub w: usize,
pub h: usize,
/// Logical pixels per texel.
pub px: f64,
pub glass: Vec<[f32; 3]>,
pub float: Vec<[f32; 3]>,
}
impl Backdrop {
pub fn new(m: &Model, blur: f64) -> Backdrop {
let (w, h) = (m.w as usize, m.h as usize);
let mut base = vec![[0f64; 3]; w * h];
for (i, c) in base.iter_mut().enumerate() {
let (x, y) = (((i % w) as f64 + 0.5) * m.px, ((i / w) as f64 + 0.5) * m.px);
*c = over(m.sky_rgb(x, y), m.halo(x, y, 0.45));
}
let mut put = |x: i32, y: i32, c: u32| {
if x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h {
base[y as usize * w + x as usize] = rgb(c);
}
};
for (x, y, len, c) in m.runs(0., (0., 0.)) {
for dx in 0..len {
put(x + dx, y, c);
}
}
for n in 0..m.ridges.len() {
let (texels, cols, rows, top) = m.ridge_texels(n, 0);
for row in 0..rows {
for col in 0..cols {
let [r, g, b, a] = texels[(row * cols + col) as usize];
if a > 0 {
put(
col - MARGIN,
top + row,
(r as u32) << 16 | (g as u32) << 8 | b as u32,
);
}
}
}
}
let filtered = |sigma: f64, brightness: f64| -> Vec<[f32; 3]> {
blur_rgb(&base, w, h, sigma / m.px)
.into_iter()
.map(|c| {
let s = saturate(c, 1.4);
s.map(|v| (clamp(v, 0., 1.) * brightness) as f32)
})
.collect()
};
Backdrop {
w,
h,
px: m.px,
glass: filtered(blur, 1.),
float: filtered(blur + 24., 0.62),
}
}
/// The blurred colour at a point in logical pixels (bilinear between texel centres).
pub fn at(&self, float: bool, x: f64, y: f64) -> [f32; 3] {
let grid = if float { &self.float } else { &self.glass };
let fx = clamp(x / self.px - 0.5, 0., (self.w - 1) as f64);
let fy = clamp(y / self.px - 0.5, 0., (self.h - 1) as f64);
let (x0, y0) = (fx.floor() as usize, fy.floor() as usize);
let (x1, y1) = ((x0 + 1).min(self.w - 1), (y0 + 1).min(self.h - 1));
let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32);
let px = |x: usize, y: usize| grid[y * self.w + x];
let l = |a: [f32; 3], b: [f32; 3], t: f32| [0, 1, 2].map(|i| a[i] + (b[i] - a[i]) * t);
l(
l(px(x0, y0), px(x1, y0), tx),
l(px(x0, y1), px(x1, y1), tx),
ty,
)
}
}
/// A Gaussian blur of standard deviation `sigma` texels, its edges held (CSS blurs a backdrop
/// with its edge pixels repeated).
fn blur_rgb(src: &[[f64; 3]], w: usize, h: usize, sigma: f64) -> Vec<[f64; 3]> {
if sigma <= 0. {
return src.to_vec();
}
let radius = (sigma * 3.).ceil() as i64;
let kernel: Vec<f64> = (-radius..=radius)
.map(|i| (-(i * i) as f64 / (2. * sigma * sigma)).exp())
.collect();
let sum: f64 = kernel.iter().sum();
let pass = |src: &[[f64; 3]], horizontal: bool| -> Vec<[f64; 3]> {
let mut out = vec![[0.; 3]; w * h];
for y in 0..h {
for x in 0..w {
let mut acc = [0.; 3];
for (k, wgt) in kernel.iter().enumerate() {
let d = k as i64 - radius;
let (sx, sy) = if horizontal {
((x as i64 + d).clamp(0, w as i64 - 1) as usize, y)
} else {
(x, (y as i64 + d).clamp(0, h as i64 - 1) as usize)
};
let c = src[sy * w + sx];
for i in 0..3 {
acc[i] += c[i] * wgt;
}
}
out[y * w + x] = acc.map(|v| v / sum);
}
}
out
};
pass(&pass(src, true), false)
}
/// CSS `saturate(s)` (Filter Effects' matrix).
fn saturate([r, g, b]: [f64; 3], s: f64) -> [f64; 3] {
[
(0.213 + 0.787 * s) * r + (0.715 - 0.715 * s) * g + (0.072 - 0.072 * s) * b,
(0.213 - 0.213 * s) * r + (0.715 + 0.285 * s) * g + (0.072 - 0.072 * s) * b,
(0.213 - 0.213 * s) * r + (0.715 - 0.715 * s) * g + (0.072 + 0.928 * s) * b,
]
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
View File
@@ -14,7 +14,7 @@ const THUMB_H: f32 = THUMB_W * 9. / 16.;
#[derive(Default)]
pub(super) struct Screenshots {
home: Option<PathBuf>,
list: Vec<Shot>,
pub(super) list: Vec<Shot>,
fresh: bool,
all: bool,
/// The picture open in the viewer, by its place in `list`.
+9 -3
View File
@@ -52,8 +52,12 @@ impl Scrollbar {
/// Wrap the scroll container, reserving a narrow gutter so the bar never covers controls.
pub fn wrap(&self, content: Stateful<Div>, theme: &Theme) -> Div {
let state = self.clone();
let track_color = theme.well;
let thumb_color = theme.muted;
// Dark wears the glass look's thin bar: white at 14 % on no track (`scrollbar-color`).
let (track_color, thumb_color) = if theme.dark {
(gpui::transparent_black(), gpui::white().opacity(0.14))
} else {
(theme.well, theme.muted)
};
div()
.relative()
.flex()
@@ -63,7 +67,9 @@ impl Scrollbar {
.child(
content
.track_scroll(&self.handle)
.mr(px(GUTTER))
// The glass look's bar lies over the page's own margin, as an overlay
// scrollbar does; the old look keeps a gutter for its track.
.mr(px(if theme.dark { 0. } else { GUTTER }))
// Nested scroll areas must not also move the page behind them.
.on_scroll(|_, _, cx| cx.stop_propagation()),
)
+25 -17
View File
@@ -898,26 +898,34 @@ impl Launcher {
let selected = focused && self.servers.selected == Some(f);
let invalid = self.servers.invalid.is_some_and(|(g, _)| g == f);
self.servers.text(f).show(((width - 24.) / 7.5) as usize);
let field = div()
// Home's Add server line is in the glass look already; the other fields follow with
// their pages.
let glass = t.dark && matches!(f, Field::ServerName | Field::ServerAddress);
let base = if glass {
crate::glass_ui::field(focused, invalid)
} else {
div()
.flex()
.items_center()
.h(px(theme::FIELD))
.px(px(12.))
.overflow_hidden()
.rounded(px(theme::RADIUS))
.border_1()
.border_color(if invalid {
t.danger
} else if focused {
t.accent
} else {
t.rule
})
.bg(t.surface)
.text_size(px(theme::UI.px))
};
let field = base
.id(SharedString::from(format!("field-{f:?}")))
.track_focus(&handle)
.flex()
.items_center()
.w(px(width))
.h(px(theme::FIELD))
.px(px(12.))
.overflow_hidden()
.rounded(px(theme::RADIUS))
.border_1()
.border_color(if invalid {
t.danger
} else if focused {
t.accent
} else {
t.rule
})
.bg(t.surface)
.text_size(px(theme::UI.px))
.cursor_text()
.child(line_edit::content(
self.servers.text(f),
+2 -2
View File
@@ -13,7 +13,7 @@ pub(super) struct Shortcuts {
}
impl Launcher {
fn has_shortcut(&mut self, id: &str) -> bool {
pub(super) fn has_shortcut(&mut self, id: &str) -> bool {
match &self.shortcuts.seen {
Some((seen, has)) if seen == id => *has,
_ => {
@@ -24,7 +24,7 @@ impl Launcher {
}
}
fn toggle_shortcut(&mut self, id: String, name: String) {
pub(super) fn toggle_shortcut(&mut self, id: String, name: String) {
let has = self.has_shortcut(&id);
let done = match has {
false => shortcut::make(&backend::update_target(), &id, &name).map(|made| {
+1 -1
View File
@@ -35,7 +35,7 @@ impl Launcher {
}
/// Everyone the player can play as, with how each signs in.
fn players(&self) -> Vec<(Player, String, &'static str)> {
pub(super) fn players(&self) -> Vec<(Player, String, &'static str)> {
let mut out: Vec<_> = self
.accounts
.accounts
+7 -7
View File
@@ -9,15 +9,15 @@ use std::path::{Path, PathBuf};
pub(super) struct Worlds {
/// The game folder the list is of, and the list as last read: kept on screen while it is
/// read again, so the page doesn't jump.
home: Option<PathBuf>,
list: Vec<World>,
pub(super) home: Option<PathBuf>,
pub(super) list: Vec<World>,
fresh: bool,
reading: bool,
/// The world being backed up, copied or deleted now.
busy: Option<String>,
pub(super) busy: Option<String>,
confirm_delete: Option<String>,
/// What the last action did (a backup's zip), or why it failed.
done: Option<Result<(String, Option<PathBuf>), String>>,
pub(super) done: Option<Result<(String, Option<PathBuf>), String>>,
}
impl Worlds {
@@ -39,7 +39,7 @@ pub(super) fn ago(when: std::time::SystemTime) -> String {
}
}
enum Action {
pub(super) enum Action {
Backup,
Copy,
Delete,
@@ -48,7 +48,7 @@ enum Action {
impl Launcher {
/// Reads the worlds of `home` in the background when the list is missing or of another
/// instance.
fn load_worlds(&mut self, home: PathBuf, cx: &mut Context<Self>) {
pub(super) fn load_worlds(&mut self, home: PathBuf, cx: &mut Context<Self>) {
if self.worlds.home.as_ref() != Some(&home) {
self.worlds = Worlds {
home: Some(home.clone()),
@@ -78,7 +78,7 @@ impl Launcher {
.detach();
}
fn world_action(&mut self, action: Action, world: World, cx: &mut Context<Self>) {
pub(super) fn world_action(&mut self, action: Action, world: World, cx: &mut Context<Self>) {
let Some(home) = self.worlds.home.clone() else {
return;
};
+12 -4
View File
@@ -106,14 +106,22 @@ if [ -f "$shared/sounds/$version/sounds.json" ] && [ -d "$assets" ]; then
fi
# The languages (horizon-prepare languages copies them from the official launcher's asset store, which no runner
# has) depend only on the Minecraft version too: kept once in $shared/languages/<version>/ (assets/pack.mcmeta, the
# language list, and assets/minecraft/lang/) and cloned into each preparation that lacks them. Without them a
# language list, and assets/minecraft/lang/) and cloned into each preparation to fill missing files. Without them a
# preparation has en_us alone, and pack_screen's harness, which draws ten languages, fails.
languages="$shared/languages/$version/assets"
if [ -d "$languages/minecraft/lang" ] && [ -d "$assets" ] && [ ! -e "$prepared/horizon-client/$version/assets/pack.mcmeta" ]; then
if [ -d "$languages/minecraft/lang" ] && [ -d "$assets" ]; then
mkdir -p "$assets/lang"
if [ "$(uname)" = Darwin ]; then
cp -cR "$languages/minecraft/lang/." "$assets/lang/" && cp -c "$languages/pack.mcmeta" "$assets/../"
cp -cRn "$languages/minecraft/lang/." "$assets/lang/"
else
cp -R --reflink=auto "$languages/minecraft/lang/." "$assets/lang/" && cp --reflink=auto "$languages/pack.mcmeta" "$assets/../"
cp -R --reflink=auto -n "$languages/minecraft/lang/." "$assets/lang/"
fi
if [ ! -e "$assets/../pack.mcmeta" ]; then
if [ "$(uname)" = Darwin ]; then
cp -c "$languages/pack.mcmeta" "$assets/../"
else
cp --reflink=auto "$languages/pack.mcmeta" "$assets/../"
fi
fi
fi
touch "$prepared"
+47
View File
@@ -836,6 +836,31 @@ def items_and_loot():
def world_misc():
return [
# BlockInput.place calculates neighbour shapes before setting the block,
# then reapplies only properties explicitly supplied by the command.
cmd('/setblock 4 219 4 minecraft:stone'),
cmd('/setblock 4 220 4 minecraft:note_block'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=basedrum]'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=harp]'),
cmd('/setblock 4 219 4 minecraft:gold_block'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=bell]'),
cmd('/setblock 4 219 4 minecraft:stone'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=basedrum]'),
cmd('/setblock 4 220 4 minecraft:air'),
cmd('/setblock 4 220 4 minecraft:note_block[instrument=flute,note=7]'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=flute,note=7]'),
cmd('/setblock 4 220 4 minecraft:air'),
cmd('/setblock 4 220 4 minecraft:note_block[note=7]'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=basedrum,note=7]'),
cmd('/setblock 4 220 4 minecraft:air'),
cmd('/setblock 4 220 4 minecraft:note_block strict'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=harp]'),
# Even an unchanged input state must be normalized before comparison.
cmd('/setblock 4 220 4 minecraft:note_block'),
cmd('/execute if block 4 220 4 minecraft:note_block[instrument=basedrum]'),
# An unsupported shape falling back to air retains the requested block.
cmd('/setblock 6 220 4 minecraft:poppy'),
cmd('/execute if block 6 220 4 minecraft:poppy'),
cmd('/setblock 1 210 1 minecraft:stone keep'),
cmd('/setblock 1 210 1 minecraft:dirt keep'),
cmd('/setblock 1 210 1 minecraft:dirt destroy'),
@@ -1718,6 +1743,27 @@ def trial_spawner_passengers():
return ops + setup('/fill 1 219 1 13 224 8 air', '/gamerule spawn_mobs false')
def forceload_query_order():
"""ForceLoadCommand sorts signed packed chunk IDs, not coordinate tuples (#7451)."""
return [
*setup('/forceload remove all'),
cmd('/forceload query'),
cmd('/forceload query 32 0'),
cmd('/forceload add 32 0'),
cmd('/forceload query'),
cmd('/forceload query 32 0'),
cmd('/forceload add -16 0'),
cmd('/forceload add 0 -16'),
# Java 26.3: [0,-1], [2,0], [-1,0]. These replies say nothing
# about whether the force-loaded chunks have finished loading.
cmd('/forceload query'),
cmd('/forceload remove 32 0'),
cmd('/forceload query 32 0'),
cmd('/forceload remove all'),
cmd('/forceload query'),
]
def groups():
return [
('setup', group_setup()),
@@ -1726,6 +1772,7 @@ def groups():
('game state', game_state()),
('player state', player_state()),
('blocks and border', world_blocks()),
('forceload query order', forceload_query_order()),
('entities', entities()),
('wither spawn', wither_spawn()),
('riding teleport', riding_teleport()),
@@ -0,0 +1,21 @@
{
"hooks": [
{
"name": "item_container",
"target": "horizon_client::game::native_hooks::Game<'_>::native_item_container",
"neoforge": "Capabilities.Item.ITEM",
"mod_api": {
"contract": "replace",
"out": "slots"
}
}
],
"helpers": [
{"name": "item_container_slots", "target": "horizon_client::game::native_item_container::Game<'_>::native_item_container_slots", "mod_api": {}},
{"name": "item_container_insert", "target": "horizon_client::game::native_item_container::Game<'_>::native_item_container_insert", "mod_api": {}},
{"name": "item_container_extract", "target": "horizon_client::game::native_item_container::Game<'_>::native_item_container_extract", "mod_api": {}}
],
"rust_summary": "a mod item holding items (`native_item_container`), and the item container helpers in\n`native_item_container.rs`: `native_item_container_slots`, `native_item_container_insert`, `native_item_container_extract`.",
"doc_summary": "items held by items (`Capabilities.Item.ITEM`): backpacks, storage cells, toolboxes. `native_item_container(item, slots)` gives a mod item its slots (clamped to 1..=256). The contents live in the stack's `minecraft:container`, as a shulker box item keeps them, so they are saved and sent with the stack; an empty item carries nothing, so it stacks with a new one. `native_item_container_slots(stack)` reads one item of a stack, empty slots included; `native_item_container_insert(stack, item)` puts in as much of `item` as fits, topping up equal stacks first and then the first empty slots (`SimpleContainer.addItem`'s order), no slot past its limit (99, or the item's own when less), and never a shulker box (`Item.canFitInsideContainerItems`); `native_item_container_extract(stack, slot, max)` takes up to `max` from a slot. Both answer what moved and the single item it becomes, through an `ItemAccess` as NeoForge's handler acts; the stack given is not changed, so the caller shrinks it by one and hands out the result, or leaves the result unused to simulate. They work on any side. Bundles need \"hooks\": @API:item_container@.",
"comments": []
}
+1 -1
View File
@@ -94,7 +94,7 @@ Cat.createAttributes skip:data attributes come from entity-types.json
Cat.handleLieDown inline crates/horizon-client/src/mobs/tame.rs::cat_ai_step gap: minor: purring and the amounts are ported; lying on top of a sleeping player (a 0.15 shift of the pose) is not tracked
Cat.updateLieDownAmount port crates/horizon-client/src/mobs/tame.rs::cat_amounts
Cat.updateRelaxStateOneAmount port crates/horizon-client/src/mobs/tame.rs::cat_amounts
Cat.canMate inline crates/horizon-client/src/mobs/goals.rs::Breed::can_use gap: minor: a sitting cat is refused as a mate, vanilla only needs both tame and in love
Cat.canMate inline crates/horizon-client/src/mobs/goals.rs::Breed::can_use both tamed; the seeker's sitting pose does not matter, and only a wolf's partner is refused while it sits
Cat.removeWhenFarAway port crates/horizon-client/src/mobs/ai.rs::feline_removed_when_far `!isTame() && tickCount > 2400`, read by check_despawn's removable flag
Cat.reassessTameGoals inline crates/horizon-client/src/mobs/tame.rs::register_cat the player avoidance goal is conditioned on being untamed instead of added and removed
Cat.tryToTame inline crates/horizon-client/src/game/creatures.rs::Game::interact_mob one chance in three, then tame, sitting, event 7 or 6 and the advancement trigger
1 AbstractHorse.registerGoals port crates/horizon-client/src/mobs/equine.rs::register same goals and priorities; llamas and camels have their own register
94 Cat.handleLieDown inline crates/horizon-client/src/mobs/tame.rs::cat_ai_step gap: minor: purring and the amounts are ported; lying on top of a sleeping player (a 0.15 shift of the pose) is not tracked
95 Cat.updateLieDownAmount port crates/horizon-client/src/mobs/tame.rs::cat_amounts
96 Cat.updateRelaxStateOneAmount port crates/horizon-client/src/mobs/tame.rs::cat_amounts
97 Cat.canMate inline crates/horizon-client/src/mobs/goals.rs::Breed::can_use gap: minor: a sitting cat is refused as a mate, vanilla only needs both tame and in love both tamed; the seeker's sitting pose does not matter, and only a wolf's partner is refused while it sits
98 Cat.removeWhenFarAway port crates/horizon-client/src/mobs/ai.rs::feline_removed_when_far `!isTame() && tickCount > 2400`, read by check_despawn's removable flag
99 Cat.reassessTameGoals inline crates/horizon-client/src/mobs/tame.rs::register_cat the player avoidance goal is conditioned on being untamed instead of added and removed
100 Cat.tryToTame inline crates/horizon-client/src/game/creatures.rs::Game::interact_mob one chance in three, then tame, sitting, event 7 or 6 and the advancement trigger
+1 -1
View File
@@ -115,7 +115,7 @@ HappyGhast.setServerStillTimeout inline crates/horizon-client/src/mobs/happy_gha
HappyGhast.registerGoals port crates/horizon-client/src/mobs/happy_ghast.rs::setup adult goals: float, tempt (harness and food for a bare adult, food otherwise), float around within 16
HappyGhast.adultGhastSetup port crates/horizon-client/src/mobs/happy_ghast.rs::setup adult half: ghast move control, happy ghast look control, goals, brain stopped
HappyGhast.babyGhastSetup port crates/horizon-client/src/mobs/happy_ghast.rs::setup baby half: flying move control, baby flying navigation, no goals, timeout cleared
HappyGhast.ageBoundaryReached port crates/horizon-client/src/mobs/happy_ghast.rs::ensure_setup reruns setup when the age sign changes
HappyGhast.ageBoundaryReached port crates/horizon-client/src/mobs/finalize.rs::age_boundary_reached reruns the setup (happy_ghast::ensure_setup) when the age sign changes, so a hatched ghastling flies its own goals before its first tick
HappyGhast.createAttributes skip:data attributes come from entity-types.json
HappyGhast.getAmbientSoundInterval port crates/horizon-client/src/mobs.rs::Mob::ambient_interval six times as long while it carries a rider
HappyGhast.canUseSlot inline crates/horizon-client/src/game/ghasts.rs::Game::interact_happy_ghast a harness goes only on a living adult without one; shears take it off
1 Fox.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the type and the flags byte are synced; the two trusted-entity slots are not
115 HappyGhast.registerGoals port crates/horizon-client/src/mobs/happy_ghast.rs::setup adult goals: float, tempt (harness and food for a bare adult, food otherwise), float around within 16
116 HappyGhast.adultGhastSetup port crates/horizon-client/src/mobs/happy_ghast.rs::setup adult half: ghast move control, happy ghast look control, goals, brain stopped
117 HappyGhast.babyGhastSetup port crates/horizon-client/src/mobs/happy_ghast.rs::setup baby half: flying move control, baby flying navigation, no goals, timeout cleared
118 HappyGhast.ageBoundaryReached port crates/horizon-client/src/mobs/happy_ghast.rs::ensure_setup crates/horizon-client/src/mobs/finalize.rs::age_boundary_reached reruns setup when the age sign changes reruns the setup (happy_ghast::ensure_setup) when the age sign changes, so a hatched ghastling flies its own goals before its first tick
119 HappyGhast.createAttributes skip:data attributes come from entity-types.json
120 HappyGhast.getAmbientSoundInterval port crates/horizon-client/src/mobs.rs::Mob::ambient_interval six times as long while it carries a rider
121 HappyGhast.canUseSlot inline crates/horizon-client/src/game/ghasts.rs::Game::interact_happy_ghast a harness goes only on a living adult without one; shears take it off
+1 -1
View File
@@ -59,7 +59,7 @@ TeamArgument.listSuggestions port crates/horizon-command/src/args.rs::ArgType::s
TimeArgument.time skip:java builder: Horizon names ArgType::Time in the command tree
TimeArgument.listSuggestions port crates/horizon-command/src/args.rs::ArgType::suggest ArgType::Time arm
BlockInput.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT of the input is not compared
BlockInput.overwriteWithDefinedProperties missing unsure: vanilla BlockInput.place keeps neighbour-updated shape properties when the command sets only some; Horizon resolves the given state directly (Game::block_state)
BlockInput.overwriteWithDefinedProperties port crates/horizon-client/src/game/commands.rs::Game::block_input_state /setblock normalizes neighbour shapes, then reapplies explicit properties; strict skips normalization; gap: /fill still places resolved input directly
BlockPredicateArgument.parse port crates/horizon-command/src/block.rs::parse
BlockPredicateArgument.BlockPredicate.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT is not compared
BlockPredicateArgument.TagPredicate.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT is not compared
1 MessageArgument.getMessage port crates/horizon-client/src/game/commands.rs::Game::expand_selectors gap: minor: plain text only, no chat signing
59 TimeArgument.time skip:java builder: Horizon names ArgType::Time in the command tree
60 TimeArgument.listSuggestions port crates/horizon-command/src/args.rs::ArgType::suggest ArgType::Time arm
61 BlockInput.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT of the input is not compared
62 BlockInput.overwriteWithDefinedProperties missing port crates/horizon-client/src/game/commands.rs::Game::block_input_state unsure: vanilla BlockInput.place keeps neighbour-updated shape properties when the command sets only some; Horizon resolves the given state directly (Game::block_state) /setblock normalizes neighbour shapes, then reapplies explicit properties; strict skips normalization; gap: /fill still places resolved input directly
63 BlockPredicateArgument.parse port crates/horizon-command/src/block.rs::parse
64 BlockPredicateArgument.BlockPredicate.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT is not compared
65 BlockPredicateArgument.TagPredicate.test port crates/horizon-client/src/game/commands.rs::Game::block_matches gap: minor: block-entity NBT is not compared