feat(ui): selectable room layouts with a thumbnail picker
Add three in-call room layouts — 3-Column (Participants | Chat | Controls), Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer (Participants | Controls with a collapsible Chat panel) — chosen via one persisted RoomLayout config setting and applied live. Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the launch and in-call screens opens a popup gallery (dimmed click-to-dismiss backdrop + centered panel) of clickable schematic thumbnails; the Settings screen shows the same thumbnails inline (no button). Thumbnails are drawn with the canvas widget (new LayoutThumb program — colored panel boxes, blue border on the selected one), so no image-decoding dependency is added. Each layout's panel boundaries are draggable (DividerKind gains Controls + ChatDrawer for the 3-column right divider and the drawer's left edge; new clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize). Participants width is shared across layouts but capped per layout at render time so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set in the dock layout won't collapse Chat in 3-column or Controls in the drawer). The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+478
-46
@@ -2,11 +2,11 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||||
use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::config::{AppConfig, NetworkMode};
|
||||
use crate::config::{AppConfig, NetworkMode, RoomLayout};
|
||||
|
||||
use iced::widget::{
|
||||
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
|
||||
progress_bar, canvas, Canvas, Column,
|
||||
progress_bar, canvas, Canvas, Column, stack, mouse_area,
|
||||
};
|
||||
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
|
||||
use iced::{
|
||||
@@ -48,6 +48,12 @@ pub enum DividerKind {
|
||||
/// Horizontal divider between the main row and the Chat dock (resizes the
|
||||
/// Chat dock height).
|
||||
Chat,
|
||||
/// Vertical divider between Chat and Controls in the 3-column layout (resizes
|
||||
/// the Controls panel width).
|
||||
Controls,
|
||||
/// Vertical divider on the left edge of the Chat drawer (resizes the drawer
|
||||
/// width) in the drawer layout.
|
||||
ChatDrawer,
|
||||
}
|
||||
|
||||
/// Minimum width of the Participants panel (px).
|
||||
@@ -76,6 +82,24 @@ fn clamp_chat_height(height: f32, window_h: f32) -> f32 {
|
||||
height.clamp(CHAT_MIN_H, max)
|
||||
}
|
||||
|
||||
/// Minimum width of the Chat column / drawer (px).
|
||||
const CHAT_MIN_W: f32 = 200.0;
|
||||
|
||||
/// Clamp the Controls panel width (3-column layout) so neither it nor the rest of
|
||||
/// the row drops below its minimum, given the current window width.
|
||||
fn clamp_controls_width(width: f32, window_w: f32) -> f32 {
|
||||
// Leave room for the Participants panel + a minimum Chat column.
|
||||
let max = (window_w - PARTICIPANTS_MIN_W - CHAT_MIN_W).max(CONTROLS_MIN_W);
|
||||
width.clamp(CONTROLS_MIN_W, max)
|
||||
}
|
||||
|
||||
/// Clamp the Chat drawer width (drawer layout) so neither it nor the rest of the
|
||||
/// row drops below its minimum, given the current window width.
|
||||
fn clamp_chat_drawer_width(width: f32, window_w: f32) -> f32 {
|
||||
let max = (window_w - PARTICIPANTS_MIN_W - CONTROLS_MIN_W).max(CHAT_MIN_W);
|
||||
width.clamp(CHAT_MIN_W, max)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AppMessage {
|
||||
NicknameChanged(String),
|
||||
@@ -121,6 +145,13 @@ pub enum AppMessage {
|
||||
/// A room divider was dragged by the given pixel delta along its drag axis
|
||||
/// (horizontal for the Panels divider, vertical for the Chat divider).
|
||||
DividerDragged(DividerKind, f32),
|
||||
/// Open / close the room-layout picker popup.
|
||||
OpenLayoutPicker,
|
||||
CloseLayoutPicker,
|
||||
/// Choose a room layout (applied live + persisted, closes the popup).
|
||||
SelectRoomLayout(RoomLayout),
|
||||
/// Toggle the Chat drawer open/closed (drawer layout).
|
||||
ToggleDrawerChat,
|
||||
}
|
||||
|
||||
fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
|
||||
@@ -171,6 +202,10 @@ pub struct AppState {
|
||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||
/// (The divider positions themselves are persisted in `config`.)
|
||||
window_size: Size,
|
||||
/// Whether the room-layout picker popup is open (launch + in-call screens).
|
||||
layout_picker_open: bool,
|
||||
/// Whether the Chat drawer is open (drawer layout only).
|
||||
drawer_chat_open: bool,
|
||||
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
|
||||
mic_level: f32,
|
||||
/// Whether the standalone (off-call) mic test stream is running.
|
||||
@@ -212,6 +247,8 @@ impl Default for AppState {
|
||||
config.participants_width =
|
||||
clamp_participants_width(config.participants_width, 900.0);
|
||||
config.chat_height = clamp_chat_height(config.chat_height, 760.0);
|
||||
config.controls_width = clamp_controls_width(config.controls_width, 900.0);
|
||||
config.chat_drawer_width = clamp_chat_drawer_width(config.chat_drawer_width, 900.0);
|
||||
notify::set_enabled(config.notifications_enabled);
|
||||
let _ = controller.send(CoreCommand::SetNoiseGateThreshold(config.noise_gate_threshold));
|
||||
let _ = controller.send(CoreCommand::SetInputVolume(config.input_volume));
|
||||
@@ -253,6 +290,8 @@ impl Default for AppState {
|
||||
chat_messages: Vec::new(),
|
||||
chat_input: String::new(),
|
||||
window_size: Size::new(900.0, 760.0),
|
||||
layout_picker_open: false,
|
||||
drawer_chat_open: false,
|
||||
mic_level: 0.0,
|
||||
mic_test_active: false,
|
||||
connecting: HashSet::new(),
|
||||
@@ -589,7 +628,37 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.window_size.height,
|
||||
);
|
||||
}
|
||||
DividerKind::Controls => {
|
||||
// Controls sits on the right; dragging the divider right (positive
|
||||
// delta) gives Chat more room and shrinks Controls.
|
||||
state.config.controls_width = clamp_controls_width(
|
||||
state.config.controls_width - delta,
|
||||
state.window_size.width,
|
||||
);
|
||||
}
|
||||
DividerKind::ChatDrawer => {
|
||||
// The drawer sits on the right; dragging its left-edge divider
|
||||
// left (negative delta) widens the drawer.
|
||||
state.config.chat_drawer_width = clamp_chat_drawer_width(
|
||||
state.config.chat_drawer_width - delta,
|
||||
state.window_size.width,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::OpenLayoutPicker => {
|
||||
state.layout_picker_open = true;
|
||||
}
|
||||
AppMessage::CloseLayoutPicker => {
|
||||
state.layout_picker_open = false;
|
||||
}
|
||||
AppMessage::SelectRoomLayout(layout) => {
|
||||
state.config.room_layout = layout;
|
||||
state.config.save();
|
||||
state.layout_picker_open = false;
|
||||
}
|
||||
AppMessage::ToggleDrawerChat => {
|
||||
state.drawer_chat_open = !state.drawer_chat_open;
|
||||
}
|
||||
AppMessage::ChatSubmit => {
|
||||
let text = sanitize_chat(&state.chat_input);
|
||||
@@ -638,6 +707,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
clamp_participants_width(state.config.participants_width, size.width);
|
||||
state.config.chat_height =
|
||||
clamp_chat_height(state.config.chat_height, size.height);
|
||||
state.config.controls_width =
|
||||
clamp_controls_width(state.config.controls_width, size.width);
|
||||
state.config.chat_drawer_width =
|
||||
clamp_chat_drawer_width(state.config.chat_drawer_width, size.width);
|
||||
}
|
||||
AppMessage::EventOccurred(_) => {}
|
||||
AppMessage::NavigateToSettings => {
|
||||
@@ -786,11 +859,19 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
let top_bar = row![
|
||||
horizontal_space(),
|
||||
button(
|
||||
Canvas::new(LayoutIcon { fg: color_text })
|
||||
.width(iced::Length::Fixed(18.0))
|
||||
.height(iced::Length::Fixed(18.0))
|
||||
)
|
||||
.on_press(AppMessage::OpenLayoutPicker)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8),
|
||||
button(text("⚙ Settings").size(14))
|
||||
.on_press(AppMessage::NavigateToSettings)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(8)
|
||||
].width(iced::Length::Fill).padding(10);
|
||||
].width(iced::Length::Fill).padding(10).spacing(8);
|
||||
|
||||
if state.current_screen == Screen::Settings {
|
||||
let path_field = |label: &'static str, sound: Sound| {
|
||||
@@ -870,6 +951,32 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
].align_y(iced::alignment::Vertical::Center).spacing(8),
|
||||
].spacing(6).width(iced::Length::Fill);
|
||||
|
||||
// Inline room-layout chooser (Settings shows the thumbnails outright, no
|
||||
// popup button). Same SelectRoomLayout message, applied live + persisted.
|
||||
let layout_choice = |layout: RoomLayout, label: &'static str| -> Element<'_, AppMessage> {
|
||||
let selected = state.config.room_layout == layout;
|
||||
let tile = Canvas::new(LayoutThumb {
|
||||
layout,
|
||||
selected,
|
||||
base: color_base,
|
||||
surface: color_surface,
|
||||
overlay: Color::from_rgb8(69, 71, 90),
|
||||
border: if selected { color_blue } else { color_surface },
|
||||
})
|
||||
.width(iced::Length::Fixed(132.0))
|
||||
.height(iced::Length::Fixed(86.0));
|
||||
column![
|
||||
button(tile)
|
||||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||||
.padding(2)
|
||||
.style(b_style(Color::TRANSPARENT, color_surface, color_text, 8.0)),
|
||||
text(label).size(11).color(if selected { color_blue } else { color_subtext }),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
};
|
||||
|
||||
let settings_content = scrollable(
|
||||
column![
|
||||
text("Settings").size(24).color(color_blue),
|
||||
@@ -926,6 +1033,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
].spacing(4).width(iced::Length::Fill),
|
||||
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
|
||||
vertical_space(12.0),
|
||||
column![
|
||||
text("Room Layout").size(14).color(color_subtext),
|
||||
row![
|
||||
layout_choice(RoomLayout::ThreeColumn, "3-Column"),
|
||||
layout_choice(RoomLayout::BottomDock, "Bottom Dock"),
|
||||
layout_choice(RoomLayout::Drawer, "Drawer"),
|
||||
].spacing(16),
|
||||
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
vertical_space(12.0),
|
||||
column![
|
||||
text("Notification Chimes").size(14).color(color_subtext),
|
||||
checkbox(state.config.notifications_enabled)
|
||||
@@ -1059,7 +1176,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
|
||||
let scroll = scrollable(content);
|
||||
|
||||
container(
|
||||
let home = container(
|
||||
column![
|
||||
top_bar,
|
||||
vertical_space(20.0),
|
||||
@@ -1068,8 +1185,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
||||
.into()
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_layout_picker(home.into(), state)
|
||||
} else {
|
||||
// --- ROOM SCREEN ---
|
||||
let participant_count = state.peers.len() + 1; // peers + you
|
||||
@@ -1104,7 +1222,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
button(text("Copy Ticket").size(12))
|
||||
.on_press(AppMessage::CopyToClipboard)
|
||||
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||
.padding(6),
|
||||
// Drawer layout: a chat toggle (the drawer is collapsed by default).
|
||||
{
|
||||
let el: Element<'_, AppMessage> =
|
||||
if state.config.room_layout == RoomLayout::Drawer {
|
||||
let (lbl, bg, fg) = if state.drawer_chat_open {
|
||||
("💬 Hide chat", color_blue, color_crust)
|
||||
} else {
|
||||
("💬 Chat", color_surface, color_text)
|
||||
};
|
||||
button(text(lbl).size(12))
|
||||
.on_press(AppMessage::ToggleDrawerChat)
|
||||
.style(b_style(bg, color_blue, fg, 6.0))
|
||||
.padding(6)
|
||||
.into()
|
||||
} else {
|
||||
iced::widget::Space::new().width(0.0).height(0.0).into()
|
||||
};
|
||||
el
|
||||
}
|
||||
]
|
||||
.spacing(16)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
@@ -1242,7 +1379,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||||
.padding(15)
|
||||
.width(iced::Length::Fixed(state.config.participants_width))
|
||||
.height(iced::Length::Fill);
|
||||
|
||||
// Control Panel Column
|
||||
@@ -1320,6 +1456,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.width(iced::Length::Fill)
|
||||
];
|
||||
|
||||
// Controls panel — width is set per layout below.
|
||||
let control_panel = container(
|
||||
column![
|
||||
text("Controls").size(18).color(color_blue),
|
||||
@@ -1331,24 +1468,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
)
|
||||
.style(c_style(color_mantle, Color::TRANSPARENT, 0.0))
|
||||
.padding(15)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
|
||||
// Draggable vertical divider between Participants and Controls.
|
||||
let panels_divider = Canvas::new(Divider {
|
||||
kind: DividerKind::Panels,
|
||||
vertical: true,
|
||||
line: color_surface,
|
||||
grip: color_lavender,
|
||||
})
|
||||
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||||
.height(iced::Length::Fill);
|
||||
|
||||
let main_layout = row![peers_panel, panels_divider, control_panel]
|
||||
.spacing(0)
|
||||
.height(iced::Length::Fill);
|
||||
|
||||
// --- CHAT DOCK (full-width strip along the bottom) ---
|
||||
// Reusable chat body (title + bottom-anchored scrollback + input row),
|
||||
// wrapped differently by each layout.
|
||||
let mut chat_col = Column::new().spacing(4).width(iced::Length::Fill);
|
||||
if state.chat_messages.is_empty() {
|
||||
chat_col = chat_col.push(
|
||||
@@ -1372,7 +1495,6 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.anchor_bottom();
|
||||
|
||||
let chat_input_row = row![
|
||||
text_input("Message the room…", &state.chat_input)
|
||||
.on_input(AppMessage::ChatInputChanged)
|
||||
@@ -1386,45 +1508,124 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center);
|
||||
|
||||
let chat_dock = container(
|
||||
column![
|
||||
let chat_inner = column![
|
||||
text("Chat").size(16).color(color_blue),
|
||||
chat_scroll,
|
||||
chat_input_row,
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.style(c_style(color_mantle, color_surface, 8.0))
|
||||
.padding(12)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fixed(state.config.chat_height));
|
||||
.spacing(8);
|
||||
|
||||
// Draggable horizontal divider between the main row and the Chat dock.
|
||||
let chat_divider = Canvas::new(Divider {
|
||||
// Divider constructors (fresh widget per call).
|
||||
let vdiv = |kind| {
|
||||
Canvas::new(Divider { kind, vertical: true, line: color_surface, grip: color_lavender })
|
||||
.width(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||||
.height(iced::Length::Fill)
|
||||
};
|
||||
let hdiv = || {
|
||||
Canvas::new(Divider {
|
||||
kind: DividerKind::Chat,
|
||||
vertical: false,
|
||||
line: color_surface,
|
||||
grip: color_lavender,
|
||||
})
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fixed(DIVIDER_THICKNESS));
|
||||
.height(iced::Length::Fixed(DIVIDER_THICKNESS))
|
||||
};
|
||||
|
||||
container(
|
||||
column![
|
||||
top_bar,
|
||||
header_container,
|
||||
vertical_space(12.0),
|
||||
main_layout,
|
||||
chat_divider,
|
||||
chat_dock
|
||||
// Assemble the body per the chosen room layout. `chat_inner` is moved into
|
||||
// exactly one arm (allowed across mutually-exclusive match arms).
|
||||
let pw = state.config.participants_width;
|
||||
let body: Element<'_, AppMessage> = match state.config.room_layout {
|
||||
RoomLayout::BottomDock => {
|
||||
// Cap Participants so the Fill Controls panel keeps its minimum.
|
||||
let avail = state.window_size.width - 30.0;
|
||||
let pwb = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||||
let main = row![
|
||||
peers_panel.width(iced::Length::Fixed(pwb)),
|
||||
vdiv(DividerKind::Panels),
|
||||
control_panel.width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(iced::Length::Fill);
|
||||
let chat = container(chat_inner)
|
||||
.style(c_style(color_mantle, color_surface, 8.0))
|
||||
.padding(12)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fixed(state.config.chat_height));
|
||||
column![main, hdiv(), chat].into()
|
||||
}
|
||||
RoomLayout::ThreeColumn => {
|
||||
// Participants is fixed and Controls is fixed, so cap Participants
|
||||
// (shared with the 2-panel layouts, where it's much wider) to leave
|
||||
// the centre Chat column at least a minimum width.
|
||||
let avail = state.window_size.width - 30.0; // outer padding
|
||||
let pw3 = pw.min(
|
||||
(avail - state.config.controls_width - CHAT_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||||
.max(PARTICIPANTS_MIN_W),
|
||||
);
|
||||
let chat = container(chat_inner)
|
||||
.style(c_style(color_mantle, color_surface, 8.0))
|
||||
.padding(12)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
row![
|
||||
peers_panel.width(iced::Length::Fixed(pw3)),
|
||||
vdiv(DividerKind::Panels),
|
||||
chat,
|
||||
vdiv(DividerKind::Controls),
|
||||
control_panel.width(iced::Length::Fixed(state.config.controls_width)),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
}
|
||||
RoomLayout::Drawer => {
|
||||
let avail = state.window_size.width - 30.0;
|
||||
if state.drawer_chat_open {
|
||||
// Participants + Chat drawer are both fixed; cap Participants so
|
||||
// the Fill Controls panel between them keeps its minimum.
|
||||
let pwd = pw.min(
|
||||
(avail - state.config.chat_drawer_width - CONTROLS_MIN_W - 2.0 * DIVIDER_THICKNESS)
|
||||
.max(PARTICIPANTS_MIN_W),
|
||||
);
|
||||
let chat = container(chat_inner)
|
||||
.style(c_style(color_mantle, color_surface, 8.0))
|
||||
.padding(12)
|
||||
.width(iced::Length::Fixed(state.config.chat_drawer_width))
|
||||
.height(iced::Length::Fill);
|
||||
row![
|
||||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||||
vdiv(DividerKind::Panels),
|
||||
control_panel.width(iced::Length::Fill),
|
||||
vdiv(DividerKind::ChatDrawer),
|
||||
chat,
|
||||
]
|
||||
.spacing(0)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
let pwd = pw.min((avail - CONTROLS_MIN_W - DIVIDER_THICKNESS).max(PARTICIPANTS_MIN_W));
|
||||
row![
|
||||
peers_panel.width(iced::Length::Fixed(pwd)),
|
||||
vdiv(DividerKind::Panels),
|
||||
control_panel.width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(0)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let room = container(
|
||||
column![top_bar, header_container, vertical_space(12.0), body]
|
||||
)
|
||||
.padding(15)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0))
|
||||
.into()
|
||||
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
|
||||
|
||||
with_layout_picker(room.into(), state)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1683,6 +1884,220 @@ impl Program<AppMessage> for Divider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a base screen with the room-layout picker popup when it's open: a dimmed,
|
||||
/// click-to-dismiss backdrop plus a centered gallery of clickable layout
|
||||
/// thumbnails. Returns the base unchanged when the picker is closed. Used by both
|
||||
/// the launch and in-call screens (the Settings screen shows thumbnails inline).
|
||||
fn with_layout_picker<'a>(
|
||||
base: Element<'a, AppMessage>,
|
||||
state: &'a AppState,
|
||||
) -> Element<'a, AppMessage> {
|
||||
if !state.layout_picker_open {
|
||||
return base;
|
||||
}
|
||||
let crust = Color::from_rgb8(17, 17, 27);
|
||||
let mantle = Color::from_rgb8(24, 24, 37);
|
||||
let base_c = Color::from_rgb8(30, 30, 46);
|
||||
let surface = Color::from_rgb8(49, 50, 68);
|
||||
let overlay = Color::from_rgb8(69, 71, 90);
|
||||
let text_c = Color::from_rgb8(205, 214, 244);
|
||||
let subtext = Color::from_rgb8(166, 173, 200);
|
||||
let blue = Color::from_rgb8(137, 180, 250);
|
||||
let green = Color::from_rgb8(166, 227, 161);
|
||||
|
||||
let backdrop = mouse_area(
|
||||
container(horizontal_space())
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(move |_t: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color { a: 0.55, ..crust })),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(AppMessage::CloseLayoutPicker);
|
||||
|
||||
let thumb = |layout: RoomLayout, label: &'static str| -> Element<'a, AppMessage> {
|
||||
let selected = state.config.room_layout == layout;
|
||||
let tile = Canvas::new(LayoutThumb {
|
||||
layout,
|
||||
selected,
|
||||
base: base_c,
|
||||
surface,
|
||||
overlay,
|
||||
border: if selected { blue } else { surface },
|
||||
})
|
||||
.width(iced::Length::Fixed(168.0))
|
||||
.height(iced::Length::Fixed(112.0));
|
||||
let btn = button(tile)
|
||||
.on_press(AppMessage::SelectRoomLayout(layout))
|
||||
.padding(2)
|
||||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => surface,
|
||||
_ => Color::TRANSPARENT,
|
||||
})),
|
||||
text_color: text_c,
|
||||
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() },
|
||||
..Default::default()
|
||||
});
|
||||
let (lbl_color, marker): (Color, Element<'a, AppMessage>) = if selected {
|
||||
(blue, text("● current").size(10).color(green).into())
|
||||
} else {
|
||||
(text_c, vertical_space(0.0).into())
|
||||
};
|
||||
column![btn, text(label).size(13).color(lbl_color), marker]
|
||||
.spacing(4)
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.into()
|
||||
};
|
||||
|
||||
let gallery = container(
|
||||
column![
|
||||
row![
|
||||
text("Choose room layout").size(16).color(blue),
|
||||
horizontal_space(),
|
||||
button(text("✕").size(16).color(subtext))
|
||||
.on_press(AppMessage::CloseLayoutPicker)
|
||||
.style(|_t: &Theme, _s: button::Status| button::Style {
|
||||
background: None,
|
||||
..Default::default()
|
||||
})
|
||||
.padding(2),
|
||||
]
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
row![
|
||||
thumb(RoomLayout::ThreeColumn, "3-Column"),
|
||||
thumb(RoomLayout::BottomDock, "Bottom Dock"),
|
||||
thumb(RoomLayout::Drawer, "Drawer"),
|
||||
]
|
||||
.spacing(20),
|
||||
text("Click a layout to apply it instantly.").size(11).color(subtext),
|
||||
]
|
||||
.spacing(16),
|
||||
)
|
||||
.style(move |_t: &Theme| container::Style {
|
||||
text_color: Some(text_c),
|
||||
background: Some(Background::Color(mantle)),
|
||||
border: Border { color: surface, width: 1.0, radius: 12.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(20)
|
||||
.width(iced::Length::Fixed(600.0));
|
||||
|
||||
stack![
|
||||
base,
|
||||
backdrop,
|
||||
container(gallery)
|
||||
.center_x(iced::Length::Fill)
|
||||
.center_y(iced::Length::Fill),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||||
struct LayoutIcon {
|
||||
fg: Color,
|
||||
}
|
||||
|
||||
impl Program<AppMessage> for LayoutIcon {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
let (w, h) = (bounds.width, bounds.height);
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1.0, 1.5), Size::new(w - 2.0, h - 3.0)),
|
||||
canvas::Stroke::default().with_color(self.fg).with_width(1.5),
|
||||
);
|
||||
// Vertical split into two panes.
|
||||
f.fill(
|
||||
&Path::rectangle(Point::new(w * 0.5 - 0.75, 1.5), Size::new(1.5, h - 3.0)),
|
||||
self.fg,
|
||||
);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
/// A schematic thumbnail of a [`RoomLayout`] (colored boxes for each panel),
|
||||
/// drawn so the picker stays in sync with the theme and needs no image assets.
|
||||
struct LayoutThumb {
|
||||
layout: RoomLayout,
|
||||
selected: bool,
|
||||
base: Color,
|
||||
surface: Color,
|
||||
/// Accent shade for the chat panel, to distinguish it from the others.
|
||||
overlay: Color,
|
||||
/// Border colour (blue when selected, surface otherwise).
|
||||
border: Color,
|
||||
}
|
||||
|
||||
impl LayoutThumb {
|
||||
fn pane(f: &mut Frame, x: f32, y: f32, w: f32, h: f32, color: Color) {
|
||||
f.fill(&Path::rectangle(Point::new(x, y), Size::new(w, h)), color);
|
||||
}
|
||||
}
|
||||
|
||||
impl Program<AppMessage> for LayoutThumb {
|
||||
type State = ();
|
||||
fn draw(
|
||||
&self,
|
||||
_state: &(),
|
||||
renderer: &Renderer,
|
||||
_theme: &Theme,
|
||||
bounds: Rectangle,
|
||||
_cursor: mouse::Cursor,
|
||||
) -> Vec<Geometry> {
|
||||
let mut f = Frame::new(renderer, bounds.size());
|
||||
let (w, h) = (bounds.width, bounds.height);
|
||||
f.fill(&Path::rectangle(Point::ORIGIN, Size::new(w, h)), self.base);
|
||||
|
||||
let pad = 9.0;
|
||||
let (ix, iy) = (pad, pad);
|
||||
let (iw, ih) = (w - 2.0 * pad, h - 2.0 * pad);
|
||||
let g = 4.0;
|
||||
match self.layout {
|
||||
RoomLayout::ThreeColumn => {
|
||||
let c1 = iw * 0.34;
|
||||
let c3 = iw * 0.24;
|
||||
let c2 = iw - c1 - c3 - 2.0 * g;
|
||||
Self::pane(&mut f, ix, iy, c1, ih, self.surface);
|
||||
Self::pane(&mut f, ix + c1 + g, iy, c2, ih, self.overlay);
|
||||
Self::pane(&mut f, ix + c1 + g + c2 + g, iy, c3, ih, self.surface);
|
||||
}
|
||||
RoomLayout::BottomDock => {
|
||||
let toph = ih * 0.6;
|
||||
let both = ih - toph - g;
|
||||
let lw = iw * 0.66;
|
||||
Self::pane(&mut f, ix, iy, lw, toph, self.surface);
|
||||
Self::pane(&mut f, ix + lw + g, iy, iw - lw - g, toph, self.surface);
|
||||
Self::pane(&mut f, ix, iy + toph + g, iw, both, self.overlay);
|
||||
}
|
||||
RoomLayout::Drawer => {
|
||||
let dw = iw * 0.18;
|
||||
let main = iw - dw - g;
|
||||
let lw = main * 0.62;
|
||||
Self::pane(&mut f, ix, iy, lw, ih, self.surface);
|
||||
Self::pane(&mut f, ix + lw + g, iy, main - lw - g, ih, self.surface);
|
||||
Self::pane(&mut f, ix + main + g, iy, dw, ih, self.overlay);
|
||||
}
|
||||
}
|
||||
|
||||
// Border on top (thicker + blue when selected).
|
||||
let bw: f32 = if self.selected { 2.0 } else { 1.0 };
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(bw / 2.0, bw / 2.0), Size::new(w - bw, h - bw)),
|
||||
canvas::Stroke::default().with_color(self.border).with_width(bw),
|
||||
);
|
||||
vec![f.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_duration, reconnect_attempt_chime, reconnected_chime, GateMeter, METER_MAX};
|
||||
@@ -1754,6 +2169,23 @@ mod tests {
|
||||
assert!(ch.is_finite() && ch >= CHAT_MIN_H);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controls_and_drawer_width_clamps() {
|
||||
use super::{clamp_chat_drawer_width, clamp_controls_width, CHAT_MIN_W, CONTROLS_MIN_W};
|
||||
let window_w = 1000.0;
|
||||
// Mid-range passes through.
|
||||
assert_eq!(clamp_controls_width(300.0, window_w), 300.0);
|
||||
assert_eq!(clamp_chat_drawer_width(320.0, window_w), 320.0);
|
||||
// Below minimum snaps up.
|
||||
assert_eq!(clamp_controls_width(10.0, window_w), CONTROLS_MIN_W);
|
||||
assert_eq!(clamp_chat_drawer_width(10.0, window_w), CHAT_MIN_W);
|
||||
// Tiny window stays finite and at/above the minimum (no inverted range).
|
||||
let c = clamp_controls_width(400.0, 100.0);
|
||||
assert!(c.is_finite() && c >= CONTROLS_MIN_W);
|
||||
let d = clamp_chat_drawer_width(400.0, 100.0);
|
||||
assert!(d.is_finite() && d >= CHAT_MIN_W);
|
||||
}
|
||||
|
||||
use crate::notify::Sound;
|
||||
use iroh::EndpointId;
|
||||
use std::collections::HashSet;
|
||||
|
||||
@@ -25,6 +25,34 @@ impl NetworkMode {
|
||||
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
||||
}
|
||||
|
||||
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum RoomLayout {
|
||||
/// Participants | Chat | Controls, all columns visible at once.
|
||||
ThreeColumn,
|
||||
/// Participants + Controls on top, full-width Chat docked along the bottom.
|
||||
#[default]
|
||||
BottomDock,
|
||||
/// Participants | Controls, with a collapsible Chat drawer on the right edge.
|
||||
Drawer,
|
||||
}
|
||||
|
||||
impl RoomLayout {
|
||||
/// All variants, in picker display order.
|
||||
pub const ALL: [RoomLayout; 3] =
|
||||
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RoomLayout {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
RoomLayout::ThreeColumn => "3-Column",
|
||||
RoomLayout::BottomDock => "Bottom Dock",
|
||||
RoomLayout::Drawer => "Drawer",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetworkMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let label = match self {
|
||||
@@ -56,6 +84,14 @@ fn default_chat_height() -> f32 {
|
||||
180.0
|
||||
}
|
||||
|
||||
fn default_controls_width() -> f32 {
|
||||
280.0
|
||||
}
|
||||
|
||||
fn default_chat_drawer_width() -> f32 {
|
||||
320.0
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AppConfig {
|
||||
/// Last nickname used to join/create a room; pre-filled on the launch screen.
|
||||
@@ -84,6 +120,15 @@ pub struct AppConfig {
|
||||
pub participants_width: f32,
|
||||
#[serde(default = "default_chat_height")]
|
||||
pub chat_height: f32,
|
||||
/// Controls panel width for the 3-column layout (px).
|
||||
#[serde(default = "default_controls_width")]
|
||||
pub controls_width: f32,
|
||||
/// Chat drawer width for the drawer layout (px).
|
||||
#[serde(default = "default_chat_drawer_width")]
|
||||
pub chat_drawer_width: f32,
|
||||
/// Chosen arrangement of the in-call room screen.
|
||||
#[serde(default)]
|
||||
pub room_layout: RoomLayout,
|
||||
#[serde(default)]
|
||||
pub custom_sound_self_join: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -116,6 +161,9 @@ impl Default for AppConfig {
|
||||
notifications_enabled: true,
|
||||
participants_width: default_participants_width(),
|
||||
chat_height: default_chat_height(),
|
||||
controls_width: default_controls_width(),
|
||||
chat_drawer_width: default_chat_drawer_width(),
|
||||
room_layout: RoomLayout::default(),
|
||||
custom_sound_self_join: None,
|
||||
custom_sound_peer_join: None,
|
||||
custom_sound_peer_leave: None,
|
||||
@@ -186,6 +234,10 @@ mod tests {
|
||||
assert_eq!(deserialized.chat_height, 180.0);
|
||||
// Configs predating the remembered username load the default nickname.
|
||||
assert_eq!(deserialized.username, "Peer");
|
||||
// Configs predating the layout picker load the default layout + sizes.
|
||||
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
||||
assert_eq!(deserialized.controls_width, 280.0);
|
||||
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
||||
assert!(deserialized.custom_sound_self_join.is_none());
|
||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||
|
||||
Reference in New Issue
Block a user