From 6ea7a51ff35983c2082cfa4e002136a461261845 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:05:29 +0200 Subject: [PATCH 1/2] Shader Packs page: the folder's path, Open Folder, a switch per pack The Shader Pack setting cycled through what shaderpacks/ held, so with the folder empty (or the packs in the launcher's version folder, as brabus_ had them) it silently stayed Off. The row now opens a Shader Packs page: a sidebar section in the Horizon menus, Video Settings > Shader Packs... in the Classic ones. It shows the game home's shaderpacks/ path and an Open Folder button (made if missing), says when there is no pack, and lists one switch per pack. A pack that fails to load says why on its row. The list is read again every second while the page shows. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/options.rs | 5 + .../horizon-client/src/game/shader_packs.rs | 8 +- .../horizon-client/src/game/startup_menu.rs | 5 + crates/horizon-client/src/graphics_ui.rs | 47 ++-- crates/horizon-client/src/options_horizon.rs | 167 ++++++++++++- crates/horizon-client/src/options_ui.rs | 219 +++++++++++++++++- crates/horizon-client/src/shader_pack.rs | 32 ++- docs/shader-packs.md | 20 +- 8 files changed, 462 insertions(+), 41 deletions(-) diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 12efde5be..61036d18c 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -230,6 +230,11 @@ impl Game<'_> { let _ = std::fs::create_dir_all(&dir); super::packs::open_folder(&dir); } + Event::OpenShaderPackFolder => { + let dir = horizon_client::shader_pack::folder(); + let _ = std::fs::create_dir_all(&dir); + super::packs::open_folder(&dir); + } } } Ok(()) diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index 6b9ee33dc..052f64d26 100644 --- a/crates/horizon-client/src/game/shader_packs.rs +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -37,11 +37,8 @@ impl ShaderPacks { } } -/// `shaderpacks/` beside `options.txt`. pub(super) fn dir() -> PathBuf { - std::env::current_dir() - .unwrap_or_default() - .join(shader_pack::DIR) + shader_pack::folder() } impl Game<'_> { @@ -77,6 +74,7 @@ impl Game<'_> { return self.shader_pack_error(&id, &e.to_string()); } self.shader_packs.pack = pack; + self.ui.options.shader_pack_error = None; } /// Give the loaded pack to the renderer (also after it is rebuilt). pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> { @@ -105,6 +103,8 @@ impl Game<'_> { } fn shader_pack_error(&mut self, id: &str, error: &str) { log::warn!("Horizon: shader pack {id}: {error}"); + // The Shader Packs page says why on the pack's row. + self.ui.options.shader_pack_error = Some((id.to_owned(), error.to_owned())); self.system_message(Text::literal(format!("Shader pack {id}: {error}"))); } } diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index c5b19763c..ffbbe7193 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -373,6 +373,11 @@ impl Loading { let _ = std::fs::create_dir_all(&dir); packs::open_folder(&dir); } + Event::OpenShaderPackFolder => { + let dir = horizon_client::shader_pack::folder(); + let _ = std::fs::create_dir_all(&dir); + packs::open_folder(&dir); + } Event::ButtonClick => {} } } diff --git a/crates/horizon-client/src/graphics_ui.rs b/crates/horizon-client/src/graphics_ui.rs index eea2d03cb..9722ddc21 100644 --- a/crates/horizon-client/src/graphics_ui.rs +++ b/crates/horizon-client/src/graphics_ui.rs @@ -237,7 +237,7 @@ pub fn rows(level: Level) -> Vec { h( H::ShaderPack, High, - "Redraws the world with a pack from shaderpacks/. Off is vanilla.", + "Redraws the world with a pack from the shaderpacks folder. Off is vanilla.", ), Row::Header("Preferences"), v(Opt::ViewBobbing, None, "Sways the view while walking."), @@ -422,17 +422,6 @@ impl H { H::Antialiasing => g.fxaa = !g.fxaa, H::BatteryMode => g.battery = !g.battery, H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back), - H::ShaderPack => { - // Off, then every pack in shaderpacks/ by folder name. - let dir = std::env::current_dir() - .unwrap_or_default() - .join(crate::shader_pack::DIR); - let mut all = vec![String::new()]; - all.extend(crate::shader_pack::list(&dir).into_iter().map(|(id, _)| id)); - let i = all.iter().position(|x| *x == g.shader_pack).unwrap_or(0); - let n = all.len(); - g.shader_pack = all[if back { (i + n - 1) % n } else { (i + 1) % n }].clone(); - } H::Pacing => { g.pacing = match g.pacing { Pacing::Efficient => Pacing::Precise, @@ -559,6 +548,8 @@ pub enum Action { Changed, OpenAdvanced, OpenSimple, + /// The Shader Pack row: the Shader Packs page (where packs are picked). + OpenShaderPacks, Done, } @@ -784,6 +775,9 @@ impl GraphicsUi { } return match wd.target { Target::Button(action) => vec![Action::ButtonClick, action], + Target::Item(Item::Horizon(H::ShaderPack)) => { + vec![Action::ButtonClick, Action::OpenShaderPacks] + } Target::Item(item) => { if item.slider(s).is_some() { self.dragging = Some(item); @@ -1003,11 +997,30 @@ mod tests { ); s.horizon.shader_pack = "horizon-sample".into(); assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample"); - // Picking a pack keeps the preset. - Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]); - let preset = s.horizon.preset; - row.cycle(&mut s, false, [854, 480]); - assert_eq!(s.horizon.preset, preset); + // The row opens the Shader Packs page, where packs are picked. + let mut ui = GraphicsUi::default(); + let find = |ui: &mut GraphicsUi| { + let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0); + widgets + .into_iter() + .find(|w| matches!(w.target, Target::Item(i) if i == row)) + .unwrap() + }; + ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0; + let w = find(&mut ui); + assert_eq!( + ui.click( + Level::Advanced, + &mut s, + (w.x + 1.0, w.y + 1.0), + (854.0, 480.0), + [854, 480], + false, + false + ), + [Action::ButtonClick, Action::OpenShaderPacks] + ); + assert_eq!(s.horizon.shader_pack, "horizon-sample"); } #[test] diff --git a/crates/horizon-client/src/options_horizon.rs b/crates/horizon-client/src/options_horizon.rs index 75458ae40..50a42f337 100644 --- a/crates/horizon-client/src/options_horizon.rs +++ b/crates/horizon-client/src/options_horizon.rs @@ -38,7 +38,14 @@ pub enum HRow { Mod(usize), /// Opens the directory where Horizon mods are installed. OpenModFolder, + /// `shaderpacks/`'s path and a button opening it. + ShaderPackFolder, + /// Shader pack `i` of `OptionsUi::shader_packs` and its switch. + ShaderPack(usize), } + +/// The sidebar section listing the shader packs. +pub const SHADER_PACKS: &str = "Shader Packs"; /// A sidebar section: name, description, rows. pub struct Section { pub name: &'static str, @@ -172,6 +179,12 @@ pub const SECTIONS: &[Section] = &[ .to_vec() }, }, + Section { + name: SHADER_PACKS, + blurb: "Packs that redraw the world. Off is the vanilla look.", + // The packs follow, from the folder (`horizon_rows`). + rows: |_| vec![HRow::ShaderPackFolder], + }, Section { name: "Interface", blurb: "Menus and resource packs.", @@ -309,7 +322,15 @@ fn horizon_batch(framebuffer: [i32; 2]) -> HudBatch { } impl OptionsUi { fn horizon_rows(&self, s: &Settings, section: usize) -> Vec { - let rows = (SECTIONS[section].rows)(s); + let mut rows = (SECTIONS[section].rows)(s); + if SECTIONS[section].name == SHADER_PACKS { + if self.shader_packs.packs.is_empty() { + rows.push(HRow::Header( + "No shader packs: put a pack's folder or .zip in this folder", + )); + } + rows.extend((0..self.shader_packs.packs.len()).map(HRow::ShaderPack)); + } let query = self.settings_search.value.to_lowercase(); if query.is_empty() || !searchable_section(section) { return rows; @@ -511,6 +532,38 @@ impl OptionsUi { ui.described_row(x, y, &installed.title, "", 0.0, cost, text, cw - 60.0); ui.switch(ROW + i as u64, control_x, y + ROW_H / 2.0, s.mods.is_on(m)); } + HRow::ShaderPackFolder => { + let label = "Open Folder"; + let bw = ui.button_width(label, false).max(96.0); + let dir = self.shader_packs.dir.display().to_string(); + ui.described_row(x, y, "Folder", "", 0.0, (0, ""), &dir, cw - bw - 24.0); + ui.button( + ROW + i as u64, + [control_x - bw, y + 12.0, bw, CONTROL], + label, + Button::Secondary, + true, + ); + } + HRow::ShaderPack(p) => { + let (id, info) = &self.shader_packs.packs[p]; + let error = match info { + Err(e) => Some(e.as_str()), + Ok(_) => self + .shader_pack_error + .as_ref() + .filter(|(failed, _)| failed == id) + .map(|(_, e)| e.as_str()), + }; + let (cost, text) = match (error, info) { + (Some(e), _) => ((3, "Failed"), e), + (None, Ok(info)) => ((0, ""), info.description.as_str()), + (None, Err(_)) => unreachable!(), + }; + ui.described_row(x, y, id, "", 0.0, cost, text, cw - 60.0); + let on = s.horizon.shader_pack == *id; + ui.switch(ROW + i as u64, control_x, y + ROW_H / 2.0, on); + } HRow::OpenModFolder => { ui.row(x, y, "Mod folder", "", 0.0); let label = "Open Folder"; @@ -756,13 +809,7 @@ impl OptionsUi { return events; } match target { - Some(Target::Nav(i)) => { - if searchable_section(self.section) || searchable_section(i) { - self.settings_search.set_value(""); - } - self.section = i; - self.hscroll = 0.0; - } + Some(Target::Nav(i)) => self.select_section(i), Some(Target::Search) => { self.settings_search.focused = true; self.settings_search.move_cursor_to_end(false); @@ -798,6 +845,10 @@ impl OptionsUi { } events.push(Event::Changed); } + Some(HRow::Graphics(Item::Horizon(graphics_ui::H::ShaderPack), ..)) => { + events.push(Event::ButtonClick); + self.open_section(SHADER_PACKS); + } Some(HRow::Graphics(item, ..)) => { item.cycle(s, right, framebuffer); events.push(Event::Changed); @@ -818,6 +869,14 @@ impl OptionsUi { events.push(Event::ButtonClick); events.push(Event::OpenModFolder); } + Some(HRow::ShaderPackFolder) if !right => { + events.push(Event::ButtonClick); + events.push(Event::OpenShaderPackFolder); + } + Some(HRow::ShaderPack(p)) => { + self.toggle_shader_pack(s, *p); + events.push(Event::Changed); + } _ => {} } } @@ -825,6 +884,19 @@ impl OptionsUi { } events } + fn select_section(&mut self, i: usize) { + if searchable_section(self.section) || searchable_section(i) { + self.settings_search.set_value(""); + } + self.section = i; + self.hscroll = 0.0; + } + /// Show the sidebar section called `name`. + fn open_section(&mut self, name: &str) { + if let Some(i) = SECTIONS.iter().position(|s| s.name == name) { + self.select_section(i); + } + } pub(crate) fn scroll_horizon(&mut self, amount: f32) { self.hscroll -= amount * 40.0; } @@ -1057,6 +1129,85 @@ mod tests { assert_eq!(ui.hscroll, 0.0); } + /// The Shader Packs section: the folder's path and Open Folder, a switch + /// per pack; the Video section's Shader Pack row leads here (#941, #925). + #[test] + fn shader_packs_section_opens_the_folder_and_picks_a_pack() { + let atlas = atlas(); + let framebuffer = [2560, 1440]; + let dir = + std::env::temp_dir().join(format!("horizon-shader-section-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut options = section_ui(SHADER_PACKS); + let mut settings = Settings::default(); + options.shader_packs.poll(dir.clone()); + assert!(matches!( + options.horizon_rows(&settings, options.section)[..], + [HRow::ShaderPackFolder, HRow::Header(_)] + )); + std::fs::write(dir.join("Pack.zip"), b"").unwrap(); + options.shader_packs = Default::default(); + options.shader_packs.poll(dir.clone()); + // Click the control with hit id `id`, scrolling until it shows. + let click = |options: &mut OptionsUi, settings: &mut Settings, id: u64| { + for _ in 0..40 { + let mut batch = horizon_batch(framebuffer); + let mut ui = Ui::dry(&atlas, &mut batch, (0.0, 0.0)); + options.horizon_layout(settings, &mut ui, 1.0); + if let Some(hit) = ui.hits.iter().find(|hit| hit.id == id) { + let r = hit.rect; + let scale = ui.density / ui.batch.scale; + let at = ((r[0] + r[2]) * 0.5 * scale, (r[1] + r[3]) * 0.5 * scale); + drop(ui); + return options.click_horizon( + settings, + &atlas, + at.0, + at.1, + framebuffer, + false, + false, + ); + } + drop(ui); + options.hscroll += 200.0; + } + panic!("no control {id}"); + }; + assert_eq!( + click(&mut options, &mut settings, ROW), + [Event::ButtonClick, Event::OpenShaderPackFolder] + ); + assert_eq!( + click(&mut options, &mut settings, ROW + 1), + [Event::Changed] + ); + assert_eq!(settings.horizon.shader_pack, "Pack.zip"); + click(&mut options, &mut settings, ROW + 1); + assert_eq!(settings.horizon.shader_pack, ""); + + let mut options = section_ui("Video"); + settings.horizon.advanced = true; + let video = SECTIONS.iter().position(|s| s.name == "Video").unwrap(); + let row = options + .horizon_rows(&settings, video) + .iter() + .position(|r| { + matches!( + r, + HRow::Graphics(Item::Horizon(graphics_ui::H::ShaderPack), ..) + ) + }) + .unwrap(); + assert_eq!( + click(&mut options, &mut settings, ROW + row as u64), + [Event::ButtonClick] + ); + assert_eq!(SECTIONS[options.section].name, SHADER_PACKS); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn mods_section_opens_folder_from_its_button() { let atlas = atlas(); diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 0ea49cb72..7d86263df 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -862,6 +862,8 @@ pub enum Page { Font, Packs, Mods, + /// The shader packs in `shaderpacks/`: pick one, open the folder. + ShaderPacks, /// Horizon's graphics settings (`graphics_ui`). Graphics, GraphicsAdvanced, @@ -888,6 +890,9 @@ enum Act { HorizonMenus, ToggleMod(usize), OpenModFolder, + /// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off). + ToggleShaderPack(usize), + OpenShaderPackFolder, } enum WKind { Button { @@ -935,6 +940,8 @@ pub enum Event { Packs(Vec), OpenPackFolder, OpenModFolder, + /// Create `shaderpacks/` if needed and open it. + OpenShaderPackFolder, } /// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after /// the last change (`OptionInstanceSliderButton.delayedApplyAt`). @@ -993,6 +1000,10 @@ pub struct OptionsUi { /// Scroll the selected language into the middle on the next layout. center_language: bool, pub graphics: crate::graphics_ui::GraphicsUi, + /// `shaderpacks/` and its packs, read while the Shader Packs page shows. + pub shader_packs: crate::shader_pack::Folder, + /// The pack that failed to load last and why (set by the game). + pub shader_pack_error: Option<(String, String)>, } impl Default for OptionsUi { fn default() -> Self { @@ -1025,6 +1036,8 @@ impl Default for OptionsUi { in_world: false, center_language: false, graphics: Default::default(), + shader_packs: Default::default(), + shader_pack_error: None, } } } @@ -1073,6 +1086,8 @@ enum Row { Big(Opt), Pair(Opt, Option), Header(&'static str), + /// A line of grey text (a path). + Note(String), Buttons(Widget, Option), } fn pairs(opts: &[Opt]) -> Vec { @@ -1131,6 +1146,33 @@ impl OptionsUi { self.graphics.reset(); } } + /// Pick shader pack `i` of the folder, or turn it off when it is the + /// one running. + pub(crate) fn toggle_shader_pack(&self, s: &mut Settings, i: usize) { + let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else { + return; + }; + let g = &mut s.horizon; + g.shader_pack = if g.shader_pack == *id { + String::new() + } else { + id.clone() + }; + } + pub(crate) fn shader_pack_failed(&self, id: &str) -> bool { + self.shader_pack_error + .as_ref() + .is_some_and(|(e, _)| e == id) + } + /// Whether the Shader Packs page (or the Horizon menus' section) shows. + fn shader_packs_shown(&self) -> bool { + self.page() == Page::ShaderPacks + || crate::ui_theme::horizon() + && self.page() == Page::Main + && crate::options_horizon::SECTIONS + .get(self.section) + .is_some_and(|s| s.name == crate::options_horizon::SHADER_PACKS) + } /// The graphics screen level of `page`, if it is one. fn graphics_level(page: Page) -> Option { match page { @@ -1153,6 +1195,7 @@ impl OptionsUi { Page::Font => "options.font.title", Page::Packs => "resourcePack.title", Page::Mods => "Mods", + Page::ShaderPacks => "Shader Packs", Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle", Page::Controller => "Controller Settings", Page::PadBinds => "Controller Binds", @@ -1262,6 +1305,16 @@ impl OptionsUi { Act::Push(Page::GraphicsAdvanced), )), )]; + rows.push(Row::Buttons( + button( + 0.0, + 0.0, + 310.0, + Text::literal("Shader Packs..."), + Act::Push(Page::ShaderPacks), + ), + None, + )); // The way back from Classic menus. Horizon-only like the row // above, here rather than on the main Options screen so that // one keeps vanilla's exact layout; vanilla's interface @@ -1450,6 +1503,32 @@ impl OptionsUi { }) .collect() } + Page::ShaderPacks => { + let folder = &self.shader_packs; + let mut rows = vec![Row::Note(folder.dir.display().to_string())]; + if folder.packs.is_empty() { + rows.push(Row::Header( + "No shader packs: put a pack's folder or .zip in this folder", + )); + } + for (i, (id, info)) in folder.packs.iter().enumerate() { + let on = s.horizon.shader_pack == *id; + let mut label = Text::literal(format!("{id}: ")).append(on_off(on)); + if info.is_err() || self.shader_pack_failed(id) { + label = label.append(" (Failed)"); + } + let widget = button(0.0, 0.0, 310.0, label, Act::ToggleShaderPack(i)); + rows.push(Row::Buttons( + if info.is_ok() { + widget + } else { + inactive(widget) + }, + None, + )); + } + rows + } _ => { let _ = s; Vec::new() @@ -1849,6 +1928,21 @@ impl OptionsUi { }); y += padding + 18.0; } + Row::Note(text) => { + out.push(Widget { + x: 0.0, + y: y + 4.0, + w, + h: 9.0, + kind: WKind::Text { + text: Text::literal(text), + color: 0xFFA0A0A0, + centered: true, + }, + list: false, + }); + y += 16.0; + } Row::Big(opt) => { out.push(option_widget(s, opt, cx - 155.0, y + 2.0, 310.0)); y += 25.0; @@ -1875,13 +1969,20 @@ impl OptionsUi { } content = y + self.scroll - top + 2.0; out[list_start..].iter_mut().for_each(|w| w.list = true); - if page == Page::Mods { + let folder = match page { + Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)), + Page::ShaderPacks => { + Some(("Open Shader Pack Folder", Act::OpenShaderPackFolder)) + } + _ => None, + }; + if let Some((label, act)) = folder { out.push(button( cx - 154.0, h - 27.0, 150.0, - Text::literal("Open Mod Folder"), - Act::OpenModFolder, + Text::literal(label), + act, )); out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done)); } else { @@ -1911,6 +2012,9 @@ impl OptionsUi { atlas: &HudAtlas, cursor: (f32, f32), ) { + if self.shader_packs_shown() { + self.shader_packs.poll(crate::shader_pack::folder()); + } let (w, h) = (batch.width, batch.height); if let Some(error) = &self.resource_error { ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world); @@ -2210,6 +2314,7 @@ impl OptionsUi { Action::ButtonClick => events.push(Event::ButtonClick), Action::Changed => events.push(Event::Changed), Action::OpenAdvanced => self.push(Page::GraphicsAdvanced), + Action::OpenShaderPacks => self.push(Page::ShaderPacks), Action::OpenSimple => { if self.stack.len() >= 2 && self.stack[self.stack.len() - 2] == Page::Graphics @@ -2377,6 +2482,11 @@ impl OptionsUi { events.push(Event::Changed); } Act::OpenModFolder => events.push(Event::OpenModFolder), + Act::ToggleShaderPack(i) => { + self.toggle_shader_pack(s, i); + events.push(Event::Changed); + } + Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder), Act::None => {} } } @@ -2831,6 +2941,109 @@ mod tests { assert!(ui.resource_error.is_none()); } + /// The Shader Packs page (Video Settings > Shader Packs..., or the + /// Shader Pack row of Advanced graphics): the folder's path, Open Shader + /// Pack Folder, and a switch per pack (#941, #925). + #[test] + fn classic_shader_packs_page_shows_the_folder_and_picks_a_pack() { + let atlas = atlas(); + let mut s = Settings::default(); + let mut ui = OptionsUi::default(); + let size = (480.0, 240.0); + let dir = std::env::temp_dir().join(format!("horizon-shaderpacks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let find = |ui: &mut OptionsUi, s: &Settings, act: &dyn Fn(&Act) -> bool| { + ui.layout(s, size.0, size.1) + .0 + .into_iter() + .find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a))) + }; + ui.show(Page::Video); + let open = find(&mut ui, &s, &|a| *a == Act::Push(Page::ShaderPacks)).unwrap(); + ui.click( + &mut s, + &atlas, + open.x + 1.0, + open.y + 1.0, + size, + [480, 240], + false, + false, + ); + assert_eq!(ui.page(), Page::ShaderPacks); + + ui.shader_packs.poll(dir.clone()); + let empty = ui.layout(&s, size.0, size.1).0; + let path = dir.display().to_string(); + for line in [path.as_str(), "No shader packs"] { + assert!( + empty.iter().any( + |w| matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(line)) + ), + "{line}" + ); + } + let folder = find(&mut ui, &s, &|a| *a == Act::OpenShaderPackFolder).unwrap(); + assert_eq!( + ui.click( + &mut s, + &atlas, + folder.x + 1.0, + folder.y + 1.0, + size, + [480, 240], + false, + false + ), + vec![Event::ButtonClick, Event::OpenShaderPackFolder] + ); + + std::fs::write(dir.join("Pack.zip"), b"").unwrap(); + ui.shader_packs = Default::default(); + ui.shader_packs.poll(dir.clone()); + let pack = find(&mut ui, &s, &|a| *a == Act::ToggleShaderPack(0)).unwrap(); + let events = ui.click( + &mut s, + &atlas, + pack.x + 1.0, + pack.y + 1.0, + size, + [480, 240], + false, + false, + ); + assert!(events.contains(&Event::Changed)); + assert_eq!(s.horizon.shader_pack, "Pack.zip"); + ui.shader_pack_error = Some(("Pack.zip".into(), "broken".into())); + let label = |ui: &mut OptionsUi, s: &Settings| match find(ui, s, &|a| { + *a == Act::ToggleShaderPack(0) + }) + .unwrap() + .kind + { + WKind::Button { label, .. } => label.plain(), + _ => unreachable!(), + }; + assert!( + label(&mut ui, &s).ends_with("(Failed)"), + "{}", + label(&mut ui, &s) + ); + ui.click( + &mut s, + &atlas, + pack.x + 1.0, + pack.y + 1.0, + size, + [480, 240], + false, + false, + ); + assert_eq!(s.horizon.shader_pack, "", "a second click turns it off"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() { let atlas = atlas(); diff --git a/crates/horizon-client/src/shader_pack.rs b/crates/horizon-client/src/shader_pack.rs index ec86edc77..f3cf46028 100644 --- a/crates/horizon-client/src/shader_pack.rs +++ b/crates/horizon-client/src/shader_pack.rs @@ -171,11 +171,41 @@ pub fn list(dir: &Path) -> Vec<(String, Result)> { packs } +/// `shaderpacks/` beside `options.txt` (the game's working folder: the game +/// home, not the launcher's version folder). +pub fn folder() -> PathBuf { + std::env::current_dir().unwrap_or_default().join(DIR) +} + +/// `shaderpacks/` and its packs as the Shader Packs page lists them, read +/// again at most once a second while the page is open, so a pack dropped +/// into the folder shows up. +#[derive(Debug, Default)] +pub struct Folder { + pub dir: PathBuf, + pub packs: Vec<(String, Result)>, + read: Option, +} + +impl Folder { + pub fn poll(&mut self, dir: PathBuf) { + let fresh = self + .read + .is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(1)); + if fresh && self.dir == dir { + return; + } + self.packs = list(&dir); + self.dir = dir; + self.read = Some(std::time::Instant::now()); + } +} + /// A Java pack has no manifest: its name is its file name. fn java_info(name: String) -> Info { Info { name, - description: String::new(), + description: "Iris/OptiFine pack: its full-screen passes run.".into(), authors: Vec::new(), version: String::new(), license: String::new(), diff --git a/docs/shader-packs.md b/docs/shader-packs.md index 3cd068f2d..faf3c7d3f 100644 --- a/docs/shader-packs.md +++ b/docs/shader-packs.md @@ -13,7 +13,7 @@ Both are off by default. With no pack selected the renderer draws exactly what i | Part | State | | --- | --- | | Renderer layout (below) | today's code | -| Horizon shader packs: `composite` stage, uniforms, hot reload, Shader Packs screen | first slice planned | +| Horizon shader packs: `composite` stage, uniforms, hot reload, Shader Packs page | done (the page: list, pick, open the folder) | | Horizon shader packs: `deferred`, `gbuffer`, `shadow` stages, custom textures | planned | | Java shader packs: preprocessing + GLSL parse coverage | spike done: 450 of 470 programs reach valid WGSL (below) | | Java shader packs: preprocessor (`crates/horizon-shaderpack`: includes, options, Iris's macros) | done: every program of the three test packs preprocesses | @@ -56,15 +56,19 @@ The lightmap, fog and sky colours are computed on the CPU (`environment.rs`, `fo ### Where packs live and how one is picked - Packs go in `shaderpacks/` beside `options.txt` in the game folder (the same folder Iris uses, so one folder - holds both kinds). A pack is a folder (zips planned). A pack with a `pack.toml` at its root is a Horizon pack; a pack - with a `shaders/` folder is a Java pack. -- **Options → Video Settings → Shader Packs...** (Classic screens, laid out like Iris's: the packs on the left with - "(off)" first, Apply and Done, *Open Shader Pack Folder*, *Shader Pack Settings...*) and a **Shader Packs** row in - the Horizon menus' Video section open the same list. + holds both kinds). That is the game home the launcher starts the game in (`%APPDATA%\horizon` on Windows, + `~/Library/Application Support/horizon` on macOS, `~/.local/share/horizon` on Linux), not the launcher's version + folder. A pack with a `pack.toml` at its root is a Horizon pack; a folder with `shaders/`, or a `.zip`, is a Java + pack. +- The **Shader Packs** page lists them: a sidebar section in the Horizon menus, **Video Settings → Shader Packs...** + in the Classic ones, and the Shader Pack row of the graphics settings leads to it. It shows the folder's full path + and an *Open Folder* button (the folder is made if it is missing), then one switch per pack: turning one on picks + it, turning it off is the vanilla picture. With no pack, it says so under the path. The list is read again every + second while the page is open, so a pack dropped into the folder shows up. - The choice is kept in `horizon-graphics.txt` (`shaderPack:`, empty = off). A pack's option values are kept in `shaderpacks/.txt`, as Iris does. -- A pack that fails to load or compile leaves the vanilla picture on and shows why, in red, on its row (and in the - log). Nothing a pack does can stop the game from starting. +- A pack that fails to load or compile leaves the vanilla picture (or the last pack that worked) on and says why on + its row ("Failed", with the error), in chat and in the log. Nothing a pack does can stop the game from starting. - Benchmarks (`--benchmark`) and the parity harnesses never load a pack. ### Layout From ac25d415784c69b3ca0fcd910317329a4daa55d3 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:27:28 +0200 Subject: [PATCH 2/2] Shader Packs page: show the first line of a pack's error Translator errors go on with a drawn source excerpt that the row can't show. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/shader_packs.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index 052f64d26..7d628ef44 100644 --- a/crates/horizon-client/src/game/shader_packs.rs +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -103,8 +103,11 @@ impl Game<'_> { } fn shader_pack_error(&mut self, id: &str, error: &str) { log::warn!("Horizon: shader pack {id}: {error}"); - // The Shader Packs page says why on the pack's row. - self.ui.options.shader_pack_error = Some((id.to_owned(), error.to_owned())); + // The Shader Packs page says why on the pack's row: the first line, + // since translator errors go on with a drawn source excerpt. + let first = error.lines().map(str::trim).find(|l| !l.is_empty()); + self.ui.options.shader_pack_error = + Some((id.to_owned(), first.unwrap_or(error).to_owned())); self.system_message(Text::literal(format!("Shader pack {id}: {error}"))); } }