diff --git a/locales/en.json b/locales/en.json index d8b14d9..784f60c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -7,7 +7,18 @@ "SyncApp.progress.launcherReady": "Launcher ready", "SyncApp.progress.playing": "Playing", "SyncApp.progress.synced": "Synced", + "SyncApp.phase.account.label": "Account", + "SyncApp.phase.account.sub": "Connect ARCTracker", + "SyncApp.phase.launcher.label": "Launcher", + "SyncApp.phase.launcher.sub": "Get %{launcher} ready", + "SyncApp.phase.play.label": "Play", + "SyncApp.phase.play.sub": "Launch ARC Raiders", + "SyncApp.phase.sync.label": "Sync", + "SyncApp.phase.sync.sub": "Keep inventory live", + "SyncApp.hero.step": "Step %{n} of 4", "SyncApp.footer.signedInAs": "Signed in as %{account}", + "SyncApp.footer.synced": "Synced · %{account}", + "SyncApp.footer.syncedLabel": "Synced ·", "SyncApp.footer.notSignedIn": "Not signed in", "SyncApp.footer.settings": "Settings", "SyncApp.state.needsAdmin.title": "One quick permission first", @@ -22,14 +33,17 @@ "SyncApp.state.prepareLauncher.body": "ARCTracker restarts %{launcher} so it can read your ARC Raiders inventory while you play. Your %{launcher} games, downloads and logins aren't touched.", "SyncApp.state.preparingLauncher.title": "Preparing %{launcher}…", "SyncApp.state.preparingLauncher.body": "Closing and reopening %{launcher}. This takes a few seconds.", + "SyncApp.state.preparingLauncher.waiting": "Preparing %{launcher}…", "SyncApp.state.closeLauncher.title": "Close %{launcher} to continue", "SyncApp.state.closeLauncher.body": "%{launcher} needs to close so ARCTracker can prepare it. Save anything in progress, then close it.", "SyncApp.state.launcherReady.title": "%{launcher} is ready. Go play.", "SyncApp.state.launcherReady.body": "Start ARC Raiders from %{launcher} whenever you like. Your inventory syncs automatically while you play.", "SyncApp.state.connecting.title": "Looking for your ARC Raiders account…", "SyncApp.state.connecting.body": "Keep ARC Raiders open. This usually connects a few seconds after you reach the main menu.", + "SyncApp.state.connecting.waiting": "Waiting for ARC Raiders…", "SyncApp.state.updating.title": "Finishing sync…", "SyncApp.state.updating.body": "Almost there — saving your stash to ARCTracker.", + "SyncApp.state.updating.waiting": "Saving your stash…", "SyncApp.state.synced.title": "You're synced", "SyncApp.state.synced.body": "Playing as %{account}. Your ARC Raiders inventory is up to date on ARCTracker.", "SyncApp.state.synced.session": "Your inventory stays synced until %{time}.", @@ -68,6 +82,7 @@ "SyncApp.settings.language": "Language", "SyncApp.settings.displayLanguage": "Display language", "SyncApp.settings.matchesWindows": "Matches Windows by default", + "SyncApp.settings.matchWindows": "Match Windows", "SyncApp.settings.network": "Network", "SyncApp.settings.networkAdapter": "Network adapter", "SyncApp.settings.networkAdapterSub": "Auto-selected. Only change it if sync won't connect.", @@ -76,11 +91,14 @@ "SyncApp.settings.activityLog": "Activity log", "SyncApp.settings.activityLogSub": "Recent events, for support", "SyncApp.settings.view": "View", + "SyncApp.settings.hide": "Hide", "SyncApp.settings.copyDiagnostics": "Copy diagnostics", "SyncApp.settings.copyDiagnosticsSub": "Share with ARCTracker support", "SyncApp.settings.copy": "Copy", "SyncApp.settings.version": "ARCTracker Sync v%{version}", "SyncApp.settings.checkForUpdates": "Check for updates", + "SyncApp.settings.whatIsSync": "What does Sync do?", + "SyncApp.settings.quit": "Quit ARCTracker Sync", "SyncApp.tray.open": "Open ARCTracker Sync", "SyncApp.tray.pause": "Pause sync", "SyncApp.tray.resume": "Resume sync", diff --git a/src/app.rs b/src/app.rs index 410092c..6bbbcb8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,10 @@ use std::sync::{Arc, Mutex, Weak}; use std::thread; use std::time::{Duration, Instant}; -use eframe::egui::{self, Align, Color32, CornerRadius, Frame, Margin, RichText, Stroke}; +use eframe::egui::{ + self, pos2, vec2, Align, Color32, CornerRadius, Frame, Layout, Rect, RichText, Sense, Stroke, + UiBuilder, +}; use crate::auth_bridge; use crate::capture::{self, CaptureEvent, CaptureHandle, CaptureStats, InterfaceInfo}; @@ -18,17 +21,19 @@ use crate::launch::{self, LauncherPlatform, LauncherStatus}; use crate::single_instance; use crate::sync_client::{self, SubmitError, SubmitResponse, BASE_URL}; use crate::theme::{ - self, apply_arc_theme, arc_bg, arc_border, arc_card, arc_foreground, arc_muted_text, - arc_primary, arc_success, arc_warning, + self, apply_arc_theme, arc_bg, arc_border, arc_border_soft, arc_border_strong, arc_fg_dim, + arc_foreground, arc_input, arc_muted_text, arc_primary, arc_success, arc_titlebar, arc_warning, }; use crate::token::TokenObservation; use crate::tr; use crate::tray::{self, TrayCommand, TrayCommandHandler, TrayController}; use crate::updater::{self, InstallProgress, ReleaseInfo}; use crate::widgets::{ - card, clickable_pill, launcher_segment, link_button, pill, primary_button, progress_stage, - screen_header, secondary_button, settings_section, spinner_row, stage, status_dot, toggle_row, - StageState, + arc_modal, back_button, clickable_pill, hairline, icon_tile, inline_check, launcher_segment, + link_button, mono_eyebrow, pill, primary_button, refresh_badge, resize_handles, + secondary_button, secondary_button_full, settings_button, settings_card, settings_row, + spinner_row, stage, toggle_switch, tone_card, top_glow, vertical_stepper, window_button, + StageState, StepperNode, WindowButton, }; type AuthResult = Result; @@ -83,7 +88,10 @@ enum UpdateState { /// No newer release known (or we're between checks). Idle, Available, - Downloading { received: u64, total: Option }, + Downloading { + received: u64, + total: Option, + }, Verifying, Installing, Relaunching, @@ -208,6 +216,9 @@ pub struct ArcTrackerSyncApp { screen: Screen, show_activity_log: bool, show_explainer: bool, + /// Last measured hero-content height, used to vertically center the hub's + /// right panel without an ahead-of-time measure pass. + hero_content_height: f32, interfaces: Vec, selected_interface_index: usize, @@ -373,6 +384,7 @@ impl ArcTrackerSyncApp { screen: Screen::Hub, show_activity_log: false, show_explainer: false, + hero_content_height: 0.0, interfaces: Vec::new(), selected_interface_index: 0, sync_key_source: sync_key_result @@ -962,6 +974,34 @@ impl ArcTrackerSyncApp { } } + /// Which of the four stepper phases (0..=3) + fn hub_phase(state: HubState) -> usize { + match state { + HubState::NeedsAdmin | HubState::SignedOut | HubState::SigningIn => 0, + HubState::SelectGame + | HubState::PrepareLauncher + | HubState::PreparingLauncher + | HubState::CloseLauncher + | HubState::NeedsLauncher => 1, + HubState::LauncherReady | HubState::Connecting => 2, + HubState::Updating + | HubState::Synced + | HubState::SyncedIdle + | HubState::NeedsAttention => 3, + } + } + + /// States that are actively waiting on background work + fn is_busy(state: HubState) -> bool { + matches!( + state, + HubState::SigningIn + | HubState::PreparingLauncher + | HubState::Connecting + | HubState::Updating + ) + } + fn progress_stages(&self, state: HubState) -> [(String, StageState); 4] { let signed_in = self.auth_token.is_some(); let steam_ready = self.launcher_ready(); @@ -1156,22 +1196,20 @@ impl ArcTrackerSyncApp { fn launcher_toggle(&mut self, ui: &mut egui::Ui) { let current = self.effective_platform(); let mut switch_to = None; - ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { - // right_to_left adds trailing-first, so place Epic then Steam to read - // "Steam | Epic" left-to-right. + ui.horizontal(|ui| { if launcher_segment( ui, - LauncherPlatform::Epic.label(), - current == LauncherPlatform::Epic, + LauncherPlatform::Steam.label(), + current == LauncherPlatform::Steam, ) { - switch_to = Some(LauncherPlatform::Epic); + switch_to = Some(LauncherPlatform::Steam); } if launcher_segment( ui, - LauncherPlatform::Steam.label(), - current == LauncherPlatform::Steam, + LauncherPlatform::Epic.label(), + current == LauncherPlatform::Epic, ) { - switch_to = Some(LauncherPlatform::Steam); + switch_to = Some(LauncherPlatform::Epic); } }); if let Some(platform) = switch_to.filter(|p| *p != current) { @@ -1481,8 +1519,9 @@ impl ArcTrackerSyncApp { } else if !self.token_submitted { self.sync_enabled = response.sync_enabled; // Authoritative server answer — no retry until a new token. - self.last_sync_error = - Some("ARCTracker answered success=false for the submitted token".to_string()); + self.last_sync_error = Some( + "ARCTracker answered success=false for the submitted token".to_string(), + ); self.submit_failed_at = None; self.push_message( "ARCTracker did not enable sync for this account".to_string(), @@ -1836,8 +1875,10 @@ impl ArcTrackerSyncApp { } fn interface_label(interface: &InterfaceInfo) -> String { + // Show the friendly adapter name only; the raw interface name is a GUID + // (kept internally for selection) and is noise in the UI. match &interface.description { - Some(desc) if !desc.is_empty() => format!("{desc} ({})", interface.name), + Some(desc) if !desc.is_empty() => desc.clone(), _ => interface.name.clone(), } } @@ -1888,17 +1929,161 @@ impl ArcTrackerSyncApp { // ----- rendering --------------------------------------------------------------- - fn render_hub(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let state = self.hub_state(); - self.render_header(ui, ctx); - ui.add_space(theme::SPACE_LG); - self.render_progress_strip(ui, state); - ui.add_space(theme::SPACE_LG); - self.render_hero(ui, ctx, state); - ui.add_space(theme::SPACE_MD); - self.render_footer(ui); - self.render_explainer_modal(ctx); - self.render_update_modal(ctx); + /// The hub body: a persistent stepper rail on the left and the active phase's + fn render_hub_body(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, state: HubState) { + let tone = Self::state_accent(state); + let busy = Self::is_busy(state); + let inner = ui + .max_rect() + .shrink2(vec2(theme::BODY_PAD_X, theme::BODY_PAD_Y)); + + let rail_h = 4.0 * theme::STEP_ROW_HEIGHT; + let pad_top = ((inner.height() - rail_h) / 2.0).max(0.0); + let top = inner.top() + pad_top; + + let rail_rect = + Rect::from_min_size(pos2(inner.left(), top), vec2(theme::RAIL_WIDTH, rail_h)); + let divider_x = inner.left() + theme::RAIL_WIDTH + theme::COLUMN_GAP / 2.0; + let hero_left = inner.left() + theme::RAIL_WIDTH + theme::COLUMN_GAP; + // The hero spans the full body height (not the rail's centered band) so + // tall states (e.g. Synced) have room; its content is centered within. + let hero_rect = Rect::from_min_max(pos2(hero_left, inner.top()), inner.right_bottom()); + + // Full-height divider (matching the design), not just the rail's band. + ui.painter().vline( + divider_x, + (inner.top() + 6.0)..=(inner.bottom() - 6.0), + Stroke::new(1.0, arc_border_soft()), + ); + + // Left rail: the four-phase stepper. progress_stages() supplies each + let stages = self.progress_stages(state); + let labels = phase_node_labels(self.effective_platform().label()); + let nodes = [ + StepperNode { + number: 1, + label: &labels[0].0, + sub: &labels[0].1, + state: stages[0].1, + }, + StepperNode { + number: 2, + label: &labels[1].0, + sub: &labels[1].1, + state: stages[1].1, + }, + StepperNode { + number: 3, + label: &labels[2].0, + sub: &labels[2].1, + state: stages[2].1, + }, + StepperNode { + number: 4, + label: &labels[3].0, + sub: &labels[3].1, + state: stages[3].1, + }, + ]; + ui.scope_builder( + UiBuilder::new() + .max_rect(rail_rect) + .layout(Layout::top_down(Align::Min)), + |ui| vertical_stepper(ui, &nodes, tone, busy), + ); + + // Right panel: active phase detail + actions + ui.scope_builder( + UiBuilder::new() + .max_rect(hero_rect) + .layout(Layout::top_down(Align::Min)), + |ui| self.hero_panel(ui, ctx, state), + ); + } + + fn hero_panel(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, state: HubState) { + let (title, body) = self.hub_copy(state); + let tone = Self::state_accent(state); + let phase = Self::hub_phase(state); + let busy = Self::is_busy(state); + let phase_name = phase_node_labels(self.effective_platform().label())[phase] + .0 + .clone(); + + // Center the content vertically within the hero column. egui can't + // measure ahead in immediate mode, so reuse last frame's height (stable + // per state; one frame settles after a state change). + let avail_h = ui.max_rect().height(); + let offset = ((avail_h - self.hero_content_height) / 2.0).max(0.0); + ui.add_space(offset); + let used = ui + .scope(|ui| { + ui.horizontal(|ui| { + // Icon tile and eyebrow share one centered row (gap 11px), + // matching the design's [tile | " · STEP n OF 4"]. + ui.spacing_mut().item_spacing.x = 11.0; + icon_tile(ui, phase, tone, busy); + mono_eyebrow( + ui, + &format!( + "{} · {}", + phase_name, + tr!("SyncApp.hero.step", n => (phase + 1).to_string()) + ), + tone, + ); + }); + ui.add_space(theme::SPACE_LG); + ui.label( + RichText::new(title) + .size(theme::TEXT_HUB_TITLE) + .color(arc_foreground()), + ); + ui.add_space(theme::SPACE_SM); + ui.label( + RichText::new(body) + .size(theme::TEXT_HERO_BODY) + .color(arc_muted_text()), + ); + + if Self::is_launcher_phase(state) && self.launcher_switch_target().is_some() { + ui.add_space(theme::SPACE_MD); + self.launcher_toggle(ui); + } + + if state == HubState::Synced { + if let Some(until) = self.session_expiry_label() { + ui.add_space(theme::SPACE_MD); + tone_card(ui, arc_success(), |ui| { + ui.horizontal(|ui| { + inline_check(ui, arc_success()); + ui.add_space(theme::SPACE_SM); + ui.label( + RichText::new( + tr!("SyncApp.state.synced.session", time => until), + ) + .size(theme::TEXT_SECONDARY) + .strong() + .color(arc_foreground()), + ); + }); + }); + } + ui.add_space(theme::SPACE_SM); + ui.label( + RichText::new(tr!("SyncApp.state.synced.canClose")) + .size(theme::TEXT_SECONDARY) + .color(arc_muted_text()), + ); + } + + ui.add_space(theme::SPACE_XL + 2.0); + self.render_hero_actions(ui, ctx, state); + }) + .response + .rect + .height(); + self.hero_content_height = used; } fn render_explainer_modal(&mut self, ctx: &egui::Context) { @@ -1906,15 +2091,19 @@ impl ArcTrackerSyncApp { return; } let launcher = self.effective_platform().label(); - let modal = egui::Modal::new(egui::Id::new("arc_explainer")).show(ctx, |ui| { + let modal = arc_modal("arc_explainer").show(ctx, |ui| { ui.set_max_width(theme::MODAL_WIDTH); - ui.label( - RichText::new(tr!("SyncApp.explain.title", launcher => launcher)) - .size(theme::TEXT_SUBTITLE) - .strong() - .color(arc_foreground()), - ); - ui.add_space(theme::SPACE_MD); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::SPACE_MD; + refresh_badge(ui); + ui.label( + RichText::new(tr!("SyncApp.explain.title", launcher => launcher)) + .size(theme::TEXT_SUBTITLE) + .strong() + .color(arc_foreground()), + ); + }); + ui.add_space(theme::SPACE_LG); ui.label( RichText::new(tr!("SyncApp.explain.body", launcher => launcher)) .size(theme::TEXT_SECONDARY) @@ -1932,8 +2121,7 @@ impl ArcTrackerSyncApp { } } - /// Changelog + install dialog. Cannot be dismissed once an install is - /// under way. + /// Changelog + install dialog fn render_update_modal(&mut self, ctx: &egui::Context) { if !self.show_update_modal { return; @@ -1943,7 +2131,7 @@ impl ArcTrackerSyncApp { let mut install_clicked = false; let mut close_clicked = false; - let modal = egui::Modal::new(egui::Id::new("arc_update")).show(ctx, |ui| { + let modal = arc_modal("arc_update").show(ctx, |ui| { ui.set_max_width(theme::MODAL_WIDTH); match &state { UpdateState::Available | UpdateState::Failed(_) => { @@ -2055,17 +2243,69 @@ impl ArcTrackerSyncApp { .clone() } - fn render_header(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + /// Optional update pill and the minimize / maximize / close + fn render_title_bar( + &mut self, + ui: &mut egui::Ui, + ctx: &egui::Context, + rect: Rect, + maximized: bool, + ) { let mark = self.arc_mark(ctx); let signed_in = self.auth_token.is_some(); let update_visible = self.update_indicator_visible(); + let window_r = if maximized { 0 } else { theme::RADIUS_WINDOW }; + + ui.painter().rect_filled( + rect, + CornerRadius { + nw: window_r, + ne: window_r, + sw: 0, + se: 0, + }, + arc_titlebar(), + ); + ui.painter().hline( + rect.x_range(), + rect.bottom() - 0.5, + Stroke::new(1.0, arc_border_soft()), + ); + + let drag = ui.interact( + rect, + egui::Id::new("arc_titlebar_drag"), + Sense::click_and_drag(), + ); + if drag.drag_started() { + ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + + let inner = Rect::from_min_max( + pos2(rect.left() + 12.0, rect.top()), + pos2(rect.right() - 8.0, rect.bottom()), + ); let mut open_update = false; - screen_header( - ui, - Some(&mark), - &tr!("SyncApp.appName"), - |_| {}, + let mut minimize = false; + let mut toggle_max = false; + let mut close = false; + + ui.scope_builder( + UiBuilder::new() + .max_rect(inner) + .layout(Layout::left_to_right(Align::Center)), |ui| { + ui.image((mark.id(), vec2(19.0, 19.0))); + ui.add_space(theme::SPACE_SM); + ui.label( + RichText::new(tr!("SyncApp.appName")) + .size(13.0) + .color(arc_foreground()), + ); + ui.add_space(theme::SPACE_SM); pill( ui, &if signed_in { @@ -2079,92 +2319,45 @@ impl ArcTrackerSyncApp { arc_muted_text() }, ); - if update_visible { - ui.add_space(theme::SPACE_SM); - if clickable_pill(ui, &tr!("SyncApp.update.pill"), arc_primary()).clicked() { - open_update = true; + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if window_button(ui, WindowButton::Close) { + close = true; } - } + let max_kind = if maximized { + WindowButton::Restore + } else { + WindowButton::Maximize + }; + if window_button(ui, max_kind) { + toggle_max = true; + } + if window_button(ui, WindowButton::Minimize) { + minimize = true; + } + if update_visible { + ui.add_space(theme::SPACE_SM); + if clickable_pill(ui, &tr!("SyncApp.update.pill"), arc_primary()).clicked() + { + open_update = true; + } + } + }); }, ); + if open_update { self.show_update_modal = true; } - } - - fn render_progress_strip(&mut self, ui: &mut egui::Ui, state: HubState) { - let stages = self.progress_stages(state); - Frame::NONE - .fill(arc_card()) - .stroke(Stroke::new(1.0, arc_border())) - .corner_radius(CornerRadius::same(theme::RADIUS_CARD)) - .inner_margin(Margin::symmetric(16, 12)) - .show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - let count = stages.len(); - for (index, (label, stage_state)) in stages.into_iter().enumerate() { - progress_stage(ui, &label, stage_state); - if index + 1 < count { - ui.add_space(theme::SPACE_SM); - ui.label( - RichText::new("›") - .size(theme::TEXT_BODY) - .color(arc_muted_text()), - ); - ui.add_space(theme::SPACE_SM); - } - } - }); - }); - } - - fn render_hero(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, state: HubState) { - let (title, body) = self.hub_copy(state); - let accent = Self::state_accent(state); - - card(ui, |ui| { - ui.horizontal(|ui| { - status_dot(ui, accent); - ui.add_space(theme::SPACE_XS); - ui.label( - RichText::new(title) - .size(theme::TEXT_TITLE) - .strong() - .color(arc_foreground()), - ); - if Self::is_launcher_phase(state) && self.launcher_switch_target().is_some() { - self.launcher_toggle(ui); - } - }); - ui.add_space(theme::SPACE_SM); - ui.label( - RichText::new(body) - .size(theme::TEXT_BODY) - .color(arc_muted_text()), - ); - - if state == HubState::Synced { - if let Some(until) = self.session_expiry_label() { - ui.add_space(theme::SPACE_SM); - ui.label( - RichText::new(tr!("SyncApp.state.synced.session", time => until)) - .size(theme::TEXT_SECONDARY) - .strong() - .color(arc_foreground()), - ); - } - ui.add_space(theme::SPACE_SM); - ui.label( - RichText::new(tr!("SyncApp.state.synced.canClose")) - .size(theme::TEXT_SECONDARY) - .color(arc_muted_text()), - ); - } - - ui.add_space(theme::SPACE_LG); - self.render_hero_actions(ui, ctx, state); - }); + if minimize { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + } + if toggle_max { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + if close { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } fn render_hero_actions(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, state: HubState) { @@ -2210,7 +2403,10 @@ impl ArcTrackerSyncApp { } } HubState::PreparingLauncher => { - ui.spinner(); + spinner_row( + ui, + &tr!("SyncApp.state.preparingLauncher.waiting", launcher => self.effective_platform().label()), + ); } HubState::CloseLauncher => { if primary_button( @@ -2225,8 +2421,11 @@ impl ArcTrackerSyncApp { self.hide_to_tray(ctx); } } - HubState::Connecting | HubState::Updating => { - ui.spinner(); + HubState::Connecting => { + spinner_row(ui, &tr!("SyncApp.state.connecting.waiting")); + } + HubState::Updating => { + spinner_row(ui, &tr!("SyncApp.state.updating.waiting")); } HubState::Synced | HubState::SyncedIdle => { if primary_button(ui, &tr!("SyncApp.action.viewStash")) { @@ -2265,79 +2464,187 @@ impl ArcTrackerSyncApp { }); } - fn render_footer(&mut self, ui: &mut egui::Ui) { - ui.horizontal(|ui| { - let identity = match &self.account_name { - Some(account) if self.token_submitted => { - tr!("SyncApp.footer.signedInAs", account => account) - } - _ if self.auth_token.is_some() => tr!("SyncApp.header.signedIn"), - _ => tr!("SyncApp.footer.notSignedIn"), - }; - ui.label( - RichText::new(identity) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - - ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { - if link_button(ui, &tr!("SyncApp.footer.settings")) { - self.screen = Screen::Settings; - } - }); - }); - } + /// The shared 52px footer: a status dot + identity line, with the settings gear + fn render_footer(&mut self, ui: &mut egui::Ui, rect: Rect, maximized: bool) { + let window_r = if maximized { 0 } else { theme::RADIUS_WINDOW }; + ui.painter().rect_filled( + rect, + CornerRadius { + nw: 0, + ne: 0, + sw: window_r, + se: window_r, + }, + arc_titlebar(), + ); + ui.painter().hline( + rect.x_range(), + rect.top() + 0.5, + Stroke::new(1.0, arc_border_soft()), + ); - fn render_settings(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - let mark = self.arc_mark(ctx); - let mut back = false; - screen_header( - ui, - Some(&mark), - &tr!("SyncApp.settings.title"), + let (dot_color, job) = self.footer_identity(); + let inner = Rect::from_min_max( + pos2(rect.left() + theme::BODY_PAD_X, rect.top()), + pos2(rect.right() - 10.0, rect.bottom()), + ); + let mut open_settings = false; + ui.scope_builder( + UiBuilder::new() + .max_rect(inner) + .layout(Layout::left_to_right(Align::Center)), |ui| { - if link_button(ui, "←") { - back = true; - } - ui.add_space(theme::SPACE_SM); + // Draw the dot + identity as one unit, aligning the dot to the + // text's actual ink center. + let galley = ui.painter().layout_job(job); + let dot = 7.0; + let gap = theme::SPACE_SM; + let (id_rect, _) = ui.allocate_exact_size( + vec2(dot + gap + galley.size().x, galley.size().y.max(dot)), + Sense::hover(), + ); + let text_top = id_rect.center().y - galley.size().y / 2.0; + let ink_center_y = text_top + galley.mesh_bounds.center().y; + ui.painter().circle_filled( + pos2(id_rect.left() + dot / 2.0, ink_center_y), + 3.5, + dot_color, + ); + ui.painter().galley( + pos2(id_rect.left() + dot + gap, text_top), + galley, + arc_muted_text(), + ); + + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if self.screen == Screen::Hub + && settings_button(ui, &tr!("SyncApp.footer.settings")) + { + open_settings = true; + } + }); }, - |_| {}, ); - if back { - self.screen = Screen::Hub; + if open_settings { + self.screen = Screen::Settings; } - ui.add_space(theme::SPACE_LG); + } - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| { - self.render_settings_account(ui); - ui.add_space(theme::SPACE_MD); - self.render_settings_game(ui); - ui.add_space(theme::SPACE_MD); - self.render_settings_startup(ui); - ui.add_space(theme::SPACE_MD); - self.render_settings_language(ui, ctx); - ui.add_space(theme::SPACE_MD); - self.render_settings_network(ui); + /// Footer status dot color + identity text (the account is emphasized). The + /// identity is a `LayoutJob` so the dot can be aligned to its ink center. + fn footer_identity(&self) -> (Color32, egui::text::LayoutJob) { + use egui::text::{LayoutJob, TextFormat}; + let font = egui::FontId::proportional(theme::TEXT_FOOTER); + let mut job = LayoutJob::default(); + if self.token_submitted { + if let Some(account) = self.account_name.as_deref() { + job.append( + &tr!("SyncApp.footer.syncedLabel"), + 0.0, + TextFormat { + font_id: font.clone(), + color: arc_muted_text(), + ..Default::default() + }, + ); + job.append( + &format!(" {account}"), + 0.0, + TextFormat { + font_id: font, + color: arc_foreground(), + ..Default::default() + }, + ); + return (arc_success(), job); + } + } + let (color, text) = if self.auth_token.is_some() { + (arc_muted_text(), tr!("SyncApp.header.signedIn")) + } else { + (arc_fg_dim(), tr!("SyncApp.footer.notSignedIn")) + }; + job.append( + &text, + 0.0, + TextFormat { + font_id: font, + color, + ..Default::default() + }, + ); + (color, job) + } + + /// The settings body: width-capped, scrollable stack of grouped cards + fn render_settings_body(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + // The scroll area spans the full body width so its scrollbar hugs the + // window's right edge (matching the design); the cards are inset with a + // horizontal frame margin instead of by shrinking the scroll region. + let pad = theme::BODY_PAD_X; + let body = ui.max_rect(); + ui.scope_builder( + UiBuilder::new() + .max_rect(body) + .layout(Layout::top_down(Align::Min)), + |ui| { + // Fixed header: back button + "Settings" title (the back + // affordance lives here rather than in the title bar). + ui.add_space(theme::BODY_PAD_Y); + ui.horizontal(|ui| { + ui.add_space(pad); + ui.spacing_mut().item_spacing.x = theme::SPACE_MD; + if back_button(ui) { + self.screen = Screen::Hub; + } + ui.label( + RichText::new(tr!("SyncApp.settings.title")) + .size(theme::TEXT_SUBTITLE) + .strong() + .color(arc_foreground()), + ); + }); ui.add_space(theme::SPACE_MD); - self.render_settings_troubleshooting(ui, ctx); - ui.add_space(theme::SPACE_LG); - self.render_settings_footer(ui); - ui.add_space(theme::SPACE_LG); - if secondary_button(ui, &tr!("SyncApp.tray.quit")) { - self.quit(); - } - if self.show_activity_log { - ui.add_space(theme::SPACE_MD); - self.render_activity_log(ui); - } - }); + egui::ScrollArea::vertical() + .auto_shrink([false, false]) + .scroll_source(egui::containers::scroll_area::ScrollSource { + drag: false, + ..Default::default() + }) + .show(ui, |ui| { + Frame::NONE + .inner_margin(egui::Margin::symmetric(pad as i8, 0)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + self.render_settings_account(ui); + ui.add_space(theme::SPACE_MD); + self.render_settings_game(ui); + ui.add_space(theme::SPACE_MD); + self.render_settings_startup(ui); + ui.add_space(theme::SPACE_MD); + self.render_settings_language(ui, ctx); + ui.add_space(theme::SPACE_MD); + self.render_settings_network(ui); + ui.add_space(theme::SPACE_MD); + self.render_settings_troubleshooting(ui, ctx); + ui.add_space(theme::SPACE_LG); + self.render_settings_footer(ui); + ui.add_space(theme::SPACE_LG); + if secondary_button_full(ui, &tr!("SyncApp.settings.quit")) { + self.quit(); + } + // Breathing room below the last card so the + // scroll doesn't end flush against the footer. + ui.add_space(theme::SPACE_SM); + }); + }); + }, + ); } fn render_settings_account(&mut self, ui: &mut egui::Ui) { - settings_section(ui, &tr!("SyncApp.settings.account"), |ui| { + settings_card(ui, &tr!("SyncApp.settings.account"), |ui| { let account = self .account_name .clone() @@ -2350,29 +2657,24 @@ impl ArcTrackerSyncApp { tr!("SyncApp.footer.notSignedIn") } }); - ui.label(RichText::new(account).color(arc_foreground())); - ui.label( - RichText::new(tr!("SyncApp.settings.staysSignedIn")) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - ui.add_space(theme::SPACE_SM); - if ui - .add_enabled( - self.auth_token.is_some(), - egui::Button::new(tr!("SyncApp.settings.signOut")), - ) - .clicked() - { - self.sign_out(); - } + settings_row(ui, &account, &tr!("SyncApp.settings.staysSignedIn"), |ui| { + if ui + .add_enabled( + self.auth_token.is_some(), + egui::Button::new(tr!("SyncApp.settings.signOut")), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.sign_out(); + } + }); }); } fn render_settings_game(&mut self, ui: &mut egui::Ui) { - settings_section(ui, &tr!("SyncApp.settings.gameLauncher"), |ui| { - ui.horizontal(|ui| { - ui.label(RichText::new(tr!("SyncApp.settings.launcher")).color(arc_foreground())); + settings_card(ui, &tr!("SyncApp.settings.gameLauncher"), |ui| { + settings_row(ui, &tr!("SyncApp.settings.launcher"), "", |ui| { let mut platform = self.config.platform; egui::ComboBox::from_id_salt("settings_platform_combo") .selected_text(platform.label()) @@ -2381,29 +2683,24 @@ impl ArcTrackerSyncApp { ui.selectable_value(&mut platform, LauncherPlatform::Steam, "Steam"); ui.selectable_value(&mut platform, LauncherPlatform::Epic, "Epic Games"); ui.selectable_value(&mut platform, LauncherPlatform::Direct, "Direct"); - }); + }) + .response + .on_hover_cursor(egui::CursorIcon::PointingHand); if platform != self.config.platform { self.set_launcher(platform); } }); - - ui.add_space(theme::SPACE_SM); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new(tr!("SyncApp.settings.arcLocation")).color(arc_foreground()), - ); - let location = self - .selected_game_path() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| tr!("SyncApp.settings.autoDetected")); - ui.label( - RichText::new(location) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - }); - if ui.button(tr!("SyncApp.settings.change")).clicked() { + hairline(ui); + let location = self + .selected_game_path() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| tr!("SyncApp.settings.autoDetected")); + settings_row(ui, &tr!("SyncApp.settings.arcLocation"), &location, |ui| { + if ui + .button(tr!("SyncApp.settings.change")) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { self.browse_game_executable(); } }); @@ -2411,100 +2708,113 @@ impl ArcTrackerSyncApp { } fn render_settings_startup(&mut self, ui: &mut egui::Ui) { - settings_section(ui, &tr!("SyncApp.settings.startup"), |ui| { + settings_card(ui, &tr!("SyncApp.settings.startup"), |ui| { let mut keep_in_tray = self.config.keep_in_tray; - if toggle_row( + settings_row( ui, &tr!("SyncApp.settings.keepInTray"), &tr!("SyncApp.settings.keepInTraySub"), - &mut keep_in_tray, - ) { - self.config.keep_in_tray = keep_in_tray; - self.save_config(); - } + |ui| { + if toggle_switch(ui, &mut keep_in_tray) { + self.config.keep_in_tray = keep_in_tray; + self.save_config(); + } + }, + ); }); } fn render_settings_language(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - settings_section(ui, &tr!("SyncApp.settings.language"), |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new(tr!("SyncApp.settings.displayLanguage")).color(arc_foreground()), - ); - - let current_label = match self.config.language.as_deref() { - Some(code) => i18n::native_name(code).to_string(), - None => tr!("SyncApp.settings.matchesWindows"), - }; - let mut chosen: Option> = None; + settings_card(ui, &tr!("SyncApp.settings.language"), |ui| { + settings_row( + ui, + &tr!("SyncApp.settings.displayLanguage"), + &tr!("SyncApp.settings.matchesWindows"), + |ui| { + let current_label = match self.config.language.as_deref() { + Some(code) => i18n::native_name(code).to_string(), + None => tr!("SyncApp.settings.matchWindows"), + }; + let mut chosen: Option> = None; - egui::ComboBox::from_id_salt("settings_language_combo") - .selected_text(current_label) - .show_ui(ui, |ui| { - if ui - .selectable_label( - self.config.language.is_none(), - tr!("SyncApp.settings.matchesWindows"), - ) - .clicked() - { - chosen = Some(None); - } - for locale in i18n::UI_LOCALES.iter().copied() { - let selected = self.config.language.as_deref() == Some(locale); + egui::ComboBox::from_id_salt("settings_language_combo") + .selected_text(current_label) + .show_ui(ui, |ui| { if ui - .selectable_label(selected, i18n::native_name(locale)) + .selectable_label( + self.config.language.is_none(), + tr!("SyncApp.settings.matchWindows"), + ) .clicked() { - chosen = Some(Some(locale.to_string())); + chosen = Some(None); } - } - }); + for locale in i18n::UI_LOCALES.iter().copied() { + let selected = self.config.language.as_deref() == Some(locale); + if ui + .selectable_label(selected, i18n::native_name(locale)) + .clicked() + { + chosen = Some(Some(locale.to_string())); + } + } + }) + .response + .on_hover_cursor(egui::CursorIcon::PointingHand); - if let Some(language) = chosen { - self.change_language(ctx, language); - } - }); + if let Some(language) = chosen { + self.change_language(ctx, language); + } + }, + ); }); } fn render_settings_network(&mut self, ui: &mut egui::Ui) { - settings_section(ui, &tr!("SyncApp.settings.network"), |ui| { - // Stacked layout: adapter labels can be long (GUIDs) and the row - // would otherwise push the card past the window edge. - ui.label(RichText::new(tr!("SyncApp.settings.networkAdapter")).color(arc_foreground())); - ui.label( - RichText::new(tr!("SyncApp.settings.networkAdapterSub")) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - ui.add_space(theme::SPACE_SM); - + settings_card(ui, &tr!("SyncApp.settings.network"), |ui| { let selected_label = self .selected_interface() .map(Self::interface_label) - .unwrap_or_else(|| "Auto".to_string()); + .unwrap_or_else(|| "Automatic".to_string()); let mut selected_name = None; let mut refresh = false; - ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { - refresh = ui.button(tr!("SyncApp.settings.refresh")).clicked(); - egui::ComboBox::from_id_salt("settings_interface_combo") - .selected_text(selected_label) - .width(theme::COMBO_WIDE.min(ui.available_width())) - .truncate() - .show_ui(ui, |ui| { - for (index, interface) in self.interfaces.iter().enumerate() { - let label = Self::interface_label(interface); - if ui - .selectable_value(&mut self.selected_interface_index, index, label) - .changed() - { - selected_name = Some(interface.name.clone()); + settings_row( + ui, + &tr!("SyncApp.settings.networkAdapter"), + &tr!("SyncApp.settings.networkAdapterSub"), + |ui| { + // Force the dropdown and Refresh button to a common height so + // they sit on the same baseline (egui won't reflow the first + // widget once the taller one is added). + ui.spacing_mut().interact_size.y = 30.0; + refresh = ui + .button(tr!("SyncApp.settings.refresh")) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked(); + egui::ComboBox::from_id_salt("settings_interface_combo") + .selected_text(selected_label) + .width(theme::COMBO_ADAPTER) + .truncate() + .show_ui(ui, |ui| { + for (index, interface) in self.interfaces.iter().enumerate() { + let label = Self::interface_label(interface); + if ui + .selectable_value( + &mut self.selected_interface_index, + index, + label, + ) + .changed() + { + selected_name = Some(interface.name.clone()); + } } - } - }); - }); + }) + .response + .on_hover_cursor(egui::CursorIcon::PointingHand); + }, + ); if let Some(name) = selected_name { self.config.selected_interface = Some(name); @@ -2520,40 +2830,45 @@ impl ArcTrackerSyncApp { } fn render_settings_troubleshooting(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { - settings_section(ui, &tr!("SyncApp.settings.troubleshooting"), |ui| { - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new(tr!("SyncApp.settings.activityLog")).color(arc_foreground()), - ); - ui.label( - RichText::new(tr!("SyncApp.settings.activityLogSub")) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - }); - if ui.button(tr!("SyncApp.settings.view")).clicked() { - self.show_activity_log = !self.show_activity_log; - } - }); - - ui.add_space(theme::SPACE_SM); - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label( - RichText::new(tr!("SyncApp.settings.copyDiagnostics")) - .color(arc_foreground()), - ); - ui.label( - RichText::new(tr!("SyncApp.settings.copyDiagnosticsSub")) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); - }); - if ui.button(tr!("SyncApp.settings.copy")).clicked() { - self.copy_diagnostics(ctx); - } - }); + settings_card(ui, &tr!("SyncApp.settings.troubleshooting"), |ui| { + let view_label = if self.show_activity_log { + tr!("SyncApp.settings.hide") + } else { + tr!("SyncApp.settings.view") + }; + settings_row( + ui, + &tr!("SyncApp.settings.activityLog"), + &tr!("SyncApp.settings.activityLogSub"), + |ui| { + if ui + .button(view_label) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.show_activity_log = !self.show_activity_log; + } + }, + ); + hairline(ui); + settings_row( + ui, + &tr!("SyncApp.settings.copyDiagnostics"), + &tr!("SyncApp.settings.copyDiagnosticsSub"), + |ui| { + if ui + .button(tr!("SyncApp.settings.copy")) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.copy_diagnostics(ctx); + } + }, + ); + if self.show_activity_log { + ui.add_space(theme::SPACE_MD); + self.render_activity_log(ui); + } }); } @@ -2563,37 +2878,47 @@ impl ArcTrackerSyncApp { RichText::new( tr!("SyncApp.settings.version", version => env!("CARGO_PKG_VERSION")), ) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), + .font(egui::FontId::monospace(theme::TEXT_PILL)) + .color(arc_fg_dim()), ); ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { - if link_button(ui, &tr!("SyncApp.settings.checkForUpdates")) { - let _ = auth_bridge::open_browser(BASE_URL); + if link_button(ui, &tr!("SyncApp.settings.whatIsSync")) { + self.show_explainer = true; } }); }); } - fn render_activity_log(&mut self, ui: &mut egui::Ui) { - card(ui, |ui| { - ui.label( - RichText::new(tr!("SyncApp.settings.activityLog")) - .strong() - .color(arc_foreground()), - ); - ui.add_space(theme::SPACE_SM); - if self.messages.is_empty() { - ui.label(RichText::new("—").color(arc_muted_text())); - } else { - for message in &self.messages { - ui.label( - RichText::new(Self::support_event_message(message)) - .size(theme::TEXT_CAPTION) - .color(arc_muted_text()), - ); + /// The activity log, rendered as an inset panel inside the Troubleshooting + /// card (each event prefixed with a muted "›", monospace). + fn render_activity_log(&self, ui: &mut egui::Ui) { + Frame::NONE + .fill(arc_input()) + .stroke(Stroke::new(1.0, arc_border())) + .corner_radius(CornerRadius::same(theme::RADIUS_CONTROL)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if self.messages.is_empty() { + ui.label(RichText::new("—").color(arc_muted_text())); + } else { + for message in &self.messages { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = theme::SPACE_SM; + ui.label( + RichText::new("›") + .font(egui::FontId::monospace(theme::TEXT_SUBLABEL)) + .color(arc_border_strong()), + ); + ui.label( + RichText::new(Self::support_event_message(message)) + .font(egui::FontId::monospace(theme::TEXT_SUBLABEL)) + .color(arc_fg_dim()), + ); + }); + } } - } - }); + }); } } @@ -2621,35 +2946,59 @@ impl ArcTrackerSyncApp { ctx.request_repaint_after(Duration::from_millis(750)); + let maximized = ctx + .input(|input| input.viewport().maximized) + .unwrap_or(false); + let window_r = if maximized { 0 } else { theme::RADIUS_WINDOW }; + let window_frame = Frame::NONE + .fill(arc_bg()) + .stroke(Stroke::new(1.0, arc_border())) + .corner_radius(CornerRadius::same(window_r)); + + let state = self.hub_state(); + egui::CentralPanel::default() - .frame(Frame::NONE.fill(arc_bg()).inner_margin(Margin::same(24))) + .frame(window_frame) .show(ctx, |ui| { - // The column wraps the scroll areas (not the other way around) - // so it always works with the panel's finite rect. - Self::content_column(ui, |ui| match self.screen { - Screen::Hub => { - egui::ScrollArea::vertical() - .auto_shrink([false, false]) - .show(ui, |ui| self.render_hub(ui, ctx)); - } - Screen::Settings => self.render_settings(ui, ctx), - }); + let app_rect = ui.max_rect(); + let title_rect = Rect::from_min_size( + app_rect.min, + vec2(app_rect.width(), theme::TITLE_BAR_HEIGHT), + ); + let footer_rect = Rect::from_min_max( + pos2(app_rect.left(), app_rect.bottom() - theme::FOOTER_HEIGHT), + app_rect.max, + ); + let body_rect = Rect::from_min_max( + pos2(app_rect.left(), app_rect.top() + theme::TITLE_BAR_HEIGHT), + pos2(app_rect.right(), app_rect.bottom() - theme::FOOTER_HEIGHT), + ); + + // Soft gold glow at the top of the body, clipped so it can't + // bleed over the title bar or the window's rounded corners. + top_glow(&ui.painter().with_clip_rect(body_rect), body_rect); + + self.render_title_bar(ui, ctx, title_rect, maximized); + self.render_footer(ui, footer_rect, maximized); + + ui.scope_builder( + UiBuilder::new() + .max_rect(body_rect) + .layout(Layout::top_down(Align::Min)), + |ui| match self.screen { + Screen::Hub => self.render_hub_body(ui, ctx, state), + Screen::Settings => self.render_settings_body(ui, ctx), + }, + ); + + // Edge/corner resize grips on top (a fixed window has none) + if !maximized { + resize_handles(ctx, ui, app_rect); + } }); - } - /// Center a column capped at [`theme::CONTENT_MAX_WIDTH`] so wide or - /// maximized windows don't stretch the cards edge-to-edge. Renders into a - /// child rect directly (not a horizontal/vertical nest, which would starve - /// nested scroll areas of height). - fn content_column(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui)) { - let available = ui.available_rect_before_wrap(); - let column = available.width().min(theme::CONTENT_MAX_WIDTH); - let x = available.left() + ((available.width() - column) / 2.0).max(0.0); - let rect = egui::Rect::from_x_y_ranges(x..=x + column, available.y_range()); - ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - ui.set_width(column); - add_contents(ui); - }); + self.render_explainer_modal(ctx); + self.render_update_modal(ctx); } } @@ -2659,10 +3008,36 @@ impl eframe::App for SharedArcTrackerSyncApp { app.update_frame(ctx, frame); } } + + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::TRANSPARENT.to_normalized_gamma_f32() + } } // ----- app-local helpers ------------------------------------------------------ +/// The four stepper phases +fn phase_node_labels(launcher: &str) -> [(String, String); 4] { + [ + ( + tr!("SyncApp.phase.account.label"), + tr!("SyncApp.phase.account.sub"), + ), + ( + tr!("SyncApp.phase.launcher.label"), + tr!("SyncApp.phase.launcher.sub", launcher => launcher), + ), + ( + tr!("SyncApp.phase.play.label"), + tr!("SyncApp.phase.play.sub"), + ), + ( + tr!("SyncApp.phase.sync.label"), + tr!("SyncApp.phase.sync.sub"), + ), + ] +} + /// Activity-log notice for a user-set SSLKEYLOGFILE that was ignored. Uses the /// app's "sync key" product term so it reads cleanly in the activity log and /// doesn't trip `support_event_message`'s secret-value redaction; the username diff --git a/src/capture.rs b/src/capture.rs index df84ead..332770d 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -635,8 +635,7 @@ fn update_observed_stats( // Emit only the first few hits as events; the counters and // last_http1_* fields keep tracking everything. if (has_embark_host || has_bearer) - && stats.http1_embark_hosts.max(stats.http1_bearer_headers) - <= MAX_HTTP_DEBUG_EVENTS + && stats.http1_embark_hosts.max(stats.http1_bearer_headers) <= MAX_HTTP_DEBUG_EVENTS { let host = field_str(message, "host").unwrap_or_else(|| "(no host)".to_string()); let method = field_str(message, "method").unwrap_or_default(); @@ -1117,8 +1116,7 @@ mod tests { insert_str(&mut fields, "path", "/v1/x"); fields.insert("has_embark_host", FieldValue::Bool(true)); fields.insert("has_bearer", FieldValue::Bool(true)); - let message = - parsed_message("embark_http_observation", 7, Direction::ToServer, fields); + let message = parsed_message("embark_http_observation", 7, Direction::ToServer, fields); update_observed_stats(&mut stats, &message, &tx, &mut embark_connections); } diff --git a/src/i18n.rs b/src/i18n.rs index 02b52ba..ca28704 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -226,7 +226,10 @@ mod tests { "SyncApp.state.signedOut.title" ); // The post-sync Stash CTA key must resolve to real text, not the raw key. - assert_eq!(__translate("SyncApp.action.viewStash", &[]), "View your stash"); + assert_eq!( + __translate("SyncApp.action.viewStash", &[]), + "View your stash" + ); // Named placeholder interpolation (%{var}) works end to end. assert_eq!( __translate( diff --git a/src/launch.rs b/src/launch.rs index a8ff12f..4fa2f07 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -753,8 +753,14 @@ mod tests { let file_path = temp_dir.join("keys.log"); fs::write(&file_path, b"CLIENT_RANDOM ...").expect("write file"); - assert!(!sync_key_override_usable(&temp_dir), "directory is not usable"); - assert!(sync_key_override_usable(&file_path), "existing file is usable"); + assert!( + !sync_key_override_usable(&temp_dir), + "directory is not usable" + ); + assert!( + sync_key_override_usable(&file_path), + "existing file is usable" + ); assert!( !sync_key_override_usable(&temp_dir.join("missing.log")), "nonexistent path is not usable" diff --git a/src/main.rs b/src/main.rs index 8733c92..eb7212f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,9 @@ fn main() -> eframe::Result<()> { viewport: eframe::egui::ViewportBuilder::default() .with_inner_size([760.0, 500.0]) .with_min_inner_size([700.0, 460.0]) + .with_decorations(false) + .with_transparent(true) + .with_resizable(true) .with_icon(app_icon()) .with_title(i18n::__translate("SyncApp.appName", &[])), ..Default::default() diff --git a/src/rawsock.rs b/src/rawsock.rs index ecf6fbc..8ddc23c 100644 --- a/src/rawsock.rs +++ b/src/rawsock.rs @@ -33,10 +33,10 @@ mod imp { const SOL_SOCKET: i32 = 0xffff; const SO_RCVBUF: i32 = 0x1002; const SIO_RCVALL: u32 = 0x9800_0001; // _WSAIOW(IOC_VENDOR, 1) - // RCVALL_IPLEVEL: all packets to/from this interface's IP, both directions, - // without promiscuous mode. RCVALL_ON (1, full promiscuous) is unreliable - // for the host's own inbound traffic, notably on Wi-Fi — it drops the - // ServerHello and breaks TLS key establishment. + // RCVALL_IPLEVEL: all packets to/from this interface's IP, both directions, + // without promiscuous mode. RCVALL_ON (1, full promiscuous) is unreliable + // for the host's own inbound traffic, notably on Wi-Fi — it drops the + // ServerHello and breaks TLS key establishment. const RCVALL_IPLEVEL: u32 = 3; const FIONBIO: i32 = 0x8004_667e_u32 as i32; const INVALID_SOCKET: usize = usize::MAX; diff --git a/src/theme.rs b/src/theme.rs index d1d151c..0cae0e9 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,9 +1,10 @@ //! ARC design tokens and the egui theme built from them. //! //! Add new values to the scales below rather than hardcoding pixel literals -//! in render code. +//! in render code. oklch values (in comments) are the source of truth for each +//! color; the `Color32` literals are the sRGB fallback used at runtime. -use eframe::egui::{self, Color32, Stroke, Vec2}; +use eframe::egui::{self, Color32, CornerRadius, Stroke, Vec2}; // ----- spacing (4pt rhythm) -------------------------------------------------- @@ -15,80 +16,161 @@ pub const SPACE_XL: f32 = 24.0; // ----- type scale ------------------------------------------------------------ -/// Screen and hero titles. +/// Hub hero title +pub const TEXT_HUB_TITLE: f32 = 24.0; +/// Screen / section titles pub const TEXT_TITLE: f32 = 20.0; -/// Modal titles. +/// Modal titles pub const TEXT_SUBTITLE: f32 = 18.0; -/// Body copy and standard labels. +/// Hero body copy +pub const TEXT_HERO_BODY: f32 = 14.5; +/// Body copy and standard labels pub const TEXT_BODY: f32 = 14.0; -/// Secondary body copy (modal bodies, changelog, session notices). +/// Stepper phase labels +pub const TEXT_STEP_LABEL: f32 = 13.5; +/// Secondary body copy (modal bodies, changelog, session notices) pub const TEXT_SECONDARY: f32 = 13.0; -/// Captions: pills, progress stages, settings sublabels, footer. +/// Footer status line +pub const TEXT_FOOTER: f32 = 12.5; +/// Captions: pills, settings sublabels pub const TEXT_CAPTION: f32 = 12.0; +/// Status pill / chip label (title-bar "Signed in", update pill) +pub const TEXT_PILL: f32 = 11.5; +/// Stepper sub-labels +pub const TEXT_SUBLABEL: f32 = 11.5; +/// Mono eyebrow above the hero title +pub const TEXT_EYEBROW: f32 = 11.0; // ----- radii & strokes --------------------------------------------------------- -pub const RADIUS_CARD: u8 = 8; -pub const RADIUS_CONTROL: u8 = 6; +/// Outer window frame (the rounded borderless chrome) +pub const RADIUS_WINDOW: u8 = 14; +pub const RADIUS_CARD: u8 = 13; +/// Icon tiles and the larger tinted tiles +pub const RADIUS_TILE: u8 = 12; +pub const RADIUS_CONTROL: u8 = 10; +/// Fully rounded pills / toggle tracks (saturates at the u8 ceiling) +pub const RADIUS_PILL: u8 = 255; + +// ----- hub / chrome layout ------------------------------------------------------ + +/// Custom title bar height +pub const TITLE_BAR_HEIGHT: f32 = 42.0; +/// Custom footer height. +pub const FOOTER_HEIGHT: f32 = 52.0; +/// Horizontal padding inside the body (between chrome and content) +pub const BODY_PAD_X: f32 = 22.0; +/// Vertical padding inside the body. +pub const BODY_PAD_Y: f32 = 24.0; +/// Left stepper rail width. +pub const RAIL_WIDTH: f32 = 196.0; +/// Gap between the stepper rail and the hero panel (divider sits in the middle) +pub const COLUMN_GAP: f32 = 30.0; +/// Minimum height of one stepper row +pub const STEP_ROW_HEIGHT: f32 = 64.0; +/// Stepper node circle diameter. +pub const NODE_DIAMETER: f32 = 26.0; +/// Hero icon tile (rounded square) size +pub const ICON_TILE: f32 = 44.0; +/// Window control buttons (minimize / maximize / tray / close) +pub const WINDOW_BUTTON: f32 = 30.0; // ----- widths ------------------------------------------------------------------ -/// Content column cap: wider windows center this column instead of stretching. -pub const CONTENT_MAX_WIDTH: f32 = 680.0; /// Shared max width for all modal dialogs. pub const MODAL_WIDTH: f32 = 480.0; -/// Wide combo boxes (e.g. network adapter names). -pub const COMBO_WIDE: f32 = 360.0; +/// Settings dropdowns (network adapter, etc.). +pub const COMBO_ADAPTER: f32 = 184.0; -// ----- pill tinting ------------------------------------------------------------- +// ----- tinting ------------------------------------------------------------------- -pub const PILL_FILL_OPACITY: f32 = 0.16; -pub const PILL_STROKE_OPACITY: f32 = 0.55; +pub const PILL_FILL_OPACITY: f32 = 0.15; +pub const PILL_STROKE_OPACITY: f32 = 0.42; +/// Fill opacity for tone-tinted tiles / status cards. +pub const TONE_FILL_OPACITY: f32 = 0.14; +/// Stroke opacity for tone-tinted status cards. +pub const TONE_STROKE_OPACITY: f32 = 0.40; // ----- palette ------------------------------------------------------------------- +/// Window background. oklch(0.135 0.017 236) pub fn arc_bg() -> Color32 { - Color32::from_rgb(18, 24, 31) + Color32::from_rgb(16, 23, 32) } +/// Title bar / footer chrome. oklch(0.118 0.017 238) +pub fn arc_titlebar() -> Color32 { + Color32::from_rgb(13, 20, 28) +} + +/// Cards / tiles. oklch(0.188 0.016 234) pub fn arc_card() -> Color32 { - Color32::from_rgb(26, 33, 42) + Color32::from_rgb(27, 35, 45) } +/// Inputs / ghost fills. oklch(0.172 0.015 236) pub fn arc_input() -> Color32 { - Color32::from_rgb(22, 29, 37) + Color32::from_rgb(24, 31, 40) } +/// Faint hover / muted surface (≈ border-soft). oklch(0.232 0.012 240) pub fn arc_muted() -> Color32 { - Color32::from_rgb(36, 44, 55) + Color32::from_rgb(37, 44, 54) } +/// Card borders. oklch(0.268 0.012 240) pub fn arc_border() -> Color32 { - Color32::from_rgb(52, 61, 74) + Color32::from_rgb(44, 52, 63) +} + +/// Hairlines / dividers. oklch(0.232 0.012 240) +pub fn arc_border_soft() -> Color32 { + Color32::from_rgb(37, 44, 54) +} + +/// Control borders / pending stepper nodes. oklch(0.340 0.013 242) +pub fn arc_border_strong() -> Color32 { + Color32::from_rgb(59, 68, 80) } +/// Primary text. oklch(0.965 0.006 240) pub fn arc_foreground() -> Color32 { - Color32::from_rgb(237, 240, 244) + Color32::from_rgb(238, 241, 245) } +/// Body text. oklch(0.712 0.013 246) pub fn arc_muted_text() -> Color32 { - Color32::from_rgb(156, 164, 176) + Color32::from_rgb(154, 163, 176) } +/// Captions / sub-labels. oklch(0.560 0.014 248) +pub fn arc_fg_dim() -> Color32 { + Color32::from_rgb(114, 123, 137) +} + +/// Brand gold (softened). oklch(0.855 0.165 89) pub fn arc_primary() -> Color32 { - Color32::from_rgb(255, 198, 1) + Color32::from_rgb(251, 200, 44) } +/// Text on the gold accent. oklch(0.185 0.02 252) pub fn arc_primary_foreground() -> Color32 { - Color32::from_rgb(24, 25, 28) + Color32::from_rgb(21, 32, 43) } +/// Synced / success. oklch(0.80 0.135 162) pub fn arc_success() -> Color32 { - Color32::from_rgb(80, 220, 150) + Color32::from_rgb(95, 216, 163) } +/// Attention / warn. oklch(0.815 0.125 66) pub fn arc_warning() -> Color32 { - Color32::from_rgb(248, 165, 80) + Color32::from_rgb(249, 177, 102) +} + +/// Hover tint for the close button. +pub fn arc_danger() -> Color32 { + Color32::from_rgb(232, 86, 86) } // ----- theme ------------------------------------------------------------------------ @@ -97,17 +179,38 @@ pub fn apply_arc_theme(ctx: &egui::Context) { let mut visuals = egui::Visuals::dark(); visuals.panel_fill = arc_bg(); visuals.window_fill = arc_card(); + visuals.window_stroke = Stroke::new(1.0, arc_border()); + visuals.window_corner_radius = CornerRadius::same(RADIUS_CARD); visuals.extreme_bg_color = arc_input(); visuals.faint_bg_color = arc_muted(); visuals.hyperlink_color = arc_primary(); visuals.selection.bg_fill = arc_primary(); visuals.selection.stroke = Stroke::new(1.0, arc_primary_foreground()); + + for state in [ + &mut visuals.widgets.inactive, + &mut visuals.widgets.hovered, + &mut visuals.widgets.active, + &mut visuals.widgets.open, + &mut visuals.widgets.noninteractive, + ] { + state.corner_radius = CornerRadius::same(RADIUS_CONTROL); + } + visuals.widgets.inactive.bg_fill = arc_input(); + visuals.widgets.inactive.weak_bg_fill = arc_input(); + visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, arc_border()); visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, arc_foreground()); visuals.widgets.hovered.bg_fill = arc_muted(); + visuals.widgets.hovered.weak_bg_fill = arc_muted(); + visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, arc_border_strong()); visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, arc_foreground()); - visuals.widgets.active.bg_fill = arc_primary(); - visuals.widgets.active.fg_stroke = Stroke::new(1.0, arc_primary_foreground()); + // Pressed/active state stays subtle (a faint surface) rather than flashing + // the gold accent, which inverted text and made controls hard to read. + visuals.widgets.active.bg_fill = arc_muted(); + visuals.widgets.active.weak_bg_fill = arc_muted(); + visuals.widgets.active.bg_stroke = Stroke::new(1.0, arc_border_strong()); + visuals.widgets.active.fg_stroke = Stroke::new(1.0, arc_foreground()); let mut style = (*ctx.style()).clone(); style.visuals = visuals; diff --git a/src/widgets.rs b/src/widgets.rs index ced2a3d..1fe626c 100644 --- a/src/widgets.rs +++ b/src/widgets.rs @@ -1,12 +1,18 @@ //! Shared ARC-styled widgets. All sizing/coloring comes from [`crate::theme`] //! tokens so the components stay on one visual scale. -use eframe::egui::{self, Align, Color32, CornerRadius, Frame, Margin, RichText, Stroke, Vec2}; +use eframe::egui::{ + self, pos2, vec2, Align, Align2, Color32, CornerRadius, FontId, Frame, Layout, Margin, Pos2, + Rect, RichText, Sense, Shape, Stroke, StrokeKind, Vec2, +}; use crate::theme::{ - arc_border, arc_card, arc_foreground, arc_input, arc_muted_text, arc_primary, - arc_primary_foreground, arc_success, PILL_FILL_OPACITY, PILL_STROKE_OPACITY, RADIUS_CARD, - RADIUS_CONTROL, SPACE_MD, SPACE_SM, SPACE_XS, TEXT_BODY, TEXT_CAPTION, TEXT_TITLE, + arc_bg, arc_border, arc_border_soft, arc_border_strong, arc_card, arc_danger, arc_fg_dim, + arc_foreground, arc_input, arc_muted, arc_muted_text, arc_primary, arc_primary_foreground, + ICON_TILE, NODE_DIAMETER, PILL_FILL_OPACITY, PILL_STROKE_OPACITY, RADIUS_CARD, RADIUS_CONTROL, + RADIUS_PILL, RADIUS_TILE, SPACE_MD, SPACE_SM, SPACE_XS, STEP_ROW_HEIGHT, TEXT_BODY, + TEXT_CAPTION, TEXT_EYEBROW, TEXT_PILL, TEXT_STEP_LABEL, TEXT_SUBLABEL, TONE_FILL_OPACITY, + TONE_STROKE_OPACITY, WINDOW_BUTTON, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +32,8 @@ pub fn stage(done: bool, current: bool) -> StageState { } } +// ----- buttons ---------------------------------------------------------------- + pub fn primary_button(ui: &mut egui::Ui, label: &str) -> bool { let button = egui::Button::new( RichText::new(label) @@ -35,7 +43,9 @@ pub fn primary_button(ui: &mut egui::Ui, label: &str) -> bool { .fill(arc_primary()) .stroke(Stroke::NONE) .corner_radius(CornerRadius::same(RADIUS_CONTROL)); - ui.add(button).clicked() + ui.add(button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() } pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> bool { @@ -43,11 +53,14 @@ pub fn secondary_button(ui: &mut egui::Ui, label: &str) -> bool { .fill(arc_input()) .stroke(Stroke::new(1.0, arc_border())) .corner_radius(CornerRadius::same(RADIUS_CONTROL)); - ui.add(button).clicked() + ui.add(button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() } pub fn link_button(ui: &mut egui::Ui, label: &str) -> bool { ui.add(egui::Button::new(RichText::new(label).color(arc_primary())).frame(false)) + .on_hover_cursor(egui::CursorIcon::PointingHand) .clicked() } @@ -66,43 +79,12 @@ pub fn launcher_segment(ui: &mut egui::Ui, label: &str, selected: bool) -> bool .fill(fill) .stroke(stroke) .corner_radius(CornerRadius::same(RADIUS_CONTROL)); - ui.add(button).clicked() -} - -pub fn toggle_row(ui: &mut egui::Ui, label: &str, sub: &str, value: &mut bool) -> bool { - let mut changed = false; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.label(RichText::new(label).color(arc_foreground())); - ui.label( - RichText::new(sub) - .size(TEXT_CAPTION) - .color(arc_muted_text()), - ); - }); - ui.with_layout(egui::Layout::right_to_left(Align::Center), |ui| { - changed = ui.add(egui::Checkbox::without_text(value)).changed(); - }); - }); - changed + ui.add(button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() } -pub fn settings_section( - ui: &mut egui::Ui, - title: &str, - add_contents: impl FnOnce(&mut egui::Ui) -> R, -) -> R { - card(ui, |ui| { - ui.label( - RichText::new(title) - .size(TEXT_BODY) - .strong() - .color(arc_primary()), - ); - ui.add_space(SPACE_MD); - add_contents(ui) - }) -} +// ----- cards ------------------------------------------------------------------ pub fn card(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R { Frame::NONE @@ -118,63 +100,142 @@ pub fn card(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) .inner } -pub fn pill(ui: &mut egui::Ui, text: &str, color: Color32) { - pill_frame(color).show(ui, |ui| { - ui.label(pill_text(text, color)); - }); +/// A tone-tinted status card (success "synced until…", warn notices) +pub fn tone_card( + ui: &mut egui::Ui, + tone: Color32, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + Frame::NONE + .fill(tone.linear_multiply(TONE_FILL_OPACITY)) + .stroke(Stroke::new(1.0, tone.linear_multiply(TONE_STROKE_OPACITY))) + .corner_radius(CornerRadius::same(RADIUS_TILE)) + .inner_margin(Margin::symmetric(14, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + add_contents(ui) + }) + .inner } -/// Like [`pill`], but the whole chip is a click target (the header -/// "update available" indicator). -pub fn clickable_pill(ui: &mut egui::Ui, text: &str, color: Color32) -> egui::Response { - let inner = pill_frame(color).show(ui, |ui| { - ui.label(pill_text(text, color)); +/// A settings group: a card with a gold mono eyebrow title and hairline-separated +pub fn settings_card( + ui: &mut egui::Ui, + title: &str, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + card(ui, |ui| { + mono_eyebrow(ui, title, arc_primary()); + ui.add_space(SPACE_MD); + add_contents(ui) + }) +} + +/// One settings row: label + optional sub-label on the left, a control placed +pub fn settings_row( + ui: &mut egui::Ui, + label: &str, + sub: &str, + add_control: impl FnOnce(&mut egui::Ui), +) { + ui.add_space(SPACE_XS); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label(RichText::new(label).color(arc_foreground())); + if !sub.is_empty() { + ui.label(RichText::new(sub).size(TEXT_CAPTION).color(arc_fg_dim())); + } + }); + ui.with_layout(Layout::right_to_left(Align::Center), add_control); }); - ui.interact( - inner.response.rect, - egui::Id::new("arc_update_pill"), - egui::Sense::click(), - ) - .on_hover_cursor(egui::CursorIcon::PointingHand) + ui.add_space(SPACE_XS); } -fn pill_frame(color: Color32) -> Frame { - Frame::NONE - .fill(color.linear_multiply(PILL_FILL_OPACITY)) - .stroke(Stroke::new(1.0, color.linear_multiply(PILL_STROKE_OPACITY))) - .corner_radius(CornerRadius::same(RADIUS_CONTROL)) - .inner_margin(Margin::symmetric(8, 4)) +/// Full-width hairline divider used between settings rows +pub fn hairline(ui: &mut egui::Ui) { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 1.0), Sense::hover()); + ui.painter().hline( + rect.x_range(), + rect.center().y, + Stroke::new(1.0, arc_border_soft()), + ); } -fn pill_text(text: &str, color: Color32) -> RichText { - RichText::new(text).size(TEXT_CAPTION).strong().color(color) +// ----- pills ------------------------------------------------------------------ + +/// Padding inside pills. +const PILL_PAD_X: f32 = 12.0; +const PILL_PAD_Y: f32 = 6.5; + +/// Draw a pill background (fill + stroke) into `rect`. +fn paint_pill_bg(ui: &egui::Ui, rect: Rect, color: Color32) { + let painter = ui.painter(); + painter.rect_filled( + rect, + CornerRadius::same(RADIUS_PILL), + color.linear_multiply(PILL_FILL_OPACITY), + ); + painter.rect_stroke( + rect, + CornerRadius::same(RADIUS_PILL), + Stroke::new(1.0, color.linear_multiply(PILL_STROKE_OPACITY)), + StrokeKind::Inside, + ); } -pub fn progress_stage(ui: &mut egui::Ui, label: &str, state: StageState) { - let (color, marker) = match state { - StageState::Done => (arc_success(), "●"), - StageState::Current => (arc_primary(), "◆"), - StageState::Pending => (arc_muted_text(), "○"), - }; - ui.label(RichText::new(marker).size(TEXT_CAPTION).color(color)); - ui.add_space(SPACE_XS); - let text_color = if state == StageState::Pending { - arc_muted_text() - } else { - arc_foreground() - }; - let mut text = RichText::new(label).size(TEXT_CAPTION).color(text_color); - if state == StageState::Current { - text = text.strong(); - } - ui.label(text); +pub fn pill(ui: &mut egui::Ui, text: &str, color: Color32) { + let galley = + ui.painter() + .layout_no_wrap(text.to_owned(), FontId::proportional(TEXT_PILL), color); + let ink = galley.mesh_bounds; + let dot = 6.0; + let gap = 6.0; + // Draw the capsule at an exact size we control (the font's line box is + // inflated, so we can't rely on egui auto-sizing the frame). + let w = PILL_PAD_X * 2.0 + dot + gap + galley.size().x; + let h = TEXT_PILL + 1.0 + PILL_PAD_Y * 2.0; + let (rect, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); + paint_pill_bg(ui, rect, color); + let cy = rect.center().y; + ui.painter() + .circle_filled(pos2(rect.left() + PILL_PAD_X + dot / 2.0, cy), 3.0, color); + ui.painter().galley( + pos2(rect.left() + PILL_PAD_X + dot + gap, cy - ink.center().y), + galley, + color, + ); } -pub fn status_dot(ui: &mut egui::Ui, color: Color32) { - let (rect, _) = ui.allocate_exact_size(Vec2::new(14.0, 14.0), egui::Sense::hover()); - ui.painter().circle_filled(rect.center(), 7.0, color); +/// Like [`pill`], but the whole chip is a click target (the "update available" +/// indicator in the title bar). +pub fn clickable_pill(ui: &mut egui::Ui, text: &str, color: Color32) -> egui::Response { + let galley = + ui.painter() + .layout_no_wrap(text.to_owned(), FontId::proportional(TEXT_PILL), color); + let ink = galley.mesh_bounds; + let w = PILL_PAD_X * 2.0 + galley.size().x; + let h = TEXT_PILL + 1.0 + PILL_PAD_Y * 2.0; + let (rect, response) = ui.allocate_exact_size(vec2(w, h), Sense::click()); + paint_pill_bg(ui, rect, color); + ui.painter().galley( + pos2(rect.left() + PILL_PAD_X, rect.center().y - ink.center().y), + galley, + color, + ); + response.on_hover_cursor(egui::CursorIcon::PointingHand) +} + +/// Uppercase monospace eyebrow (hero " · STEP n OF 4", settings titles) +pub fn mono_eyebrow(ui: &mut egui::Ui, text: &str, color: Color32) { + ui.label( + RichText::new(text.to_uppercase()) + .font(FontId::monospace(TEXT_EYEBROW)) + .color(color), + ); } +// ----- status / spinners ------------------------------------------------------ + /// Spinner plus status line; the update dialog body while installing. pub fn spinner_row(ui: &mut egui::Ui, label: &str) { ui.horizontal(|ui| { @@ -184,27 +245,553 @@ pub fn spinner_row(ui: &mut egui::Ui, label: &str) { }); } -/// The shared top bar: optional ARC mark, screen title, and a right-aligned -/// slot (pills on the hub, nothing on settings). A `leading` slot renders -/// before the mark for the settings back link. -pub fn screen_header( - ui: &mut egui::Ui, - mark: Option<&egui::TextureHandle>, - title: &str, - leading: impl FnOnce(&mut egui::Ui), - right: impl FnOnce(&mut egui::Ui), -) { - ui.horizontal(|ui| { - leading(ui); - if let Some(mark) = mark { - ui.image((mark.id(), Vec2::splat(20.0))); +// ----- toggle switch ---------------------------------------------------------- + +/// A 42×24 pill toggle. Returns true when toggled this frame. +pub fn toggle_switch(ui: &mut egui::Ui, on: &mut bool) -> bool { + let (rect, mut response) = ui.allocate_exact_size(vec2(42.0, 24.0), Sense::click()); + if response.clicked() { + *on = !*on; + response.mark_changed(); + } + let how_on = ui.ctx().animate_bool_with_time(response.id, *on, 0.12); + + let track = mix(arc_input(), arc_primary(), how_on); + let painter = ui.painter(); + painter.rect_filled(rect, CornerRadius::same(RADIUS_PILL), track); + if how_on < 1.0 { + painter.rect_stroke( + rect, + CornerRadius::same(RADIUS_PILL), + Stroke::new(1.0, arc_border_strong()), + StrokeKind::Inside, + ); + } + let knob_x = egui::lerp((rect.left() + 12.0)..=(rect.right() - 12.0), how_on); + let knob = mix(arc_fg_dim(), arc_primary_foreground(), how_on); + painter.circle_filled(pos2(knob_x, rect.center().y), 9.0, knob); + + response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .changed() +} + +// ----- vertical stepper ------------------------------------------------------- + +pub struct StepperNode<'a> { + /// 1-based step number, shown inside pending nodes. + pub number: usize, + pub label: &'a str, + pub sub: &'a str, + pub state: StageState, +} + +/// The persistent left rail: four nodes joined by connectors, each with a phase +/// label and sub-label. `tone` colors the current node's ring/halo/dot; `busy` +/// makes that halo breathe. Renders fully without animation. +pub fn vertical_stepper(ui: &mut egui::Ui, nodes: &[StepperNode<'_>], tone: Color32, busy: bool) { + let node_r = NODE_DIAMETER / 2.0; + let count = nodes.len(); + let time = ui.input(|input| input.time) as f32; + + for (index, node) in nodes.iter().enumerate() { + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(vec2(width, STEP_ROW_HEIGHT), Sense::hover()); + let painter = ui.painter(); + let center = pos2(rect.left() + node_r, rect.center().y); + + // Connector down to the next node (accent once this step is done). + if index + 1 < count { + let color = if node.state == StageState::Done { + arc_primary() + } else { + arc_border_soft() + }; + let top = pos2(center.x, center.y + node_r + 4.0); + let bottom = pos2(center.x, center.y + STEP_ROW_HEIGHT - node_r - 4.0); + painter.line_segment([top, bottom], Stroke::new(2.0, color)); } - ui.label( - RichText::new(title) - .size(TEXT_TITLE) - .strong() - .color(arc_foreground()), + + match node.state { + StageState::Done => { + painter.circle_filled(center, node_r, arc_primary()); + draw_check(painter, center, arc_primary_foreground()); + } + StageState::Current => { + let halo = if busy { + 0.10 + 0.18 * (0.5 + 0.5 * (time * 3.0).sin()) + } else { + 0.18 + }; + painter.circle_filled(center, node_r + 6.0, tone.linear_multiply(halo)); + painter.circle_stroke(center, node_r, Stroke::new(2.0, tone)); + painter.circle_filled(center, 4.5, tone); + } + StageState::Pending => { + painter.circle_stroke(center, node_r, Stroke::new(1.5, arc_border_strong())); + painter.text( + center, + Align2::CENTER_CENTER, + node.number.to_string(), + FontId::proportional(12.0), + arc_fg_dim(), + ); + } + } + + let label_color = match node.state { + StageState::Current => arc_foreground(), + StageState::Pending => arc_fg_dim(), + StageState::Done => arc_muted_text(), + }; + let text_x = center.x + node_r + 14.0; + painter.text( + pos2(text_x, center.y - 9.0), + Align2::LEFT_CENTER, + node.label, + FontId::proportional(TEXT_STEP_LABEL), + label_color, ); - ui.with_layout(egui::Layout::right_to_left(Align::Center), right); - }); + painter.text( + pos2(text_x, center.y + 9.0), + Align2::LEFT_CENTER, + node.sub, + FontId::proportional(TEXT_SUBLABEL), + arc_fg_dim(), + ); + } + + if busy { + ui.ctx().request_repaint(); + } +} + +// ----- hero icon tile --------------------------------------------------------- + +/// The 44×44 rounded tone-tinted tile above the hero eyebrow. Shows a spinner +/// for busy phases, otherwise a simple per-phase glyph. +pub fn icon_tile(ui: &mut egui::Ui, phase: usize, tone: Color32, busy: bool) { + let (rect, _) = ui.allocate_exact_size(vec2(ICON_TILE, ICON_TILE), Sense::hover()); + ui.painter().rect_filled( + rect, + CornerRadius::same(RADIUS_TILE), + tone.linear_multiply(TONE_FILL_OPACITY), + ); + if busy { + // paint_at fills the given rect, so use a small centered rect rather + // than the whole 44px tile. + let inner = Rect::from_center_size(rect.center(), Vec2::splat(20.0)); + egui::Spinner::new().color(tone).paint_at(ui, inner); + } else { + draw_phase_icon(ui.painter(), rect.center(), tone, phase); + } +} + +// ----- window chrome ---------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowButton { + Minimize, + Maximize, + Restore, + Close, +} + +/// A 30×30 ghost window-control button with a painter-drawn glyph. Close hovers +/// red; the rest hover with a faint fill. +pub fn window_button(ui: &mut egui::Ui, kind: WindowButton) -> bool { + let (rect, response) = + ui.allocate_exact_size(vec2(WINDOW_BUTTON, WINDOW_BUTTON), Sense::click()); + let hovered = response.hovered(); + let is_close = kind == WindowButton::Close; + let painter = ui.painter(); + + if hovered { + let fill = if is_close { arc_danger() } else { arc_muted() }; + painter.rect_filled(rect, CornerRadius::same(8), fill); + } + let fg = if is_close && hovered { + Color32::WHITE + } else if hovered { + arc_foreground() + } else { + arc_muted_text() + }; + let c = rect.center(); + let stroke = Stroke::new(1.4, fg); + match kind { + WindowButton::Minimize => { + painter.line_segment([c + vec2(-5.0, 0.0), c + vec2(5.0, 0.0)], stroke); + } + WindowButton::Maximize => { + painter.rect_stroke( + Rect::from_center_size(c, vec2(10.0, 10.0)), + CornerRadius::same(2), + stroke, + StrokeKind::Middle, + ); + } + WindowButton::Restore => { + painter.rect_stroke( + Rect::from_min_size(c + vec2(-3.0, -5.0), vec2(8.0, 8.0)), + CornerRadius::same(2), + stroke, + StrokeKind::Middle, + ); + painter.rect_stroke( + Rect::from_min_size(c + vec2(-5.0, -3.0), vec2(8.0, 8.0)), + CornerRadius::same(2), + stroke, + StrokeKind::Middle, + ); + } + WindowButton::Close => { + painter.line_segment([c + vec2(-5.0, -5.0), c + vec2(5.0, 5.0)], stroke); + painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke); + } + } + response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() +} + +/// Invisible edge/corner grab zones that drive native window resizing. Skip +/// calling this while maximized. +pub fn resize_handles(ctx: &egui::Context, ui: &mut egui::Ui, rect: Rect) { + use egui::{CursorIcon, ResizeDirection, ViewportCommand}; + + let m = 6.0; + let zones = [ + ( + Rect::from_min_max( + pos2(rect.left() + m, rect.top()), + pos2(rect.right() - m, rect.top() + m), + ), + CursorIcon::ResizeNorth, + ResizeDirection::North, + ), + ( + Rect::from_min_max( + pos2(rect.left() + m, rect.bottom() - m), + pos2(rect.right() - m, rect.bottom()), + ), + CursorIcon::ResizeSouth, + ResizeDirection::South, + ), + ( + Rect::from_min_max( + pos2(rect.left(), rect.top() + m), + pos2(rect.left() + m, rect.bottom() - m), + ), + CursorIcon::ResizeWest, + ResizeDirection::West, + ), + ( + Rect::from_min_max( + pos2(rect.right() - m, rect.top() + m), + pos2(rect.right(), rect.bottom() - m), + ), + CursorIcon::ResizeEast, + ResizeDirection::East, + ), + ( + Rect::from_min_max(rect.left_top(), pos2(rect.left() + m, rect.top() + m)), + CursorIcon::ResizeNorthWest, + ResizeDirection::NorthWest, + ), + ( + Rect::from_min_max( + pos2(rect.right() - m, rect.top()), + pos2(rect.right(), rect.top() + m), + ), + CursorIcon::ResizeNorthEast, + ResizeDirection::NorthEast, + ), + ( + Rect::from_min_max( + pos2(rect.left(), rect.bottom() - m), + pos2(rect.left() + m, rect.bottom()), + ), + CursorIcon::ResizeSouthWest, + ResizeDirection::SouthWest, + ), + ( + Rect::from_min_max( + pos2(rect.right() - m, rect.bottom() - m), + rect.right_bottom(), + ), + CursorIcon::ResizeSouthEast, + ResizeDirection::SouthEast, + ), + ]; + + for (index, (zone, cursor, direction)) in zones.into_iter().enumerate() { + let response = ui.interact(zone, ui.id().with(("arc_resize", index)), Sense::drag()); + if response.hovered() || response.dragged() { + ctx.set_cursor_icon(cursor); + } + if response.drag_started() { + ctx.send_viewport_cmd(ViewportCommand::BeginResize(direction)); + } + } +} + +// ----- painter helpers -------------------------------------------------------- + +fn mix(a: Color32, b: Color32, t: f32) -> Color32 { + let t = t.clamp(0.0, 1.0); + let f = |x: u8, y: u8| (x as f32 + (y as f32 - x as f32) * t).round() as u8; + Color32::from_rgb(f(a.r(), b.r()), f(a.g(), b.g()), f(a.b(), b.b())) +} + +fn draw_check(painter: &egui::Painter, center: Pos2, color: Color32) { + let stroke = Stroke::new(2.0, color); + let p1 = center + vec2(-4.5, 0.0); + let p2 = center + vec2(-1.0, 3.5); + let p3 = center + vec2(5.0, -3.5); + painter.line_segment([p1, p2], stroke); + painter.line_segment([p2, p3], stroke); +} + +fn arc_points( + center: Pos2, + radius: f32, + start_deg: f32, + end_deg: f32, + segments: usize, +) -> Vec { + (0..=segments) + .map(|i| { + let t = start_deg + (end_deg - start_deg) * (i as f32 / segments as f32); + let a = t.to_radians(); + pos2(center.x + radius * a.cos(), center.y + radius * a.sin()) + }) + .collect() +} + +fn arrowhead(painter: &egui::Painter, points: &[Pos2], color: Color32) { + let n = points.len(); + if n < 2 { + return; + } + let tip = points[n - 1]; + let dir = (tip - points[n - 2]).normalized(); + let normal = vec2(-dir.y, dir.x); + let back = tip - dir * 5.0; + painter.add(Shape::convex_polygon( + vec![tip, back + normal * 3.5, back - normal * 3.5], + color, + Stroke::NONE, + )); +} + +fn draw_phase_icon(painter: &egui::Painter, center: Pos2, color: Color32, phase: usize) { + let stroke = Stroke::new(2.0, color); + match phase { + 0 => { + // Account — head + shoulders. + painter.circle_stroke(center + vec2(0.0, -5.0), 4.0, stroke); + let dome = arc_points(center + vec2(0.0, 8.5), 8.0, 180.0, 360.0, 18); + painter.add(Shape::line(dome, stroke)); + } + 1 => { + // Launcher — 2×2 app grid. + let size = 7.0; + let step = size / 2.0 + 1.0; + for (dx, dy) in [(-1.0, -1.0), (1.0, -1.0), (-1.0, 1.0), (1.0, 1.0)] { + painter.rect_filled( + Rect::from_center_size(center + vec2(dx * step, dy * step), vec2(size, size)), + CornerRadius::same(2), + color, + ); + } + } + 2 => { + // Play — triangle. + painter.add(Shape::convex_polygon( + vec![ + center + vec2(-5.0, -7.0), + center + vec2(7.0, 0.0), + center + vec2(-5.0, 7.0), + ], + color, + Stroke::NONE, + )); + } + _ => { + // Sync — two chasing arcs. + let lower = arc_points(center, 8.0, 25.0, 165.0, 16); + let upper = arc_points(center, 8.0, 205.0, 345.0, 16); + painter.add(Shape::line(lower.clone(), stroke)); + painter.add(Shape::line(upper.clone(), stroke)); + arrowhead(painter, &lower, color); + arrowhead(painter, &upper, color); + } + } +} + +/// A footer ghost button: a gear glyph + label (e.g. "Settings"). Sizes itself +/// to the label so it reads as a single icon+text unit regardless of layout +/// direction. +pub fn settings_button(ui: &mut egui::Ui, label: &str) -> bool { + let font_size = 13.0; + let font = FontId::proportional(font_size); + // Lay out with PLACEHOLDER so the hover color can be applied at paint time. + let galley = ui + .painter() + .layout_no_wrap(label.to_owned(), font, Color32::PLACEHOLDER); + let icon = 13.0; + let gap = 7.0; + let pad_x = 10.0; + let width = pad_x * 2.0 + icon + gap + galley.size().x; + let (rect, response) = ui.allocate_exact_size(vec2(width, WINDOW_BUTTON), Sense::click()); + let hovered = response.hovered(); + let painter = ui.painter(); + if hovered { + painter.rect_filled(rect, CornerRadius::same(8), arc_muted()); + } + let fg = if hovered { + arc_foreground() + } else { + arc_muted_text() + }; + // Center both the glyph and the label on the button's vertical center. Use + // the cap region (ink top + ~half a cap height) rather than the full ink + // center, so descenders like the "g" don't push the text up. + let cy = rect.center().y; + let text_top = cy - (galley.mesh_bounds.top() + 0.36 * font_size); + draw_gear( + painter, + pos2(rect.left() + pad_x + icon / 2.0, cy), + fg, + icon, + ); + painter.galley(pos2(rect.left() + pad_x + icon + gap, text_top), galley, fg); + response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() +} + +/// A gear glyph: a hub circle ringed by eight short spokes. +fn draw_gear(painter: &egui::Painter, center: Pos2, color: Color32, size: f32) { + // The "spoke" settings glyph: a hub ring with eight evenly-spaced spokes. + // Round caps on both ends make every spoke look identical so the icon reads + // as symmetric and round. + let stroke = Stroke::new(1.6, color); + let cap = stroke.width / 2.0; + let hub = size * 0.20; + let r_in = size * 0.31; + let r_out = size * 0.47; + painter.circle_stroke(center, hub, stroke); + for i in 0..8 { + let angle = (i as f32) * std::f32::consts::FRAC_PI_4; + let dir = vec2(angle.cos(), angle.sin()); + let p_in = center + dir * r_in; + let p_out = center + dir * r_out; + painter.line_segment([p_in, p_out], stroke); + painter.circle_filled(p_in, cap, color); + painter.circle_filled(p_out, cap, color); + } +} + +/// A 34×34 ghost back button (left arrow), used in the settings header. +pub fn back_button(ui: &mut egui::Ui) -> bool { + let (rect, response) = ui.allocate_exact_size(vec2(34.0, 34.0), Sense::click()); + let hovered = response.hovered(); + let painter = ui.painter(); + if hovered { + painter.rect_filled(rect, CornerRadius::same(8), arc_muted()); + } + let fg = if hovered { + arc_foreground() + } else { + arc_muted_text() + }; + let stroke = Stroke::new(1.7, fg); + let c = rect.center(); + painter.line_segment([c + vec2(7.0, 0.0), c + vec2(-7.0, 0.0)], stroke); + painter.line_segment([c + vec2(-7.0, 0.0), c + vec2(-2.0, -5.0)], stroke); + painter.line_segment([c + vec2(-7.0, 0.0), c + vec2(-2.0, 5.0)], stroke); + response + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() +} + +/// A full-width secondary button (the settings "Quit" action). +pub fn secondary_button_full(ui: &mut egui::Ui, label: &str) -> bool { + let button = egui::Button::new(RichText::new(label).color(arc_foreground())) + .fill(arc_input()) + .stroke(Stroke::new(1.0, arc_border_strong())) + .corner_radius(CornerRadius::same(RADIUS_CONTROL)); + ui.add_sized(vec2(ui.available_width(), 38.0), button) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() +} + +/// A tone-tinted check badge used inline (e.g. the synced status card). +pub fn inline_check(ui: &mut egui::Ui, color: Color32) { + let (rect, _) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover()); + let center = rect.center() - vec2(0.0, 1.0); + ui.painter() + .circle_filled(center, 9.0, color.linear_multiply(0.22)); + draw_check(ui.painter(), center, color); +} + +/// A 40×40 accent-tinted rounded tile holding a refresh glyph — the explainer +/// modal's header badge. +pub fn refresh_badge(ui: &mut egui::Ui) { + let (rect, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover()); + let accent = arc_primary(); + let painter = ui.painter(); + painter.rect_filled(rect, CornerRadius::same(11), accent.linear_multiply(0.15)); + painter.rect_stroke( + rect, + CornerRadius::same(11), + Stroke::new(1.0, accent.linear_multiply(0.40)), + StrokeKind::Inside, + ); + let arc = arc_points(rect.center(), 6.5, 130.0, 400.0, 28); + painter.add(Shape::line(arc.clone(), Stroke::new(2.0, accent))); + arrowhead(painter, &arc, accent); +} + +/// A light gold radial glow at the top-center of `area`, matching the design's +/// accent gradient. Pass a painter clipped to the body so it can't bleed over +/// the title bar or the window's rounded corners. +pub fn top_glow(painter: &egui::Painter, area: Rect) { + let accent = arc_primary(); + let center = pos2(area.center().x, area.top() - area.height() * 0.04); + let rx = area.width() * 0.6; + let ry = area.height() * 0.4; + let inner = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 38); + let outer = Color32::from_rgba_unmultiplied(accent.r(), accent.g(), accent.b(), 0); + let mut mesh = egui::Mesh::default(); + mesh.colored_vertex(center, inner); + let segments = 48; + for i in 0..=segments { + let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; + mesh.colored_vertex( + pos2(center.x + rx * angle.cos(), center.y + ry * angle.sin()), + outer, + ); + } + for i in 1..=segments { + mesh.add_triangle(0, i as u32, i as u32 + 1); + } + painter.add(mesh); +} + +/// A modal pre-styled like the app's cards: a soft dimmed backdrop and a +/// generously padded card frame. +pub fn arc_modal(id: &str) -> egui::Modal { + let bg = arc_bg(); + egui::Modal::new(egui::Id::new(id)) + .backdrop_color(Color32::from_rgba_unmultiplied(bg.r(), bg.g(), bg.b(), 188)) + .frame( + Frame::NONE + .fill(arc_card()) + .stroke(Stroke::new(1.0, arc_border())) + .corner_radius(CornerRadius::same(RADIUS_CARD)) + .inner_margin(Margin::same(26)), + ) }