diff --git a/src/app/mod.rs b/src/app/mod.rs index 6c6ae09..8ed699a 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -30,6 +30,25 @@ pub enum Screen { Settings, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HomeLayoutMode { + FocusedEmpty, + ThreeColumn, + Stacked, +} + +fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode { + if width < 900.0 { + HomeLayoutMode::Stacked + } else if !has_recents && !has_friends { + HomeLayoutMode::FocusedEmpty + } else if width >= 1280.0 { + HomeLayoutMode::ThreeColumn + } else { + HomeLayoutMode::Stacked + } +} + /// One rendered room-chat line. `mine` distinguishes our own (locally echoed) /// messages from peers' for colouring. #[derive(Debug, Clone)] @@ -1335,7 +1354,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { selection: color_blue, }; - let logo = text("PEERSPEAK").size(36).color(color_blue); + let logo = text("PEERSPEAK").size(38).color(color_blue); let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext); let nickname_input = column![ @@ -1399,8 +1418,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> { .align_x(iced::alignment::Horizontal::Center), ) .style(c_style(color_mantle, color_surface, 12.0)) - .padding(30) - .width(380) + .padding(32) + .width(420) .into() } @@ -1444,50 +1463,51 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { } }; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - let mut rows = column![].spacing(6).width(iced::Length::Fill); - if state.config.recents.is_empty() { - rows = rows.push( - text("No recent rooms yet — they'll appear here after you join one.") - .size(12) - .color(color_subtext), - ); - } - for r in &state.config.recents { - let label = { - let n = crate::sanitize::sanitize_name(&r.name); - if n.is_empty() { "Untitled room".to_string() } else { n } - }; - let when = crate::recents::relative_time(now, r.joined_at); - let entry = button( - row![ - text(label).size(14).color(color_text), - horizontal_space(), - text(when).size(11).color(color_subtext), - ] - .align_y(iced::alignment::Vertical::Center), - ) - .on_press(AppMessage::JoinRecent(r.ticket.clone())) - .style(b_style(color_crust, color_surface, color_text, 6.0)) - .padding(8) - .width(iced::Length::Fill); - rows = rows.push( - row![ - entry, - button(text("✕").size(12)) - .on_press(AppMessage::RemoveRecent(r.ticket.clone())) - .style(b_style(color_surface, color_maroon, color_text, 6.0)) - .padding(8), - ] - .spacing(6) - .align_y(iced::alignment::Vertical::Center), - ); - } + let empty = state.config.recents.is_empty(); + let content: Element<'_, AppMessage> = if empty { + column![ + text("RECENT ROOMS").size(14).color(color_subtext), + text("No recent rooms yet.").size(12).color(color_subtext), + ] + .spacing(4) + .into() + } else { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let mut rows = column![].spacing(6).width(iced::Length::Fill); + for r in &state.config.recents { + let label = { + let n = crate::sanitize::sanitize_name(&r.name); + if n.is_empty() { "Untitled room".to_string() } else { n } + }; + let when = crate::recents::relative_time(now, r.joined_at); + let entry = button( + row![ + text(label).size(14).color(color_text), + horizontal_space(), + text(when).size(11).color(color_subtext), + ] + .align_y(iced::alignment::Vertical::Center), + ) + .on_press(AppMessage::JoinRecent(r.ticket.clone())) + .style(b_style(color_crust, color_surface, color_text, 6.0)) + .padding(8) + .width(iced::Length::Fill); + rows = rows.push( + row![ + entry, + button(text("✕").size(12)) + .on_press(AppMessage::RemoveRecent(r.ticket.clone())) + .style(b_style(color_surface, color_maroon, color_text, 6.0)) + .padding(8), + ] + .spacing(6) + .align_y(iced::alignment::Vertical::Center), + ); + } - container( column![ text("RECENT ROOMS").size(18).color(color_text), text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.") @@ -1496,11 +1516,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> { vertical_space(10.0), rows, ] - .spacing(6), - ) - .style(c_style(color_mantle, color_surface, 12.0)) - .padding(24) - .width(380) + .spacing(6) + .into() + }; + + container(content) + .style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0)) + .padding(if empty { 16 } else { 24 }) + .width(if empty { 340 } else { 380 }) .into() } @@ -1516,6 +1539,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { let color_red = pal.red; let color_maroon = pal.maroon; let color_green = pal.green; + let has_friends = !state.friends.list().is_empty(); let c_style = move |bg: Color, b_color: Color, radius: f32| { move |_theme: &Theme| container::Style { @@ -1554,9 +1578,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { // The live friends list: status dot, inline rename, short id, remove. let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); - if state.friends.list().is_empty() { + if !has_friends { friend_rows = friend_rows.push( - text("No friends yet — add one by their node ID below.") + text("No friends yet.") .size(12) .color(color_subtext), ); @@ -1659,28 +1683,34 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(4) .width(iced::Length::Fill); + let intro: Element<'_, AppMessage> = if has_friends { + text("Who's online — click Join to hop into a friend's room.") + .size(11) + .color(color_subtext) + .into() + } else { + column![].into() + }; container( column![ - text("FRIENDS").size(18).color(color_text), - text("Who's online — click Join to hop into a friend's room.") - .size(11) - .color(color_subtext), - vertical_space(10.0), + text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text), + intro, + vertical_space(if has_friends { 10.0 } else { 4.0 }), readonly_warning, friend_rows, - vertical_space(12.0), + vertical_space(if has_friends { 12.0 } else { 8.0 }), text("Add a friend").size(13).color(color_subtext), add_form, - vertical_space(14.0), + vertical_space(if has_friends { 14.0 } else { 10.0 }), text("Your presence").size(13).color(color_subtext), presence_picker, ] .spacing(6), ) .style(c_style(color_mantle, color_surface, 12.0)) - .padding(24) - .width(460) + .padding(if has_friends { 24 } else { 18 }) + .width(if has_friends { 460 } else { 360 }) .into() } @@ -1750,8 +1780,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { } }; - let top_bar = row![ - horizontal_space(), + let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home { + iced::widget::Space::new().width(0.0).height(0.0).into() + } else { tooltip( button( Canvas::new(LayoutIcon { fg: color_text }) @@ -1766,7 +1797,13 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { .style(c_style(color_crust, color_surface, 6.0)), iced::widget::tooltip::Position::Bottom, ) - .gap(8), + .gap(8) + .into() + }; + + let top_bar = row![ + horizontal_space(), + layout_button, button( row![ icon(IconKind::Settings, 15.0, color_text), @@ -2306,29 +2343,44 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { if state.current_screen == Screen::Home { // --- HOME SCREEN --- - // Two cards: Connect (left) + the live Friends list (right). They sit - // side-by-side when the window is wide enough, and stack vertically on a - // narrow window so the Friends card never gets crushed — below ~860px the - // fixed-width Connect card would otherwise squeeze it until its node-ID - // field and remove button clip away. `responsive` measures the available - // width each layout pass and picks the orientation accordingly. - // Three cards: Recents | Connect | Friends, side-by-side when there's room. - // Three 380–460px cards need ~1280px to fit in a row, so below that the - // `responsive` measure stacks them in a column (Connect first — the primary - // action) rather than letting the row clip. Recents always shows (empty- - // state hint when no history) for parity with the Friends card. + // Keep Create/Join dominant on a fresh install. Once Recents or Friends + // has real content, the wider three-card layout returns. + let has_recents = !state.config.recents.is_empty(); + let has_friends = !state.friends.list().is_empty(); let body = responsive(move |size| { - let cards: Element = if size.width < 1280.0 { - column![connect_card(state), recents_card(state), friends_panel(state)] - .spacing(20) - .align_x(iced::alignment::Horizontal::Center) - .into() - } else { - row![recents_card(state), connect_card(state), friends_panel(state)] + let cards: Element = + match home_layout_mode(size.width, has_recents, has_friends) { + HomeLayoutMode::FocusedEmpty => row![ + connect_card(state), + column![friends_panel(state), recents_card(state)] + .spacing(16) + .width(iced::Length::Fixed(360.0)), + ] + .spacing(22) + .align_y(iced::alignment::Vertical::Top) + .into(), + HomeLayoutMode::ThreeColumn => row![ + recents_card(state), + connect_card(state), + friends_panel(state), + ] .spacing(20) .align_y(iced::alignment::Vertical::Top) - .into() - }; + .into(), + HomeLayoutMode::Stacked => { + let mut stack = column![connect_card(state)] + .spacing(20) + .align_x(iced::alignment::Horizontal::Center); + if has_recents { + stack = stack.push(recents_card(state)); + } + stack = stack.push(friends_panel(state)); + if !has_recents { + stack = stack.push(recents_card(state)); + } + stack.into() + } + }; scrollable(container(cards).center_x(iced::Length::Fill)) .width(iced::Length::Fill) .into() @@ -4088,6 +4140,16 @@ mod tests { assert_eq!(format_duration(3661), "1:01:01"); assert_eq!(format_duration(3725), "1:02:05"); } + + #[test] + fn home_layout_prioritizes_connect_on_empty_home() { + use super::{home_layout_mode, HomeLayoutMode}; + assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty); + assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked); + assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn); + assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked); + } + use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W}; #[test]