feat(ui): Missing Java Popup

This commit is contained in:
LynithDev 2026-07-19 16:37:08 +02:00
parent a9481a5789
commit c881bf858d
10 changed files with 516 additions and 248 deletions

View file

@ -0,0 +1,278 @@
use freya::prelude::*;
use oneclient_core::java::{AvailableJava, JavaVendor};
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{provider_versions, use_provider_versions};
use crate::theme::colors;
use crate::ui::border_all_color;
fn providers() -> Vec<(JavaVendor, &'static str)> {
vec![
(JavaVendor::Zulu, "Azul Zulu"),
(JavaVendor::Adoptium, "Eclipse Temurin"),
(JavaVendor::Corretto, "Amazon Corretto"),
(JavaVendor::Liberica, "BellSoft Liberica"),
]
}
/// Reusable "Install Java" picker. Lists downloadable runtimes per provider and
/// calls `on_install` with the chosen `(vendor, major)`. When `suggested` is set,
/// the matching major is pulled to the top and highlighted.
#[derive(PartialEq)]
pub struct JavaInstallManager {
suggested: Option<u32>,
on_install: Option<EventHandler<(JavaVendor, u32)>>,
on_close: Option<EventHandler<()>>,
}
impl Default for JavaInstallManager {
fn default() -> Self {
Self::new()
}
}
impl JavaInstallManager {
pub fn new() -> Self {
Self {
suggested: None,
on_install: None,
on_close: None,
}
}
pub fn suggested(mut self, major: Option<u32>) -> Self {
self.suggested = major;
self
}
pub fn on_install(mut self, handler: impl Into<EventHandler<(JavaVendor, u32)>>) -> Self {
self.on_install = Some(handler.into());
self
}
pub fn on_close(mut self, handler: impl Into<EventHandler<()>>) -> Self {
self.on_close = Some(handler.into());
self
}
}
impl Component for JavaInstallManager {
fn render(&self) -> impl IntoElement {
let providers = providers();
let mut selected = use_state(|| 0usize);
let suggested = self.suggested;
let idx = (*selected.read()).min(providers.len() - 1);
let (vendor, _) = providers[idx].clone();
let versions_query = use_provider_versions(vendor.clone());
let (mut versions, loading) = provider_versions(&versions_query);
// Pull the suggested major to the top so it reads as the default choice.
if let Some(major) = suggested {
versions.sort_by_key(|v| v.major != major);
}
let provider_labels: Vec<String> =
providers.iter().map(|(_, name)| name.to_string()).collect();
let current_label = provider_labels[idx].clone();
let inner: Element = if loading {
status_text("Loading available versions...")
} else if versions.is_empty() {
status_text("No versions available from this provider.")
} else {
let mut area = ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.spacing(8.);
for available in versions {
let highlight = suggested == Some(available.major);
area = area.child(
VersionRow {
vendor: vendor.clone(),
available,
suggested: highlight,
on_install: self.on_install.clone(),
}
.into_element(),
);
}
area.into_element()
};
let list = rect()
.width(Size::fill())
.height(Size::px(280.))
.child(inner)
.into_element();
let close_scrim = self.on_close.clone();
let close_button = self.on_close.clone();
OverlayPopup::new()
.on_close(move |()| {
if let Some(handler) = &close_scrim {
handler.call(());
}
})
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(16.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Install Java")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.child(
label()
.text("Provider")
.font_size(11.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_secondary()),
)
.child(
Dropdown::new(current_label, provider_labels)
.width(Size::fill())
.height(Size::px(34.))
.on_select(move |i: usize| selected.set(i)),
),
)
.child(list)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.child(
Button::new()
.ghost()
.on_press(move |_| {
if let Some(handler) = &close_button {
handler.call(());
}
})
.text("Close"),
),
),
),
)
.into_element()
}
}
fn status_text(text: &'static str) -> Element {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(16., 4.))
.child(
label()
.text(text)
.font_size(13.)
.color(colors::fg_secondary()),
)
.into_element()
}
#[derive(PartialEq)]
struct VersionRow {
vendor: JavaVendor,
available: AvailableJava,
suggested: bool,
on_install: Option<EventHandler<(JavaVendor, u32)>>,
}
impl Component for VersionRow {
fn render(&self) -> impl IntoElement {
let vendor = self.vendor.clone();
let major = self.available.major;
let version = self.available.package.name.clone();
let on_install = self.on_install.clone();
let mut title_row = rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(8.)
.child(
label()
.text(format!("Java {major}"))
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
);
if self.suggested {
title_row = title_row.child(
rect()
.padding(Gaps::new_symmetric(2., 8.))
.corner_radius(CornerRadius::new_all(6.))
.background(colors::brand())
.child(
label()
.text("Suggested")
.font_size(10.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
);
}
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new_symmetric(10., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::component_bg())
.maybe(self.suggested, |el| {
el.border(border_all_color(1., colors::brand()))
})
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(title_row)
.child(
label()
.text(version)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(
Button::new()
.primary()
.small()
.on_press(move |_| {
if let Some(handler) = &on_install {
handler.call((vendor.clone(), major));
}
})
.child(Icon::new(IconType::Download01).size(13.))
.text("Install"),
)
}
}

View file

@ -0,0 +1,138 @@
use freya::prelude::*;
use oneclient_core::java::JavaVendor;
use oneclient_core::notification::{PromptKind, UserChoice};
use crate::components::{Button, Icon, IconType, JavaInstallManager, OverlayPopup};
use crate::hooks::{use_dispatch, use_notifications_snapshot};
use crate::theme::colors;
use crate::ui::border_all_color;
const CARD_BG: Color = Color::from_rgb(26, 34, 41);
#[derive(PartialEq)]
pub struct JavaPromptOverlay;
impl Component for JavaPromptOverlay {
fn render(&self) -> impl IntoElement {
// Hooks must run unconditionally, before any early return, so the hook
// count stays stable whether or not a Java prompt is pending.
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let mut show_manager = use_state(|| false);
let Some(prompt) = snapshot.pending_prompt.clone() else {
return rect().into_element();
};
let PromptKind::JavaInstall { major } = prompt.kind else {
return rect().into_element();
};
// Download opens the install manager with the required major highlighted.
if *show_manager.read() {
let install = dispatch.clone();
return JavaInstallManager::new()
.suggested(Some(major))
.on_install(move |(vendor, _row_major): (JavaVendor, u32)| {
// The launch needs `major`; the manager only picks the vendor.
install.answer_prompt(UserChoice::Install { vendor, major });
show_manager.set(false);
})
.on_close(move |()| show_manager.set(false))
.into_element();
}
let close = dispatch.clone();
let cancel = dispatch.clone();
let folder = dispatch.clone();
let pick_folder = move |_| {
let dispatch = folder.clone();
spawn(async move {
if let Some(handle) = rfd::AsyncFileDialog::new()
.set_title(format!("Select a Java {major} installation folder"))
.pick_folder()
.await
{
dispatch.answer_prompt(UserChoice::Folder(handle.path().to_path_buf()));
}
});
};
OverlayPopup::new()
.on_close(move |_| close.answer_prompt(UserChoice::Cancel))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(14.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(14.))
.background(CARD_BG)
.border(border_all_color(1., colors::component_border()))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(10.)
.child(
Icon::new(IconType::FolderDownload)
.size(20.)
.color(colors::brand()),
)
.child(
label()
.text(prompt.title.clone())
.font_size(16.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
),
)
.child(
label()
.text(prompt.question.clone())
.font_size(12.)
.max_lines(4)
.width(Size::fill())
.color(colors::fg_secondary()),
)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.spacing(8.)
.child(
Button::new()
.secondary()
.on_press(move |_| {
cancel.answer_prompt(UserChoice::Cancel)
})
.text("Cancel"),
)
.child(
Button::new()
.secondary()
.on_press(pick_folder)
.child(Icon::new(IconType::Folder).size(14.))
.text("Choose folder"),
)
.child(
Button::new()
.primary()
.on_press(move |_| show_manager.set(true))
.child(Icon::new(IconType::DownloadCloud02).size(14.))
.text("Download"),
),
),
),
)
.into_element()
}
}

View file

@ -9,6 +9,8 @@ mod context_menu;
mod dropdown;
mod dynamic_art;
mod icons;
mod java_install_manager;
mod java_prompt;
mod link_confirm;
mod local_image;
mod log_viewer;
@ -42,6 +44,8 @@ pub use context_menu::ContextMenu;
pub use dropdown::Dropdown;
pub use dynamic_art::DynamicArt;
pub use icons::{Icon, IconType};
pub use java_install_manager::JavaInstallManager;
pub use java_prompt::JavaPromptOverlay;
pub use link_confirm::ConfirmLinkOverlay;
pub use local_image::LocalImage;
pub use log_viewer::LogViewer;

View file

@ -2,7 +2,8 @@ use freya::prelude::*;
use freya::router::*;
use crate::components::{
AccountSwitcher, ClusterUpdatePopup, NotificationCenter, StatusBar, Toasts, UpdatePromptOverlay,
AccountSwitcher, ClusterUpdatePopup, JavaPromptOverlay, NotificationCenter, StatusBar, Toasts,
UpdatePromptOverlay,
};
use crate::routes::Route;
use crate::theme;
@ -31,6 +32,7 @@ impl Component for RootLayout {
.child(AccountSwitcher)
.child(Toasts)
.child(UpdatePromptOverlay)
.child(JavaPromptOverlay)
.child(ClusterUpdatePopup)
.child(StatusBar)
}

View file

@ -312,15 +312,6 @@ impl NotificationState {
kind,
reply_tx,
} => {
let entry_id = self.push_inbox(
inbox,
title.clone(),
question.clone(),
NotificationLevel::Info,
None,
false,
);
self.push_ephemeral_toast(entry_id, MESSAGE_TOAST_TTL);
let pending_prompt = Some(PendingPrompt {
title: title.clone(),
question: question.clone(),

View file

@ -1,33 +1,22 @@
use freya::prelude::*;
use oneclient_core::java::{AvailableJava, JavaRuntime, JavaVendor};
use oneclient_core::java::{JavaRuntime, JavaVendor};
use super::settings_page;
use crate::components::{Button, Dropdown, Icon, IconType, OverlayPopup, ScrollArea};
use crate::hooks::{
BridgeDispatch, java_runtimes, provider_versions, use_dispatch, use_java_runtimes,
use_provider_versions,
};
use crate::components::{Button, Icon, IconType, JavaInstallManager, ScrollArea};
use crate::hooks::{BridgeDispatch, java_runtimes, use_dispatch, use_java_runtimes};
use crate::theme::colors;
use crate::ui::border_all_color;
use crate::view::app::settings::section_header;
fn providers() -> Vec<(JavaVendor, &'static str)> {
vec![
(JavaVendor::Zulu, "Azul Zulu"),
(JavaVendor::Adoptium, "Eclipse Temurin"),
(JavaVendor::Corretto, "Amazon Corretto"),
(JavaVendor::Liberica, "BellSoft Liberica"),
]
}
#[derive(PartialEq)]
pub struct SettingsJava;
impl Component for SettingsJava {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let runtimes_query = use_java_runtimes();
let runtimes = java_runtimes(&runtimes_query);
let show_manager = use_state(|| false);
let mut show_manager = use_state(|| false);
let mut shell = settings_page()
.child(section_header("ADD RUNTIME"))
@ -36,7 +25,15 @@ impl Component for SettingsJava {
.child(runtimes_table(runtimes));
if *show_manager.read() {
shell = shell.child(InstallManagerPopup { show_manager }.into_element());
shell = shell.child(
JavaInstallManager::new()
.on_install(move |(vendor, major): (JavaVendor, u32)| {
dispatch.install_java_runtime(vendor, major);
show_manager.set(false);
})
.on_close(move |()| show_manager.set(false))
.into_element(),
);
}
shell.into_element()
@ -249,185 +246,3 @@ fn remove_button(dispatch: BridgeDispatch, path: String) -> impl IntoElement {
)
.into_element()
}
#[derive(PartialEq)]
struct InstallManagerPopup {
show_manager: State<bool>,
}
impl Component for InstallManagerPopup {
fn render(&self) -> impl IntoElement {
let mut show_manager = self.show_manager;
let providers = providers();
let mut selected = use_state(|| 0usize);
let idx = (*selected.read()).min(providers.len() - 1);
let (vendor, _) = providers[idx].clone();
let versions_query = use_provider_versions(vendor.clone());
let (versions, loading) = provider_versions(&versions_query);
let provider_labels: Vec<String> =
providers.iter().map(|(_, name)| name.to_string()).collect();
let current_label = provider_labels[idx].clone();
let inner: Element = if loading {
status_text("Loading available versions...")
} else if versions.is_empty() {
status_text("No versions available from this provider.")
} else {
let mut area = ScrollArea::new()
.width(Size::fill())
.height(Size::fill())
.spacing(8.);
for available in versions {
area = area.child(
VersionRow {
vendor: vendor.clone(),
available,
show_manager,
}
.into_element(),
);
}
area.into_element()
};
let list = rect()
.width(Size::fill())
.height(Size::px(280.))
.child(inner)
.into_element();
OverlayPopup::new()
.on_close(move |()| show_manager.set(false))
.child(
rect()
.width(Size::window_percent(100.))
.height(Size::window_percent(100.))
.center()
.child(
rect()
.vertical()
.width(Size::px(440.))
.max_width(Size::window_percent(90.))
.spacing(16.)
.padding(Gaps::new_all(20.))
.corner_radius(CornerRadius::new_all(16.))
.background(colors::page_elevated())
.border(border_all_color(1., colors::component_border()))
.child(
label()
.text("Install Java")
.font_size(18.)
.font_weight(FontWeight::SEMI_BOLD)
.color(colors::fg_primary()),
)
.child(
rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.child(
label()
.text("Provider")
.font_size(11.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_secondary()),
)
.child(
Dropdown::new(current_label, provider_labels)
.width(Size::fill())
.height(Size::px(34.))
.on_select(move |i: usize| selected.set(i)),
),
)
.child(list)
.child(
rect()
.horizontal()
.width(Size::fill())
.main_align(Alignment::End)
.child(
Button::new()
.ghost()
.on_press(move |_| show_manager.set(false))
.text("Close"),
),
),
),
)
.into_element()
}
}
fn status_text(text: &'static str) -> Element {
rect()
.width(Size::fill())
.padding(Gaps::new_symmetric(16., 4.))
.child(
label()
.text(text)
.font_size(13.)
.color(colors::fg_secondary()),
)
.into_element()
}
#[derive(PartialEq)]
struct VersionRow {
vendor: JavaVendor,
available: AvailableJava,
show_manager: State<bool>,
}
impl Component for VersionRow {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let mut show_manager = self.show_manager;
let vendor = self.vendor.clone();
let major = self.available.major;
let version = self.available.package.name.clone();
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(12.)
.padding(Gaps::new_symmetric(10., 12.))
.corner_radius(CornerRadius::new_all(10.))
.background(colors::component_bg())
.child(
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(
label()
.text(format!("Java {major}"))
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.color(colors::fg_primary()),
)
.child(
label()
.text(version)
.font_size(11.)
.max_lines(1)
.color(colors::fg_secondary()),
),
)
.child(
Button::new()
.primary()
.small()
.on_press(move |_| {
dispatch.install_java_runtime(vendor.clone(), major);
show_manager.set(false);
})
.child(Icon::new(IconType::Download01).size(13.))
.text("Install"),
)
}
}

View file

@ -171,6 +171,7 @@ pub async fn launch_cluster(
.as_ref()
.map(|v| v.major_version)
.ok_or(GameError::MissingJavaVersion)?;
JavaManager::prepare_java(state, major, search_for_java).await?
};

View file

@ -68,6 +68,9 @@ impl JavaManager {
match services.notifier.prompt_java_install(major).await? {
UserChoice::Accept => download_and_register(major, services).await,
UserChoice::Folder(folder) => register_custom_java(&services.db, folder, major).await,
UserChoice::Install { vendor, .. } => {
Self::install_runtime_from(services, &vendor, major).await
}
UserChoice::Cancel => Err(JavaError::Cancelled.into()),
}
}

View file

@ -179,6 +179,10 @@ pub enum UserChoice {
Accept,
Cancel,
Folder(PathBuf),
Install {
vendor: crate::java::JavaVendor,
major: u32,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]

View file

@ -1,15 +1,33 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use uuid::Uuid;
use super::data::{GroupedProgressEvent, TaskCategory};
use super::NotificationService;
use super::data::{GroupedProgressEvent, TaskCategory};
#[derive(Clone)]
struct SessionInner {
session_id: Uuid,
notifier: NotificationService,
ended: AtomicBool,
}
impl SessionInner {
fn end(&self) {
if self.ended.swap(true, Ordering::Relaxed) {
return;
}
self.notifier.send_grouped(GroupedProgressEvent::End {
session_id: self.session_id,
});
}
}
impl Drop for SessionInner {
fn drop(&mut self) {
self.end();
}
}
struct ChildInner {
@ -42,6 +60,7 @@ impl GroupedProgressSession {
inner: Arc::new(SessionInner {
session_id,
notifier: notifier.clone(),
ended: AtomicBool::new(false),
}),
}
}
@ -56,12 +75,14 @@ impl GroupedProgressSession {
if count == 0 {
return;
}
self.inner.notifier.send_grouped(GroupedProgressEvent::Expect {
session_id: self.inner.session_id,
category,
count,
total,
});
self.inner
.notifier
.send_grouped(GroupedProgressEvent::Expect {
session_id: self.inner.session_id,
category,
count,
total,
});
}
pub fn child(
@ -74,13 +95,15 @@ impl GroupedProgressSession {
let label = label.into();
let total = total.max(1);
self.inner.notifier.send_grouped(GroupedProgressEvent::AddChild {
session_id: self.inner.session_id,
child_id,
label: label.clone(),
total,
category,
});
self.inner
.notifier
.send_grouped(GroupedProgressEvent::AddChild {
session_id: self.inner.session_id,
child_id,
label: label.clone(),
total,
category,
});
GroupedProgressChild {
inner: Arc::new(ChildInner {
@ -114,11 +137,7 @@ impl GroupedProgressSession {
}
pub fn finish(self) {
self.inner
.notifier
.send_grouped(GroupedProgressEvent::End {
session_id: self.inner.session_id,
});
self.inner.end();
}
}
@ -128,18 +147,28 @@ impl GroupedProgressChild {
}
pub fn set_phase(&self, phase: super::data::TaskPhase) {
if self.inner.finished.load(std::sync::atomic::Ordering::Relaxed) {
if self
.inner
.finished
.load(std::sync::atomic::Ordering::Relaxed)
{
return;
}
self.inner.notifier.send_grouped(GroupedProgressEvent::SetChildPhase {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
phase,
});
self.inner
.notifier
.send_grouped(GroupedProgressEvent::SetChildPhase {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
phase,
});
}
pub fn set_progress(&self, current: u64, total: Option<u64>) {
if self.inner.finished.load(std::sync::atomic::Ordering::Relaxed) {
if self
.inner
.finished
.load(std::sync::atomic::Ordering::Relaxed)
{
return;
}
@ -152,12 +181,14 @@ impl GroupedProgressChild {
if total > stored {
self.inner.total.store(total, Ordering::Relaxed);
}
self.inner.notifier.send_grouped(GroupedProgressEvent::UpdateChild {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
current,
total,
});
self.inner
.notifier
.send_grouped(GroupedProgressEvent::UpdateChild {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
current,
total,
});
}
pub fn finish(&self) {
@ -169,10 +200,12 @@ impl GroupedProgressChild {
return;
}
self.inner.notifier.send_grouped(GroupedProgressEvent::FinishChild {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
});
self.inner
.notifier
.send_grouped(GroupedProgressEvent::FinishChild {
session_id: self.inner.session_id,
child_id: self.inner.child_id,
});
}
}
@ -181,4 +214,3 @@ impl Drop for GroupedProgressChild {
self.finish();
}
}