feat(ui): Stats page tweaks (pie charts and consistent spacing)

This commit is contained in:
LynithDev 2026-07-16 11:01:51 +02:00
parent d593ecb09f
commit 0a51018954
16 changed files with 697 additions and 140 deletions

View file

@ -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<LauncherState>,
state: &Arc<LauncherState>,
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) {

View file

@ -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<i64>,
labels: Vec<String>,
unit: ValueUnit,
size: f32,
hovered: Option<State<Option<usize>>>,
}
impl PieChart {
pub fn new(values: Vec<i64>, labels: Vec<String>) -> 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<Option<usize>>) -> 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::<usize>::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<PointerEventData>| {
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<usize> {
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<i64>,
labels: Vec<String>,
readout_labels: Vec<String>,
highlight: Option<usize>,
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<String>) -> 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::<usize>::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<SizedEventData>| {
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())

View file

@ -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;

View file

@ -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<i64> = slice.iter().map(|(_, s)| *s).collect();
let readout_labels: Vec<String> = slice.iter().map(|(d, _)| format_day(*d)).collect();
let labels: Vec<String> = slice
.iter()
.enumerate()
.map(|(i, (d, _))| {
if i == 0 || i == slice.len() - 1 {
format_day(*d)
} else {
String::new()
}
})
.collect();
let labels: Vec<String> = 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<Element> {
if session_secs.len() < 2 {
pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option<Element> {
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<Element> {
fn weekday_labels() -> Vec<String> {
WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect()
}
fn hour_labels() -> Vec<String> {
(0..24)
.map(|h| {
if h % 6 == 0 {
format_hour(h)
} else {
String::new()
}
})
.collect()
}

View file

@ -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<DayPlaytime> = (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>) -> 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()));

View file

@ -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<ServerStat>,
}
impl Component for ServersSection {
fn render(&self) -> impl IntoElement {
let hovered = use_state(|| Option::<usize>::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<i64> = top.iter().map(|s| s.total_secs).collect();
let mut labels: Vec<String> = 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<usize>) -> 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<ServerStat>,
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)]

View file

@ -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",

View file

@ -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),
),

View file

@ -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;

View file

@ -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())

View file

@ -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<OptionalMod> {
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<Element> = 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, &[]),
)),
),
);

View file

@ -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<String> =
[pkg_key(1, &sb.manifest.name, "skyblock-main")].into();
let selected: HashSet<String> = [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);
}
}

View file

@ -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<String> {
let mut categories: Vec<String> = Vec::new();

View file

@ -83,11 +83,7 @@ pub fn set_archive_selected(
selected: &mut HashSet<String>,
) {
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 {

View file

@ -64,4 +64,3 @@ pub fn cluster(id: i64) -> Cluster {
linked_modpack_hash: None,
}
}

View file

@ -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";