Merge codex-home-empty-state-ui: focused empty-state Home layout

Fresh/empty Home keeps Create/Join dominant via a tested home_layout_mode
seam (FocusedEmpty / ThreeColumn / Stacked); once Recents or Friends has
content the normal three-card layout returns. Quieter empty-state cards.

Conflict resolution:
- HomeLayoutMode enum/fn coexists with the SettingsCategory enum (separate
  derives); both unit tests kept.
- Top bar: the wishlist Hotkeys-info button is always shown; the room-layout
  button is hidden on Home (home-empty's intent) and shown in Room. The
  auto-merge had wedged the info tooltip into the conditional as a stray
  expression — split into separate info_button / layout_button bindings.

291 lib tests pass, clippy --all-targets clean, bin builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 01:36:19 -04:00
co-authored by Claude Opus 4.8
+116 -51
View File
@@ -85,6 +85,25 @@ impl std::fmt::Display for SettingsCategory {
} }
} }
#[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) /// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
/// messages from peers' for colouring. /// messages from peers' for colouring.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -1603,7 +1622,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
selection: color_blue, 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 subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
let nickname_input = column![ let nickname_input = column![
@@ -1667,8 +1686,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
.align_x(iced::alignment::Horizontal::Center), .align_x(iced::alignment::Horizontal::Center),
) )
.style(c_style(color_mantle, color_surface, 12.0)) .style(c_style(color_mantle, color_surface, 12.0))
.padding(30) .padding(32)
.width(380) .width(420)
.into() .into()
} }
@@ -1712,18 +1731,20 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
} }
}; };
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() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let mut rows = column![].spacing(6).width(iced::Length::Fill); 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 { for r in &state.config.recents {
let label = { let label = {
let n = crate::sanitize::sanitize_name(&r.name); let n = crate::sanitize::sanitize_name(&r.name);
@@ -1755,7 +1776,6 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
); );
} }
container(
column![ column![
text("RECENT ROOMS").size(18).color(color_text), 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.") text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
@@ -1764,11 +1784,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(10.0), vertical_space(10.0),
rows, rows,
] ]
.spacing(6), .spacing(6)
) .into()
.style(c_style(color_mantle, color_surface, 12.0)) };
.padding(24)
.width(380) 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() .into()
} }
@@ -1784,6 +1807,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
let color_red = pal.red; let color_red = pal.red;
let color_maroon = pal.maroon; let color_maroon = pal.maroon;
let color_green = pal.green; let color_green = pal.green;
let has_friends = !state.friends.list().is_empty();
let c_style = move |bg: Color, b_color: Color, radius: f32| { let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style { move |_theme: &Theme| container::Style {
@@ -1822,9 +1846,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
// The live friends list: status dot, inline rename, short id, remove. // The live friends list: status dot, inline rename, short id, remove.
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); 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( friend_rows = friend_rows.push(
text("No friends yet — add one by their node ID below.") text("No friends yet.")
.size(12) .size(12)
.color(color_subtext), .color(color_subtext),
); );
@@ -1927,28 +1951,34 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(4) .spacing(4)
.width(iced::Length::Fill); .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( container(
column![ column![
text("FRIENDS").size(18).color(color_text), text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
text("Who's online — click Join to hop into a friend's room.") intro,
.size(11) vertical_space(if has_friends { 10.0 } else { 4.0 }),
.color(color_subtext),
vertical_space(10.0),
readonly_warning, readonly_warning,
friend_rows, 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), text("Add a friend").size(13).color(color_subtext),
add_form, add_form,
vertical_space(14.0), vertical_space(if has_friends { 14.0 } else { 10.0 }),
text("Your presence").size(13).color(color_subtext), text("Your presence").size(13).color(color_subtext),
presence_picker, presence_picker,
] ]
.spacing(6), .spacing(6),
) )
.style(c_style(color_mantle, color_surface, 12.0)) .style(c_style(color_mantle, color_surface, 12.0))
.padding(24) .padding(if has_friends { 24 } else { 18 })
.width(460) .width(if has_friends { 460 } else { 360 })
.into() .into()
} }
@@ -2018,9 +2048,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
} }
}; };
let top_bar = row![ // The Hotkeys info button is always available (hotkeys are app-wide). The
horizontal_space(), // room-layout button is hidden on the Home screen, leaving only it + Settings.
tooltip( let info_button = tooltip(
button(icon(IconKind::Info, 18.0, color_text)) button(icon(IconKind::Info, 18.0, color_text))
.on_press(AppMessage::OpenHotkeyInfo) .on_press(AppMessage::OpenHotkeyInfo)
.style(b_style(color_surface, color_blue, color_text, 6.0)) .style(b_style(color_surface, color_blue, color_text, 6.0))
@@ -2030,7 +2060,11 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(c_style(color_crust, color_surface, 6.0)), .style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
) )
.gap(8), .gap(8);
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
iced::widget::Space::new().width(0.0).height(0.0).into()
} else {
tooltip( tooltip(
button( button(
Canvas::new(LayoutIcon { fg: color_text }) Canvas::new(LayoutIcon { fg: color_text })
@@ -2045,7 +2079,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(c_style(color_crust, color_surface, 6.0)), .style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
) )
.gap(8), .gap(8)
.into()
};
let top_bar = row![
horizontal_space(),
info_button,
layout_button,
button( button(
row![ row![
icon(IconKind::Settings, 15.0, color_text), icon(IconKind::Settings, 15.0, color_text),
@@ -2742,28 +2783,43 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
if state.current_screen == Screen::Home { if state.current_screen == Screen::Home {
// --- HOME SCREEN --- // --- HOME SCREEN ---
// Two cards: Connect (left) + the live Friends list (right). They sit // Keep Create/Join dominant on a fresh install. Once Recents or Friends
// side-by-side when the window is wide enough, and stack vertically on a // has real content, the wider three-card layout returns.
// narrow window so the Friends card never gets crushed — below ~860px the let has_recents = !state.config.recents.is_empty();
// fixed-width Connect card would otherwise squeeze it until its node-ID let has_friends = !state.friends.list().is_empty();
// 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 380460px 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.
let body = responsive(move |size| { let body = responsive(move |size| {
let cards: Element<AppMessage> = if size.width < 1280.0 { let cards: Element<AppMessage> =
column![connect_card(state), recents_card(state), friends_panel(state)] match home_layout_mode(size.width, has_recents, has_friends) {
.spacing(20) HomeLayoutMode::FocusedEmpty => row![
.align_x(iced::alignment::Horizontal::Center) connect_card(state),
.into() column![friends_panel(state), recents_card(state)]
} else { .spacing(16)
row![recents_card(state), connect_card(state), friends_panel(state)] .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) .spacing(20)
.align_y(iced::alignment::Vertical::Top) .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)) scrollable(container(cards).center_x(iced::Length::Fill))
.width(iced::Length::Fill) .width(iced::Length::Fill)
@@ -4675,6 +4731,15 @@ mod tests {
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity"); assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
} }
#[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}; use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
#[test] #[test]