From 0a5101895427c97d6b38e7ac7c3fca01ba9befa0 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:01:51 +0200 Subject: [PATCH] feat(ui): Stats page tweaks (pie charts and consistent spacing) --- packages/oneclient_app/src/bridge/runtime.rs | 11 +- .../oneclient_app/src/components/charts.rs | 311 +++++++++++++++++- packages/oneclient_app/src/components/mod.rs | 2 +- .../src/view/app/analytics/charts.rs | 41 +-- .../src/view/app/analytics/mod.rs | 93 +++++- .../src/view/app/analytics/servers.rs | 275 ++++++++++++++-- .../src/view/app/analytics/tiles.rs | 4 +- .../src/view/app/cluster/overview.rs | 10 +- packages/oneclient_app/src/view/app/mod.rs | 2 +- packages/oneclient_app/src/view/app/stats.rs | 12 +- .../src/view/onboarding/bundles/mod.rs | 31 +- .../src/view/onboarding/downloading/mod.rs | 33 +- .../src/view/onboarding/migration.rs | 4 +- .../src/view/onboarding/selection.rs | 6 +- .../src/view/onboarding/test_support.rs | 1 - packages/oneclient_core/src/constants.rs | 1 - 16 files changed, 697 insertions(+), 140 deletions(-) diff --git a/packages/oneclient_app/src/bridge/runtime.rs b/packages/oneclient_app/src/bridge/runtime.rs index 0c3ea912..85329ab9 100644 --- a/packages/oneclient_app/src/bridge/runtime.rs +++ b/packages/oneclient_app/src/bridge/runtime.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use oneclient_core::notification::{Notification, NotificationLevel, NotificationService}; use oneclient_core::packages::PackageStore; use oneclient_core::settings::store::{ @@ -813,7 +815,7 @@ fn refresh_settings(snapshots: &mut BridgeSnapshot) -> anyhow::Result<()> { } async fn install_package( - state: &std::sync::Arc, + state: &Arc, provider: oneclient_core::packages::ProviderId, project_id: &str, version_id: &str, @@ -856,10 +858,7 @@ fn apply_game_stage(snapshots: &mut BridgeSnapshot, cluster_id: i64, stage: Laun snapshots.game.stages.insert(cluster_id, stage); if stage == LaunchStage::Checking { snapshots.game.error = None; - snapshots - .game - .logs - .insert(cluster_id, std::sync::Arc::new(Vec::new())); + snapshots.game.logs.insert(cluster_id, Arc::new(Vec::new())); } } @@ -873,7 +872,7 @@ fn apply_game_failed(snapshots: &mut BridgeSnapshot, cluster_id: i64, message: S fn apply_game_log(snapshots: &mut BridgeSnapshot, cluster_id: i64, line: String) { let buffer = snapshots.game.logs.entry(cluster_id).or_default(); - std::sync::Arc::make_mut(buffer).push(std::sync::Arc::from(line)); + Arc::make_mut(buffer).push(Arc::from(line)); } fn bump_profiles_generation(snapshots: &mut BridgeSnapshot) { diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index eda5ed80..7d83c50c 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -1,3 +1,4 @@ +use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect}; use freya::prelude::*; use crate::theme::colors; @@ -21,11 +22,259 @@ impl ValueUnit { const DEFAULT_HEIGHT: f32 = 150.; const Y_AXIS_WIDTH: f32 = 44.; +/// Slice colors, cycled when there are more slices than entries. +pub const SLICE_COLORS: [Color; 10] = [ + Color::from_rgb(43, 75, 255), + Color::from_rgb(27, 217, 106), + Color::from_rgb(241, 100, 54), + Color::from_rgb(198, 120, 221), + Color::from_rgb(97, 175, 239), + Color::from_rgb(229, 192, 123), + Color::from_rgb(35, 154, 96), + Color::from_rgb(224, 108, 117), + Color::from_rgb(120, 129, 141), + Color::from_rgb(152, 195, 121), +]; + +pub fn slice_color(i: usize) -> Color { + SLICE_COLORS[i % SLICE_COLORS.len()] +} + +const PIE_SIZE: f32 = 190.; +/// Inner hole as a fraction of the radius, making it a donut. +const PIE_HOLE: f32 = 0.58; + +/// A donut chart. Hovering a slice reports it via `on_hover` so callers can +/// sync an external legend; the hovered slice is also expanded slightly. +pub struct PieChart { + values: Vec, + labels: Vec, + unit: ValueUnit, + size: f32, + hovered: Option>>, +} + +impl PieChart { + pub fn new(values: Vec, labels: Vec) -> Self { + Self { + values, + labels, + unit: ValueUnit::Duration, + size: PIE_SIZE, + hovered: None, + } + } + + pub fn unit(mut self, unit: ValueUnit) -> Self { + self.unit = unit; + self + } + + /// Shares the hovered slice index with the caller (e.g. to highlight a legend row). + pub fn hovered(mut self, hovered: State>) -> Self { + self.hovered = Some(hovered); + self + } +} + +impl PartialEq for PieChart { + fn eq(&self, other: &Self) -> bool { + self.values == other.values + && self.labels == other.labels + && self.unit == other.unit + && self.size == other.size + } +} + +impl Component for PieChart { + fn render(&self) -> impl IntoElement { + let internal = use_state(|| Option::::None); + let mut hovered = self.hovered.unwrap_or(internal); + let active = *hovered.read(); + + let size = self.size; + let total: i64 = self.values.iter().sum(); + let values = self.values.clone(); + let hit_values = self.values.clone(); + + let render_cb = RenderCallback::new(move |ctx: &mut CanvasContext| { + let (w, h) = (ctx.size.width, ctx.size.height); + if w <= 0.0 || h <= 0.0 { + return; + } + + let cx = w * 0.5; + let cy = h * 0.5; + let radius = (w.min(h) * 0.5) - 6.0; + if radius <= 0.0 { + return; + } + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_style(PaintStyle::Fill); + + // With nothing recorded, draw an empty track so the ring is still there. + if total <= 0 { + paint.set_color(SkColor::from(colors::component_bg_hover())); + ctx.canvas.draw_circle((cx, cy), radius - 5.0, &paint); + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + return; + } + + let mut start = -90.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + // Grow the hovered slice a touch, and dim the rest. + let is_active = Some(i) == active; + let r = if is_active { radius } else { radius - 5.0 }; + let color = slice_color(i); + let color = if active.is_some() && !is_active { + color.with_a(120) + } else { + color + }; + paint.set_color(SkColor::from(color)); + + let oval = SkRect::new(cx - r, cy - r, cx + r, cy + r); + let mut builder = PathBuilder::new(); + builder.move_to((cx, cy)); + builder.arc_to(oval, start, sweep, false); + builder.close(); + ctx.canvas.draw_path(&builder.detach(), &paint); + + start += sweep; + } + + // Punch the donut hole using the card background. + paint.set_color(SkColor::from(colors::page_elevated())); + ctx.canvas + .draw_circle((cx, cy), (radius - 5.0) * PIE_HOLE, &paint); + }); + + let (center_name, center_value) = match active { + Some(i) if i < self.values.len() => ( + self.labels + .get(i) + .cloned() + .unwrap_or_else(|| format!("#{}", i + 1)), + self.unit.format(self.values[i]), + ), + _ => ("Total".to_string(), self.unit.format(total)), + }; + + rect() + .width(Size::px(size)) + .height(Size::px(size)) + .on_pointer_move(move |e: Event| { + let loc = e.element_location(); + let next = slice_at(&hit_values, loc.x as f32, loc.y as f32, size); + if *hovered.peek() != next { + hovered.set(next); + } + }) + .on_pointer_leave(move |_| { + if hovered.peek().is_some() { + hovered.set(None); + } + }) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // The canvas defaults to auto-sizing and has no children, so + // without an explicit size it measures 0x0 and draws nothing. + .child(canvas(render_cb).width(Size::fill()).height(Size::fill())), + ) + .child( + rect() + .width(Size::fill()) + .height(Size::fill()) + .position(Position::new_absolute()) + .interactive(false) + // Sibling absolute rects on an equal layer paint in undefined + // order, so pin the readout above the canvas. + .layer(Layer::Relative(1)) + .center() + // Keeps the readout inside the donut hole (Gaps is vertical, horizontal). + .padding(Gaps::new_symmetric(0., size * 0.25)) + .child( + rect() + .vertical() + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(2.) + .child( + label() + .text(center_value) + .font_size(16.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_primary()), + ) + .child( + label() + .text(center_name) + .font_size(10.) + .max_lines(2) + .width(Size::fill()) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + } +} + +/// Maps a pointer position inside the chart box to a slice index. +fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option { + let total: i64 = values.iter().sum(); + if total <= 0 { + return None; + } + + let cx = size * 0.5; + let cy = size * 0.5; + let radius = (size * 0.5) - 6.0; + let (dx, dy) = (x - cx, y - cy); + let dist = (dx * dx + dy * dy).sqrt(); + if dist > radius || dist < (radius - 5.0) * PIE_HOLE { + return None; + } + + // Angle measured clockwise from 12 o'clock, matching the drawing order. + let mut angle = dy.atan2(dx).to_degrees() + 90.0; + if angle < 0.0 { + angle += 360.0; + } + + let mut start = 0.0_f32; + for (i, &v) in values.iter().enumerate() { + if v <= 0 { + continue; + } + let sweep = (v as f32 / total as f32) * 360.0; + if angle >= start && angle < start + sweep { + return Some(i); + } + start += sweep; + } + None +} + #[derive(PartialEq)] pub struct BarChart { values: Vec, labels: Vec, - readout_labels: Vec, highlight: Option, unit: ValueUnit, height: f32, @@ -37,7 +286,6 @@ impl BarChart { Self { values, labels, - readout_labels: Vec::new(), highlight: None, unit: ValueUnit::Duration, height: DEFAULT_HEIGHT, @@ -55,11 +303,6 @@ impl BarChart { self } - pub fn readout_labels(mut self, labels: Vec) -> Self { - self.readout_labels = labels; - self - } - #[allow(dead_code)] pub fn height(mut self, height: f32) -> Self { self.height = height; @@ -72,9 +315,8 @@ impl BarChart { } fn readout_name(&self, i: usize) -> String { - self.readout_labels + self.labels .get(i) - .or_else(|| self.labels.get(i)) .cloned() .filter(|s| !s.is_empty()) .unwrap_or_else(|| format!("#{}", i + 1)) @@ -85,6 +327,7 @@ impl Component for BarChart { fn render(&self) -> impl IntoElement { let mut hovered = use_state(|| Option::::None); let active = *hovered.read(); + let mut plot_width = use_state(|| 0f32); let unit = self.unit; let height = self.height; @@ -146,22 +389,26 @@ impl Component for BarChart { ); } + let stride = label_stride(&self.labels, *plot_width.read(), self.gap); let mut label_row = rect() .horizontal() .content(Content::Flex) .width(Size::fill()) .height(Size::px(14.)) .spacing(self.gap); - for text in &self.labels { + for (i, text) in self.labels.iter().enumerate() { + let shown = i % stride == 0; label_row = label_row.child( rect() .width(Size::flex(1.0)) .main_align(Alignment::Center) .child( label() - .text(text.clone()) + .text(if shown { text.clone() } else { String::new() }) .font_size(10.) .max_lines(1) + .width(Size::fill()) + .text_align(TextAlign::Center) .color(colors::fg_secondary()), ), ); @@ -180,7 +427,17 @@ impl Component for BarChart { .width(Size::fill()) .spacing(6.) .child(y_axis(max, unit, height)) - .child(rect().width(Size::flex(1.0)).child(plot)); + .child( + rect() + .width(Size::flex(1.0)) + .on_sized(move |e: Event| { + let w = e.area.width(); + if (*plot_width.peek() - w).abs() > 0.5 { + plot_width.set(w); + } + }) + .child(plot), + ); let labels_row = rect() .horizontal() @@ -200,6 +457,36 @@ impl Component for BarChart { } } +/// How many bars to skip between shown labels so text never overlaps. +/// Derived from the measured plot width: a wider chart (e.g. maximised window) +/// fits more labels and returns a smaller stride. +fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize { + let n = labels.len(); + if n == 0 { + return 1; + } + // Before the first measurement, thin conservatively to avoid an overlap flash. + if plot_width <= 0.0 { + return (n / 12).max(1); + } + + let max_chars = labels + .iter() + .map(|s| s.chars().count()) + .max() + .unwrap_or(0) + .max(1); + // ~6.2px per char at font_size 10, plus breathing room between labels. + let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0; + let max_labels = (plot_width / slot_px).floor().max(1.0) as usize; + + if n <= max_labels { + 1 + } else { + n.div_ceil(max_labels) + } +} + fn gridlines(height: f32) -> Element { let mut overlay = rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 3187b3fa..c115d599 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -35,7 +35,7 @@ pub use account_switcher::AccountSwitcher; pub use active_cluster_panel::ActiveClusterPanel; pub use avatar::Avatar; pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button}; -pub use charts::{BarChart, ValueUnit}; +pub use charts::{BarChart, PieChart, ValueUnit, slice_color}; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 7aeb7756..7157d66e 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -68,8 +68,7 @@ impl Component for WhenChart { .unwrap_or_else(|| "—".to_string()); ( format!("Peak around {hour}"), - BarChart::new(self.per_hour.clone(), hour_labels()) - .readout_labels((0..24).map(format_hour).collect()) + BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect()) .highlight(self.peak_hour) .unit(ValueUnit::Duration) .gap(3.) @@ -135,18 +134,7 @@ impl Component for DailyChart { let slice = &series[start..end]; let values: Vec = slice.iter().map(|(_, s)| *s).collect(); - let readout_labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); - let labels: Vec = slice - .iter() - .enumerate() - .map(|(i, (d, _))| { - if i == 0 || i == slice.len() - 1 { - format_day(*d) - } else { - String::new() - } - }) - .collect(); + let labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); let win_total: i64 = values.iter().sum(); let subtitle = match (slice.first(), slice.last()) { @@ -192,7 +180,6 @@ impl Component for DailyChart { subtitle, Some(nav), BarChart::new(values, labels) - .readout_labels(readout_labels) .unit(ValueUnit::Duration) .gap(3.) .into_element(), @@ -224,17 +211,17 @@ fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { out } -pub(super) fn distribution_card(session_secs: &[i64]) -> Option { - if session_secs.len() < 2 { +pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option { + if session_secs.len() < 2 && !force { return None; } const EDGES: [(i64, &str); 6] = [ (15 * 60, "<15m"), - (30 * 60, "15–30"), - (60 * 60, "30–60"), - (2 * 3600, "1–2h"), - (4 * 3600, "2–4h"), + (30 * 60, "15m-30m"), + (60 * 60, "30m-60m"), + (2 * 3600, "1h-2h"), + (4 * 3600, "2h-4h"), (i64::MAX, "4h+"), ]; @@ -269,15 +256,3 @@ pub(super) fn distribution_card(session_secs: &[i64]) -> Option { fn weekday_labels() -> Vec { WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect() } - -fn hour_labels() -> Vec { - (0..24) - .map(|h| { - if h % 6 == 0 { - format_hour(h) - } else { - String::new() - } - }) - .collect() -} diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs index 2235de77..81922cbd 100644 --- a/packages/oneclient_app/src/view/app/analytics/mod.rs +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -1,6 +1,7 @@ +use chrono::{Duration, Local}; use freya::prelude::*; -use oneclient_core::game::Analytics; +use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats}; use crate::components::{Icon, IconType}; use crate::theme::colors; @@ -15,11 +16,18 @@ use servers::servers_section; use tiles::{personas_row, tiles_row}; pub fn analytics_body(analytics: &Analytics) -> Element { + analytics_body_inner(analytics, false) +} + +/// `force_all` keeps the sections that are normally hidden for lack of data +/// (session length, servers) on screen, so the zeroed empty state still shows +/// the whole layout. +fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { let stats = &analytics.playtime; let mut root = rect().vertical().width(Size::fill()).spacing(24.); - root = root.child(tiles_row(analytics)); + root = root.child(tiles_row(analytics, force_all)); if !stats.personas.is_empty() { root = root.child(personas_row(&stats.personas)); @@ -29,20 +37,95 @@ pub fn analytics_body(analytics: &Analytics) -> Element { WhenChart::from_stats(stats).into_element(), DailyChart::new(stats.daily.clone()).into_element(), ]; - if let Some(dist) = distribution_card(&stats.session_secs) { + if let Some(dist) = distribution_card(&stats.session_secs, force_all) { charts.push(dist); } root = root.child(charts_grid(charts)); - if !analytics.servers.is_empty() { + if force_all || !analytics.servers.is_empty() { root = root.child(servers_section(&analytics.servers)); } root.into_element() } +/// Renders the full analytics layout with zeroed data, dimmed behind a scrim +/// with `note` centered on top. Used by the cluster overview and global +/// statistics pages when nothing has been recorded yet, so the charts and stats +/// are always visible without inventing numbers. +pub fn analytics_placeholder(note: &str) -> Element { + rect() + .width(Size::fill()) + .child(analytics_body_inner(&empty_analytics(), true)) + .child( + rect() + .position(Position::new_absolute()) + .width(Size::fill()) + .height(Size::fill()) + .layer(Layer::Relative(1)) + .center() + .padding(40.) + .background(colors::page_overlay()) + // swallow clicks so the placeholder controls aren't interactive + .on_press(|_| {}) + .child( + rect() + .max_width(Size::px(360.)) + .padding(Gaps::new_symmetric(16., 24.)) + .corner_radius(CornerRadius::new_all(12.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .center() + .child( + label() + .text(note.to_string()) + .font_size(14.) + .text_align(TextAlign::Center) + .color(colors::fg_secondary()), + ), + ), + ) + .into_element() +} + +/// Every field zeroed: real, honest "nothing recorded" data rather than +/// invented numbers. The daily series is a run of zero-second days ending today +/// so the timeline still renders its columns for each range selector. +fn empty_analytics() -> Analytics { + const EMPTY_DAYS: i64 = 90; + + let today = Local::now().date_naive(); + let daily: Vec = (0..EMPTY_DAYS) + .rev() + .map(|i| DayPlaytime { + date: (today - Duration::days(i)).format("%Y-%m-%d").to_string(), + secs: 0, + }) + .collect(); + + let playtime = PlaytimeStats { + total_secs: 0, + session_count: 0, + per_weekday: [0; 7], + per_hour: [0; 24], + daily, + session_secs: Vec::new(), + active_days: 0, + avg_secs_per_active_day: 0.0, + peak_hour: None, + peak_weekday: None, + night_share: 0.0, + personas: Vec::new(), + }; + + Analytics { + playtime, + servers: Vec::new(), + } +} + fn charts_grid(cards: Vec) -> Element { - let mut grid = rect().vertical().width(Size::fill()).spacing(16.); + let mut grid = rect().vertical().width(Size::fill()).spacing(24.); for pair in cards.chunks(2) { if pair.len() == 1 { grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index a8e3e7cc..0cd7125c 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -3,46 +3,261 @@ use freya::query::QueryStateData; use oneclient_core::game::ServerStat; -use crate::components::{Icon, IconType}; +use crate::components::{ + Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, +}; use crate::hooks::use_cached_image; use crate::theme::colors; +use crate::ui::border_all_color; use crate::utils::{format_duration_hm, plural}; use super::{card, card_header}; -pub(super) fn servers_section(servers: &[ServerStat]) -> Element { - let total_joins: i64 = servers.iter().map(|s| s.joins).sum(); - let max_secs = servers - .iter() - .map(|s| s.total_secs) - .max() - .unwrap_or(0) - .max(1); +/// Number of servers charted; the rest are grouped into an "Other" slice. +const TOP_SERVERS: usize = 10; - let mut list = rect().vertical().width(Size::fill()).spacing(4.); - for server in servers.iter().take(10) { - list = list.child(ServerRow { - server: server.clone(), - max_secs, +pub(super) fn servers_section(servers: &[ServerStat]) -> Element { + ServersSection { + servers: servers.to_vec(), + } + .into_element() +} + +#[derive(PartialEq)] +struct ServersSection { + servers: Vec, +} + +impl Component for ServersSection { + fn render(&self) -> impl IntoElement { + let hovered = use_state(|| Option::::None); + let mut show_all = use_state(|| false); + + let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum(); + + let mut sorted = self.servers.clone(); + sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs)); + + let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS)); + let mut values: Vec = top.iter().map(|s| s.total_secs).collect(); + let mut labels: Vec = top.iter().map(server_label).collect(); + if !rest.is_empty() { + values.push(rest.iter().map(|s| s.total_secs).sum()); + labels.push(format!("{} more", rest.len())); + } + + let details = (!self.servers.is_empty()).then(|| { + Button::new() + .ghost() + .small() + .on_press(move |_| show_all.set(true)) + .child(Icon::new(IconType::Eye).size(12.)) + .text("Details") + .into_element() }); + + card() + .width(Size::fill()) + .spacing(14.) + .child(card_header( + "Servers", + format!( + "{} server{} · {} join{}", + self.servers.len(), + plural(self.servers.len() as i64), + total_joins, + plural(total_joins), + ), + details, + )) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(20.) + .child( + PieChart::new(values.clone(), labels.clone()) + .unit(ValueUnit::Duration) + .hovered(hovered), + ) + .child(rect().width(Size::flex(1.0)).child(legend( + &values, + &labels, + *hovered.read(), + ))), + ) + .maybe_child(show_all.read().then(|| { + OverlayPopup::new() + .on_close(move |_| show_all.set(false)) + .child( + rect() + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .center() + .child(ServerDetailsPopup { + servers: sorted.clone(), + on_close: EventHandler::new(move |()| show_all.set(false)), + }), + ) + .into_element() + })) + } +} + +fn legend(values: &[i64], labels: &[String], active: Option) -> Element { + let total: i64 = values.iter().sum(); + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + + for (i, (value, name)) in values.iter().zip(labels).enumerate() { + let is_active = Some(i) == active; + let pct = if total > 0 { + (*value as f32 / total as f32) * 100. + } else { + 0. + }; + let dim = active.is_some() && !is_active; + + list = list.child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .padding(Gaps::new_symmetric(3., 6.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(if is_active { + colors::component_bg() + } else { + Color::TRANSPARENT + }) + .child( + rect() + .width(Size::px(8.)) + .height(Size::px(8.)) + .corner_radius(CornerRadius::new_all(2.)) + .background(slice_color(i).with_a(if dim { 120 } else { 255 })), + ) + .child( + label() + .text(name.clone()) + .font_size(11.) + .max_lines(1) + .width(Size::flex(1.0)) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!("{pct:.0}%")) + .font_size(11.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(format_duration_hm(*value)) + .font_size(11.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ), + ); } - card() - .width(Size::fill()) - .spacing(14.) - .child(card_header( - "Servers", - format!( - "{} server{} · {} join{}", - servers.len(), - plural(servers.len() as i64), - total_joins, - plural(total_joins), - ), - None, - )) - .child(list) - .into_element() + list.into_element() +} + +/// A Component (not a plain builder) because it mounts a `ScrollArea`, whose +/// hooks run in the caller's render scope — mounting it conditionally from +/// `ServersSection` would change that scope's hook count and panic Freya. +#[derive(PartialEq)] +struct ServerDetailsPopup { + servers: Vec, + on_close: EventHandler<()>, +} + +impl Component for ServerDetailsPopup { + fn render(&self) -> impl IntoElement { + let servers = &self.servers; + let on_close = self.on_close.clone(); + let max_secs = servers + .iter() + .map(|s| s.total_secs) + .max() + .unwrap_or(0) + .max(1); + + let mut list = rect().vertical().width(Size::fill()).spacing(4.); + for server in servers { + list = list.child(ServerRow { + server: server.clone(), + max_secs, + }); + } + + rect() + .vertical() + .width(Size::px(520.)) + .max_height(Size::window_percent(70.)) + .padding(20.) + .spacing(14.) + .corner_radius(CornerRadius::new_all(14.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .child( + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(12.) + .child( + rect() + .vertical() + .width(Size::flex(1.0)) + .spacing(2.) + .child( + label() + .text("All servers") + .font_size(16.) + .font_weight(FontWeight::SEMI_BOLD) + .color(colors::fg_primary()), + ) + .child( + label() + .text(format!( + "{} server{} by playtime", + servers.len(), + plural(servers.len() as i64) + )) + .font_size(12.) + .color(colors::fg_secondary()), + ), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| on_close.call(())) + .child(Icon::new(IconType::XClose).size(12.)), + ), + ) + .child( + ScrollArea::new() + .width(Size::fill()) + .height(Size::px(380.)) + .child(list), + ) + } +} + +/// IP addresses stay masked in the always-visible chart legend; the details +/// popup reveals them on hover only. +fn server_label(s: &ServerStat) -> String { + if s.is_ip { + return "Direct IP".to_string(); + } + display_address(s) } #[derive(PartialEq)] diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index 10bb6bd2..bdfa60a5 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -9,7 +9,7 @@ use crate::utils::format_duration_hm; use super::card; -pub(super) fn tiles_row(analytics: &Analytics) -> Element { +pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element { let stats = &analytics.playtime; let avg_session = if stats.session_count > 0 { stats.total_secs / stats.session_count as i64 @@ -36,7 +36,7 @@ pub(super) fn tiles_row(analytics: &Analytics) -> Element { stats.active_days.to_string(), ), ]; - if !analytics.servers.is_empty() { + if force_all || !analytics.servers.is_empty() { tiles.push(stat_tile( IconType::Globe01, "Server joins", diff --git a/packages/oneclient_app/src/view/app/cluster/overview.rs b/packages/oneclient_app/src/view/app/cluster/overview.rs index 79764af3..bb2458fa 100644 --- a/packages/oneclient_app/src/view/app/cluster/overview.rs +++ b/packages/oneclient_app/src/view/app/cluster/overview.rs @@ -4,7 +4,7 @@ use crate::components::ScrollArea; use crate::hooks::{try_cluster_analytics, use_cluster_analytics}; use crate::layout::cluster_content; use crate::theme::colors; -use crate::view::app::analytics_body; +use crate::view::app::{analytics_body, analytics_placeholder}; use super::{cluster_not_found, load_cluster}; @@ -26,9 +26,9 @@ impl Component for ClusterOverview { let body: Element = match &analytics { None => centered_note("Loading play history…"), - Some(a) if a.playtime.session_count == 0 => { - centered_note("No sessions recorded for this cluster yet. Launch it and come back!") - } + Some(a) if a.playtime.session_count == 0 => analytics_placeholder( + "No sessions recorded for this cluster yet. Launch it and come back!", + ), Some(a) => analytics_body(a), }; @@ -41,7 +41,7 @@ impl Component for ClusterOverview { rect() .vertical() .width(Size::fill()) - .spacing(20.) + .spacing(24.) .child(overview_header(&cluster.name)) .child(body), ), diff --git a/packages/oneclient_app/src/view/app/mod.rs b/packages/oneclient_app/src/view/app/mod.rs index 1cf93964..924029ff 100644 --- a/packages/oneclient_app/src/view/app/mod.rs +++ b/packages/oneclient_app/src/view/app/mod.rs @@ -24,7 +24,7 @@ pub fn launch_button_state(game: &GameSnapshot, cluster_id: i64) -> (&'static st } pub use accounts::Accounts; -pub(crate) use analytics::analytics_body; +pub(crate) use analytics::{analytics_body, analytics_placeholder}; pub use clusters::Clusters; pub use debug::Debug; pub use home::Home; diff --git a/packages/oneclient_app/src/view/app/stats.rs b/packages/oneclient_app/src/view/app/stats.rs index 06157a00..cad698b0 100644 --- a/packages/oneclient_app/src/view/app/stats.rs +++ b/packages/oneclient_app/src/view/app/stats.rs @@ -3,7 +3,7 @@ use freya::prelude::*; use crate::components::ScrollArea; use crate::hooks::{try_global_analytics, use_global_analytics}; use crate::theme::colors; -use crate::view::app::analytics_body; +use crate::view::app::{analytics_body, analytics_placeholder}; #[derive(PartialEq)] pub struct Stats; @@ -15,7 +15,9 @@ impl Component for Stats { let body: Element = match &analytics { None => loading_state(), - Some(a) if a.playtime.session_count == 0 => empty_state(), + Some(a) if a.playtime.session_count == 0 => { + analytics_placeholder("No playtime recorded yet. Launch a game and come back!") + } Some(a) => analytics_body(a), }; @@ -32,7 +34,7 @@ impl Component for Stats { rect() .vertical() .width(Size::fill()) - .padding(40.) + .padding(Gaps::new(0., 40., 40., 40.)) .spacing(24.) .child(header()) .child(body), @@ -65,10 +67,6 @@ fn loading_state() -> Element { centered_note("Crunching your playtime…") } -fn empty_state() -> Element { - centered_note("No playtime recorded yet. Launch a game and come back!") -} - fn centered_note(text: &str) -> Element { rect() .width(Size::fill()) diff --git a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs index 5bf807b2..7635a0a8 100644 --- a/packages/oneclient_app/src/view/onboarding/bundles/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/bundles/mod.rs @@ -69,7 +69,13 @@ impl OptInBundle { fn mod_count(&self) -> usize { self.members .first() - .map(|(_, a)| a.manifest.files.iter().filter(|f| f.enabled && !f.hidden).count()) + .map(|(_, a)| { + a.manifest + .files + .iter() + .filter(|f| f.enabled && !f.hidden) + .count() + }) .unwrap_or(0) } } @@ -133,7 +139,12 @@ fn optional_mods(clusters: &[ClusterBundles]) -> Vec { for cb in clusters { for archive in &cb.archives { - for file in archive.manifest.files.iter().filter(|f| is_optional_file(f)) { + for file in archive + .manifest + .files + .iter() + .filter(|f| is_optional_file(f)) + { let package_id = file.kind.package_id(); let entry = map.entry(package_id.clone()).or_insert_with(|| { order.push(package_id.clone()); @@ -227,9 +238,8 @@ impl Component for OnboardingBundles { let mut sections: Vec = Vec::new(); for bundle in &opt_ins { - sections.push( - opt_in_card(bundle, &selected_set, selected, user_touched).into_element(), - ); + sections + .push(opt_in_card(bundle, &selected_set, selected, user_touched).into_element()); } if !extras.is_empty() { @@ -336,12 +346,7 @@ fn opt_in_card( if wanted { return; } - set_bundle_versions( - selected, - user_touched, - &yes_bundle, - &yes_ids, - ) + set_bundle_versions(selected, user_touched, &yes_bundle, &yes_ids) }, )), ) @@ -354,9 +359,7 @@ fn opt_in_card( "", !wanted, Size::fill(), - move |()| { - set_bundle_versions(selected, user_touched, &no_bundle, &[]) - }, + move |()| set_bundle_versions(selected, user_touched, &no_bundle, &[]), )), ), ); diff --git a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs index 10dbc8fe..cfa70c03 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/mod.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/mod.rs @@ -4,10 +4,10 @@ use std::time::{Duration, Instant}; use freya::animation::*; use freya::prelude::*; use freya::router::RouterContext; -use oneclient_core::{BundleArchive, BundleFile, ImportTarget}; use oneclient_core::notification::{ GroupedProgressEvent, GroupedProgressSession, Notification, NotificationService, }; +use oneclient_core::{BundleArchive, BundleFile, ImportTarget}; use oneclient_db::models::OverrideType; use tokio::sync::mpsc; use uuid::Uuid; @@ -484,7 +484,11 @@ fn archive_overrides( let mut overrides = Vec::new(); for file in &archive.manifest.files { - let wanted = if file.hidden { bundle_taken } else { wants(file) }; + let wanted = if file.hidden { + bundle_taken + } else { + wants(file) + }; let override_type = match (wanted, file.enabled) { // Matches the manifest default; nothing to record. @@ -702,13 +706,9 @@ async fn install_one( "Saving your package choices...".to_string(), )); } - oneclient_core::set_bundle_package_overrides( - plan.cluster_id, - &plan.overrides, - &state.services, - ) - .await - .map_err(|err| err.to_string())?; + oneclient_core::set_bundle_package_overrides(plan.cluster_id, &plan.overrides, &state.services) + .await + .map_err(|err| err.to_string())?; if !predownload { return Ok(()); @@ -963,14 +963,16 @@ mod tests { archives: vec![sb.clone()], }, ]; - let selected: HashSet = - [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); + let selected: HashSet = [pkg_key(1, &sb.manifest.name, "skyblock-main")].into(); let plans = build_plans(&both, &selected); assert!(plans[0].overrides.is_empty()); - assert!(plans[1].overrides.iter().any(|(_, pid, ty)| pid - == "skyblock-main" - && *ty == OverrideType::Removed)); + assert!( + plans[1] + .overrides + .iter() + .any(|(_, pid, ty)| pid == "skyblock-main" && *ty == OverrideType::Removed) + ); } #[test] @@ -985,8 +987,7 @@ mod tests { let accepted = rough_download_estimate(&all, &keys(&sb, &["skyblock-main"])); assert_eq!(accepted, baseline + 2); - let with_extra = - rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"])); + let with_extra = rough_download_estimate(&all, &keys(&sb, &["skyblock-main", "skycubed"])); assert_eq!(with_extra, baseline + 3); } } diff --git a/packages/oneclient_app/src/view/onboarding/migration.rs b/packages/oneclient_app/src/view/onboarding/migration.rs index c5887188..7df6d269 100644 --- a/packages/oneclient_app/src/view/onboarding/migration.rs +++ b/packages/oneclient_app/src/view/onboarding/migration.rs @@ -10,7 +10,9 @@ use crate::hooks::{ use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; -use crate::view::onboarding::{choice_row, onboarding_illustration, onboarding_slide, step_heading}; +use crate::view::onboarding::{ + choice_row, onboarding_illustration, onboarding_slide, step_heading, +}; fn migrated_categories(source: &[SourceInstance]) -> Vec { let mut categories: Vec = Vec::new(); diff --git a/packages/oneclient_app/src/view/onboarding/selection.rs b/packages/oneclient_app/src/view/onboarding/selection.rs index a9538820..9afe969f 100644 --- a/packages/oneclient_app/src/view/onboarding/selection.rs +++ b/packages/oneclient_app/src/view/onboarding/selection.rs @@ -83,11 +83,7 @@ pub fn set_archive_selected( selected: &mut HashSet, ) { for file in archive.manifest.files.iter().filter(|f| is_default_file(f)) { - let key = pkg_key( - cluster_id, - &archive.manifest.name, - &file.kind.package_id(), - ); + let key = pkg_key(cluster_id, &archive.manifest.name, &file.kind.package_id()); if on { selected.insert(key); } else { diff --git a/packages/oneclient_app/src/view/onboarding/test_support.rs b/packages/oneclient_app/src/view/onboarding/test_support.rs index 9aea0908..516df63c 100644 --- a/packages/oneclient_app/src/view/onboarding/test_support.rs +++ b/packages/oneclient_app/src/view/onboarding/test_support.rs @@ -64,4 +64,3 @@ pub fn cluster(id: i64) -> Cluster { linked_modpack_hash: None, } } - diff --git a/packages/oneclient_core/src/constants.rs b/packages/oneclient_core/src/constants.rs index 6d01a600..ab28ed17 100644 --- a/packages/oneclient_core/src/constants.rs +++ b/packages/oneclient_core/src/constants.rs @@ -11,7 +11,6 @@ pub const MODRINTH_CDN_PREFIX: &str = "https://cdn.modrinth.com/data/"; pub const CURSEFORGE_API_URL: &str = "https://api.curseforge.com/v1"; pub const CURSEFORGE_GAME_ID: u32 = 432; pub const METADATA_API_URL: &str = "https://meta.polyfrost.org"; -pub const FEATURED_PACKAGES_URL: &str = "https://polyfrost.org/meta/onelauncher/featured.json"; pub const MCLOGS_API_URL: &str = "https://api.mclo.gs/1"; pub const SKYCLIENT_BASE_URL: &str = "https://raw.githubusercontent.com/SkyblockClient/SkyblockClient-REPO/refs/heads/main/v1";