fix(home): responsive friends-panel layout (stack vertically when narrow)

The home-screen Connect + Friends cards were a fixed-width row, so below
~860px the 380px Connect card squeezed the Friends card until its node-ID
field, status, and remove button clipped away. Extract the Connect card into a
free `connect_card(state)` fn (mirroring `friends_panel`) and wrap both in
`responsive`: side-by-side row at >=900px, stacked column below. Verified at
560/760/1000/1912px. clippy clean, 256 lib tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 14:07:10 -04:00
co-authored by Claude Opus 4.8
parent 173585f1a0
commit ddc78f190e
+130 -75
View File
@@ -9,7 +9,7 @@ use crate::theme::{AppTheme, Palette};
use iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
rich_text, span,
rich_text, span, responsive,
};
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{
@@ -1206,6 +1206,113 @@ fn vertical_space(height: f32) -> iced::widget::Space {
/// the add-by-node-ID form, and the presence posture selector. Self-contained:
/// recomputes the palette + the few style helpers it needs so it doesn't depend on
/// `view`'s locals.
// The home-screen "Connect" card (left panel): create / join a room. Extracted
// into its own free fn (mirroring `friends_panel`) so the responsive home layout
// can rebuild it per layout pass and place it in either a row or a column.
fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let pal = state.config.theme.palette();
let color_crust = pal.crust;
let color_mantle = pal.mantle;
let color_surface = pal.surface;
let color_text = pal.text;
let color_subtext = pal.subtext;
let color_blue = pal.blue;
let color_lavender = pal.lavender;
let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style {
text_color: Some(color_text),
background: Some(Background::Color(bg)),
border: Border {
color: b_color,
width: if b_color == Color::TRANSPARENT { 0.0 } else { 1.0 },
radius: radius.into(),
},
..Default::default()
}
};
let b_style = move |bg: Color, hover_bg: Color, text_c: Color, radius: f32| {
move |_theme: &Theme, status: button::Status| {
let active_bg = match status {
button::Status::Hovered => hover_bg,
_ => bg,
};
button::Style {
background: Some(Background::Color(active_bg)),
text_color: text_c,
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: radius.into() },
..Default::default()
}
}
};
let t_style = move |_theme: &Theme, _status: text_input::Status| text_input::Style {
background: Background::Color(color_crust),
border: Border { color: color_surface, width: 1.0, radius: 6.0.into() },
icon: color_subtext,
placeholder: pal.overlay,
value: color_text,
selection: color_blue,
};
let logo = text("PEERSPEAK").size(36).color(color_blue);
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
let nickname_input = column![
text("Nickname").size(14).color(color_subtext),
vertical_space(4.0),
text_input("Enter nickname...", &state.name)
.on_input(AppMessage::NicknameChanged)
.style(t_style)
.padding(10)
];
let create_btn = button(btn_content(IconKind::Create, "Create New Room", color_crust))
.on_press(AppMessage::CreatePressed)
.style(b_style(color_blue, color_lavender, color_crust, 8.0))
.padding(12)
.width(iced::Length::Fill);
let join_group = column![
text("Join Existing Room").size(14).color(color_subtext),
vertical_space(4.0),
text_input("Paste room ticket here...", &state.ticket_input)
.on_input(AppMessage::TicketInputChanged)
.style(t_style)
.padding(10),
vertical_space(8.0),
button(text("Join Room").size(16).align_x(iced::alignment::Horizontal::Center))
.on_press(AppMessage::JoinPressed)
.style(b_style(color_surface, color_blue, color_text, 8.0))
.padding(12)
.width(iced::Length::Fill)
];
let status = text(&state.status_message).size(14).color(color_subtext);
container(
column![
logo,
subtitle,
vertical_space(20.0),
nickname_input,
vertical_space(16.0),
create_btn,
vertical_space(16.0),
text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center),
vertical_space(16.0),
join_group,
vertical_space(10.0),
status
]
.spacing(10)
.align_x(iced::alignment::Horizontal::Center),
)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(30)
.width(380)
.into()
}
fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
let pal = state.config.theme.palette();
let color_crust = pal.crust;
@@ -2008,86 +2115,34 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
if state.current_screen == Screen::Home {
// --- HOME SCREEN ---
let logo = text("PEERSPEAK")
.size(36)
.color(color_blue);
let subtitle = text("NAT-traversing full-mesh voice chat")
.size(16)
.color(color_subtext);
let nickname_input = column![
text("Nickname").size(14).color(color_subtext),
vertical_space(4.0),
text_input("Enter nickname...", &state.name)
.on_input(AppMessage::NicknameChanged)
.style(t_style)
.padding(10)
];
let create_btn = button(btn_content(IconKind::Create, "Create New Room", color_crust))
.on_press(AppMessage::CreatePressed)
.style(b_style(color_blue, color_lavender, color_crust, 8.0))
.padding(12)
.width(iced::Length::Fill);
let join_group = column![
text("Join Existing Room").size(14).color(color_subtext),
vertical_space(4.0),
text_input("Paste room ticket here...", &state.ticket_input)
.on_input(AppMessage::TicketInputChanged)
.style(t_style)
.padding(10),
vertical_space(8.0),
button(
text("Join Room")
.size(16)
// 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.
let body = responsive(move |size| {
let cards: Element<AppMessage> = if size.width < 900.0 {
column![connect_card(state), friends_panel(state)]
.spacing(20)
.align_x(iced::alignment::Horizontal::Center)
)
.on_press(AppMessage::JoinPressed)
.style(b_style(color_surface, color_blue, color_text, 8.0))
.padding(12)
.width(iced::Length::Fill)
];
let status = text(&state.status_message)
.size(14)
.color(color_subtext);
let content = container(
column![
logo,
subtitle,
vertical_space(20.0),
nickname_input,
vertical_space(16.0),
create_btn,
vertical_space(16.0),
text("— OR —").size(12).color(color_surface).align_x(iced::alignment::Horizontal::Center),
vertical_space(16.0),
join_group,
vertical_space(10.0),
status
]
.spacing(10)
.align_x(iced::alignment::Horizontal::Center)
)
.style(c_style(color_mantle, color_surface, 12.0))
.padding(30)
.width(380);
// Two side-by-side cards: connect (left) + the live friends list (right),
// centered together. The friends panel was moved here from Settings.
let panels = row![content, friends_panel(state)]
.spacing(20)
.align_y(iced::alignment::Vertical::Top);
let scroll = scrollable(container(panels).center_x(iced::Length::Fill));
.into()
} else {
row![connect_card(state), friends_panel(state)]
.spacing(20)
.align_y(iced::alignment::Vertical::Top)
.into()
};
scrollable(container(cards).center_x(iced::Length::Fill))
.width(iced::Length::Fill)
.into()
});
let home = container(
column![
top_bar,
vertical_space(20.0),
scroll
body
].align_x(iced::alignment::Horizontal::Center)
)
.width(iced::Length::Fill)