mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #4490 from horizon-rust/feat/launcher-night-mods
🤖 Agent: Launcher redesign R7b (the instance's Mods tab), approved by Ludovic after the screenshots. main merged in; full pr_ready green, verify and launcher CI green on this head. The only comment is the overlap bot's note on #4478 (different change; not landed).
This commit is contained in:
13 files changed
+540
-25
No files matched your search
@@ -267,6 +267,12 @@ Light); `tokens.css` carries them as `--night-*`. The game's menus don't use it.
|
||||
A tab names how many it holds ("Worlds 3"); its own buttons (Add server, Open folder) sit at the
|
||||
strip's end, where a list's header would put them (`HORIZON_LAUNCHER_PAGE=worlds`, `packs`...
|
||||
opens one for screenshots).
|
||||
- **The Mods tab** (`launcher/src/instance_mods.rs`): the instance's mods on one nearly opaque
|
||||
card, its loader in the header ("Fabric mods", "Horizon mods") beside a filter. A row is a
|
||||
coloured square with the mod's initial, its name and version, where it comes from (Modrinth,
|
||||
"needed by another mod", or a local file by name), an update as an amber pill when the browser
|
||||
found one, and a switch (a Java jar turned off is renamed `.disabled`). Update all and Add mods
|
||||
sit at the strip's end (`HORIZON_LAUNCHER_PAGE=instance-mods`).
|
||||
- **The sign-in sheet** (`launcher/src/owner_page.rs`, the owner gate): an opaque plate over the
|
||||
dimmed launcher, never instead of it. It says why (the account that owns Java Edition, no install
|
||||
needed), the code in mono on a raised box with Copy, and Open link as the main action; the window
|
||||
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Mehrspieler",
|
||||
"Packs": "Pakete",
|
||||
"Screenshots": "Screenshots",
|
||||
"Console": "Konsole"
|
||||
"Console": "Konsole",
|
||||
"Update all ({count})": "Alle aktualisieren ({count})",
|
||||
"Add mods": "Mods hinzufügen",
|
||||
"{loader} mods": "{loader}-Mods",
|
||||
"Horizon mods": "Horizon-Mods",
|
||||
"Filter installed": "Installierte filtern",
|
||||
"Reading the mods…": "Mods werden gelesen…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Noch keine Mods: „Mods hinzufügen“ findet welche für diese Instanz.",
|
||||
"No mod matches.": "Kein Mod passt.",
|
||||
"{source} · needed by another mod": "{source} · von einem anderen Mod benötigt",
|
||||
"Local file · {file}": "Lokale Datei · {file}",
|
||||
"Update {version}": "Update {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Multijugador",
|
||||
"Packs": "Paquetes",
|
||||
"Screenshots": "Capturas",
|
||||
"Console": "Consola"
|
||||
"Console": "Consola",
|
||||
"Update all ({count})": "Actualizar todo ({count})",
|
||||
"Add mods": "Añadir mods",
|
||||
"{loader} mods": "Mods de {loader}",
|
||||
"Horizon mods": "Mods de Horizon",
|
||||
"Filter installed": "Filtrar instalados",
|
||||
"Reading the mods…": "Leyendo los mods…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Aún no hay mods: Añadir mods encuentra algunos para esta instancia.",
|
||||
"No mod matches.": "Ningún mod coincide.",
|
||||
"{source} · needed by another mod": "{source} · lo necesita otro mod",
|
||||
"Local file · {file}": "Archivo local · {file}",
|
||||
"Update {version}": "Actualizar a {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Multijoueur",
|
||||
"Packs": "Packs",
|
||||
"Screenshots": "Captures d'écran",
|
||||
"Console": "Console"
|
||||
"Console": "Console",
|
||||
"Update all ({count})": "Tout mettre à jour ({count})",
|
||||
"Add mods": "Ajouter des mods",
|
||||
"{loader} mods": "Mods {loader}",
|
||||
"Horizon mods": "Mods Horizon",
|
||||
"Filter installed": "Filtrer les mods installés",
|
||||
"Reading the mods…": "Lecture des mods…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Pas encore de mods : Ajouter des mods en trouve pour cette instance.",
|
||||
"No mod matches.": "Aucun mod ne correspond.",
|
||||
"{source} · needed by another mod": "{source} · requis par un autre mod",
|
||||
"Local file · {file}": "Fichier local · {file}",
|
||||
"Update {version}": "Mettre à jour : {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Gra wieloosobowa",
|
||||
"Packs": "Paczki",
|
||||
"Screenshots": "Zrzuty ekranu",
|
||||
"Console": "Konsola"
|
||||
"Console": "Konsola",
|
||||
"Update all ({count})": "Zaktualizuj wszystko ({count})",
|
||||
"Add mods": "Dodaj mody",
|
||||
"{loader} mods": "Mody {loader}",
|
||||
"Horizon mods": "Mody Horizon",
|
||||
"Filter installed": "Filtruj zainstalowane",
|
||||
"Reading the mods…": "Odczytywanie modów…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Brak modów: Dodaj mody znajdzie jakieś dla tej instancji.",
|
||||
"No mod matches.": "Żaden mod nie pasuje.",
|
||||
"{source} · needed by another mod": "{source} · wymagany przez inny mod",
|
||||
"Local file · {file}": "Plik lokalny · {file}",
|
||||
"Update {version}": "Aktualizuj do {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Сетевая игра",
|
||||
"Packs": "Наборы",
|
||||
"Screenshots": "Скриншоты",
|
||||
"Console": "Консоль"
|
||||
"Console": "Консоль",
|
||||
"Update all ({count})": "Обновить все ({count})",
|
||||
"Add mods": "Добавить моды",
|
||||
"{loader} mods": "Моды {loader}",
|
||||
"Horizon mods": "Моды Horizon",
|
||||
"Filter installed": "Фильтр установленных",
|
||||
"Reading the mods…": "Чтение модов…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Модов пока нет: «Добавить моды» найдёт их для этой сборки.",
|
||||
"No mod matches.": "Нет подходящих модов.",
|
||||
"{source} · needed by another mod": "{source} · нужен другому моду",
|
||||
"Local file · {file}": "Локальный файл · {file}",
|
||||
"Update {version}": "Обновить до {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "Мережева гра",
|
||||
"Packs": "Набори",
|
||||
"Screenshots": "Знімки екрана",
|
||||
"Console": "Консоль"
|
||||
"Console": "Консоль",
|
||||
"Update all ({count})": "Оновити все ({count})",
|
||||
"Add mods": "Додати моди",
|
||||
"{loader} mods": "Моди {loader}",
|
||||
"Horizon mods": "Моди Horizon",
|
||||
"Filter installed": "Фільтр встановлених",
|
||||
"Reading the mods…": "Читання модів…",
|
||||
"No mods yet: Add mods finds some for this instance.": "Модів поки немає: «Додати моди» знайде їх для цієї збірки.",
|
||||
"No mod matches.": "Немає відповідних модів.",
|
||||
"{source} · needed by another mod": "{source} · потрібен іншому моду",
|
||||
"Local file · {file}": "Локальний файл · {file}",
|
||||
"Update {version}": "Оновити до {version}"
|
||||
}
|
||||
+12
-1
@@ -223,5 +223,16 @@
|
||||
"Multiplayer": "多人游戏",
|
||||
"Packs": "资源包",
|
||||
"Screenshots": "截图",
|
||||
"Console": "控制台"
|
||||
"Console": "控制台",
|
||||
"Update all ({count})": "全部更新({count})",
|
||||
"Add mods": "添加模组",
|
||||
"{loader} mods": "{loader} 模组",
|
||||
"Horizon mods": "Horizon 模组",
|
||||
"Filter installed": "筛选已安装",
|
||||
"Reading the mods…": "正在读取模组…",
|
||||
"No mods yet: Add mods finds some for this instance.": "还没有模组:点击“添加模组”为此实例查找。",
|
||||
"No mod matches.": "没有匹配的模组。",
|
||||
"{source} · needed by another mod": "{source} · 其他模组需要",
|
||||
"Local file · {file}": "本地文件 · {file}",
|
||||
"Update {version}": "更新到 {version}"
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::*;
|
||||
pub(super) enum HomeTab {
|
||||
#[default]
|
||||
Overview,
|
||||
Mods,
|
||||
Worlds,
|
||||
Multiplayer,
|
||||
Packs,
|
||||
@@ -15,10 +16,11 @@ pub(super) enum HomeTab {
|
||||
}
|
||||
|
||||
impl HomeTab {
|
||||
/// `HORIZON_LAUNCHER_PAGE=worlds` (or `multiplayer`, `packs`, `screenshots`, `console`) opens
|
||||
/// Home on that tab, for screenshots.
|
||||
/// `HORIZON_LAUNCHER_PAGE=worlds` (or `instance-mods`, `multiplayer`, `packs`, `screenshots`,
|
||||
/// `console`) opens Home on that tab, for screenshots.
|
||||
pub(super) fn from_env() -> Self {
|
||||
match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() {
|
||||
Ok("instance-mods") => HomeTab::Mods,
|
||||
Ok("worlds") => HomeTab::Worlds,
|
||||
Ok("multiplayer") => HomeTab::Multiplayer,
|
||||
Ok("packs") => HomeTab::Packs,
|
||||
@@ -37,6 +39,9 @@ impl Launcher {
|
||||
if self.selected_instance().is_none() {
|
||||
return tabs;
|
||||
}
|
||||
if self.has_mods_tab() {
|
||||
tabs.push((HomeTab::Mods, self.instance_mods_count(cx)));
|
||||
}
|
||||
tabs.push((HomeTab::Worlds, self.worlds_count(cx)));
|
||||
tabs.push((HomeTab::Multiplayer, Some(self.servers_count())));
|
||||
tabs.push((HomeTab::Packs, Some(self.packs_count())));
|
||||
@@ -67,6 +72,7 @@ impl Launcher {
|
||||
.children(tabs.into_iter().map(|(tab, count)| {
|
||||
let name = match tab {
|
||||
HomeTab::Overview => tr("Overview"),
|
||||
HomeTab::Mods => tr("Mods"),
|
||||
HomeTab::Worlds => tr("Worlds"),
|
||||
HomeTab::Multiplayer => tr("Multiplayer"),
|
||||
HomeTab::Packs => tr("Packs"),
|
||||
@@ -87,6 +93,10 @@ impl Launcher {
|
||||
// The open tab's own buttons sit at the strip's end, as a list's header would.
|
||||
let (actions, body) = match open {
|
||||
HomeTab::Overview => (None, Some(overview.into_any_element())),
|
||||
HomeTab::Mods => {
|
||||
let (actions, body) = split(self.instance_mods_tab(cx));
|
||||
(Some(actions), Some(body))
|
||||
}
|
||||
HomeTab::Worlds => (
|
||||
None,
|
||||
self.worlds_section(cx).map(IntoElement::into_any_element),
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Home's Mods tab (docs/design-system.md, "Night"; plan R0, screens 5 and 6): every mod of the
|
||||
//! selected instance, the browser's and those put in its folder by hand, with its version, where
|
||||
//! it comes from, an update when the browser found one, and a switch. A Java instance turns a mod
|
||||
//! off by renaming its jar (`.disabled`, as `java_instance` does); a Horizon instance's mods are
|
||||
//! the registry's, switched as the game's Options > Mods does. Adding mods is the Mods page's.
|
||||
use super::*;
|
||||
use crate::plate::alpha;
|
||||
use crate::shell::{c, Action};
|
||||
use horizon_launcher::browser::{horizon, installed, java, Kind as PackKind, SourceId};
|
||||
use servers_page::Field;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theme::night;
|
||||
|
||||
/// A turned-off jar's suffix, which the loaders don't read.
|
||||
const OFF: &str = ".disabled";
|
||||
|
||||
/// A mod as the tab lists it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(super) struct ModRow {
|
||||
/// Its file in the instance, as it is when on (`mods/lumen-0.6.2.jar`).
|
||||
file: String,
|
||||
name: String,
|
||||
version: String,
|
||||
on: bool,
|
||||
/// What the browser knows of it; None: a file put in by hand.
|
||||
entry: Option<installed::Entry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct InstanceMods {
|
||||
home: Option<PathBuf>,
|
||||
fresh: bool,
|
||||
reading: bool,
|
||||
list: Vec<ModRow>,
|
||||
/// The browser was installing at the last draw: its end changes the list.
|
||||
was_busy: bool,
|
||||
}
|
||||
|
||||
/// The mods of the instance at `home`: a Java one's jars in `mods/` (on or off), a Horizon one's
|
||||
/// registry mods. Each jar names itself (`fabric.mod.json`, `quilt.mod.json`...), else its file.
|
||||
fn read(home: &Path, java_instance: bool) -> Vec<ModRow> {
|
||||
let entries: Vec<installed::Entry> = installed::Installed::load(home)
|
||||
.entries
|
||||
.into_iter()
|
||||
.filter(|e| e.kind == PackKind::Mod)
|
||||
.collect();
|
||||
let mut rows: Vec<ModRow> = if java_instance {
|
||||
let Ok(dir) = std::fs::read_dir(home.join("mods")) else {
|
||||
return Vec::new();
|
||||
};
|
||||
dir.flatten()
|
||||
.filter_map(|f| {
|
||||
let name = f.file_name().to_string_lossy().into_owned();
|
||||
let on = name.ends_with(".jar");
|
||||
let jar = name.strip_suffix(OFF).unwrap_or(&name);
|
||||
(jar.ends_with(".jar") && f.path().is_file()).then(|| {
|
||||
let file = format!("mods/{jar}");
|
||||
let entry = entries.iter().find(|e| e.file == file).cloned();
|
||||
let (name, version) = match &entry {
|
||||
Some(e) => (e.title.clone(), e.number.clone()),
|
||||
None => horizon_port_pack::jar::read(&f.path())
|
||||
.map(|j| (j.name, j.version))
|
||||
.unwrap_or_else(|_| {
|
||||
(jar.trim_end_matches(".jar").into(), String::new())
|
||||
}),
|
||||
};
|
||||
ModRow {
|
||||
file,
|
||||
name,
|
||||
version,
|
||||
on,
|
||||
entry,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|e| ModRow {
|
||||
file: e.file.clone(),
|
||||
name: e.title.clone(),
|
||||
version: e.number.clone(),
|
||||
on: horizon::mod_id(&e).is_some_and(|id| java::is_enabled(home, id)),
|
||||
entry: Some(e),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
rows.sort_by_key(|r| r.name.to_lowercase());
|
||||
rows
|
||||
}
|
||||
|
||||
/// Turns a Java instance's jar on or off by renaming it.
|
||||
fn switch_jar(home: &Path, file: &str, on: bool) -> Result<(), String> {
|
||||
let (jar, off) = (home.join(file), home.join(format!("{file}{OFF}")));
|
||||
let (from, to) = if on { (off, jar) } else { (jar, off) };
|
||||
std::fs::rename(&from, &to).map_err(|e| format!("{}: {e}", from.display()))
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
/// Whether the selected instance has mods to show: Horizon's always, a Java one with a loader.
|
||||
pub(super) fn has_mods_tab(&self) -> bool {
|
||||
self.selected_instance()
|
||||
.is_some_and(|i| i.java.as_ref().is_none_or(|j| j.loader.is_some()))
|
||||
}
|
||||
|
||||
/// Reads the selected instance's mods off the UI thread, once until something changes them.
|
||||
fn load_instance_mods(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(i) = self.selected_instance() else {
|
||||
return;
|
||||
};
|
||||
let (home, java_instance) = (i.home.clone(), i.java.is_some());
|
||||
let m = &mut self.instance_mods;
|
||||
if m.home.as_ref() != Some(&home) {
|
||||
*m = InstanceMods {
|
||||
home: Some(home.clone()),
|
||||
..InstanceMods::default()
|
||||
};
|
||||
}
|
||||
let busy = self.mods.busy.is_some();
|
||||
if m.was_busy && !busy {
|
||||
m.fresh = false;
|
||||
}
|
||||
m.was_busy = busy;
|
||||
if m.fresh || m.reading {
|
||||
return;
|
||||
}
|
||||
m.reading = true;
|
||||
let task = cx
|
||||
.background_executor()
|
||||
.spawn(async move { (home.clone(), read(&home, java_instance)) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let (home, list) = task.await;
|
||||
let _ = this.update(cx, |this, cx| {
|
||||
let m = &mut this.instance_mods;
|
||||
m.reading = false;
|
||||
if m.home.as_ref() == Some(&home) {
|
||||
m.list = list;
|
||||
m.fresh = true;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// How many mods the selected instance has; None while they are read.
|
||||
pub(super) fn instance_mods_count(&mut self, cx: &mut Context<Self>) -> Option<usize> {
|
||||
self.mods_load();
|
||||
self.load_instance_mods(cx);
|
||||
let m = &self.instance_mods;
|
||||
m.fresh.then_some(m.list.len())
|
||||
}
|
||||
|
||||
fn switch_mod(&mut self, row: &ModRow) {
|
||||
let home = self.instance_mods.home.clone().unwrap_or_default();
|
||||
let java_instance = self.selected_instance().is_some_and(|i| i.java.is_some());
|
||||
match &row.entry {
|
||||
Some(e) if !java_instance => self.mods_switch(e),
|
||||
_ => {
|
||||
self.mods.message = switch_jar(&home, &row.file, !row.on).err().map(Err);
|
||||
}
|
||||
}
|
||||
self.instance_mods.fresh = false;
|
||||
}
|
||||
|
||||
/// The Mods tab: its buttons (beside the tabs) and the list.
|
||||
pub(super) fn instance_mods_tab(&mut self, cx: &mut Context<Self>) -> (Div, Div) {
|
||||
let t = self.theme;
|
||||
let n = t.night;
|
||||
let busy = self.mods.busy.is_some();
|
||||
let updates = self.mods.updates.len();
|
||||
let actions = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(8.))
|
||||
.children((updates > 0 && !busy).then(|| {
|
||||
shell::action("instance-mods-update-all", Action::Raised, &t)
|
||||
.child(shell::label(trf(
|
||||
"Update all ({count})",
|
||||
&[("count", &updates.to_string())],
|
||||
)))
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.mods_update_all();
|
||||
cx.notify();
|
||||
}))
|
||||
}))
|
||||
.child(
|
||||
shell::action("instance-mods-add", Action::Raised, &t)
|
||||
.child(shell::label(tr("Add mods").to_string()))
|
||||
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
|
||||
this.go(Page::Mods);
|
||||
cx.notify();
|
||||
})),
|
||||
);
|
||||
let loader = self
|
||||
.selected_instance()
|
||||
.and_then(|i| i.java.as_ref())
|
||||
.map(|j| j.loader.as_ref().map(|l| l.kind.name()));
|
||||
let title = match loader {
|
||||
Some(Some(loader)) => trf("{loader} mods", &[("loader", loader)]),
|
||||
_ => tr("Horizon mods").to_string(),
|
||||
};
|
||||
let filter = self
|
||||
.servers
|
||||
.value(Field::InstanceModsFilter)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
let field = self.field(Field::InstanceModsFilter, "Filter installed", 240., cx);
|
||||
let m = &self.instance_mods;
|
||||
let rows: Vec<ModRow> = m
|
||||
.list
|
||||
.iter()
|
||||
.filter(|r| filter.is_empty() || r.name.to_lowercase().contains(&filter))
|
||||
.cloned()
|
||||
.collect();
|
||||
let header = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap(px(16.))
|
||||
.px(px(18.))
|
||||
.py(px(14.))
|
||||
.child(
|
||||
shell::text(night::UI, c(n.ink))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(title),
|
||||
)
|
||||
.child(field);
|
||||
let empty = |s: &str| {
|
||||
shell::text(night::UI, c(n.muted))
|
||||
.px(px(18.))
|
||||
.py(px(16.))
|
||||
.child(tr(s).to_string())
|
||||
};
|
||||
let body: Vec<AnyElement> = if !m.fresh {
|
||||
vec![empty("Reading the mods…").into_any_element()]
|
||||
} else if m.list.is_empty() {
|
||||
vec![empty("No mods yet: Add mods finds some for this instance.").into_any_element()]
|
||||
} else if rows.is_empty() {
|
||||
vec![empty("No mod matches.").into_any_element()]
|
||||
} else {
|
||||
rows.into_iter()
|
||||
.map(|row| self.mod_row(row, busy, cx).into_any_element())
|
||||
.collect()
|
||||
};
|
||||
let card = plate::cut(
|
||||
night::PIXEL_CORNER,
|
||||
2,
|
||||
// Nearly opaque: a list reads over the sunset.
|
||||
Some(alpha(n.card, 0.94)),
|
||||
Some((alpha(n.stroke, n.stroke_alpha), 1.)),
|
||||
)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(header)
|
||||
.children(body.into_iter().map(|row| {
|
||||
div()
|
||||
.border_t_1()
|
||||
.border_color(alpha(n.stroke, n.rule_alpha))
|
||||
.child(row)
|
||||
}));
|
||||
(
|
||||
actions,
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(12.))
|
||||
.children(self.mods_busy_plate())
|
||||
.child(card),
|
||||
)
|
||||
}
|
||||
|
||||
fn mod_row(&self, row: ModRow, busy: bool, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
let n = t.night;
|
||||
// Each mod its own colour, the same every time: a hue from its name.
|
||||
let hue = row
|
||||
.name
|
||||
.bytes()
|
||||
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(b as u32));
|
||||
let icon = div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(px(40.))
|
||||
.rounded(px(night::RADIUS_CONTROL))
|
||||
.bg(gpui::hsla((hue % 360) as f32 / 360., 0.42, 0.52, 1.))
|
||||
.child(
|
||||
shell::text(night::TITLE, c(n.on_accent))
|
||||
.font_weight(FontWeight::BOLD)
|
||||
.child(
|
||||
row.name
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
let source = match &row.entry {
|
||||
Some(e) if e.dependency => trf(
|
||||
"{source} · needed by another mod",
|
||||
&[("source", e.source.label())],
|
||||
),
|
||||
Some(e) => e.source.label().to_string(),
|
||||
None => trf(
|
||||
"Local file · {file}",
|
||||
&[("file", row.file.trim_start_matches("mods/"))],
|
||||
),
|
||||
};
|
||||
let update = row.entry.as_ref().and_then(|e| {
|
||||
let key = (e.source, e.project.clone());
|
||||
let number = self.mods.updates.get(&key)?.clone();
|
||||
let (source, id): (SourceId, String) = key;
|
||||
let chip = div()
|
||||
.id(SharedString::from(format!("mod-update-{id}")))
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.h(px(24.))
|
||||
.px(px(10.))
|
||||
.rounded(px(night::RADIUS_CONTROL))
|
||||
.bg(alpha(n.warn, 0.16))
|
||||
.cursor_pointer()
|
||||
.hover(move |s| s.bg(alpha(n.warn, 0.26)))
|
||||
.child(
|
||||
shell::text(night::CAPTION, c(n.warn))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(trf("Update {version}", &[("version", &number)])),
|
||||
);
|
||||
Some(match busy {
|
||||
true => chip.opacity(theme::DISABLED),
|
||||
false => chip.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
|
||||
this.mods_install(source, id.clone(), None);
|
||||
cx.notify();
|
||||
})),
|
||||
})
|
||||
});
|
||||
let switch_row = row.clone();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap(px(14.))
|
||||
.px(px(18.))
|
||||
.py(px(12.))
|
||||
.child(icon)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(2.))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_baseline()
|
||||
.gap(px(6.))
|
||||
.child(
|
||||
shell::text(night::UI, c(n.ink))
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(row.name.clone()),
|
||||
)
|
||||
.child(shell::text(night::CAPTION, c(n.muted)).child(row.version)),
|
||||
)
|
||||
.child(
|
||||
shell::text(night::CAPTION, c(n.muted))
|
||||
.truncate()
|
||||
.child(source),
|
||||
),
|
||||
)
|
||||
.children(update)
|
||||
.child(
|
||||
switch(format!("mod-switch-{}", row.file), row.on, &t).on_click(cx.listener(
|
||||
move |this, _: &ClickEvent, _, cx| {
|
||||
this.switch_mod(&switch_row);
|
||||
cx.notify();
|
||||
},
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_java_instance_lists_its_jars_on_and_off() {
|
||||
let home = std::env::temp_dir().join(format!("instance-mods-{}", std::process::id()));
|
||||
let mods = home.join("mods");
|
||||
std::fs::create_dir_all(&mods).unwrap();
|
||||
std::fs::write(mods.join("zeta-1.0.jar"), b"not a zip").unwrap();
|
||||
std::fs::write(mods.join("alpha-2.0.jar.disabled"), b"not a zip").unwrap();
|
||||
std::fs::write(mods.join("notes.txt"), b"").unwrap();
|
||||
let rows = read(&home, true);
|
||||
let seen: Vec<_> = rows.iter().map(|r| (r.name.as_str(), r.on)).collect();
|
||||
assert_eq!(seen, [("alpha-2.0", false), ("zeta-1.0", true)]);
|
||||
switch_jar(&home, "mods/alpha-2.0.jar", true).unwrap();
|
||||
assert!(read(&home, true).iter().all(|r| r.on));
|
||||
let _ = std::fs::remove_dir_all(&home);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ mod game_tools;
|
||||
mod home;
|
||||
mod home_tabs;
|
||||
mod import_page;
|
||||
mod instance_mods;
|
||||
mod instances_page;
|
||||
mod internet;
|
||||
mod language_page;
|
||||
@@ -276,6 +277,8 @@ struct Launcher {
|
||||
download_seen: Option<(Instant, usize)>,
|
||||
/// The tab open under Home's hero (`home_tabs.rs`).
|
||||
home_tab: home_tabs::HomeTab,
|
||||
/// The selected instance's mods, for Home's Mods tab (`instance_mods.rs`).
|
||||
instance_mods: instance_mods::InstanceMods,
|
||||
play_pressed: Option<Instant>,
|
||||
/// `horizon-launcher benchmark` started this window to measure it.
|
||||
bench: Option<perf::Bench>,
|
||||
@@ -480,6 +483,7 @@ impl Launcher {
|
||||
prepare_started: None,
|
||||
download_seen: None,
|
||||
home_tab: home_tabs::HomeTab::from_env(),
|
||||
instance_mods: Default::default(),
|
||||
play_pressed: None,
|
||||
bench: perf::Bench::from_env(),
|
||||
packs: Vec::new(),
|
||||
|
||||
+24
-15
@@ -73,8 +73,10 @@ pub(super) struct Mods {
|
||||
pub(super) report_list: cull::Cull,
|
||||
more: bool,
|
||||
error: Option<String>,
|
||||
installed: Vec<installed::Entry>,
|
||||
pub(super) installed: Vec<installed::Entry>,
|
||||
loaded: bool,
|
||||
/// Whether the page searched for the instance as it is (its version and loader).
|
||||
asked: bool,
|
||||
/// The game folder the browser installs into and lists: the selected instance's.
|
||||
home: Option<PathBuf>,
|
||||
/// The selected Java instance's version and loader: its mods and resource packs are the
|
||||
@@ -85,8 +87,8 @@ pub(super) struct Mods {
|
||||
world: Option<PathBuf>,
|
||||
/// The project being installed, and the line and fraction of its download.
|
||||
pub(super) busy: Option<(String, String, f32)>,
|
||||
message: Option<Result<String, String>>,
|
||||
updates: HashMap<(SourceId, String), String>,
|
||||
pub(super) message: Option<Result<String, String>>,
|
||||
pub(super) updates: HashMap<(SourceId, String), String>,
|
||||
confirm_remove: Option<String>,
|
||||
/// A native mod's Install waiting for the one-time notice (`native_mods::needs_notice`).
|
||||
native_notice: Option<(SourceId, String, Option<Project>)>,
|
||||
@@ -166,6 +168,7 @@ impl Mods {
|
||||
error: None,
|
||||
installed: Vec::new(),
|
||||
loaded: false,
|
||||
asked: false,
|
||||
home: None,
|
||||
java: None,
|
||||
worlds: Vec::new(),
|
||||
@@ -465,7 +468,7 @@ impl Launcher {
|
||||
|
||||
/// Turns a Horizon mod the browser installed on or off, as the game's Options > Mods does;
|
||||
/// in a Java instance, renames its file.
|
||||
fn mods_switch(&mut self, e: &installed::Entry) {
|
||||
pub(super) fn mods_switch(&mut self, e: &installed::Entry) {
|
||||
let home = self.mods.home();
|
||||
if self.mods.java.is_some() {
|
||||
let on = !java_instance::is_on(&home, e);
|
||||
@@ -580,7 +583,7 @@ impl Launcher {
|
||||
|
||||
/// Updates every installed pack a newer version was found for, one after the other on a
|
||||
/// thread, each checked as an install is. One failing doesn't stop the others.
|
||||
fn mods_update_all(&mut self) {
|
||||
pub(super) fn mods_update_all(&mut self) {
|
||||
if self.mods.busy.is_some() || self.mods.updates.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -827,18 +830,18 @@ impl Launcher {
|
||||
line_edit::mouse(field, cx, |this| &mut this.mods.text)
|
||||
}
|
||||
|
||||
pub(super) fn mods_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
// Packs and mods go into the selected instance: picking another reads its list.
|
||||
/// Reads what the browser installed in the selected instance (once per instance) and looks
|
||||
/// for updates: the Mods page and Home's Mods tab (`instance_mods.rs`) show it. Packs and mods
|
||||
/// go into the selected instance: picking another reads its list.
|
||||
pub(super) fn mods_load(&mut self) {
|
||||
let home = self.selected_instance().map(|i| i.home.clone());
|
||||
let java = self
|
||||
.selected_instance()
|
||||
.and_then(|i| i.java.as_ref())
|
||||
.map(|j| j.target());
|
||||
// Another version or loader: other results.
|
||||
if self.mods.loaded && java != self.mods.java {
|
||||
self.mods.java = java.clone();
|
||||
self.mods_search(0);
|
||||
// Another version or loader: other results, on the page's next draw.
|
||||
if java != self.mods.java {
|
||||
self.mods.asked = false;
|
||||
}
|
||||
self.mods.java = java;
|
||||
if !self.mods.loaded || (home.is_some() && self.mods.home != home) {
|
||||
@@ -850,11 +853,17 @@ impl Launcher {
|
||||
self.mods.read_worlds();
|
||||
self.mods_check_updates();
|
||||
self.mods_taken_down();
|
||||
if !self.mods.loaded {
|
||||
self.mods_search(0);
|
||||
}
|
||||
self.mods.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mods_page(&mut self, cx: &mut Context<Self>) -> Div {
|
||||
let t = self.theme;
|
||||
self.mods_load();
|
||||
if !self.mods.asked {
|
||||
self.mods.asked = true;
|
||||
self.mods_search(0);
|
||||
}
|
||||
let header = page_header(
|
||||
"Mods",
|
||||
"Mods and shader packs made for Horizon from its own reviewed registry, and Java \
|
||||
|
||||
@@ -38,6 +38,8 @@ pub(super) enum Field {
|
||||
InstanceGroup,
|
||||
/// Its search in Mojang's versions (`new_instance.rs`).
|
||||
InstanceVersion,
|
||||
/// Home's Mods tab's filter (`instance_mods.rs`).
|
||||
InstanceModsFilter,
|
||||
/// Home's Multiplayer section's (`multiplayer_page.rs`).
|
||||
ServerName,
|
||||
ServerAddress,
|
||||
@@ -54,7 +56,7 @@ pub(super) enum Field {
|
||||
}
|
||||
|
||||
impl Field {
|
||||
const ALL: [Field; 21] = [
|
||||
const ALL: [Field; 22] = [
|
||||
Field::Name,
|
||||
Field::Seed,
|
||||
Field::Players,
|
||||
@@ -67,6 +69,7 @@ impl Field {
|
||||
Field::InstanceName,
|
||||
Field::InstanceGroup,
|
||||
Field::InstanceVersion,
|
||||
Field::InstanceModsFilter,
|
||||
Field::ServerName,
|
||||
Field::ServerAddress,
|
||||
Field::ConsoleSearch,
|
||||
@@ -999,6 +1002,7 @@ impl Launcher {
|
||||
Field::InstanceName
|
||||
| Field::InstanceGroup
|
||||
| Field::InstanceVersion
|
||||
| Field::InstanceModsFilter
|
||||
| Field::ConsoleSearch
|
||||
| Field::DeleteServer => {}
|
||||
Field::PreLaunch | Field::Wrapper | Field::LaunchEnv | Field::PostExit => {
|
||||
|
||||
Reference in new issue
Block a user