From a00e11d2049375c50067ac65930faa7307983c3c Mon Sep 17 00:00:00 2001
From: Maciek <108873945+rozwader@users.noreply.github.com>
Date: Wed, 16 Sep 2026 20:29:14 +0200
Subject: [PATCH] feat: control center and smaller navbar link gaps (#832)
* feat: added a control center
* feat: added no account signed in handling; added a modal on sign out clicked
* feat: use the discord icon for the discord rpc quick setting
* style: fade out disabled quick setting tiles
* style: drop the 'Active' suffix from the control center account subtitle
* feat: added a gear indicator to the navbar avatar button
* add small gear icon on avatar
---------
Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
---
.../assets/icons/bar-chart-square-02.svg | 3 +
.../oneclient_app/assets/icons/discord.svg | 1 +
.../oneclient_app/assets/icons/log-out-01.svg | 3 +
.../oneclient_app/assets/icons/moon-01.svg | 3 +
.../src/components/account_switcher.rs | 54 +-
.../oneclient_app/src/components/button.rs | 9 +-
.../src/components/control_center.rs | 634 ++++++++++++++++++
.../oneclient_app/src/components/icons.rs | 4 +
packages/oneclient_app/src/components/mod.rs | 2 +
.../src/components/navbar/app_navbar.rs | 58 +-
packages/oneclient_app/src/hooks/actions.rs | 39 ++
packages/oneclient_app/src/hooks/mod.rs | 4 +
.../oneclient_app/src/layout/root_layout.rs | 3 +-
packages/oneclient_app/src/state.rs | 2 +
14 files changed, 793 insertions(+), 26 deletions(-)
create mode 100644 packages/oneclient_app/assets/icons/bar-chart-square-02.svg
create mode 100644 packages/oneclient_app/assets/icons/discord.svg
create mode 100644 packages/oneclient_app/assets/icons/log-out-01.svg
create mode 100644 packages/oneclient_app/assets/icons/moon-01.svg
create mode 100644 packages/oneclient_app/src/components/control_center.rs
diff --git a/packages/oneclient_app/assets/icons/bar-chart-square-02.svg b/packages/oneclient_app/assets/icons/bar-chart-square-02.svg
new file mode 100644
index 00000000..50591e14
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/bar-chart-square-02.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/assets/icons/discord.svg b/packages/oneclient_app/assets/icons/discord.svg
new file mode 100644
index 00000000..81605170
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/discord.svg
@@ -0,0 +1 @@
+
diff --git a/packages/oneclient_app/assets/icons/log-out-01.svg b/packages/oneclient_app/assets/icons/log-out-01.svg
new file mode 100644
index 00000000..14cdf901
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/log-out-01.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/assets/icons/moon-01.svg b/packages/oneclient_app/assets/icons/moon-01.svg
new file mode 100644
index 00000000..d6cbd869
--- /dev/null
+++ b/packages/oneclient_app/assets/icons/moon-01.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/oneclient_app/src/components/account_switcher.rs b/packages/oneclient_app/src/components/account_switcher.rs
index 673b7dbf..0f18cc61 100644
--- a/packages/oneclient_app/src/components/account_switcher.rs
+++ b/packages/oneclient_app/src/components/account_switcher.rs
@@ -107,11 +107,19 @@ impl Component for AccountPanel {
Color::from_argb(120, 0, 0, 0),
)))
.child(
- label()
- .text("Accounts")
- .font_size(18.)
- .font_weight(FontWeight::MEDIUM)
- .a11y_role(AccessibilityRole::TitleBar),
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .cross_align(Alignment::Center)
+ .spacing(6.)
+ .child(BackToControlCenter)
+ .child(
+ label()
+ .text("Accounts")
+ .font_size(18.)
+ .font_weight(FontWeight::MEDIUM)
+ .a11y_role(AccessibilityRole::TitleBar),
+ ),
)
.child(divider())
.child(rows)
@@ -120,6 +128,42 @@ impl Component for AccountPanel {
}
}
+#[derive(PartialEq)]
+struct BackToControlCenter;
+
+impl Component for BackToControlCenter {
+ fn render(&self) -> impl IntoElement {
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let back = move |_| {
+ dispatch.close_account_switcher();
+ dispatch.open_control_center();
+ };
+
+ rect()
+ .width(Size::px(28.))
+ .height(Size::px(28.))
+ .center()
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .a11y_alt("Back to control center")
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(back)
+ .child(
+ Icon::new(IconType::ArrowLeft)
+ .size(18.)
+ .color(colors::fg_secondary()),
+ )
+ }
+}
+
struct AccountRow {
account: MinecraftAccount,
active: bool,
diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs
index 30a28be8..b4276b9a 100644
--- a/packages/oneclient_app/src/components/button.rs
+++ b/packages/oneclient_app/src/components/button.rs
@@ -56,6 +56,7 @@ pub struct Button {
tooltip: Option>,
tooltip_placement: TooltipPlacement,
on_press: Option>>,
+ overflow: Overflow,
elements: Vec,
key: DiffKey,
@@ -117,6 +118,7 @@ impl Button {
tooltip_placement: TooltipPlacement::default(),
on_press: None,
elements: Vec::new(),
+ overflow: Overflow::Clip,
key: DiffKey::None,
cursor_icon: CursorIcon::Pointer,
}
@@ -156,6 +158,11 @@ impl Button {
self
}
+ pub fn overflow(mut self, overflow: Overflow) -> Self {
+ self.overflow = overflow;
+ self
+ }
+
pub fn font_size(mut self, font_size: impl Into) -> Self {
self.text_style.font_size = Some(font_size.into().into());
self
@@ -314,7 +321,7 @@ impl Component for Button {
.a11y_focusable(enabled() && self.focusable)
.a11y_role(AccessibilityRole::Button)
.map(self.alt.clone(), |rect, alt| rect.a11y_alt(alt))
- .overflow(Overflow::Clip)
+ .overflow(self.overflow)
.layout(self.layout.clone())
.text_style(self.text_style.clone())
.children(self.elements.clone());
diff --git a/packages/oneclient_app/src/components/control_center.rs b/packages/oneclient_app/src/components/control_center.rs
new file mode 100644
index 00000000..1fce04f4
--- /dev/null
+++ b/packages/oneclient_app/src/components/control_center.rs
@@ -0,0 +1,634 @@
+use freya::{
+ animation::{AnimNum, Ease, OnCreation, use_animation},
+ prelude::*,
+ router::RouterContext,
+};
+
+use crate::{
+ Route,
+ components::{Avatar, Button, Icon, IconType, OVERLAY_MAX_LEVEL, OverlayPopup},
+ hooks::{
+ RemoveAccountKeys, query_error, query_is_loading, try_accounts, try_default_account,
+ use_accounts, use_control_center_open, use_current_account, use_dispatch,
+ use_remove_account, use_settings_snapshot,
+ },
+ theme::colors,
+ ui::{border_all_color, divider},
+};
+
+const PANEL_WIDTH: f32 = 344.;
+
+#[derive(PartialEq)]
+pub struct ControlCenter;
+
+impl Component for ControlCenter {
+ fn render(&self) -> impl IntoElement {
+ let open = use_control_center_open();
+ let dispatch = use_dispatch();
+
+ if !open {
+ return rect().into_element();
+ }
+
+ OverlayPopup::new()
+ .position(Position::new_global().top(72.).right(40.))
+ .on_close(move |()| dispatch.close_control_center())
+ .child(ControlPanel)
+ .into_element()
+ }
+}
+
+#[derive(PartialEq)]
+struct ControlPanel;
+
+impl Component for ControlPanel {
+ fn render(&self) -> impl IntoElement {
+ let intro = use_animation(|conf| {
+ conf.on_creation(OnCreation::Run);
+ AnimNum::new(0., 1.).time(200).ease(Ease::Out)
+ });
+ let progress = intro.read().value();
+
+ rect()
+ .vertical()
+ .width(Size::px(PANEL_WIDTH))
+ .padding(Gaps::new_all(12.))
+ .spacing(10.)
+ .opacity(progress)
+ .margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
+ .background(colors::page_elevated().with_a(220))
+ .blur(12.)
+ .corner_radius(CornerRadius::new_all(14.))
+ .border(
+ Border::new()
+ .fill(colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ .shadow(Shadow::from((
+ 0.,
+ 8.,
+ 32.,
+ 0.,
+ Color::from_argb(120, 0, 0, 0),
+ )))
+ .child(AccountHeader)
+ .child(divider())
+ .child(
+ rect()
+ .vertical()
+ .width(Size::fill())
+ .spacing(2.)
+ .child(NavRow {
+ icon: IconType::BarChartSquare02,
+ row_label: "Stats",
+ target: Route::Stats {},
+ })
+ .child(NavRow {
+ icon: IconType::Settings02,
+ row_label: "Settings",
+ target: Route::SettingsLauncher {},
+ }),
+ )
+ .child(section_label("QUICK SETTINGS"))
+ .child(QuickSettings)
+ .child(divider())
+ .child(PanelFooter)
+ }
+}
+
+fn section_label(text: &'static str) -> impl IntoElement {
+ label()
+ .text(text)
+ .font_size(11.)
+ .letter_spacing(0.8)
+ .font_weight(FontWeight::SEMI_BOLD)
+ .color(colors::fg_secondary())
+}
+
+#[derive(PartialEq)]
+struct AccountHeader;
+
+impl Component for AccountHeader {
+ fn render(&self) -> impl IntoElement {
+ let current = use_current_account();
+ let accounts_query = use_accounts();
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let loading = query_is_loading(¤t);
+ let unusable = query_error(¤t).is_some();
+ let account = try_default_account(¤t).or_else(|| {
+ unusable
+ .then(|| try_accounts(&accounts_query).unwrap_or_default().into_iter().next())
+ .flatten()
+ });
+
+ let uuid = account
+ .as_ref()
+ .map(|account| account.id.to_string())
+ .unwrap_or_else(|| uuid::Uuid::nil().to_string());
+ let username = match account.as_ref() {
+ Some(account) => account.username.clone(),
+ None if loading => "Loading…".to_string(),
+ None => "Not signed in".to_string(),
+ };
+ let subtitle = match (account.as_ref(), unusable) {
+ (_, true) => "Microsoft Account required",
+ (Some(account), false) if account.is_microsoft() => "Microsoft account",
+ (Some(_), false) => "Offline account",
+ (None, false) if loading => "Checking accounts",
+ (None, false) => "No account selected",
+ };
+ let subtitle_color = if unusable {
+ colors::danger()
+ } else {
+ colors::fg_secondary()
+ };
+
+ let signed_out = account.is_none() && !loading;
+ let action_label = if signed_out { "Sign In" } else { "Switch" };
+ let open_switcher = move |_| {
+ dispatch.close_control_center();
+ if signed_out {
+ let _ = RouterContext::get().push(Route::SettingsAccounts {});
+ } else {
+ dispatch.open_account_switcher();
+ }
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .padding(Gaps::new_all(4.))
+ .child(
+ Avatar::new(uuid)
+ .width(Size::px(40.))
+ .height(Size::px(40.)),
+ )
+ .child(
+ rect()
+ .vertical()
+ .width(Size::flex(1.0))
+ .spacing(2.)
+ .child(
+ label()
+ .text(username)
+ .font_size(16.)
+ .font_weight(FontWeight::MEDIUM)
+ .max_lines(1)
+ .color(colors::fg_primary()),
+ )
+ .child(
+ label()
+ .text(subtitle)
+ .font_size(12.)
+ .max_lines(1)
+ .color(subtitle_color),
+ ),
+ )
+ .child(
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(6.)
+ .padding(Gaps::new_symmetric(6., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(open_switcher)
+ .child(
+ label()
+ .text(action_label)
+ .font_size(13.)
+ .color(colors::fg_secondary()),
+ )
+ .child(
+ Icon::new(IconType::ArrowRight)
+ .size(14.)
+ .color(colors::fg_secondary()),
+ ),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct NavRow {
+ icon: IconType,
+ row_label: &'static str,
+ target: Route,
+}
+
+impl Component for NavRow {
+ fn render(&self) -> impl IntoElement {
+ let dispatch = use_dispatch();
+ let mut hovered = use_state(|| false);
+
+ let target = self.target.clone();
+ let go = move |_| {
+ dispatch.close_control_center();
+ let _ = RouterContext::get().push(target.clone());
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .height(Size::px(42.))
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(12.)
+ .padding(Gaps::new_symmetric(0., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .on_press(go)
+ .child(
+ Icon::new(self.icon)
+ .size(20.)
+ .color(colors::fg_secondary()),
+ )
+ .child(
+ label()
+ .text(self.row_label)
+ .font_size(15.)
+ .width(Size::flex(1.0))
+ .color(colors::fg_primary()),
+ )
+ .child(
+ Icon::new(IconType::ArrowRight)
+ .size(16.)
+ .color(colors::fg_secondary()),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct QuickSettings;
+
+impl Component for QuickSettings {
+ fn render(&self) -> impl IntoElement {
+ let settings = use_settings_snapshot().settings;
+ let dispatch = use_dispatch();
+
+ let discord_on = settings.discord_enabled;
+ let toggle_discord = move |_| {
+ dispatch.edit_settings(|s| s.discord_enabled = !s.discord_enabled);
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .spacing(8.)
+ .child(
+ QuickTile::new(IconType::Discord, "Discord RPC", discord_on)
+ .on_press(toggle_discord),
+ )
+ .child(QuickTile::new(IconType::Moon01, "Close on launch", false).disabled())
+ }
+}
+
+struct QuickTile {
+ icon: IconType,
+ title: &'static str,
+ on: bool,
+ enabled: bool,
+ on_press: Option>>,
+}
+
+impl PartialEq for QuickTile {
+ fn eq(&self, other: &Self) -> bool {
+ self.icon == other.icon
+ && self.title == other.title
+ && self.on == other.on
+ && self.enabled == other.enabled
+ }
+}
+
+impl QuickTile {
+ fn new(icon: IconType, title: &'static str, on: bool) -> Self {
+ Self {
+ icon,
+ title,
+ on,
+ enabled: true,
+ on_press: None,
+ }
+ }
+
+ fn disabled(mut self) -> Self {
+ self.enabled = false;
+ self
+ }
+
+ fn on_press(mut self, on_press: impl Into>>) -> Self {
+ self.on_press = Some(on_press.into());
+ self
+ }
+}
+
+impl Component for QuickTile {
+ fn render(&self) -> impl IntoElement {
+ let mut hovered = use_state(|| false);
+
+ let on = self.on;
+ let enabled = self.enabled;
+ let handler = self.on_press.clone();
+
+ let background = if !enabled {
+ colors::component_bg().with_a(120)
+ } else if on {
+ if *hovered.read() {
+ colors::brand_hover()
+ } else {
+ colors::brand()
+ }
+ } else if *hovered.read() {
+ colors::component_bg_hover()
+ } else {
+ colors::component_bg()
+ };
+
+ let title_color = if enabled {
+ colors::fg_primary()
+ } else {
+ colors::fg_primary_disabled()
+ };
+
+ let sub_color = if !enabled {
+ colors::fg_primary_disabled()
+ } else if on {
+ colors::fg_primary().with_a(200)
+ } else {
+ colors::fg_secondary()
+ };
+
+ rect()
+ .horizontal()
+ .width(Size::flex(1.0))
+ .height(Size::px(64.))
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .padding(Gaps::new_symmetric(0., 12.))
+ .corner_radius(CornerRadius::new_all(10.))
+ .background(background)
+ .maybe(!enabled, |el| el.opacity(0.45))
+ .maybe(!on || !enabled, |el| {
+ el.border(
+ Border::new()
+ .fill(colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ })
+ .maybe(enabled, |el| {
+ el.cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ })
+ .map(handler, |el, handler| el.on_press(move |e| handler.call(e)))
+ .child(Icon::new(self.icon).size(24.).color(title_color))
+ .child(
+ rect()
+ .vertical()
+ .width(Size::flex(1.0))
+ .child(
+ label()
+ .text(self.title)
+ .font_size(12.)
+ .font_weight(FontWeight::MEDIUM)
+ .max_lines(1)
+ .color(title_color),
+ )
+ .child(
+ label()
+ .text(if on { "On" } else { "Off" })
+ .font_size(11.)
+ .color(sub_color),
+ ),
+ )
+ }
+}
+
+#[derive(PartialEq)]
+struct PanelFooter;
+
+impl Component for PanelFooter {
+ fn render(&self) -> impl IntoElement {
+ let settings = use_settings_snapshot().settings;
+ let current = use_current_account();
+ let remove = use_remove_account();
+
+ let mut confirming = use_state(|| false);
+
+ let folder = settings
+ .data_dir
+ .clone()
+ .or_else(|| oneclient_core::settings::data_dir::default_path().ok());
+
+ let account = try_default_account(¤t);
+ let account_id = account.as_ref().map(|account| account.id);
+ let username = account
+ .as_ref()
+ .map(|account| account.username.clone())
+ .unwrap_or_default();
+
+ let confirm_sign_out = move |_| {
+ if let Some(id) = account_id {
+ remove.mutate(RemoveAccountKeys { id });
+ }
+ confirming.set(false);
+ };
+
+ rect()
+ .vertical()
+ .width(Size::fill())
+ .child(
+ rect()
+ .horizontal()
+ .width(Size::fill())
+ .content(Content::Flex)
+ .cross_align(Alignment::Center)
+ .child(
+ footer_action(
+ IconType::Folder,
+ "Open launcher folder",
+ colors::fg_primary(),
+ )
+ .on_press(move |_| {
+ if let Some(folder) = folder.clone() {
+ std::fs::create_dir_all(&folder).ok();
+ crate::platform::open_path(&folder.to_string_lossy());
+ }
+ }),
+ )
+ .child(rect().width(Size::flex(1.0)))
+ .map(account_id, |el, _| {
+ el.child(
+ footer_action(
+ IconType::LogOut01,
+ "Sign out",
+ colors::fg_primary(),
+ )
+ .on_press(move |_| confirming.set(true)),
+ )
+ }),
+ )
+ .maybe(*confirming.read(), |el| {
+ el.child(
+ OverlayPopup::new()
+ .overlay_level(OVERLAY_MAX_LEVEL)
+ .on_close(move |_| confirming.set(false))
+ .child(
+ rect()
+ .width(Size::window_percent(100.))
+ .height(Size::window_percent(100.))
+ .center()
+ .child(
+ rect()
+ .vertical()
+ .width(Size::px(400.))
+ .max_width(Size::window_percent(90.))
+ .spacing(14.)
+ .padding(Gaps::new_all(20.))
+ .corner_radius(CornerRadius::new_all(14.))
+ .background(colors::page_elevated())
+ .border(border_all_color(
+ 1.,
+ colors::component_border(),
+ ))
+ .child(
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(10.)
+ .child(
+ Icon::new(IconType::AlertTriangle)
+ .size(20.)
+ .color(colors::code_warn()),
+ )
+ .child(
+ label()
+ .text("Sign out?")
+ .font_size(16.)
+ .font_weight(FontWeight::SEMI_BOLD)
+ .color(colors::fg_primary()),
+ ),
+ )
+ .child(
+ label()
+ .text(format!(
+ "{username} will be removed from OneClient. You will need to sign in again to use this account."
+ ))
+ .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()
+ .text("Cancel")
+ .on_press(move |_| {
+ confirming.set(false)
+ }),
+ )
+ .child(
+ Button::new()
+ .danger()
+ .text("Sign out")
+ .on_press(confirm_sign_out),
+ ),
+ ),
+ ),
+ ),
+ )
+ })
+ }
+}
+
+fn footer_action(icon: IconType, text: &'static str, color: Color) -> FooterAction {
+ FooterAction {
+ icon,
+ text,
+ color,
+ on_press: None,
+ }
+}
+
+struct FooterAction {
+ icon: IconType,
+ text: &'static str,
+ color: Color,
+ on_press: Option>>,
+}
+
+impl PartialEq for FooterAction {
+ fn eq(&self, other: &Self) -> bool {
+ self.icon == other.icon && self.text == other.text && self.color == other.color
+ }
+}
+
+impl FooterAction {
+ fn on_press(mut self, on_press: impl Into>>) -> Self {
+ self.on_press = Some(on_press.into());
+ self
+ }
+}
+
+impl Component for FooterAction {
+ fn render(&self) -> impl IntoElement {
+ let mut hovered = use_state(|| false);
+ let handler = self.on_press.clone();
+
+ rect()
+ .horizontal()
+ .cross_align(Alignment::Center)
+ .spacing(8.)
+ .height(Size::px(34.))
+ .padding(Gaps::new_symmetric(0., 8.))
+ .corner_radius(CornerRadius::new_all(8.))
+ .background(Color::RED.with_a(0))
+ .maybe(*hovered.read(), |el| {
+ el.background(colors::ghost_overlay_hover())
+ })
+ .cursor(CursorIcon::Pointer)
+ .a11y_role(AccessibilityRole::Button)
+ .on_pointer_enter(move |_| hovered.set(true))
+ .on_pointer_leave(move |_| hovered.set(false))
+ .map(handler, |el, handler| {
+ el.on_press(move |e| handler.call(e))
+ })
+ .child(Icon::new(self.icon).size(16.).color(self.color))
+ .child(
+ label()
+ .text(self.text)
+ .font_size(13.)
+ .max_lines(1)
+ .color(self.color),
+ )
+ }
+}
diff --git a/packages/oneclient_app/src/components/icons.rs b/packages/oneclient_app/src/components/icons.rs
index 4847de28..0675cf11 100644
--- a/packages/oneclient_app/src/components/icons.rs
+++ b/packages/oneclient_app/src/components/icons.rs
@@ -87,6 +87,7 @@ pub enum IconType {
ArrowLeft,
ArrowRight,
Brush01,
+ BarChartSquare02,
Bell01,
Calendar,
CheckCircle,
@@ -102,6 +103,7 @@ pub enum IconType {
Copy01,
Curseforge,
Database01,
+ Discord,
DotsGrid,
DotsVertical,
Download01,
@@ -123,10 +125,12 @@ pub enum IconType {
Link03,
LinkExternal01,
Loading02,
+ LogOut01,
Maximize01,
MessageTextSquare01,
Minus,
Modrinth,
+ Moon01,
OnboardingAccount,
OnboardingComplete,
OnboardingLanguage,
diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs
index de7cdae1..5dd33b9e 100644
--- a/packages/oneclient_app/src/components/mod.rs
+++ b/packages/oneclient_app/src/components/mod.rs
@@ -1,4 +1,5 @@
mod account_switcher;
+mod control_center;
mod active_cluster_panel;
mod avatar;
mod button;
@@ -47,6 +48,7 @@ mod version_card;
pub mod upload_mclogs;
pub use account_switcher::AccountSwitcher;
+pub use control_center::ControlCenter;
pub use active_cluster_panel::ActiveClusterPanel;
pub use avatar::Avatar;
pub use button::{Button, ButtonSize, ButtonVariant, link_button, open_folder_button};
diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs
index 3be4be2f..304f967d 100644
--- a/packages/oneclient_app/src/components/navbar/app_navbar.rs
+++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs
@@ -280,12 +280,8 @@ impl Component for NavbarRight {
notif_dispatch.toggle_notification_center();
};
- let open_account_switcher = move |_| {
- dispatch.toggle_account_switcher();
- };
-
- let open_settings = |_| {
- let _ = RouterContext::get().push(Route::SettingsLauncher {});
+ let open_control_center = move |_| {
+ dispatch.toggle_control_center();
};
let open_stats = |_| {
@@ -312,25 +308,49 @@ impl Component for NavbarRight {
)
.child(
super::navbar_button()
- .tooltip("Settings")
- .child(Icon::new(IconType::Settings02).size(20.))
- .on_press(open_settings),
- )
- .child(
- super::navbar_button()
+ .overflow(Overflow::None)
+ .tooltip("Control Center")
.padding(0.0)
- .tooltip("Switch account")
- .on_press(open_account_switcher)
- .child(
- Avatar::new(account_uuid)
- .width(Size::px(24.))
- .height(Size::px(24.)),
- ),
+ .on_press(open_control_center)
+ .child(avatar_with_gear(account_uuid)),
)
.child(super::window_controls())
}
}
+fn avatar_with_gear(uuid: String) -> impl IntoElement {
+ rect()
+ .width(Size::px(28.))
+ .height(Size::px(28.))
+ .center()
+ .child(
+ Avatar::new(uuid)
+ .width(Size::px(28.))
+ .height(Size::px(28.)),
+ )
+ .child(
+ rect()
+ .position(Position::new_absolute().bottom(-7.).right(-7.))
+ .width(Size::px(16.))
+ .height(Size::px(16.))
+ .corner_radius(CornerRadius::from(7.))
+ .background(theme::colors::page_elevated())
+ .border(
+ Border::new()
+ .fill(theme::colors::component_border())
+ .width(1.)
+ .alignment(BorderAlignment::Inner),
+ )
+ .layer(Layer::Relative(3))
+ .center()
+ .child(
+ Icon::new(IconType::Settings02)
+ .size(12.)
+ .color(theme::colors::fg_secondary()),
+ ),
+ )
+}
+
fn notification_bell(unread: usize) -> impl IntoElement {
rect()
.width(Size::px(20.))
diff --git a/packages/oneclient_app/src/hooks/actions.rs b/packages/oneclient_app/src/hooks/actions.rs
index b83a9ce7..33443417 100644
--- a/packages/oneclient_app/src/hooks/actions.rs
+++ b/packages/oneclient_app/src/hooks/actions.rs
@@ -255,6 +255,16 @@ impl Actions {
});
}
+ pub fn edit_settings(&self, edit: impl FnOnce(&mut LauncherSettings)) {
+ let Some(updated) = self.mutate_settings(edit) else {
+ return;
+ };
+ if let Ok(state) = launcher::state() {
+ state.discord.set_enabled(updated.discord_enabled);
+ }
+ self.persist(updated);
+ }
+
pub fn set_settings(&self, settings: LauncherSettings) {
let Some(updated) = self.mutate_settings(|s| *s = settings) else {
return;
@@ -634,6 +644,13 @@ impl Actions {
.account_switcher_open = !open;
}
+ pub fn open_account_switcher(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::AccountSwitcher)
+ .account_switcher_open = true;
+ }
+
pub fn close_account_switcher(&self) {
self.station
.clone()
@@ -641,6 +658,28 @@ impl Actions {
.account_switcher_open = false;
}
+ pub fn toggle_control_center(&self) {
+ let open = self.station.peek().control_center_open;
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = !open;
+ }
+
+ pub fn open_control_center(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = true;
+ }
+
+ pub fn close_control_center(&self) {
+ self.station
+ .clone()
+ .write_channel(AppChannel::ControlCenter)
+ .control_center_open = false;
+ }
+
pub fn close_notification_center(&self) {
self.with_engine(|state| state.center_open = false);
}
diff --git a/packages/oneclient_app/src/hooks/mod.rs b/packages/oneclient_app/src/hooks/mod.rs
index c2e76d39..cf024931 100644
--- a/packages/oneclient_app/src/hooks/mod.rs
+++ b/packages/oneclient_app/src/hooks/mod.rs
@@ -116,6 +116,10 @@ pub fn use_account_switcher_open() -> bool {
.account_switcher_open
}
+pub fn use_control_center_open() -> bool {
+ use_radio(AppChannel::ControlCenter).read().control_center_open
+}
+
pub fn use_game_snapshot() -> GameState {
use_radio(AppChannel::Game).read().game.clone()
}
diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs
index a291e302..8f481f69 100644
--- a/packages/oneclient_app/src/layout/root_layout.rs
+++ b/packages/oneclient_app/src/layout/root_layout.rs
@@ -2,7 +2,7 @@ use freya::prelude::*;
use freya::router::*;
use crate::components::{
- AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
+ AccountSwitcher, ClusterUpdatePopup, ControlCenter, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay,
NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost,
UpdatePromptOverlay, use_provide_tooltips,
};
@@ -74,6 +74,7 @@ impl Component for RootLayout {
)
.child(NotificationCenter)
.child(AccountSwitcher)
+ .child(ControlCenter)
.child(Toasts)
.child(UpdatePromptOverlay)
.child(JavaPromptOverlay)
diff --git a/packages/oneclient_app/src/state.rs b/packages/oneclient_app/src/state.rs
index 51eeed8b..dc04a493 100644
--- a/packages/oneclient_app/src/state.rs
+++ b/packages/oneclient_app/src/state.rs
@@ -20,6 +20,7 @@ pub enum AppChannel {
Notifications,
Game,
AccountSwitcher,
+ ControlCenter,
MicrosoftLogin,
Installs,
StorageScan,
@@ -42,6 +43,7 @@ pub struct AppState {
pub center_open: bool,
pub game: GameState,
pub account_switcher_open: bool,
+ pub control_center_open: bool,
pub microsoft_login: Option,
pub installs: InstallState,
pub storage_scan: Option,