diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 4810eea..a40ca07 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -29,7 +29,7 @@ jobs: path-to-signatures: '.github/cla-signatures.json' path-to-document: 'https://github.com/${{ github.repository }}/blob/main/CLA.md' branch: main - allowlist: dependabot[bot],github-actions[bot] + allowlist: dependabot[bot],github-actions[bot],Emperiusm custom-notsigned-prcomment: | Thank you for your contribution! Before we can merge this PR, you need to sign the [Contributor License Agreement](https://github.com/${{ github.repository }}/blob/main/CLA.md). diff --git a/crates/prism-client/src/config/client_config_prefs.rs b/crates/prism-client/src/config/client_config_prefs.rs index 2bed47f..3999922 100644 --- a/crates/prism-client/src/config/client_config_prefs.rs +++ b/crates/prism-client/src/config/client_config_prefs.rs @@ -58,9 +58,11 @@ mod tests { #[test] fn save_and_load() { let dir = TempDir::new().expect("tempdir"); - let mut prefs = UserPrefs::default(); - prefs.default_profile = "Gaming".to_string(); - prefs.exclusive_keyboard = false; + let prefs = UserPrefs { + default_profile: "Gaming".to_string(), + exclusive_keyboard: false, + ..UserPrefs::default() + }; prefs.save(dir.path()).expect("save prefs"); let loaded = UserPrefs::load(dir.path()); diff --git a/crates/prism-client/src/ui/launcher/card_grid.rs b/crates/prism-client/src/ui/launcher/card_grid.rs index f2f4cf2..2de22fb 100644 --- a/crates/prism-client/src/ui/launcher/card_grid.rs +++ b/crates/prism-client/src/ui/launcher/card_grid.rs @@ -15,6 +15,12 @@ const FILTER_H: f32 = 32.0; const FILTER_GAP: f32 = 10.0; const TOOLBAR_H: f32 = 52.0; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GridMode { + Grid, + Rows, +} + pub struct CardGrid { cards: Vec, visible_indices: Vec, @@ -27,6 +33,7 @@ pub struct CardGrid { visible_limit: Option, show_add_card: bool, show_filters: bool, + layout_mode: GridMode, } impl CardGrid { @@ -43,6 +50,7 @@ impl CardGrid { visible_limit: None, show_add_card: true, show_filters: false, + layout_mode: GridMode::Grid, } } @@ -53,10 +61,36 @@ impl CardGrid { .unwrap_or(b.created_at) .cmp(&a.last_connected.unwrap_or(a.created_at)) }); - self.cards = ordered.iter().map(ServerCard::from_saved).collect(); + let card_mode = match self.layout_mode { + GridMode::Grid => super::server_card::CardLayoutMode::Card, + GridMode::Rows => super::server_card::CardLayoutMode::Row, + }; + self.cards = ordered + .iter() + .enumerate() + .map(|(i, s)| { + ServerCard::from_saved(s) + .with_layout_mode(card_mode) + .with_index(i + 1) + }) + .collect(); self.positions.clear(); } + pub fn set_layout_mode(&mut self, mode: GridMode) { + if self.layout_mode != mode { + self.layout_mode = mode; + let card_mode = match mode { + GridMode::Grid => super::server_card::CardLayoutMode::Card, + GridMode::Rows => super::server_card::CardLayoutMode::Row, + }; + for card in &mut self.cards { + card.set_layout_mode(card_mode); + } + self.positions.clear(); + } + } + pub fn set_visible_limit(&mut self, limit: Option) { if self.visible_limit != limit { self.visible_limit = limit; @@ -134,38 +168,55 @@ impl CardGrid { self.recompute_visible_indices(); self.recompute_filter_chip_rects(); - let cards_per_row = ((self.grid_width + CARD_GAP) / (CARD_WIDTH + CARD_GAP)) - .floor() - .max(1.0) as usize; let total = self.total_items(); if total == 0 { return; } - for idx in 0..total { - let col = idx % cards_per_row; - let row = idx / cards_per_row; - let items_in_row = if row == total / cards_per_row { - let remainder = total % cards_per_row; - if remainder == 0 { - cards_per_row - } else { - remainder + match self.layout_mode { + GridMode::Grid => { + let cards_per_row = ((self.grid_width + CARD_GAP) / (CARD_WIDTH + CARD_GAP)) + .floor() + .max(1.0) as usize; + + for idx in 0..total { + let col = idx % cards_per_row; + let row = idx / cards_per_row; + let items_in_row = if row == total / cards_per_row { + let remainder = total % cards_per_row; + if remainder == 0 { + cards_per_row + } else { + remainder + } + } else { + cards_per_row + }; + + let row_pixel_w = items_in_row as f32 * CARD_WIDTH + + (items_in_row.saturating_sub(1)) as f32 * CARD_GAP; + let x_offset = ((self.grid_width - row_pixel_w) / 2.0).max(0.0); + + self.positions.push(Rect::new( + self.rect.x + x_offset + col as f32 * (CARD_WIDTH + CARD_GAP), + self.rect.y + self.toolbar_height() + row as f32 * (CARD_HEIGHT + CARD_GAP), + CARD_WIDTH, + CARD_HEIGHT, + )); } - } else { - cards_per_row - }; - - let row_pixel_w = items_in_row as f32 * CARD_WIDTH - + (items_in_row.saturating_sub(1)) as f32 * CARD_GAP; - let x_offset = ((self.grid_width - row_pixel_w) / 2.0).max(0.0); - - self.positions.push(Rect::new( - self.rect.x + x_offset + col as f32 * (CARD_WIDTH + CARD_GAP), - self.rect.y + self.toolbar_height() + row as f32 * (CARD_HEIGHT + CARD_GAP), - CARD_WIDTH, - CARD_HEIGHT, - )); + } + GridMode::Rows => { + let row_height = 64.0; + let row_gap = 12.0; + for idx in 0..total { + self.positions.push(Rect::new( + self.rect.x, + self.rect.y + self.toolbar_height() + idx as f32 * (row_height + row_gap), + self.grid_width, + row_height, + )); + } + } } let visible = self.visible_card_count(); @@ -185,13 +236,24 @@ impl CardGrid { return self.toolbar_height(); } - let cards_per_row = ((self.grid_width + CARD_GAP) / (CARD_WIDTH + CARD_GAP)) - .floor() - .max(1.0) as usize; - let rows = total.div_ceil(cards_per_row); - self.toolbar_height() - + rows as f32 * CARD_HEIGHT - + (rows.saturating_sub(1)) as f32 * CARD_GAP + match self.layout_mode { + GridMode::Grid => { + let cards_per_row = ((self.grid_width + CARD_GAP) / (CARD_WIDTH + CARD_GAP)) + .floor() + .max(1.0) as usize; + let rows = total.div_ceil(cards_per_row); + self.toolbar_height() + + rows as f32 * CARD_HEIGHT + + (rows.saturating_sub(1)) as f32 * CARD_GAP + } + GridMode::Rows => { + let row_height = 64.0; + let row_gap = 12.0; + self.toolbar_height() + + total as f32 * row_height + + (total.saturating_sub(1)) as f32 * row_gap + } + } } fn add_card_rect(&self) -> Option { @@ -232,34 +294,45 @@ impl Widget for CardGrid { for (filter, rect) in &self.filter_chip_rects { let active = *filter == self.active_filter; let hovered = self.hovered_filter == Some(*filter); - ctx.push_glass_quad(theme::glass_quad( - *rect, - if active { - [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 0.20] - } else if hovered { - [1.0, 1.0, 1.0, 0.08] - } else { - [1.0, 1.0, 1.0, 0.04] - }, - if active { - [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 0.26] - } else { - [1.0, 1.0, 1.0, 0.10] - }, - theme::CHIP_RADIUS, - )); - ctx.push_text_run(TextRun { - x: rect.x + 14.0, - y: rect.y + 8.0, - text: filter.label(self.cards.len()), - font_size: 11.0, - color: if active { - theme::TEXT_PRIMARY - } else { - theme::TEXT_SECONDARY - }, - monospace: false, - }); + + let pill_radius = 16.0; + if active { + // Solid primary look for active text chip + ctx.push_glass_quad(theme::glass_quad( + *rect, + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 0.9], + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 1.0], + pill_radius, + )); + ctx.push_text_run(TextRun { + x: rect.x + 14.0, + y: rect.y + 10.0, + text: filter.label(self.cards.len()), + font_size: 11.0, + color: [1.0, 1.0, 1.0, 1.0], + monospace: false, + }); + } else { + // Glass pill for inactive + ctx.push_glass_quad(theme::glass_quad( + *rect, + if hovered { + [1.0, 1.0, 1.0, 0.08] + } else { + [1.0, 1.0, 1.0, 0.04] + }, + [1.0, 1.0, 1.0, 0.10], + pill_radius, + )); + ctx.push_text_run(TextRun { + x: rect.x + 14.0, + y: rect.y + 10.0, + text: filter.label(self.cards.len()), + font_size: 11.0, + color: theme::TEXT_SECONDARY, + monospace: false, + }); + } } } @@ -280,39 +353,56 @@ impl Widget for CardGrid { } if let Some(add_rect) = self.add_card_rect() { + // Mimic Stitch's dashed border with a high-contrast thin border ctx.push_glass_quad(theme::glass_quad( add_rect, - [1.0, 1.0, 1.0, 0.05], - [1.0, 1.0, 1.0, 0.16], + [1.0, 1.0, 1.0, 0.02], + [1.0, 1.0, 1.0, 0.25], // Stronger border simulating dashed style conceptually theme::CARD_RADIUS, )); + // Plus icon circle + let icon_radius = 24.0; + let icon_cx = add_rect.x + add_rect.w * 0.5; + let icon_cy = add_rect.y + add_rect.h * 0.4; + ctx.push_glass_quad(theme::glass_quad( + Rect::new( + icon_cx - icon_radius, + icon_cy - icon_radius, + icon_radius * 2.0, + icon_radius * 2.0, + ), + [1.0, 1.0, 1.0, 0.1], + [1.0, 1.0, 1.0, 0.2], + icon_radius, + )); + let plus = "+"; ctx.push_text_run(TextRun { - x: add_rect.x + (CARD_WIDTH - theme::text_width(plus, 32.0)) * 0.5, - y: add_rect.y + 58.0, + x: icon_cx - theme::text_width(plus, 28.0) * 0.5, + y: icon_cy - 14.0, text: plus.to_string(), - font_size: 32.0, - color: theme::TEXT_SECONDARY, + font_size: 28.0, + color: [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 1.0], monospace: false, }); - let title = "Add Connection"; + let title = "Add New Connection"; ctx.push_text_run(TextRun { - x: add_rect.x + (CARD_WIDTH - theme::text_width(title, 15.0)) * 0.5, - y: add_rect.y + 114.0, + x: add_rect.x + (add_rect.w - theme::text_width(title, 14.0)) * 0.5, + y: icon_cy + 40.0, text: title.to_string(), - font_size: 15.0, + font_size: 14.0, color: theme::TEXT_PRIMARY, monospace: false, }); - let body = "Manual IP or quick setup"; + let body = "Manual IP or Network Discovery"; ctx.push_text_run(TextRun { - x: add_rect.x + (CARD_WIDTH - theme::text_width(body, 12.0)) * 0.5, - y: add_rect.y + 138.0, + x: add_rect.x + (add_rect.w - theme::text_width(body, 11.0)) * 0.5, + y: icon_cy + 60.0, text: body.to_string(), - font_size: 12.0, + font_size: 11.0, color: theme::TEXT_MUTED, monospace: false, }); @@ -446,7 +536,7 @@ mod tests { let mut ctx = PaintContext::new(); grid.paint(&mut ctx); - assert!(ctx.glass_quads.len() >= 1); + assert!(!ctx.glass_quads.is_empty()); } #[test] diff --git a/crates/prism-client/src/ui/launcher/profiles.rs b/crates/prism-client/src/ui/launcher/profiles.rs index 76d424c..84ca710 100644 --- a/crates/prism-client/src/ui/launcher/profiles.rs +++ b/crates/prism-client/src/ui/launcher/profiles.rs @@ -7,6 +7,7 @@ use crate::config::profiles::{AudioMode, ProfileConfig, ProfileStore}; use crate::ui::theme; use crate::ui::widgets::button::{Button, ButtonStyle}; use crate::ui::widgets::dropdown::Dropdown; +use crate::ui::widgets::segmented::SegmentedControl; use crate::ui::widgets::slider::Slider; use crate::ui::widgets::toggle::Toggle; use crate::ui::widgets::{ @@ -31,7 +32,7 @@ pub struct ProfilesPanel { list_rows: Vec, bitrate_slider: Slider, fps_dropdown: Dropdown, - encoder_dropdown: Dropdown, + encoder_dropdown: SegmentedControl, native_scaling_toggle: Toggle, audio_mode_dropdown: Dropdown, av1_toggle: Toggle, @@ -59,7 +60,7 @@ impl ProfilesPanel { bitrate_slider: Slider::new("Bitrate", 5.0, 80.0, 35.0) .with_format(|v| format!("{} Mbps", v.round() as u32)), fps_dropdown: Dropdown::new(Self::fps_options(), 2), - encoder_dropdown: Dropdown::new(Self::encoder_options(), 0), + encoder_dropdown: SegmentedControl::new(Self::encoder_options(), 0), native_scaling_toggle: Toggle::new(true), audio_mode_dropdown: Dropdown::new(Self::audio_options(), 0), av1_toggle: Toggle::new(true), @@ -160,9 +161,9 @@ impl ProfilesPanel { fn encoder_options() -> Vec { vec![ - "UltraLowLatency".into(), + "Lowest Latency".into(), "Balanced".into(), - "Quality".into(), + "Highest Quality".into(), ] } @@ -348,31 +349,49 @@ impl Widget for ProfilesPanel { let editor = self.editor_rect(); let x = editor.x + PANEL_PAD; let w = (editor.w - PANEL_PAD * 2.0).max(260.0); - let mut y = editor.y + 98.0; - - self.bitrate_slider.layout(Rect::new(x, y, w, 32.0)); - y += 54.0; - self.fps_dropdown.layout(Rect::new(x, y, w, 40.0)); - y += 52.0; - self.encoder_dropdown.layout(Rect::new(x, y, w, 40.0)); - y += 52.0; - self.native_scaling_toggle.layout(Rect::new(x, y, w, 22.0)); - y += 34.0; - self.audio_mode_dropdown.layout(Rect::new(x, y, w, 40.0)); - y += 52.0; - self.av1_toggle.layout(Rect::new(x, y, w, 22.0)); - y += 34.0; - self.exclusive_input_toggle.layout(Rect::new(x, y, w, 22.0)); - y += 34.0; - self.touch_mode_toggle.layout(Rect::new(x, y, w, 22.0)); - y += 34.0; - self.auto_reconnect_toggle.layout(Rect::new(x, y, w, 22.0)); - - let buttons_y = (editor.y + editor.h - 48.0).max(y + 16.0); + + let header_h = 90.0; + let right_edge = editor.x + w; + let buttons_y = editor.y + 24.0; + self.discard_button - .layout(Rect::new(x, buttons_y, 132.0, 40.0)); + .layout(Rect::new(right_edge - 264.0, buttons_y, 120.0, 36.0)); self.save_button - .layout(Rect::new(x + 146.0, buttons_y, 132.0, 40.0)); + .layout(Rect::new(right_edge - 132.0, buttons_y, 132.0, 36.0)); + + let y_start = editor.y + header_h + 32.0; + let col_w = ((w - 40.0) / 2.0).max(180.0); + let col1_x = x; + let col2_x = x + col_w + 40.0; + + let mut y = y_start; + self.bitrate_slider + .layout(Rect::new(col1_x, y + 20.0, col_w, 32.0)); + y += 70.0; + self.encoder_dropdown + .layout(Rect::new(col1_x, y + 20.0, col_w, 36.0)); + y += 70.0; + self.native_scaling_toggle + .layout(Rect::new(col1_x, y + 20.0, col_w, 22.0)); + y += 70.0; + self.av1_toggle + .layout(Rect::new(col1_x, y + 20.0, col_w, 22.0)); + + let mut y = y_start; + self.fps_dropdown + .layout(Rect::new(col2_x, y + 20.0, col_w, 40.0)); + y += 70.0; + self.audio_mode_dropdown + .layout(Rect::new(col2_x, y + 20.0, col_w, 40.0)); + y += 70.0; + self.exclusive_input_toggle + .layout(Rect::new(col2_x, y + 20.0, col_w, 22.0)); + y += 70.0; + self.touch_mode_toggle + .layout(Rect::new(col2_x, y + 20.0, col_w, 22.0)); + y += 70.0; + self.auto_reconnect_toggle + .layout(Rect::new(col2_x, y + 20.0, col_w, 22.0)); Size { w: available.w, @@ -399,22 +418,48 @@ impl Widget for ProfilesPanel { let selected = idx == self.selected_index; let profile = &self.profiles[idx]; ctx.push_glass_quad(theme::nav_item_surface(*row, selected, false)); + + if selected { + ctx.push_glass_quad(theme::glass_quad( + Rect::new(row.x, row.y, 4.0, row.h), + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 0.9], + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 1.0], + 2.0, + )); + } + + let name_y = row.y + 14.0; ctx.push_text_run(TextRun { - x: row.x + 12.0, - y: row.y + 12.0, + x: row.x + 16.0, + y: name_y, text: profile.name.clone(), font_size: 14.0, - color: theme::TEXT_PRIMARY, + color: if selected { + theme::TEXT_PRIMARY + } else { + theme::TEXT_SECONDARY + }, monospace: false, }); + + let subtitle = if profile.builtin { + format!( + "Built-in • {} FPS • {} Mbps", + profile.max_fps, + profile.bitrate_bps / 1_000_000 + ) + } else { + format!( + "{} FPS • {} Mbps", + profile.max_fps, + profile.bitrate_bps / 1_000_000 + ) + }; + ctx.push_text_run(TextRun { - x: row.x + 12.0, - y: row.y + 32.0, - text: if profile.builtin { - "Built-in".to_string() - } else { - "Custom".to_string() - }, + x: row.x + 16.0, + y: row.y + 34.0, + text: subtitle, font_size: 11.0, color: theme::TEXT_MUTED, monospace: false, @@ -422,59 +467,102 @@ impl Widget for ProfilesPanel { } if let Some(draft) = &self.draft { + ctx.push_glass_quad(theme::glass_quad( + Rect::new(editor.x, editor.y, editor.w, 90.0), + [1.0, 1.0, 1.0, 0.2], + [1.0, 1.0, 1.0, 0.4], + 0.0, + )); + + let tw = theme::text_width(&draft.name, theme::FONT_HERO); ctx.push_text_run(TextRun { x: editor.x + PANEL_PAD, - y: editor.y + 24.0, + y: editor.y + 20.0, text: draft.name.clone(), - font_size: theme::FONT_HEADLINE, + font_size: theme::FONT_HERO, color: theme::TEXT_PRIMARY, monospace: false, }); + + if draft.builtin { + let badge = Rect::new( + editor.x + PANEL_PAD + tw + 16.0, + editor.y + 24.0, + 60.0, + 20.0, + ); + ctx.push_glass_quad(theme::status_chip(badge, theme::ChipTone::Success)); + ctx.push_text_run(TextRun { + x: badge.x + 10.0, + y: badge.y + 3.0, + text: "SYSTEM".to_string(), + font_size: 10.0, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + } + + if self.dirty { + let badge_x = + editor.x + PANEL_PAD + tw + 16.0 + if draft.builtin { 70.0 } else { 0.0 }; + let chip = Rect::new(badge_x, editor.y + 24.0, 80.0, 20.0); + ctx.push_glass_quad(theme::status_chip(chip, theme::ChipTone::Warning)); + ctx.push_text_run(TextRun { + x: chip.x + 10.0, + y: chip.y + 3.0, + text: "UNSAVED".to_string(), + font_size: 10.0, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + } + ctx.push_text_run(TextRun { x: editor.x + PANEL_PAD, - y: editor.y + 52.0, - text: "Streaming profile tuning".to_string(), + y: editor.y + 60.0, + text: "Optimized for high-performance interaction".to_string(), font_size: theme::FONT_BODY, color: theme::TEXT_SECONDARY, monospace: false, }); } - if self.dirty { - let chip = Rect::new(editor.x + editor.w - 132.0, editor.y + 14.0, 114.0, 24.0); - ctx.push_glass_quad(theme::status_chip(chip, theme::ChipTone::Warning)); - ctx.push_text_run(TextRun { - x: chip.x + 10.0, - y: chip.y + 5.0, - text: "Unsaved".to_string(), - font_size: 11.0, - color: theme::TEXT_PRIMARY, - monospace: false, - }); - } + let header_h = 90.0; + let y_start = editor.y + header_h + 32.0; + let col_w = ((editor.w - PANEL_PAD * 2.0 - 40.0) / 2.0).max(180.0); + let col1_x = editor.x + PANEL_PAD; + let col2_x = editor.x + PANEL_PAD + col_w + 40.0; - let editor_x = editor.x + PANEL_PAD; - let mut y = editor.y + 148.0; - for label in [ - "Max FPS", - "Encoder Preset", - "Native Scaling", - "Audio Mode", - "Prefer AV1", - "Exclusive Input", - "Touch Mode", - "Auto Reconnect", - ] { + let mut draw_label = |x, y, text: &str| { ctx.push_text_run(TextRun { - x: editor_x, + x, y, - text: label.to_string(), + text: text.to_string(), font_size: theme::FONT_CAPTION, color: theme::TEXT_MUTED, monospace: false, }); - y += 52.0; - } + }; + + let mut y = y_start; + draw_label(col1_x, y, "Bitrate Preference"); + y += 70.0; + draw_label(col1_x, y, "Latency vs Quality"); + y += 70.0; + draw_label(col1_x, y, "Native Scaling"); + y += 70.0; + draw_label(col1_x, y, "Prefer AV1"); + + let mut y = y_start; + draw_label(col2_x, y, "Max FPS"); + y += 70.0; + draw_label(col2_x, y, "Audio Mode"); + y += 70.0; + draw_label(col2_x, y, "Exclusive Input"); + y += 70.0; + draw_label(col2_x, y, "Touch Mode"); + y += 70.0; + draw_label(col2_x, y, "Auto Reconnect"); self.bitrate_slider.paint(ctx); self.fps_dropdown.paint(ctx); diff --git a/crates/prism-client/src/ui/launcher/quick_connect.rs b/crates/prism-client/src/ui/launcher/quick_connect.rs index 6bfdcab..e6fd568 100644 --- a/crates/prism-client/src/ui/launcher/quick_connect.rs +++ b/crates/prism-client/src/ui/launcher/quick_connect.rs @@ -43,24 +43,20 @@ impl Default for QuickConnect { impl Widget for QuickConnect { fn layout(&mut self, available: Rect) -> Size { - let panel_h = 94.0; + // Hero container sizes + let panel_h = 260.0; self.rect = Rect::new(available.x, available.y, available.w, panel_h); - let controls_y = available.y + 38.0; + let pad_x = 32.0; + let content_w = available.w - (pad_x * 2.0); + let input_y = available.y + 128.0; + let btn_y = input_y + 54.0; - self.address_input.layout(Rect::new( - available.x + 18.0, - controls_y, - available.w - 156.0, - 42.0, - )); + self.address_input + .layout(Rect::new(available.x + pad_x, input_y, content_w, 42.0)); - self.connect_button.layout(Rect::new( - available.x + available.w - 120.0, - controls_y, - 102.0, - 42.0, - )); + self.connect_button + .layout(Rect::new(available.x + pad_x, btn_y, content_w, 42.0)); Size { w: available.w, @@ -70,12 +66,27 @@ impl Widget for QuickConnect { fn paint(&self, ctx: &mut PaintContext) { ctx.push_glass_quad(theme::hero_surface(self.rect)); + + // Match Stitch visual intent with FONT_HERO heading centered + let title = "Quick Connect"; + let title_w = theme::text_width(title, theme::FONT_HERO); + ctx.push_text_run(TextRun { + x: self.rect.x + (self.rect.w - title_w) * 0.5, + y: self.rect.y + 40.0, + text: title.into(), + font_size: theme::FONT_HERO, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + + let subtitle = "Enter a hostname or IP address"; + let sub_w = theme::text_width(subtitle, theme::FONT_BODY); ctx.push_text_run(TextRun { - x: self.rect.x + 18.0, - y: self.rect.y + 14.0, - text: "Quick connect".into(), - font_size: 12.0, - color: theme::TEXT_SECONDARY, + x: self.rect.x + (self.rect.w - sub_w) * 0.5, + y: self.rect.y + 80.0, + text: subtitle.into(), + font_size: theme::FONT_BODY, + color: theme::TEXT_MUTED, monospace: false, }); @@ -161,7 +172,10 @@ mod tests { qc.paint(&mut ctx); // At least 1 glass_quad for the panel background (plus sub-widget quads) - assert!(ctx.glass_quads.len() >= 1, "expected at least 1 glass quad"); + assert!( + !ctx.glass_quads.is_empty(), + "expected at least 1 glass quad" + ); // Sub-widgets contribute text runs assert!(!ctx.text_runs.is_empty(), "expected text from sub-widgets"); } diff --git a/crates/prism-client/src/ui/launcher/server_card.rs b/crates/prism-client/src/ui/launcher/server_card.rs index de65960..fc51aac 100644 --- a/crates/prism-client/src/ui/launcher/server_card.rs +++ b/crates/prism-client/src/ui/launcher/server_card.rs @@ -13,6 +13,12 @@ use crate::ui::widgets::{ const WEEK_SECS: u64 = 7 * 24 * 60 * 60; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CardLayoutMode { + Card, + Row, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CardFilter { All, @@ -55,6 +61,14 @@ impl CardStatus { CardStatus::New => theme::ACCENT, } } + + fn chip_tone(self) -> theme::ChipTone { + match self { + CardStatus::Recent => theme::ChipTone::Success, + CardStatus::Dormant => theme::ChipTone::Warning, + CardStatus::New => theme::ChipTone::Accent, + } + } } pub struct ServerCard { @@ -70,6 +84,8 @@ pub struct ServerCard { delete_button: Button, hover_anim: Animation, hovered: bool, + layout_mode: CardLayoutMode, + index: Option, rect: Rect, } @@ -116,10 +132,26 @@ impl ServerCard { .with_style(ButtonStyle::Destructive), hover_anim: Animation::new(EaseCurve::EaseOut, 150.0), hovered: false, + layout_mode: CardLayoutMode::Card, + index: None, rect: Rect::new(0.0, 0.0, Self::WIDTH, Self::HEIGHT), } } + pub fn with_index(mut self, index: usize) -> Self { + self.index = Some(index); + self + } + + pub fn with_layout_mode(mut self, mode: CardLayoutMode) -> Self { + self.layout_mode = mode; + self + } + + pub fn set_layout_mode(&mut self, mode: CardLayoutMode) { + self.layout_mode = mode; + } + pub fn matches_filter(&self, filter: CardFilter) -> bool { match filter { CardFilter::All => true, @@ -152,10 +184,11 @@ impl ServerCard { } fn profile_chip_rect(&self) -> Rect { + let status_w = theme::text_width(self.status().label(), 10.0) + 26.0; let label = self.last_profile.to_uppercase(); let w = theme::text_width(&label, 10.0) + 26.0; Rect::new( - self.rect.x + self.rect.w - 18.0 - w, + self.rect.x + 18.0 + status_w + 6.0, self.rect.y + 16.0, w, 20.0, @@ -163,7 +196,11 @@ impl ServerCard { } fn buttons_y(&self) -> f32 { - self.rect.y + self.rect.h - 54.0 + if self.layout_mode == CardLayoutMode::Row { + self.rect.y + 12.0 + } else { + self.rect.y + self.rect.h - 52.0 + } } fn relative_last_connected(&self) -> String { @@ -191,23 +228,44 @@ impl Widget for ServerCard { fn layout(&mut self, available: Rect) -> Size { const ACTION_GAP: f32 = 8.0; const SECONDARY_W: f32 = 62.0; - self.rect = Rect::new(available.x, available.y, Self::WIDTH, Self::HEIGHT); + + let h = match self.layout_mode { + CardLayoutMode::Card => Self::HEIGHT, + CardLayoutMode::Row => 64.0, + }; + let w = match self.layout_mode { + CardLayoutMode::Card => Self::WIDTH, + CardLayoutMode::Row => available.w, + }; + + self.rect = Rect::new(available.x, available.y, w, h); let button_y = self.buttons_y(); - let delete_x = self.rect.x + self.rect.w - 18.0 - SECONDARY_W; - let edit_x = delete_x - ACTION_GAP - SECONDARY_W; - let connect_x = self.rect.x + 18.0; - let connect_w = (edit_x - ACTION_GAP - connect_x).max(80.0); - - self.connect_button - .layout(Rect::new(connect_x, button_y, connect_w, 40.0)); - self.edit_button - .layout(Rect::new(edit_x, button_y, SECONDARY_W, 40.0)); - self.delete_button - .layout(Rect::new(delete_x, button_y, SECONDARY_W, 40.0)); - Size { - w: Self::WIDTH, - h: Self::HEIGHT, + + if self.layout_mode == CardLayoutMode::Row { + let delete_x = self.rect.x + self.rect.w - 18.0 - SECONDARY_W; + let edit_x = delete_x - ACTION_GAP - SECONDARY_W; + let connect_x = edit_x - ACTION_GAP - 90.0; + self.connect_button + .layout(Rect::new(connect_x, button_y, 90.0, 40.0)); + self.edit_button + .layout(Rect::new(edit_x, button_y, SECONDARY_W, 40.0)); + self.delete_button + .layout(Rect::new(delete_x, button_y, SECONDARY_W, 40.0)); + } else { + let sec_w = 48.0; + let delete_x = self.rect.x + self.rect.w - 18.0 - sec_w; + let edit_x = delete_x - ACTION_GAP - sec_w; + let connect_x = self.rect.x + 18.0; + let connect_w = (edit_x - ACTION_GAP - connect_x).max(80.0); + self.connect_button + .layout(Rect::new(connect_x, button_y, connect_w, 36.0)); + self.edit_button + .layout(Rect::new(edit_x, button_y, sec_w, 36.0)); + self.delete_button + .layout(Rect::new(delete_x, button_y, sec_w, 36.0)); } + + Size { w, h } } fn paint(&self, ctx: &mut PaintContext) { @@ -222,23 +280,38 @@ impl Widget for ServerCard { 1.0, ]; - ctx.push_glass_quad(theme::card_surface(r)); - if hover > 0.01 { + if self.layout_mode == CardLayoutMode::Row { + ctx.push_glass_quad(theme::list_row_surface(r, hover > 0.01)); + } else { + ctx.push_glass_quad(theme::card_surface(r)); + if hover > 0.01 { + ctx.push_glass_quad(theme::glass_quad( + r, + [accent[0], accent[1], accent[2], 0.04 + hover * 0.08], + [accent[0], accent[1], accent[2], 0.08 + hover * 0.10], + theme::CARD_RADIUS, + )); + } + } + + let status_rect = if self.layout_mode == CardLayoutMode::Row { + let label = status.label(); + let w = theme::text_width(label, 10.0) + 26.0; + Rect::new(r.x + 220.0, r.y + 22.0, w, 20.0) + } else { + self.status_chip_rect() + }; + + if self.layout_mode == CardLayoutMode::Row { + ctx.push_glass_quad(theme::status_chip(status_rect, status.chip_tone())); + } else { ctx.push_glass_quad(theme::glass_quad( - r, - [accent[0], accent[1], accent[2], 0.04 + hover * 0.08], - [accent[0], accent[1], accent[2], 0.08 + hover * 0.10], - theme::CARD_RADIUS, + status_rect, + [status_tone[0], status_tone[1], status_tone[2], 0.18], + [status_tone[0], status_tone[1], status_tone[2], 0.22], + theme::CHIP_RADIUS, )); } - - let status_rect = self.status_chip_rect(); - ctx.push_glass_quad(theme::glass_quad( - status_rect, - [status_tone[0], status_tone[1], status_tone[2], 0.18], - [status_tone[0], status_tone[1], status_tone[2], 0.22], - theme::CHIP_RADIUS, - )); ctx.push_text_run(TextRun { x: status_rect.x + 12.0, y: status_rect.y + 4.0, @@ -248,58 +321,104 @@ impl Widget for ServerCard { monospace: false, }); - let profile_rect = self.profile_chip_rect(); - let profile_label = self.last_profile.to_uppercase(); - ctx.push_glass_quad(theme::glass_quad( - profile_rect, - [1.0, 1.0, 1.0, 0.08], - [1.0, 1.0, 1.0, 0.10], - theme::CHIP_RADIUS, - )); - ctx.push_text_run(TextRun { - x: profile_rect.x + 12.0, - y: profile_rect.y + 4.0, - text: profile_label, - font_size: 10.0, - color: theme::TEXT_SECONDARY, - monospace: false, - }); - - ctx.push_text_run(TextRun { - x: r.x + 18.0, - y: r.y + 52.0, - text: self.display_name.clone(), - font_size: 16.0, - color: theme::TEXT_PRIMARY, - monospace: false, - }); - - ctx.push_text_run(TextRun { - x: r.x + 18.0, - y: r.y + 76.0, - text: self.address.clone(), - font_size: 11.0, - color: theme::TEXT_MUTED, - monospace: false, - }); - - ctx.push_text_run(TextRun { - x: r.x + 18.0, - y: r.y + 110.0, - text: self.relative_last_connected(), - font_size: 11.0, - color: theme::TEXT_SECONDARY, - monospace: false, - }); - - ctx.push_text_run(TextRun { - x: r.x + 18.0, - y: r.y + 132.0, - text: self.last_info.clone(), - font_size: 11.0, - color: theme::TEXT_TERTIARY, - monospace: false, - }); + if self.layout_mode == CardLayoutMode::Card { + let profile_rect = self.profile_chip_rect(); + let profile_label = self.last_profile.to_uppercase(); + ctx.push_glass_quad(theme::glass_quad( + profile_rect, + [1.0, 1.0, 1.0, 0.08], + [1.0, 1.0, 1.0, 0.10], + theme::CHIP_RADIUS, + )); + ctx.push_text_run(TextRun { + x: profile_rect.x + 12.0, + y: profile_rect.y + 4.0, + text: profile_label, + font_size: 10.0, + color: theme::TEXT_SECONDARY, + monospace: false, + }); + + ctx.push_text_run(TextRun { + x: r.x + 18.0, + y: r.y + 60.0, + text: self.display_name.clone(), + font_size: 16.0, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + + ctx.push_text_run(TextRun { + x: r.x + 18.0, + y: r.y + 80.0, + text: self.address.clone(), + font_size: 11.0, + color: theme::TEXT_MUTED, + monospace: false, + }); + + ctx.push_text_run(TextRun { + x: r.x + 18.0, + y: r.y + 116.0, + text: self.relative_last_connected(), + font_size: 11.0, + color: theme::TEXT_SECONDARY, + monospace: false, + }); + + ctx.push_text_run(TextRun { + x: r.x + 18.0, + y: r.y + 134.0, + text: self.last_info.clone(), + font_size: 11.0, + color: theme::TEXT_TERTIARY, + monospace: false, + }); + } else { + // Row text placement + ctx.push_glass_quad(theme::glass_quad( + Rect::new(r.x + 18.0, r.y + 16.0, 32.0, 32.0), + [accent[0], accent[1], accent[2], 0.1], + [0.0, 0.0, 0.0, 0.0], + 8.0, + )); + let index_text = format!("{:02}", self.index.unwrap_or(0)); + let idx_w = theme::text_width(&index_text, 14.0); + ctx.push_text_run(TextRun { + x: r.x + 18.0 + (32.0 - idx_w) * 0.5, + y: r.y + 26.0, + text: index_text, + font_size: 14.0, + color: accent, + monospace: true, + }); + ctx.push_text_run(TextRun { + x: r.x + 64.0, + y: r.y + 16.0, + text: self.display_name.clone(), + font_size: 14.0, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + ctx.push_text_run(TextRun { + x: r.x + 64.0, + y: r.y + 36.0, + text: self.address.clone(), + font_size: 11.0, + color: theme::TEXT_MUTED, + monospace: false, + }); + + let status_end = status_rect.x + status_rect.w; + ctx.push_text_run(TextRun { + x: status_end + 32.0, + y: r.y + 24.0, + text: self.relative_last_connected(), + font_size: 12.0, + color: theme::TEXT_SECONDARY, + monospace: false, + }); + } self.connect_button.paint(ctx); self.edit_button.paint(ctx); diff --git a/crates/prism-client/src/ui/launcher/server_form.rs b/crates/prism-client/src/ui/launcher/server_form.rs index ab6faa6..f28eee5 100644 --- a/crates/prism-client/src/ui/launcher/server_form.rs +++ b/crates/prism-client/src/ui/launcher/server_form.rs @@ -116,33 +116,33 @@ impl Widget for ServerForm { return Size { w: 0.0, h: 0.0 }; } - let panel_w = 300.0; - let panel_h = 280.0; + let panel_w = 340.0; + let panel_h = 320.0; self.rect = Rect::new(available.x, available.y, panel_w, panel_h); - let x = available.x + 12.0; - let w = panel_w - 24.0; + let x = available.x + 20.0; + let w = panel_w - 40.0; - // Name input at y+50 + // Name input at y+60 self.name_input - .layout(Rect::new(x, available.y + 50.0, w, 36.0)); - // Address at y+95 + .layout(Rect::new(x, available.y + 60.0, w, 36.0)); + // Address at y+110 self.address_input - .layout(Rect::new(x, available.y + 95.0, w, 36.0)); - // Noise key at y+140 + .layout(Rect::new(x, available.y + 110.0, w, 36.0)); + // Noise key at y+160 self.noise_key_input - .layout(Rect::new(x, available.y + 140.0, w, 36.0)); - // Profile dropdown at y+185 + .layout(Rect::new(x, available.y + 160.0, w, 36.0)); + // Profile dropdown at y+210 self.profile_dropdown - .layout(Rect::new(x, available.y + 185.0, w, 32.0)); - // Save button at y+230 + .layout(Rect::new(x, available.y + 210.0, w, 32.0)); + // Save button at y+260 self.save_button - .layout(Rect::new(x, available.y + 230.0, (w / 2.0) - 4.0, 36.0)); + .layout(Rect::new(x, available.y + 260.0, (w / 2.0) - 6.0, 36.0)); // Cancel beside save self.cancel_button.layout(Rect::new( - x + (w / 2.0) + 4.0, - available.y + 230.0, - (w / 2.0) - 4.0, + x + (w / 2.0) + 6.0, + available.y + 260.0, + (w / 2.0) - 6.0, 36.0, )); @@ -263,7 +263,7 @@ mod tests { form.paint(&mut ctx); assert!( - ctx.glass_quads.len() > 0, + !ctx.glass_quads.is_empty(), "expected glass quads when form is visible" ); } diff --git a/crates/prism-client/src/ui/launcher/settings.rs b/crates/prism-client/src/ui/launcher/settings.rs index 270f091..f6dc871 100644 --- a/crates/prism-client/src/ui/launcher/settings.rs +++ b/crates/prism-client/src/ui/launcher/settings.rs @@ -10,8 +10,7 @@ use crate::ui::widgets::dropdown::Dropdown; use crate::ui::widgets::toggle::Toggle; use crate::ui::widgets::{EventResponse, PaintContext, Rect, Size, TextRun, UiEvent, Widget}; -const SECTION_GAP: f32 = 14.0; -const SECTION_PAD: f32 = 18.0; +const ROW_GAP: f32 = 28.0; pub struct SettingsPanel { rect: Rect, @@ -24,6 +23,10 @@ pub struct SettingsPanel { relative_mouse_toggle: Toggle, audio_output_dropdown: Dropdown, mic_dropdown: Dropdown, + + // Simplest native scroll tracking possible without overengineering clipped contexts + scroll_y: f32, + max_scroll: f32, } impl SettingsPanel { @@ -61,6 +64,8 @@ impl SettingsPanel { ], 0, ), + scroll_y: 0.0, + max_scroll: 0.0, } } @@ -117,61 +122,67 @@ impl SettingsPanel { let _ = prefs.save(prefs_dir); } } - - fn section_rects(&self) -> (Rect, Rect, Rect, Rect, Rect) { - let x = self.rect.x; - let w = self.rect.w; - let mut y = self.rect.y + 58.0; - - let identity = Rect::new(x, y, w, 92.0); - y += identity.h + SECTION_GAP; - let streaming = Rect::new(x, y, w, 102.0); - y += streaming.h + SECTION_GAP; - let input = Rect::new(x, y, w, 108.0); - y += input.h + SECTION_GAP; - let audio = Rect::new(x, y, w, 132.0); - y += audio.h + SECTION_GAP; - let about = Rect::new(x, y, w, 74.0); - - (identity, streaming, input, audio, about) - } } impl Widget for SettingsPanel { fn layout(&mut self, available: Rect) -> Size { self.rect = available; - let (_identity, streaming, input, audio, _about) = self.section_rects(); - self.default_profile_dropdown.layout(Rect::new( - streaming.x + SECTION_PAD, - streaming.y + 44.0, - streaming.w - SECTION_PAD * 2.0, - 40.0, - )); + let content_x = available.x + 40.0; + let content_w = (available.w - 80.0).clamp(400.0, 900.0); + + // Settings inner components width mappings (split layout logic) + let _left_w = content_w * 0.35; + let right_w = content_w * 0.65; + let right_x = content_x + content_w - right_w; + + // Base y is affected smoothly by manual scrolling + let mut cursor_y = available.y + 110.0 - self.scroll_y; + + // Sections + // Identity + cursor_y += 34.0; + + // Device Trust + cursor_y += ROW_GAP + 20.0 + 34.0; + + // Streaming Defaults + cursor_y += ROW_GAP + 20.0; + self.default_profile_dropdown + .layout(Rect::new(right_x, cursor_y, right_w, 40.0)); + cursor_y += 40.0; + + // Input + cursor_y += ROW_GAP + 20.0; self.exclusive_keyboard_toggle.layout(Rect::new( - input.x + SECTION_PAD, - input.y + 40.0, - input.w - SECTION_PAD * 2.0, + right_x + right_w - 42.0, + cursor_y + 14.0, + 42.0, 22.0, )); + cursor_y += 56.0; self.relative_mouse_toggle.layout(Rect::new( - input.x + SECTION_PAD, - input.y + 72.0, - input.w - SECTION_PAD * 2.0, + right_x + right_w - 42.0, + cursor_y + 14.0, + 42.0, 22.0, )); - self.audio_output_dropdown.layout(Rect::new( - audio.x + SECTION_PAD, - audio.y + 36.0, - audio.w - SECTION_PAD * 2.0, - 40.0, - )); - self.mic_dropdown.layout(Rect::new( - audio.x + SECTION_PAD, - audio.y + 84.0, - audio.w - SECTION_PAD * 2.0, - 40.0, - )); + cursor_y += 50.0; + + // Audio + cursor_y += ROW_GAP + 20.0; + self.audio_output_dropdown + .layout(Rect::new(right_x, cursor_y + 24.0, right_w, 40.0)); + cursor_y += 76.0; + self.mic_dropdown + .layout(Rect::new(right_x, cursor_y + 24.0, right_w, 40.0)); + cursor_y += 76.0; + + // Compute total unscaled height + let total_content_h = (cursor_y + self.scroll_y - available.y) + 120.0; + + // Update valid max scroll bounds dynamically + self.max_scroll = (total_content_h - available.h).max(0.0); Size { w: available.w, @@ -180,113 +191,236 @@ impl Widget for SettingsPanel { } fn paint(&self, ctx: &mut PaintContext) { - let (identity, streaming, input, audio, about) = self.section_rects(); + // Sticky Header Region Layout (unaffected by scroll_y directly, visually floats if needed, but we'll draw it to scroll for harmony with Stitch). + let scroll_top = self.rect.y - self.scroll_y; + let content_x = self.rect.x + 40.0; + let content_w = (self.rect.w - 80.0).clamp(400.0, 900.0); + + // Header ctx.push_text_run(TextRun { - x: self.rect.x, - y: self.rect.y + 10.0, - text: "Client Settings".to_string(), - font_size: 13.0, - color: theme::TEXT_MUTED, + x: content_x, + y: scroll_top + 40.0, + text: "Identity & Security".to_string(), + font_size: theme::FONT_DISPLAY, + color: theme::TEXT_PRIMARY, monospace: false, }); - for section in [identity, streaming, input, audio, about] { - ctx.push_glass_quad(theme::card_surface(section)); - } - ctx.push_text_run(TextRun { - x: identity.x + SECTION_PAD, - y: identity.y + 14.0, - text: "Identity & Security".to_string(), - font_size: 13.0, + x: content_x, + y: scroll_top + 74.0, + text: "Manage your digital footprint and application settings.".to_string(), + font_size: theme::FONT_BODY, color: theme::TEXT_MUTED, monospace: false, }); + + // Main Card Surface bounding all attributes + let card_y = scroll_top + 110.0; + let card_h = (self.max_scroll + self.rect.h) - 200.0; // Approximation of content depth + let card_rect = Rect::new(content_x, card_y, content_w, card_h.max(680.0)); + + ctx.push_glass_quad(theme::floating_surface(card_rect)); + + // Drawing Helper + let draw_row = |ctx: &mut PaintContext, y: f32, title: &str, subtitle: &str| { + ctx.push_text_run(TextRun { + x: content_x + 32.0, + y: y + 20.0, + text: title.to_string(), + font_size: theme::FONT_BODY, + color: theme::TEXT_PRIMARY, + monospace: false, + }); + ctx.push_text_run(TextRun { + x: content_x + 32.0, + y: y + 42.0, + text: subtitle.to_string(), + font_size: theme::FONT_CAPTION, + color: theme::TEXT_MUTED, + monospace: false, + }); + }; + + let draw_separator = |ctx: &mut PaintContext, y: f32| { + ctx.push_glass_quad(theme::glass_quad( + Rect::new(content_x + 32.0, y, content_w - 64.0, 1.0), + [0.0, 0.0, 0.0, 0.05], + [0.0, 0.0, 0.0, 0.0], + 0.0, + )); + }; + + let mut cy = card_y; + + // Identity Path + draw_row( + ctx, + cy, + "Identity Path", + "Your unique cryptographic identifier.", + ); + let id_badge_w = theme::text_width(&self.identity_path, theme::FONT_LABEL) + 32.0; + ctx.push_glass_quad(theme::glass_quad( + Rect::new( + content_x + content_w - id_badge_w - 32.0, + cy + 12.0, + id_badge_w, + 36.0, + ), + [1.0, 1.0, 1.0, 0.6], + [0.0, 0.0, 0.0, 0.08], + theme::CONTROL_RADIUS, + )); ctx.push_text_run(TextRun { - x: identity.x + SECTION_PAD, - y: identity.y + 36.0, + x: content_x + content_w - id_badge_w - 16.0, + y: cy + 24.0, text: self.identity_path.clone(), - font_size: 12.0, - color: theme::TEXT_PRIMARY, + font_size: theme::FONT_LABEL, + color: theme::ACCENT, monospace: true, }); - ctx.push_text_run(TextRun { - x: identity.x + SECTION_PAD, - y: identity.y + 58.0, - text: "Trust status: verified identity key".to_string(), - font_size: 11.0, - color: theme::TEXT_SECONDARY, - monospace: false, - }); + cy += 74.0; + draw_separator(ctx, cy); + cy += ROW_GAP; + + // Device Trust + draw_row( + ctx, + cy, + "Device Trust", + "Validation status of this hardware endpoint.", + ); + let trust_badge = Rect::new(content_x + content_w * 0.35, cy + 12.0, 110.0, 24.0); + ctx.push_glass_quad(theme::status_chip(trust_badge, theme::ChipTone::Success)); ctx.push_text_run(TextRun { - x: streaming.x + SECTION_PAD, - y: streaming.y + 14.0, - text: "Streaming Defaults".to_string(), - font_size: 13.0, - color: theme::TEXT_MUTED, + x: trust_badge.x + 12.0, + y: trust_badge.y + 5.0, + text: "Trusted Device".to_string(), + font_size: theme::FONT_CAPTION, + color: theme::SUCCESS, monospace: false, }); + + cy += 74.0; + draw_separator(ctx, cy); + cy += ROW_GAP; + + // Streaming Defaults + draw_row( + ctx, + cy, + "Streaming Defaults", + "Balance latency and fidelity.", + ); self.default_profile_dropdown.paint(ctx); + cy += 84.0; + draw_separator(ctx, cy); + cy += ROW_GAP; + + // Input + let right_x = content_x + content_w * 0.35; + let right_w = content_w * 0.65 - 32.0; + + draw_row( + ctx, + cy, + "Input", + "Configure how local peripherals interact.", + ); + ctx.push_glass_quad(theme::glass_quad( + Rect::new(right_x, cy, right_w, 56.0), + [1.0, 1.0, 1.0, 0.4], + [1.0, 1.0, 1.0, 0.6], + theme::CONTROL_RADIUS, + )); ctx.push_text_run(TextRun { - x: input.x + SECTION_PAD, - y: input.y + 14.0, - text: "Input Controls".to_string(), - font_size: 13.0, - color: theme::TEXT_MUTED, - monospace: false, - }); - ctx.push_text_run(TextRun { - x: input.x + SECTION_PAD, - y: input.y + 44.0, - text: "Exclusive keyboard capture".to_string(), - font_size: 12.0, + x: right_x + 16.0, + y: cy + 16.0, + text: "Exclusive Keyboard Capture".to_string(), + font_size: theme::FONT_LABEL, color: theme::TEXT_PRIMARY, monospace: false, }); + self.exclusive_keyboard_toggle.paint(ctx); + cy += 64.0; + + ctx.push_glass_quad(theme::glass_quad( + Rect::new(right_x, cy, right_w, 56.0), + [1.0, 1.0, 1.0, 0.4], + [1.0, 1.0, 1.0, 0.6], + theme::CONTROL_RADIUS, + )); ctx.push_text_run(TextRun { - x: input.x + SECTION_PAD, - y: input.y + 76.0, - text: "Relative mouse mode".to_string(), - font_size: 12.0, + x: right_x + 16.0, + y: cy + 16.0, + text: "Relative Mouse Movement".to_string(), + font_size: theme::FONT_LABEL, color: theme::TEXT_PRIMARY, monospace: false, }); - self.exclusive_keyboard_toggle.paint(ctx); self.relative_mouse_toggle.paint(ctx); + cy += 84.0; + draw_separator(ctx, cy); + cy += ROW_GAP; + + // Audio + draw_row( + ctx, + cy, + "Audio", + "Route sound between local and remote boundaries.", + ); + let audio_label_color = theme::TEXT_MUTED; ctx.push_text_run(TextRun { - x: audio.x + SECTION_PAD, - y: audio.y + 14.0, - text: "Audio Paths".to_string(), - font_size: 13.0, - color: theme::TEXT_MUTED, + x: right_x, + y: cy + 6.0, + text: "REMOTE OUTPUT".to_string(), + font_size: 10.0, + color: audio_label_color, monospace: false, }); self.audio_output_dropdown.paint(ctx); - self.mic_dropdown.paint(ctx); + cy += 76.0; ctx.push_text_run(TextRun { - x: about.x + SECTION_PAD, - y: about.y + 14.0, - text: "About".to_string(), - font_size: 13.0, - color: theme::TEXT_MUTED, + x: right_x, + y: cy + 6.0, + text: "LOCAL MIC PATH".to_string(), + font_size: 10.0, + color: audio_label_color, monospace: false, }); + self.mic_dropdown.paint(ctx); + + // Versioning watermark at the bottom + let watermark_y = card_y + card_h + 30.0; ctx.push_text_run(TextRun { - x: about.x + SECTION_PAD, - y: about.y + 38.0, - text: format!("PRISM Client {}", self.version), - font_size: 12.0, - color: theme::TEXT_PRIMARY, + x: content_x + (content_w - 200.0) / 2.0, + y: watermark_y, + text: format!("PRISM Professional Edition • {}", self.version), + font_size: 10.0, + color: [ + theme::TEXT_PRIMARY[0], + theme::TEXT_PRIMARY[1], + theme::TEXT_PRIMARY[2], + 0.3, + ], monospace: false, }); } fn handle_event(&mut self, event: &UiEvent) -> EventResponse { + // Handle scroll behavior at container level + if let UiEvent::Scroll { dy, .. } = event { + self.scroll_y = (self.scroll_y - dy).clamp(0.0, self.max_scroll); + return EventResponse::Consumed; + } + let old_profile = self.default_profile_dropdown.selected_index(); let profile_resp = self.default_profile_dropdown.handle_event(event); if self.default_profile_dropdown.selected_index() != old_profile { @@ -344,7 +478,7 @@ mod tests { use super::*; #[test] - fn settings_panel_paints_sections() { + fn settings_panel_paints_single_card() { let mut panel = SettingsPanel::new( "/home/user/.prism/client_identity.json".to_string(), "0.1.0".to_string(), @@ -354,22 +488,19 @@ mod tests { let mut ctx = PaintContext::new(); panel.paint(&mut ctx); - assert!(ctx.glass_quads.len() >= 9); - assert!(ctx.text_runs.len() >= 12); + // Core UI elements verify single card surface + internal dividers and backgrounds + assert!(ctx.glass_quads.len() >= 4); } #[test] - fn toggles_handle_clicks() { + fn scrolling_updates_offset() { let mut panel = SettingsPanel::new("id".to_string(), "0.1.0".to_string()); - panel.layout(Rect::new(0.0, 0.0, 900.0, 720.0)); + panel.layout(Rect::new(0.0, 0.0, 900.0, 300.0)); // Small height to induce max_scroll bounds - let (.., input, _, _) = panel.section_rects(); - let resp = panel.handle_event(&UiEvent::MouseDown { - x: input.x + input.w - 30.0, - y: input.y + 50.0, - button: crate::ui::widgets::MouseButton::Left, - }); + // Dispatch a scroll event + let resp = panel.handle_event(&UiEvent::Scroll { dx: 0.0, dy: -20.0 }); assert!(matches!(resp, EventResponse::Consumed)); + assert!(panel.scroll_y > 0.0); } } diff --git a/crates/prism-client/src/ui/launcher/shell.rs b/crates/prism-client/src/ui/launcher/shell.rs index 5be72de..a6ade48 100644 --- a/crates/prism-client/src/ui/launcher/shell.rs +++ b/crates/prism-client/src/ui/launcher/shell.rs @@ -4,7 +4,7 @@ use super::{ActiveModal, FormMode, LauncherTab}; use crate::config::servers::SavedServer; use crate::ui::UiState; -use crate::ui::launcher::card_grid::CardGrid; +use crate::ui::launcher::card_grid::{CardGrid, GridMode}; use crate::ui::launcher::nav::LauncherNav; use crate::ui::launcher::profiles::ProfilesPanel; use crate::ui::launcher::quick_connect::QuickConnect; @@ -32,6 +32,7 @@ pub struct LauncherShell { screen_rect: Rect, sidebar_rect: Rect, content_rect: Rect, + home_recent_y: f32, ui_state: UiState, } @@ -56,6 +57,7 @@ impl LauncherShell { screen_rect: Rect::new(0.0, 0.0, 0.0, 0.0), sidebar_rect: Rect::new(0.0, 0.0, 0.0, 0.0), content_rect: Rect::new(0.0, 0.0, 0.0, 0.0), + home_recent_y: 0.0, ui_state: UiState::Launcher, }; shell.configure_widgets(); @@ -142,11 +144,13 @@ impl LauncherShell { fn configure_widgets(&mut self) { match self.active_tab { LauncherTab::Home => { + self.card_grid.set_layout_mode(GridMode::Rows); self.card_grid.set_visible_limit(Some(3)); self.card_grid.set_show_add_card(false); self.card_grid.set_show_filters(false); } LauncherTab::SavedConnections => { + self.card_grid.set_layout_mode(GridMode::Grid); self.card_grid.set_visible_limit(None); self.card_grid.set_show_add_card(true); self.card_grid.set_show_filters(true); @@ -190,15 +194,18 @@ impl LauncherShell { match self.active_tab { LauncherTab::Home => { let quick_y = self.content_rect.y + HEADER_OFFSET; - let section_y = quick_y + 132.0; - let card_y = section_y + 34.0; - self.quick_connect.layout(Rect::new( + let quick_size = self.quick_connect.layout(Rect::new( self.content_rect.x, quick_y, self.content_rect.w, - 94.0, + 300.0, )); + + let section_y = quick_y + quick_size.h + 38.0; + self.home_recent_y = section_y; + let card_y = section_y + 34.0; + self.card_grid.layout(Rect::new( self.content_rect.x, card_y, @@ -262,14 +269,14 @@ impl LauncherShell { fn paint_active_tab(&self, ctx: &mut PaintContext) { match self.active_tab { LauncherTab::Home => { - let section_y = self.content_rect.y + HEADER_OFFSET + 132.0; + let section_y = self.home_recent_y; self.quick_connect.paint(ctx); ctx.push_text_run(TextRun { x: self.content_rect.x, y: section_y, - text: "Recent connections".to_string(), - font_size: 12.0, - color: theme::TEXT_MUTED, + text: "Recent Connections".to_string(), + font_size: 13.0, + color: theme::TEXT_SECONDARY, monospace: false, }); ctx.push_glass_quad(theme::separator(Rect::new( diff --git a/crates/prism-client/src/ui/overlay/capsule.rs b/crates/prism-client/src/ui/overlay/capsule.rs index fc2e7a6..87f9273 100644 --- a/crates/prism-client/src/ui/overlay/capsule.rs +++ b/crates/prism-client/src/ui/overlay/capsule.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -//! Top capsule overlay with single-panel expansion. +//! Top capsule overlay with single-panel expansion and floating bottom bar accessories. use super::conn_panel::ConnPanel; use super::display_panel::DisplayPanel; use super::perf_panel::PerfPanel; use super::quality_panel::QualityPanel; use super::stats_bar::{SessionStats, StatsBar}; +use crate::ui::theme; use crate::ui::widgets::{ - EventResponse, MouseButton, PaintContext, Rect, Size, UiAction, UiEvent, Widget, + EventResponse, MouseButton, PaintContext, Rect, Size, TextRun, UiAction, UiEvent, Widget, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +27,8 @@ pub struct OverlayCapsule { display_panel: DisplayPanel, rect: Rect, capsule_rect: Rect, + bottom_bar_rect: Rect, + disconnect_rect: Rect, panel_rect: Option, visible: bool, active_panel: Option, @@ -41,6 +44,8 @@ impl OverlayCapsule { display_panel: DisplayPanel::new(), rect: Rect::new(0.0, 0.0, 0.0, 0.0), capsule_rect: Rect::new(0.0, 0.0, 0.0, 0.0), + bottom_bar_rect: Rect::new(0.0, 0.0, 0.0, 0.0), + disconnect_rect: Rect::new(0.0, 0.0, 0.0, 0.0), panel_rect: None, visible: false, active_panel: None, @@ -103,14 +108,32 @@ impl Widget for OverlayCapsule { return Size { w: 0.0, h: 0.0 }; } - let capsule_w = (available.w - 40.0).clamp(320.0, 660.0); + let capsule_w = (available.w - 40.0).clamp(520.0, 780.0); let capsule_x = available.x + (available.w - capsule_w) * 0.5; - self.capsule_rect = Rect::new(capsule_x, available.y + 18.0, capsule_w, 48.0); + self.capsule_rect = Rect::new(capsule_x, available.y + 18.0, capsule_w, 56.0); self.stats_bar.layout(self.capsule_rect); + // Bottom Info Bar + let bot_w = 420.0; + self.bottom_bar_rect = Rect::new( + available.x + (available.w - bot_w) * 0.5, + available.y + available.h - 60.0, + bot_w, + 40.0, + ); + + // Disconnect Circle Bottom Right + self.disconnect_rect = Rect::new( + available.x + available.w - 80.0, + available.y + available.h - 80.0, + 56.0, + 56.0, + ); + self.panel_rect = None; if let Some(panel) = self.active_panel { - let panel_w = 260.0; + // Dropdown panel dimensions + let panel_w = 320.0; let panel_x = capsule_x + (capsule_w - panel_w) * 0.5; let panel_y = self.capsule_rect.y + self.capsule_rect.h + 18.0; let panel_rect = Rect::new(panel_x, panel_y, panel_w, 320.0); @@ -150,6 +173,84 @@ impl Widget for OverlayCapsule { Some(CapsulePanel::Display) => self.display_panel.paint(ctx), None => {} } + + // Paint Bottom Technical Specs Bar + ctx.push_glass_quad(theme::glass_quad( + self.bottom_bar_rect, + [1.0, 1.0, 1.0, 0.40], + [1.0, 1.0, 1.0, 0.50], + 20.0, + )); + + let mut bl_x = self.bottom_bar_rect.x + 30.0; + let bl_y = self.bottom_bar_rect.y + 24.0; + let txt_c = [0.0, 0.0, 0.0, 0.8]; + + ctx.push_text_run(TextRun { + x: bl_x, + y: bl_y, + text: "DECODE".into(), + font_size: 10.0, + color: txt_c, + monospace: false, + }); + bl_x += 46.0; + ctx.push_text_run(TextRun { + x: bl_x, + y: bl_y, + text: format!("{:.1}ms", self.stats_bar.stats.decode_time_ms), + font_size: 11.0, + color: theme::ACCENT, + monospace: true, + }); + bl_x += 50.0; + + ctx.push_text_run(TextRun { + x: bl_x, + y: bl_y, + text: "RES".into(), + font_size: 10.0, + color: txt_c, + monospace: false, + }); + bl_x += 24.0; + ctx.push_text_run(TextRun { + x: bl_x, + y: bl_y, + text: format!( + "{}x{}", + self.stats_bar.stats.resolution.0, self.stats_bar.stats.resolution.1 + ), + font_size: 11.0, + color: theme::ACCENT, + monospace: true, + }); + bl_x += 80.0; + + ctx.push_text_run(TextRun { + x: bl_x, + y: bl_y, + text: "ACTIVE SESSION".into(), + font_size: 10.0, + color: theme::SUCCESS, + monospace: false, + }); + + // Paint Disconnect Button (Bottom Corner) + ctx.push_glass_quad(theme::glass_quad( + self.disconnect_rect, + [theme::DANGER[0], theme::DANGER[1], theme::DANGER[2], 0.9], + [theme::DANGER[0], theme::DANGER[1], theme::DANGER[2], 1.0], + 28.0, // Fully rounded + )); + ctx.push_text_run(TextRun { + x: self.disconnect_rect.x + 6.0, + y: self.disconnect_rect.y + 34.0, + text: "DISCONNECT".into(), + font_size: 8.0, + color: [1.0, 1.0, 1.0, 1.0], + monospace: false, + }); } fn handle_event(&mut self, event: &UiEvent) -> EventResponse { @@ -181,12 +282,27 @@ impl Widget for OverlayCapsule { y, button: MouseButton::Left, } = event - && let Some(panel_rect) = self.panel_rect - && !panel_rect.contains(*x, *y) - && !self.capsule_rect.contains(*x, *y) { - self.set_active_panel(None); - return EventResponse::Consumed; + if self.disconnect_rect.contains(*x, *y) { + return EventResponse::Action(UiAction::Disconnect); + } + if let Some(panel_rect) = self.panel_rect { + if !panel_rect.contains(*x, *y) + && !self.capsule_rect.contains(*x, *y) + && !self.bottom_bar_rect.contains(*x, *y) + { + self.set_active_panel(None); + return EventResponse::Consumed; + } + } else { + // Clicked outside capsule when no panel open = dismiss overlay + if !self.capsule_rect.contains(*x, *y) + && !self.disconnect_rect.contains(*x, *y) + && !self.bottom_bar_rect.contains(*x, *y) + { + return EventResponse::Action(UiAction::CloseOverlay); + } + } } EventResponse::Ignored diff --git a/crates/prism-client/src/ui/overlay/stats_bar.rs b/crates/prism-client/src/ui/overlay/stats_bar.rs index a729617..c42d40b 100644 --- a/crates/prism-client/src/ui/overlay/stats_bar.rs +++ b/crates/prism-client/src/ui/overlay/stats_bar.rs @@ -19,16 +19,6 @@ pub struct SessionStats { pub active_profile: String, } -fn metric_color(value: f32, good_threshold: f32, warn_threshold: f32) -> [f32; 4] { - if value >= good_threshold { - theme::SUCCESS - } else if value >= warn_threshold { - theme::WARNING - } else { - theme::DANGER - } -} - fn latency_color(ms: f32) -> [f32; 4] { if ms < 20.0 { theme::SUCCESS @@ -40,7 +30,7 @@ fn latency_color(ms: f32) -> [f32; 4] { } pub struct StatsBar { - stats: SessionStats, + pub stats: SessionStats, profile_dropdown: Dropdown, pinned: bool, visible: bool, @@ -63,7 +53,7 @@ impl StatsBar { pub fn update_stats(&mut self, stats: SessionStats) { let profile = stats.active_profile.clone(); self.stats = stats; - let options = ["Gaming", "Coding"]; + let options = ["Gaming", "Coding", "Balanced", "Low Bandwidth"]; if let Some(idx) = options.iter().position(|&p| p == profile.as_str()) { self.profile_dropdown.set_selected(idx); } @@ -91,48 +81,36 @@ impl StatsBar { self.pinned = !self.pinned; } - fn fps_rect(&self) -> Rect { - Rect::new( - self.rect.x + 18.0, - self.rect.y + 6.0, - 92.0, - self.rect.h - 12.0, - ) - } - - fn codec_rect(&self) -> Rect { + fn perf_btn_rect(&self) -> Rect { Rect::new( - self.rect.x + 252.0, - self.rect.y + 6.0, - 220.0, - self.rect.h - 12.0, + self.rect.x + self.rect.w - 220.0, + self.rect.y + 12.0, + 40.0, + 24.0, ) } - - fn pin_rect(&self) -> Rect { + fn qual_btn_rect(&self) -> Rect { Rect::new( - self.rect.x + self.rect.w - 124.0, - self.rect.y + 8.0, - 54.0, - self.rect.h - 16.0, + self.rect.x + self.rect.w - 170.0, + self.rect.y + 12.0, + 40.0, + 24.0, ) } - - fn close_rect(&self) -> Rect { + fn conn_btn_rect(&self) -> Rect { Rect::new( - self.rect.x + self.rect.w - 62.0, - self.rect.y + 8.0, - 44.0, - self.rect.h - 16.0, + self.rect.x + self.rect.w - 120.0, + self.rect.y + 12.0, + 40.0, + 24.0, ) } - - fn dropdown_rect(&self) -> Rect { + fn disp_btn_rect(&self) -> Rect { Rect::new( - self.rect.x + self.rect.w - 270.0, - self.rect.y + 4.0, - 132.0, - self.rect.h - 8.0, + self.rect.x + self.rect.w - 70.0, + self.rect.y + 12.0, + 40.0, + 24.0, ) } } @@ -149,9 +127,13 @@ impl Widget for StatsBar { return Size { w: 0.0, h: 0.0 }; } - let h = available.h.max(48.0); + // Stitch capsule is a thinner, more centered pill + let h = 56.0; self.rect = Rect::new(available.x, available.y, available.w, h); - self.profile_dropdown.layout(self.dropdown_rect()); + + let mx = self.rect.x + 130.0 + 45.0 + 65.0 + 65.0 + 60.0; + self.profile_dropdown + .layout(Rect::new(mx, self.rect.y + 16.0, 100.0, 24.0)); Size { w: available.w, h } } @@ -162,165 +144,125 @@ impl Widget for StatsBar { return; } + // Stitch glass pill ctx.push_glass_quad(theme::glass_quad( self.rect, - [0.12, 0.16, 0.22, 0.80 * alpha], - if self.pinned { - [ - theme::ACCENT[0], - theme::ACCENT[1], - theme::ACCENT[2], - 0.20 * alpha, - ] - } else { - [1.0, 1.0, 1.0, 0.16 * alpha] - }, - theme::PANEL_RADIUS, + [1.0, 1.0, 1.0, 0.45 * alpha], + [1.0, 1.0, 1.0, 0.50 * alpha], + 28.0, // Fully rounded pill )); - let y_text = self.rect.y + 16.0; - let mut x_cursor = self.rect.x + 18.0; - - let fps_color = metric_color(self.stats.fps, 30.0, 15.0); - let fps_text = format!("{:.0} FPS", self.stats.fps); + // 1. Brand ctx.push_text_run(TextRun { - x: x_cursor, - y: y_text, - text: fps_text, - font_size: 13.0, - color: [fps_color[0], fps_color[1], fps_color[2], alpha], - monospace: true, + x: self.rect.x + 20.0, + y: self.rect.y + 20.0, + text: "PRISM REMOTE".into(), + font_size: 11.0, + color: theme::ACCENT, + monospace: false, }); - x_cursor += 92.0; ctx.push_glass_quad(theme::separator(Rect::new( - x_cursor, - self.rect.y + 10.0, + self.rect.x + 115.0, + self.rect.y + 16.0, 1.0, - self.rect.h - 20.0, + 24.0, ))); - x_cursor += 14.0; - - let latency = latency_color(self.stats.latency_ms); - let latency_text = format!("{:.1} ms", self.stats.latency_ms); - ctx.push_text_run(TextRun { - x: x_cursor, - y: y_text, - text: latency_text, - font_size: 13.0, - color: [latency[0], latency[1], latency[2], alpha], - monospace: true, - }); - x_cursor += 104.0; - ctx.push_glass_quad(theme::separator(Rect::new( - x_cursor, - self.rect.y + 10.0, - 1.0, - self.rect.h - 20.0, - ))); - x_cursor += 14.0; + // 2. Metrics Block + let mut mx = self.rect.x + 130.0; + let metric_label_y = self.rect.y + 8.0; + let metric_val_y = self.rect.y + 24.0; + let metric_label_color = [0.0, 0.0, 0.0, 0.6 * alpha]; + let metric_val_color = [0.0, 0.0, 0.0, 0.9 * alpha]; + + let draw_metric = + |ctx: &mut PaintContext, x: f32, label: &str, val: &str, val_c: [f32; 4]| { + ctx.push_text_run(TextRun { + x, + y: metric_label_y, + text: label.into(), + font_size: 9.0, + color: metric_label_color, + monospace: false, + }); + ctx.push_text_run(TextRun { + x, + y: metric_val_y, + text: val.into(), + font_size: 12.0, + color: val_c, + monospace: true, + }); + }; + + draw_metric( + ctx, + mx, + "FPS", + &format!("{:.0}", self.stats.fps), + metric_val_color, + ); + mx += 45.0; + + let lat_c = latency_color(self.stats.latency_ms); + draw_metric( + ctx, + mx, + "LATENCY", + &format!("{:.0}ms", self.stats.latency_ms), + [lat_c[0], lat_c[1], lat_c[2], alpha], + ); + mx += 65.0; - let codec = if self.stats.codec.is_empty() { - "Codec --".to_owned() - } else { - format!("Codec {}", self.stats.codec) - }; - ctx.push_text_run(TextRun { - x: x_cursor, - y: y_text, - text: codec, - font_size: 13.0, - color: theme::TEXT_PRIMARY, - monospace: false, - }); - x_cursor += 108.0; + let mbps = self.stats.bandwidth_bps as f32 / 1_000_000.0; + draw_metric( + ctx, + mx, + "BITRATE", + &format!("{:.1}M", mbps), + metric_val_color, + ); + mx += 65.0; + + draw_metric( + ctx, + mx, + "CODEC", + if self.stats.codec.is_empty() { + "---" + } else { + &self.stats.codec + }, + metric_val_color, + ); - let resolution = format!("{}x{}", self.stats.resolution.0, self.stats.resolution.1); - ctx.push_text_run(TextRun { - x: x_cursor, - y: y_text, - text: resolution, - font_size: 13.0, - color: theme::TEXT_SECONDARY, - monospace: false, - }); - x_cursor += 98.0; + // Profile Dropdown (Restores native interaction and SwitchProfile event) + self.profile_dropdown.paint(ctx); + // 3. Navigation Buttons (Right Aligned) ctx.push_glass_quad(theme::separator(Rect::new( - x_cursor, - self.rect.y + 10.0, + self.perf_btn_rect().x - 16.0, + self.rect.y + 16.0, 1.0, - self.rect.h - 20.0, + 24.0, ))); - x_cursor += 14.0; - - let mbps = self.stats.bandwidth_bps as f32 / 1_000_000.0; - ctx.push_text_run(TextRun { - x: x_cursor, - y: y_text, - text: format!("{mbps:.1} Mbps"), - font_size: 13.0, - color: theme::TEXT_SECONDARY, - monospace: true, - }); - - self.profile_dropdown.paint(ctx); - let pin_rect = self.pin_rect(); - ctx.push_glass_quad(theme::glass_quad( - pin_rect, - if self.pinned { - [ - theme::ACCENT[0], - theme::ACCENT[1], - theme::ACCENT[2], - 0.14 * alpha, - ] - } else { - [1.0, 1.0, 1.0, 0.05 * alpha] - }, - if self.pinned { - [ - theme::ACCENT[0], - theme::ACCENT[1], - theme::ACCENT[2], - 0.22 * alpha, - ] - } else { - [1.0, 1.0, 1.0, 0.08 * alpha] - }, - theme::CHIP_RADIUS, - )); - let pin_label = if self.pinned { "Pinned" } else { "Pin" }; - ctx.push_text_run(TextRun { - x: pin_rect.x + (pin_rect.w - theme::text_width(pin_label, 12.0)) * 0.5, - y: pin_rect.y + 6.0, - text: pin_label.into(), - font_size: 12.0, - color: if self.pinned { - theme::accent(alpha) - } else { - theme::TEXT_SECONDARY - }, - monospace: false, - }); + let draw_nav_btn = |ctx: &mut PaintContext, r: Rect, label: &str| { + ctx.push_text_run(TextRun { + x: r.x + 6.0, + y: r.y + 6.0, + text: label.into(), + font_size: 11.0, + color: metric_label_color, + monospace: false, + }); + }; - let close_rect = self.close_rect(); - ctx.push_glass_quad(theme::glass_quad( - close_rect, - [1.0, 1.0, 1.0, 0.05 * alpha], - [1.0, 1.0, 1.0, 0.08 * alpha], - theme::CHIP_RADIUS, - )); - ctx.push_text_run(TextRun { - x: close_rect.x + (close_rect.w - theme::text_width("Done", 12.0)) * 0.5, - y: close_rect.y + 6.0, - text: "Done".into(), - font_size: 12.0, - color: theme::TEXT_SECONDARY, - monospace: false, - }); + draw_nav_btn(ctx, self.perf_btn_rect(), "PERF"); + draw_nav_btn(ctx, self.qual_btn_rect(), "QUAL"); + draw_nav_btn(ctx, self.conn_btn_rect(), "CONN"); + draw_nav_btn(ctx, self.disp_btn_rect(), "DISP"); } fn handle_event(&mut self, event: &UiEvent) -> EventResponse { @@ -340,19 +282,18 @@ impl Widget for StatsBar { y, button: MouseButton::Left, } => { - if self.close_rect().contains(*x, *y) { - return EventResponse::Action(UiAction::CloseOverlay); - } - if self.pin_rect().contains(*x, *y) { - self.toggle_pin(); - return EventResponse::Consumed; - } - if self.fps_rect().contains(*x, *y) { + if self.perf_btn_rect().contains(*x, *y) { return EventResponse::Action(UiAction::OpenPanel("performance".into())); } - if self.codec_rect().contains(*x, *y) { + if self.qual_btn_rect().contains(*x, *y) { return EventResponse::Action(UiAction::OpenPanel("quality".into())); } + if self.conn_btn_rect().contains(*x, *y) { + return EventResponse::Action(UiAction::OpenPanel("connection".into())); + } + if self.disp_btn_rect().contains(*x, *y) { + return EventResponse::Action(UiAction::OpenPanel("display".into())); + } if self.rect.contains(*x, *y) { return EventResponse::Consumed; } @@ -401,28 +342,55 @@ mod tests { bar.paint(&mut ctx); let texts: Vec<&str> = ctx.text_runs.iter().map(|t| t.text.as_str()).collect(); - let has_fps = texts.iter().any(|t| t.contains("60 FPS")); - let has_latency = texts.iter().any(|t| t.contains("12.5 ms")); - assert!(has_fps, "expected FPS metric in text runs, got: {texts:?}"); assert!( - has_latency, - "expected latency metric in text runs, got: {texts:?}" + texts.iter().any(|t| t.contains("60")), + "expected FPS metric" + ); + assert!( + texts.iter().any(|t| t.contains("12")), + "expected latency metric" ); } #[test] - fn stats_bar_profile_switch() { + fn stats_bar_navigation_click() { let mut bar = make_visible_bar(sample_stats()); - bar.layout(Rect::new(0.0, 0.0, 960.0, 48.0)); + bar.layout(Rect::new(0.0, 0.0, 960.0, 56.0)); - let dd_rect = bar.dropdown_rect(); + let perf_rect = bar.perf_btn_rect(); + let resp = bar.handle_event(&UiEvent::MouseDown { + x: perf_rect.x + 5.0, + y: perf_rect.y + 5.0, + button: MouseButton::Left, + }); + + match &resp { + EventResponse::Action(UiAction::OpenPanel(p)) => { + assert_eq!(p, "performance"); + } + other => panic!("expected OpenPanel action, got {other:?}"), + } + } + + #[test] + fn stats_bar_profile_dropdown_emits_switch() { + let mut bar = make_visible_bar(sample_stats()); + bar.layout(Rect::new(0.0, 0.0, 960.0, 56.0)); + + let mx = bar.rect.x + 130.0 + 45.0 + 65.0 + 65.0 + 60.0; + let dd_rect = Rect::new(mx, bar.rect.y + 16.0, 100.0, 24.0); + + // Click dropdown open bar.handle_event(&UiEvent::MouseDown { x: dd_rect.x + 10.0, y: dd_rect.y + 10.0, button: MouseButton::Left, }); - let item_y = dd_rect.y + dd_rect.h + 28.0 + 14.0; + // Dropdown layout hardcodes h=40.0, so options start at rect.y + 40.0. + // Index 1 (Coding) starts at rect.y + 40.0 + 28.0. + // Center of Index 1 is rect.y + 40.0 + 28.0 + 14.0. + let item_y = dd_rect.y + 40.0 + 28.0 + 14.0; let resp = bar.handle_event(&UiEvent::MouseDown { x: dd_rect.x + 10.0, y: item_y, @@ -431,25 +399,9 @@ mod tests { match &resp { EventResponse::Action(UiAction::SwitchProfile(p)) => { - assert_eq!(p, "Coding", "expected Coding profile, got {p}"); + assert_eq!(p, "Coding", "expected SwitchProfile"); } other => panic!("expected SwitchProfile action, got {other:?}"), } } - - #[test] - fn stats_bar_hidden_no_paint() { - let mut bar = StatsBar::new(); - bar.layout(Rect::new(0.0, 0.0, 960.0, 48.0)); - let mut ctx = PaintContext::new(); - bar.paint(&mut ctx); - assert!( - ctx.text_runs.is_empty(), - "hidden bar should emit no text runs" - ); - assert!( - ctx.glass_quads.is_empty(), - "hidden bar should emit no glass quads" - ); - } } diff --git a/crates/prism-client/src/ui/theme.rs b/crates/prism-client/src/ui/theme.rs index 1f5d8d0..da0bb0b 100644 --- a/crates/prism-client/src/ui/theme.rs +++ b/crates/prism-client/src/ui/theme.rs @@ -23,6 +23,7 @@ pub const CARD_RADIUS: f32 = 20.0; pub const CONTROL_RADIUS: f32 = 14.0; pub const CHIP_RADIUS: f32 = 12.0; pub const SIDEBAR_RADIUS: f32 = 28.0; +pub const FONT_HERO: f32 = 36.0; pub const FONT_DISPLAY: f32 = 30.0; pub const FONT_HEADLINE: f32 = 20.0; pub const FONT_BODY: f32 = 14.0; @@ -127,10 +128,24 @@ pub fn separator(rect: Rect) -> GlassQuad { glass_quad(rect, [1.0, 1.0, 1.0, 0.08], [0.0, 0.0, 0.0, 0.0], 0.0) } +pub fn list_row_surface(rect: Rect, hovered: bool) -> GlassQuad { + glass_quad( + rect, + if hovered { + [0.16, 0.20, 0.27, 0.82] + } else { + [0.14, 0.18, 0.24, 0.76] + }, + [1.0, 1.0, 1.0, 0.10], + CONTROL_RADIUS, + ) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChipTone { Success, Warning, + Danger, Accent, Neutral, } @@ -145,6 +160,10 @@ pub fn status_chip(rect: Rect, tone: ChipTone) -> GlassQuad { [WARNING[0], WARNING[1], WARNING[2], 0.14], [WARNING[0], WARNING[1], WARNING[2], 0.22], ), + ChipTone::Danger => ( + [DANGER[0], DANGER[1], DANGER[2], 0.14], + [DANGER[0], DANGER[1], DANGER[2], 0.22], + ), ChipTone::Accent => ( [ACCENT[0], ACCENT[1], ACCENT[2], 0.12], [ACCENT[0], ACCENT[1], ACCENT[2], 0.18], diff --git a/crates/prism-client/src/ui/widgets/mod.rs b/crates/prism-client/src/ui/widgets/mod.rs index 45dbc25..04a870e 100644 --- a/crates/prism-client/src/ui/widgets/mod.rs +++ b/crates/prism-client/src/ui/widgets/mod.rs @@ -6,6 +6,7 @@ pub mod checkbox; pub mod dropdown; pub mod label; pub mod monitor_map; +pub mod segmented; pub mod separator; pub mod slider; pub mod sparkline; diff --git a/crates/prism-client/src/ui/widgets/segmented.rs b/crates/prism-client/src/ui/widgets/segmented.rs new file mode 100644 index 0000000..dd7679e --- /dev/null +++ b/crates/prism-client/src/ui/widgets/segmented.rs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Segmented control widget for mutually exclusive linear options. + +use super::{EventResponse, MouseButton, PaintContext, Rect, Size, TextRun, UiEvent, Widget}; +use crate::ui::theme; + +pub struct SegmentedControl { + options: Vec, + selected_index: usize, + hovered_index: Option, + rect: Rect, +} + +impl SegmentedControl { + pub fn new(options: Vec, selected_index: usize) -> Self { + Self { + options, + selected_index, + hovered_index: None, + rect: Rect::new(0.0, 0.0, 0.0, 0.0), + } + } + + pub fn selected_index(&self) -> usize { + self.selected_index + } + + pub fn set_selected(&mut self, index: usize) { + self.selected_index = index; + } + + fn index_rect(&self, idx: usize) -> Rect { + let w = self.rect.w / self.options.len() as f32; + Rect::new(self.rect.x + (idx as f32) * w, self.rect.y, w, self.rect.h) + } +} + +impl Widget for SegmentedControl { + fn layout(&mut self, available: Rect) -> Size { + self.rect = available; + Size { + w: available.w, + h: available.h, + } + } + + fn paint(&self, ctx: &mut PaintContext) { + // Base track + ctx.push_glass_quad(theme::glass_quad( + self.rect, + [1.0, 1.0, 1.0, 0.04], + [1.0, 1.0, 1.0, 0.1], + theme::CARD_RADIUS, // Assuming CARD_RADIUS makes a nice pill or rounded rect + )); + + for (idx, label) in self.options.iter().enumerate() { + let r = self.index_rect(idx); + let selected = idx == self.selected_index; + let hovered = Some(idx) == self.hovered_index; + + if selected { + ctx.push_glass_quad(theme::glass_quad( + r, + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 0.9], + [theme::ACCENT[0], theme::ACCENT[1], theme::ACCENT[2], 1.0], + theme::CARD_RADIUS, + )); + } else if hovered { + ctx.push_glass_quad(theme::glass_quad( + r, + [1.0, 1.0, 1.0, 0.08], + [1.0, 1.0, 1.0, 0.0], + theme::CARD_RADIUS, + )); + } + + let text_color = if selected { + [1.0, 1.0, 1.0, 1.0] + } else { + theme::TEXT_SECONDARY + }; + + let tw = theme::text_width(label, 12.0); + ctx.push_text_run(TextRun { + x: r.x + (r.w - tw) * 0.5, + y: r.y + (r.h - 12.0) * 0.5 + 1.0, + text: label.clone(), + font_size: 12.0, + color: text_color, + monospace: false, + }); + } + } + + fn handle_event(&mut self, event: &UiEvent) -> EventResponse { + match event { + UiEvent::MouseMove { x, y } => { + if !self.rect.contains(*x, *y) { + self.hovered_index = None; + return EventResponse::Ignored; + } + for idx in 0..self.options.len() { + if self.index_rect(idx).contains(*x, *y) { + self.hovered_index = Some(idx); + return EventResponse::Ignored; + } + } + self.hovered_index = None; + EventResponse::Ignored + } + UiEvent::MouseDown { + x, + y, + button: MouseButton::Left, + } => { + if !self.rect.contains(*x, *y) { + return EventResponse::Ignored; + } + for idx in 0..self.options.len() { + if self.index_rect(idx).contains(*x, *y) { + if self.selected_index != idx { + self.selected_index = idx; + return EventResponse::Consumed; + } else { + return EventResponse::Ignored; + } + } + } + EventResponse::Ignored + } + _ => EventResponse::Ignored, + } + } + + fn animate(&mut self, _dt_ms: f32) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn click_changes_selection() { + let mut seg = SegmentedControl::new(vec!["A".into(), "B".into()], 0); + seg.layout(Rect::new(0.0, 0.0, 100.0, 30.0)); + + let resp = seg.handle_event(&UiEvent::MouseDown { + x: 75.0, + y: 15.0, + button: MouseButton::Left, + }); + + assert!(matches!(resp, EventResponse::Consumed)); + assert_eq!(seg.selected_index(), 1); + } +}