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:
Ludovic MarieandClaude Opus 5.5 committed 2026-10-13 12:45:05 +02:00
1 parent b279705c8d
commit 57257ae231
13 files changed
+9183

No files matched your search

+1
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
target/
+7321
View File
File diff suppressed because it is too large. Load diff
+22
View File
@@ -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.
+92
View File
@@ -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.
+11
View File
@@ -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 ];
}
+353
View File
@@ -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());
}
}
+1158
View File
File diff suppressed because it is too large. Load diff
+154
View File
@@ -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()
}
+70
View File
@@ -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),
}
}
}