mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Launcher: a GPUI app that installs, prepares and starts Horizon builds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b279705c8d
commit
57257ae231
13 files changed
+9183
No files matched your search
@@ -1,4 +1,5 @@
|
||||
[workspace]
|
||||
exclude = ["launcher"]
|
||||
members = ["crates/horizon-core", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-assets", "crates/horizon-protocol"]
|
||||
resolver = "2"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
target/
|
||||
Generated
+7321
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "horizon-launcher"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Downloads, prepares and starts Horizon builds"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
gpui = "0.2.2"
|
||||
ureq = { version = "2", features = ["json", "tls"], default-features = false }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
pulldown-cmark = { version = "0.12", default-features = false }
|
||||
flate2 = "1"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
dirs = "5"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION AND CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Build environment for the launcher on NixOS: `nix-shell` then `cargo build`.
|
||||
{ pkgs ? import <nixpkgs> {} }:
|
||||
pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [ pkg-config clang mold ];
|
||||
buildInputs = with pkgs; [
|
||||
libxkbcommon wayland vulkan-loader fontconfig freetype alsa-lib openssl
|
||||
libx11 libxcb libxcursor libxrandr libxi
|
||||
];
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
LD_LIBRARY_PATH = with pkgs; lib.makeLibraryPath [ libxkbcommon wayland vulkan-loader fontconfig freetype libx11 libxcb libxcursor libxrandr libxi ];
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Everything that is not UI: GitHub, installs, resource preparation and the game's processes.
|
||||
//! Functions here block; the UI runs them on threads and reads their messages.
|
||||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub const REPO: &str = "ludovic111/horizon";
|
||||
pub const SPONSOR_URL: &str = "https://github.com/sponsors/ludovic111";
|
||||
const AGENT: &str = concat!("horizon-launcher/", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Release {
|
||||
pub tag: String,
|
||||
pub name: String,
|
||||
pub body: String,
|
||||
pub date: String,
|
||||
pub asset: Option<(String, u64)>,
|
||||
}
|
||||
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-linux-x86_64.tar.gz");
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-macos-arm64.zip");
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
pub const ASSET: Option<&str> = Some("horizon-windows-x64.zip");
|
||||
#[cfg(not(any(
|
||||
all(target_os = "linux", target_arch = "x86_64"),
|
||||
all(target_os = "macos", target_arch = "aarch64"),
|
||||
all(target_os = "windows", target_arch = "x86_64")
|
||||
)))]
|
||||
pub const ASSET: Option<&str> = None;
|
||||
|
||||
const CLIENT: &str = if cfg!(windows) { "horizon-client.exe" } else { "horizon-client" };
|
||||
|
||||
fn agent() -> ureq::Agent {
|
||||
ureq::AgentBuilder::new().user_agent(AGENT).build()
|
||||
}
|
||||
|
||||
fn get(url: &str) -> Result<ureq::Response, String> {
|
||||
agent().get(url).call().map_err(|e| match e {
|
||||
ureq::Error::Status(403, _) => "GitHub's rate limit was reached. Try again in a few minutes.".into(),
|
||||
ureq::Error::Status(code, _) => format!("{url} answered {code}"),
|
||||
ureq::Error::Transport(t) => format!("No connection: {t}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Published releases, newest first. Drafts never reach the anonymous API.
|
||||
pub fn releases() -> Result<Vec<Release>, String> {
|
||||
let url = format!("https://api.github.com/repos/{REPO}/releases?per_page=50");
|
||||
let list: serde_json::Value = get(&url)?.into_json().map_err(|e| e.to_string())?;
|
||||
let mut out = Vec::new();
|
||||
for r in list.as_array().into_iter().flatten() {
|
||||
if r["draft"].as_bool().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let asset = r["assets"].as_array().into_iter().flatten().find_map(|a| {
|
||||
(Some(a["name"].as_str()?) == ASSET)
|
||||
.then(|| Some((a["browser_download_url"].as_str()?.to_string(), a["size"].as_u64()?)))?
|
||||
});
|
||||
out.push(Release {
|
||||
tag: r["tag_name"].as_str().unwrap_or_default().into(),
|
||||
name: r["name"].as_str().unwrap_or_default().into(),
|
||||
body: r["body"].as_str().unwrap_or_default().replace("\r\n", "\n"),
|
||||
date: r["published_at"].as_str().unwrap_or_default().chars().take(10).collect(),
|
||||
asset,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The README's roadmap section from main (the project status section if it has none).
|
||||
pub fn roadmap() -> Result<String, String> {
|
||||
let url = format!("https://raw.githubusercontent.com/{REPO}/main/README.md");
|
||||
let readme = get(&url)?.into_string().map_err(|e| e.to_string())?;
|
||||
for title in ["## Roadmap", "## Project status"] {
|
||||
if let Some(start) = readme.lines().position(|l| l.trim() == title) {
|
||||
let section: Vec<&str> =
|
||||
readme.lines().skip(start + 1).take_while(|l| !l.starts_with("## ")).collect();
|
||||
return Ok(section.join("\n").trim().to_string());
|
||||
}
|
||||
}
|
||||
Err("The README has no roadmap section yet.".into())
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
dirs::data_dir().unwrap_or_else(std::env::temp_dir).join("horizon-launcher")
|
||||
}
|
||||
|
||||
fn versions_dir() -> PathBuf {
|
||||
data_dir().join("versions")
|
||||
}
|
||||
|
||||
pub fn version_dir(tag: &str) -> PathBuf {
|
||||
versions_dir().join(tag)
|
||||
}
|
||||
|
||||
const COMPLETE: &str = ".installed";
|
||||
|
||||
/// Installed versions, newest tag first.
|
||||
pub fn installed() -> Vec<String> {
|
||||
let mut tags: Vec<String> = fs::read_dir(versions_dir())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join(COMPLETE).exists())
|
||||
.filter_map(|e| e.file_name().into_string().ok())
|
||||
.collect();
|
||||
tags.sort_by(|a, b| b.cmp(a));
|
||||
tags
|
||||
}
|
||||
|
||||
/// Downloads and unpacks one release; `progress` gets the fraction done.
|
||||
pub fn install(tag: &str, url: &str, size: u64, progress: impl Fn(f32)) -> Result<(), String> {
|
||||
let root = versions_dir();
|
||||
let partial = root.join(format!(".{tag}.partial"));
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
fs::create_dir_all(&partial).map_err(|e| e.to_string())?;
|
||||
let archive = partial.join("download");
|
||||
{
|
||||
let mut reader = get(url)?.into_reader();
|
||||
let mut file = fs::File::create(&archive).map_err(|e| e.to_string())?;
|
||||
let (mut done, mut buf) = (0u64, vec![0u8; 1 << 16]);
|
||||
loop {
|
||||
let n = reader.read(&mut buf).map_err(|e| format!("Download interrupted: {e}"))?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
file.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||||
done += n as u64;
|
||||
progress((done as f32 / size.max(1) as f32).min(1.0) * 0.9);
|
||||
}
|
||||
}
|
||||
let unpacked = partial.join("unpacked");
|
||||
let file = fs::File::open(&archive).map_err(|e| e.to_string())?;
|
||||
if url.ends_with(".tar.gz") {
|
||||
tar::Archive::new(flate2::read::GzDecoder::new(file)).unpack(&unpacked).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
zip::ZipArchive::new(file).and_then(|mut z| z.extract(&unpacked)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
progress(0.97);
|
||||
// The archives hold one top folder (horizon-linux-x86_64/...); the version folder is its contents.
|
||||
let entries: Vec<_> = fs::read_dir(&unpacked).map_err(|e| e.to_string())?.flatten().collect();
|
||||
let top = match entries.as_slice() {
|
||||
[only] if only.path().is_dir() => only.path(),
|
||||
_ => unpacked.clone(),
|
||||
};
|
||||
if !top.join(CLIENT).exists() {
|
||||
return Err(format!("The download has no {CLIENT}"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(top.join(CLIENT), fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
let target = version_dir(tag);
|
||||
let _ = fs::remove_dir_all(&target);
|
||||
fs::rename(&top, &target).map_err(|e| e.to_string())?;
|
||||
fs::write(target.join(COMPLETE), "").map_err(|e| e.to_string())?;
|
||||
let _ = fs::remove_dir_all(&partial);
|
||||
progress(1.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove(tag: &str) -> Result<(), String> {
|
||||
fs::remove_dir_all(version_dir(tag)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The Minecraft version a Horizon build targets, from its research-lock.json.
|
||||
pub fn game_version(tag: &str) -> String {
|
||||
fs::read_to_string(version_dir(tag).join("research-lock.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
|
||||
.and_then(|v| v["minecraft"]["version"].as_str().map(String::from))
|
||||
.unwrap_or_else(|| "26.3".into())
|
||||
}
|
||||
|
||||
pub fn resources(tag: &str) -> PathBuf {
|
||||
version_dir(tag).join(".local/horizon-client").join(game_version(tag))
|
||||
}
|
||||
|
||||
pub fn is_prepared(tag: &str) -> bool {
|
||||
resources(tag).join("horizon-resources.json").exists()
|
||||
}
|
||||
|
||||
/// The official launcher's game directory, where prepare_native_client.py looks by default.
|
||||
pub fn default_minecraft_dir() -> Option<PathBuf> {
|
||||
let home = dirs::home_dir()?;
|
||||
let dir = if cfg!(target_os = "macos") {
|
||||
home.join("Library/Application Support/minecraft")
|
||||
} else if cfg!(windows) {
|
||||
dirs::config_dir()?.join(".minecraft")
|
||||
} else {
|
||||
home.join(".minecraft")
|
||||
};
|
||||
dir.is_dir().then_some(dir)
|
||||
}
|
||||
|
||||
fn found(program: &str, arg: &str) -> bool {
|
||||
Command::new(program).arg(arg).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok()
|
||||
}
|
||||
|
||||
fn python() -> Option<&'static str> {
|
||||
let candidates: &[&'static str] = if cfg!(windows) { &["py", "python"] } else { &["python3", "python"] };
|
||||
candidates.iter().copied().find(|p| found(p, "--version"))
|
||||
}
|
||||
|
||||
/// Checks what the preparation needs and builds its command. PLAY.txt describes the same steps:
|
||||
/// the player's own launcher install, the server jar from that version's own json, Java and Python.
|
||||
pub fn prepare_command(tag: &str, minecraft: &Path, log: &dyn Fn(String)) -> Result<Command, String> {
|
||||
let version = game_version(tag);
|
||||
let manifest = minecraft.join("versions").join(&version).join(format!("{version}.json"));
|
||||
if !manifest.exists() {
|
||||
return Err(format!(
|
||||
"Minecraft {version} is not in {}. Play {version} once from the official launcher, or choose another folder.",
|
||||
minecraft.display()
|
||||
));
|
||||
}
|
||||
let python = python().ok_or("Python 3.10 or newer is needed. Install it and try again.")?;
|
||||
if !found("java", "-version") {
|
||||
return Err("Java 25 is needed (a JDK, e.g. Eclipse Temurin 25). Install it and try again.".into());
|
||||
}
|
||||
let jar = data_dir().join("server-jars").join(format!("{version}.jar"));
|
||||
if !jar.exists() {
|
||||
let json: serde_json::Value = serde_json::from_str(&fs::read_to_string(&manifest).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let url = json["downloads"]["server"]["url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("{} has no server download", manifest.display()))?;
|
||||
log(format!("Downloading the {version} server jar from Mojang"));
|
||||
fs::create_dir_all(jar.parent().unwrap()).map_err(|e| e.to_string())?;
|
||||
let mut body = Vec::new();
|
||||
get(url)?.into_reader().read_to_end(&mut body).map_err(|e| e.to_string())?;
|
||||
let partial = jar.with_extension("partial");
|
||||
fs::write(&partial, body).and_then(|_| fs::rename(&partial, &jar)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut cmd = Command::new(python);
|
||||
cmd.current_dir(version_dir(tag))
|
||||
.args(["-u", "tools/prepare_native_client.py", "--prepare", "--server-jar"])
|
||||
.arg(&jar)
|
||||
.arg("--minecraft-dir")
|
||||
.arg(minecraft);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
pub fn client_command(tag: &str) -> Command {
|
||||
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
|
||||
cmd.current_dir(version_dir(tag));
|
||||
cmd
|
||||
}
|
||||
|
||||
pub fn server_command(tag: &str, folder: &Path) -> Command {
|
||||
let mut cmd = Command::new(version_dir(tag).join(CLIENT));
|
||||
cmd.current_dir(folder).arg("--resources").arg(resources(tag)).args(["--dedicated", "--nogui"]);
|
||||
cmd
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Job {
|
||||
Prepare,
|
||||
Client,
|
||||
Server,
|
||||
}
|
||||
|
||||
pub enum Msg {
|
||||
Releases(Result<Vec<Release>, String>),
|
||||
Roadmap(Result<String, String>),
|
||||
Progress(String, f32),
|
||||
Installed(String, Result<(), String>),
|
||||
Line(Job, String),
|
||||
Exit(Job, Result<(), String>),
|
||||
}
|
||||
|
||||
/// A running process; its output arrives as `Msg::Line`, its end as `Msg::Exit`.
|
||||
pub struct Running {
|
||||
pub child: Arc<Mutex<Child>>,
|
||||
pub stdin: Option<ChildStdin>,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
pub fn send(&mut self, line: &str) {
|
||||
if let Some(stdin) = &mut self.stdin {
|
||||
let _ = writeln!(stdin, "{line}");
|
||||
let _ = stdin.flush();
|
||||
}
|
||||
}
|
||||
pub fn kill(&self) {
|
||||
let _ = self.child.lock().unwrap().kill();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn(job: Job, mut cmd: Command, tx: Sender<Msg>) -> Result<Running, String> {
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| format!("Could not start {:?}: {e}", cmd.get_program()))?;
|
||||
let stdin = child.stdin.take();
|
||||
let mut readers = Vec::new();
|
||||
let out = child.stdout.take().map(|o| Box::new(o) as Box<dyn Read + Send>);
|
||||
let err = child.stderr.take().map(|o| Box::new(o) as Box<dyn Read + Send>);
|
||||
for stream in [out, err].into_iter().flatten() {
|
||||
let tx = tx.clone();
|
||||
readers.push(std::thread::spawn(move || {
|
||||
for line in BufReader::new(stream).lines().map_while(Result::ok) {
|
||||
let _ = tx.send(Msg::Line(job, line));
|
||||
}
|
||||
}));
|
||||
}
|
||||
let child = Arc::new(Mutex::new(child));
|
||||
let waited = child.clone();
|
||||
std::thread::spawn(move || {
|
||||
for r in readers {
|
||||
let _ = r.join();
|
||||
}
|
||||
let status = loop {
|
||||
if let Some(s) = waited.lock().unwrap().try_wait().transpose() {
|
||||
break s;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
};
|
||||
let result = match status {
|
||||
Ok(s) if s.success() => Ok(()),
|
||||
Ok(s) => Err(format!("Exited with {s}")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
let _ = tx.send(Msg::Exit(job, result));
|
||||
});
|
||||
Ok(Running { child, stdin })
|
||||
}
|
||||
|
||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Settings {
|
||||
pub selected: Option<String>,
|
||||
pub minecraft_dir: Option<PathBuf>,
|
||||
pub server_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
fs::read_to_string(data_dir().join("settings.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn save(&self) {
|
||||
let _ = fs::create_dir_all(data_dir());
|
||||
let _ = fs::write(data_dir().join("settings.json"), serde_json::to_string_pretty(self).unwrap());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,154 @@
|
||||
//! Release notes and the roadmap: pulldown-cmark events into gpui elements.
|
||||
use crate::theme::Theme;
|
||||
use gpui::{div, prelude::*, px, AnyElement, FontStyle, FontWeight, HighlightStyle, SharedString, StyledText};
|
||||
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
|
||||
use std::ops::Range;
|
||||
|
||||
enum Block {
|
||||
Heading(u8, String, Vec<(Range<usize>, Span)>),
|
||||
Paragraph(String, Vec<(Range<usize>, Span)>),
|
||||
Item(usize, Option<u64>, String, Vec<(Range<usize>, Span)>),
|
||||
Code(String),
|
||||
Rule,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Span {
|
||||
Strong,
|
||||
Emphasis,
|
||||
Code,
|
||||
Link,
|
||||
}
|
||||
|
||||
pub fn render(source: &str, t: &Theme) -> AnyElement {
|
||||
let mut blocks = Vec::new();
|
||||
let (mut text, mut spans) = (String::new(), Vec::new());
|
||||
let mut open: Vec<(Span, usize)> = Vec::new();
|
||||
let mut lists: Vec<Option<u64>> = Vec::new();
|
||||
let mut item: Option<(usize, Option<u64>)> = None;
|
||||
let mut heading = None;
|
||||
let mut code: Option<String> = None;
|
||||
let flush = |blocks: &mut Vec<Block>, text: &mut String, spans: &mut Vec<_>, item: &mut Option<(usize, Option<u64>)>, heading: Option<u8>| {
|
||||
if text.trim().is_empty() {
|
||||
text.clear();
|
||||
spans.clear();
|
||||
return;
|
||||
}
|
||||
let (t, s) = (std::mem::take(text), std::mem::take(spans));
|
||||
blocks.push(match (heading, item.take()) {
|
||||
(Some(level), _) => Block::Heading(level, t, s),
|
||||
(None, Some((depth, n))) => Block::Item(depth, n, t, s),
|
||||
_ => Block::Paragraph(t, s),
|
||||
});
|
||||
};
|
||||
for event in Parser::new_ext(source, Options::ENABLE_STRIKETHROUGH) {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
heading = Some(match level {
|
||||
HeadingLevel::H1 => 1,
|
||||
HeadingLevel::H2 => 2,
|
||||
_ => 3,
|
||||
})
|
||||
}
|
||||
Event::End(TagEnd::Heading(_)) => {
|
||||
flush(&mut blocks, &mut text, &mut spans, &mut item, heading);
|
||||
heading = None;
|
||||
}
|
||||
Event::End(TagEnd::Paragraph) => flush(&mut blocks, &mut text, &mut spans, &mut item, None),
|
||||
Event::Start(Tag::List(start)) => {
|
||||
flush(&mut blocks, &mut text, &mut spans, &mut item, None);
|
||||
lists.push(start)
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
lists.pop();
|
||||
}
|
||||
Event::Start(Tag::Item) => {
|
||||
flush(&mut blocks, &mut text, &mut spans, &mut item, None);
|
||||
let depth = lists.len().saturating_sub(1);
|
||||
let number = lists.last_mut().and_then(|n| n.as_mut().map(|n| {
|
||||
*n += 1;
|
||||
*n - 1
|
||||
}));
|
||||
item = Some((depth, number));
|
||||
}
|
||||
Event::End(TagEnd::Item) => flush(&mut blocks, &mut text, &mut spans, &mut item, None),
|
||||
Event::Start(Tag::CodeBlock(_)) => code = Some(String::new()),
|
||||
Event::End(TagEnd::CodeBlock) => blocks.push(Block::Code(code.take().unwrap_or_default())),
|
||||
Event::Start(Tag::Strong) => open.push((Span::Strong, text.len())),
|
||||
Event::Start(Tag::Emphasis) => open.push((Span::Emphasis, text.len())),
|
||||
Event::Start(Tag::Link { .. }) => open.push((Span::Link, text.len())),
|
||||
Event::End(TagEnd::Strong | TagEnd::Emphasis | TagEnd::Link) => {
|
||||
if let Some((span, start)) = open.pop() {
|
||||
spans.push((start..text.len(), span));
|
||||
}
|
||||
}
|
||||
Event::Code(c) => {
|
||||
let start = text.len();
|
||||
text.push_str(&c);
|
||||
spans.push((start..text.len(), Span::Code));
|
||||
}
|
||||
Event::Text(s) => match &mut code {
|
||||
Some(c) => c.push_str(&s),
|
||||
None => text.push_str(&s),
|
||||
},
|
||||
Event::SoftBreak => text.push(' '),
|
||||
Event::HardBreak => text.push('\n'),
|
||||
Event::Rule => blocks.push(Block::Rule),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
flush(&mut blocks, &mut text, &mut spans, &mut item, None);
|
||||
|
||||
let styled = |text: String, spans: Vec<(Range<usize>, Span)>| {
|
||||
let highlights = spans.into_iter().map(|(range, span)| {
|
||||
let style = match span {
|
||||
Span::Strong => HighlightStyle { font_weight: Some(FontWeight::SEMIBOLD), color: Some(t.text), ..Default::default() },
|
||||
Span::Emphasis => HighlightStyle { font_style: Some(FontStyle::Italic), ..Default::default() },
|
||||
Span::Code => HighlightStyle { background_color: Some(t.raised), color: Some(t.text), ..Default::default() },
|
||||
Span::Link => HighlightStyle { color: Some(t.text), ..Default::default() },
|
||||
};
|
||||
(range, style)
|
||||
});
|
||||
StyledText::new(SharedString::from(text)).with_highlights(highlights.collect::<Vec<_>>())
|
||||
};
|
||||
let mut column = div().flex().flex_col().gap(px(8.)).text_size(px(13.5)).line_height(px(21.)).text_color(t.muted);
|
||||
for block in blocks {
|
||||
column = column.child(match block {
|
||||
Block::Heading(level, text, spans) => div()
|
||||
.pt(px(if level <= 2 { 12. } else { 6. }))
|
||||
.text_size(px(if level <= 2 { 14.5 } else { 13.5 }))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.text_color(t.text)
|
||||
.child(styled(text, spans))
|
||||
.into_any_element(),
|
||||
Block::Paragraph(text, spans) => div().child(styled(text, spans)).into_any_element(),
|
||||
Block::Item(depth, number, text, spans) => div()
|
||||
.flex()
|
||||
.gap(px(8.))
|
||||
.pl(px(depth as f32 * 16.))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.w(px(16.))
|
||||
.text_color(t.faint)
|
||||
.child(number.map(|n| format!("{n}.")).unwrap_or_else(|| "–".into())),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(styled(text, spans)))
|
||||
.into_any_element(),
|
||||
Block::Code(code) => div()
|
||||
.p(px(12.))
|
||||
.rounded(px(6.))
|
||||
.border_1()
|
||||
.border_color(t.border)
|
||||
.bg(t.raised)
|
||||
.font_family(crate::theme::MONO)
|
||||
.text_size(px(12.))
|
||||
.line_height(px(18.))
|
||||
.text_color(t.text)
|
||||
.child(code.trim_end().to_string())
|
||||
.into_any_element(),
|
||||
Block::Rule => div().h(px(1.)).bg(t.border).my(px(8.)).into_any_element(),
|
||||
});
|
||||
}
|
||||
column.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! One neutral palette in two appearances and one accent. Spacing follows 4/8/12/16/24/32.
|
||||
use gpui::{rgb, Hsla, WindowAppearance};
|
||||
|
||||
pub const FONT: &str = "Inter";
|
||||
#[cfg(target_os = "macos")]
|
||||
pub const MONO: &str = "Menlo";
|
||||
#[cfg(windows)]
|
||||
pub const MONO: &str = "Consolas";
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
pub const MONO: &str = "DejaVu Sans Mono";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Theme {
|
||||
pub bg: Hsla,
|
||||
pub sidebar: Hsla,
|
||||
pub raised: Hsla,
|
||||
pub hover: Hsla,
|
||||
pub border: Hsla,
|
||||
pub text: Hsla,
|
||||
pub muted: Hsla,
|
||||
pub faint: Hsla,
|
||||
pub accent: Hsla,
|
||||
pub on_accent: Hsla,
|
||||
pub danger: Hsla,
|
||||
}
|
||||
|
||||
fn c(hex: u32) -> Hsla {
|
||||
rgb(hex).into()
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn for_appearance(appearance: WindowAppearance) -> Self {
|
||||
match appearance {
|
||||
WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(),
|
||||
_ => Self::dark(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dark() -> Self {
|
||||
Self {
|
||||
bg: c(0x111113),
|
||||
sidebar: c(0x0c0c0d),
|
||||
raised: c(0x1a1a1d),
|
||||
hover: c(0x202024),
|
||||
border: c(0x26262a),
|
||||
text: c(0xe8e8ea),
|
||||
muted: c(0x9a9aa1),
|
||||
faint: c(0x626269),
|
||||
accent: c(0x4c9a64),
|
||||
on_accent: c(0xffffff),
|
||||
danger: c(0xd9705f),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn light() -> Self {
|
||||
Self {
|
||||
bg: c(0xfcfcfb),
|
||||
sidebar: c(0xf4f4f2),
|
||||
raised: c(0xf2f2f0),
|
||||
hover: c(0xebebe8),
|
||||
border: c(0xe3e3df),
|
||||
text: c(0x19191b),
|
||||
muted: c(0x63636a),
|
||||
faint: c(0x9a9a9f),
|
||||
accent: c(0x3b8753),
|
||||
on_accent: c(0xffffff),
|
||||
danger: c(0xb84a3a),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user